commit4
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<?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()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user