#!/usr/bin/env python3
"""Автосинк ПЛАНОВЫХ РАСХОДОВ и снятие ДОХОДНЫХ ПЛАНОК Марса в Финтабло.

Два действия по расходам (по решению Наташи 20.07):
  RETIRE — убрать «отработавшие» плановые: для каждой нашей plansync-плановой ищу совпавший
           факт (та же статья + сумма ±5% + тот же factMonth, жадное 1:1) → предлагаю удалить.
  BUILD  — создать план расходов на следующий месяц из канона (постоянка по контрагентам +
           ЗП из pay_config + кредиты + налоги). Идемпотентно: пересобирает namespace plansync:sync:{MM}.

Отдельный режим по доходам:
  --retire-income — снять доходные плановые операции закрывшегося месяца (namespace
           plansync:income:{MM}:{slug}). В отличие от расходов, доходные планки не матчатся
           по совпадению факта (доход приходит россыпью мелких платежей CRM) — снимаются
           целиком по месяцу, с итогом план↔факт по канону income_lines для контроля.

Запуск (по умолчанию DRY-RUN — только показывает, ничего не пишет):
  python3 plan_sync.py                       # авто: retire + build следующего месяца, dry-run
  python3 plan_sync.py --month 08.2026       # build конкретного месяца
  python3 plan_sync.py --commit              # ВЫПОЛНИТЬ (удалить/создать в Финтабло)
  python3 plan_sync.py --send                # послать предложение в чат «Марс х Оля»
  python3 plan_sync.py --retire-only / --build-only
  python3 plan_sync.py --retire-income                  # снять доходные планки ПРОШЛОГО месяца
  python3 plan_sync.py --retire-income --month 07.2026   # снять доходные планки указанного месяца
                                                          # (нужно, пока закрывшийся месяц ещё текущий)

События Оли (внутренние ивенты / b2b / съёмки) НЕ генерятся тут — заносятся отдельно из её таблицы.
"""
import sys, os, re, json, datetime, urllib.request, urllib.parse
from calendar import monthrange
import core
import matching
import pay_config as pc

# ── статьи Финтабло ──
CAT = dict(arenda=1325377, servisy=1543704, buh=1325371, tipo=1351393, nalog=1325600,
           proc_zaym=1544226, proc_kredit=1544225, zp_back=1543724, zp_admin=1543725,
           vznos=1287211, ndfl=1287210, zp_ped=1543716,
           hoz=1332557, autsors=1543727, kom_rr=1544281, bank=1325533, ekvayring=1325544)
MB_TOCHKA, MB_NAL = 151784, 154399

