diff --git a/.claude/memory/feedback_reference_code_handling.md b/.claude/memory/feedback_reference_code_handling.md index 747f058..0e93cd6 100644 --- a/.claude/memory/feedback_reference_code_handling.md +++ b/.claude/memory/feedback_reference_code_handling.md @@ -23,12 +23,13 @@ render()-методы Controller, CSS/JS-подключение во View). Эт 1. Когда пользователь присылает референс-код — сначала спросить себя (не обязательно вслух), реально ли это то, что нужно 1-в-1, или в нём могут быть баги/устаревшие паттерны конкретно под старый проект (например поддержка нескольких "модулей", которых в Bicycle нет). -2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoDriver` с его SAVEPOINT- +2. Портировать с адаптацией под реалии Bicycle (уже собранные классы вроде `PdoConnection` с его SAVEPOINT- логикой могут делать что-то лучше/надёжнее, чем то, что в референсе — не дублировать хуже). 3. Если сомневаешься, действительно ли имя/сигнатура должны совпадать буквально — можно посмотреть исходник самого референс-проекта на диске (он часто лежит рядом, в других папках `/home/isaevea/http/*`), а не гадать по вставленным фрагментам — так нашлись оригиналы `Database.php`/`PdoDriver.php`/ - `Statement.php`/`ProfilerPDO.php`/`Repository.php` в `eoffice_v3/System/Classes/`. + `Statement.php`/`ProfilerPDO.php`/`Repository.php` в `eoffice_v3/System/Classes/` (имя файла + там осталось `PdoDriver.php` — это чужой проект, не переименовывался вслед за нашим `PdoConnection`). 4. Если пользователь явно пишет что-то вроде `$this->pdo` (даже с ошибкой в другом месте кода) — это сильный сигнал именно про нейминг, не опечатка; после второго повтора — не переспрашивать, а сразу переименовывать под него (см. также [[feedback-less-confirmation]]). diff --git a/.claude/memory/roadmap.md b/.claude/memory/roadmap.md index 14f7b87..527c9ec 100644 --- a/.claude/memory/roadmap.md +++ b/.claude/memory/roadmap.md @@ -10,7 +10,7 @@ metadata: за одной абстракцией). Подробности использования — CLAUDE.md → разделы DataBase/Elasticsearch/Mongo. - **Реляционная БД (MariaDB, полностью рабочая и протестированная, реальное подключение)** — - `Services\Database` (фабрика), `Services\DataBase\Classes\PdoDriver` (failover по хостам, транзакции + `Services\Database` (фабрика), `Services\DataBase\Classes\PdoConnection` (failover по хостам, транзакции с вложенными SAVEPOINT — единственный источник истины по вложенности, общий на всё подключение), `Services\DataBase\Classes\Statement` (`showQuery()`/`sq()`, `execute()` профилирует в `Profiler`), `Services\DataBase\Classes\Profiler` (тайминги, см. также `System\Classes\ProfilerToolbar`). @@ -20,7 +20,7 @@ metadata: 1. `getList()` по умолчанию `PDO::FETCH_CLASS` с проброшенным `$this->obj_class` (в оригинале — `PDO::FETCH_KEY_PAIR` по умолчанию, и `FETCH_CLASS` не получает `obj_class` вовсе — нестыковка PHPDoc/кода в оригинале, не повторяли). - 2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoDriver` (см. выше), а не свой + 2. Транзакции (`beginTransaction/commit/rollBack`) — тонкие делегаты в `PdoConnection` (см. выше), а не свой счётчик вложенности в каждом `Repository`, как в оригинале (там это ломается, если два репозитория на одном соединении оба вызывают `beginTransaction()` — оба думают, что «внешние»). Методы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `primary_col`/`is_auto_increment` @@ -44,8 +44,8 @@ metadata: - **`System\Classes\ProfilerToolbar`** — debug-панель внизу страницы (аналог ProfilerToolbar для Kohana), время/память/SQL из `Profiler`, видна только `role === 'admin'`, подключена в `App/view/layout.html`. -**Профилирование — в `Statement::execute()`, не в `PdoDriver::query()` (важно, был реальный баг):** -первая версия вешала таймер только на `PdoDriver::query()` через отдельный класс `ProfilerPDO::wrap()` +**Профилирование — в `Statement::execute()`, не в `PdoConnection::query()` (важно, был реальный баг):** +первая версия вешала таймер только на `PdoConnection::query()` через отдельный класс `ProfilerPDO::wrap()` (теперь удалён) — но `Repository::get()/getList()/create()/update()/delete()/deleteWhere()` сами делают `$this->pdo->prepare($sql); $stmt->execute();` напрямую, в обход `query()` — то есть почти все реальные запросы через `Repository` не логировались вообще (пользователь поймал это по `ProfilerToolbar`, @@ -53,13 +53,13 @@ metadata: что через `Statement` проходит вообще любое выполнение запроса (и `query()`, и ручной `prepare()+execute()`), таймер и вызов `Profiler::log()` теперь в `Statement::execute()` — единственной точке, которую нельзя обойти. Общий урок: если добавляешь профилирование/логирование поверх PDO-обёртки — вешать его на -низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoDriver::query()`), +низкоуровневый общий метод (`Statement`/`PDOStatement`), а не на удобный высокоуровневый (`PdoConnection::query()`), если у обёртки есть другие пути выполнения запроса в обход этого высокоуровневого метода. -**Свойство `Repository`/`PdoDriver` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это +**Свойство `Repository`/`PdoConnection` называется `$pdo`** (не `$_driver`/`$_pdo` с подчёркиванием, хотя это нарушает общий стиль `_prefixed` protected-свойств проекта) — пользователь явно попросил именно так, дважды сам написал `$this->pdo` в своём коде раньше, чем я успел объяснить структуру. См. также -`PdoDriver::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с +`PdoConnection::rollback()` — с маленькой буквы (не `rollBack()`, хотя нативный `\PDO::rollBack()` — с большой); `Repository::rollBack()` (с большой буквы) — публичный метод, который делегирует в `$this->pdo->rollback()` (с маленькой) — это НЕ опечатка, два разных метода двух разных классов. @@ -74,7 +74,7 @@ metadata: (внешний хост, не контейнер `db` из docker-dev) — эта часть инфраструктуры уже готова пользователем, тесты по MariaDB реально гоняются, не skip. -**Тесты**: `PdoDriverTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт +**Тесты**: `PdoConnectionTest`/`StatementTest`/`RepositoryTest` бьют по реальной MariaDB (не мокают) — гейт через `markTestSkipped()` в `setUp()` при неудачном подключении остался в коде на случай, если у кого-то ещё не настроен `config.local.php`, но сейчас реально проходят (не skip). `ElasticsearchClientTest` — через `Client::ping()`. `MongoDriverTest` — через `extension_loaded('mongodb')`. Эти два оживут сами, diff --git a/.claude/memory/Общий_промт_по_конструктору_метрик.md b/.claude/memory/Общий_промт_по_конструктору_метрик.md new file mode 100644 index 0000000..cc81e7d --- /dev/null +++ b/.claude/memory/Общий_промт_по_конструктору_метрик.md @@ -0,0 +1,168 @@ +Спроектируй и реализуй универсальный конструктор пользовательских метрик для аналитического дашборда. + +Цель + +Пользователь без знания программирования должен иметь возможность собрать собственную метрику из доступных данных, настроить способ расчёта и выбрать её визуальное представление. + +Конструктор должен подходить как для простых показателей, так и для сложных вычисляемых метрик. + +Основной сценарий + +Раздели создание метрики на три логических этапа: + +1. Основная информация. +2. Расчёт. +3. Отображение. + +Пользователь должен свободно перемещаться между этапами, не теряя введённые данные. + +Основная информация + +Предусмотри: + +- название метрики; +- необязательное описание; +- категорию или смысловую группу; +- единицу измерения; +- понятное отличие новой метрики от редактирования существующей. + +Расчёт + +Поддержи несколько способов создания метрики: + +- выбор готового показателя; +- сумма выбранных значений; +- разница между показателями; +- отношение или процент; +- среднее, минимум и максимум; +- накопительный итог; +- собственная формула. + +Источники данных должны определяться динамически. Не используй жёстко заданные категории или показатели. + +Позволь выбирать: + +- набор учитываемых данных; +- временной период; +- режим расчёта; +- фильтры; +- группировку; +- правила обработки отсутствующих значений. + +Формулы + +Добавь редактор собственных формул с поддержкой: + +- арифметических операций; +- скобок; +- доступных показателей и категорий; +- ссылок на другие пользовательские метрики; +- функций агрегации; +- сравнений с предыдущими периодами; +- автодополнения; +- описания доступных переменных; +- проверки формулы до сохранения. + +Конструктор должен обнаруживать: + +- неизвестные переменные; +- синтаксические ошибки; +- деление на ноль; +- циклические зависимости между метриками; +- несовместимые единицы измерения; +- отсутствующие источники данных. + +Ошибки показывай рядом с проблемным полем и объясняй понятным языком. + +Отображение + +Позволь настроить: + +- формат числа; +- количество знаков после запятой; +- денежную единицу или процент; +- направление оценки результата; +- целевое значение; +- предупредительные и критические пороги; +- цветовые состояния; +- выбранные графики; +- отображение на дашборде. + +Поддержи основные варианты визуализации: + +- числовая карточка; +- прогресс; +- сравнение; +- динамика; +- столбчатый график; +- линейный график; +- компактный индикатор состояния. + +Показывай живое превью метрики. Оно должно обновляться при изменении настроек, но не сохранять черновик как готовую метрику. + +Пользовательский опыт + +Интерфейс должен постепенно раскрывать сложность: + +- сначала показывать только обязательные параметры; +- дополнительные настройки размещать в раскрывающихся блоках; +- скрывать поля, которые не относятся к выбранному типу расчёта; +- сохранять незавершённый черновик; +- предупреждать при закрытии формы с несохранёнными изменениями; +- не перегружать экран вспомогательным текстом. + +Предусмотри понятные состояния: + +- пустые данные; +- загрузка; +- ошибка; +- некорректная формула; +- успешное сохранение; +- редактирование; +- дублирование; +- удаление и восстановление. + +Управление метриками + +Пользователь должен иметь возможность: + +- создавать метрики; +- редактировать; +- дублировать; +- временно скрывать; +- закреплять на дашборде; +- менять порядок; +- переносить в хранилище; +- удалять с возможностью восстановления; +- окончательно удалять; +- импортировать и экспортировать конфигурации. + +Совместимость + +Модель метрики должна быть версионируемой. При развитии конструктора старые сохранённые метрики должны продолжать работать или автоматически мигрировать в новую схему. + +Изменение или удаление источника данных не должно незаметно ломать метрику. Показывай её состояние и предлагай выбрать замену. + +Адаптивность и доступность + +Конструктор должен одинаково хорошо работать на компьютерах и мобильных устройствах: + +- без горизонтального скролла; +- с удобными зонами нажатия; +- с поддержкой экранной клавиатуры; +- с полной клавиатурной навигацией; +- с корректными focus-состояниями; +- с доступными названиями элементов; +- с достаточным контрастом. + +Критерии готовности + +- Простую метрику можно создать за несколько понятных действий. +- Сложную формулу можно собрать и проверить до сохранения. +- Все параметры влияют на живое превью. +- Некорректную конфигурацию невозможно сохранить. +- Ошибки объясняют причину и способ исправления. +- Старые метрики остаются совместимыми. +- Интерфейс корректно работает на мобильных и настольных экранах. +- Нет жёсткой зависимости от конкретных категорий, показателей или структуры данных. +- Основные пользовательские сценарии покрыты автоматическими тестами. \ No newline at end of file diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index b3a58e1..d91a9c9 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -20,9 +20,96 @@ metadata: `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 (счета) -Три типа `type_acc`: +**Один пользователь приложения может вести несколько реальных счетов, включая счета, номинально +оформленные на других членов семьи** (пример: «мой счёт», «счёт жены») — это не мультипользовательская +функция и не требует отдельного `user_id` на счёт: все они просто разные строки `accounts` с разными +`title`, привязанные к одному `user_id` (см. "Продукт" выше). Различать их — по `title`, не по схеме. + +**Будущие типы счёта под инвестиции (не сейчас, см. "Приоритет фич" → Инвестиции)** — уточнено, это +два разных счёта, не один: +- **Брокерский** — акции, облигации, фонды, фьючерсы. +- **ЦФА** (цифровые финансовые активы) — долговые, ноты, картины (NFT-подобное), крипто. + +Оба не подходят ни под `cash` (не наличные), ни под `bank` (не просто расчётный счёт), ни под `savings` +(не вклад под процент, а портфель с рыночной переоценкой, не фиксированной ставкой). **Как именно с ними +работать (отдельные позиции/тикеры внутри счёта, переоценка, дивиденды/купоны, transfer при +покупке/продаже) — пользователь явно попросил отложить обсуждение, не проектировать сейчас вообще**, +даже на уровне схемы `accounts`/`type_acc`. Сейчас — только основной бюджет (cash/bank/savings). + +Три типа `type_acc` (актуальные, реализовано): - **`cash`** — кошелёк (наличные). - **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/ @@ -31,50 +118,234 @@ metadata: привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals` нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты. -Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс), `is_delete` (архивация вместо удаления), -`order`. +Поля: `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 ЦБ РФ +(daily XML/JSON), куда класть клиент — скорее всего новый `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`. +(`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 между обычными счетами — без категории** (пример: переложил 3000 ₽ с карты в -кошелёк — деньги не потрачены и не заработаны, просто переехали, категория тут не имеет смысла). +**Все transfer — без категории, без исключений**, в том числе пополнение savings-счёта/копилки/цели: +переложил деньги в копилку «Отпуск» — это просто перемещение между своими счетами, не потрачено и не +заработано, `categorie_id = null`. **Решено** (закрывает предыдущий открытый вопрос про задвоение): +реальный расход считается только тогда, когда деньги действительно потрачены — гостиница, билеты и +т.п. оформляются обычными `expense`-транзакциями с `account_f_id` = счёт, с которого реально платили +(в т.ч. со счёта-копилки, если платили прямо с него), и категорией «Отпуск» (точнее — под-категорией +типа «Билеты»/«Гостиница»). Задвоения нет, потому что откладывание в копилку категорией не помечается +вовсе — план/факт по категории «Отпуск» видит только реальные траты. -**⚠️ ОТКРЫТЫЙ ВОПРОС — transfer на savings-счёт (пополнение копилки/цели) МОЖЕТ иметь категорию**, -и пользователь хочет, чтобы такой transfer засчитывался как "потрачено" по этой категории в -план/факт бюджета — то есть отложил 10 000 ₽ в копилку «Отпуск» в январе → сразу считается -"потрачено 10 000 на Отпуск" в январском бюджете, ещё до самой поездки. +**Баланс счёта (`accounts.summa`) — решено, «как в банках».** Не пересчитывать всю историю на каждый +показ, но и не хранить как самостоятельное число без контроля. Гибрид: `summa` — хранимый кэш, который +обновляется **атомарно, в той же БД-транзакции**, что и сама операция создания/правки/удаления +`transactions` (`PdoConnection::transaction()` уже есть для этого) — источник истины всё равно лента +операций, баланс — материализованный кэш поверх неё для скорости. Дополнительно нужен служебный метод +«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления +при подозрении на расхождение (аналог банковской реконсиляции). -Нерешённая проблема с этим: когда деньги реально тратятся (сама поездка — гостиница, билеты), если те -траты ТОЖЕ пометить категорией «Отпуск» — сумма задвоится (одни и те же деньги посчитаны и при -откладывании, и при реальной трате). Пользователь сказал «я подумаю» — **решение пока не принято**, -не проектировать/не кодировать эту часть, пока не будет ответа. Варианты, которые обсуждались (но не -выбраны): (а) реальные траты в момент поездки НЕ помечать той же категорией, раз уже засчитано при -откладывании; (б) какой-то другой механизм анти-задвоения. +**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько +пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽ +Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions` +достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно +`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит +дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно: +нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` — +transfer и так без категории, доход обычно один источник). **Два способа ввода:** +1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары + 300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически. +2. **Ручной** — то же самое, но вводится руками, пара за парой. +Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом +`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания. + +**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот +подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и +удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда, +что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id, +user_id, changed_at, action, old_values/new_values — набросок, не финал). ## Budgets (план/факт) -`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. План — вручную на месяц по категории; факт -— не хранится, считается на лету как сумма `transactions` по этой категории за месяц (с учётом -открытого вопроса выше — возможно, включая помеченные transfer на копилки). +`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как +сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая +помеченные transfer на копилки). + +**Рабочий процесс ввода плана — не помесячно по ходу года, а весь год сразу.** План составляется один +раз, в конце года, сразу на все 12 месяцев следующего года по каждой статье (пример: в конце 2026-го +вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не +«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью. + +**Открыто: формульные статьи бюджета.** Пользователь пояснил про «10%»: план = ЗП × 0,1, факт = +факт ЗП × 0,1 — то есть не фиксированное число и не сумма собственных транзакций, а процент от +другой категории (дохода). Концепция обсуждалась, не спроектирована окончательно: скорее всего +`budgets` получит необязательные `formula_source_categorie_id` + `formula_pct`, и для таких строк +план/факт вычисляются на лету от категории-источника, а не читаются напрямую. Не решено: один +источник или сумма нескольких (у пользователя два дохода — Зарплата Егор/Зарплата Лена), что +происходит при смене процента задним числом. Не проектировать/не кодировать, пока не обсудим детали. + +## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ + +Спрашивал явно — нужна ли фича «повторяющаяся операция», которая сама заводит транзакцию по расписанию +(для подписок типа Клод/Чат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 = текущая). Общая механика для -`savings`, с расчётом на будущее переиспользование для кредита (там % работает в другую сторону — -не начисление, а долг — но структура «ставка + период» та же). Кредит сейчас вне скоупа. +`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая). + +**История (не единственное поле на `accounts`) нужна ради конкретной фичи — узнать, сколько процентов +начислилось за произвольный выбранный период** (например «сколько капнуло за март»). Если ставка +внутри периода менялась, расчёт должен пройти по каждому отрезку `account_rates`, пересекающему +период, со своей ставкой — без истории это невозможно восстановить задним числом. Это и есть +причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого +раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но +раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный). +Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не +спроектирован, отдельная задача на будущее. + +**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — +план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта: + +- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». + Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая + пользователем), `valid_to = null`. +- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой + строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и + дату, с которой он начинает действовать** (`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` ниже — «по текущей + ставке») — не решено, обсудить перед реализацией расчёта. ## Goals (цель для копилки) @@ -86,6 +357,14 @@ metadata: категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится пользователю автоматически при первом использовании. +## Быстрый ввод операции — замена идее Telegram-бота + +В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для +быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции +внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API). +**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже +поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией. + ## Приоритет фич 1. **Цели (план/факт) + анализ бюджета** — сейчас. diff --git a/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md b/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md new file mode 100644 index 0000000..d754ad7 --- /dev/null +++ b/.claude/memory/промт_по_конкретной_реализации_конструктора_метрик.md @@ -0,0 +1,92 @@ + +Задача: провести полный аудит и довести до законченного состояния конструктор пользовательских метрик для дашборда. Не переписывай работающий движок без необходимости — сначала изучи текущую реализацию в app/assets/js/metrics.js, app/assets/css/metrics.css и связанные тесты. + +Цель + +Пользователь должен без технических знаний собрать метрику, выбрать источник данных, настроить отображение и сразу понимать, что получится. Интерфейс должен быть компактным, визуально цельным и соответствовать брендовому glass-дизайну приложения. + +Функциональные требования + +1. Сохрани структуру конструктора из трёх разделов: + - «Основное»; + - «Расчёт»; + - «Отображение». + +2. В разделе «Основное»: + - название метрики; + - понятная идентификация создаваемой или редактируемой метрики; + - отсутствие лишнего поясняющего текста. + +3. Поддержи типы расчёта: + - сумма выбранных категорий; + - доходы минус расходы; + - доля расходов от дохода; + - собственная формула. + +4. Поддержи режимы данных: + - факт, если есть, иначе план; + - только факт; + - только план. + +5. Категории: + - получай исключительно из пользовательских данных; + - не возвращай хардкод категорий и позиций; + - пустой инстанс должен оставаться пустым; + - исторические категории должны оставаться доступными для расчётов; + - выбор категорий оформить компактным раскрывающимся блоком с количеством выбранных элементов. + +6. Собственные формулы: + - сохранить существующий движок формул; + - поддерживать базовые переменные, категории, ссылки на другие метрики и соседние периоды; + - поддерживать префиксы prev_, next_, prev2_ и next2_; + - автодополнение должно показывать полный доступный список без искусственного ограничения; + - неизвестные переменные, пустые выражения и синтаксические ошибки должны блокировать сохранение с понятной ошибкой; + - выбор результата: денежная сумма, коэффициент или процент; + - при собственной формуле отключать обычный выбор категорий, поскольку категории указываются токенами в выражении. + +7. Настройки отображения: + - сравнение; + - динамика; + - прогресс; + - направление оценки «больше — лучше» или «меньше — лучше»; + - целевое значение показывать только при выбранном графике прогресса; + - переключатель закрепления метрики на дашборде. + +8. Добавь живое превью карточки метрики внутри конструктора: + - название; + - рассчитанное или демонстрационное значение; + - единица измерения; + - выбранные графики; + - состояние цели; + - превью должно обновляться при изменении настроек и не сохранять данные само по себе. + +9. Сохранение: + - создание новой метрики; + - редактирование существующей без потери ID и owner; + - удаление с возможностью восстановления через существующие хранилище и корзину; + - метрики должны продолжать работать в дашборде и каталоге калькулятора; + - при изменении структуры данных предусмотреть schemaVersion и миграцию старых записей. + +Дизайн + +- Используй существующие брендовые переменные, стеклянные поверхности и SVG-иконки проекта. +- Не добавляй новые случайные цвета и стили. +- Модальное окно должно находиться поверх всего интерфейса и использовать общий popup/modal слой. +- На десктопе поля можно размещать в две колонки. +- На мобильном всё должно переходить в одну колонку без горизонтального скролла. +- Учти экранную клавиатуру, safe-area и небольшую высоту экрана. +- Не перегружай форму подсказками: оставляй только ошибки, важные ограничения и необходимые пояснения для формул. +- Состояния выбора, disabled, loading, validation и focus должны быть визуально различимы. + +Критерии готовности + +- Можно создать и отредактировать каждый тип метрики. +- Формулы корректно работают с планом, фактом, категориями, соседними периодами и другими метриками. +- Пустой инстанс не получает предустановленных категорий или метрик. +- Старые сохранённые метрики продолжают работать. +- Конструктор не выходит за границы экрана на desktop и mobile. +- Все существующие тесты проходят. +- Добавлены тесты на живое превью, валидацию, динамические категории, адаптив и обратную совместимость. +- Проведена проверка синтаксиса всех изменённых JS-файлов. + + diff --git a/.gitignore b/.gitignore index db85cee..c989633 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ config.json App/config/config.local.php App/config/auth_users.php -/App/logs/ +/App/logs/* +!/App/logs/.gitkeep **/files/**/* !**/files/**/ diff --git a/App/Repositories/AccountRateRepository.php b/App/Repositories/AccountRateRepository.php new file mode 100644 index 0000000..e5bd244 --- /dev/null +++ b/App/Repositories/AccountRateRepository.php @@ -0,0 +1,36 @@ +getItemWhere("account_id = $account_id AND valid_to IS NULL"); + } +} diff --git a/App/Repositories/AccountRepository.php b/App/Repositories/AccountRepository.php new file mode 100644 index 0000000..85f85ae --- /dev/null +++ b/App/Repositories/AccountRepository.php @@ -0,0 +1,25 @@ + [ 'default' => [ - 'driver' => 'pdo', - 'connection' => [ - 'host' => '192.168.1.36', - 'port' => 3307, - 'user' => 'root', - 'password' => 'qwerty', - 'dbname' => 'budget' - ], + 'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn() + 'host' => '192.168.1.36', + 'port' => 3307, + 'user' => 'root', + 'password' => 'qwerty', + 'dbname' => 'budget' ], ], diff --git a/App/logs/.gitkeep b/App/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/media/css/.gitkeep b/App/media/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/media/js/.gitkeep b/App/media/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/App/view/Index/index.html b/App/view/Index/index.html index f0a2e7e..b68b375 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -8,6 +8,7 @@ */ use System\Classes\CSRF; +use System\Classes\ProfilerToolbar; ?> @@ -25,6 +26,7 @@ use System\Classes\CSRF;
Вы вошли как = htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?>.
+ = ProfilerToolbar::render() ?>