This commit is contained in:
Egor Isaev 2026-08-11 17:05:39 +03:00
parent 29c98a2a7a
commit 69ce89a695
32 changed files with 1185 additions and 280 deletions

View File

@ -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]]).

View File

@ -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')`. Эти два оживут сами,

View File

@ -0,0 +1,168 @@
Спроектируй и реализуй универсальный конструктор пользовательских метрик для аналитического дашборда.
Цель
Пользователь без знания программирования должен иметь возможность собрать собственную метрику из доступных данных, настроить способ расчёта и выбрать её визуальное представление.
Конструктор должен подходить как для простых показателей, так и для сложных вычисляемых метрик.
Основной сценарий
Раздели создание метрики на три логических этапа:
1. Основная информация.
2. Расчёт.
3. Отображение.
Пользователь должен свободно перемещаться между этапами, не теряя введённые данные.
Основная информация
Предусмотри:
- название метрики;
- необязательное описание;
- категорию или смысловую группу;
- единицу измерения;
- понятное отличие новой метрики от редактирования существующей.
Расчёт
Поддержи несколько способов создания метрики:
- выбор готового показателя;
- сумма выбранных значений;
- разница между показателями;
- отношение или процент;
- среднее, минимум и максимум;
- накопительный итог;
- собственная формула.
Источники данных должны определяться динамически. Не используй жёстко заданные категории или показатели.
Позволь выбирать:
- набор учитываемых данных;
- временной период;
- режим расчёта;
- фильтры;
- группировку;
- правила обработки отсутствующих значений.
Формулы
Добавь редактор собственных формул с поддержкой:
- арифметических операций;
- скобок;
- доступных показателей и категорий;
- ссылок на другие пользовательские метрики;
- функций агрегации;
- сравнений с предыдущими периодами;
- автодополнения;
- описания доступных переменных;
- проверки формулы до сохранения.
Конструктор должен обнаруживать:
- неизвестные переменные;
- синтаксические ошибки;
- деление на ноль;
- циклические зависимости между метриками;
- несовместимые единицы измерения;
- отсутствующие источники данных.
Ошибки показывай рядом с проблемным полем и объясняй понятным языком.
Отображение
Позволь настроить:
- формат числа;
- количество знаков после запятой;
- денежную единицу или процент;
- направление оценки результата;
- целевое значение;
- предупредительные и критические пороги;
- цветовые состояния;
- выбранные графики;
- отображение на дашборде.
Поддержи основные варианты визуализации:
- числовая карточка;
- прогресс;
- сравнение;
- динамика;
- столбчатый график;
- линейный график;
- компактный индикатор состояния.
Показывай живое превью метрики. Оно должно обновляться при изменении настроек, но не сохранять черновик как готовую метрику.
Пользовательский опыт
Интерфейс должен постепенно раскрывать сложность:
- сначала показывать только обязательные параметры;
- дополнительные настройки размещать в раскрывающихся блоках;
- скрывать поля, которые не относятся к выбранному типу расчёта;
- сохранять незавершённый черновик;
- предупреждать при закрытии формы с несохранёнными изменениями;
- не перегружать экран вспомогательным текстом.
Предусмотри понятные состояния:
- пустые данные;
- загрузка;
- ошибка;
- некорректная формула;
- успешное сохранение;
- редактирование;
- дублирование;
- удаление и восстановление.
Управление метриками
Пользователь должен иметь возможность:
- создавать метрики;
- редактировать;
- дублировать;
- временно скрывать;
- закреплять на дашборде;
- менять порядок;
- переносить в хранилище;
- удалять с возможностью восстановления;
- окончательно удалять;
- импортировать и экспортировать конфигурации.
Совместимость
Модель метрики должна быть версионируемой. При развитии конструктора старые сохранённые метрики должны продолжать работать или автоматически мигрировать в новую схему.
Изменение или удаление источника данных не должно незаметно ломать метрику. Показывай её состояние и предлагай выбрать замену.
Адаптивность и доступность
Конструктор должен одинаково хорошо работать на компьютерах и мобильных устройствах:
- без горизонтального скролла;
- с удобными зонами нажатия;
- с поддержкой экранной клавиатуры;
- с полной клавиатурной навигацией;
- с корректными focus-состояниями;
- с доступными названиями элементов;
- с достаточным контрастом.
Критерии готовности
- Простую метрику можно создать за несколько понятных действий.
- Сложную формулу можно собрать и проверить до сохранения.
- Все параметры влияют на живое превью.
- Некорректную конфигурацию невозможно сохранить.
- Ошибки объясняют причину и способ исправления.
- Старые метрики остаются совместимыми.
- Интерфейс корректно работает на мобильных и настольных экранах.
- Нет жёсткой зависимости от конкретных категорий, показателей или структуры данных.
- Основные пользовательские сценарии покрыты автоматическими тестами.