# ── КАНОН постоянки (slug, описание, статья, сумма, день) ──
FIXED = [
    ("arenda",      "Аренда помещения",        CAT["arenda"], 1_146_000, 15),
    ("pozharka",    "Пожарка (ТО АПС и СО)",    CAT["arenda"], 7_000,  25),
    # тариф ЭКОТЕХНОЛОГИИ: 14 550 до апреля, с мая держится 10 650 (май, июнь, июль)
    ("voda",        "Вода (аренда автоматов)",  CAT["arenda"], 10_650, 25),
    ("vodootv",     "Водоотведение",            CAT["arenda"], 2_000,  15),
    ("internet",    "Интернет (телематика)",    CAT["servisy"], 7_875, 30),
    ("bot",         "Бот (обслуживание ПО)",    CAT["servisy"], 2_500, 25),
    ("buildin",     "Buildin (автосписание)",   CAT["servisy"], 7_900, 28),
    ("tipografiya", "Типография",               CAT["tipo"], 5_000,  25),
    ("buhgalteriya","Бухгалтерия ФСК",          CAT["buh"], 35_000, 25),
    ("nalog_enp",   "Налоги ЕНП",               CAT["nalog"], 15_120, 5),
    # ── авто-списания и агрегаты (руками не платятся, но кассу едят; суммы = средний факт май-июнь) ──
    ("hoz",         "Хозяйственные (среднее)",     CAT["hoz"], 40_000, 28),
    ("autsors",     "Аутсорс",                     CAT["autsors"], 40_000, 25),
    ("kom_rr",      "Комиссия Рабочих рук (авто)", CAT["kom_rr"], 34_000, 28),
    ("bank",        "Банковское обслуж. (авто)",   CAT["bank"], 33_000, 28),
    ("ekvayring",   "Комиссия эквайринг (авто)",   CAT["ekvayring"], 10_000, 28),
    ("podpiski",    "Сервисы: подписки прочие (SaaS, среднее)", CAT["servisy"], 55_000, 28),
]
# кредиты: (slug, описание, статья, сумма, день, с_месяца (y,mo)|None, ПО_месяц (y,mo)|None)
# until — ПОСЛЕДНИЙ месяц платежа включительно. None = бессрочно: платёж будет планироваться
# и в 2027, и дальше, а кассовый прогноз — вечно вычитать эти деньги. Скрипт про такие строки
# предупреждает (см. credits_without_end).
# TODO: проставить реальные сроки — Наташа знает даты закрытия по каждому кредиту.
CREDITS = [
    # Заём Назипова: тело 5 млн под 20% годовых → ~85к/мес процентов (факт 82–85к, гуляет
    # по числу дней в месяце). Проценты платятся ДО ВОЗВРАТА ТЕЛА, а возврат 5 млн стоит в
    # финмодели (строка 43) в марте 2027 — отсюда until (Наташа, 29.07.2026).
    ("proc_zaym",  "% по займу (Назипов, тело 5М)", CAT["proc_zaym"], 85_000, 25,
     None, (2027, 3)),
    # «Кредит Харитоновой Ксении» и «кредит на ИП Ксюши» — ОДНО И ТО ЖЕ (Наташа, 29.07.2026).
    # Платёж 80 000 20-го числа с р/с Точка, в выписке маскируется под «работы по флористике».
    # Отдельной строкой его не заводить — будет двойной счёт.
    # Последний платёж — февраль 2027 (Наташа, 29.07.2026).
    ("credit_ksy", "Кредит Харитоновой Ксении (ИП Ксюши)", CAT["proc_kredit"], 80_000, 20,
     None, (2027, 2)),
    # 36 платежей по 70к с августа 2026 (Наташа, 29.07.2026) → последний в июле 2029.
    # ⚠️ в выписке приход назван «беспроцентный заём», но 36 × 70к = 2,52М на тело 1,5М —
    #    либо заём не беспроцентный, либо срок короче (1,5М / 70к ≈ 21 месяц). Уточнить.
    ("loan15m",    "Новый кредит 1,5М (платёж)",  CAT["proc_kredit"], 70_000, 15,
     (2026, 8), (2029, 7)),
    # Оборотный 610к — ОТДЕЛЬНЫЙ кредит, 60к/мес, гасится с наличных (Наташа, 29.07.2026).
    # По графику — до июня 2027. Хотят закрыть досрочно (взят наличкой): когда решат, ставим
    # сюда until=(год, месяц) досрочного закрытия, а сам платёж тела — в cashflow.ONE_OFFS.
    ("oborotnyj",  "Оборотный (610к, погашение)", CAT["proc_kredit"], 60_000, 20,
     None, (2027, 6)),
    # Кредит ТБанка 1 000 000, взят 10.02.2026 на ООО «СЧАСТЛИВЫЕ ЛЮДИ ПОЮТ», договор
    # № 7145134903. Платёж 206 600 10-го числа. ЗАКРЫТ ДОСРОЧНО 15.07.2026 — в тот же день
    # пришёл беспроцентный заём 1,5М, и с него ушла «Полная оплата задолженности FULL»
    # на 166 500. Всего по кредиту уплачено 1 239 400. until=(2026,7) → вперёд не планируется.
    # Строка оставлена, чтобы прошлые месяцы сходились и кредит не «потерялся» в истории.
    ("tbank1m",    "Кредит ТБанк 1М (дог. 7145134903, закрыт 15.07)",
     CAT["proc_kredit"], 206_600, 10, (2026, 2), (2026, 7)),
]
# ЕЖЕМЕСЯЧНЫЕ С ДАТЫ: (slug, описание, статья, сумма, день, с_месяца (y,mo))
MONTHLY_SINCE = [
    # НДС 5% на УСН (начисляется с июл-2026, Q3): платится ТРЕМЯ ДОЛЯМИ до 28-го числа каждого
    # месяца СЛЕДУЮЩЕГО квартала → с окт-2026 фактически ежемесячный платёж ~1/3 квартального.
    # Оценка: 5% × ~13М выручки квартала / 3 ≈ 215к. TODO: пересчитывать от фактической выручки квартала.
    ("nds", "НДС 5% (1/3 за квартал, оценка)", CAT["nalog"], 215_000, 28, (2026, 10)),
]
# ПЕРИОДИЧЕСКИЕ (не ежемесячные): (slug, описание, статья, сумма, день, [месяцы], с_года)
PERIODIC = [
    # 1С:Фреш (ООО СЕРВИСКЛАУД), подписка 6 мес. Оплачено 12.05.2026, следующее продление янв-2027.
    ("1c_fresh", "1С:Фреш (СЕРВИСКЛАУД, 6 мес)", CAT["servisy"], 9_500, 15, [1], 2027),
    # Квартальный ЕНП (взносы/НДФЛ за квартал): в июне-2026 факт 73 680 «ЕНП. Без НДС.»
    ("enp_kvartal", "ЕНП квартальный (оценка)", CAT["nalog"], 74_000, 25, [3, 6, 9, 12], 2026),
]
# Контрагент постоянных платежей → partnerId (чтобы report матчил их фазой A, а не по грубой сумме).
# Ключ — slug из FIXED/CREDITS/…; значение — либо ФАМИЛИЯ (резолв через /v1/partner, как у ЗП),
# либо явный int id (пиним, когда по имени НЕ различить — в базе есть дубли-тёзки). Не нашли → None,
# контрагента НЕ создаём.
FIXED_PARTNERS = {
    # В базе ДВА контакта «НАЗИПОВ АЛМАЗ АНСАРОВИЧ» (4878685 с адресом, 4977253 — чистый); платежи
    # по займу идут на 4977253 → пиним id (по фамилии не различить). Даёт двойную страховку к фазе C2.
    "proc_zaym": 4977253,
    "tbank1m":   4451827,   # АО «ТБанк» — платёж по договору № 7145134903, ровно 206 600 10-го
    # "credit_ksy": Харитонова Ксения — тоже два контакта (4451828, 4513139), неизвестно какой платят →
    #               не ставим (партнёр останется None), матчинг отработает по другим фазам.

    # Постоянка с ОДНИМ счётом в месяц от одного юрлица. Без контрагента такая планка
    # держалась на совпадении суммы — и разваливалась, стоило поставщику поменять тариф:
    # 27.07.2026 «Вода» на 14 560 висела «просрочено», хотя ЭКОТЕХНОЛОГИИ 21.07 заплатили
    # 10 650. С partnerId сумма перестаёт быть критерием, недоплата видна как «оплачено X из Y».
    "voda":         4594669,   # ООО «ЭКОТЕХНОЛОГИЯ» — аренда автоматов питьевой воды
    "pozharka":     4665884,   # ООО «МОНТАЖ» — ТО АПС и СО
    "internet":     4949168,   # ООО «Версия» — телематика
    "bot":          4451829,   # ИП Обухов Евгений — обслуживание ПО
    "buhgalteriya": 4435884,   # ООО «ФСК» — бухгалтерия
    # НЕ пиним «arenda» и «vodootv»: оба от ООО «ПУШКАРЁВ ЦЕНТР», и счетов в месяц четыре
    # (аренда, водоотведение, электроэнергия, компенсация). Жадное гашение по контрагенту
    # смешало бы их между собой. Там работают прочие фазы каскада.
    # НЕ пиним «tipografiya»: к счёту ИП Аксенова добавляются наличные доставки из таблицы
    # Оли, и по контрагенту планка выглядела бы недоплаченной.
}

