148 lines
9.5 KiB
PHP
148 lines
9.5 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\Purchasing;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Identity\Services\Permissions;
|
|
use App\Shared\Context\CompanyContext;
|
|
use Brick\Math\BigDecimal;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Tempo\Finance\Budget\BudgetFailure;
|
|
|
|
/** Synthetic UI/application orchestration only. All financial effects delegate to BudgetControl. */
|
|
final class BudgetWorkspace
|
|
{
|
|
private function scope(User $user, string $right): array
|
|
{
|
|
BudgetAdapter::guard();
|
|
$context = app(CompanyContext::class);
|
|
abort_unless(auth()->id() === $user->id && $context->userId() === $user->id && $context->unitId() !== null, 403);
|
|
$s = $context->identifiers() + ['unit_id' => $context->unitId()];
|
|
$context->selectFor($user->id, $s['tenant_id'], $s['company_id'], $s['unit_id']);
|
|
foreach (array_unique(['view', $right]) as $permission) {
|
|
app(Permissions::class)->require($user->id, $s['tenant_id'], $s['company_id'], 'purchasing_budget.'.$permission, $s['unit_id']);
|
|
}
|
|
|
|
return $s;
|
|
}
|
|
|
|
private function input(array $data, array $rules): array
|
|
{
|
|
abort_if(array_diff(array_keys($data), array_keys($rules)) !== [], 422, 'Campo protegido ou fora do percurso sintético.');
|
|
|
|
return Validator::make($data, $rules)->validate();
|
|
}
|
|
|
|
private function money(string $value): int
|
|
{
|
|
if (! preg_match('/^(0|[1-9][0-9]{0,11})(\.[0-9]{1,2})?$/D', $value)) {
|
|
throw ValidationException::withMessages(['amount' => 'Use um montante MZN com ponto e até duas casas, sem arredondamento.']);
|
|
}
|
|
|
|
return BigDecimal::of($value)->multipliedBy(100)->toInt();
|
|
}
|
|
|
|
public function listing(User $user): array
|
|
{
|
|
$s = $this->scope($user, 'view');
|
|
$engine = app(BudgetAdapter::class)->for($user);
|
|
$budgets = DB::table('cb_budgets')->where($s)->orderBy('id')->get()->map(fn ($b) => (array) $b + ['balance' => $engine->balance($b->id)])->all();
|
|
$names = DB::table('company_memberships as m')->join('users as u', 'u.id', '=', 'm.user_id')->where('m.tenant_id', $s['tenant_id'])->where('m.company_id', $s['company_id'])->pluck('u.name', 'u.id')->all();
|
|
|
|
return ['synthetic_only' => true, 'currency' => 'MZN', 'current_year' => $engine->context->year, 'budgets' => $budgets,
|
|
'centres' => DB::table('cb_centres')->where($s)->orderBy('id')->get(),
|
|
'origins' => DB::table('cb_origins')->where($s)->orderBy('id')->get(),
|
|
'entries' => DB::table('cb_budget_entries')->where($s)->orderBy('id')->get(),
|
|
'consumptions' => DB::table('cb_budget_consumptions')->where($s)->orderBy('id')->get(),
|
|
'consumption_sources' => DB::table('cb_consumption_sources')->where($s)->orderBy('id')->get(),
|
|
'reinforcements' => DB::table('cb_budget_reinforcements')->where($s)->orderBy('id')->get(['id', 'budget_id', 'created_by', 'reviewed_by', 'reference', 'amount_minor', 'reason', 'status', 'review_reason']),
|
|
'history' => DB::table('cb_audit')->where($s)->orderBy('id')->get(), 'actors' => $names];
|
|
}
|
|
|
|
public function command(User $user, string $action, array $input, string $key): array
|
|
{
|
|
$right = match ($action) {
|
|
'create' => 'create', 'approve' => 'approve', 'movement' => 'record', 'consume' => 'consume',
|
|
'reinforcement' => 'reinforcement.propose', 'review' => 'reinforcement.review', default => abort(404),
|
|
};
|
|
$s = $this->scope($user, $right);
|
|
abort_unless(Str::isUuid($key), 422, 'Chave de comando em falta.');
|
|
ksort($input);
|
|
$hash = hash('sha256', json_encode($input, JSON_THROW_ON_ERROR));
|
|
try {
|
|
return DB::transaction(function () use ($user, $action, $right, $input, $key, $hash, $s) {
|
|
DB::table('companies')->where('tenant_id', $s['tenant_id'])->where('id', $s['company_id'])->lockForUpdate()->firstOrFail();
|
|
$this->scope($user, $right);
|
|
$commands = DB::table('cb_ui_commands')->where($s)->where('actor_id', $user->id)->where('command_key', $key);
|
|
if ($old = $commands->first()) {
|
|
abort_unless($old->action === $action && hash_equals($old->request_hash, $hash), 409, 'Chave já usada com outro conteúdo.');
|
|
|
|
return json_decode($old->result, true, 32, JSON_THROW_ON_ERROR) + ['replayed' => true];
|
|
}
|
|
$adapter = app(BudgetAdapter::class);
|
|
$engine = $adapter->for($user);
|
|
$idRule = 'required|integer|min:1';
|
|
if ($action === 'create') {
|
|
$d = $this->input($input, ['cost_center_id' => $idRule, 'year' => 'required|integer|min:2026|max:2100', 'kind' => 'required|in:operating,investment', 'includes_tax' => 'required|boolean', 'amount' => 'required|string', 'reason' => 'required|string|min:3|max:4000']);
|
|
$d['amount_minor'] = $this->money($d['amount']);
|
|
unset($d['amount']);
|
|
$d['includes_tax'] = (bool) $d['includes_tax'];
|
|
$id = $adapter->create($user, $d);
|
|
} elseif ($action === 'approve') {
|
|
$d = $this->input($input, ['budget_id' => $idRule]);
|
|
$id = $d['budget_id'];
|
|
$engine->approve($id);
|
|
} elseif ($action === 'reinforcement') {
|
|
$d = $this->input($input, ['budget_id' => $idRule, 'amount' => 'required|string', 'reference' => 'required|string|max:100', 'reason' => 'required|string|min:3|max:4000']);
|
|
$id = $engine->proposeReinforcement($d['budget_id'], $this->money($d['amount']), Str::upper(Str::squish($d['reference'])), trim($d['reason']));
|
|
} elseif ($action === 'review') {
|
|
$d = $this->input($input, ['reinforcement_id' => $idRule, 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:3|max:4000']);
|
|
$id = $d['reinforcement_id'];
|
|
$engine->reviewReinforcement($id, $d['decision'], $d['reason']);
|
|
} else {
|
|
$rules = ['origin_id' => $idRule, 'total' => 'required|string', 'subtotal' => 'required|string'];
|
|
$rules += $action === 'movement' ? ['kind' => 'required|in:reserve,award,commit,release'] : ['reference' => 'required|string|min:3|max:100'];
|
|
$d = $this->input($input, $rules);
|
|
$origin = DB::table('cb_origins')->where($s)->where('id', $d['origin_id'])->first() ?? abort(403, 'Origem fora deste contexto.');
|
|
$total = $this->money($d['total']);
|
|
$net = $this->money($d['subtotal']);
|
|
abort_if($net > $total, 422, 'Subtotal não pode exceder o total indicado.');
|
|
$entries = DB::table('cb_budget_entries')->where($s)->where('requisition_id', $origin->id);
|
|
if ($action === 'movement') {
|
|
$kind = $d['kind'];
|
|
if (! (clone $entries)->where('kind', $kind)->exists()) {
|
|
// Procurement sequence, not budget arithmetic. No reopening or invented reversal.
|
|
$reserved = (clone $entries)->where('kind', 'reserve')->exists();
|
|
$closed = (clone $entries)->whereIn('kind', ['commit', 'release'])->exists();
|
|
abort_if($closed || ($kind !== 'reserve' && ! $reserved) || ($kind === 'commit' && ! (clone $entries)->where('kind', 'award')->exists()), 409, 'Sequência indisponível: reserve, ajuste a adjudicação e depois comprometa; libertação apenas antes do compromisso.');
|
|
}
|
|
$effect = $adapter->record($user, $origin->id, $kind, $total, $net);
|
|
abort_unless($effect, 409, 'Falta preparar e aprovar uma dotação para este centro/exercício.');
|
|
$id = $effect['budget_id'];
|
|
} else {
|
|
$reference = Str::upper(Str::squish($d['reference']));
|
|
$sources = DB::table('cb_consumption_sources')->where($s)->where('reference', $reference);
|
|
if ($source = $sources->first()) {
|
|
abort_unless((int) $source->origin_id === $origin->id && (int) $source->total_minor === $total && (int) $source->subtotal_minor === $net, 409, 'Referência sintética já usada com outros valores ou origem.');
|
|
$id = $source->id;
|
|
} else {
|
|
$id = DB::table('cb_consumption_sources')->insertGetId($s + ['origin_id' => $origin->id, 'reference' => $reference, 'total_minor' => $total, 'subtotal_minor' => $net, 'created_by' => $user->id, 'created_at' => now()]);
|
|
}
|
|
$engine->consume($origin->id, $id, $total, $net);
|
|
}
|
|
}
|
|
$result = ['id' => (int) $id, 'action' => $action];
|
|
DB::table('cb_ui_commands')->insert($s + ['actor_id' => $user->id, 'command_key' => $key, 'action' => $action, 'request_hash' => $hash, 'result' => json_encode($result, JSON_THROW_ON_ERROR), 'created_at' => now()]);
|
|
|
|
return $result + ['replayed' => false];
|
|
});
|
|
} catch (BudgetFailure $e) {
|
|
abort_if($e->status !== 422, $e->status, $e->getMessage());
|
|
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
|
|
}
|
|
}
|
|
}
|