View File

@ -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. **Цели (план/факт) + анализ бюджета** — сейчас.

View File

@ -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-файлов.

3
.gitignore vendored
View File

@ -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/**/

View File

@ -0,0 +1,36 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description AccountRateRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы account_rates (история процентных ставок savings-счёта).
*/
class AccountRateRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('account_rates', connection: $connection);
}
/**
* Текущая ставка счёта (запись с valid_to IS NULL — см. бюджет_текущий_план.md).
*
* @param int $account_id
* @return object|false|null Строка account_rates | false, если не найдено | null (не бывает — $where не пуст)
*/
public function getCurrentRate(int $account_id): mixed
{
return $this->getItemWhere("account_id = $account_id AND valid_to IS NULL");
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description AccountRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы accounts (счета: cash/bank/savings).
*/
class AccountRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('accounts', connection: $connection);
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description BudgetRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы budgets (план по категории на месяц; факт считается на лету из transactions).
*/
class BudgetRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('budgets', connection: $connection);
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CategoryRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы categories (статьи income/expense, с подкатегориями через parent_id).
*/
class CategoryRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('categories', connection: $connection);
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description GoalRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы goals (цель для savings-счёта — необязательная надстройка).
*/
class GoalRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('goals', connection: $connection);
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description TransactionRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы transactions (income/expense/transfer).
*/
class TransactionRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('transactions', connection: $connection);
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description UserRepository.php
* @copyright (c) 11/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы users.
*/
class UserRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('users', connection: $connection);
}
}

View File

@ -8,8 +8,7 @@ return [
'db' => [
'default' => [
'driver' => 'pdo',
'connection' => [
'dialect' => 'mysql', // mysql|pgsql|sqlite — какой DSN собирает PdoConnection::buildDsn()
'host' => '192.168.1.36',
'port' => 3307,
'user' => 'root',
@ -17,7 +16,6 @@ return [
'dbname' => 'budget'
],
],
],
'elasticsearch' => [
'default' => [

0
App/logs/.gitkeep Normal file
View File

0
App/media/css/.gitkeep Normal file
View File

0
App/media/js/.gitkeep Normal file
View File

View File

@ -8,6 +8,7 @@
*/
use System\Classes\CSRF;
use System\Classes\ProfilerToolbar;
?>
<!DOCTYPE html>
@ -25,6 +26,7 @@ use System\Classes\CSRF;
<?php if ($user !== null): ?>
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p>
<?= ProfilerToolbar::render() ?>
<?php else: ?>
<?php if ($error !== null): ?>
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>

View File

@ -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)

View File

@ -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

View File

@ -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, '?');

View File

@ -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<string,PdoDriver> Кэш подключений по имени */
/** @var array<string,PdoConnection> Кэш подключений по имени */
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];

View File

@ -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();

View File

@ -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() безопасно участвуют
* в одной внешней транзакции).

BIN
img.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

110
schema.sql Normal file
View File

@ -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;

View File

@ -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');
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Services\DataBase\Classes\PdoConnection;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\MyException;
class PdoConnectionTest extends TestCase
{
private PdoConnection $connection;
protected function setUp(): void
{
$config = Config::get('db', 'default') ?? [];
$this->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']);
}
}

View File

@ -1,145 +0,0 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Services\DataBase\Classes\PdoDriver;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\MyException;
class PdoDriverTest extends TestCase
{
private PdoDriver $driver;
protected function setUp(): void
{
$config = Config::get('db', 'default')['connection'] ?? [];
$this->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());
}
}

View File

@ -9,7 +9,7 @@ class ProfilerTest extends TestCase
{
protected function setUp(): void
{
// Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же
// Другие тесты (PdoConnectionTest/StatementTest/RepositoryTest) пишут в этот же
// статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами.
Profiler::reset();
}

View File

@ -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) {
}
}

View File

@ -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());
}