currentUser(); $year = (int)(Request::$current->query('year') ?: (int)date('Y')); $category_repo = new CategoryRepository(); $group_repo = new CategoryGroupRepository(); $budget_repo = new BudgetRepository(); $tree = $category_repo->getTree($user->id); $groups = $group_repo->getForUser($user->id); $plan = $budget_repo->getYear($user->id, $year); $archived = $category_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); $archived_groups = $group_repo->getList('*', ['user_id' => $user->id, 'is_delete' => 1], 'title'); return $this->render('budgets', [ 'tree' => $tree, 'groups' => $groups, 'plan' => $plan, 'year' => $year, 'archived' => $archived ?: [], 'archived_groups' => $archived_groups ?: [], ]); } /** * Сохраняет всю сетку одним запросом: plan[categorie_id][month] = сумма. Для статей дохода * со связанной статьёй расхода (formula_pct) — автоматически пересчитывает и перезаписывает * план связанной статьи расхода тем же процентом (см. бюджет_текущий_план.md → Categories). * * @return string JSON * @throws HTTPException|MyException */ public function saveAction(): string { $request = Request::$current; $user = $this->currentUser(); $year = (int)$request->post('year'); $plan_input = $request->post('plan'); if ($year < 2000 || $year > 2100 || !is_array($plan_input)) { return $this->json(['success' => false, 'errors' => ['year' => 'Некорректный год']], 422); } $category_repo = new CategoryRepository(); $budget_repo = new BudgetRepository(); $categories = []; foreach (array_keys($plan_input) as $categorie_id) { $category = $category_repo->get((int)$categorie_id); if (!$category || (int)$category->user_id !== (int)$user->id) { throw HTTPException::factory(404); } $categories[(int)$categorie_id] = $category; } // Пересчитанные формулой ячейки связанной статьи расхода — возвращаются клиенту, чтобы // JS обновил их в сетке сразу, без перезагрузки страницы (см. budgets.js). $recalculated = []; $budget_repo->transaction(static function () use ($categories, $plan_input, $year, $budget_repo, &$recalculated) { // 1. Прямые значения из формы — как есть. foreach ($categories as $categorie_id => $category) { foreach ($plan_input[$categorie_id] ?? [] as $month => $summ) { $month = (int)$month; if ($month < 1 || $month > 12) { continue; } $budget_repo->upsert($categorie_id, $year, $month, (float)$summ, $category->type); } } // 2. Формульные статьи дохода — пересчитывают связанную статью расхода поверх, // последним шагом, чтобы формула всегда побеждала независимо от порядка обхода. foreach ($categories as $category) { if ($category->linked_expense_categorie_id === null || $category->formula_pct === null) { continue; } foreach ($plan_input[(int)$category->id] ?? [] as $month => $summ) { $month = (int)$month; if ($month < 1 || $month > 12) { continue; } $expense_summ = round((float)$summ * (float)$category->formula_pct / 100, 2); $target_id = (int)$category->linked_expense_categorie_id; $budget_repo->upsert($target_id, $year, $month, $expense_summ, 'expense', (float)$category->formula_pct); $recalculated[$target_id][$month] = $expense_summ; } } }); return $this->json(['success' => true, 'recalculated' => $recalculated]); } /** * Бюджетный пользователь, связанный с текущим логином 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; } }