# Педагоги: ВРЕМЕННАЯ закладка ~1,4 млн/мес = 700к × 2 (1-е и 16-е). Со слов Наташи 20.07.
# TODO заменить на проекцию по регулярному расписанию CRM × ставки × калибровка (будущих уроков в CRM нет,
#      только recurring schedule; уроки×ставки занижают ~2×, факт денег май ≈ 1,17М/мес).
TEACH_EST = 700_000


def credit_active(y, mo, since, until):
    """Платёж по кредиту попадает в месяц (y, mo)? Чистая функция."""
    if since and (y, mo) < since:
        return False
    if until and (y, mo) > until:
        return False
    return True


def credits_without_end():
    """Кредиты без даты окончания — их платёж планируется бессрочно. Для предупреждения."""
    return [desc for _s, desc, _c, _a, _d, _since, until in CREDITS if until is None]


money = core.money


def payday(y, mo, day):
    last = monthrange(y, mo)[1]
    d = datetime.date(y, mo, min(day, last))
    wd = d.weekday()                       # 0=Пн … 6=Вс
    if wd == 5:
        d += datetime.timedelta(days=2)    # сб → пн
    elif wd == 6:
        d += datetime.timedelta(days=1)    # вс → пн
    return d


def duedate(y, mo, day):
    """Дата обязательного платежа: с выходного сдвигаем НАЗАД, на пятницу.

    Зарплату можно отдать в понедельник (payday), а аренду, коммуналку, кредит и налог —
    нельзя: по ним опоздание стоит денег. 15.08.2026 суббота → платим 14.08 (Наташа).
    """
    last = monthrange(y, mo)[1]
    d = datetime.date(y, mo, min(day, last))
    wd = d.weekday()
    if wd == 5:
        d -= datetime.timedelta(days=1)    # сб → пт
    elif wd == 6:
        d -= datetime.timedelta(days=2)    # вс → пт
    return d


# Статьи, по которым опоздание стоит денег: дата двигается назад, а не вперёд.
# По ID, а не по имени: имена статей в Финтабло переименовывают, ID — нет.
MANDATORY_CATS = {CAT["arenda"], CAT["nalog"], CAT["ndfl"], CAT["vznos"],
                  CAT["proc_zaym"], CAT["proc_kredit"]}


def is_mandatory(cat_id):
    return cat_id in MANDATORY_CATS


def _duedate_for(cat_id):
    """Функция расчёта даты под статью: обязательные — назад, остальные — вперёд."""
    return duedate if is_mandatory(cat_id) else payday


def _slug_name(name):
    return name.replace(" ", "_").replace("(", "").replace(")", "")


# ── lookup контрагента по фамилии (для partnerId в ЗП-планках) ──
# Финтабло хранит имя контрагента как «Фамилия Имя Отчество» → первое слово = фамилия.
# Имена в pay_config идут в разном порядке («Блинова Наталия», но «Оля Прохорова»),
# поэтому перебираем все слова имени и берём то, что нашлось в индексе фамилий.
def _norm_word(s):
    return str(s or "").strip().lower().replace("ё", "е")


