#!/usr/bin/env python3
"""Лист «Касса» — кассовый прогноз (v3).

От сегодняшнего остатка проецируем вперёд по месяцам (без новых кредитов),
чтобы видеть, когда касса уходит в минус (кассовый разрыв).

Базы прогноза:
  - строки расходов — из финмодели (Google Sheet), колонка ищется ПО ШАПКЕ
    (год в строке 0, месяц в строке 1), поэтому горизонт спокойно уходит в 2027-й;
  - ЗП педагогов — МАКСИМУМ из строки финмодели и проекции по расписанию CRM
    (regular-lesson: слот × вхождения дня недели в месяце × ставка × сезон).
    Решение Наташи 29.07.2026: модель занижала (сен: 919к против 1.79М по живому
    расписанию), а расписание занижает месяцы, которые ещё не продлили;
  - кредиты — явный график plan_sync.CREDITS (суммы, since/until), НЕ строка модели;
  - НДС — отдельной строкой из plan_sync.MONTHLY_SINCE: в строке 40 финмодели его нет,
    там только УСН 5% + 1% с дохода;
  - остаток текущего месяца = «сколько обычно за месяц минус сколько уже заплачено».

Крутилки (KNOBS ниже): горизонт, сезонный коэффициент, коэф.доборов, выручка, разовые.
Запуск: python3 cashflow.py            # посчитать и записать лист «Касса»
        python3 cashflow.py --dry      # только посчитать и напечатать (лист не трогаем)
"""
import os, io, csv, re, calendar, datetime, urllib.request
import core
import margins as m
import income_lines as il
import gspread

HERE = core.HERE
YEAR = core.FIN_YEAR
SA_FILE = os.path.join(HERE, "gsheets_sa.json")
SHEET_ID = "1C0z2ONr-QrLozztfTCjkFqBteKLZyxFW5yg9RIhbLvA"
OLYA_ID = "1MaEMGjyFfMeHw-xhaW38MjT954Bg9a3rFEZSAjLe1t0"

# ---------------- KNOBS (крутилки) ----------------
# Считаем до этого месяца включительно (год, месяц). Май 2027 — чтобы за возвратом тела
# займа Назипову (5 млн, март 2027) было видно, как касса восстанавливается.
HORIZON = (2027, 5)
DOBOR = 1.0                        # без множителя: ЗП = уроки по расписанию × ставки (доборы не угадываем)
SEASON = {7: 0.78, 8: 0.72, 12: 0.8}   # летний/праздничный коэф. — ТОЛЬКО для ЗП/уроков; иначе 1.0
# Ручное переопределение плановой выручки. ПУСТО = берём из финмодели, и это дефолт:
# захардкоженные цифры молча перекрывают таблицу, и правки Наташи в финмодели не доезжают
# (так и вышло — к 29.07.2026 расхождение доходило до 790к в месяц).
# Ключ — (год, месяц). Заполнять только если сознательно спорим с моделью, и тогда чистить.
# Замена этому хардкоду — прогноз из планок Финтабло (income_lines.plan_from_fintablo),
# он важнее финмодели автоматически, см. pick_revenue() и revenue_of() в push().
REVENUE_OVERRIDE = {}
ONE_OFFS = {}                      # разовые, ключ (год, месяц): {(2026, 8): -500_000} — тело кредита и т.п.
# --- УСН «доходы минус расходы» ---
# Наташа, 29.07.2026: у Марса ООО на УСН Д−Р 15%, минимальный налог 1% от доходов, если
# 15% с прибыли выходит меньше. ИП больше нет — старая статья «Налог ИП (наличка)» вперёд
# не планируется. Строку 40 финмодели («6% с оборота») мы НЕ используем: она считала налог
# с выручки и завышала его вчетверо (2,48М против ~581к за 2026).
USN_RATE = 0.15
USN_MIN_RATE = 0.01                # минимальный налог от ДОХОДОВ, если 15% с прибыли меньше
USN_NONDEDUCTIBLE = {43}           # строки модели, не идущие в расход (возврат тела займа)
# Платежи по кредитам в расход НЕ берём: в CREDITS лежит аннуитет (тело+проценты) одной
# суммой, разделить нечем. Это завышает базу → налог, то есть ошибается в безопасную сторону.
# ⚠️ проверить с ФСК: состав принимаемых расходов и учёт НДС в доходах.

# --- НДС 5% на УСН ---
NDS_RATE = 0.05
NDS_ACCRUE_FROM = (2026, 7)        # первый месяц, попадающий под НДС (Q3-2026)
NDS_BASE_SHARE = 1.0               # доля выручки, попадающая под НДС. 1.0 = вся.
# ⚠️ вопрос к бухгалтерии (ФСК): вся ли выручка Марса — база по НДС. Если часть услуг
#    под НДС не попадает, ставим сюда долю, и весь расчёт поедет пропорционально.
# --- кредиты: явный график (заменяет строку финмодели) ---
# Новый кредит 1,5М под 23,9% УЖЕ ВЗЯТ — приход уже в остатке счетов, дальше только платёж 70к/мес.
# Оборотный-2 (150к) закрыт этим кредитом. Оборотный-1 (610к) ещё открыт — закроют позже из наличных.
# Кредиты — ЕДИНЫЙ источник plan_sync.CREDITS (суммы, дата начала И дата окончания).
# Раньше здесь были свои копии сумм, а since/until игнорировались: платёж по кредиту 1,5М
# добавлялся во все месяцы, включая те, где кредита ещё не было.
from plan_sync import (CREDITS as _PS_CREDITS, MONTHLY_SINCE as _PS_MONTHLY,
                       credit_active, credits_without_end)
# Раньше тут лежали мёртвые крутилки PRICE_UP/NDS_RATE/NDS_FROM/USN: объявлены и НИ РАЗУ
# не использованы — НДС 5% просто не попадал в кассу. Теперь НДС берётся из
# plan_sync.MONTHLY_SINCE (единый источник с плановыми операциями Финтабло).
# --------------------------------------------------

OUT_LINES = ["Аренда и помещение", "ЗП педагогов", "ЗП бэк-офиса",
             "Проценты по кредитам/займам", "Налоги", "Прочие расходы"]
LT = {1: "Индивидуальный", 3: "Пробный", 5: "Вписанный", 4: "ФИТМОСТ"}  # 1-1 bucket ids
ONE_BUCKET = ("Индивидуальный", "Пробный", "Вписанный", "ФИТМОСТ")


