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;

Вы вошли как .

+
diff --git a/CLAUDE.md b/CLAUDE.md index d6433c7..48703de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ App/config/ — конфиги (config.php; config.local. App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/media/ — статические ресурсы (js, css, img) Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже) -Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler} (см. раздел DataBase ниже) +Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoConnection,Statement,Profiler} (см. раздел DataBase ниже) Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже) Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже) Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет @@ -109,12 +109,12 @@ PhpStorm может показывать предупреждение «Namespac | `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу | | `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` | | `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak | -| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения | -| `Services\DataBase\Classes\PdoDriver` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, failover по `hosts`, транзакции с вложенными SAVEPOINT (`transaction()`) | +| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoConnection`, кэш по имени подключения | +| `Services\DataBase\Classes\PdoConnection` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, диалект `mysql`\|`pgsql`\|`sqlite` через конфиг `dialect` (по умолчанию `mysql`), failover по `hosts` (кроме sqlite — там нет хостов), транзакции с вложенными SAVEPOINT (`transaction()`) | | `Services\DataBase\Classes\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` | | `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` | | `Services\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` | -| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoDriver`); конкретные — в `App\Repositories\*` | +| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoConnection`); конкретные — в `App\Repositories\*` | | `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения | | `Services\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` | | `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения | @@ -311,34 +311,49 @@ return [ ### DataBase (Services\Database, System\Classes\Repository) Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя -*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`: +*подключения*, по умолчанию `'default'`; значение — параметры подключения плоским массивом +(`host`/`hosts`/`port`/`user`/`password`/`dbname`/`dialect`/`charset`), без обёртки под тип +драйвера — PDO не «драйвер» в смысле выбора между несколькими реализациями (это сама обёртка над +клиентской библиотекой конкретной СУБД), других вариантов подключения к реляционной БД в проекте +нет, поэтому и нечего выбирать конфигом: ```php use Services\Database; -$driver = Database::instance(); // Config::get('db', 'default') -$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]); +$connection = Database::instance(); // Config::get('db', 'default') +$stmt = $connection->query('SELECT * FROM users WHERE id = ?', [42]); $row = $stmt->fetch(); echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery()) ``` -Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts` +Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `hosts` (массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок -по каждому хосту. Транзакции на уровне `PdoDriver` — вложенные через SAVEPOINT +по каждому хосту. + +**Диалект** — `dialect` (`'mysql'` по умолчанию, либо `'pgsql'`/`'sqlite'`) переключает +только сборку DSN внутри `PdoConnection::buildDsn()` — реестра классов-драйверов по типу СУБД нет +(в отличие от `Auth`/`LogReader`), потому что весь остальной код (`Statement`, `Profiler`, +SAVEPOINT-транзакции) от диалекта не зависит, это осталось бы дублированием ради дублирования. +`sqlite` — особый случай: `dbname` там путь к файлу (или `':memory:'`), а не имя базы на хосте, +поэтому у него нет `host`/`hosts`/`user`/`password`/`charset` и failover-перебор для него не +запускается (`connect()` подключается напрямую). Расширения `pdo_mysql` и `pdo_sqlite` есть в +текущем Docker-образе, `pdo_pgsql` — нет (диалект `pgsql` в коде поддержан, но не проверен вживую). + +Транзакции на уровне `PdoConnection` — вложенные через SAVEPOINT (`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку: ```php -$driver->transaction(function ($driver) { - $driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); - $driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); +$connection->transaction(function ($connection) { + $connection->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]); + $connection->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]); }); // исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw ``` Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч. -ручного `$driver->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`). +ручного `$connection->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`). **Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в `App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение @@ -366,7 +381,7 @@ $repo->create(['title' => 'Q3', 'amount' => 1000]); // lastInsertId $repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data $repo->delete(1); $repo->deleteWhere('status = ?', ['closed']); -$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoDriver (там же и SAVEPOINT-логика) +$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoConnection (там же и SAVEPOINT-логика) ``` `public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в @@ -381,7 +396,7 @@ $repo->transaction(fn () => /* несколько операций одной т нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение); -в Bicycle транзакции делегируются в `PdoDriver`, у которого счётчик один на всё подключение. +в Bicycle транзакции делегируются в `PdoConnection`, у которого счётчик один на всё подключение. `Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных, это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) — @@ -627,7 +642,7 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла | | `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение | | `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение | -| `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()` | +| `tests/Unit/PdoConnectionTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()`; отдельно диалект `sqlite` (`:memory:`, не требует внешней БД) | | `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB | | `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` | | `tests/Unit/RepositoryTest.php` | `get()/getItemWhere()/getList()` (в т.ч. дефолтный `FETCH_CLASS` + `obj_class`)/`create()/update()/delete()/deleteWhere()`, `transaction()` с rollback — требует живую MariaDB | @@ -638,7 +653,8 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap | `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` | | `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT | -**248 тестов, 386 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** +**248 тестов, 387 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — +MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).** ### Frontend dependencies (через Composer) diff --git a/Services/DataBase/Classes/PdoDriver.php b/Services/DataBase/Classes/PdoConnection.php similarity index 61% rename from Services/DataBase/Classes/PdoDriver.php rename to Services/DataBase/Classes/PdoConnection.php index 9119b79..19dfd19 100644 --- a/Services/DataBase/Classes/PdoDriver.php +++ b/Services/DataBase/Classes/PdoConnection.php @@ -2,7 +2,7 @@ /** * @package Bicycle * @author Egor Isaev - * @description PdoDriver.php + * @description PdoConnection.php * @copyright (c) 07/08/2026 */ @@ -17,8 +17,12 @@ use Throwable; /** * Обёртка над PDO: ленивое подключение (с перебором хостов при отказе — failover), * запросы через Statement (см. Statement::showQuery()) и вложенные транзакции через SAVEPOINT. + * Диалект (`mysql`/`pgsql`/`sqlite` — набор PDO-драйверов, реально доступных через + * pdo_mysql/pdo_pgsql/pdo_sqlite) задаётся конфигом, не подклассом — DSN и есть единственное + * отличие между ними на уровне этого класса, остального (Statement/Profiler/SAVEPOINT-транзакции) + * это не касается. */ -class PdoDriver +class PdoConnection { /** @var PDO|null Подключение; null до первого обращения (connect()/pdo()) */ protected ?PDO $_pdo = null; @@ -27,14 +31,18 @@ class PdoDriver protected int $_transaction_level = 0; /** - * @param array $config Параметры подключения: host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4) + * @param array $config Параметры подключения: dialect (mysql|pgsql|sqlite, по умолчанию mysql), + * host|hosts, port, dbname, user, password, charset (по умолчанию utf8mb4, + * только для mysql). Для sqlite dbname — путь к файлу (или ':memory:'), + * host/port/user/password/charset не используются. */ public function __construct(protected array $config) { } /** - * Подключается к первому доступному хосту из host|hosts (failover). + * Подключается к первому доступному хосту из host|hosts (failover); для sqlite (нет + * понятия хоста — файл или ':memory:') подключается напрямую, без перебора. * Не делает ничего, если подключение уже установлено. * * @return void @@ -45,26 +53,24 @@ class PdoDriver return; } - $hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost']; - $port = $this->config['port'] ?? null; - $dbname = $this->config['dbname'] ?? ''; - $user = $this->config['user'] ?? ''; - $password = $this->config['password'] ?? ''; - $charset = $this->config['charset'] ?? 'utf8mb4'; + $dialect = $this->config['dialect'] ?? 'mysql'; + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + if ($dialect === 'sqlite') { + $this->_pdo = $this->connectPdo($this->buildDsn($dialect, null), $options); + + return; + } + + $hosts = $this->config['hosts'] ?? [$this->config['host'] ?? 'localhost']; $errors = []; foreach ($hosts as $host) { - $dsn = "mysql:host=$host;dbname=$dbname;charset=$charset" . ($port !== null ? ";port=$port" : ''); - try { - $pdo = new PDO($dsn, $user, $password, [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); - $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]); - - $this->_pdo = $pdo; + $this->_pdo = $this->connectPdo($this->buildDsn($dialect, $host), $options); return; } catch (PDOException $e) { @@ -77,6 +83,46 @@ class PdoDriver ); } + /** + * Собирает DSN под конкретный PDO-диалект. + * + * @param string $dialect mysql|pgsql|sqlite + * @param string|null $host Хост; не используется для sqlite + * @return string + */ + protected function buildDsn(string $dialect, ?string $host): string + { + $dbname = $this->config['dbname'] ?? ''; + $port = $this->config['port'] ?? null; + $port_suffix = $port !== null ? ";port=$port" : ''; + + return match ($dialect) { + 'sqlite' => "sqlite:$dbname", + 'pgsql' => "pgsql:host=$host;dbname=$dbname$port_suffix", + default => "mysql:host=$host;dbname=$dbname;charset=" . ($this->config['charset'] ?? 'utf8mb4') . $port_suffix, + }; + } + + /** + * Открывает PDO-подключение по готовому DSN и настраивает Statement::class. + * sqlite не использует user/password — PDO принимает для него null. + * + * @param string $dsn + * @param array $options + * @return PDO + */ + protected function connectPdo(string $dsn, array $options): PDO + { + $is_sqlite = str_starts_with($dsn, 'sqlite:'); + $user = $is_sqlite ? null : ($this->config['user'] ?? ''); + $password = $is_sqlite ? null : ($this->config['password'] ?? ''); + + $pdo = new PDO($dsn, $user, $password, $options); + $pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, [Statement::class, [$this]]); + + return $pdo; + } + /** * @return PDO Подключается при первом обращении */ @@ -177,7 +223,7 @@ class PdoDriver /** * Выполняет $callback в транзакции: begin → callback → commit; * при исключении — rollback и повторный throw. Вложенные вызовы (в том числе - * из разных Repository на одном PdoDriver) используют SAVEPOINT прозрачно. + * из разных Repository на одном PdoConnection) используют SAVEPOINT прозрачно. * * @param callable $callback * @return mixed Результат $callback diff --git a/Services/DataBase/Classes/Statement.php b/Services/DataBase/Classes/Statement.php index b6f7475..e9924b8 100644 --- a/Services/DataBase/Classes/Statement.php +++ b/Services/DataBase/Classes/Statement.php @@ -13,10 +13,10 @@ use PDOStatement; /** * Расширение PDOStatement, подключается через PDO::ATTR_STATEMENT_CLASS - * (см. PdoDriver::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams() + * (см. PdoConnection::connect()). Добавляет showQuery()/sq() — PDOStatement::debugDumpParams() * печатает в stdout, а не возвращает строку, поэтому значения bind-параметров * копятся вручную (bindValue()/execute() переопределены) и подставляются в SQL сами. - * execute() также профилирует время в Profiler — здесь, а не в PdoDriver::query(), + * execute() также профилирует время в Profiler — здесь, а не в PdoConnection::query(), * потому что через ATTR_STATEMENT_CLASS проходит вообще любое выполнение запроса, * в том числе ручной $pdo->prepare()->execute() в обход query() (как в Repository). */ @@ -34,9 +34,9 @@ class Statement extends PDOStatement /** * Конструктор вызывается только изнутри PDO (PDO::ATTR_STATEMENT_CLASS) — не создавать напрямую. * - * @param PdoDriver $driver Владелец, нужен для quote() в showQuery() + * @param PdoConnection $connection Владелец, нужен для quote() в showQuery() */ - protected function __construct(protected PdoDriver $driver) + protected function __construct(protected PdoConnection $connection) { } @@ -82,7 +82,7 @@ class Statement extends PDOStatement $sql = $this->queryString; foreach ($this->_bound_params as $key => $value) { - $quoted = is_int($value) || is_float($value) ? (string) $value : $this->driver->pdo()->quote((string) $value); + $quoted = is_int($value) || is_float($value) ? (string) $value : $this->connection->pdo()->quote((string) $value); if (is_int($key)) { $pos = strpos($sql, '?'); diff --git a/Services/Database.php b/Services/Database.php index ab146ef..16caf4a 100644 --- a/Services/Database.php +++ b/Services/Database.php @@ -8,32 +8,33 @@ namespace Services; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use System\Classes\Config; use System\Classes\MyException; /** * Точка входа в реляционную БД. Имя аргумента — это имя подключения - * (ключ в Config::get('db', $name), по умолчанию 'default'), а не тип драйвера — - * сейчас есть только один драйвер ('pdo'), поэтому реестр по типу драйвера - * (как в Auth::$_drivers) не заводится, пока не появится второй. + * (ключ в Config::get('db', $name), по умолчанию 'default'). Подключение всегда через PDO — + * PDO не «драйвер» в смысле выбора между несколькими реализациями (это и есть сама обёртка + * над клиентской библиотекой конкретной СУБД, см. CLAUDE.md, раздел DataBase), поэтому в + * конфиге нет отдельного ключа под тип драйвера — только параметры подключения. * * Database::instance()->query('SELECT ...'); * Database::instance('reporting')->query(...); // отдельное подключение */ class Database { - /** @var array Кэш подключений по имени */ + /** @var array Кэш подключений по имени */ protected static array $_instances = []; /** * Возвращает подключение по имени (кэшируется). * * @param string|null $name Имя подключения из конфига; null — 'default' - * @return PdoDriver - * @throws MyException Если подключение не описано в конфиге или драйвер неизвестен + * @return PdoConnection + * @throws MyException Если подключение не описано в конфиге */ - public static function instance(?string $name = null): PdoDriver + public static function instance(?string $name = null): PdoConnection { $name ??= 'default'; @@ -44,13 +45,7 @@ class Database throw new MyException('Подключение к БД не описано в конфиге: :name', [':name' => $name]); } - if (($config['driver'] ?? null) !== 'pdo') { - throw new MyException( - 'Неизвестный драйвер БД: :driver', [':driver' => $config['driver'] ?? 'null'] - ); - } - - self::$_instances[$name] = new PdoDriver($config['connection'] ?? []); + self::$_instances[$name] = new PdoConnection($config); } return self::$_instances[$name]; diff --git a/System/Classes/ProfilerToolbar.php b/System/Classes/ProfilerToolbar.php index c34c359..cf437cd 100644 --- a/System/Classes/ProfilerToolbar.php +++ b/System/Classes/ProfilerToolbar.php @@ -55,7 +55,7 @@ class ProfilerToolbar $time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000; $memory = memory_get_peak_usage(true); // Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query() - // через тот же PdoDriver) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы. + // через тот же PdoConnection) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы. $query_count = Profiler::count(); $query_time = Profiler::totalTime(); diff --git a/System/Classes/Repository.php b/System/Classes/Repository.php index 3e11a42..f1d4c62 100644 --- a/System/Classes/Repository.php +++ b/System/Classes/Repository.php @@ -11,7 +11,7 @@ namespace System\Classes; use JsonException; use PDO; use Services\Database; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Model; use Throwable; @@ -21,7 +21,7 @@ use Throwable; * Конкретный репозиторий — в App\Repositories\* (например App\Repositories\BudgetRepository * extends Repository), таблица/класс строки задаются через конструктор: * `parent::__construct('budget', Budget::class)` (по умолчанию — Services\DataBase\Model). - * Подключение — $this->pdo (PdoDriver, не сырой \PDO — но с тем же query()/prepare()/... + * Подключение — $this->pdo (PdoConnection, не сырой \PDO — но с тем же query()/prepare()/... * API, включая fetch-методы через Statement). */ abstract class Repository @@ -32,8 +32,8 @@ abstract class Repository /** @var bool Первичный ключ — автоинкремент (влияет на processData()/create()) */ public bool $is_auto_increment = true; - /** @var PdoDriver Подключение */ - protected PdoDriver $pdo; + /** @var PdoConnection Подключение */ + protected PdoConnection $pdo; /** @var string Таблица, с которой работает репозиторий */ protected string $table; @@ -54,7 +54,7 @@ abstract class Repository } /** - * Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoDriver — он же считает + * Начинает транзакцию; на вложенном уровне — SAVEPOINT (см. PdoConnection — он же считает * вложенность: у него общий счётчик на всё подключение, а не свой на каждый Repository, * поэтому несколько репозиториев на одном Database::instance() безопасно участвуют * в одной внешней транзакции). diff --git a/img.jpg b/img.jpg new file mode 100644 index 0000000..95088d4 Binary files /dev/null and b/img.jpg differ diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..a0570d1 --- /dev/null +++ b/schema.sql @@ -0,0 +1,110 @@ +-- Схема БД проекта «Бюджет» (MariaDB/InnoDB). +-- Модель согласована в .claude/memory/бюджет_текущий_план.md — сверяться туда при любых изменениях. + +SET NAMES utf8mb4; + +CREATE TABLE users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + login VARCHAR(64) NOT NULL, + password VARCHAR(255) NOT NULL, + name VARCHAR(128) NOT NULL, + email VARCHAR(128) NULL, + date_add DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_login DATETIME NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_users_login (login) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE accounts ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + type_acc ENUM ('cash', 'bank', 'savings') NOT NULL, + title VARCHAR(128) NOT NULL, + summa DECIMAL(15, 2) NOT NULL DEFAULT 0, + currency CHAR(3) NOT NULL DEFAULT 'RUB', -- ISO 4217; курс — Services\CurrencyRate (ЦБ РФ), не хранится тут + description VARCHAR(255) NULL, -- свободное поле, например "счёт жены" — без отдельной структуры + include_in_total TINYINT(1) NOT NULL DEFAULT 1, -- чисто ручной переключатель пользователя, не системное правило по типу/валюте (дефолт 0 для savings — просто стартовое значение) + is_delete TINYINT(1) NOT NULL DEFAULT 0, + `order` INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_accounts_user_id (user_id), + CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- Ставка savings-счёта хранится историей, а не полем в accounts — старые периоды не должны +-- меняться задним числом при смене ставки; та же структура пригодится для кредитов позже. +CREATE TABLE account_rates ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + account_id INT UNSIGNED NOT NULL, + rate_percent DECIMAL(5, 2) NOT NULL, + valid_from DATE NOT NULL, + valid_to DATE NULL, + PRIMARY KEY (id), + KEY ix_account_rates_account_id (account_id), + CONSTRAINT fk_account_rates_account FOREIGN KEY (account_id) REFERENCES accounts (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- Необязательная надстройка над savings-счётом: без записи здесь — просто копилка без цели. +CREATE TABLE goals ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + account_id INT UNSIGNED NOT NULL, + target_summa DECIMAL(15, 2) NOT NULL, + deadline DATE NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_goals_account_id (account_id), + CONSTRAINT fk_goals_account FOREIGN KEY (account_id) REFERENCES accounts (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +CREATE TABLE categories ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + parent_id INT UNSIGNED NULL, + title VARCHAR(128) NOT NULL, + type ENUM ('income', 'expense') NOT NULL, + is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций + `order` INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_categories_user_id (user_id), + KEY ix_categories_parent_id (parent_id), + CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id), + -- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня + CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто +-- переехали между своими счетами, план/факт по категориям это не видит (см. бюджет_текущий_план.md). +-- account_f_id/account_in_id: income заполняет только _in, expense — только _f, transfer — оба. +CREATE TABLE transactions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + categorie_id INT UNSIGNED NULL, + account_f_id INT UNSIGNED NULL, + account_in_id INT UNSIGNED NULL, + date DATE NOT NULL, + summa DECIMAL(15, 2) NOT NULL, + currency CHAR(3) NULL, -- валюта операции (обычно = валюте счёта списания); NULL = рубли + rate_to_rub DECIMAL(10, 4) NULL, -- курс ЦБ РФ, зафиксированный на момент операции; NULL для рублёвых + type ENUM ('income', 'expense', 'transfer') NOT NULL, + PRIMARY KEY (id), + KEY ix_transactions_user_date (user_id, date), + KEY ix_transactions_categorie_id (categorie_id), + KEY ix_transactions_account_f_id (account_f_id), + KEY ix_transactions_account_in_id (account_in_id), + CONSTRAINT fk_transactions_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_transactions_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id), + CONSTRAINT fk_transactions_account_f FOREIGN KEY (account_f_id) REFERENCES accounts (id), + CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- Факт не хранится — считается на лету суммой transactions по категории за месяц. +CREATE TABLE budgets ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + categorie_id INT UNSIGNED NOT NULL, + plan_summ DECIMAL(15, 2) NOT NULL, + year SMALLINT UNSIGNED NOT NULL, + month TINYINT UNSIGNED NOT NULL, + type ENUM ('income', 'expense') NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month), + CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/tests/Unit/DatabaseTest.php b/tests/Unit/DatabaseTest.php index 1e17e39..6a9c915 100644 --- a/tests/Unit/DatabaseTest.php +++ b/tests/Unit/DatabaseTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; use ReflectionClass; use Services\Database; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use System\Classes\Config; use System\Classes\MyException; @@ -25,9 +25,9 @@ class DatabaseTest extends TestCase $prop->setValue(null, null); } - public function testDefaultConnectionReturnsPdoDriver(): void + public function testDefaultConnectionReturnsPdoConnection(): void { - $this->assertInstanceOf(PdoDriver::class, Database::instance()); + $this->assertInstanceOf(PdoConnection::class, Database::instance()); } public function testInstanceIsCachedPerConnectionName(): void @@ -38,8 +38,8 @@ class DatabaseTest extends TestCase public function testDifferentConnectionNamesAreDistinctInstances(): void { Config::set('db', [ - 'default' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'a']], - 'second' => ['driver' => 'pdo', 'connection' => ['host' => 'db', 'dbname' => 'b']], + 'default' => ['host' => 'db', 'dbname' => 'a'], + 'second' => ['host' => 'db', 'dbname' => 'b'], ]); $this->assertNotSame(Database::instance('default'), Database::instance('second')); @@ -50,12 +50,4 @@ class DatabaseTest extends TestCase $this->expectException(MyException::class); Database::instance('nonexistent'); } - - public function testUnknownDriverThrows(): void - { - Config::set('db', ['bad' => ['driver' => 'mysqli', 'connection' => []]]); - - $this->expectException(MyException::class); - Database::instance('bad'); - } } diff --git a/tests/Unit/PdoConnectionTest.php b/tests/Unit/PdoConnectionTest.php new file mode 100644 index 0000000..636e5f2 --- /dev/null +++ b/tests/Unit/PdoConnectionTest.php @@ -0,0 +1,164 @@ +connection = new PdoConnection($config); + + try { + $this->connection->pdo(); + } catch (MyException $e) { + $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); + } + + $this->connection->exec('DROP TABLE IF EXISTS pdo_connection_test'); + $this->connection->exec('CREATE TABLE pdo_connection_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))'); + } + + protected function tearDown(): void + { + Profiler::reset(); + + if (isset($this->connection)) { + try { + $this->connection->exec('DROP TABLE IF EXISTS pdo_connection_test'); + } catch (MyException) { + // соединения не было — нечего дропать + } + } + } + + public function testFailoverThrowsWithAllHostErrors(): void + { + // Чисто логика failover — не требует живой БД, оба хоста заведомо недоступны + $connection = new PdoConnection(['hosts' => ['bad-host-1', 'bad-host-2'], 'dbname' => 'x', 'user' => 'x', 'password' => 'x']); + + try { + $connection->pdo(); + $this->fail('Ожидалось MyException'); + } catch (MyException $e) { + $this->assertStringContainsString('bad-host-1', $e->getMessage()); + $this->assertStringContainsString('bad-host-2', $e->getMessage()); + } + } + + public function testQueryInsertsAndSelects(): void + { + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['foo']); + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['foo'])->fetch(); + + $this->assertSame('foo', $row['name']); + } + + public function testLastInsertId(): void + { + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['foo']); + $this->assertNotFalse($this->connection->lastInsertId()); + } + + public function testCommitPersistsChanges(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['committed']); + $this->connection->commit(); + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['committed'])->fetch(); + $this->assertNotFalse($row); + } + + public function testRollbackDiscardsChanges(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['rolled_back']); + $this->connection->rollback(); + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['rolled_back'])->fetch(); + $this->assertFalse($row); + } + + public function testNestedTransactionRollbackDoesNotAffectOuter(): void + { + $this->connection->beginTransaction(); + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['outer']); + + $this->connection->beginTransaction(); // SAVEPOINT sp_1 + $this->connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['inner']); + $this->connection->rollback(); // ROLLBACK TO SAVEPOINT sp_1 — не трогает 'outer' + + $this->connection->commit(); // фиксирует внешнюю транзакцию + + $outer = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['outer'])->fetch(); + $inner = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['inner'])->fetch(); + + $this->assertNotFalse($outer); + $this->assertFalse($inner); + } + + public function testTransactionHelperRollsBackOnException(): void + { + try { + $this->connection->transaction(function (PdoConnection $connection) { + $connection->query('INSERT INTO pdo_connection_test (name) VALUES (?)', ['should_rollback']); + throw new \RuntimeException('boom'); + }); + $this->fail('Ожидалось RuntimeException'); + } catch (\RuntimeException) { + // ожидаемо + } + + $row = $this->connection->query('SELECT * FROM pdo_connection_test WHERE name = ?', ['should_rollback'])->fetch(); + $this->assertFalse($row); + } + + public function testManualPrepareExecuteIsProfiledToo(): void + { + // Регрессия: профилирование должно ловить любой execute(), а не только PdoConnection::query() — + // Repository сам делает $this->pdo->prepare()->execute() напрямую (см. get()/create()/...). + Profiler::reset(); + + $stmt = $this->connection->prepare('INSERT INTO pdo_connection_test (name) VALUES (?)'); + $stmt->execute(['foo']); + + $this->assertSame(1, Profiler::count()); + } + + public function testInTransaction(): void + { + $this->assertFalse($this->connection->inTransaction()); + $this->connection->beginTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->connection->commit(); + $this->assertFalse($this->connection->inTransaction()); + } + + public function testSqliteDialectConnectsAndQueries(): void + { + // dialect — не требует живой MariaDB (в отличие от остальных тестов класса, см. setUp()), + // регрессия на DSN/подключение для диалекта, отличного от дефолтного mysql + $connection = new PdoConnection(['dialect' => 'sqlite', 'dbname' => ':memory:']); + $connection->exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)'); + $connection->query('INSERT INTO t (name) VALUES (?)', ['hello']); + + $row = $connection->query('SELECT * FROM t')->fetch(); + $this->assertSame('hello', $row['name']); + + $connection->beginTransaction(); + $connection->query('INSERT INTO t (name) VALUES (?)', ['rolled_back']); + $connection->rollback(); + + $count = $connection->query('SELECT COUNT(*) AS c FROM t')->fetch(); + $this->assertSame(1, (int) $count['c']); + } +} diff --git a/tests/Unit/PdoDriverTest.php b/tests/Unit/PdoDriverTest.php deleted file mode 100644 index 6c60312..0000000 --- a/tests/Unit/PdoDriverTest.php +++ /dev/null @@ -1,145 +0,0 @@ -driver = new PdoDriver($config); - - try { - $this->driver->pdo(); - } catch (MyException $e) { - $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); - } - - $this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test'); - $this->driver->exec('CREATE TABLE pdo_driver_test (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255))'); - } - - protected function tearDown(): void - { - Profiler::reset(); - - if (isset($this->driver)) { - try { - $this->driver->exec('DROP TABLE IF EXISTS pdo_driver_test'); - } catch (MyException) { - // соединения не было — нечего дропать - } - } - } - - public function testFailoverThrowsWithAllHostErrors(): void - { - // Чисто логика failover — не требует живой БД, оба хоста заведомо недоступны - $driver = new PdoDriver(['hosts' => ['bad-host-1', 'bad-host-2'], 'dbname' => 'x', 'user' => 'x', 'password' => 'x']); - - try { - $driver->pdo(); - $this->fail('Ожидалось MyException'); - } catch (MyException $e) { - $this->assertStringContainsString('bad-host-1', $e->getMessage()); - $this->assertStringContainsString('bad-host-2', $e->getMessage()); - } - } - - public function testQueryInsertsAndSelects(): void - { - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']); - $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['foo'])->fetch(); - - $this->assertSame('foo', $row['name']); - } - - public function testLastInsertId(): void - { - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['foo']); - $this->assertNotFalse($this->driver->lastInsertId()); - } - - public function testCommitPersistsChanges(): void - { - $this->driver->beginTransaction(); - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['committed']); - $this->driver->commit(); - - $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['committed'])->fetch(); - $this->assertNotFalse($row); - } - - public function testRollbackDiscardsChanges(): void - { - $this->driver->beginTransaction(); - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['rolled_back']); - $this->driver->rollback(); - - $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['rolled_back'])->fetch(); - $this->assertFalse($row); - } - - public function testNestedTransactionRollbackDoesNotAffectOuter(): void - { - $this->driver->beginTransaction(); - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['outer']); - - $this->driver->beginTransaction(); // SAVEPOINT sp_1 - $this->driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['inner']); - $this->driver->rollback(); // ROLLBACK TO SAVEPOINT sp_1 — не трогает 'outer' - - $this->driver->commit(); // фиксирует внешнюю транзакцию - - $outer = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['outer'])->fetch(); - $inner = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['inner'])->fetch(); - - $this->assertNotFalse($outer); - $this->assertFalse($inner); - } - - public function testTransactionHelperRollsBackOnException(): void - { - try { - $this->driver->transaction(function (PdoDriver $driver) { - $driver->query('INSERT INTO pdo_driver_test (name) VALUES (?)', ['should_rollback']); - throw new \RuntimeException('boom'); - }); - $this->fail('Ожидалось RuntimeException'); - } catch (\RuntimeException) { - // ожидаемо - } - - $row = $this->driver->query('SELECT * FROM pdo_driver_test WHERE name = ?', ['should_rollback'])->fetch(); - $this->assertFalse($row); - } - - public function testManualPrepareExecuteIsProfiledToo(): void - { - // Регрессия: профилирование должно ловить любой execute(), а не только PdoDriver::query() — - // Repository сам делает $this->pdo->prepare()->execute() напрямую (см. get()/create()/...). - Profiler::reset(); - - $stmt = $this->driver->prepare('INSERT INTO pdo_driver_test (name) VALUES (?)'); - $stmt->execute(['foo']); - - $this->assertSame(1, Profiler::count()); - } - - public function testInTransaction(): void - { - $this->assertFalse($this->driver->inTransaction()); - $this->driver->beginTransaction(); - $this->assertTrue($this->driver->inTransaction()); - $this->driver->commit(); - $this->assertFalse($this->driver->inTransaction()); - } -} diff --git a/tests/Unit/ProfilerTest.php b/tests/Unit/ProfilerTest.php index 425607b..c197368 100644 --- a/tests/Unit/ProfilerTest.php +++ b/tests/Unit/ProfilerTest.php @@ -9,7 +9,7 @@ class ProfilerTest extends TestCase { protected function setUp(): void { - // Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же + // Другие тесты (PdoConnectionTest/StatementTest/RepositoryTest) пишут в этот же // статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами. Profiler::reset(); } diff --git a/tests/Unit/RepositoryTest.php b/tests/Unit/RepositoryTest.php index beafb37..b08f1c1 100644 --- a/tests/Unit/RepositoryTest.php +++ b/tests/Unit/RepositoryTest.php @@ -6,7 +6,7 @@ use PDO; use PHPUnit\Framework\TestCase; use ReflectionClass; use Services\Database; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Classes\Profiler; use System\Classes\Config; use System\Classes\MyException; @@ -22,22 +22,22 @@ class RepositoryTestItemRepository extends Repository class RepositoryTest extends TestCase { - private PdoDriver $driver; + private PdoConnection $connection; private RepositoryTestItemRepository $repository; protected function setUp(): void { - $config = Config::get('db', 'default')['connection'] ?? []; - $this->driver = new PdoDriver($config); + $config = Config::get('db', 'default') ?? []; + $this->connection = new PdoConnection($config); try { - $this->driver->pdo(); + $this->connection->pdo(); } catch (MyException $e) { $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); } - $this->driver->exec('DROP TABLE IF EXISTS repository_test_items'); - $this->driver->exec('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)'); + $this->connection->exec('DROP TABLE IF EXISTS repository_test_items'); + $this->connection->exec('CREATE TABLE repository_test_items (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255), qty INT)'); $this->repository = new RepositoryTestItemRepository(); } @@ -46,9 +46,9 @@ class RepositoryTest extends TestCase { Profiler::reset(); - if (isset($this->driver)) { + if (isset($this->connection)) { try { - $this->driver->exec('DROP TABLE IF EXISTS repository_test_items'); + $this->connection->exec('DROP TABLE IF EXISTS repository_test_items'); } catch (MyException) { } } diff --git a/tests/Unit/StatementTest.php b/tests/Unit/StatementTest.php index 5fd6717..8fb0f05 100644 --- a/tests/Unit/StatementTest.php +++ b/tests/Unit/StatementTest.php @@ -3,22 +3,22 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; -use Services\DataBase\Classes\PdoDriver; +use Services\DataBase\Classes\PdoConnection; use Services\DataBase\Classes\Profiler; use System\Classes\Config; use System\Classes\MyException; class StatementTest extends TestCase { - private PdoDriver $driver; + private PdoConnection $connection; protected function setUp(): void { - $config = Config::get('db', 'default')['connection'] ?? []; - $this->driver = new PdoDriver($config); + $config = Config::get('db', 'default') ?? []; + $this->connection = new PdoConnection($config); try { - $this->driver->pdo(); + $this->connection->pdo(); } catch (MyException $e) { $this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage()); } @@ -31,21 +31,21 @@ class StatementTest extends TestCase public function testShowQuerySubstitutesPositionalParams(): void { - $statement = $this->driver->query('SELECT ? AS a, ? AS b', ['foo', 42]); + $statement = $this->connection->query('SELECT ? AS a, ? AS b', ['foo', 42]); $this->assertSame("SELECT 'foo' AS a, 42 AS b", $statement->showQuery()); } public function testShowQuerySubstitutesNamedParams(): void { - $statement = $this->driver->query('SELECT :name AS name', ['name' => 'bicycle']); + $statement = $this->connection->query('SELECT :name AS name', ['name' => 'bicycle']); $this->assertSame("SELECT 'bicycle' AS name", $statement->showQuery()); } public function testSqIsAliasForShowQuery(): void { - $statement = $this->driver->query('SELECT ? AS a', ['foo']); + $statement = $this->connection->query('SELECT ? AS a', ['foo']); $this->assertSame($statement->showQuery(), $statement->sq()); }