def surname_index(partners):
    """[{id,name,…}] контрагентов → {фамилия(норм): id}. Первый по фамилии выигрывает (детерминированно)."""
    idx = {}
    for p in partners:
        toks = re.findall(r"[А-Яа-яЁё]+", p.get("name") or "")
        if toks:
            idx.setdefault(_norm_word(toks[0]), p.get("id"))
    return idx


def resolve_pid(person_name, index):
    """partnerId по фамилии из person_name (перебирая слова), либо None если не нашли. Чистая функция."""
    for tok in re.findall(r"[А-Яа-яЁё]+", person_name or ""):
        n = _norm_word(tok)
        if len(n) >= 3 and n in index:
            return index[n]
    return None


def zp_ops(y, mo, pid_for=None):
    """ЗП бэк-офиса + Харитонов + педагоги из pay_config → [(slug, desc, cat, amount, date, mb, pid)].

    pid_for(name)->partnerId ставит контрагента ЗП-планкам ПО ЛЮДЯМ (чтобы report матчил по partnerId,
    а не по грубой сумме). Агрегатам (администраторы, педагоги) partnerId НЕ ставим — они на несколько людей.
    Без pid_for (dry-run/тесты без сети) partnerId = None."""
    pid_for = pid_for or (lambda name: None)
    days = monthrange(y, mo)[1]
    amt = {}
    for name, accts in pc.BACKOFFICE_FIXED.items():
        if "Харитонов" in name:
            continue
        amt[name] = sum(accts.values())
    for name, cfg in pc.BACKOFFICE_VARIABLE.items():
        amt[name] = cfg["per_day"] * days if cfg["type"] == "per_day" else cfg["amount"]
    full5 = pc.BACKOFFICE_PAY["split"]["full_on_5"]
    half = pc.BACKOFFICE_PAY["split"]["half_5_half_20"]
    res = []
    for name, a in amt.items():
        cat = CAT["zp_admin"] if "админ" in name.lower() else CAT["zp_back"]
        sn = _slug_name(name)
        # «администраторы» — агрегат (несколько людей), контрагента не ставим
        pid = None if "администратор" in name.lower() else pid_for(name)
        if name in half:
            res.append((f"zp:{sn}:5",  f"ЗП {name} (50%)", cat, a / 2, payday(y, mo, 5), MB_TOCHKA, pid))
            res.append((f"zp:{sn}:20", f"ЗП {name} (50%)", cat, a / 2, payday(y, mo, 20), MB_TOCHKA, pid))
        else:                              # full_on_5 или дефолт
            res.append((f"zp:{sn}:5", f"ЗП {name}", cat, a, payday(y, mo, 5), MB_TOCHKA, pid))
    k = pc.BACKOFFICE_PAY["kharitonov"]
    kh_pid = pid_for("Харитонов Александр")
    for s in k["salary"]:
        mb = MB_NAL if s["moneybag"] == "наличка" else MB_TOCHKA
        res.append((f"zp:haritonov:{s['day']}", "ЗП Харитонов (Саша)", CAT["zp_back"], s["amount"], payday(y, mo, s["day"]), mb, kh_pid))
    tv = k["tax_vznos"]
    res.append(("zp:haritonov:vznos", "ЗП Харитонов доп. (Саша, 20-е)", CAT["zp_back"], tv["amount"], payday(y, mo, tv["day"]), MB_TOCHKA, kh_pid))
    res.append(("teach:01", "ЗП педагогов (1-я, оценка)", CAT["zp_ped"], TEACH_EST, payday(y, mo, 1), MB_TOCHKA, None))
    res.append(("teach:16", "ЗП педагогов (2-я, оценка)", CAT["zp_ped"], TEACH_EST, payday(y, mo, 16), MB_TOCHKA, None))
    return res


from core import RU_MONTH_GEN


def prev_month(mm):
    """«08.2026» → «07.2026». Чистая функция."""
    mo, y = int(mm[:2]), int(mm[3:])
    return f"12.{y - 1}" if mo == 1 else f"{mo - 1:02d}.{y}"


def prior_facts_from(all_out, prior_mm):
    """Факты (не план) месяца prior_mm → [{partnerId, categoryId, value}] — база для оценки сумм."""
    return [{"partnerId": t.get("partnerId"), "categoryId": t.get("categoryId"),
             "value": float(t.get("value") or 0)}
            for t in all_out if not t.get("isPlan") and t.get("factMonth") == prior_mm]


def fact_amount(prior_facts, partnerId, categoryId, cat_plan_count):
    """Сумма факта прошлого месяца для постоянной планки → (amount|None, basis).
    По partnerId (если запинен/разрешён), иначе по СТАТЬЕ — но только если планка в статье ОДНА
    (несколько планок одной статьи → неоднозначно, None). Нет факта → (None, basis). Чистая функция.
    Несколько платежей контрагента за месяц суммируются."""
    if partnerId:
        matched = [f for f in prior_facts if f["partnerId"] == partnerId]
        basis = "контрагент"
    elif cat_plan_count == 1:
        matched = [f for f in prior_facts if f["categoryId"] == categoryId]
        basis = "статья"
    else:
        return None, None
    if not matched:
        return None, basis
    return sum(f["value"] for f in matched), basis


