dev
This commit is contained in:
parent
913e3eec38
commit
c21555f18f
@ -455,11 +455,14 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
|
||||
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
|
||||
план, часть реализована (см. ниже):
|
||||
|
||||
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых».
|
||||
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая
|
||||
пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`,
|
||||
`valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом
|
||||
не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
|
||||
- **При создании копилки** (форма счёта, `type_acc = savings`) — поле «Ставка, % годовых»
|
||||
**обязательное, не опционально** — уточнено явно: у копилки/цели процент на остаток есть
|
||||
всегда, счёт `savings` без ставки не бывает (в отличие от `goal_target_summa`/`goal_deadline` —
|
||||
вот те действительно опциональная надстройка). Создаёт первую строку `account_rates`:
|
||||
`valid_from` = дата создания счёта, `valid_to = null`. **Реализовано** —
|
||||
`AccountsController::createAction()` требует `rate_percent` при `type_acc = savings` (иначе
|
||||
`422`), `valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним
|
||||
числом не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
|
||||
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
|
||||
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
|
||||
дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог
|
||||
@ -468,8 +471,19 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
|
||||
`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()` ещё не написан.
|
||||
**Реализовано** (2026-08-15) — ровно логика выше (закрыть текущую, открыть новую, одной
|
||||
транзакцией) плюс защита, которой в описании выше не было явно проговорено, но которую иначе
|
||||
легко нарушить: `$valid_from` новой ставки должен быть строго позже `valid_from` текущей —
|
||||
иначе закрываемая строка получила бы `valid_to` раньше собственного `valid_from` (невалидный
|
||||
период задом наперёд). Нарушение → `MyException` (сообщение с обеими датами), транзакция
|
||||
откатывается целиком — ни закрытия старой строки, ни новой записи. Тесты —
|
||||
`tests/Unit/AccountRateRepositoryTest.php` (временная таблица, не боевая `account_rates`,
|
||||
тот же приём, что у `BudgetRepositoryTest`/`CategoryRepositoryTest`): создание без текущей
|
||||
ставки, закрытие+открытие, отказ на дате ≤ текущей `valid_from` (включая точное совпадение),
|
||||
откат при отказе, независимость истории разных счетов. **Не сделано осознанно** — ещё нет
|
||||
контроллер-эндпоинта/UI-кнопки «изменить ставку» на карточке копилки (само действие в разделе
|
||||
`Goals` выше упомянуто, но отдельно от этой задачи не запрашивалось — метод самодостаточен и
|
||||
протестирован сам по себе, вызывать его сейчас неоткуда).
|
||||
- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
|
||||
ставке, дневная капитализация (не линейно) — см. выше.
|
||||
|
||||
@ -566,6 +580,44 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
|
||||
Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты
|
||||
проверены curl (302 на /login без сессии, без 500).
|
||||
|
||||
**Транзакции — ручной ввод (create/edit/delete) реализован** (2026-08-15):
|
||||
`App/Controller/TransactionsController.php` — тот же паттерн, что у Accounts/Categories
|
||||
(JSON, CSRF/владение через `user_id`), плюс то, чего не было ни у одного из предыдущих CRUD:
|
||||
- **Баланс счёта обновляется атомарно** в той же БД-транзакции, что и сама операция —
|
||||
`AccountRepository::adjustBalance()` (новый метод, читает текущий `summa` и перезаписывает).
|
||||
- **Уход в минус невозможен** (см. `Accounts` выше) — expense/transfer, на которые не хватает
|
||||
средств на счёте списания, отклоняются 422 `«Недостаточно средств на счёте»`; при правке
|
||||
порог считается так, будто старая версия операции ещё не списана (иначе правка своей же
|
||||
операции без изменения суммы ложно упёрлась бы в нехватку).
|
||||
- **Перевод между счетами разных валют — отклоняется 422.** Не решённая заранее фича, а
|
||||
следствие схемы: у `transactions` одна пара `summa`/`currency` на строку, представить перевод
|
||||
с конвертацией (разные суммы на разных концах) ею нельзя — не проектировать эту фичу сейчас,
|
||||
просто не дать создать операцию, которую схема не может корректно хранить.
|
||||
См. `.claude/memory/feedback_check_domain_logic.md` — тот же принцип «не просто прошло валидацию,
|
||||
а имеет ли смысл по домену», применённый здесь ещё до того, как кто-то наткнулся на баг.
|
||||
- **Категория/счета — по владению и типу**, не только «существует»: `categorie_id` обязателен
|
||||
для income/expense (тип статьи должен совпадать с типом операции), всегда `null` для transfer
|
||||
независимо от того, что пришло в форме (см. `Transactions` выше — «без категории, без
|
||||
исключений»); `account_f_id`/`account_in_id` — по типу операции (expense → только f, income →
|
||||
только in, transfer → оба, разные).
|
||||
- **Курс (`rate_to_rub`)** — `Services\CurrencyRate::instance()->rate($currency)` (уже был
|
||||
реализован отдельно, до этой сессии) в момент создания/правки; валюта операции = валюта счёта
|
||||
(списания для expense/transfer, зачисления для income) — не выбирается вручную в форме.
|
||||
- **`transaction_history`** (таблица уже была в `schema.sql`, но не наполнялась) — новый
|
||||
`App\Repositories\TransactionHistoryRepository::log()`, пишет снимок `create`/`edit`/`delete`
|
||||
той же БД-транзакцией. Только запись — само чтение лога и механизм отката по-прежнему не
|
||||
спроектированы (см. `Transactions` → «Лог правок/удалений» выше).
|
||||
- UI — `App/view/Transactions/index.html` + `App/media/js/transactions.js`, модалка на самой
|
||||
странице `/transactions` (не на дашборде, в отличие от Accounts) — тип операции тайлами
|
||||
(Расход/Доход/Перевод), остальные поля обычными `select`/`input`, показ/скрытие и
|
||||
обязательность полей зависит от типа. Список операций (`indexAction`, уже был) дополнен
|
||||
меню правки/удаления на каждой строке.
|
||||
- Тесты — не добавлены (в проекте вообще нет тестов на уровне контроллеров, см. таблицу тестов в
|
||||
CLAUDE.md — только Repository/Model/Core). 266 тестов, всё зелено (8 skip). Живой флоу в
|
||||
браузере не проверен (нет учётных данных) — маршруты проверены curl (302/403 без сессии/CSRF,
|
||||
без 500), `php -l` на все изменённые файлы.
|
||||
- **Голосовой/AI-ввод (`transaction_drafts`) — не начат**, следующий шаг по этому приоритету.
|
||||
|
||||
**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
|
||||
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
|
||||
без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/
|
||||
|
||||
@ -53,6 +53,10 @@ class AccountsController extends Controller
|
||||
$type_acc = $request->post('type_acc');
|
||||
$rate_percent = $request->post('rate_percent');
|
||||
|
||||
if ($type_acc === 'savings' && (!is_numeric($rate_percent) || (float)$rate_percent < 0)) {
|
||||
return $this->json(['success' => false, 'errors' => ['rate_percent' => 'Укажите ставку, % годовых']], 422);
|
||||
}
|
||||
|
||||
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
|
||||
|
||||
if ($goal_error !== null) {
|
||||
@ -78,7 +82,7 @@ class AccountsController extends Controller
|
||||
$account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent, $goal_target, $goal_deadline) {
|
||||
$account_id = $account_repo->create($data);
|
||||
|
||||
if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') {
|
||||
if ($type_acc === 'savings') {
|
||||
(new AccountRateRepository())->create([
|
||||
'account_id' => $account_id,
|
||||
'rate_percent' => (float)$rate_percent,
|
||||
@ -124,6 +128,10 @@ class AccountsController extends Controller
|
||||
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422);
|
||||
}
|
||||
|
||||
if ($type_acc !== $account->type_acc) {
|
||||
return $this->json(['success' => false, 'errors' => ['type_acc' => 'Тип счёта нельзя изменить после создания']], 422);
|
||||
}
|
||||
|
||||
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
|
||||
|
||||
if ($goal_error !== null) {
|
||||
|
||||
@ -85,8 +85,7 @@ class IndexController extends Controller
|
||||
$budget_repo->getYear($user->id, $year)
|
||||
) : [];
|
||||
|
||||
// Факт за месяц — реальные transactions (таблица/репозиторий уже есть, UI для их ввода
|
||||
// пока нет, поэтому в живой БД сумма обычно 0 — это корректный факт, не заглушка).
|
||||
// Факт за месяц — реальные transactions (ручной ввод — App\Controller\TransactionsController).
|
||||
$fact_month = $user ? (new TransactionRepository())->getMonthActualByCategory($user->id, $year, $month) : [];
|
||||
|
||||
// % бюджета по расходам за месяц — план/факт только по expense-статьям (для плашки в шапке).
|
||||
|
||||
@ -8,15 +8,29 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repositories\AccountRepository;
|
||||
use App\Repositories\CategoryRepository;
|
||||
use App\Repositories\TransactionHistoryRepository;
|
||||
use App\Repositories\TransactionRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
use Services\Auth;
|
||||
use Services\CurrencyRate;
|
||||
use System\Classes\Controller;
|
||||
use System\Classes\HTTP\HTTPException;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\Request;
|
||||
use System\Classes\Validation;
|
||||
|
||||
/**
|
||||
* Список транзакций пользователя — последние 50, с категорией и счётом.
|
||||
* Список транзакций пользователя (последние 50, с категорией и счётом) + ручной ввод —
|
||||
* CRUD-модалка на самой странице (см. App/view/Transactions/index.html,
|
||||
* App/media/js/transactions.js), тот же паттерн, что и у App\Controller\AccountsController.
|
||||
*
|
||||
* Баланс счёта (`accounts.summa`) — хранимый кэш, обновляется атомарно в той же БД-транзакции,
|
||||
* что и сама операция (см. бюджет_текущий_план.md → Transactions → «Баланс счёта»),
|
||||
* через AccountRepository::adjustBalance(). Уход в минус для cash/bank/savings невозможен —
|
||||
* expense/transfer, на которые не хватает средств на счёте списания, отклоняются как 422,
|
||||
* до открытия БД-транзакции.
|
||||
*/
|
||||
class TransactionsController extends Controller
|
||||
{
|
||||
@ -27,6 +41,371 @@ class TransactionsController extends Controller
|
||||
* @throws HTTPException|MyException
|
||||
*/
|
||||
public function indexAction(): string
|
||||
{
|
||||
$auth_user = Auth::instance()->getUser();
|
||||
$user = $this->currentUser();
|
||||
|
||||
$transactions = (new TransactionRepository())->getRecentForUser($user->id, 50);
|
||||
$accounts = (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`');
|
||||
$categories = (new CategoryRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], 'title');
|
||||
|
||||
return $this->render('index', [
|
||||
'user' => $auth_user,
|
||||
'transactions' => $transactions,
|
||||
'accounts' => $accounts ?: [],
|
||||
'categories' => $categories ?: [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string JSON
|
||||
* @throws MyException
|
||||
*/
|
||||
public function createAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
$user = $this->currentUser();
|
||||
|
||||
$validation = $this->buildValidation($request->post());
|
||||
|
||||
if (!$validation->check()) {
|
||||
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
|
||||
}
|
||||
|
||||
$type = $request->post('type');
|
||||
$summa = (float)$request->post('summa');
|
||||
|
||||
if ($summa <= 0) {
|
||||
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма должна быть больше 0']], 422);
|
||||
}
|
||||
|
||||
$account_repo = new AccountRepository();
|
||||
$category_repo = new CategoryRepository();
|
||||
|
||||
[$account_f, $account_in, $account_error_field, $account_error] = $this->resolveAccounts(
|
||||
$account_repo, $user, $type, $request->post('account_f_id'), $request->post('account_in_id')
|
||||
);
|
||||
|
||||
if ($account_error !== null) {
|
||||
return $this->json(['success' => false, 'errors' => [$account_error_field => $account_error]], 422);
|
||||
}
|
||||
|
||||
[$categorie_id, $category_error] = $this->resolveCategory($category_repo, $user, $type, $request->post('categorie_id'));
|
||||
|
||||
if ($category_error !== null) {
|
||||
return $this->json(['success' => false, 'errors' => ['categorie_id' => $category_error]], 422);
|
||||
}
|
||||
|
||||
if ($account_f !== null && (float)$account_f->summa < $summa) {
|
||||
return $this->json(['success' => false, 'errors' => ['summa' => 'Недостаточно средств на счёте']], 422);
|
||||
}
|
||||
|
||||
$currency_account = $type === 'income' ? $account_in : $account_f;
|
||||
$currency = $currency_account->currency;
|
||||
$rate_to_rub = $currency !== 'RUB' ? CurrencyRate::instance()->rate($currency) : null;
|
||||
|
||||
$data = [
|
||||
'user_id' => $user->id,
|
||||
'categorie_id' => $categorie_id,
|
||||
'account_f_id' => $account_f?->id,
|
||||
'account_in_id' => $account_in?->id,
|
||||
'date' => $request->post('date'),
|
||||
'summa' => $summa,
|
||||
'currency' => $currency,
|
||||
'rate_to_rub' => $rate_to_rub,
|
||||
'type' => $type,
|
||||
];
|
||||
|
||||
$tx_repo = new TransactionRepository();
|
||||
|
||||
$tx_repo->transaction(function () use ($tx_repo, $account_repo, $data, $account_f, $account_in, $summa) {
|
||||
$id = $tx_repo->create($data);
|
||||
|
||||
$this->applyBalance($account_repo, $account_f, $account_in, $summa);
|
||||
|
||||
(new TransactionHistoryRepository())->log($id, $data['user_id'], 'create', ['id' => $id] + $data);
|
||||
});
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string JSON
|
||||
* @throws HTTPException|MyException
|
||||
*/
|
||||
public function editAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
$user = $this->currentUser();
|
||||
$tx_repo = new TransactionRepository();
|
||||
$tx = $this->ownTransaction($tx_repo, $user, (int)$request->param(0));
|
||||
|
||||
$validation = $this->buildValidation($request->post());
|
||||
|
||||
if (!$validation->check()) {
|
||||
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
|
||||
}
|
||||
|
||||
$type = $request->post('type');
|
||||
$summa = (float)$request->post('summa');
|
||||
|
||||
if ($summa <= 0) {
|
||||
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма должна быть больше 0']], 422);
|
||||
}
|
||||
|
||||
$account_repo = new AccountRepository();
|
||||
$category_repo = new CategoryRepository();
|
||||
|
||||
[$account_f, $account_in, $account_error_field, $account_error] = $this->resolveAccounts(
|
||||
$account_repo, $user, $type, $request->post('account_f_id'), $request->post('account_in_id')
|
||||
);
|
||||
|
||||
if ($account_error !== null) {
|
||||
return $this->json(['success' => false, 'errors' => [$account_error_field => $account_error]], 422);
|
||||
}
|
||||
|
||||
[$categorie_id, $category_error] = $this->resolveCategory($category_repo, $user, $type, $request->post('categorie_id'));
|
||||
|
||||
if ($category_error !== null) {
|
||||
return $this->json(['success' => false, 'errors' => ['categorie_id' => $category_error]], 422);
|
||||
}
|
||||
|
||||
// Овердрафт считаем так, как будто старая версия операции ещё не списана со счёта —
|
||||
// иначе правка своей же операции без изменения суммы могла бы ложно упереться в "не хватает".
|
||||
if ($account_f !== null) {
|
||||
$available = (float)$account_f->summa;
|
||||
|
||||
if ((int)$tx->account_f_id === (int)$account_f->id && in_array($tx->type, ['expense', 'transfer'], true)) {
|
||||
$available += (float)$tx->summa;
|
||||
}
|
||||
|
||||
if ($available < $summa) {
|
||||
return $this->json(['success' => false, 'errors' => ['summa' => 'Недостаточно средств на счёте']], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$currency_account = $type === 'income' ? $account_in : $account_f;
|
||||
$currency = $currency_account->currency;
|
||||
$rate_to_rub = $currency !== 'RUB' ? CurrencyRate::instance()->rate($currency) : null;
|
||||
|
||||
$data = [
|
||||
'id' => $tx->id,
|
||||
'categorie_id' => $categorie_id,
|
||||
'account_f_id' => $account_f?->id,
|
||||
'account_in_id' => $account_in?->id,
|
||||
'date' => $request->post('date'),
|
||||
'summa' => $summa,
|
||||
'currency' => $currency,
|
||||
'rate_to_rub' => $rate_to_rub,
|
||||
'type' => $type,
|
||||
];
|
||||
|
||||
$tx_repo->transaction(function () use ($tx_repo, $account_repo, $tx, $data, $account_f, $account_in, $summa) {
|
||||
$this->reverseBalance($account_repo, $tx);
|
||||
|
||||
$tx_repo->update($data);
|
||||
|
||||
$this->applyBalance($account_repo, $account_f, $account_in, $summa);
|
||||
|
||||
(new TransactionHistoryRepository())->log((int)$tx->id, (int)$tx->user_id, 'edit', ['user_id' => $tx->user_id] + $data);
|
||||
});
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Физическое удаление (у transactions, в отличие от accounts/categories, нет is_delete —
|
||||
* см. бюджет_текущий_план.md; история операции остаётся в transaction_history, не в самой
|
||||
* таблице).
|
||||
*
|
||||
* @return string JSON
|
||||
* @throws HTTPException|MyException
|
||||
*/
|
||||
public function deleteAction(): string
|
||||
{
|
||||
$request = Request::$current;
|
||||
$user = $this->currentUser();
|
||||
$tx_repo = new TransactionRepository();
|
||||
$tx = $this->ownTransaction($tx_repo, $user, (int)$request->param(0));
|
||||
$account_repo = new AccountRepository();
|
||||
|
||||
$tx_repo->transaction(function () use ($tx_repo, $account_repo, $tx) {
|
||||
$this->reverseBalance($account_repo, $tx);
|
||||
|
||||
$tx_repo->delete($tx->id);
|
||||
|
||||
(new TransactionHistoryRepository())->log((int)$tx->id, (int)$tx->user_id, 'delete', null);
|
||||
});
|
||||
|
||||
return $this->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Общие правила валидации формы операции (create/edit) — поля, не зависящие от типа. Поля,
|
||||
* зависящие от типа (categorie_id/account_f_id/account_in_id — какие обязательны, зависит от
|
||||
* income/expense/transfer), проверяются отдельно в resolveAccounts()/resolveCategory(), не
|
||||
* тут, тем же паттерном, что и goal_target_summa/goal_deadline в AccountsController::parseGoal().
|
||||
*
|
||||
* @param array $data
|
||||
* @return Validation
|
||||
*/
|
||||
protected function buildValidation(array $data): Validation
|
||||
{
|
||||
return Validation::factory($data)
|
||||
->label('date', 'Дата')
|
||||
->label('type', 'Тип')
|
||||
->label('summa', 'Сумма')
|
||||
->rule('date', 'required')
|
||||
->rule('date', 'regex', ['/^\d{4}-\d{2}-\d{2}$/'])
|
||||
->rule('type', 'required')
|
||||
->rule('type', 'in', [['income', 'expense', 'transfer']])
|
||||
->rule('summa', 'required')
|
||||
->rule('summa', 'numeric');
|
||||
}
|
||||
|
||||
/**
|
||||
* Счета операции по типу: income — только account_in, expense — только account_f,
|
||||
* transfer — оба, разные, одной валюты (в схеме transactions одна сумма/валюта на строку —
|
||||
* перевод между счетами разных валют ею не представим, см. проверку ниже).
|
||||
*
|
||||
* @param AccountRepository $account_repo
|
||||
* @param object $user
|
||||
* @param string $type
|
||||
* @param mixed $raw_f
|
||||
* @param mixed $raw_in
|
||||
* @return array{0:object|null,1:object|null,2:string,3:string|null} [account_f, account_in, error_field, error]
|
||||
*/
|
||||
protected function resolveAccounts(AccountRepository $account_repo, object $user, string $type, mixed $raw_f, mixed $raw_in): array
|
||||
{
|
||||
$account_f = null;
|
||||
$account_in = null;
|
||||
|
||||
if (in_array($type, ['expense', 'transfer'], true)) {
|
||||
$account_f = $this->resolveOwnAccount($account_repo, $user, $raw_f);
|
||||
|
||||
if ($account_f === null) {
|
||||
return [null, null, 'account_f_id', 'Выберите счёт списания'];
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($type, ['income', 'transfer'], true)) {
|
||||
$account_in = $this->resolveOwnAccount($account_repo, $user, $raw_in);
|
||||
|
||||
if ($account_in === null) {
|
||||
return [null, null, 'account_in_id', 'Выберите счёт зачисления'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'transfer') {
|
||||
if ((int)$account_f->id === (int)$account_in->id) {
|
||||
return [null, null, 'account_in_id', 'Счета списания и зачисления должны различаться'];
|
||||
}
|
||||
|
||||
if ($account_f->currency !== $account_in->currency) {
|
||||
return [null, null, 'account_in_id', 'Перевод между счетами разных валют не поддерживается'];
|
||||
}
|
||||
}
|
||||
|
||||
return [$account_f, $account_in, '', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccountRepository $account_repo
|
||||
* @param object $user
|
||||
* @param mixed $raw_id
|
||||
* @return object|null
|
||||
*/
|
||||
protected function resolveOwnAccount(AccountRepository $account_repo, object $user, mixed $raw_id): ?object
|
||||
{
|
||||
if (!is_numeric($raw_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$account = $account_repo->get((int)$raw_id);
|
||||
|
||||
if (!$account || (int)$account->user_id !== (int)$user->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Статья операции — обязательна для income/expense (принадлежит пользователю, тип совпадает),
|
||||
* всегда null для transfer (см. бюджет_текущий_план.md — «Все transfer — без категории, без
|
||||
* исключений»), независимо от того, что пришло в форме.
|
||||
*
|
||||
* @param CategoryRepository $category_repo
|
||||
* @param object $user
|
||||
* @param string $type
|
||||
* @param mixed $raw
|
||||
* @return array{0:int|null,1:string|null} [categorie_id, error]
|
||||
*/
|
||||
protected function resolveCategory(CategoryRepository $category_repo, object $user, string $type, mixed $raw): array
|
||||
{
|
||||
if ($type === 'transfer') {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
if (!is_numeric($raw)) {
|
||||
return [null, 'Выберите статью'];
|
||||
}
|
||||
|
||||
$category = $category_repo->get((int)$raw);
|
||||
|
||||
if (!$category || (int)$category->user_id !== (int)$user->id || $category->type !== $type) {
|
||||
return [null, 'Статья недоступна'];
|
||||
}
|
||||
|
||||
return [(int)$category->id, null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Снять эффект операции со счетов — перед правкой (реверс старой версии) или перед удалением.
|
||||
*
|
||||
* @param AccountRepository $account_repo
|
||||
* @param object $tx
|
||||
* @return void
|
||||
*/
|
||||
protected function reverseBalance(AccountRepository $account_repo, object $tx): void
|
||||
{
|
||||
if (in_array($tx->type, ['income', 'transfer'], true) && $tx->account_in_id) {
|
||||
$account_repo->adjustBalance((int)$tx->account_in_id, -(float)$tx->summa);
|
||||
}
|
||||
|
||||
if (in_array($tx->type, ['expense', 'transfer'], true) && $tx->account_f_id) {
|
||||
$account_repo->adjustBalance((int)$tx->account_f_id, (float)$tx->summa);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить эффект операции на счета — при создании или после правки (уже с новыми
|
||||
* счетами/суммой).
|
||||
*
|
||||
* @param AccountRepository $account_repo
|
||||
* @param object|null $account_f
|
||||
* @param object|null $account_in
|
||||
* @param float $summa
|
||||
* @return void
|
||||
*/
|
||||
protected function applyBalance(AccountRepository $account_repo, ?object $account_f, ?object $account_in, float $summa): void
|
||||
{
|
||||
if ($account_f !== null) {
|
||||
$account_repo->adjustBalance((int)$account_f->id, -$summa);
|
||||
}
|
||||
|
||||
if ($account_in !== null) {
|
||||
$account_repo->adjustBalance((int)$account_in->id, $summa);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Бюджетный пользователь, связанный с текущим логином Services\Auth.
|
||||
*
|
||||
* @return object
|
||||
* @throws HTTPException|MyException
|
||||
*/
|
||||
protected function currentUser(): object
|
||||
{
|
||||
$auth_user = Auth::instance()->getUser();
|
||||
$user = (new UserRepository())->getByLogin($auth_user['login']);
|
||||
@ -35,11 +414,26 @@ class TransactionsController extends Controller
|
||||
throw HTTPException::factory(404);
|
||||
}
|
||||
|
||||
$transactions = (new TransactionRepository())->getRecentForUser($user->id, 50);
|
||||
return $user;
|
||||
}
|
||||
|
||||
return $this->render('index', [
|
||||
'user' => $auth_user,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
/**
|
||||
* Операция по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов/статей).
|
||||
*
|
||||
* @param TransactionRepository $tx_repo
|
||||
* @param object $user
|
||||
* @param int $id
|
||||
* @return object
|
||||
* @throws HTTPException|MyException
|
||||
*/
|
||||
protected function ownTransaction(TransactionRepository $tx_repo, object $user, int $id): object
|
||||
{
|
||||
$tx = $tx_repo->get($id);
|
||||
|
||||
if (!$tx || (int)$tx->user_id !== (int)$user->id) {
|
||||
throw HTTPException::factory(404);
|
||||
}
|
||||
|
||||
return $tx;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\Repository;
|
||||
|
||||
/**
|
||||
@ -33,4 +35,44 @@ class AccountRateRepository extends Repository
|
||||
{
|
||||
return $this->getItemWhere("account_id = $account_id AND valid_to IS NULL");
|
||||
}
|
||||
|
||||
/**
|
||||
* Меняет ставку задним/будущим числом — не правит старую строку, а закрывает её
|
||||
* (`valid_to` = день перед новым `valid_from`) и открывает новую, чтобы не потерять историю
|
||||
* за уже прошедшие периоды (см. бюджет_текущий_план.md → Account_rates → «При изменении
|
||||
* ставки»). Атомарно — `Repository::transaction()` (на вложенном уровне — SAVEPOINT).
|
||||
*
|
||||
* @param int $account_id
|
||||
* @param float $rate_percent Новая ставка, % годовых
|
||||
* @param string $valid_from Дата, с которой действует новая ставка (Y-m-d) — не обязательно
|
||||
* «сегодня», банк мог прислать уведомление заранее/задним числом
|
||||
* @return void
|
||||
* @throws MyException Если $valid_from не позже даты начала текущей ставки — иначе закрытая
|
||||
* строка получила бы valid_to раньше собственного valid_from
|
||||
*/
|
||||
public function setRate(int $account_id, float $rate_percent, string $valid_from): void
|
||||
{
|
||||
$this->transaction(function () use ($account_id, $rate_percent, $valid_from) {
|
||||
$current = $this->getCurrentRate($account_id);
|
||||
|
||||
if ($current) {
|
||||
if ($valid_from <= $current->valid_from) {
|
||||
throw new MyException(
|
||||
'Дата новой ставки (:new) должна быть позже даты начала текущей (:current)',
|
||||
[':new' => $valid_from, ':current' => $current->valid_from]
|
||||
);
|
||||
}
|
||||
|
||||
$valid_to = (new DateTimeImmutable($valid_from))->modify('-1 day')->format('Y-m-d');
|
||||
$this->update(['id' => $current->id, 'valid_to' => $valid_to]);
|
||||
}
|
||||
|
||||
$this->create([
|
||||
'account_id' => $account_id,
|
||||
'rate_percent' => $rate_percent,
|
||||
'valid_from' => $valid_from,
|
||||
'valid_to' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,4 +22,22 @@ class AccountRepository extends Repository
|
||||
{
|
||||
parent::__construct('accounts', connection: $connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменить баланс счёта на $delta (может быть отрицательным) — читает текущий `summa` и
|
||||
* перезаписывает, а не `summa = summa + :delta` прямо в SQL, чтобы остаться на общем
|
||||
* processData()/update() репозитория (bind-параметры, автоопределение типа), как и весь
|
||||
* остальной код Repository. Вызывающая сторона (App\Controller\TransactionsController)
|
||||
* оборачивает это в `$repo->transaction()` вместе с самой операцией — атомарность.
|
||||
*
|
||||
* @param int $id
|
||||
* @param float $delta
|
||||
* @return void
|
||||
*/
|
||||
public function adjustBalance(int $id, float $delta): void
|
||||
{
|
||||
$account = $this->get($id);
|
||||
|
||||
$this->update(['id' => $id, 'summa' => (float)$account->summa + $delta]);
|
||||
}
|
||||
}
|
||||
|
||||
45
App/Repositories/TransactionHistoryRepository.php
Normal file
45
App/Repositories/TransactionHistoryRepository.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @author Egor Isaev
|
||||
* @description TransactionHistoryRepository.php
|
||||
* @copyright (c) 15/08/2026
|
||||
*/
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use System\Classes\Repository;
|
||||
|
||||
/**
|
||||
* Репозиторий таблицы transaction_history — лог правок/удалений операций (см.
|
||||
* бюджет_текущий_план.md → Transactions → «Лог правок/удалений»). Только запись — само чтение
|
||||
* лога/механизм отката не спроектированы, здесь только то, что нужно, чтобы лог начал
|
||||
* наполняться вместе с CRUD транзакций (App\Controller\TransactionsController).
|
||||
*/
|
||||
class TransactionHistoryRepository extends Repository
|
||||
{
|
||||
/**
|
||||
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
|
||||
*/
|
||||
public function __construct(?string $connection = null)
|
||||
{
|
||||
parent::__construct('transaction_history', connection: $connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $transaction_id
|
||||
* @param int $user_id
|
||||
* @param string $action create|edit|delete
|
||||
* @param array|null $snapshot Полный слепок строки transactions после действия; null для delete
|
||||
* @return void
|
||||
*/
|
||||
public function log(int $transaction_id, int $user_id, string $action, ?array $snapshot): void
|
||||
{
|
||||
$this->create([
|
||||
'transaction_id' => $transaction_id,
|
||||
'user_id' => $user_id,
|
||||
'action' => $action,
|
||||
'snapshot' => $snapshot,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,7 @@ class TransactionRepository extends Repository
|
||||
{
|
||||
$limit = (int)$limit;
|
||||
$sql = "SELECT t.id, t.date, t.type, t.summa, t.currency, t.rate_to_rub,
|
||||
t.categorie_id, t.account_f_id, t.account_in_id,
|
||||
c.title AS category_title,
|
||||
af.title AS account_f_title,
|
||||
ai.title AS account_in_title
|
||||
|
||||
@ -149,7 +149,10 @@ body {
|
||||
main {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 6.5rem;
|
||||
/* top — с запасом ниже фиксированной кнопки выхода (layout.html, position:fixed;top:1rem;
|
||||
иконка 1.3rem) — иначе первая же строка страницы (.section-label с кнопками справа —
|
||||
счета на дашборде, статьи в бюджете, "+" в транзакциях) заезжает под неё. */
|
||||
padding: 3rem 1.5rem 6.5rem;
|
||||
}
|
||||
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
@ -951,6 +954,8 @@ a.dock-item:hover { color: var(--ink-soft); }
|
||||
|
||||
.pm-tile.selected .pm-check { display: flex; }
|
||||
|
||||
.pm-tiles-grid.locked .pm-tile { cursor: not-allowed; pointer-events: none; opacity: 0.55; }
|
||||
|
||||
.pm-tile-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
@ -49,6 +49,7 @@ $(function () {
|
||||
|
||||
var isSavings = type === 'savings';
|
||||
$rate.toggle(isSavings && !isEdit);
|
||||
$rate.find('[name=rate_percent]').prop('required', isSavings && !isEdit);
|
||||
$goalFields.toggle(isSavings);
|
||||
|
||||
if (!isEdit) {
|
||||
@ -65,6 +66,8 @@ $(function () {
|
||||
$colorHidden.val('');
|
||||
setType('bank', false);
|
||||
$form.find('[name=include_in_total][value="1"]').prop('checked', true);
|
||||
$('#acc-type-tiles').removeClass('locked');
|
||||
$('#acc-type-lock-hint').hide();
|
||||
}
|
||||
|
||||
$(document).on('click', '.js-account-add', function () {
|
||||
@ -113,6 +116,8 @@ $(function () {
|
||||
$form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true);
|
||||
|
||||
setType($card.data('type'), true);
|
||||
$('#acc-type-tiles').addClass('locked');
|
||||
$('#acc-type-lock-hint').show();
|
||||
|
||||
$('#account-modal-title').text('Редактировать счёт');
|
||||
accountModal.show();
|
||||
@ -161,6 +166,10 @@ $(function () {
|
||||
});
|
||||
|
||||
$(document).on('click', '#acc-type-tiles .pm-tile', function () {
|
||||
if ($form.data('mode') === 'edit') {
|
||||
return;
|
||||
}
|
||||
|
||||
var type = $(this).data('type');
|
||||
|
||||
if (type === 'credit') {
|
||||
|
||||
126
App/media/js/transactions.js
Normal file
126
App/media/js/transactions.js
Normal file
@ -0,0 +1,126 @@
|
||||
$(function () {
|
||||
var modalEl = document.getElementById('tx-modal');
|
||||
|
||||
if (!modalEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var modal = new bootstrap.Modal(modalEl);
|
||||
var $form = $('#tx-form');
|
||||
var $error = $('#tx-form-error');
|
||||
|
||||
function filterCategoryOptions(type) {
|
||||
$('#tx-category option[data-type]').each(function () {
|
||||
var match = $(this).data('type') === type;
|
||||
$(this).prop('hidden', !match).prop('disabled', !match);
|
||||
});
|
||||
|
||||
var $selected = $('#tx-category option:selected');
|
||||
if ($selected.data('type') && $selected.data('type') !== type) {
|
||||
$('#tx-category').val('');
|
||||
}
|
||||
}
|
||||
|
||||
function setType(type) {
|
||||
$('#tx-type-tiles .pm-tile').removeClass('selected');
|
||||
$('#tx-type-tiles .pm-tile[data-type="' + type + '"]').addClass('selected');
|
||||
$('#tx-type-hidden').val(type);
|
||||
|
||||
var isTransfer = type === 'transfer';
|
||||
|
||||
$('#tx-category-field').toggle(!isTransfer);
|
||||
$('#tx-category').prop('required', !isTransfer);
|
||||
if (!isTransfer) {
|
||||
filterCategoryOptions(type);
|
||||
}
|
||||
|
||||
var needsF = type === 'expense' || isTransfer;
|
||||
$('#tx-account-f-field').toggle(needsF);
|
||||
$('#tx-account-f').prop('required', needsF);
|
||||
|
||||
var needsIn = type === 'income' || isTransfer;
|
||||
$('#tx-account-in-field').toggle(needsIn);
|
||||
$('#tx-account-in').prop('required', needsIn);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
$form[0].reset();
|
||||
$form.find('[name=id]').val('');
|
||||
$error.hide().text('');
|
||||
$form.find('[name=date]').val(new Date().toISOString().slice(0, 10));
|
||||
setType('expense');
|
||||
}
|
||||
|
||||
function populateEditForm($row) {
|
||||
resetForm();
|
||||
$form.data('mode', 'edit').attr('action', '/transactions/edit/' + $row.data('id'));
|
||||
$form.find('[name=id]').val($row.data('id'));
|
||||
$form.find('[name=date]').val($row.data('date'));
|
||||
$form.find('[name=summa]').val($row.data('summa'));
|
||||
|
||||
setType($row.data('type'));
|
||||
|
||||
$form.find('[name=categorie_id]').val($row.data('categorie-id') || '');
|
||||
$form.find('[name=account_f_id]').val($row.data('account-f-id') || '');
|
||||
$form.find('[name=account_in_id]').val($row.data('account-in-id') || '');
|
||||
|
||||
$('#tx-modal-title').text('Редактировать операцию');
|
||||
modal.show();
|
||||
}
|
||||
|
||||
$(document).on('click', '.js-tx-add', function () {
|
||||
resetForm();
|
||||
$form.data('mode', 'create').attr('action', '/transactions/create');
|
||||
$('#tx-modal-title').text('Новая операция');
|
||||
modal.show();
|
||||
});
|
||||
|
||||
$(document).on('click', '#tx-type-tiles .pm-tile', function () {
|
||||
setType($(this).data('type'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.js-tx-edit', function (e) {
|
||||
e.preventDefault();
|
||||
populateEditForm($(this).closest('.stat-row'));
|
||||
});
|
||||
|
||||
$(document).on('click', '.js-tx-delete', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $row = $(this).closest('.stat-row');
|
||||
var csrf = $form.find('[name=csrf_token]').val();
|
||||
|
||||
if (!window.confirm('Удалить операцию?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.post('/transactions/delete/' + $row.data('id'), {csrf_token: csrf})
|
||||
.done(function () { window.location.reload(); })
|
||||
.fail(function () { window.alert('Не удалось удалить операцию'); });
|
||||
});
|
||||
|
||||
$(document).on('click', '.room-menu-btn', function () {
|
||||
$(this).closest('.room-menu').toggleClass('open');
|
||||
});
|
||||
|
||||
$(document).on('click', function (e) {
|
||||
if (!$(e.target).closest('.room-menu').length) {
|
||||
$('.room-menu.open').removeClass('open');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('submit', '#tx-form', function (e) {
|
||||
e.preventDefault();
|
||||
$error.hide().text('');
|
||||
|
||||
$.post($form.attr('action'), $form.serialize())
|
||||
.done(function () { window.location.reload(); })
|
||||
.fail(function (xhr) {
|
||||
var errors = xhr.responseJSON && xhr.responseJSON.errors;
|
||||
var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить операцию';
|
||||
$error.text(message).show();
|
||||
});
|
||||
});
|
||||
|
||||
resetForm();
|
||||
});
|
||||
@ -404,7 +404,7 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
|
||||
<div class="modal-body">
|
||||
<div id="account-form-error" class="alert-error" style="display:none"></div>
|
||||
|
||||
<div class="dialog-section-label" style="margin-top:0">Тип счёта</div>
|
||||
<div class="dialog-section-label" style="margin-top:0">Тип счёта <span id="acc-type-lock-hint" style="display:none;font-weight:400;text-transform:none;letter-spacing:normal;color:color-mix(in srgb, var(--ink) 55%, transparent)">— нельзя изменить после создания</span></div>
|
||||
<div class="pm-tiles-grid" id="acc-type-tiles" style="grid-template-columns:1fr 1fr">
|
||||
<div class="pm-tile selected" data-type="bank" data-color="oklch(0.80 0.18 90)" data-icon="ic-card">
|
||||
<span class="pm-check">✓</span>
|
||||
@ -475,7 +475,7 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
|
||||
|
||||
<div id="account-rate-field" style="display:none;margin-top:10px">
|
||||
<div class="field" style="margin:0">
|
||||
<label>Ставка, % годовых</label>
|
||||
<label>Ставка, % годовых *</label>
|
||||
<input class="input" type="number" step="0.01" min="0" name="rate_percent" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
<?php
|
||||
/**
|
||||
* @package Bicycle
|
||||
* @description Transactions/index.html — список последних 50 транзакций.
|
||||
* @description Transactions/index.html — список последних 50 транзакций + модалка ручного
|
||||
* ввода (создание/правка), см. App/Controller/TransactionsController.php,
|
||||
* App/media/js/transactions.js.
|
||||
*
|
||||
* @var array $user
|
||||
* @var object[] $transactions
|
||||
* @var object[] $accounts Активные счета пользователя (для селектов счёта списания/зачисления)
|
||||
* @var object[] $categories Активные статьи пользователя (для селекта категории, income/expense)
|
||||
*/
|
||||
|
||||
use System\Classes\CSRF;
|
||||
|
||||
$month_names_gen = [
|
||||
1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня',
|
||||
7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря',
|
||||
@ -25,12 +31,56 @@ $type_signs = [
|
||||
'expense' => '−',
|
||||
'transfer' => '',
|
||||
];
|
||||
|
||||
$this->setScript('transactions.js');
|
||||
?>
|
||||
|
||||
<h1 style="margin:0 0 20px;font-size:24px">Транзакции</h1>
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</symbol>
|
||||
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.6"/>
|
||||
<circle cx="12" cy="12" r="1.6"/>
|
||||
<circle cx="12" cy="19" r="1.6"/>
|
||||
</symbol>
|
||||
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
|
||||
</symbol>
|
||||
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||
</symbol>
|
||||
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 7h11l-3-3"/>
|
||||
<path d="M17 17H6l3 3"/>
|
||||
</symbol>
|
||||
<symbol id="ic-arrow-down-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M12 8v8"/>
|
||||
<path d="M9 13l3 3 3-3"/>
|
||||
</symbol>
|
||||
<symbol id="ic-arrow-up-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<path d="M12 16V8"/>
|
||||
<path d="M9 11l3-3 3 3"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="section-label" style="margin-top:0">
|
||||
<span>Транзакции</span>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<button type="button" class="btn-add js-tx-add" title="Добавить операцию"><svg class="ico"><use href="#ic-plus"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($transactions)): ?>
|
||||
<div class="empty-state">Нет операций — транзакции появятся здесь после добавления</div>
|
||||
<div class="empty-state">Нет операций — нажмите «+», чтобы добавить первую</div>
|
||||
<?php else: ?>
|
||||
<div class="card elev-md pm-wrap" style="padding:6px 0">
|
||||
<?php foreach ($transactions as $tx): ?>
|
||||
@ -40,12 +90,23 @@ $type_signs = [
|
||||
$amount = (float)$tx->summa * ((float)($tx->rate_to_rub ?? 1));
|
||||
$color = $type_colors[$tx->type] ?? 'var(--muted)';
|
||||
$sign = $type_signs[$tx->type] ?? '';
|
||||
$category = $tx->category_title ?? ($tx->type === 'transfer' ? 'Перевод' : '—');
|
||||
$account = $tx->account_f_title ?? $tx->account_in_title ?? '';
|
||||
|
||||
$title = match (true) {
|
||||
$tx->type === 'transfer' => htmlspecialchars(($tx->account_f_title ?? '') . ' → ' . ($tx->account_in_title ?? ''), ENT_QUOTES, 'UTF-8'),
|
||||
default => htmlspecialchars($tx->category_title ?? '—', ENT_QUOTES, 'UTF-8'),
|
||||
};
|
||||
$account = $tx->type === 'income' ? $tx->account_in_title : $tx->account_f_title;
|
||||
?>
|
||||
<div class="stat-row">
|
||||
<div class="stat-row"
|
||||
data-id="<?= (int)$tx->id ?>"
|
||||
data-type="<?= htmlspecialchars($tx->type, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-date="<?= htmlspecialchars($tx->date, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-summa="<?= htmlspecialchars((string)$tx->summa, ENT_QUOTES, 'UTF-8') ?>"
|
||||
data-categorie-id="<?= (int)($tx->categorie_id ?? 0) ?>"
|
||||
data-account-f-id="<?= (int)($tx->account_f_id ?? 0) ?>"
|
||||
data-account-in-id="<?= (int)($tx->account_in_id ?? 0) ?>">
|
||||
<div style="width:auto;flex:1">
|
||||
<div style="font-size:13px;font-weight:600"><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="font-size:13px;font-weight:600"><?= $title ?></div>
|
||||
<div class="card-meta" style="font-size:11px;margin-top:2px">
|
||||
<?= htmlspecialchars($dateLabel, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php if ($account): ?> · <?= htmlspecialchars($account, ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
|
||||
@ -54,7 +115,106 @@ $type_signs = [
|
||||
<div class="num" style="width:auto;flex-shrink:0;font-size:14px;font-weight:600;color:<?= $color ?>">
|
||||
<?= $sign . number_format($amount, 0, ',', ' ') ?> ₽
|
||||
</div>
|
||||
<div class="room-menu" style="flex-shrink:0;margin-left:6px">
|
||||
<button type="button" class="room-menu-btn">
|
||||
<svg class="ico"><use href="#ic-dots"/></svg>
|
||||
</button>
|
||||
<div class="room-menu-list">
|
||||
<a href="#" class="js-tx-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
|
||||
<a href="#" class="danger js-tx-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="modal fade" id="tx-modal" tabindex="-1" data-bs-theme="dark">
|
||||
<div class="modal-dialog modal-dialog-scrollable" style="max-width:480px">
|
||||
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
|
||||
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
|
||||
<div style="display:flex;gap:12px;align-items:flex-start">
|
||||
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-swap"/></svg></span>
|
||||
<div>
|
||||
<h5 class="modal-title" id="tx-modal-title">Новая операция</h5>
|
||||
<div class="dialog-sub">Доход, расход или перевод между счетами</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
|
||||
<form id="tx-form" method="post">
|
||||
<?= CSRF::field() ?>
|
||||
<input type="hidden" name="id">
|
||||
<div class="modal-body">
|
||||
<div id="tx-form-error" class="alert-error" style="display:none"></div>
|
||||
|
||||
<div class="dialog-section-label" style="margin-top:0">Тип операции</div>
|
||||
<div class="pm-tiles-grid" id="tx-type-tiles" style="grid-template-columns:1fr 1fr 1fr">
|
||||
<div class="pm-tile selected" data-type="expense">
|
||||
<span class="pm-check">✓</span>
|
||||
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--red) 20%,transparent);color:var(--red)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-arrow-up-circle"/></svg></span>
|
||||
<div><div class="pm-tile-title">Расход</div></div>
|
||||
</div>
|
||||
<div class="pm-tile" data-type="income">
|
||||
<span class="pm-check">✓</span>
|
||||
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--positive) 20%,transparent);color:var(--positive)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-arrow-down-circle"/></svg></span>
|
||||
<div><div class="pm-tile-title">Доход</div></div>
|
||||
</div>
|
||||
<div class="pm-tile" data-type="transfer">
|
||||
<span class="pm-check">✓</span>
|
||||
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--blue) 20%,transparent);color:var(--blue)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-swap"/></svg></span>
|
||||
<div><div class="pm-tile-title">Перевод</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="type" id="tx-type-hidden" value="expense">
|
||||
|
||||
<div class="field" id="tx-category-field" style="margin-top:10px">
|
||||
<label>Статья</label>
|
||||
<select class="input" name="categorie_id" id="tx-category">
|
||||
<option value="">— выберите статью —</option>
|
||||
<?php foreach ($categories as $c): ?>
|
||||
<option value="<?= (int)$c->id ?>" data-type="<?= htmlspecialchars($c->type, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($c->title, ENT_QUOTES, 'UTF-8') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:10px">
|
||||
<div class="field" style="flex:1;margin:0">
|
||||
<label>Дата</label>
|
||||
<input class="input" type="date" name="date" required>
|
||||
</div>
|
||||
<div class="field" style="flex:1;margin:0">
|
||||
<label>Сумма</label>
|
||||
<input class="input" type="number" step="0.01" min="0.01" name="summa" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" id="tx-account-f-field" style="margin-top:10px">
|
||||
<label>Счёт списания</label>
|
||||
<select class="input" name="account_f_id" id="tx-account-f">
|
||||
<option value="">— выберите счёт —</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option value="<?= (int)$a->id ?>"><?= htmlspecialchars($a->title, ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($a->currency, ENT_QUOTES, 'UTF-8') ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field" id="tx-account-in-field" style="margin-top:10px">
|
||||
<label>Счёт зачисления</label>
|
||||
<select class="input" name="account_in_id" id="tx-account-in">
|
||||
<option value="">— выберите счёт —</option>
|
||||
<?php foreach ($accounts as $a): ?>
|
||||
<option value="<?= (int)$a->id ?>"><?= htmlspecialchars($a->title, ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($a->currency, ENT_QUOTES, 'UTF-8') ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-color:var(--surface-3)">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -70,14 +70,27 @@ class Client
|
||||
}
|
||||
}
|
||||
|
||||
$rates = $this->fetch();
|
||||
// Недавний неудачный fetch() (сеть/ЦБ недоступны) — не бьём в сеть по второму разу на
|
||||
// каждый следующий запрос страницы, а сразу идём в фолбэк на протухший кэш/пустоту.
|
||||
// Без этого маркера каждый заход на дашборд/операцию с не-рублёвым счётом ждал бы полный
|
||||
// timeout (по умолчанию 10 сек, App/config/config.php) при недоступном ЦБ РФ.
|
||||
$fail_path = $cache_path . '.failed';
|
||||
$fail_cooldown = (int)($this->config['fail_cooldown'] ?? 300);
|
||||
$recent_failure = is_file($fail_path) && (time() - filemtime($fail_path)) < $fail_cooldown;
|
||||
|
||||
$rates = $recent_failure ? null : $this->fetch();
|
||||
|
||||
if ($rates !== null) {
|
||||
@file_put_contents($cache_path, json_encode($rates));
|
||||
@unlink($fail_path);
|
||||
|
||||
return $this->rates = $rates;
|
||||
}
|
||||
|
||||
if (!$recent_failure) {
|
||||
@touch($fail_path);
|
||||
}
|
||||
|
||||
// Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего.
|
||||
if (is_file($cache_path)) {
|
||||
$cached = json_decode((string)file_get_contents($cache_path), true);
|
||||
|
||||
147
tests/Unit/AccountRateRepositoryTest.php
Normal file
147
tests/Unit/AccountRateRepositoryTest.php
Normal file
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Repositories\AccountRateRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use Services\Database;
|
||||
use Services\DataBase\Classes\PdoConnection;
|
||||
use Services\DataBase\Classes\Profiler;
|
||||
use System\Classes\Config;
|
||||
use System\Classes\MyException;
|
||||
use System\Classes\Repository;
|
||||
|
||||
/**
|
||||
* AccountRateRepository жёстко привязан к боевой таблице account_rates конструктором — для
|
||||
* тестов подменяем её на временную (тот же приём, что и в BudgetRepositoryTest/CategoryRepositoryTest:
|
||||
* прямой вызов Repository::__construct(), минуя AccountRateRepository::__construct()).
|
||||
*/
|
||||
class AccountRateRepositoryTestDouble extends AccountRateRepository
|
||||
{
|
||||
public function __construct(?string $connection = null)
|
||||
{
|
||||
Repository::__construct('account_rate_repository_test_rates', connection: $connection);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountRateRepositoryTest extends TestCase
|
||||
{
|
||||
private PdoConnection $connection;
|
||||
private AccountRateRepositoryTestDouble $repository;
|
||||
|
||||
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 account_rate_repository_test_rates');
|
||||
|
||||
$this->connection->exec(
|
||||
'CREATE TABLE account_rate_repository_test_rates (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
rate_percent DECIMAL(5,2) NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE NULL
|
||||
)'
|
||||
);
|
||||
|
||||
$this->repository = new AccountRateRepositoryTestDouble();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Profiler::reset();
|
||||
|
||||
if (isset($this->connection)) {
|
||||
try {
|
||||
$this->connection->exec('DROP TABLE IF EXISTS account_rate_repository_test_rates');
|
||||
} catch (MyException) {
|
||||
}
|
||||
}
|
||||
|
||||
// Database::instance() кэширует подключение статически — сбрасываем между тестами
|
||||
$ref = new ReflectionClass(Database::class);
|
||||
$prop = $ref->getProperty('_instances');
|
||||
$prop->setAccessible(true);
|
||||
$prop->setValue(null, []);
|
||||
}
|
||||
|
||||
public function testSetRateWithNoCurrentRateJustCreates(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-01-01');
|
||||
|
||||
$current = $this->repository->getCurrentRate(1);
|
||||
|
||||
$this->assertNotFalse($current);
|
||||
$this->assertSame('5.50', $current->rate_percent);
|
||||
$this->assertSame('2026-01-01', $current->valid_from);
|
||||
$this->assertNull($current->valid_to);
|
||||
}
|
||||
|
||||
public function testSetRateClosesPreviousAndOpensNew(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-01-01');
|
||||
$this->repository->setRate(1, 6.0, '2026-03-01');
|
||||
|
||||
$rows = $this->repository->getList('*', ['account_id' => 1], 'id');
|
||||
|
||||
$this->assertCount(2, $rows);
|
||||
|
||||
$this->assertSame('2026-01-01', $rows[0]->valid_from);
|
||||
$this->assertSame('2026-02-28', $rows[0]->valid_to);
|
||||
|
||||
$this->assertSame('2026-03-01', $rows[1]->valid_from);
|
||||
$this->assertNull($rows[1]->valid_to);
|
||||
|
||||
$current = $this->repository->getCurrentRate(1);
|
||||
$this->assertSame('6.00', $current->rate_percent);
|
||||
}
|
||||
|
||||
public function testSetRateRejectsDateNotAfterCurrentValidFrom(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-03-01');
|
||||
|
||||
$this->expectException(MyException::class);
|
||||
$this->repository->setRate(1, 6.0, '2026-03-01');
|
||||
}
|
||||
|
||||
public function testSetRateRejectsDateBeforeCurrentValidFrom(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-03-01');
|
||||
|
||||
$this->expectException(MyException::class);
|
||||
$this->repository->setRate(1, 6.0, '2026-01-01');
|
||||
}
|
||||
|
||||
public function testSetRateIsolatedPerAccount(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-01-01');
|
||||
$this->repository->setRate(2, 4.0, '2026-01-01');
|
||||
|
||||
$this->assertSame('5.50', $this->repository->getCurrentRate(1)->rate_percent);
|
||||
$this->assertSame('4.00', $this->repository->getCurrentRate(2)->rate_percent);
|
||||
}
|
||||
|
||||
public function testSetRateRollsBackOnRejectedDate(): void
|
||||
{
|
||||
$this->repository->setRate(1, 5.5, '2026-03-01');
|
||||
|
||||
try {
|
||||
$this->repository->setRate(1, 6.0, '2026-01-01');
|
||||
} catch (MyException) {
|
||||
}
|
||||
|
||||
// Отклонённая правка не должна была ни закрыть текущую строку, ни создать новую.
|
||||
$rows = $this->repository->getList('*', ['account_id' => 1], 'id');
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertNull($rows[0]->valid_to);
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,10 @@ class CurrencyRateClientTest extends TestCase
|
||||
if (is_file($this->cache_path)) {
|
||||
unlink($this->cache_path);
|
||||
}
|
||||
|
||||
if (is_file($this->cache_path . '.failed')) {
|
||||
unlink($this->cache_path . '.failed');
|
||||
}
|
||||
}
|
||||
|
||||
public function testRubAlwaysReturnsOne(): void
|
||||
@ -59,4 +63,54 @@ class CurrencyRateClientTest extends TestCase
|
||||
|
||||
$this->assertNull($client->rate('XXX'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Неудачный fetch() (сеть/ЦБ недоступны) должен оставить .failed-маркер — см. Client::rates().
|
||||
* fetch() замокан, а не реально бьёт в сеть: тайминг на "недоступном" хосте ненадёжен для
|
||||
* теста (DNS-отказ мгновенный, а реальный таймаут — секунды, в зависимости от окружения).
|
||||
*/
|
||||
public function testFailedFetchWritesFailureMarker(): void
|
||||
{
|
||||
$client = $this->getMockBuilder(Client::class)
|
||||
->setConstructorArgs([['cache_path' => $this->cache_path]])
|
||||
->onlyMethods(['fetch'])
|
||||
->getMock();
|
||||
$client->method('fetch')->willReturn(null);
|
||||
|
||||
$client->rate('EUR');
|
||||
|
||||
$this->assertFileExists($this->cache_path . '.failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Без этого кулдауна каждый следующий запрос (например, каждая загрузка дашборда, пока ЦБ
|
||||
* недоступен) заново ждал бы полный timeout — см. Client::rates(). Проверяем не временем
|
||||
* (ненадёжно), а тем, что fetch() вообще не вызывается повторно, пока действует недавний
|
||||
* .failed-маркер.
|
||||
*/
|
||||
public function testFailureCooldownSkipsFetch(): void
|
||||
{
|
||||
touch($this->cache_path . '.failed');
|
||||
|
||||
$client = $this->getMockBuilder(Client::class)
|
||||
->setConstructorArgs([['cache_path' => $this->cache_path, 'fail_cooldown' => 300]])
|
||||
->onlyMethods(['fetch'])
|
||||
->getMock();
|
||||
$client->expects($this->never())->method('fetch');
|
||||
|
||||
$this->assertNull($client->rate('EUR'));
|
||||
}
|
||||
|
||||
public function testFetchIsRetriedAfterCooldownExpires(): void
|
||||
{
|
||||
touch($this->cache_path . '.failed', time() - 10);
|
||||
|
||||
$client = $this->getMockBuilder(Client::class)
|
||||
->setConstructorArgs([['cache_path' => $this->cache_path, 'fail_cooldown' => 5]])
|
||||
->onlyMethods(['fetch'])
|
||||
->getMock();
|
||||
$client->expects($this->once())->method('fetch')->willReturn(null);
|
||||
|
||||
$this->assertNull($client->rate('EUR'));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user