dev
This commit is contained in:
parent
29c98a2a7a
commit
69ce89a695
@ -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]]).
|
||||
|
||||
@ -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')`. Эти два оживут сами,
|
||||
|
||||
168
.claude/memory/Общий_промт_по_конструктору_метрик.md
Normal file
168
.claude/memory/Общий_промт_по_конструктору_метрик.md
Normal file
@ -0,0 +1,168 @@
|
||||
Спроектируй и реализуй универсальный конструктор пользовательских метрик для аналитического дашборда.
|
||||
|
||||
Цель
|
||||
|
||||
Пользователь без знания программирования должен иметь возможность собрать собственную метрику из доступных данных, настроить способ расчёта и выбрать её визуальное представление.
|
||||
|
||||
Конструктор должен подходить как для простых показателей, так и для сложных вычисляемых метрик.
|
||||
|
||||
Основной сценарий
|
||||
|
||||
Раздели создание метрики на три логических этапа:
|
||||
|
||||
1. Основная информация.
|
||||
2. Расчёт.
|
||||
3. Отображение.
|
||||
|
||||
Пользователь должен свободно перемещаться между этапами, не теряя введённые данные.
|
||||
|
||||
Основная информация
|
||||
|
||||
Предусмотри:
|
||||
|
||||
- название метрики;
|
||||
- необязательное описание;
|
||||
- категорию или смысловую группу;
|
||||
- единицу измерения;
|
||||
- понятное отличие новой метрики от редактирования существующей.
|
||||
|
||||
Расчёт
|
||||
|
||||
Поддержи несколько способов создания метрики:
|
||||
|
||||
- выбор готового показателя;
|
||||
- сумма выбранных значений;
|
||||
- разница между показателями;
|
||||
- отношение или процент;
|
||||
- среднее, минимум и максимум;
|
||||
- накопительный итог;
|
||||
- собственная формула.
|
||||
|
||||
Источники данных должны определяться динамически. Не используй жёстко заданные категории или показатели.
|
||||
|
||||
Позволь выбирать:
|
||||
|
||||
- набор учитываемых данных;
|
||||
- временной период;
|
||||
- режим расчёта;
|
||||
- фильтры;
|
||||
- группировку;
|
||||
- правила обработки отсутствующих значений.
|
||||
|
||||
Формулы
|
||||
|
||||
Добавь редактор собственных формул с поддержкой:
|
||||
|
||||
- арифметических операций;
|
||||
- скобок;
|
||||
- доступных показателей и категорий;
|
||||
- ссылок на другие пользовательские метрики;
|
||||
- функций агрегации;
|
||||
- сравнений с предыдущими периодами;
|
||||
- автодополнения;
|
||||
- описания доступных переменных;
|
||||
- проверки формулы до сохранения.
|
||||
|
||||
Конструктор должен обнаруживать:
|
||||
|
||||
- неизвестные переменные;
|
||||
- синтаксические ошибки;
|
||||
- деление на ноль;
|
||||
- циклические зависимости между метриками;
|
||||
- несовместимые единицы измерения;
|
||||
- отсутствующие источники данных.
|
||||
|
||||
Ошибки показывай рядом с проблемным полем и объясняй понятным языком.
|
||||
|
||||
Отображение
|
||||
|
||||
Позволь настроить:
|
||||
|
||||
- формат числа;
|
||||
- количество знаков после запятой;
|
||||
- денежную единицу или процент;
|
||||
- направление оценки результата;
|
||||
- целевое значение;
|
||||
- предупредительные и критические пороги;
|
||||
- цветовые состояния;
|
||||
- выбранные графики;
|
||||
- отображение на дашборде.
|
||||
|
||||
Поддержи основные варианты визуализации:
|
||||
|
||||
- числовая карточка;
|
||||
- прогресс;
|
||||
- сравнение;
|
||||
- динамика;
|
||||
- столбчатый график;
|
||||
- линейный график;
|
||||
- компактный индикатор состояния.
|
||||
|
||||
Показывай живое превью метрики. Оно должно обновляться при изменении настроек, но не сохранять черновик как готовую метрику.
|
||||
|
||||
Пользовательский опыт
|
||||
|
||||
Интерфейс должен постепенно раскрывать сложность:
|
||||
|
||||
- сначала показывать только обязательные параметры;
|
||||
- дополнительные настройки размещать в раскрывающихся блоках;
|
||||
- скрывать поля, которые не относятся к выбранному типу расчёта;
|
||||
- сохранять незавершённый черновик;
|
||||
- предупреждать при закрытии формы с несохранёнными изменениями;
|
||||
- не перегружать экран вспомогательным текстом.
|
||||
|
||||
Предусмотри понятные состояния:
|
||||
|
||||
- пустые данные;
|
||||
- загрузка;
|
||||
- ошибка;
|
||||
- некорректная формула;
|
||||
- успешное сохранение;
|
||||
- редактирование;
|
||||
- дублирование;
|
||||
- удаление и восстановление.
|
||||
|
||||
Управление метриками
|
||||
|
||||
Пользователь должен иметь возможность:
|
||||
|
||||
- создавать метрики;
|
||||
- редактировать;
|
||||
- дублировать;
|
||||
- временно скрывать;
|
||||
- закреплять на дашборде;
|
||||
- менять порядок;
|
||||
- переносить в хранилище;
|
||||
- удалять с возможностью восстановления;
|
||||
- окончательно удалять;
|
||||
- импортировать и экспортировать конфигурации.
|
||||
|
||||
Совместимость
|
||||
|
||||
Модель метрики должна быть версионируемой. При развитии конструктора старые сохранённые метрики должны продолжать работать или автоматически мигрировать в новую схему.
|
||||
|
||||
Изменение или удаление источника данных не должно незаметно ломать метрику. Показывай её состояние и предлагай выбрать замену.
|
||||
|
||||
Адаптивность и доступность
|
||||
|
||||
Конструктор должен одинаково хорошо работать на компьютерах и мобильных устройствах:
|
||||
|
||||
- без горизонтального скролла;
|
||||
- с удобными зонами нажатия;
|
||||
- с поддержкой экранной клавиатуры;
|
||||
- с полной клавиатурной навигацией;
|
||||
- с корректными focus-состояниями;
|
||||
- с доступными названиями элементов;
|
||||
- с достаточным контрастом.
|
||||
|
||||
Критерии готовности
|
||||
|
||||
- Простую метрику можно создать за несколько понятных действий.
|
||||
- Сложную формулу можно собрать и проверить до сохранения.
|
||||
- Все параметры влияют на живое превью.
|
||||
- Некорректную конфигурацию невозможно сохранить.
|
||||
- Ошибки объясняют причину и способ исправления.
|
||||
- Старые метрики остаются совместимыми.
|
||||
- Интерфейс корректно работает на мобильных и настольных экранах.
|
||||
- Нет жёсткой зависимости от конкретных категорий, показателей или структуры данных.
|
||||
- Основные пользовательские сценарии покрыты автоматическими тестами.
|
||||
@ -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. **Цели (план/факт) + анализ бюджета** — сейчас.
|
||||
|
||||
@ -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
3
.gitignore
vendored
@ -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/**/
|
||||
|
||||
36
App/Repositories/AccountRateRepository.php
Normal file
36
App/Repositories/AccountRateRepository.php
Normal 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");
|
||||
}
|
||||
}
|
||||
25
App/Repositories/AccountRepository.php
Normal file
25
App/Repositories/AccountRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
App/Repositories/BudgetRepository.php
Normal file
25
App/Repositories/BudgetRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
App/Repositories/CategoryRepository.php
Normal file
25
App/Repositories/CategoryRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
App/Repositories/GoalRepository.php
Normal file
25
App/Repositories/GoalRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
App/Repositories/TransactionRepository.php
Normal file
25
App/Repositories/TransactionRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
App/Repositories/UserRepository.php
Normal file
25
App/Repositories/UserRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -8,14 +8,12 @@ return [
|
||||
|
||||
'db' => [
|
||||
'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'
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
0
App/logs/.gitkeep
Normal file
0
App/logs/.gitkeep
Normal file
0
App/media/css/.gitkeep
Normal file
0
App/media/css/.gitkeep
Normal file
0
App/media/js/.gitkeep
Normal file
0
App/media/js/.gitkeep
Normal 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>
|
||||
|
||||
50
CLAUDE.md
50
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)
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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, '?');
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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() безопасно участвуют
|
||||
* в одной внешней транзакции).
|
||||
|
||||
110
schema.sql
Normal file
110
schema.sql
Normal 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;
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
164
tests/Unit/PdoConnectionTest.php
Normal file
164
tests/Unit/PdoConnectionTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ class ProfilerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Другие тесты (PdoDriverTest/StatementTest/RepositoryTest) пишут в этот же
|
||||
// Другие тесты (PdoConnectionTest/StatementTest/RepositoryTest) пишут в этот же
|
||||
// статический реестр, если есть живое подключение к БД — сбрасываем перед своими тестами.
|
||||
Profiler::reset();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user