def _fixed_pid(slug, pid_for):
    """partnerId постоянного платежа: int в FIXED_PARTNERS — пин, str — lookup по фамилии, иначе None."""
    val = FIXED_PARTNERS.get(slug)
    if isinstance(val, int):
        return val
    return pid_for(val) if pid_for else None


def fixed_lines(mm, pid_for=None):
    """Постоянные (НЕ ЗП) планки месяца с КОНФИГ-суммами → [(slug, desc, cat, amount, date, mb, pid)]."""
    y, mo = int(mm[3:]), int(mm[:2])
    lines = []
    for slug, desc, cat, amt, day in FIXED:
        lines.append((slug, desc, cat, amt, _duedate_for(cat)(y, mo, day), MB_TOCHKA, _fixed_pid(slug, pid_for)))
    for slug, desc, cat, amt, day, since, until in CREDITS:
        if not credit_active(y, mo, since, until):
            continue
        lines.append((slug, desc, cat, amt, _duedate_for(cat)(y, mo, day), MB_TOCHKA, _fixed_pid(slug, pid_for)))
    for slug, desc, cat, amt, day, since in MONTHLY_SINCE:
        if (y, mo) >= since:
            lines.append((slug, desc, cat, amt, _duedate_for(cat)(y, mo, day), MB_TOCHKA, _fixed_pid(slug, pid_for)))
    for slug, desc, cat, amt, day, months, since_year in PERIODIC:
        if mo in months and y >= since_year:
            lines.append((slug, desc, cat, amt, _duedate_for(cat)(y, mo, day), MB_TOCHKA, _fixed_pid(slug, pid_for)))
    return lines


def _cat_counts(fixed):
    cnt = {}
    for _s, _d, cat, _a, _dt, _mb, _p in fixed:
        cnt[cat] = cnt.get(cat, 0) + 1
    return cnt


def month_plan(mm, pid_for=None, prior_facts=None):
    """Полный список плановых для месяца mm → [(slug, desc, cat, amount, date, mb, pid)].
    Если задан prior_facts (факты прошлого месяца), суммы ПОСТОЯННЫХ планок берутся из факта
    контрагента/статьи (см. fact_amount) — «займ Алмаза 85 000 → факт 82 192». ЗП-планки из
    pay_config фактом НЕ трогаем (ставки — источник правды)."""
    y, mo = int(mm[3:]), int(mm[:2])
    fx = fixed_lines(mm, pid_for)
    counts = _cat_counts(fx)
    ops = []
    for slug, desc, cat, cfg, dt, mb, pid in fx:
        amt = cfg
        if prior_facts is not None:
            fs, _ = fact_amount(prior_facts, pid, cat, counts[cat])
            if fs is not None:
                amt = fs
        ops.append((slug, desc, cat, amt, dt, mb, pid))
    ops += zp_ops(y, mo, pid_for)
    return ops


def fixed_proposal_rows(mm, prior_mm, pid_for=None, prior_facts=None):
    """Строки предложения по постоянным планкам, где сумма ВЗЯТА ИЗ ФАКТА и отличается от конфига:
    «% по займу (Назипов) — 82 192 (факт июля; конфиг 85 000)». Чистая функция."""
    if prior_facts is None:
        return []
    fx = fixed_lines(mm, pid_for)
    counts = _cat_counts(fx)
    pmo = int(prior_mm[:2])
    rows = []
    for slug, desc, cat, cfg, dt, mb, pid in fx:
        fs, _basis = fact_amount(prior_facts, pid, cat, counts[cat])
        if fs is not None and abs(fs - cfg) > 1e-6:
            rows.append(f"{desc} — {money(fs)} (факт {RU_MONTH_GEN[pmo]}; конфиг {money(cfg)})")
    return rows


def backoffice_amounts(y, mo):
    """{имя: полная месячная ставка} бэк-офиса из pay_config (fixed+variable + зарплата Харитонова).
    Без взноса Харитонова и без агрегата «администраторы» отдельно не выделяем — фильтруем при сверке."""
    days = monthrange(y, mo)[1]
    amt = {}
    for name, accts in pc.BACKOFFICE_FIXED.items():
        if "Харитонов" in name:
            continue
        amt[name] = sum(accts.values())
    for name, cfg in pc.BACKOFFICE_VARIABLE.items():
        amt[name] = cfg["per_day"] * days if cfg["type"] == "per_day" else cfg["amount"]
    amt["Харитонов Александр"] = sum(s["amount"] for s in pc.BACKOFFICE_PAY["kharitonov"]["salary"])
    return amt


