Bicycle/App/Controller/CategoriesController.php
2026-08-15 10:39:22 +03:00

309 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CategoriesController.php
* @copyright (c) 13/08/2026
*/
namespace App\Controller;
use App\Repositories\CategoryGroupRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\UserRepository;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP\HTTPException;
use System\Classes\MyException;
use System\Classes\Request;
use System\Classes\Validation;
/**
* CRUD статей (categories) — доходы/расходы, опционально в группе (group_id, см.
* App\Controller\CategoryGroupsController — группа теперь отдельная сущность, не статья с
* детьми). JSON API, вызывается ajax'ом из модалки «Статьи» на странице /budgets
* (см. App/view/Budgets/budgets.html, App/media/js/categories.js).
*/
class CategoriesController extends Controller
{
protected bool $_auth_protection = true;
/**
* @return string JSON
* @throws MyException
*/
public function createAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$validation = $this->buildValidation($request->post());
if (!$validation->check()) {
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
}
$type = $request->post('type');
$title = $request->post('title');
$group_id = $this->parseGroupId($user, $type, $request->post('group_id'));
if ($group_id === false) {
return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
$category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct')
);
if ($link_error !== null) {
return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422);
}
if ($category_repo->findActiveDuplicate($user->id, $type, $group_id, $title)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
}
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$category_repo->create([
'user_id' => $user->id,
'group_id' => $group_id,
'title' => $title,
'type' => $type,
'is_delete' => false,
'order' => 0,
'linked_expense_categorie_id' => $linked_id,
'formula_pct' => $formula_pct,
]);
return $this->json(['success' => true]);
}
/**
* @return string JSON
* @throws HTTPException|MyException
*/
public function editAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_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');
$title = $request->post('title');
$group_id = $this->parseGroupId($user, $type, $request->post('group_id'));
if ($group_id === false) {
return $this->json(['success' => false, 'errors' => ['group_id' => 'Группа недоступна']], 422);
}
[$linked_id, $formula_pct, $link_error] = $this->parseFormulaLink(
$category_repo, $user, $type, $request->post('linked_expense_categorie_id'), $request->post('formula_pct')
);
if ($link_error !== null) {
return $this->json(['success' => false, 'errors' => ['linked_expense_categorie_id' => $link_error]], 422);
}
if ($category_repo->findActiveDuplicate($user->id, $type, $group_id, $title, (int)$category->id)) {
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
}
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $group_id, $title, (int)$category->id);
if ($archived_duplicate) {
return $this->json([
'success' => false,
'archived' => ['id' => $archived_duplicate->id, 'title' => $archived_duplicate->title],
], 422);
}
$category_repo->update([
'id' => $category->id,
'group_id' => $group_id,
'title' => $title,
'type' => $type,
'linked_expense_categorie_id' => $linked_id,
'formula_pct' => $formula_pct,
]);
return $this->json(['success' => true]);
}
/**
* Архивация (is_delete = 1), не физическое удаление — история транзакций может ссылаться на статью.
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function deleteAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_repo, $user, (int)$request->param(0));
$category_repo->update(['id' => $category->id, 'is_delete' => true]);
return $this->json(['success' => true]);
}
/**
* Возврат из архива (is_delete = 0).
*
* @return string JSON
* @throws HTTPException|MyException
*/
public function restoreAction(): string
{
$request = Request::$current;
$user = $this->currentUser();
$category_repo = new CategoryRepository();
$category = $this->ownCategory($category_repo, $user, (int)$request->param(0));
$category_repo->update(['id' => $category->id, 'is_delete' => false]);
return $this->json(['success' => true]);
}
/**
* Общие правила валидации формы статьи (create/edit).
*
* @param array $data
* @return Validation
*/
protected function buildValidation(array $data): Validation
{
return Validation::factory($data)
->label('title', 'Название')
->label('type', 'Тип')
->rule('title', 'required')
->rule('title', 'max_length', [128])
->rule('type', 'required')
->rule('type', 'in', [['income', 'expense']])
->rule('formula_pct', 'numeric');
}
/**
* Группа статьи из сырого значения формы — с проверкой владения/типа. Группа теперь
* самостоятельная сущность (App\Repositories\CategoryGroupRepository), поэтому, в отличие от
* старой parent_id-модели, тут не нужно отдельно проверять "родитель не должен сам быть
* подстатьёй" — group_id физически не может указывать ни на что, кроме реальной группы.
*
* @param object $user
* @param string $type Тип статьи — группа должна совпадать
* @param mixed $raw Сырое значение поля group_id
* @return int|false|null null — без группы; false — группа недоступна
*/
protected function parseGroupId(object $user, string $type, mixed $raw): int|false|null
{
if ($raw === null || $raw === '') {
return null;
}
$group = (new CategoryGroupRepository())->get((int)$raw);
if (!$group || (int)$group->user_id !== (int)$user->id || $group->type !== $type || $group->is_delete) {
return false;
}
return (int)$group->id;
}
/**
* Формульная связь income-статьи со статьёй расхода — оба поля (linked+pct) заполняются вместе,
* только для type=income, с проверкой владения и типа связанной статьи.
*
* @param CategoryRepository $category_repo
* @param object $user
* @param string $type
* @param mixed $raw_linked
* @param mixed $raw_pct
* @return array{0:int|null,1:float|null,2:string|null} [linked_id, formula_pct, error]
*/
protected function parseFormulaLink(CategoryRepository $category_repo, object $user, string $type, mixed $raw_linked, mixed $raw_pct): array
{
$has_linked = $raw_linked !== null && $raw_linked !== '';
$has_pct = $raw_pct !== null && $raw_pct !== '';
if (!$has_linked && !$has_pct) {
return [null, null, null];
}
if ($type !== 'income') {
return [null, null, 'Формула доступна только для статей дохода'];
}
if ($has_linked !== $has_pct) {
return [null, null, 'Укажите и связанную статью, и процент'];
}
if (!is_numeric($raw_pct) || (float)$raw_pct < 0 || (float)$raw_pct > 100) {
return [null, null, 'Процент должен быть от 0 до 100'];
}
$linked = $category_repo->get((int)$raw_linked);
if (!$linked || (int)$linked->user_id !== (int)$user->id || $linked->type !== 'expense') {
return [null, null, 'Связанная статья недоступна'];
}
return [(int)$linked->id, (float)$raw_pct, null];
}
/**
* Бюджетный пользователь, связанный с текущим логином Services\Auth.
*
* @return object
* @throws HTTPException|MyException
*/
protected function currentUser(): object
{
$auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']);
if (!$user) {
throw HTTPException::factory(404);
}
return $user;
}
/**
* Статья по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов).
*
* @param CategoryRepository $category_repo
* @param object $user
* @param int $id
* @return object
* @throws HTTPException|MyException
*/
protected function ownCategory(CategoryRepository $category_repo, object $user, int $id): object
{
$category = $category_repo->get($id);
if (!$category || (int)$category->user_id !== (int)$user->id) {
throw HTTPException::factory(404);
}
return $category;
}
}