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