def zp_rate_warnings(y, mo, prior_mm, prior_facts, pid_for, threshold=0.10):
    """Предупреждения, где ФАКТ прошлого месяца по человеку отличается от ставки pay_config > threshold:
    «⚠️ Наташа Мухина: ставка 30 000, факт июля 12 000 — проверь pay_config». Чистая функция.
    Ставки не меняем (источник правды), только сигналим владелице. Агрегат «администраторы» пропускаем."""
    amt = backoffice_amounts(y, mo)
    pmo = int(prior_mm[:2])
    warns = []
    for name in sorted(amt):
        rate = amt[name]
        if "администратор" in name.lower() or rate <= 0:
            continue
        pid = pid_for(name) if pid_for else None
        if not pid:
            continue
        fact = sum(f["value"] for f in prior_facts if f["partnerId"] == pid)
        if fact <= 0:
            continue
        if abs(fact - rate) > threshold * rate:
            warns.append(f"⚠️ {name}: ставка {money(rate)}, факт {RU_MONTH_GEN[pmo]} {money(fact)} — проверь pay_config")
    return warns


# ── Финтабло I/O ── (мутации с ретраями — core.ftmut)
ftreq = core.ftmut


def all_outcome(years):
    """Расходные операции за указанные годы (план + факт). На стыке года нужны оба:
    в декабре мы строим план на январь, и без январского окна build не увидел бы уже
    созданные планки и создал бы их поверх — дубль плана на месяц."""
    out = []
    for y in sorted(set(years)):
        out += core.ft_transactions(year=y, group="outcome")
    return out


def find_retire(all_out, cats, partners, today):
    """Плановые plansync, которые уже ОТРАБОТАЛИ (факт пришёл) → [(операция, разбор)].

    Матчинг — общий каскад из matching.py (по контрагенту, фамилии, сумме…), тот же, что
    в отчёте. Раньше здесь было своё правило «та же статья + сумма ±5%», и планку мог
    погасить случайный платёж близкой суммы — обязательство исчезало из прогноза.
    Удаляем строго при покрытии ≥95% (RETIRE_MIN_COVER): недоплату не хороним."""
    plans = [t for t in all_out if t.get("isPlan") and "plansync" in (t.get("externalId") or "")]
    facts = [t for t in all_out if not t.get("isPlan")]
    plan_ops = [matching.plan_op(t, cats) for t in plans]
    fact_ops = [matching.fact_op(t, cats, partners) for t in facts]
    matching.match_cascade(plan_ops, fact_ops, today)
    return [(t, o) for t, o in zip(plans, plan_ops)
            if matching.settled(o, matching.RETIRE_MIN_COVER)]


def existing_sync(all_out, mm):
    """Всё plansync за месяц mm, КРОМЕ событий Оли (plansync:olya:*) — их ведём отдельно.
    Build забирает месяц под себя: чистит легаси (teach0801, fix0726, credit80, new-loan…) и пересоздаёт из канона."""
    res = []
    for t in all_out:
        ext = t.get("externalId") or ""
        if not (t.get("isPlan") and ext.startswith("plansync:")):
            continue
        if ext.startswith("plansync:olya:"):
            continue
        if t.get("factMonth") != mm:
            continue
        res.append(t)
    return res


def income_plans(all_income, mm):
    """Доходные плановые операции месяца mm (MM.YYYY) — список операций Финтабло.

    В отличие от расходов, матчить по контрагенту и сумме нечего: факт доходов
    приходит россыпью мелких платежей (в июле 2026 — 267 штук на одну плановую
    строку), поэтому снимаем по месяцу, а не по совпадению.
    """
    res = []
    for t in all_income:
        ext = t.get("externalId") or ""
        if t.get("isPlan") and ext.startswith("plansync:income:%s:" % mm):
            res.append(t)
    return res


def retire_target_month(args, target, today):
    """Месяц, чьи доходные планки снимаем — единственное решение в `--retire-income`,
    определяющее, чей план денег будет удалён, поэтому вынесено отдельно и покрыто
    тестами без сети (см. test_income_retire.py).

    `--month` — снимаем указанный месяц как есть (`target` уже провалидирован форматом
    MM.YYYY в main()). Без флага — прошлый месяц от сегодня: таймер 1-го числа снимает
    то, что только что закрылось."""
    return target if "--month" in args else prev_month(core.month_key(today))


def income_summary(mm, all_income, token=None):
    """{slug: {plan, fact, delta}} — итог месяца по канону: прогноз ↔ факт CRM."""
    import income_lines as il
    plan = il.plan_from_fintablo(mm, all_income)
    fact = il.crm_fact(mm, token)
    res = {}
    for slug in set(list(plan) + list(fact)):
        p = plan.get(slug, 0.0)
        f = fact.get(slug, 0.0)
        res[slug] = {"plan": p, "fact": f, "delta": f - p}
    return res


