Preserve shared budget adapter and limited technical acceptance
This commit is contained in:
@@ -2,100 +2,29 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetReinforcement;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BudgetReinforcementWorkflow
|
||||
{
|
||||
private function actor(User $user, int $tenantId): void
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id === $tenantId && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
}
|
||||
|
||||
private function valid(Budget $budget, int $amount): void
|
||||
{
|
||||
if (! $budget->approved_by || (int) $budget->year !== now()->year || $amount <= 0) {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'Exige dotação aprovada do exercício actual e montante positivo.']);
|
||||
}
|
||||
// Same technical monetary ceiling as requisitions; not a company approval threshold.
|
||||
if (BigDecimal::of($budget->approvedTotalMinor())->plus($amount)->isGreaterThan('100000000000000')) {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'A dotação acumulada ultrapassa o limite técnico de valores suportados.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function propose(User $user, Budget $budget, array $data): BudgetReinforcement
|
||||
{
|
||||
$this->actor($user, $budget->tenant_id);
|
||||
abort_unless($user->tenant_id === $budget->tenant_id, 403);
|
||||
$adapter = app(CommonBudget::class);
|
||||
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$id = $adapter->execute(fn () => $adapter->for($user)->proposeReinforcement($budget->id, $amount, Str::upper(Str::squish($data['reference'])), trim($data['reason'])));
|
||||
|
||||
return DB::transaction(function () use ($user, $budget, $data) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||
$this->actor($user, $budget->tenant_id);
|
||||
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
|
||||
$amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$reference = Str::upper(Str::squish($data['reference']));
|
||||
$reason = trim($data['reason']);
|
||||
$hash = hash('sha256', json_encode(['budget_id' => $budget->id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
|
||||
$existing = BudgetReinforcement::query()->where('reference', $reference)->first();
|
||||
if ($existing) {
|
||||
if ($existing->created_by !== $user->id || $existing->payload_hash !== $hash) {
|
||||
throw ValidationException::withMessages(['reference' => 'Referência já registada com outros dados.']);
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
$this->valid($budget, $amount);
|
||||
if ($reference === '' || $reason === '') {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'Referência e fundamento obrigatórios.']);
|
||||
}
|
||||
$reinforcement = BudgetReinforcement::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'created_by' => $user->id, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason]);
|
||||
$this->audit($user, $reinforcement, 'budget.reinforcement_proposed');
|
||||
|
||||
return $reinforcement;
|
||||
});
|
||||
return BudgetReinforcement::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
public function review(User $user, BudgetReinforcement $reinforcement, string $decision, string $reason): void
|
||||
{
|
||||
$this->actor($user, $reinforcement->tenant_id);
|
||||
abort_if($user->id === $reinforcement->created_by, 403);
|
||||
if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'Decisão e fundamento obrigatórios.']);
|
||||
}
|
||||
DB::transaction(function () use ($user, $reinforcement, $decision, $reason) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id);
|
||||
$this->actor($user, $reinforcement->tenant_id);
|
||||
$budget = Budget::query()->lockForUpdate()->findOrFail($reinforcement->budget_id);
|
||||
$reinforcement = BudgetReinforcement::query()->lockForUpdate()->findOrFail($reinforcement->id);
|
||||
if ($reinforcement->status !== 'pending') {
|
||||
if ($reinforcement->status !== $decision) {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'Decisão final imutável.']);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if ($decision === 'approved') {
|
||||
$this->valid($budget, $reinforcement->amount_minor);
|
||||
$author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($reinforcement->created_by);
|
||||
if (! $author || ! in_array($author->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
|
||||
throw ValidationException::withMessages(['reinforcement' => 'Autor da proposta deixou de ser elegível.']);
|
||||
}
|
||||
}
|
||||
$reinforcement->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]);
|
||||
$this->audit($user, $reinforcement, 'budget.reinforcement_'.$decision);
|
||||
});
|
||||
}
|
||||
|
||||
private function audit(User $user, BudgetReinforcement $reinforcement, string $action): void
|
||||
{
|
||||
app(RequisitionWorkflow::class)->audit($user, $action, null, null, ['reinforcement_id' => $reinforcement->id, 'budget_id' => $reinforcement->budget_id, 'amount_minor' => $reinforcement->amount_minor, 'status' => $reinforcement->status]);
|
||||
abort_unless($user->tenant_id === $reinforcement->tenant_id, 403);
|
||||
$adapter = app(CommonBudget::class);
|
||||
$adapter->execute(fn () => $adapter->for($user)->reviewReinforcement($reinforcement->id, $decision, $reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,68 +2,25 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BudgetWorkflow
|
||||
{
|
||||
/** Called inside the procurement transaction, after the company lock.
|
||||
* @return array<string,int>|null
|
||||
*/
|
||||
/** @return array<string,int>|null */
|
||||
public function record(User $user, Requisition $request, string $kind, int $amount = 0, ?int $subtotal = null): ?array
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null && $request->tenant_id === $user->tenant_id, 403);
|
||||
if (DB::connection()->transactionLevel() === 0) {
|
||||
throw new \LogicException('Movimentos de orçamento exigem transacção.');
|
||||
}
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$existing = BudgetEntry::query()->where('requisition_id', $request->id)->orderBy('id')->first();
|
||||
$budget = $existing
|
||||
? Budget::query()->lockForUpdate()->findOrFail($existing->budget_id)
|
||||
: Budget::query()->where('cost_center_id', $request->cost_center_id)->where('year', now()->year)->lockForUpdate()->first();
|
||||
// Unconfigured legacy processes remain development documents, explicitly unchecked.
|
||||
if (! $budget) {
|
||||
return null;
|
||||
}
|
||||
$requiredKind = $request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating';
|
||||
if ($budget->kind !== $requiredKind) {
|
||||
throw ValidationException::withMessages(['budget' => 'A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.']);
|
||||
}
|
||||
if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== now()->year)) {
|
||||
throw ValidationException::withMessages(['budget' => 'Dotação pendente de aprovação ou exercício encerrado.']);
|
||||
}
|
||||
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'])) {
|
||||
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
|
||||
}
|
||||
if (BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', $kind)->exists()) {
|
||||
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
|
||||
}
|
||||
$own = BudgetEntry::query()->where('requisition_id', $request->id);
|
||||
$reserved = (int) (clone $own)->sum('reserved_minor');
|
||||
$committed = (int) $own->sum('committed_minor');
|
||||
if ($committed !== 0 || ($kind !== 'reserve' && ! $existing)) {
|
||||
throw ValidationException::withMessages(['budget' => 'Processo sem reserva válida ou já comprometido.']);
|
||||
}
|
||||
[$reserveDelta, $commitDelta] = match ($kind) {
|
||||
'reserve', 'award' => [$amount - $reserved, 0],
|
||||
'release' => [-$reserved, 0],
|
||||
'commit' => [-$reserved, $amount],
|
||||
default => throw new \LogicException('Movimento orçamental inválido.'),
|
||||
};
|
||||
$ledger = BudgetEntry::query()->where('budget_id', $budget->id);
|
||||
$used = (int) (clone $ledger)->sum('reserved_minor') + (int) $ledger->sum('committed_minor');
|
||||
$used += (int) BudgetConsumption::query()->where('budget_id', $budget->id)->sum('amount_minor');
|
||||
if ($used + $reserveDelta + $commitDelta > $budget->approvedTotalMinor()) {
|
||||
throw ValidationException::withMessages(['budget' => 'Saldo orçamental insuficiente. Não são permitidos excessos.']);
|
||||
}
|
||||
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => $kind, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta, 'created_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'budget.'.$kind, $request, null, ['budget_id' => $budget->id, 'reserved_minor' => $reserveDelta, 'committed_minor' => $commitDelta]);
|
||||
$adapter = app(CommonBudget::class);
|
||||
|
||||
return ['budget_id' => $budget->id, 'year' => (int) $budget->year];
|
||||
return $adapter->execute(fn () => $adapter->for($user)->record(
|
||||
$request->id, $request->cost_center_id,
|
||||
$request->items()->where('purchase_type', 'equipment')->exists() ? 'investment' : 'operating',
|
||||
$kind, $amount, $subtotal,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tempo\Finance\Budget\BudgetContext;
|
||||
use Tempo\Finance\Budget\BudgetControl;
|
||||
use Tempo\Finance\Budget\BudgetFailure;
|
||||
|
||||
/** Local host adapter: auth, tenant lock and audit; no budget arithmetic. */
|
||||
final class CommonBudget
|
||||
{
|
||||
public function for(User $user): BudgetControl
|
||||
{
|
||||
abort_unless(auth()->id() === $user->id && $user->tenant_id !== null, 403);
|
||||
abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Controlo comum desta entrega suporta apenas MZN; câmbio permanece pendente.');
|
||||
$context = new BudgetContext($user->id, ['tenant_id' => (int) $user->tenant_id], (int) now()->year, 'MZN');
|
||||
|
||||
return new BudgetControl(DB::connection(), $context, '',
|
||||
function (string $action) use ($user): void {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
abort_unless(DB::table('tenants')->where('id', $user->tenant_id)->value('currency') === 'MZN', 422, 'Moeda da organização incompatível com esta entrega.');
|
||||
$roles = in_array($action, ['create', 'approve', 'reinforcement.propose', 'reinforcement.review', 'view'], true)
|
||||
? [PurchasingRole::Admin, PurchasingRole::Finance]
|
||||
: ($action === 'consume' ? [PurchasingRole::Finance] : null);
|
||||
app(CurrentActor::class)->lock($user, $roles);
|
||||
},
|
||||
function (string $action, array $data, ?int $origin) use ($user): void {
|
||||
// InvoiceWorkflow retains invoice.validated in the enclosing transaction.
|
||||
if ($action !== 'budget.consumed') {
|
||||
$request = $origin ? Requisition::query()->findOrFail($origin) : null;
|
||||
app(RequisitionWorkflow::class)->audit($user, $action, $request, null, $data);
|
||||
}
|
||||
},
|
||||
fn (int $id): bool => User::query()->where('tenant_id', $user->tenant_id)->whereKey($id)->lockForUpdate()->whereIn('purchasing_role', [PurchasingRole::Admin->value, PurchasingRole::Finance->value])->exists(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param callable(): T $command
|
||||
* @return T
|
||||
*/
|
||||
public function execute(callable $command): mixed
|
||||
{
|
||||
try {
|
||||
return $command();
|
||||
} catch (BudgetFailure $e) {
|
||||
abort_if($e->status !== 422, $e->status, $e->getMessage());
|
||||
throw ValidationException::withMessages([$e->field => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,6 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Budget;
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\GoodsReceiptItem;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
@@ -130,23 +127,10 @@ class InvoiceWorkflow
|
||||
$this->invalid('Conferência alterada por outra factura; a validação está bloqueada.');
|
||||
}
|
||||
}
|
||||
$commit = BudgetEntry::query()->where('requisition_id', $request->id)->where('kind', 'commit')->first();
|
||||
if (! $commit) {
|
||||
$this->invalid('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.');
|
||||
}
|
||||
$budget = Budget::query()->lockForUpdate()->findOrFail($commit->budget_id);
|
||||
if (! $budget->approved_by || (int) $budget->year !== now()->year) {
|
||||
$this->invalid('Orçamento não aprovado ou fora do exercício.');
|
||||
}
|
||||
$amount = (int) ($budget->includes_tax ? $invoice->total_minor : $invoice->subtotal_minor);
|
||||
$remaining = (int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor');
|
||||
if ($amount > $remaining) {
|
||||
$this->invalid('Compromisso insuficiente para a factura.');
|
||||
}
|
||||
BudgetEntry::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'requisition_id' => $request->id, 'created_by' => $user->id, 'kind' => 'invoice-'.$invoice->id, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => now()]);
|
||||
BudgetConsumption::query()->create(['tenant_id' => $user->tenant_id, 'budget_id' => $budget->id, 'invoice_id' => $invoice->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'created_at' => now()]);
|
||||
$adapter = app(CommonBudget::class);
|
||||
$effect = $adapter->execute(fn () => $adapter->for($user)->consume($request->id, $invoice->id, (int) $invoice->total_minor, (int) $invoice->subtotal_minor));
|
||||
$invoice->update(['status' => 'validated', 'validated_by' => $user->id, 'validated_at' => now()]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id, 'budget_id' => $budget->id, 'consumed_minor' => $amount]);
|
||||
app(RequisitionWorkflow::class)->audit($user, 'invoice.validated', $request, null, ['invoice_id' => $invoice->id] + $effect);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user