322 lines
12 KiB
PHP
322 lines
12 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description CategoriesController.php
|
||
* @copyright (c) 13/08/2026
|
||
*/
|
||
|
||
namespace App\Controller;
|
||
|
||
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) — доходы/расходы с группами (parent_id). Своя страница /categories
|
||
* (в отличие от счетов, для которых достаточно модалки на дашборде — см. AccountsController).
|
||
*/
|
||
class CategoriesController extends Controller
|
||
{
|
||
protected bool $_auth_protection = true;
|
||
|
||
/**
|
||
* @return string
|
||
* @throws MyException
|
||
*/
|
||
public function indexAction(): string
|
||
{
|
||
$user = $this->currentUser();
|
||
$category_repo = new CategoryRepository();
|
||
|
||
$tree = $category_repo->getTree($user->id);
|
||
$archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title');
|
||
|
||
return $this->render('categories', [
|
||
'tree' => $tree,
|
||
'archived' => $archived ?: [],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @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');
|
||
|
||
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
|
||
|
||
if ($parent_id === false) {
|
||
return $this->json(['success' => false, 'errors' => ['parent_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, $parent_id, $title)) {
|
||
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
|
||
}
|
||
|
||
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_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,
|
||
'parent_id' => $parent_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');
|
||
|
||
$parent_id = $this->parseParentId($category_repo, $user, $type, $request->post('parent_id'));
|
||
|
||
if ($parent_id === false || $parent_id === (int)$category->id) {
|
||
return $this->json(['success' => false, 'errors' => ['parent_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, $parent_id, $title, (int)$category->id)) {
|
||
return $this->json(['success' => false, 'errors' => ['title' => 'Такая статья уже есть']], 422);
|
||
}
|
||
|
||
$archived_duplicate = $category_repo->findArchivedDuplicate($user->id, $type, $parent_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,
|
||
'parent_id' => $parent_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');
|
||
}
|
||
|
||
/**
|
||
* Родительская статья (группа) из сырого значения формы — с проверкой владения/уровня/типа.
|
||
*
|
||
* @param CategoryRepository $category_repo
|
||
* @param object $user
|
||
* @param string $type Тип создаваемой/правимой статьи — родитель должен совпадать
|
||
* @param mixed $raw Сырое значение поля parent_id
|
||
* @return int|false|null null — верхний уровень (родителя нет); false — родитель недопустим
|
||
*/
|
||
protected function parseParentId(CategoryRepository $category_repo, object $user, string $type, mixed $raw): int|false|null
|
||
{
|
||
if ($raw === null || $raw === '') {
|
||
return null;
|
||
}
|
||
|
||
$parent = $category_repo->get((int)$raw);
|
||
|
||
if (!$parent || (int)$parent->user_id !== (int)$user->id || $parent->parent_id !== null || $parent->type !== $type) {
|
||
return false;
|
||
}
|
||
|
||
return (int)$parent->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;
|
||
}
|
||
}
|