def season(mo):
    return SEASON.get(mo, 1.0)


def credit_rows(ym):
    """[(описание, сумма)] кредитных платежей, действующих в месяце (год, месяц). Чистая функция."""
    y, mo = ym
    return [(desc, amt) for _s, desc, _c, amt, _day, since, until in _PS_CREDITS
            if credit_active(y, mo, since, until)]


def credit_sched(ym):
    """Сумма всех кредитных платежей месяца (год, месяц)."""
    return sum(a for _d, a in credit_rows(ym))


def monthly_since_rows(ym, skip=("nds",)):
    """[(описание, сумма)] ежемесячных платежей «с даты» из plan_sync.MONTHLY_SINCE.

    НДС по умолчанию пропускаем: в plan_sync он лежит плоской оценкой 215к (нужен, чтобы
    создать плановую операцию в Финтабло), а касса считает его от выручки — см. nds_for_month.
    Складывать оба — двойной счёт.
    """
    return [(desc, amt) for slug, desc, _c, amt, _day, since in _PS_MONTHLY
            if ym >= since and slug not in skip]


def monthly_since_total(ym, skip=("nds",)):
    return sum(a for _d, a in monthly_since_rows(ym, skip))


# ---------- НДС 5%: считаем от выручки квартала, а не константой ----------
def quarter_of(ym):
    y, mo = ym
    return (y, (mo - 1) // 3 + 1)


def quarter_months(q):
    y, n = q
    return [(y, (n - 1) * 3 + i) for i in (1, 2, 3)]


def prev_quarter(q):
    y, n = q
    return (y - 1, 4) if n == 1 else (y, n - 1)


def usn_payment(ym, income_of, expense_of, rate=USN_RATE, min_rate=USN_MIN_RATE):
    """Платёж по УСН Д−Р в месяце (год, месяц). Чистая функция.

    Механика: авансы считаются от базы НАРАСТАЮЩИМ ИТОГОМ и платятся до 28-го числа
    месяца, следующего за кварталом (апрель / июль / октябрь), каждый за вычетом ранее
    исчисленных. Годовой платёж — до 28 марта следующего года, и вот в нём уже работает
    минимальный налог: если 15% с прибыли меньше 1% с доходов, платим 1% с доходов.

    Отсюда важное для кассы: пока прибыль нарастающим итогом в минусе, авансов НЕТ вообще,
    а весь налог падает одним платежом в марте. Строка 40 модели вместо этого размазывала
    6% с оборота по осенним месяцам.
    """
    y, mo = ym

    def cum(yy, upto):
        months = [(yy, m) for m in range(1, upto + 1)]
        return sum(income_of(m) for m in months), sum(expense_of(m) for m in months)

    def adv(yy, upto):
        inc, exp = cum(yy, upto)
        return max(0.0, rate * (inc - exp))

    if mo == 4:
        return adv(y, 3)
    if mo == 7:
        return max(0.0, adv(y, 6) - adv(y, 3))
    if mo == 10:
        return max(0.0, adv(y, 9) - adv(y, 6))
    if mo == 3:                                  # годовой расчёт за ПРЕДЫДУЩИЙ год
        py = y - 1
        inc, exp = cum(py, 12)
        return max(0.0, max(rate * (inc - exp), min_rate * inc) - adv(py, 9))
    return 0.0


def nds_for_month(ym, rev_of_month, rate=NDS_RATE, accrue_from=NDS_ACCRUE_FROM,
                  base_share=NDS_BASE_SHARE):
    """Платёж по НДС в месяце (год, месяц). Чистая функция (выручку отдаёт rev_of_month).

    НДС за квартал платится ТРЕМЯ равными долями до 28-го числа каждого месяца следующего
    квартала. Начисление — с accrue_from (июль 2026, Q3) → первые платежи в октябре.
    Раньше в кассе стояла плоская константа 215к/мес: она была посчитана от 13М выручки
    квартала, а по прогнозу квартал уже 15,8–17М — недобор ~50–70к в месяц.
    """
    ms = [m for m in quarter_months(prev_quarter(quarter_of(ym))) if m >= accrue_from]
    if not ms:
        return 0.0
    return rate * base_share * sum(rev_of_month(m) for m in ms) / 3


# ---------- финмодель: колонка месяца ищется по шапке, а не по формуле ----------
MONTHS_RU = {"янв": 1, "фев": 2, "мар": 3, "апр": 4, "май": 5, "июн": 6,
             "июл": 7, "авг": 8, "сен": 9, "окт": 10, "ноя": 11, "дек": 12}


def month_columns(plan):
    """{(год, месяц): индекс колонки} из шапки финмодели. Чистая функция.

    Строка 0 — год («26»/«27»), строка 1 — месяц («янв»). Раньше колонка считалась как
    mm+1: для 2026-го совпадало, а для 2027-го молча бралcя чужой месяц 2026 года.
    """
    years = plan[0] if plan else []
    names = plan[1] if len(plan) > 1 else []
    idx = {}
    for c in range(2, max(len(years), len(names))):
        y = core.norm(years[c]) if c < len(years) else ""
        nm = core.norm(names[c])[:3] if c < len(names) else ""
        if y.isdigit() and nm in MONTHS_RU:
            idx[(2000 + int(y), MONTHS_RU[nm])] = c
    return idx


def next_month(ym):
    y, mo = ym
    return (y + 1, 1) if mo == 12 else (y, mo + 1)


def forecast_months(start, colidx, horizon=HORIZON, report_missing=False):
    """[(год, месяц)] от start до horizon включительно — сквозь границу года.

    Месяцы, которых нет в финмодели, считать не из чего: их выкидываем (и, если попросили,
    возвращаем списком, чтобы предупредить — иначе расходы молча стали бы нулём).
    """
    got, missing, ym = [], [], start
    while ym <= horizon:
        (got if ym in colidx else missing).append(ym)
        ym = next_month(ym)
    return (got, missing) if report_missing else got


def pick_revenue(plan_ft, model):
    """Прогноз месяца: планки Финтабло, если они есть, иначе финмодель.

    Чистая функция — оба аргумента {slug: сумма}. Пустые планки (дальние месяцы)
    это норма, а не сбой: до них Наташа ещё не дошла.
    """
    return plan_ft if plan_ft else model


def current_month_revenue(plan_total, collected):
    """(остаток к сбору, коэффициент гашения строк) для текущего (незакрытого) месяца.

    Остаток = план минус уже собранное — но НЕ МЕНЬШЕ НУЛЯ: если план на месяц занесён
    частично (одна-две строки канона), а факт уже перекрыл эту урезанную сумму, «плана
    собрать ещё» не бывает отрицательным — это не кассовый разрыв, а неполная планка
    (см. partial_plan_months). Без этого зажима «ВЫРУЧКА» текущего месяца рисовала
    многомиллионный провал, хотя строки разбивки под ней (гашённые тем же коэффициентом)
    показывали ноль — сумма строк расходилась с шапкой (ревью Task 4, Critical).

    Тот же паттерн уже используется в rev_full: max(planned, fact) для полной выручки
    месяца; здесь — эквивалент для ОСТАТКА: max(planned - fact, 0) = max(planned, fact) - fact.
    """
    rem_rev = max(0.0, plan_total - collected)
    rem_ratio = max(0.0, rem_rev / plan_total) if plan_total > 0 else 1.0
    return rem_rev, rem_ratio


def revenue_breakdown(src, rem_ratio=1.0):
    """[(имя строки канона, сумма)] из {slug: сумма} (результат pick_revenue).

    Чистая функция — не знает про push()/сеть/финмодель, поэтому тестируется без
    моков. rem_ratio=1.0 (дефолт) ничего не гасит — так считаются месяцы прогноза.
    Для текущего (незакрытого) месяца push() передаёт REM_REV, чтобы сумма строк
    совпала с уже показанным остатком «ВЫРУЧКА» (см. rev_val/REM_REV).
    """
    return [(l.name, src.get(l.slug, 0.0) * rem_ratio) for l in il.LINES]


def pv(plan, colidx, rlist, ym):
    """Сумма строк rlist финмодели за месяц (год, месяц). Чистая функция."""
    c = colidx.get(ym)
    if c is None:
        return 0.0
    return sum(_num(plan[r][c]) for r in rlist if r < len(plan) and c < len(plan[r]))


def teachers_val(model_val, crm_val):
    """ЗП педагогов: максимум из строки финмодели и проекции по расписанию CRM.

    Решение Наташи 29.07.2026. Модель систематически занижала (сен: 919к против 1.79М по
    живому расписанию, доля от выручки 17% против фактических 32%), а расписание занижает
    месяцы, которые ещё не продлили. CRM недоступна → crm_val = 0 → падаем на модель.
    """
    return max(model_val, crm_val)


# ---------- Финтабло: факт по строкам ----------
def cash_line(cat_name, group):
    n = m._norm(cat_name)
    if group == "income":
        # Депозит, проценты по нему и переводы между счетами — движение собственных
        # денег, а не выручка. Без этого возврат депозита (54,8 млн в июне-июле 2025)
        # попадал в «Приток»: июль 2025 выглядел как 37,7 млн при выручке 1,6 млн,
        # и любое сравнение месяцев превращалось в мусор.
        if any(k in n for k in ["кредит", "займ", "ввод средств", "получение",
                                "депозит", "перевод между счетами"]):
            return "__fin_in__"
        return "Приток"
    if "аренда и содержание" in n or "хозяйствен" in n or n == "ремонт" or "мебель" in n:
        return "Аренда и помещение"
    if "зарплаты педагог" in n:
        return "ЗП педагогов"
    if "зарплаты бэк" in n or "зарплаты админ" in n or "аутсорс" in n:
        return "ЗП бэк-офиса"
    if n.startswith("%") or "тело кредита" in n or (("кредит" in n or "займ" in n or "овердрафт" in n) and "комисс" not in n):
        return "Проценты по кредитам/займам"
    if "налог" in n or "ндфл" in n or "взносы в фонды" in n:
        return "Налоги"
    return "Прочие расходы"


def _scan(group):
    return [t for t in core.ft_transactions(group=group) if not t.get("isPlan")]


def fact_by_month():
    cats = core.ft_categories()
    months = core.months_to_now()
    fact = {mo: {} for mo in months}
    for grp in ("income", "outcome"):
        for t in _scan(grp):
            mo = t.get("factMonth")
            if mo not in fact:
                continue
            line = cash_line(cats.get(t.get("categoryId"), ""), grp)
            fact[mo][line] = fact[mo].get(line, 0.0) + float(t.get("value") or 0)
    return months, fact


def balances():
    items = core.ftget("/v1/moneybag").get("items", [])
    detail, total = [], 0.0
    for x in items:
        if x.get("archived") or x.get("hideInTotal"):
            continue
        # «Рабочие руки» — транзитный счёт под выплаты педагогам-самозанятым, НЕ свободные деньги
        if "рабоч" in (x.get("name") or "").lower():
            continue
        b = float(x.get("balance") or 0)
        total += b
        detail.append((x.get("name"), b))
    return total, detail


def platform_balance():
    """Остаток транзитного счёта «Рабочие руки» — деньги, уже переведённые площадке
    под выплаты самозанятым.

    В «Свободно» они не входят (см. balances), но и требовать их повторно из свободного
    остатка нельзя: планка ЗП педагогов висит непогашенной, пока Оля не разнесла выплату,
    и отчёт просил те же деньги второй раз.
    """
    for x in core.ftget("/v1/moneybag").get("items", []):
        if x.get("archived") or "рабоч" not in (x.get("name") or "").lower():
            continue
        return float(x.get("balance") or 0)
    return 0.0


# ---------- CRM: проекция ЗП педагогов 1-1 по расписанию ----------
def fetch_schedule(token):
    reg = []
    for br in core.BRANCHES:                   # оба филиала (раньше только 3 — недосчитывали)
        reg += core.alfa_all(f"/v2api/{br}/regular-lesson/index", {}, token)
    return reg


def salary_1_1_projection(reg, y, mo):
    """Проекция ЗП 1-1 за месяц из расписания × доборы × сезон."""
    ndays = calendar.monthrange(y, mo)[1]
    occ = {wd: 0 for wd in range(1, 8)}
    for d in range(1, ndays + 1):
        occ[datetime.date(y, mo, d).isoweekday()] += 1
    first, last = f"{y}-{mo:02d}-01", f"{y}-{mo:02d}-{ndays:02d}"
    base = 0.0
    for e in reg:
        nm = LT.get(e.get("lesson_type_id"))
        if nm not in ONE_BUCKET:
            continue
        if (e.get("b_date") or "") > last or (e.get("e_date") or "9999") < first:
            continue
        base += occ.get(e.get("day"), 0) * m.LESSON_TYPE_RATE.get(nm, 0)
    return base * DOBOR * season(mo)


# ---------- Оля: план «Не оплачено» по месяцам ----------
def olya_plan():
    url = f"https://docs.google.com/spreadsheets/d/{OLYA_ID}/export?format=csv&gid=0"
    raw = urllib.request.urlopen(url, timeout=30).read().decode("utf-8")
    by_month = {}
    for r in csv.DictReader(io.StringIO(raw)):
        if not (r.get("Статус") or "").strip().lower().startswith("не оплач"):
            continue
        s = (r.get("Сумма") or "").replace("р.", "").replace("\xa0", "").replace(" ", "").replace(",", ".")
        mm = re.findall(r"-?\d+\.?\d*", s)
        val = float(mm[0]) if mm else 0.0
        d = (r.get("Дата платежа") or "").strip()
        key = d[3:] if len(d) >= 10 else None
        if key:
            by_month[key] = by_month.get(key, 0.0) + val
    return by_month


# ---------- финмодель (ПЛАН вперёд) ----------
FM_ID = "1orFwTHu_qKAagxd3eMU7LyaFsVNkt94gf03QFIZwO4A"
FM_GID = "2015808034"


_num = core.num          # общий парсер чисел из таблиц (был свой дубль здесь и в plan_fact)


def read_plan():
    url = f"https://docs.google.com/spreadsheets/d/{FM_ID}/export?format=csv&gid={FM_GID}"
    return list(csv.reader(io.StringIO(
        urllib.request.urlopen(url, timeout=30).read().decode("utf-8"))))


# Строки прогноза: раздел → [(имя, [строки финмодели], факт-бакет)].
# Факт-бакет — одна из OUT_LINES: по ней в текущем (незакрытом) месяце вычитается уже
# оплаченное, пропорционально плану строк внутри бакета.
# Раньше здесь было четыре схлопнутых группы, и в таблице не было видно, ЧТО именно
# съедает деньги (Наташа, 29.07.2026: «как будто мало костов»). Сумма всех строк ниже
# в точности равна «Итого расходы» (стр.45) минус кредиты (стр.38) — см. unassigned().
# Доходы теперь разбиты по канону income_lines.LINES (девять строк), а не по строкам
# финмодели напрямую: печатаем revenue_lines(ym)/revenue_of(ym) — прогноз (планки
# Финтабло), если он есть, иначе финмодель (см. pick_revenue и push()). B2B — единственная
# строка канона с category == il.CAT_B2B, всё остальное — B2C.
REVENUE_SECTIONS = [
    ("B2C", [l for l in il.LINES if l.category != il.CAT_B2B]),
    ("B2B", [l for l in il.LINES if l.category == il.CAT_B2B]),
]
# (имя, строки финмодели) для каждой строки канона — используется проверкой разметки
# (test_revenue_rows_are_layout_checked) и тестом на полноту покрытия модели, чтобы при
# переходе на канон ни одна строка финмодели не потерялась молча.
ALL_REV_LINES = [(l.name, l.model_rows) for l in il.LINES]

LINE_TEACHERS_1_1 = "Занятия 1-1"
SECTIONS = [
    ("ФОТ педагогов", [
        (LINE_TEACHERS_1_1,           [21], "ЗП педагогов"),
        ("B2B занятия",               [22], "Прочие расходы"),
        ("Хоровые курсы",             [24], "Прочие расходы"),
        ("Групповые занятия вокалом", [25], "Прочие расходы"),
        ("Онлайн-подписка",           [26], "Прочие расходы"),
    ]),
    ("Помещение", [
        ("Аренда здания",  [23], "Аренда и помещение"),
        ("Хозяйственные",  [30], "Аренда и помещение"),
        ("Ремонт",         [41], "Аренда и помещение"),
    ]),
    ("ФОТ бэк-офиса", [
        ("Бэк-офис (с фаундерами)", [31], "ЗП бэк-офиса"),
        ("Администраторы",          [32], "ЗП бэк-офиса"),
        ("Аутсорс",                 [35], "ЗП бэк-офиса"),
    ]),
    ("Маркетинг и производство", [
        ("Маркетинг",         [33], "Прочие расходы"),
        ("Съёмки",            [29], "Прочие расходы"),
        ("B2B ивенты",        [27], "Прочие расходы"),
        ("Внутренние ивенты", [28], "Прочие расходы"),
    ]),
    ("Операционка", [
        ("Сервисы",                      [34], "Прочие расходы"),
        ("Комиссии (эквайринг, банк, РР)", [39], "Прочие расходы"),
        ("Возвраты клиентам",            [37], "Прочие расходы"),
        ("Прочее / неразнесённое",       [36], "Прочие расходы"),
        ("Возврат займа",                [43], "Прочие расходы"),
    ]),
    ("Налоги", []),          # УСН и НДС считаем сами (см. usn_payment / nds_for_month)
]
ALL_LINES = [ln for _sec, lines in SECTIONS for ln in lines]
# Номера строк финмодели — магические: сдвинут строку в таблице, и _num молча вернёт 0
# (так и случилось: план прибыли брали из строки 49 «Заемные средства» — он всегда был 0).
# Поэтому перед расчётом сверяем подписи ключевых строк. Подпись лежит в колонке A/B,
# колонка месяца ищется по шапке (см. month_columns).
ROW_REVENUE = 18
ROW_PROFIT = 50
ROW_TOTAL_OUT = 45
ROW_CREDITS = 38
ROW_MODEL_TAX = 40      # налог модели («6% с оборота») — заменён собственным расчётом УСН
# Теперь строк в прогнозе два десятка — сверяем подпись КАЖДОЙ, иначе сдвиг одной строки
# в таблице молча подменит, скажем, «Маркетинг» на «Сервисы», и никто не заметит.
FM_EXPECT = {ROW_REVENUE: ("выруч",), ROW_CREDITS: ("кредит",), ROW_TOTAL_OUT: ("расход",),
             ROW_PROFIT: ("прибыл",),
             3: ("аренда",), 4: ("абонемент",), 5: ("хоров",), 6: ("группов",),
             7: ("онлайн",), 8: ("мероприят",), 9: ("ивент",), 10: ("session",),
             11: ("мерч",), 14: ("абонемент",), 15: ("ивент",), 16: ("аренд",),
             21: ("занят", "педагог"), 22: ("b2b",), 23: ("аренд",), 24: ("хоров",),
             25: ("группов",), 26: ("онлайн",), 27: ("ивент",), 28: ("ивент",),
             29: ("съем",), 30: ("хозяйствен",), 31: ("бэк",), 32: ("администратор",),
             33: ("маркетинг",), 34: ("сервис",), 35: ("аутсорс",), 36: ("прочее",),
             37: ("возврат",), 39: ("комисси",), 40: ("налог",), 41: ("ремонт",),
             43: ("возврат", "займ")}


def row_label(plan, r):
    """Подпись строки финмодели (лежит в A или B). Чистая функция."""
    row = plan[r] if r < len(plan) else []
    return core.norm(" ".join(c for c in row[:2] if c))


def check_plan_layout(plan):
    """Подписи ключевых строк финмодели на месте? → [] или список расхождений. Чистая функция."""
    bad = []
    for r, keys in FM_EXPECT.items():
        label = row_label(plan, r)
        if not any(k in label for k in keys):
            bad.append(f"строка {r + 1}: ожидали {'/'.join(keys)}, а там «{label[:40]}»")
    return bad


def push(dry=False):
    plan = read_plan()
    bad = check_plan_layout(plan)
    if bad:
        raise RuntimeError("разметка финмодели поехала — план считался бы из чужих строк:\n  "
                           + "\n  ".join(bad))
    months, fact = fact_by_month()
    if not months:
        raise RuntimeError(f"нет месяцев для расчёта: сегодня вне {YEAR} года (см. FINSYNC_YEAR)")
    total_bal, bal_detail = balances()
    no_end = credits_without_end()
    colidx = month_columns(plan)

    def mv(rlist, ym):                         # значение строк модели за (год, месяц)
        return pv(plan, colidx, rlist, ym)

    # --- выручка: канон income_lines.LINES, прогноз (планки Финтабло) либо финмодель ---
    # Раньше разбивка доходов считалась строго по финмодели (mv/pv) и не видела прогноз
    # Наташи из плановых операций Финтабло — задача этого шага именно в этом (29.07.2026).
    all_income = core.ft_transactions(group="income")

    # Кэш на месяц: без него _revenue_src дергала бы plan_from_fintablo (полный проход по
    # all_income) и plan_from_model (девять pv) на КАЖДЫЙ вызов rev_line_val/rev_unassigned,
    # а таких вызовов за прогон — сотни (строка × месяц × печать). Ревью Task 4, minor.
    _ft_cache, _model_cache = {}, {}

    def _plan_ft(ym):
        """{slug: сумма} планок Финтабло на месяц (как есть, включая slug вне канона —
        нужно для rev_unassigned, чтобы ловить «осиротевшие» планки)."""
        mm = "%02d.%d" % (ym[1], ym[0])
        if mm not in _ft_cache:
            _ft_cache[mm] = il.plan_from_fintablo(mm, all_income)
        return _ft_cache[mm]

    def _model_rev(ym):
        if ym not in _model_cache:
            _model_cache[ym] = il.plan_from_model(plan, colidx, ym)
        return _model_cache[ym]

    def _revenue_src(ym):
        """{slug: сумма} канона за месяц — как есть, без сглаживания текущего месяца
        (см. REM_REV ниже — гашение уже собранного делает вызывающий код при печати)."""
        return pick_revenue(_plan_ft(ym), _model_rev(ym))

    def revenue_lines(ym):
        """[(имя строки канона, сумма)] за месяц — из прогноза либо из финмодели."""
        return revenue_breakdown(_revenue_src(ym))

    def revenue_of(ym):
        return sum(v for _n, v in revenue_lines(ym))

    # текущий месяц ещё не закрыт: его колонка = «остаток» (план месяца − уже оплачено/получено).
    # Раньше остаток считался от стр.18 финмодели, а rev_full/rev_val(не-текущие месяцы) —
    # уже от прогноза: если на текущий месяц когда-нибудь заведут планку (plan_income.py
    # это не запрещает), одна и та же колонка считалась бы из двух разных источников —
    # НДС/УСН от прогноза, а денежный остаток от модели (ревью Task 4, находка 1).
    jm = months[-1]
    cur = (YEAR, int(jm[:2]))
    _rev_plan_cur = revenue_of(cur)     # прогноз месяца целиком (план Финтабло либо модель), без гашения
    # rem_rev зажат нулём внутри current_month_revenue: частичная планка + факт, её
    # перекрывший, не должны рисовать отрицательную выручку (ревью Task 4, Critical, 2й раунд)
    rem_rev, REM_REV = current_month_revenue(_rev_plan_cur, fact[jm].get("Приток", 0.0))

    cols, missing = forecast_months(cur, colidx, report_missing=True)   # текущий (остаток) + будущие
    if not cols:
        raise RuntimeError(f"в финмодели нет колонки под {cur[1]:02d}.{cur[0]} — считать не из чего")
    fut = cols
    ml = [f"{mo:02d}.{str(y)[2:]}" + (" ост." if (y, mo) == cur else "") for y, mo in cols]

    def partial_plan_months():
        """Месяцы, где план Финтабло ЕСТЬ, но покрывает не все строки канона, ожидаемые
        ненулевыми по финмодели. pick_revenue() — «всё или ничего»: если Наташа занесёт
        одной командой plan_income.py только одну строку (например arenda=800000), касса
        покажет выручку месяца равной этой одной строке, а уроки/хор/B2B молча обнулятся —
        ложный кассовый разрыв (ревью Task 4, находка 3). Семантику pick_revenue не меняем
        («планки, если есть, иначе финмодель» — решение владелицы), только предупреждаем."""
        warn = []
        for ym in cols:
            plan_ft = _plan_ft(ym)
            if not plan_ft:
                continue                        # планок нет вообще — норма, фолбэк на модель
            model_d = _model_rev(ym)
            gone = [l.name for l in il.LINES if l.slug not in plan_ft and model_d.get(l.slug, 0.0) > 0]
            if gone:
                warn.append((ym, gone))
        return warn

    # --- ЗП педагогов: максимум из модели и живого расписания CRM ---
    # (проекция была написана, но НИ РАЗУ не вызывалась — прогноз шёл только по модели,
    #  а она закладывала 17% выручки при фактических 32%)
    crm_note = ""
    try:
        reg = fetch_schedule(core.alfa_login())
        crm_sal = {ym: salary_1_1_projection(reg, ym[0], ym[1]) for ym in cols}
    except Exception as e:
        crm_sal = {ym: 0.0 for ym in cols}
        crm_note = f"⚠️ расписание CRM недоступно ({type(e).__name__}) — ЗП педагогов только по финмодели"

    # --- кредиты считаем ЯВНО (не из строки модели row38) ---
    # график из plan_sync.CREDITS с учётом since/until: платёж попадает только в те месяцы,
    # когда кредит реально обслуживается (раньше все платежи добавлялись во все месяцы)

    def rev_val(ym):
        return rem_rev if ym == cur else REVENUE_OVERRIDE.get(ym, revenue_of(ym))

    # REM_REV (гашение строк разбивки текущего месяца) уже посчитан выше, вместе с rem_rev,
    # через current_month_revenue — оба должны согласовываться друг с другом по построению.

    def _rem_ratio(ym):
        return REM_REV if ym == cur else 1.0

    def _rev_bd(ym):
        """{slug: значение для печати} за месяц — revenue_breakdown, проиндексированный
        по slug, с гашением текущего месяца."""
        return {l.slug: v for l, (_n, v) in zip(il.LINES, revenue_breakdown(_revenue_src(ym), _rem_ratio(ym)))}

    def rev_line_val(l, ym):
        return _rev_bd(ym)[l.slug]

    def rev_section_val(lines, ym):
        return sum(rev_line_val(l, ym) for l in lines)

    def rev_unassigned(ym):
        """Сверка «ВЫРУЧКА» с НЕЗАВИСИМЫМ источником, а не с суммой тех же самых строк
        канона: если сверять revenue_of() с суммой revenue_lines(), результат тождественно
        0 по построению (обе части — одно и то же вычисление) и не ловит регрессии
        (ревью Task 4, находка 2). Поэтому:
          - месяц с планками Финтабло — сверяем с суммой ВСЕХ планок месяца (не только
            совпавших со slug'ом канона): если slug переименовали или планку завели
            вручную с опечаткой, тут появится расхождение, которое revenue_of() иначе
            бы тихо проглотил;
          - месяц-фолбэк на модель (планок нет) — сверяем со стр.18 «Итого выручка»:
            если в финмодель добавят новую доходную строку мимо канона, она попадёт
            в стр.18, но не в revenue_of(), и здесь это будет видно.
        Текущий месяц гасим тем же REM_REV, что и остальные строки, иначе появится
        мнимое расхождение размером с уже собранное за месяц."""
        plan_ft = _plan_ft(ym)
        independent_raw = sum(plan_ft.values()) if plan_ft else mv([ROW_REVENUE], ym)
        raw_diff = independent_raw - revenue_of(ym)
        return raw_diff * _rem_ratio(ym)

    def rev_full(ym):
        """Полная выручка месяца (база НДС), а НЕ «остаток» текущего месяца: закрытые
        месяцы — по факту притока, текущий и будущие — по плану (прогноз либо финмодель)."""
        mk = f"{ym[1]:02d}.{ym[0]}"
        planned = REVENUE_OVERRIDE.get(ym, revenue_of(ym))
        if ym < cur:
            return fact.get(mk, {}).get("Приток", 0.0)
        if ym == cur:                       # месяц не закрыт: берём больший из плана и уже собранного
            return max(planned, fact[jm].get("Приток", 0.0))
        return planned

    def base_val(name, rr, ym):
        """Полное значение строки по модели (до вычета уже оплаченного)."""
        return teachers_val(mv(rr, ym), crm_sal.get(ym, 0.0)) if name == LINE_TEACHERS_1_1 \
            else mv(rr, ym)

    def unassigned(ym):
        """Что осталось от «Итого расходы» после кредитов, налога модели и показанных строк.
        Должно быть ~0 — это сверка, что разбивка ничего не потеряла."""
        return (mv([ROW_TOTAL_OUT], ym) - mv([ROW_CREDITS], ym) - mv([ROW_MODEL_TAX], ym)
                - sum(mv(rr, ym) for _nm, rr, _bk in ALL_LINES))

    def tax_expense(ym):
        """Расходы месяца, принимаемые в базу УСН: показанные строки минус невычитаемые
        (возврат тела займа). Кредиты сюда не входят — см. USN_NONDEDUCTIBLE выше."""
        if ym < cur:                      # закрытый месяц — по факту, без кредитов и налогов
            f = fact.get(f"{ym[1]:02d}.{ym[0]}", {})
            return sum(f.get(l, 0.0) for l in OUT_LINES
                       if l not in ("Проценты по кредитам/займам", "Налоги"))
        return sum(base_val(nm, rr, ym) for nm, rr, _bk in ALL_LINES
                   if not set(rr) & USN_NONDEDUCTIBLE) + unassigned(ym)

    def usn_raw(ym):
        return usn_payment(ym, rev_full, tax_expense)

    def nds_raw(ym):                           # НДС отдельной строкой: в стр.40 модели его нет
        return nds_for_month(ym, rev_full) + monthly_since_total(ym)

    # текущий месяц не закрыт: внутри каждого бакета факта гасим уже оплаченное
    # пропорционально плану строк (раньше вычиталось из одной схлопнутой строки).
    cur_plan = {}
    for nm, rr, bk in ALL_LINES:
        cur_plan[bk] = cur_plan.get(bk, 0.0) + base_val(nm, rr, cur)
    cur_plan["Прочие расходы"] = cur_plan.get("Прочие расходы", 0.0) + unassigned(cur)
    cur_plan["Налоги"] = cur_plan.get("Налоги", 0.0) + nds_raw(cur) + usn_raw(cur)
    cur_plan["Проценты по кредитам/займам"] = credit_sched(cur)
    REM = {bk: (max(0.0, 1 - fact[jm].get(bk, 0.0) / tot) if tot > 0 else 1.0)
           for bk, tot in cur_plan.items()}

    def _rem(full, bucket, ym):
        return full * REM.get(bucket, 1.0) if ym == cur else full

    def line_val(name, rr, bucket, ym):
        return _rem(base_val(name, rr, ym), bucket, ym)

    def unassigned_val(ym):
        return _rem(unassigned(ym), "Прочие расходы", ym)

    def credit_val(ym, cf):
        return _rem(cf(ym), "Проценты по кредитам/займам", ym)

    def nds_val(ym):
        return _rem(nds_raw(ym), "Налоги", ym)

    def usn_val(ym):
        return _rem(usn_raw(ym), "Налоги", ym)

    def section_val(lines, ym):
        return sum(line_val(nm, rr, bk, ym) for nm, rr, bk in lines)

    def exp_total(ym, cf):
        return (sum(section_val(lines, ym) for _sec, lines in SECTIONS) + unassigned_val(ym)
                + credit_val(ym, cf) + nds_val(ym) + usn_val(ym) - ONE_OFFS.get(ym, 0))

    # достижение плана — только по ЗАКРЫТЫМ месяцам: текущий месяц ещё не собрал выручку,
    # и раньше он занижал коэффициент, которым потом умножался весь будущий прогноз
    closed = months[:-1]
    plan_rev_past = sum(mv([ROW_REVENUE], (YEAR, int(mo[:2]))) for mo in closed)
    attain = (sum(fact[mo].get("Приток", 0) for mo in closed) / plan_rev_past
              if closed and plan_rev_past else 1.0)

    def rev_val_realistic(ym):
        """rev_val с дисконтом attain (историческая собираемость план→факт по финмодели) —
        но ТОЛЬКО для месяцев, посчитанных от финмодели. Если месяц уже прогнозируется по
        планкам Финтабло — это собственная оценка Наташи, а не строка модели, и второй раз
        дисконтировать её тем же attain нельзя: занижает прогноз дважды (ревью Task 4,
        находка 4). Планки бывают и на текущий месяц (см. комментарий у rem_rev выше),
        поэтому проверяем cur наравне с будущими."""
        return rev_val(ym) if _plan_ft(ym) else rev_val(ym) * attain

    rows = [[f"КАССА — реальный остаток + план вперёд по {ml[-1]} (кредиты и НДС явно)"] + [""] * len(cols)]
    bal_str = ";  ".join(f"{n}: {round(b):,}".replace(",", " ") for n, b in bal_detail)
    rows.append([f"Остаток на сегодня: {round(total_bal):,}".replace(",", " ")] + [""] * len(cols))
    rows.append(["  (" + bal_str + ")"] + [""] * len(cols))
    pf = [round(fact[mo].get("Приток", 0) - sum(fact[mo].get(l, 0) for l in OUT_LINES)) for mo in months]
    rows.append(["Факт прибыли прошедших мес.: " +
                 ";  ".join(f"{mo[:2]}={v//1000}к" for mo, v in zip(months, pf))] + [""] * len(cols))
    rows.append([f"{ml[0]} «ост.» = план месяца минус уже оплачено/получено"] + [""] * len(cols))
    rows.append(["ЗП педагогов = max(финмодель, расписание CRM)" +
                 (f" — {crm_note}" if crm_note else "")] + [""] * len(cols))
    if missing:
        rows.append(["⚠️ нет в финмодели (и потому не в прогнозе): "
                     + ", ".join(f"{mo:02d}.{str(y)[2:]}" for y, mo in missing)] + [""] * len(cols))
    partial = partial_plan_months()
    if partial:
        rows.append(["⚠️ план Финтабло неполный (строки без планки обнулены, а не взяты "
                     "из модели): " + "; ".join(f"{mo:02d}.{str(y)[2:]} — нет {', '.join(names)}"
                                                 for (y, mo), names in partial)] + [""] * len(cols))
    rows.append([""] * (len(cols) + 1))

    rows.append(["Прогноз, ₽"] + ml); hdr = len(rows)
    rows.append(["ВЫРУЧКА"] + [round(rev_val(ym)) for ym in cols]); rev_row = len(rows)
    sec_rows = []
    for sec, lines in REVENUE_SECTIONS:
        rows.append([sec] + [round(rev_section_val(lines, ym)) for ym in cols])
        sec_rows.append(len(rows))
        for l in lines:
            vals = [round(rev_line_val(l, ym)) for ym in cols]
            if any(vals):                      # строки без факта (например, «Онлайн-подписка» до запуска клуба) не показываем
                rows.append([f"   {l.name}"] + vals)
    rev_una = [round(rev_unassigned(ym)) for ym in cols]
    if any(rev_una):
        rows.append(["Не разнесено (сверка со стр.18 модели / суммой планок Финтабло)"] + rev_una)
        sec_rows.append(len(rows))
    rows.append([""] * (len(cols) + 1))
    # НДС живёт в разделе «Налоги», но считается не из модели — поэтому отдельной строкой
    EXTRA = {"Налоги": [("УСН 15% Д−Р (аванс / годовой)", usn_val),
                        ("НДС 5% (1/3 выручки пред. квартала)", nds_val)]}
    for sec, lines in SECTIONS:
        extra = EXTRA.get(sec, [])
        total = [-round(section_val(lines, ym) + sum(f(ym) for _n, f in extra)) for ym in cols]
        rows.append([sec] + total); sec_rows.append(len(rows))
        for nm, rr, bk in lines:
            vals = [-round(line_val(nm, rr, bk, ym)) for ym in cols]
            if any(vals):                      # пустые строки модели не показываем
                rows.append([f"   {nm}"] + vals)
        for nm, f in extra:
            vals = [-round(f(ym)) for ym in cols]
            if any(vals):
                rows.append([f"   {nm}"] + vals)
    # кредиты — построчно (для текущего месяца оплаченное распределяем пропорционально)
    def credit_line(desc, ym):
        # 0, если в этом месяце кредита ещё/уже нет. В текущем месяце гасим оплаченное тем же
        # пропорциональным механизмом, что и остальные строки, — иначе итог раздела не сходился
        # с суммой строк под ним.
        return _rem(dict(credit_rows(ym)).get(desc, 0.0), "Проценты по кредитам/займам", ym)
    rows.append(["Кредиты и займы"] + [-round(credit_val(ym, credit_sched)) for ym in cols])
    sec_rows.append(len(rows))
    for desc in [d for _s, d, _c, _a, _day, _si, _u in _PS_CREDITS]:
        vals = [-round(credit_line(desc, ym)) for ym in cols]
        if not any(vals):          # закрытые кредиты (ТБанк) — строка из нулей, только шум
            continue
        rows.append([f"   {desc}"] + vals)
    if ONE_OFFS:
        rows.append(["Разовые платежи"] + [round(ONE_OFFS.get(ym, 0)) for ym in cols])
        sec_rows.append(len(rows))
    una = [-round(unassigned_val(ym)) for ym in cols]
    if any(una):                   # сверка: сколько «Итого расходы» не легло ни в одну строку
        rows.append(["Не разнесено (сверка с «Итого расходы»)"] + una)
        sec_rows.append(len(rows))
    rows.append(["ИТОГО РАСХОДЫ"] + [-round(exp_total(ym, credit_sched)) for ym in cols]); itogo_row = len(rows)
    profit = [round(rev_val(ym) - exp_total(ym, credit_sched)) for ym in cols]
    rows.append(["ПРИБЫЛЬ ЗА МЕСЯЦ (план)"] + profit); prof_row = len(rows)
    end, bal = [], total_bal
    for v in profit:
        bal += v; end.append(round(bal))
    rows.append(["ОСТАТОК НА КОНЕЦ (план)"] + end); end_row = len(rows)
    profit_real = [round(rev_val_realistic(ym) - exp_total(ym, credit_sched)) for ym in cols]
    rows.append([f"ПРИБЫЛЬ (реалистично: финмодель ×{round(attain*100)}%, планки Финтабло — без дисконта)"]
                + profit_real); prof_real_row = len(rows)
    end_real, bal = [], total_bal
    for v in profit_real:
        bal += v; end_real.append(round(bal))
    rows.append(["ОСТАТОК НА КОНЕЦ (реалистично)"] + end_real); end_real_row = len(rows)

    gap_pre = next((ml[i] for i, v in enumerate(end) if v < 0), None)
    if dry:
        print("DRY-RUN (лист не трогаю)")
        for r in rows:
            print(" | ".join(str(c)[:34] for c in r))
        print(f"остаток сегодня {round(total_bal)} | достижение плана {round(attain*100)}% "
              f"| разрыв(план): {gap_pre or 'нет'}")
        if no_end:
            print("⚠️ кредиты без даты окончания: " + "; ".join(no_end))
        return

    gc = gspread.service_account(filename=SA_FILE)
    sh = gc.open_by_key(SHEET_ID)
    try:
        ws = sh.worksheet("Касса")
    except gspread.WorksheetNotFound:
        ws = sh.add_worksheet("Касса", rows=40, cols=len(fut) + 2)
    sid = ws.id
    md = sh.fetch_sheet_metadata()
    nc = next((len(s.get("conditionalFormats", []) or []) for s in md["sheets"]
               if s["properties"]["sheetId"] == sid), 0)
    reset = [{"repeatCell": {"range": {"sheetId": sid}, "cell": {"userEnteredFormat": {}}, "fields": "userEnteredFormat"}}]
    reset += [{"deleteConditionalFormatRule": {"sheetId": sid, "index": 0}} for _ in range(nc)]
    sh.batch_update({"requests": reset})
    ws.clear()
    ws.update(rows, "A1")
    nrow = len(rows)
    last = chr(ord("A") + len(fut) + 1)
    W = {"red": 1, "green": 1, "blue": 1}
    ws.batch_format([
        {"range": f"A1:{last}{nrow}", "format": {"textFormat": {"fontSize": 10}}},
        {"range": f"B{hdr}:{last}{nrow}", "format": {"numberFormat": {"type": "NUMBER", "pattern": "#,##0"}}},
        {"range": "A1", "format": {"textFormat": {"bold": True, "fontSize": 11}}},
        {"range": f"A2", "format": {"textFormat": {"bold": True}}},
        {"range": f"A{hdr}:{last}{hdr}", "format": {
            "backgroundColor": {"red": 0.12, "green": 0.14, "blue": 0.18},
            "textFormat": {"bold": True, "foregroundColor": W}, "horizontalAlignment": "CENTER"}},
        {"range": f"A{hdr}", "format": {"horizontalAlignment": "LEFT"}},
        {"range": f"A{rev_row}:{last}{rev_row}", "format": {"textFormat": {"bold": True}}},
        # разделы расходов (итог группы) — жирным, детальные строки под ними обычным
        *[{"range": f"A{r}:{last}{r}", "format": {
            "textFormat": {"bold": True},
            "backgroundColor": {"red": 0.97, "green": 0.97, "blue": 0.98}}} for r in sec_rows],
        {"range": f"A{itogo_row}:{last}{itogo_row}", "format": {"textFormat": {"bold": True}}},
        {"range": f"A{prof_row}:{last}{prof_row}", "format": {"textFormat": {"bold": True}}},
        {"range": f"A{end_row}:{last}{end_row}", "format": {
            "backgroundColor": {"red": 0.95, "green": 0.96, "blue": 0.97}, "textFormat": {"bold": True}}},
        {"range": f"A{prof_real_row}:{last}{prof_real_row}", "format": {"textFormat": {"bold": True, "italic": True}}},
        {"range": f"A{end_real_row}:{last}{end_real_row}", "format": {
            "backgroundColor": {"red": 0.99, "green": 0.94, "blue": 0.90}, "textFormat": {"bold": True}}},
    ])
    ws.freeze(rows=hdr, cols=1)
    RED = {"textFormat": {"foregroundColor": {"red": 0.72, "green": 0.11, "blue": 0.11}}}
    sh.batch_update({"requests": [
        {"updateDimensionProperties": {
            "range": {"sheetId": sid, "dimension": "COLUMNS", "startIndex": 0, "endIndex": 1},
            "properties": {"pixelSize": 260}, "fields": "pixelSize"}},
        {"addConditionalFormatRule": {"rule": {
            "ranges": [{"sheetId": sid, "startRowIndex": r - 1, "endRowIndex": r,
                        "startColumnIndex": 1, "endColumnIndex": len(fut) + 2} for r in (end_row, end_real_row)],
            "booleanRule": {"condition": {"type": "NUMBER_LESS", "values": [{"userEnteredValue": "0"}]}, "format": RED}},
            "index": 0}},
    ]})

    gap = next((ml[i] for i, v in enumerate(end) if v < 0), None)
    gap_r = next((ml[i] for i, v in enumerate(end_real) if v < 0), None)
    print("OK →", sh.url, "| вкладка Касса")
    if no_end:
        print("⚠️ кредиты без даты окончания (платёж планируется бессрочно, уточни срок): "
              + "; ".join(no_end))
    print(f"остаток сегодня {round(total_bal)} | достижение плана по выручке {round(attain*100)}%")
    print("ПЛАН       остаток:", dict(zip(ml, end)), "| разрыв:", gap or "нет")
    print("РЕАЛИСТИЧНО остаток:", dict(zip(ml, end_real)), "| разрыв:", gap_r or "нет")


if __name__ == "__main__":
    import sys
    push(dry="--dry" in sys.argv)
