590 lines
64 KiB
Markdown
590 lines
64 KiB
Markdown
---
|
||
name: budget-current-plan
|
||
description: Актуальный бизнес-процесс фичи «Бюджет» — версия после полного сброса проекта (см. ниже "Второй урок"); не путать со старым бюджет_проект_план.md (2006 год, источник идей, не требований)
|
||
metadata:
|
||
type: project
|
||
---
|
||
|
||
Старый файл `бюджет_проект_план.md` — заметки 2006 года, источник идей, не требований. Этот файл —
|
||
то, что реально подтверждено в разговоре. **Актуальна только эта версия** — предыдущая версия этого
|
||
же файла (обсуждение копилок/целей ДО того, как весь проект снесли и начали заново) частично устарела,
|
||
см. "Второй урок" ниже про то, почему.
|
||
|
||
## Продукт
|
||
|
||
Личный (не мульти-пользовательский) трекер бюджета — один человек ведёт свой бюджет. `users` всё же
|
||
нужна (не совсем однопользовательский в смысле схемы БД) — счета/категории/транзакции ссылаются на
|
||
`user_id`, без этого пришлось бы возвращаться к вопросу при первом же втором пользователе.
|
||
|
||
## Users
|
||
|
||
`id`, `login`, `password`, `name`, `email`, `date_add`, `last_login`. Пока достаточно.
|
||
|
||
## Мультипользовательский режим — согласованный дизайн (гипотетика, НЕ в текущем приоритете, не кодировать)
|
||
|
||
Обсуждали отдельно от основного скоупа, пользователь попросил зафиксировать. Два независимых механизма,
|
||
оба дёшевы и не требуют объединять/сверять чужие бюджеты между собой:
|
||
|
||
1. **Изолированные бюджеты (например «я» и «сын»)** — ничего доделывать не нужно, уже работает: каждый
|
||
`users.id` — это и есть владелец полностью независимого бюджета, `accounts`/`categories`/
|
||
`transactions`/`budgets`/`goals` уже фильтруются по своему `user_id` (см. "Продукт" выше — заложено
|
||
с самого начала на этот случай). Один бюджет никак не видит другой, ничего сверять/делить не нужно
|
||
(в отличие от гипотезы про «общие расходы между раздельными бюджетами», которую обсуждали и
|
||
отбросили — пользователь явно сказал не думать про дележ ЖКХ/общих счетов).
|
||
|
||
2. **Viewer/editor доступ к ЧУЖОМУ бюджету (например жена смотрит бюджет мужа)** — новая маленькая
|
||
таблица-разрешение, не полноценная система приглашений:
|
||
```sql
|
||
CREATE TABLE budget_access (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
owner_user_id INT UNSIGNED NOT NULL, -- чей бюджет (users.id)
|
||
viewer_login VARCHAR(64) NOT NULL, -- логин из Services\Auth — кого пускаем
|
||
role ENUM('viewer','editor') NOT NULL DEFAULT 'viewer',
|
||
FOREIGN KEY (owner_user_id) REFERENCES users (id)
|
||
);
|
||
```
|
||
Логика: человек логинится через `Services\Auth` (уже поддерживает несколько логинов). Приложение
|
||
смотрит — есть ли у него свой `users.id` (владеет своим бюджетом), и есть ли записи в `budget_access`
|
||
по его `login` (доступ к чужим). Оба факта независимы и совместимы одновременно: можно и владеть
|
||
своим бюджетом, и иметь viewer/editor-доступ к чужому сразу — это НЕ взаимоисключающие случаи.
|
||
|
||
**Следствие для UI**: если у залогиненного человека доступ больше чем к одному бюджету (свой + чужой
|
||
через `budget_access`), нужен переключатель «чей бюджет сейчас смотрим».
|
||
|
||
**Точечная видимость внутри доступа (например скрыть «Подарки», чтобы не спалить сюрприз жене)** —
|
||
блок-лист поверх `budget_access`, не allow-лист (по умолчанию viewer видит всё разрешённое, точечно
|
||
скрываем конкретные счета/категории, а не наоборот — перечислять вручную всё видимое было бы утомительно):
|
||
```sql
|
||
CREATE TABLE budget_access_restrictions (
|
||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||
budget_access_id INT UNSIGNED NOT NULL, -- к какому разрешению (budget_access) относится
|
||
entity_type ENUM('account', 'category') NOT NULL,
|
||
entity_id INT UNSIGNED NOT NULL, -- id скрываемого счёта или категории
|
||
FOREIGN KEY (budget_access_id) REFERENCES budget_access (id)
|
||
);
|
||
```
|
||
Скрытие категории должно прятать и связанные с ней транзакции/план-факт в отчётах viewer'а (не
|
||
спроектировано, как именно — фильтровать на уровне запроса или на уровне рендера).
|
||
|
||
**Решено, с примером.** У viewer'а те же две цифры, что у владельца («Доступно сейчас» и «Всего по
|
||
всем счетам»), но пересчитанные только по счетам, которые ему разрешены (не заблокированы через
|
||
`budget_access_restrictions`). Два независимых уровня фильтрации, не путать:
|
||
1. **`include_in_total`** — решает владелец, на каждом своём счёте: считать ли его в СВОЁ «Доступно
|
||
сейчас» (кошелёк/карта — да, копилка — обычно нет).
|
||
2. **Доступ viewer'а** (`budget_access_restrictions`) — более грубый фильтр: какие счета viewer вообще
|
||
видит. Скрытый от viewer'а счёт не участвует ни в одной из его двух сумм, независимо от того, какая
|
||
у этого счёта галочка `include_in_total` у владельца.
|
||
|
||
Пример: Лена смотрит бюджет Егора, но копилка «Остаток» от неё скрыта. У Лены «Доступно сейчас» =
|
||
кошелёк+карта (то же, что и у Егора, минус «Остаток», которого она вообще не видит), «Всего по всем
|
||
счетам» — тоже без «Остатка». Скрытый счёт просто не участвует в подсчёте — не до конца проговорено,
|
||
нужно ли отдельно прятать сам факт его существования (не только сумму), и блок-лист это или allow-лист
|
||
по ощущению пользователя — переспросить при реализации.
|
||
|
||
**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной)
|
||
становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается
|
||
в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое
|
||
всплывёт, только если реально начнём кодировать мультипользовательский режим.
|
||
|
||
**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной)
|
||
становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается
|
||
в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое
|
||
всплывёт, только если реально начнём кодировать мультипользовательский режим.
|
||
|
||
## Accounts (счета)
|
||
|
||
**Один пользователь приложения может вести несколько реальных счетов, включая счета, номинально
|
||
оформленные на других членов семьи** (пример: «мой счёт», «счёт жены») — это не мультипользовательская
|
||
функция и не требует отдельного `user_id` на счёт: все они просто разные строки `accounts` с разными
|
||
`title`, привязанные к одному `user_id` (см. "Продукт" выше). Различать их — по `title`, не по схеме.
|
||
|
||
**Будущие типы счёта под инвестиции (не сейчас, см. "Приоритет фич" → Инвестиции)** — уточнено, это
|
||
два разных счёта, не один:
|
||
- **Брокерский** — акции, облигации, фонды, фьючерсы.
|
||
- **ЦФА** (цифровые финансовые активы) — долговые, ноты, картины (NFT-подобное), крипто.
|
||
|
||
Оба не подходят ни под `cash` (не наличные), ни под `bank` (не просто расчётный счёт), ни под `savings`
|
||
(не вклад под процент, а портфель с рыночной переоценкой, не фиксированной ставкой). **Как именно с ними
|
||
работать (отдельные позиции/тикеры внутри счёта, переоценка, дивиденды/купоны, transfer при
|
||
покупке/продаже) — пользователь явно попросил отложить обсуждение, не проектировать сейчас вообще**,
|
||
даже на уровне схемы `accounts`/`type_acc`. Сейчас — только основной бюджет (cash/bank/savings).
|
||
|
||
Три типа `type_acc` (актуальные, реализовано):
|
||
- **`cash`** — кошелёк (наличные).
|
||
- **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это
|
||
сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/
|
||
"е-money" и т.п., это НЕ подтверждено заново после сброса, убрано).
|
||
- **`savings`** — копилка: тот же счёт, но с процентом на остаток (история ставок — отдельная таблица,
|
||
привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals`
|
||
нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты.
|
||
|
||
Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс, вводится как есть при создании счёта —
|
||
любая сумма и своя валюта, не обязательно с нуля), `currency` (при создании счёта, вместе с суммой),
|
||
`description` (необязательное свободное поле), `is_delete` (архивация вместо удаления), `order`,
|
||
**`include_in_total`** (bool) — счёт учитывается в общей сумме на дашборде или нет; по умолчанию `true`
|
||
для `cash`/`bank`, `false` для `savings` — но это **чисто ручной переключатель пользователя, не
|
||
системное правило по типу/валюте счёта**. Подтверждено явно: то, что валютный счёт «Евро» был
|
||
исключён из «Доступно сейчас» на макете — не потому что валютные счета по умолчанию исключаются, а
|
||
потому что пользователь лично выбрал не включать именно этот счёт. Дефолт (true для cash/bank, false
|
||
для savings) — просто стартовое значение при создании счёта, дальше пользователь сам решает по каждому.
|
||
|
||
**Счёт = title + summa + currency (+ description)** — минимальный набор полей, ничего структурного
|
||
сверху (например «принадлежит члену семьи X») не нужно — если понадобится пометить «это счёт жены»,
|
||
это просто текст в `title`/`description`, не отдельное поле.
|
||
|
||
**Уход в минус — невозможен для `cash`/`bank`/`savings`.** Овердрафт не подключается и не планируется;
|
||
наличным взяться неоткуда, если их нет. Если пользователь тратит больше, чем есть на счету — это
|
||
концептуально уже кредит (транзакция «с кредита»), а не отрицательный баланс обычного счёта. Кредиты
|
||
по-прежнему вне скоупа (см. "Приоритет фич"), но когда до них дойдём — они не про минус на обычном
|
||
счёте, а про отдельный тип счёта/операции.
|
||
|
||
**Копилка/цель — намеренно тот же счёт, не отдельная сущность.** Пользователь явно подтвердил принцип
|
||
«не плодить сущности»: `savings` — это `accounts` с типом + история ставок (`account_rates`) + опционально
|
||
`goals` (цель) сверху, а не отдельная параллельная таблица «копилки». Это уже так и спроектировано —
|
||
просто явное подтверждение архитектурного выбора.
|
||
|
||
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
|
||
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
|
||
есть и он надёжный, незачем дублировать руками).
|
||
|
||
**Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET,
|
||
плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`)
|
||
рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только
|
||
`Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна).
|
||
|
||
Формат ответа (проверено на реальном запросе):
|
||
```xml
|
||
<ValCurs Date="11.08.2026" name="Foreign Currency Market">
|
||
<Valute ID="R01235">
|
||
<NumCode>840</NumCode>
|
||
<CharCode>USD</CharCode>
|
||
<Nominal>1</Nominal>
|
||
<Name>Доллар США</Name>
|
||
<Value>82,6060</Value>
|
||
<VunitRate>82,606</VunitRate>
|
||
</Valute>
|
||
...
|
||
</ValCurs>
|
||
```
|
||
**Подводные камни при парсинге:**
|
||
- Кодировка ответа — `windows-1251`, не UTF-8 (проект — `utf8mb4`), нужна конвертация.
|
||
- Разделитель дробной части в `Value`/`VunitRate` — запятая, не точка (`82,6060`) — `str_replace(',', '.', …)`
|
||
перед `(float)`.
|
||
- Нужен `VunitRate` (курс за 1 единицу валюты), не `Value` — у некоторых валют `Nominal` не 1 (например
|
||
100 йен), `Value` — курс за весь номинал, не за единицу; `rate_to_rub`/курс на дашборде — всегда за
|
||
единицу.
|
||
- `CharCode` — это и есть ISO-код, совпадает с форматом `accounts.currency`/`transactions.currency` (`CHAR(3)`).
|
||
|
||
Клиент — новый `Services\CurrencyRate` (по аналогии с `Services\Elasticsearch`/`Services\Mongo` — точка
|
||
входа + REST/HTTP-клиент), поверх уже существующего `System\Classes\HTTP\Client\Curl`. Дашборд
|
||
показывает курс для валют, которые реально встречаются среди счетов пользователя (не весь список валют
|
||
ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее значение» с кэшем на сутки, историю хранить
|
||
для этого не нужно** (история нужна в другом месте, см. ниже).
|
||
|
||
**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`,
|
||
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
|
||
(остальные конвертируются в неё для складывания в одну сумму).
|
||
|
||
**Курс валюты на операции — фиксируется прямо в транзакции, не отдельной историей.** Возникло из
|
||
вопроса «отчёт по Отпуску, если траты в разных валютах» — решено, что в момент создания расходной
|
||
операции по валютному счёту курс ЦБ РФ на этот день записывается прямо в саму транзакцию и остаётся
|
||
неизменным навсегда (так же, как в реальных банковских FX-операциях — курс фиксируется в моменте, а не
|
||
ищется задним числом). Отдельная таблица `currency_rates` с историей по датам **не нужна** — это было
|
||
более сложное первое решение, отменено в пользу этого:
|
||
```sql
|
||
ALTER TABLE transactions ADD COLUMN currency CHAR(3) NULL; -- валюта операции (обычно = валюте счёта списания)
|
||
ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс ЦБ РФ на момент операции; NULL для рублёвых
|
||
```
|
||
Отчёт за период (например «Отпуск» за произвольные даты, не обязательно календарный месяц) суммирует
|
||
`summa * COALESCE(rate_to_rub, 1)` по нужным транзакциям — без join'ов и без логики «на эту дату курса
|
||
нет, берём ближайший». Период отчёта по категории — отдельная фича поверх `transactions`, не привязана
|
||
к месячной сетке `budgets` (та остаётся по месяцам, это для другого — план/факт, не разовые отчёты).
|
||
|
||
## Categories (статьи) — с подкатегориями
|
||
|
||
`id`, `user_id`, `parent_id` (null — категория верхнего уровня; иначе — id родителя), `title`, `type`
|
||
(`income`/`expense`), `order`, **`is_delete`** (архивация вместо удаления — тот же приём, что уже
|
||
есть у `accounts`; подтверждено: если по статье есть история транзакций, просто скрывать её из выбора
|
||
при вводе новой операции, не удалять физически).
|
||
|
||
Пример: «Продукты» — родительская категория, «Алкоголь» — подкатегория внутри неё. Транзакция
|
||
привязывается к конкретной (под)категории. В отчёте можно смотреть по родителю целиком (сумма всех
|
||
подкатегорий) или провалиться в конкретную подкатегорию отдельно.
|
||
|
||
**Защита от дублирования статей — подтверждено, нужно.** Два сценария:
|
||
1. Заархивировали статью, забыли, завели новую с тем же названием — история разъезжается на две части.
|
||
2. Опечатка/невнимательность — завели одну и ту же статью дважды по рассеянности.
|
||
|
||
Решение для обоих — на уровне приложения, не БД-constraint: при создании/переименовании статьи
|
||
проверять, нет ли уже **активной** (`is_delete = 0`) статьи с таким же `title` у **этого же
|
||
`user_id`** (в рамках типа доход/расход и той же родительской группы) — если есть, не давать создать
|
||
копию. Если находится **архивная** статья с тем же именем — предлагать восстановить её
|
||
(`is_delete = 0`) вместо создания новой.
|
||
|
||
**Важно: проверка скоуплена по `user_id`, не глобально.** Если у двух разных (изолированных)
|
||
пользователей — например Егора и сына — у каждого своя статья «Питание», это два независимых ряда в
|
||
`categories` с одинаковым текстом в `title` и это нормально, конфликта нет — проверка сработала бы
|
||
только если ОДИН И ТОТ ЖЕ пользователь попытался завести такую статью у себя дважды. Изоляция
|
||
пользователей (см. раздел про мультипользовательский режим) и так гарантирует, что один вообще не
|
||
видит статьи другого — здесь ничего дополнительно обеспечивать не нужно.
|
||
|
||
**Группировка — целиком в руках пользователя, через `parent_id`, никак не трогает историю.**
|
||
Пользователь явно подтвердил это как требование: перекинул статью в другую группу (сменил `parent_id`)
|
||
— старые `transactions` не меняются (они ссылаются на `categorie_id` листа, не на группу), просто
|
||
отчёты/дашборд на лету пересчитываются по текущей группировке при следующем открытии. Схему менять не
|
||
пришлось — `parent_id` уже это поддерживает.
|
||
|
||
**Реальный список статей пользователя** (из его старой таблицы, не придумано мной) —
|
||
Доход: Зарплата Егор, Зарплата Лена, Квартира, Другие доходы.
|
||
Расход: 10%, К, Питание, Хозтовары, ЖКХ, ЖКХ Люберцы, Авто+Бензин, Проезд, Связь+Инет, Школа, Садик,
|
||
Дети, Медицина, Одежда, Личные расходы, Развлечение, Подарки, Парик/Красота, Отпуск, Хобби,
|
||
Образование, Спорт, Дом, Непредв. расходы, Непонятные расходы.
|
||
|
||
Расшифровка непрозрачных названий:
|
||
- **«К»** — Кирилл (сын). Инвестиция на его счёт, вносится один раз в начале года всей суммой
|
||
(сложный процент выгоднее, чем вносить помесячно), но в плане (`budgets.plan_summ`) размазывается
|
||
по 3 000 ₽/мес. Следствие: факт в январе резко подскочит (реальная транзакция ~36 000 ₽), с февраля
|
||
по декабрь факт будет 0 — это ожидаемо, не баг.
|
||
- **«10%»** — отчисление 10% от зарплаты в копилку «Магнит», позже инвестируется. Та же природа, что
|
||
и «К» — плановая регулярная строка поверх нерегулярных реальных движений денег.
|
||
- **«ЖКХ» / «ЖКХ Люберцы»** — коммуналка по двум разным квартирам (не первая — родитель, вторая —
|
||
подкатегория; это две равноправные отдельные статьи, обе можно сгруппировать вместе, если нужно).
|
||
|
||
Группировка (пример, обсуждали при показе макета категорий) — пользователь дал 5 групп-примеров:
|
||
«Основные» (ЖКХ, Питание, Связь), «Подписки» (Клод, ЧатGPT — новые примеры, не было в старом списке),
|
||
«Дети» (Биба, Боба — тоже новые имена, не факт что совпадают с «Дети»/«Школа»/«Садик»/«К»), «Транспорт»
|
||
(Бензин, Штрафы, Авто, Платные дороги), «Медицина» (Аптека, Лечение). Остальные статьи из старого
|
||
списка пользователь осознанно оставил без группы — сам разберёт в приложении, не наша забота гадать.
|
||
|
||
## Transactions
|
||
|
||
`id`, `user_id`, `categorie_id`, `account_f_id` (откуда), `account_in_id` (куда), `date`, `summa`,
|
||
`type` (`income`/`expense`/`transfer`). Для дохода — только `account_in_id`, для расхода — только
|
||
`account_f_id`, для перевода — оба.
|
||
|
||
**Все transfer — без категории, без исключений**, в том числе пополнение savings-счёта/копилки/цели:
|
||
переложил деньги в копилку «Отпуск» — это просто перемещение между своими счетами, не потрачено и не
|
||
заработано, `categorie_id = null`. **Решено** (закрывает предыдущий открытый вопрос про задвоение):
|
||
реальный расход считается только тогда, когда деньги действительно потрачены — гостиница, билеты и
|
||
т.п. оформляются обычными `expense`-транзакциями с `account_f_id` = счёт, с которого реально платили
|
||
(в т.ч. со счёта-копилки, если платили прямо с него), и категорией «Отпуск» (точнее — под-категорией
|
||
типа «Билеты»/«Гостиница»). Задвоения нет, потому что откладывание в копилку категорией не помечается
|
||
вовсе — план/факт по категории «Отпуск» видит только реальные траты.
|
||
|
||
**Баланс счёта (`accounts.summa`) — решено, «как в банках».** Не пересчитывать всю историю на каждый
|
||
показ, но и не хранить как самостоятельное число без контроля. Гибрид: `summa` — хранимый кэш, который
|
||
обновляется **атомарно, в той же БД-транзакции**, что и сама операция создания/правки/удаления
|
||
`transactions` (`PdoConnection::transaction()` уже есть для этого) — источник истины всё равно лента
|
||
операций, баланс — материализованный кэш поверх неё для скорости. Дополнительно нужен служебный метод
|
||
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
|
||
при подозрении на расхождение (аналог банковской реконсиляции).
|
||
|
||
**Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.**
|
||
Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна,
|
||
отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых
|
||
строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽
|
||
Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к
|
||
другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью
|
||
это поддерживает как есть, менять её под эту фичу не нужно.
|
||
|
||
**Два способа ввода — оба просто заводят обычные транзакции:**
|
||
1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
|
||
рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
|
||
`transactions`.
|
||
2. **Ручной** — то же самое, но пользователь сам вводит несколько отдельных транзакций подряд.
|
||
**AI-провайдер — решено, Claude API, но с возможностью переключиться (например на ChatGPT).**
|
||
Не хардкодить конкретного вендора — по аналогии со `Services\Auth`/`AuthDriver` (интерфейс + сменный
|
||
драйвер, см. CLAUDE.md → раздел Auth): скорее всего новый `Services\AI`/`AIDriver`-интерфейс,
|
||
`ClaudeAIDriver` как реализация по умолчанию, конкретный провайдер — через конфиг (как
|
||
`Config::get('auth', 'driver')`).
|
||
|
||
**Запись голоса — не наша забота, решено.** Никакого захвата аудио/распознавания речи в приложении
|
||
не нужно — голос в текст переводит штатная голосовая клавиатура Android (диктовка). В приложение
|
||
попадает уже готовый **текст** (обычное текстовое поле, куда пользователь надиктовал через клавиатуру
|
||
телефона) — именно этот текст целиком уходит в AI API на разбор в JSON с транзакциями. Никакой
|
||
интеграции с аудио/speech-to-text на своей стороне не требуется.
|
||
|
||
**Ошибки разбора — через отложенную проверку (tmp-таблица), решено.** Более раннее решение «без
|
||
подтверждения, сохраняем сразу» — **отменено, заменено этим**. Схема:
|
||
1. ИИ разбирает текст диктовки → результат пишется не в `transactions` напрямую, а во временную
|
||
таблицу `transaction_drafts` (имя решено, точная структура полей — нет).
|
||
2. При заходе на дашборд или страницу бюджета — если в этой таблице есть непросмотренные записи,
|
||
всплывает окно с ними.
|
||
3. Пользователь просматривает, при необходимости правит (или отклоняет — не уточнено явно, но
|
||
логично, что можно и удалить черновик, а не только редактировать).
|
||
4. Подтверждённые записи переносятся в `transactions`, черновик по ним удаляется из tmp-таблицы.
|
||
|
||
Не спроектировано: точная схема tmp-таблицы, можно ли отклонить/удалить черновик (не только
|
||
редактировать), что происходит, если пользователь долго не заходит на дашборд/бюджет (черновики
|
||
просто копятся до просмотра — видимо ок, не обсуждали иное).
|
||
|
||
**Промпт с контекстом пользователя — решено, нужно.** В запрос к AI API передаётся не только текст
|
||
диктовки, но и список реальных статей/счетов пользователя (актуальные `categories`/`accounts`) — чтобы
|
||
ИИ подбирал существующие `categorie_id`/`account_id`, а не придумывал названия статей от себя. Точный
|
||
формат промпта/что именно передавать (весь список категорий целиком, или как-то отфильтрованный) —
|
||
не спроектировано, решать при реализации.
|
||
|
||
**Лог правок/удалений — отдельная таблица в БД, структура решена.** Не общий файловый `System\Classes\Log`
|
||
(тот — для аудита запросов вообще, не для точечных «покажи все правки операции №123»). Правка и удаление
|
||
разрешены всегда, без ограничений по давности. **Цель — возможность откатить операцию по логу**, отсюда
|
||
и структура:
|
||
|
||
```sql
|
||
CREATE TABLE transaction_history (
|
||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||
transaction_id INT UNSIGNED NOT NULL,
|
||
user_id INT UNSIGNED NOT NULL,
|
||
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
action ENUM ('create', 'edit', 'delete') NOT NULL,
|
||
snapshot JSON NULL, -- полный слепок transactions на этот момент; NULL для action='delete'
|
||
PRIMARY KEY (id),
|
||
KEY ix_transaction_history_transaction_id (transaction_id)
|
||
);
|
||
```
|
||
|
||
**Без отдельных `old_values`/`new_values` — не нужно дублировать «было».** Каждая запись `create`/`edit`
|
||
хранит цельный снимок состояния операции **после** действия (`snapshot`). Откат — это переход по цепочке
|
||
снимков одной `transaction_id`:
|
||
- откатить `edit` → взять `snapshot` из **предыдущей** записи истории этой же операции;
|
||
- восстановить после `delete` → взять `snapshot` из записи, предшествующей записи об удалении (сама
|
||
запись `delete` данных не хранит, только кто/когда её удалил);
|
||
- откатить `create` → просто удалить операцию, данные снимка не нужны.
|
||
|
||
Собственно механизм отката (кнопка/логика восстановления) — не спроектирован, только структура лога.
|
||
|
||
## Budgets (план/факт)
|
||
|
||
`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как
|
||
сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая
|
||
помеченные transfer на копилки).
|
||
|
||
**Рабочий процесс ввода плана — не помесячно по ходу года, а весь год сразу.** План составляется один
|
||
раз, в конце года, сразу на все 12 месяцев следующего года по каждой статье (пример: в конце 2026-го
|
||
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
|
||
«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью.
|
||
|
||
## Формульные статьи бюджета
|
||
|
||
У статьи дохода (например «ЗП Егор») может быть привязана статья расхода (например «Курица») с
|
||
настраиваемым процентом — **связь настраивается со стороны статьи дохода**, не расхода (пример:
|
||
«ЗП Егор» → «Курица» 10%; «ЗП Лена» → ничего не привязано, это нормально, необязательное поле;
|
||
«Халтура» → «Инвестиции» 15% — независимая от первой связь, свой процент). **Строго один-к-одному**:
|
||
одна статья дохода — максимум одна статья расхода; вешать одну и ту же статью расхода на две статьи
|
||
дохода **не нужно** (сумма нескольких источников не предусмотрена — рассматривали и отклонили).
|
||
|
||
Механика:
|
||
- **Процент — дефолт на статье дохода** (настройка при создании/редактировании категории), не
|
||
разовое значение только на момент ввода.
|
||
- **При вводе годового плана по статье-источнику** (ЗП, 12 месяцев за один заход — см. `Budgets`
|
||
выше) план привязанной статьи расхода автозаполняется = дефолтный процент × план ЗП **для каждого
|
||
месяца отдельно**.
|
||
- **Процент можно менять помесячно** — правится не история/задним числом (в отличие от
|
||
`account_rates`), а сам месяц: меняешь процент для конкретного месяца — пересчитывается план
|
||
только этого месяца, остальные 11 не трогаются. Никакой ретроактивности/периодов действия, как у
|
||
ставки копилки, не нужно — у каждого месяца просто свой процент.
|
||
|
||
**Факт — обычный, не формула.** Формулой автозаполняется только `plan_summ`; факт по такой статье —
|
||
такие же реальные транзакции, как у любой другой статьи расхода, без ограничения по формуле. Можно
|
||
потратить больше (или меньше) рассчитанного плана — это просто обычное расхождение план/факт, ничем
|
||
не отличается от нессылочных статей.
|
||
|
||
**Схема — решено:**
|
||
```sql
|
||
ALTER TABLE categories ADD COLUMN linked_expense_categorie_id INT UNSIGNED NULL;
|
||
ALTER TABLE categories ADD COLUMN formula_pct DECIMAL(5, 2) NULL;
|
||
ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
|
||
FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id);
|
||
```
|
||
Оба поля имеют смысл только у статей дохода (`type = 'income'`) с настроенной связью, у остальных
|
||
`NULL`. В `budgets` — новое поле `formula_pct_used DECIMAL(5, 2) NULL`, хранит, каким процентом
|
||
посчитан план конкретного месяца (нужно для показа/правки в форме плана), `NULL` для обычных
|
||
(нессылочных) строк.
|
||
|
||
**`plan_summ` — хранится как обычно, даже у формульных строк** (как и у обычных статей — план в
|
||
принципе всегда хранимое число в `budgets`, только у формульных он не вводится руками, а
|
||
автоматически пересчитывается и перезаписывается при правке суммы/процента источника, в той же
|
||
транзакции — тот же паттерн кэша, что и `accounts.summa`). Чтение `budgets` одинаково для всех
|
||
строк, формульных и обычных — везде просто число, разница только в том, кто его туда положил.
|
||
|
||
## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ
|
||
|
||
Спрашивал явно — нужна ли фича «повторяющаяся операция», которая сама заводит транзакцию по расписанию
|
||
(для подписок типа Клод/ЧатGPT, коммуналки и т.п.). **Ответ: всё вручную**, автозаведения не нужно.
|
||
Обоснование пользователя: у подписок сумма почти всегда одна и та же, но может повыситься (автоматика
|
||
завела бы неверную сумму молча), а у коммуналки сумма всегда разная (автоматике там вообще нечего
|
||
угадывать) — в обоих случаях ручной ввод надёжнее, чем шаблон "повторить прошлый месяц".
|
||
|
||
## Остаток / Резерв — РЕШЕНО НЕ ДЕЛАТЬ (обсуждали и отменили)
|
||
|
||
Пользователь прислал реальный список категорий из своей старой таблицы, где внизу были строки
|
||
«Остаток», «Ост. с прошлого мес.», «Резерв» — сквозные метрики уровня всего бюджета (не по счёту,
|
||
не по категории). Формула, которую успели согласовать: Остаток(M) = Доход(M) − Расход(M); Резерв(M) =
|
||
Ост. с прошлого мес.(M) + Остаток(M); Ост. с прошлого мес.(M) = Резерв(M−1) — то есть running total
|
||
«Доход минус Расход» с начала учёта.
|
||
|
||
**Но при уточнении пользователь сам решил, что это не нужно** — это был костыль конкретно таблицы,
|
||
у которой не было реальных остатков на счетах: приходилось вручную вычислять «сколько у меня реально
|
||
осталось» через накопительный доход-минус-расход. У нас есть настоящие `accounts.summa` (см. «Доступно
|
||
сейчас» на дашборде — сумма счетов с `include_in_total = true`) — это и есть тот самый ответ на вопрос
|
||
«сколько у меня есть», причём точнее (не зависит от того, залогирована ли вообще каждая транзакция).
|
||
А сравнение план/факт — уже отдельно покрыто таблицей `budgets` по категориям. Резерв как отдельная
|
||
сущность дублировал бы то, что уже есть в двух других местах — **не реализовывать**.
|
||
|
||
## Account_rates (история ставок)
|
||
|
||
`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая).
|
||
|
||
**История (не единственное поле на `accounts`) нужна ради конкретной фичи — узнать, сколько процентов
|
||
начислилось за произвольный выбранный период** (например «сколько капнуло за март»). Если ставка
|
||
внутри периода менялась, расчёт должен пройти по каждому отрезку `account_rates`, пересекающему
|
||
период, со своей ставкой — без истории это невозможно восстановить задним числом. Это и есть
|
||
причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого
|
||
раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но
|
||
раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный).
|
||
|
||
**Период капитализации — день** (решено, актуально и для расчёта начисленных процентов, и для
|
||
формулы прогноза в `Goals` ниже — один и тот же вопрос на обе задачи).
|
||
|
||
**Фактическое начисление процентов на копилку — без автоматического расчёта.** Пользователь сам
|
||
вводит сумму вручную по кнопке «начислить проценты» (см. `Goals` ниже) — система не считает и не
|
||
подставляет её сама. Расчёт «сколько капнуло за период» по дневной ставке (с разбивкой по
|
||
`account_rates`, если ставка менялась внутри периода) нужен только для **прогноза/отчёта**
|
||
(показать пользователю ориентир перед тем, как он введёт сумму сам), не для автозаполнения
|
||
транзакции.
|
||
|
||
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
|
||
план, часть реализована (см. ниже):
|
||
|
||
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых».
|
||
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая
|
||
пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`,
|
||
`valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом
|
||
не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
|
||
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
|
||
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
|
||
дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог
|
||
прислать уведомление заранее/задним числом). Логика: найти текущую строку через
|
||
`AccountRateRepository::getCurrentRate($account_id)`, закрыть её (`valid_to` = день перед новым
|
||
`valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый
|
||
`rate_percent`) — одной транзакцией (`$repo->transaction(...)`).
|
||
- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`.
|
||
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
|
||
протестировано на реальной БД) — `setRate()` ещё не написан.
|
||
- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
|
||
ставке, дневная капитализация (не линейно) — см. выше.
|
||
|
||
## Goals (цель для копилки)
|
||
|
||
`id`, `account_id`, `target_summa`, `deadline`. Необязательная надстройка над `savings`-счётом.
|
||
Прогноз «сколько класть в месяц» считается на лету по ТЕКУЩЕЙ ставке (не хранится), в предположении,
|
||
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
|
||
|
||
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
|
||
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится
|
||
пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить
|
||
проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см.
|
||
`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для
|
||
автозаполнения этой транзакции).
|
||
|
||
**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс
|
||
как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть
|
||
дедлайн.
|
||
|
||
**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» —
|
||
добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина
|
||
(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не
|
||
отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно
|
||
поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под
|
||
цель не обсуждали.
|
||
|
||
**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у
|
||
обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед
|
||
архивацией спросить, на какой счёт перевести остаток.
|
||
|
||
**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на
|
||
счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на
|
||
саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям»
|
||
(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед
|
||
закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого:
|
||
не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см.
|
||
`Transactions`), просто слив всё в одну сумму перевода это бы стёр.
|
||
|
||
## Быстрый ввод операции — замена идее Telegram-бота
|
||
|
||
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
|
||
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
|
||
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
|
||
**PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`.
|
||
|
||
## Онбординг нового пользователя — создание из шаблона
|
||
|
||
Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с
|
||
нуля, а из готового шаблона (пока только один шаблон, без выбора):
|
||
|
||
**Решено:**
|
||
- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже —
|
||
название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных
|
||
правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см.
|
||
`Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе
|
||
создания (как и при обычном создании любого счёта — `summa` вводится как есть).
|
||
- **1 статья дохода**: «Зарплата».
|
||
- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение».
|
||
- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие
|
||
`accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц.
|
||
|
||
**Не решено:**
|
||
- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока
|
||
нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации?
|
||
- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные
|
||
плановые суммы на текущий месяц по каждой статье?
|
||
- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
|
||
|
||
## Приоритет фич
|
||
|
||
1. **Цели (план/факт) + анализ бюджета** — сейчас. **Счета (CRUD) — реализовано** (2026-08-12):
|
||
`App/Controller/AccountsController.php` (create/edit/delete=архивация, JSON, CSRF/владение
|
||
через `user_id` проверяются), модалка + карточки на дашборде (`App/view/Index/index.html`,
|
||
`App/media/js/accounts.js`) — без отдельной страницы `/accounts`, как и решено выше. Дальше по
|
||
этому приоритету — Категории/Операции/Цели, тем же паттерном контроллера (см. CLAUDE.md →
|
||
Controller hierarchy).
|
||
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
|
||
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
|
||
|
||
**Статьи (categories) + годовой план (budgets)** — реализовано (2026-08-13), план
|
||
`/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` выполнен целиком: `CategoryRepository`
|
||
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`), `CategoriesController`
|
||
(index/create/edit/delete/restore, защита от дублей активных/архивных статей), `categories.html` +
|
||
`categories.js` (страница `/categories`, группы+подкатегории, формула income→expense в модалке);
|
||
`BudgetRepository` (`getYear`/`upsert`, JOIN-таблица categories вынесена в свойство
|
||
`$categories_table` — ради тестируемости без риска для боевой таблицы), `BudgetsController`
|
||
(сетка статьи×12 месяцев, один POST, автопересчёт формульной expense-статьи отдельным проходом
|
||
после прямых значений — не зависит от порядка обхода), `budgets.html` + `budgets.js`. Ссылка
|
||
«Категории» в доке (`layout.html`) — теперь реальная, с подсветкой активного пункта по
|
||
`Request::$current->controller()`. Тесты — `tests/Unit/CategoryRepositoryTest.php`,
|
||
`tests/Unit/BudgetRepositoryTest.php` (реальная MariaDB, временные таблицы с отдельными именами —
|
||
не боевые `categories`/`budgets`). 259 тестов, всё зелено (8 skip — Elasticsearch/Mongo).
|
||
Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты
|
||
проверены curl (302 на /login без сессии, без 500).
|
||
|
||
**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
|
||
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
|
||
без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/
|
||
`App/config/auth_users.php`, и в бюджетной таблице `users`, `name='Egor'`), заглушки `admin`/`manager`
|
||
в `auth_users.php` оставлены как тестовые логины, в бюджетной `users` для них строк нет.
|
||
|
||
## Второй урок (почему эта версия файла переписана)
|
||
|
||
После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя,
|
||
я в следующем же заходе молча притащил старые выводы из ПРЕДЫДУЩЕЙ версии этого файла (users, accounts,
|
||
categories, transactions, budgets) как будто они уже согласованы — хотя после сброса заново обсуждались
|
||
только копилки/цели. Пользователь: «про пользователя мы ничего не обсуждали, про бюджет мы ничего не
|
||
обсуждали... ОТКУДА БЛЯДЬ???». **Урок: "сохранено в памяти" ≠ "можно молча переиспользовать после
|
||
полного сброса"** — если проект/фичу explicitly снесли и просили начать заново, каждое решение
|
||
переподтверждается в текущем разговоре заново, даже если оно дословно совпадёт с тем, что уже было
|
||
записано. Ссылаться на память можно только явно и открыто ("вот что записано, актуально ли?"), не
|
||
молча вставлять в предложение как решённое.
|
||
|
||
**How to apply**: перед тем как писать `schema.sql`/контроллеры/репозитории — свериться с этим файлом
|
||
**и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора**, а не просто
|
||
существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.
|