Corrige autorizacao transaccional e valida modulos implementados

This commit is contained in:
2026-10-28 17:19:33 +02:00
parent 95dd2cc1ed
commit 5ff8d78803
30 changed files with 601 additions and 97 deletions
+4 -1
View File
@@ -9,6 +9,7 @@ use App\Models\BudgetEntry;
use App\Models\BudgetReinforcement;
use App\Models\CostCenter;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\RequisitionWorkflow;
use Brick\Math\BigDecimal;
use Illuminate\Http\RedirectResponse;
@@ -37,7 +38,7 @@ class BudgetController extends Controller
$total = $budget->approvedTotalMinor();
return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $total, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $total - $reserved - $committed - $consumed];
return [...$budget->toArray(), 'reinforced_minor' => $total - $budget->amount_minor, 'approved_total_minor' => $budget->approved_by ? $total : 0, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
}), 'reinforcements' => BudgetReinforcement::query()->latest('id')->paginate(20)]);
}
@@ -48,6 +49,7 @@ class BudgetController extends Controller
$kind = $request->validate(['kind' => ['sometimes', Rule::in(['operating', 'investment'])]])['kind'] ?? 'operating';
DB::transaction(function () use ($request, $data, $kind) {
app(ConflictGuard::class)->lockCompany($request->user());
app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]);
abort_if(Budget::query()->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists(), 409, 'Já existe dotação para este centro e exercício.');
$budget = Budget::query()->create(['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'cost_center_id' => $data['cost_center_id'], 'year' => $data['year'], 'kind' => $kind, 'includes_tax' => $data['includes_tax'], 'amount_minor' => BigDecimal::of($data['amount'])->multipliedBy(100)->toInt(), 'reason' => $data['reason']]);
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray());
@@ -61,6 +63,7 @@ class BudgetController extends Controller
$this->access($request);
DB::transaction(function () use ($request, $budget) {
app(ConflictGuard::class)->lockCompany($request->user());
app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]);
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
abort_if($budget->created_by === $request->user()->id, 403);
if ($budget->approved_by) {
@@ -33,7 +33,7 @@ class SecurityController extends Controller
'password' => $request->password,
]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Palavra-passe actualizada.')]);
return back();
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class CurrentActor
{
/** Call after the company lock, inside the business transaction.
* @param list<PurchasingRole>|null $roles
*/
public function lock(User $actor, ?array $roles = null): User
{
abort_unless(auth()->id() === $actor->id && $actor->tenant_id !== null, 403);
if (DB::connection()->transactionLevel() === 0) {
throw new \LogicException('Revalidar o operador dentro da transacção.');
}
$user = User::query()->where('tenant_id', $actor->tenant_id)->lockForUpdate()->findOrFail($actor->id);
abort_unless($roles === null || in_array($user->purchasing_role, $roles, true), 403);
return $user;
}
}
+9 -1
View File
@@ -66,6 +66,7 @@ class EquipmentWorkflow
{
return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::DepartmentHead]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($this->canReview($user, $requisition), 403);
app(ConflictGuard::class)->assertParticipation($user, $requisition);
@@ -88,7 +89,14 @@ class EquipmentWorkflow
return;
}
$review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first();
$user = $review ? User::query()->where('tenant_id', $requisition->tenant_id)->find($review->reviewed_by) : null;
$user = null;
if ($review) {
$query = User::query()->where('tenant_id', $requisition->tenant_id);
if (DB::connection()->transactionLevel() > 0) {
$query->lockForUpdate();
}
$user = $query->find($review->reviewed_by);
}
if (! $review || $review->decision !== 'approved' || ! $user || ! $this->eligible($user, $requisition)) {
throw ValidationException::withMessages(['technical' => 'É necessária validação técnica válida do responsável da área, distinto do solicitante, para a versão actual.']);
}
+2
View File
@@ -75,6 +75,7 @@ class InvoiceWorkflow
return DB::transaction(function () use ($user, $order, $data) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
$request = Requisition::query()->findOrFail($order->requisition_id);
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
@@ -107,6 +108,7 @@ class InvoiceWorkflow
abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
DB::transaction(function () use ($user, $invoice) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
if ($invoice->status === 'validated') {
return;
+4 -1
View File
@@ -26,6 +26,7 @@ class PurchaseWorkflow
{
return DB::transaction(function () use ($user, $requisition, $data) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
@@ -73,6 +74,7 @@ class PurchaseWorkflow
{
DB::transaction(function () use ($user, $award, $decision, $comment) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
abort_unless($this->canDecide($user, $award, $requisition), 403);
@@ -94,6 +96,7 @@ class PurchaseWorkflow
{
return DB::transaction(function () use ($user, $award) {
$this->guard->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
@@ -262,7 +265,7 @@ class PurchaseWorkflow
throw ValidationException::withMessages(['approvals' => 'O ciclo de aprovação do valor final está incompleto.']);
}
foreach ($steps as $index => $step) {
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($step->approver_id);
$user = User::query()->where('tenant_id', $requisition->tenant_id)->lockForUpdate()->find($step->approver_id);
if (! $user || $step->decision !== 'approved' || $step->decided_at === null || $step->role !== $roles[$index] || $user->purchasing_role->value !== $step->role ||
($step->role === 'department_head' && $user->department_id !== $requisition->department_id) || in_array($user->id, $seen, true)) {
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
+3 -1
View File
@@ -2,6 +2,7 @@
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\GoodsReceipt;
use App\Models\GoodsReceiptItem;
use App\Models\PurchaseOrder;
@@ -66,8 +67,9 @@ class ReceiptWorkflow
{
return DB::transaction(function () use ($user, $order, $data) {
$this->conflicts->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Warehouse]);
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
Gate::authorize('create', [GoodsReceipt::class, $order]);
Gate::forUser($user)->authorize('create', [GoodsReceipt::class, $order]);
$deliveryNote = Str::squish(Str::upper($data['delivery_note']));
if ($deliveryNote === '') {
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
+10
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\AuditLog;
use App\Models\CostCenter;
use App\Models\Requisition;
use App\Models\Tenant;
use App\Models\User;
@@ -18,6 +19,10 @@ class RequisitionWorkflow
{
return DB::transaction(function () use ($user, $data, $existing) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
if (! $user->department_id || ! CostCenter::query()->whereKey($data['cost_center_id'])->where('department_id', $user->department_id)->exists()) {
throw ValidationException::withMessages(['cost_center_id' => 'O departamento ou centro de custo mudou. Actualize o pedido antes de guardar.']);
}
$requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition;
if ($existing) {
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
@@ -70,8 +75,12 @@ class RequisitionWorkflow
{
DB::transaction(function () use ($user, $requisition) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
if ($user->department_id !== $requisition->department_id) {
throw ValidationException::withMessages(['submission' => 'O departamento do solicitante mudou. Reveja o rascunho e o centro de custo antes de submeter.']);
}
app(ConflictGuard::class)->assertParticipation($user, $requisition);
foreach ($requisition->items()->get() as $item) {
app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority);
@@ -103,6 +112,7 @@ class RequisitionWorkflow
{
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
$requisition->load('steps');
abort_unless($requisition->canDecide($user), 403);
+1
View File
@@ -32,6 +32,7 @@ class StockWorkflow
return DB::transaction(function () use ($actor, $data) {
// Same first lock as receipts: includes empty balances and concurrent receipts.
app(ConflictGuard::class)->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first();
if ($existing) {
+2
View File
@@ -28,6 +28,7 @@ class SupplierBankWorkflow
return DB::transaction(function () use ($user, $supplier, $data) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
$this->actor($user, $supplier);
$latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
@@ -49,6 +50,7 @@ class SupplierBankWorkflow
}
DB::transaction(function () use ($user, $detail, $decision, $reason) {
app(ConflictGuard::class)->lockCompany($user);
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
$supplier = Supplier::query()->lockForUpdate()->findOrFail($detail->supplier_id);
$this->actor($user, $supplier);
$latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->firstOrFail();