Категорийные конверты: раскладка прихода по истории трат (ADR-008) - #62
Merged
Conversation
Миграция 00017: budget_envelope_share (доли раскладки прихода, суммы в THB), budget_envelope_share_category (двойной ключ: category_id + нормализованное имя), budget_envelope_limit_override (ручные лимиты, живут между приходами). UNIQUE(envelope_id, name) — структурный инвариант: имя доли служит ключом переноса накоплений между приходами, дубликат делает перенос неоднозначным. Стор: CreateShares (одной транзакцией с категориями), ListShares (chat-scope через join на budget_envelope), SetOverride/ListOverrides/DeleteOverride, ResolveShare (category_id -> lower(name) -> fallback «прочее»).
…енарии Решения зафиксированы до кода: - концепт share (budget_envelope_share*), Bucket из дайджеста не трогаем - лимиты назначает система по прогнозу переменных трат, override переживает пересчёт - делится FreeAfterObligations; инвариант сходимости только на allocated - recurring исключается из факта доли; остаток доли не хранится - ключ категории id + lower(name), fallback-доля «прочее» обязательна - carry-over по имени для kind='save', period_end := now − 1 день при закрытии golden_set: r043–r047 (раскладка, статус, override, контраст add_expense). r043–r046 краснеют до реализации — ожидаемый TDD-красный, срез: -tag envelope_shares.
Чистая функция allocateShares: базовые лимиты из прогноза трат (переиспользует buildForecastBreakdown), мелочь и категории без истории — в «прочее», override поверх авто, пропорциональное усечение под свободный остаток, непокрытое — в «накопления». Инвариант: Σ Allocated + свободно = free копейка в копейку. Доли трат округляются до копейки, точный остаток забирают «накопления»; перебор округления снимается с крупнейшей доли и только в пределах копейки на долю — больший перебор означает несработавшее усечение и маскировать его нельзя. Пороги (3% от свободного, 2 месяца истории) и имя доли-накопления — в config.go.
Конверт заводится сразу с раскладкой (ADR-008): снапшот и прогноз → computeSafeToSpend → FreeAfterObligations → allocateShares с override'ами и реальной глубиной истории из БД. Конверт и доли пишутся одной транзакцией (CreateEnvelopeWithShares): конверт без раскладки — состояние, в котором трате некуда падать (ResolveShare вернёт nil), а «сколько осталось в конвертах» отвечать нечем. Новое в сторе: - CreateEnvelopeWithShares + вынесенные insertEnvelopeTx/insertSharesTx; - CategoryHistoryMonths — полных месяцев истории по категории; окно то же, что у GetForecastData, текущий (неполный) месяц исключён. Без неё allocateShares по пустой карте не назначил бы ни одного лимита; - SpentOutsideShares — факт «вне конвертов»: фиксированные категории + переменные recurring (ADR-008 §4/§5). Фикс в аллокаторе: категория, буквально названная «прочее», и регистровые дубли категорий порождали ВТОРУЮ долю с тем же именем — на сборке одна затирала другую, но её сумма оставалась в Σ allocated, и инвариант Σ allocated + свободно = free не сходился (на живом прогоне утекало 5581 ₽). Теперь такие черновики сливаются. Форматтер FormatEnvelopePlan: приход, обязательства, «к раскладке», конверты с лимитами, «свободно», «вне конвертов», warnings. Обрезка советов LLM — по рунам, а не по байтам (резала кириллицу пополам).
set_share_limit / clear_share_limit в BudgetSkill: «на еду хватит 15000» сохраняет ручной лимит доли (SetOverride) и тут же пересчитывает раскладку активного конверта, «убери лимит на еду» снимает его (DeleteOverride). Правка — правило, а не разовая подмена: она переживает конверт и применяется к каждому следующему приходу, пока её не сняли (ADR-008 §2). Пересчёт идёт тем же путём, что и start_envelope (PlanEnvelope + shareOverrides + attachCategoryIDs), а доли переписываются новым Store.ReplaceShares — delete и insert в одной транзакции: между ними у конверта нет ни одной доли, и трате некуда падать. carried_in переносится со старых долей по имени: перенос с прошлого конверта — факт прошлого периода, пересчёт лимита не вправе его обнулить. Триггеры правки живут в блоке ROUTING RULES (router.go), а не только в Manifest: блок объявлен приоритетнее описаний инструментов, и разграничение «на еду хватит 15000» (лимит) vs «купил еды на 3000» (трата) фразой в Manifest не чинится. Там же разведены «хватит ли на <покупку>?» → advisor.advice и вопрос без суммы («сколько откладывать на машину») → advisor.advice. Заодно закрыт r043: «пришло 127000, разложи по конвертам» уходил в safe_to_spend, потому что «разложи» не было ничьим триггером — добавлено в start_envelope и явно отбито в манифесте safe_to_spend. Golden (DeepSeek, боевой ключ): 46/47, было 43/47. Позеленели r043, r045, r046; r047 (контраст «купил еды на 3000» → add_expense) остаётся зелёным. С r043/r045/ r046 снят тег pending_impl. Единственный красный — r021, mixed-intent кейс: модель отдаёт оба вызова, харнес смотрит только первый; на трёх отдельных прогонах кейс зелёный.
Остаток доли не хранится: allocated + carried_in − факт по её категориям за период конверта. Факт берётся из нового SpentByCategoryExcludingRecurring — транзакции с recurring_id отсекаются в SQL, они уже вычтены как обязательства (двойной учёт, ADR-008 §5). consumptionSpentTHB не тронут: у него свой набор категорий и свой режим остатка ADR-007. Режим конвертов включается вопросом без суммы («сколько в конвертах», «сколько осталось на еду») — скилл read-only, ничего не пишет.
…(ADR-008 §9, §10) Перед записью нового конверта остаток прошлого считается тем же computeShareRemaining, что и ответ «сколько осталось»: доли kind=save переносят его в carried_in одноимённой новой доли, kind=spend обнуляются явно, исчезнувшая save-доля создаётся с allocated=0. Прошлый конверт закрывается period_end := now − 1 день (обрезка до period_start, истёкший не удлиняется) в той же транзакции, что и создание нового: даты DATE и фильтры включительные, при period_end = now трата дня переключения попала бы в оба конверта.
add_expense после записи транзакции находит долю активного конверта, владеющую категорией траты (ResolveShare: id → lower(name) → «прочее»), и добавляет строку о пробое поверх обычного ответа. Остаток берётся из computeShareRemaining через safetospend.ShareRemainingFor — формула ADR-008 §8 не дублируется. Транзакция не откатывается и не блокируется: нет конверта, нет курса или ошибка чтения → обычный ответ, ошибка только в лог. Ради проверяемости этого инварианта чтение конверта вынесено в узкий интерфейс shareWarningStore.
Пересчёт конверта под set_share_limit/clear_share_limit восстанавливал carried_in только у долей, которые есть в свежей раскладке. Доля-носитель переноса («Отпуск», выпавший из авто-раскладки) в PlanEnvelope не приходит никогда — ReplaceShares выносил её вместе с накопленным, молча. Механизм сведён к одному: логика «положить накопленное, воссоздав долю, если её в раскладке нет» вынесена из CarryOver в safetospend.ApplyCarry, и правка лимита кладёт carried_in тем же путём, что и заведение нового конверта. Ошибка чтения старых долей теперь не проглатывается: пересчёт без них затёр бы деньги, а сам лимит уже сохранён и применится при следующем приходе. Заодно: - runShares обрезает окно факта не только сверху, но и снизу по period_start — конверт с периодом в будущем отдавал «Временную ошибку» вместо статуса; - ключ нормализации имени один на проект: budget.NormalizeName, через него ходят и safetospend.normalizeShareName, и attachCategoryIDs.
Валюта показа конвертов отделена от хранения: доли по-прежнему живут в THB (ADR-008 §7), конвертация происходит только на последнем шаге перед печатью. По умолчанию конверты печатаются батами, «в рублях» — по просьбе оператора. Переведены на Display: start_envelope, статус конвертов, предупреждение о пробитом конверте, строка переноса, пересчёт лимита. Лимит словами принимается и в рублях, и в батах — валюта суммы доезжает в currency и конвертируется ровно один раз, в overridesToTHB при раскладке. Правило про валюту добавлено в ROUTING RULES рядом с существующими: не весь роутинг задаётся Manifest'ом, и правка только манифеста была бы перебита хардкодом роутера.
Два дефекта из code-review. NewBudgetSkill(nil) клал nil-указатель в интерфейсное поле shareStore — получался typed nil, guard `st == nil` не срабатывал и первый же вызов стора паниковал (так скилл создаёт evals/cmd/routing). Теперь поле присваивается только при непустом сторе. Трата в НЕ-переменной категории («Переводы», «Кредит», «Аренда») и трата с recurring_id исключены из факта доли (ADR-008 §4, §5), но всё равно маршрутизировались в fallback-долю «прочее» и печатали чужой пробой. Теперь такие траты предупреждения не порождают.
Расписание живёт в той же строке budget_reminder, что и вечернее напоминание: у чата один часовой пояс, но два независимых включения — конверты по утрам можно хотеть без напоминания вечером. Включение и выключение словами разбирается детерминированно, до LLM: ошибка маршрутизации здесь молчаливая — оператор считал бы рассылку включённой, пока месяц не приходило ни одного пуша. Тело печатает существующий показ конвертов (safe_to_spend); формат воркер не дублирует. Активного конверта нет — пуша нет.
….10) Три регрессии, вскрытые прогоном на реплике 2026-08-24. 1. carried_in множился при повторной раскладке того же прихода (0 -> 26681 -> 53362 -> 65335 THB). Конверт, заведённый и закрытый в один день, не прожил ничего: его allocated профинансирован тем же приходом, который в этот момент раскладывается заново. Такой конверт теперь переносит только собственный carried_in (CarryInput.PrevSuperseded). 2. Категории нестабильно пропадали: планка схлопывания мелочи считалась как доля свободных денег, free двигался вместе с обязательствами, и «Транспорт 985» перепрыгивал её туда-обратно. Планка стала абсолютной (minShareMonthlyTHB, пропорционально длине периода). 3. «Два active=true конверта» оказались конвертами РАЗНЫХ чатов — по ADR-004 это норма, а правило «один активный на чат» держит индекс с 00016. Теста на него не было — добавлен; GetActiveEnvelope получил детерминированный порядок вместо LIMIT 1 наугад. Инвариант «Σ allocated + Σ carried_in <= приход + реальный остаток прошлого периода» заведён тестом. Golden-кейсы r050–r052.
Оператор разнёс прежний ответ и утвердил новый. Меняется не только печать. Регулярные платежи перестают быть скрытым вычетом и становятся видимыми конвертами kind='fixed' — каждый своей строкой, с настоящим именем из budget_recurring и датой платежа. Деления трат на «обязательные» и «на жизнь» больше нет: один список. Платёж за границей периода (аренда 10.09 при периоде до 06.09) всё равно откладывается сейчас — окно финансирования месяц вперёд, а не период конверта. Дневной лимит: гибкие конверты / оставшиеся дни. Приход в формуле не участвует, поэтому работает и при приходе в 10 рублей, и при нулевом. Пересчитывается от остатка при каждом показе конвертов. Строка «вне конвертов» убрана — она показывала 0 при аренде 18000 за период, потому что считала факт прошедших трат, а читалась как «что предстоит». Печать: обычный текст + один моноблок шириной 32 знака, две с половиной колонки, ни одного эмодзи внутри pre, разряды через пробел, итог сходится с приходом до бата. Одиннадцать строк «мало данных» свёрнуты в одну. Горизонт «2 недели» стал ровно 14 днями (был 15 — граница включительная). Миграция 00018: kind='fixed' и due_date. ADR-008 дополнен поправкой.
Моноблок раскладки конвертов доезжает настоящим <pre>, заголовки — <b>. parse_mode=HTML, а не MarkdownV2: в MarkdownV2 экранирования требуют 18 символов, включая точку и минус — они есть в каждой дате и сумме, а часть текста пишет LLM. Пропуск одного символа — 400 от Telegram и ноль сообщений у пользователя. В HTML опасных символов три. Разметка включается в единственной точке отправки каждого канала: адаптер и утренний пуш. Длинный текст режется по лимиту, разрез закрывает и переоткрывает pre. Если Telegram всё же ответил ошибкой и не ушло ничего — текст уходит простым: молчание бота хуже звёздочек в чате.
Незавершённый форматтер показа не компилировался: дубль dayStart, отсутствующие shareLabelWidth и scopeSentence, разошедшаяся сигнатура вызова из remaining.go. Ужатие имени под ширину колонки и предложение о составе лимита вынесены общими для раскладки прихода и показа — два ответа об одних и тех же деньгах обязаны звучать одинаково. Тесты подтянуты к новому формату. Разбор колонок моноблока шёл по strings.Fields и разваливал числа с разрядами через пробел — теперь по границам колонок. Проверка «нет чужого знака валюты» больше не спотыкается о строку курса, которая печатает оба знака по определению.
Две миграции с версией 00018 (утренний пуш и kind='fixed') — goose ключует применённые по номеру, вторая молча не применилась бы. Раскладка kind='fixed' опирается на схему долей из 00017, напоминание независимо, поэтому в 00019 уехала именно она; в down добавлены IF EXISTS. ApplyEnvelopeCommand считал любую ошибку GetReminder отсутствием строки и писал поверх дефолты — моргнувшая БД стирала вечернее напоминание. Теперь дефолты только на ErrNoRows, остальное — ошибка без записи. EditWithButtons снова умеет снимать клавиатуру (пустой не-nil набор рядов) и не выглядит инверсией: явный if err != nil с фоллбэком на простой текст.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Приход раскладывается по конвертам, посчитанным из истории трат. Обязательные платежи видны отдельными строками с датами, дневной лимит пересчитывается после каждой траты, утренний пуш с конвертами включается словами.
Миграции 00017-00019 применятся при старте.
ADR-008.