def main():
    args = sys.argv[1:]
    commit = "--commit" in args
    send = "--send" in args
    retire_only = "--retire-only" in args
    build_only = "--build-only" in args
    today = datetime.date.today()
    target = args[args.index("--month") + 1] if "--month" in args else core.next_month_key(today)
    if not re.match(r"\d{2}\.\d{4}$", target or ""):
        print(f"⚠️ месяц должен быть MM.YYYY, получено «{target}»"); return

    retire_income = "--retire-income" in args
    if retire_income:
        prev = retire_target_month(args, target, today)
        print(f"📅 снимаю доходные планки за {prev}")
        # Год берём из снимаемого месяца (prev), а НЕ из FIN_YEAR по умолчанию: 1 января
        # снимается декабрь ПРОШЛОГО года (prev="12.<год-1>"), а ft_transactions() без year
        # тянет за текущий FIN_YEAR — декабрьские планки не попадут в выборку и retire
        # молча найдёт 0 операций при живом факте (см. находку #1 финального ревью).
        all_income = core.ft_transactions(year=int(prev[3:]), group="income")
        ops = income_plans(all_income, prev)
        summary = income_summary(prev, all_income)
        lines = ["📊 <b>Доходы %s: прогноз ↔ факт</b>" % prev]
        import income_lines as il
        known_slugs = {l.slug for l in il.LINES}
        for l in il.LINES:
            s = summary.get(l.slug)
            if not s or (not s["plan"] and not s["fact"]):
                continue
            lines.append("  %s: план %s / факт %s / Δ %s"
                         % (l.name, money(s["plan"]), money(s["fact"]), money(s["delta"])))
        # Слаги в summary, которых уже нет в каноне LINES (легаси externalId с ДО
        # переименования канона, напр. koncert/syomki/abonementy вместо iventy/cover/uroki):
        # цикл выше их молча пропускает, а деньги реальные — печатаем отдельной строкой,
        # чтобы план не терялся из сводки перед необратимым удалением. Алиасы легаси-слагов
        # в канон намеренно не заводим — abonementy не разделяется обратно на uroki/gruppy,
        # тащить вечную легаси-таблицу ради разовой миграции не стоит (решение контроллера).
        unmatched = sorted(slug for slug in summary if slug not in known_slugs)
        if unmatched:
            unmatched_sum = sum(summary[slug]["plan"] for slug in unmatched)
            lines.append("  не сопоставлено: %s ₽ (устаревшие слаги: %s)"
                         % (money(unmatched_sum), ", ".join(unmatched)))
        lines.append("\nСнимаю плановых строк: %d" % len(ops))
        text = "\n".join(lines)
        if commit:
            # ЖУРНАЛ до любых мутаций: полные копии снимаемых плановых операций + сводка
            # план/факт по месяцу. Если упадём посреди удаления — из журнала видно, что
            # восстанавливать (как в build/retire расходов выше по файлу).
            jpath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "exports",
                                 "income_retire_%s.json" % prev.replace(".", "-"))
            os.makedirs(os.path.dirname(jpath), exist_ok=True)
            with open(jpath, "w", encoding="utf-8") as jf:
                json.dump({"month": prev, "summary": summary, "deleted": ops},
                          jf, ensure_ascii=False, indent=1)
            for t in ops:
                ftreq("DELETE", "/v1/transaction/%s" % t["id"])
            text += "\n✅ снято, журнал: %s" % os.path.basename(jpath)
        else:
            text += "\n⚠️ dry-run, добавь --commit"
        if send:
            tok = os.environ.get("TELEGRAM_BOT_TOKEN"); chat = os.environ.get("TELEGRAM_CHAT_ID")
            data = urllib.parse.urlencode({"chat_id": chat, "text": text,
                                           "parse_mode": "HTML"}).encode()
            urllib.request.urlopen("https://api.telegram.org/bot%s/sendMessage" % tok,
                                   data=data, timeout=20)
        print(text.replace("<b>", "").replace("</b>", ""))
        return

    cats = core.ft_categories()
    partners = core.ft_partners()
    # окно сканирования: год целевого месяца + текущий (декабрьский прогон строит январь)
    all_out = all_outcome({today.year, int(target[3:]), int(prev_month(target)[3:])})

    lines = [f"🗓 <b>Автосинк плана расходов</b> · {today.strftime('%d.%m')}"]

    # RETIRE
    retire = [] if build_only else find_retire(all_out, cats, partners, today)
    if not build_only:
        rsum = sum(float(p.get("value") or 0) for p, _ in retire)
        lines.append(f"\n<b>Убрать отработавшие (план сбылся): {len(retire)} шт · {money(rsum)}</b>")
        for p, o in sorted(retire, key=lambda pf: pf[0].get("date") or "")[:40]:
            lines.append(f"— {p.get('date')} {money(float(p.get('value') or 0))} "
                         f"{cats.get(p.get('categoryId'),'')[:22]} ✓оплачено {money(o['paid'])}")

    # BUILD (partnerId ЗП-планок по людям — lookup по фамилии в /v1/partner; не нашёл → None)
    pid_for = None
    prior_mm = prev_month(target)
    prior_facts = prior_facts_from(all_out, prior_mm)         # факты прошлого месяца для оценки сумм
    if not retire_only:
        sidx = surname_index(core.ftget("/v1/partner").get("items", []))
        pid_for = lambda name: resolve_pid(name, sidx)
    # суммы постоянных планок ориентируем на факт прошлого месяца (контрагент/статья), ЗП — по ставкам
    plan = [] if retire_only else month_plan(target, pid_for, prior_facts)
    ex = existing_sync(all_out, target)
    if not retire_only:
        psum = sum(o[3] for o in plan)
        n_pid = sum(1 for o in plan if o[6])
        lines.append(f"\n<b>Создать план на {target}: {len(plan)} шт · {money(psum)}</b>"
                     + (f"  (пересоздать, было {len(ex)})" if ex else "")
                     + f"\n<i>ЗП-планок с контрагентом: {n_pid}</i>")
        from collections import defaultdict
        by = defaultdict(float)
        for slug, desc, cat, amt, dt, mb, pid in plan:
            by[cats.get(cat, "")] += amt
        for c, v in sorted(by.items(), key=lambda z: -z[1]):
            lines.append(f"— {c[:34]}: {money(v)}")

        # откуда взяты суммы: постоянные планки, где сумма подтянута из факта (видны дельты к конфигу)
        fact_rows = fixed_proposal_rows(target, prior_mm, pid_for, prior_facts)
        if fact_rows:
            lines.append(f"\n<b>Суммы по факту {RU_MONTH_GEN[int(prior_mm[:2])]} (было в конфиге):</b>")
            for r in fact_rows:
                lines.append(f"— {r}")
        # сверка ставок ЗП с фактом (ставки НЕ меняем, только сигналим)
        warns = zp_rate_warnings(int(target[3:]), int(target[:2]), prior_mm, prior_facts, pid_for)
        if warns:
            lines.append(f"\n<b>Проверить ставки pay_config (факт разошёлся более 10%):</b>")
            for w in warns:
                lines.append(f"— {w}")

    no_end = credits_without_end()
    if no_end and not retire_only:
        lines.append("\n⚠️ <b>Кредиты без даты окончания</b> (планируются бессрочно, уточни срок): "
                     + "; ".join(no_end))
    lines.append("\n<i>Событий Оли тут нет — заносятся из её таблицы отдельно.</i>")
    if not commit:
        lines.append("\n⚠️ Это предложение (dry-run). Скажи «давай» — выполню (--commit).")
    text = "\n".join(lines)

    if send:
        tok = os.environ.get("TELEGRAM_BOT_TOKEN"); chat = os.environ.get("TELEGRAM_CHAT_ID")
        data = urllib.parse.urlencode({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode()
        urllib.request.urlopen(f"https://api.telegram.org/bot{tok}/sendMessage", data=data, timeout=20)
    print(text.replace("<b>", "").replace("</b>", "").replace("<i>", "").replace("</i>", ""))

    if commit:
        # ЖУРНАЛ до любых мутаций: полные копии удаляемого + план создаваемого.
        # Если упадём посреди коммита — из журнала видно, что восстановить.
        os.makedirs(os.path.join(os.path.dirname(os.path.abspath(__file__)), "exports"), exist_ok=True)
        jpath = os.path.join(os.path.dirname(os.path.abspath(__file__)), "exports",
                             f"plansync_journal_{today.strftime('%Y%m%d')}_{target.replace('.','-')}.json")
        with open(jpath, "w", encoding="utf-8") as jf:
            json.dump({
                "retire_deleted": [p for p, _ in retire] if not build_only else [],
                "rebuild_deleted": ex if not retire_only else [],
                "to_create": [dict(slug=s, desc=d2, categoryId=c, value=a, date=dt.strftime("%d.%m.%Y"), moneybagId=mb, partnerId=pid)
                              for s, d2, c, a, dt, mb, pid in plan] if not retire_only else [],
            }, jf, ensure_ascii=False, indent=1)
        n_del = n_new = 0
        if not build_only:
            for p, _ in retire:
                ftreq("DELETE", f"/v1/transaction/{p['id']}")
                n_del += 1
        if not retire_only:
            retired_ids = {p["id"] for p, _ in retire}
            for t in ex:                       # пересобираем месяц под sync
                if t["id"] in retired_ids:
                    continue
                ftreq("DELETE", f"/v1/transaction/{t['id']}")
            for slug, desc, cat, amt, dt, mb, pid in plan:
                body = dict(
                    value=amt, group="outcome", moneybagId=mb, date=dt.strftime("%d.%m.%Y"),
                    factMonth=target, categoryId=cat, isPlan=1, description=desc,
                    externalId=f"plansync:sync:{target}:{slug}")
                if pid:
                    body["partnerId"] = pid       # чтобы report матчил план↔факт по контрагенту
                ftreq("POST", "/v1/transaction", body)
                n_new += 1
        print(f"\n✅ ВЫПОЛНЕНО: удалено отработавших {n_del}, пересоздано план {target}: {n_new}")
        print(f"журнал (для восстановления): {jpath}")


if __name__ == "__main__":
    main()
