157 lines
6.2 KiB
PHP
157 lines
6.2 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description BudgetsController.php
|
||
* @copyright (c) 13/08/2026
|
||
*/
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Repositories\BudgetRepository;
|
||
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;
|
||
|
||
/**
|
||
* Годовой план бюджета по месяцам — сетка «статья × 12 месяцев», сохраняется одной кнопкой
|
||
* (см. App/view/Budgets/budgets.html, App/media/js/budgets.js). Факт (реальные transactions)
|
||
* здесь не показывается — это отдельная фича, см. бюджет_текущий_план.md.
|
||
*/
|
||
class BudgetsController extends Controller
|
||
{
|
||
protected bool $_auth_protection = true;
|
||
|
||
/**
|
||
* @return string
|
||
* @throws MyException
|
||
*/
|
||
public function indexAction(): string
|
||
{
|
||
$user = $this->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;
|
||
}
|
||
}
|