Corrige autorizacao transaccional e valida modulos implementados
This commit is contained in:
@@ -9,6 +9,7 @@ use App\Models\BudgetEntry;
|
|||||||
use App\Models\BudgetReinforcement;
|
use App\Models\BudgetReinforcement;
|
||||||
use App\Models\CostCenter;
|
use App\Models\CostCenter;
|
||||||
use App\Services\ConflictGuard;
|
use App\Services\ConflictGuard;
|
||||||
|
use App\Services\CurrentActor;
|
||||||
use App\Services\RequisitionWorkflow;
|
use App\Services\RequisitionWorkflow;
|
||||||
use Brick\Math\BigDecimal;
|
use Brick\Math\BigDecimal;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -37,7 +38,7 @@ class BudgetController extends Controller
|
|||||||
|
|
||||||
$total = $budget->approvedTotalMinor();
|
$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)]);
|
}), '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';
|
$kind = $request->validate(['kind' => ['sometimes', Rule::in(['operating', 'investment'])]])['kind'] ?? 'operating';
|
||||||
DB::transaction(function () use ($request, $data, $kind) {
|
DB::transaction(function () use ($request, $data, $kind) {
|
||||||
app(ConflictGuard::class)->lockCompany($request->user());
|
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.');
|
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']]);
|
$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());
|
app(RequisitionWorkflow::class)->audit($request->user(), 'budget.created', null, null, $budget->toArray());
|
||||||
@@ -61,6 +63,7 @@ class BudgetController extends Controller
|
|||||||
$this->access($request);
|
$this->access($request);
|
||||||
DB::transaction(function () use ($request, $budget) {
|
DB::transaction(function () use ($request, $budget) {
|
||||||
app(ConflictGuard::class)->lockCompany($request->user());
|
app(ConflictGuard::class)->lockCompany($request->user());
|
||||||
|
app(CurrentActor::class)->lock($request->user(), [PurchasingRole::Admin, PurchasingRole::Finance]);
|
||||||
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
|
$budget = Budget::query()->lockForUpdate()->findOrFail($budget->id);
|
||||||
abort_if($budget->created_by === $request->user()->id, 403);
|
abort_if($budget->created_by === $request->user()->id, 403);
|
||||||
if ($budget->approved_by) {
|
if ($budget->approved_by) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class SecurityController extends Controller
|
|||||||
'password' => $request->password,
|
'password' => $request->password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
|
Inertia::flash('toast', ['type' => 'success', 'message' => __('Palavra-passe actualizada.')]);
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ class EquipmentWorkflow
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) {
|
return DB::transaction(function () use ($user, $requisition, $basis, $decision, $reason) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::DepartmentHead]);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||||
abort_unless($this->canReview($user, $requisition), 403);
|
abort_unless($this->canReview($user, $requisition), 403);
|
||||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||||
@@ -88,7 +89,14 @@ class EquipmentWorkflow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$review = TechnicalReview::query()->where('requisition_id', $requisition->id)->where('basis_hash', $this->basis($requisition))->first();
|
$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)) {
|
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.']);
|
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.']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class InvoiceWorkflow
|
|||||||
|
|
||||||
return DB::transaction(function () use ($user, $order, $data) {
|
return DB::transaction(function () use ($user, $order, $data) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
||||||
$request = Requisition::query()->findOrFail($order->requisition_id);
|
$request = Requisition::query()->findOrFail($order->requisition_id);
|
||||||
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
|
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);
|
abort_unless($invoice->tenant_id === $user->tenant_id && $invoice->created_by !== $user->id, 403);
|
||||||
DB::transaction(function () use ($user, $invoice) {
|
DB::transaction(function () use ($user, $invoice) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||||
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
|
$invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);
|
||||||
if ($invoice->status === 'validated') {
|
if ($invoice->status === 'validated') {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PurchaseWorkflow
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($user, $requisition, $data) {
|
return DB::transaction(function () use ($user, $requisition, $data) {
|
||||||
$this->guard->lockCompany($user);
|
$this->guard->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
|
abort_unless($user->purchasing_role === PurchasingRole::Procurement && $user->id !== $requisition->requester_id, 403);
|
||||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||||
@@ -73,6 +74,7 @@ class PurchaseWorkflow
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
||||||
$this->guard->lockCompany($user);
|
$this->guard->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||||
abort_unless($this->canDecide($user, $award, $requisition), 403);
|
abort_unless($this->canDecide($user, $award, $requisition), 403);
|
||||||
@@ -94,6 +96,7 @@ class PurchaseWorkflow
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($user, $award) {
|
return DB::transaction(function () use ($user, $award) {
|
||||||
$this->guard->lockCompany($user);
|
$this->guard->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->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.']);
|
throw ValidationException::withMessages(['approvals' => 'O ciclo de aprovação do valor final está incompleto.']);
|
||||||
}
|
}
|
||||||
foreach ($steps as $index => $step) {
|
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 ||
|
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)) {
|
($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.']);
|
throw ValidationException::withMessages(['approvals' => 'As aprovações do valor final devem continuar elegíveis e ser de pessoas distintas.']);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
use App\Models\GoodsReceipt;
|
use App\Models\GoodsReceipt;
|
||||||
use App\Models\GoodsReceiptItem;
|
use App\Models\GoodsReceiptItem;
|
||||||
use App\Models\PurchaseOrder;
|
use App\Models\PurchaseOrder;
|
||||||
@@ -66,8 +67,9 @@ class ReceiptWorkflow
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($user, $order, $data) {
|
return DB::transaction(function () use ($user, $order, $data) {
|
||||||
$this->conflicts->lockCompany($user);
|
$this->conflicts->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Warehouse]);
|
||||||
$order = PurchaseOrder::query()->lockForUpdate()->findOrFail($order->id);
|
$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']));
|
$deliveryNote = Str::squish(Str::upper($data['delivery_note']));
|
||||||
if ($deliveryNote === '') {
|
if ($deliveryNote === '') {
|
||||||
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
|
throw ValidationException::withMessages(['delivery_note' => 'Indique a guia de remessa do fornecedor.']);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\CostCenter;
|
||||||
use App\Models\Requisition;
|
use App\Models\Requisition;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -18,6 +19,10 @@ class RequisitionWorkflow
|
|||||||
{
|
{
|
||||||
return DB::transaction(function () use ($user, $data, $existing) {
|
return DB::transaction(function () use ($user, $data, $existing) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
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;
|
$requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition;
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||||
@@ -70,8 +75,12 @@ class RequisitionWorkflow
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($user, $requisition) {
|
DB::transaction(function () use ($user, $requisition) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
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);
|
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||||
foreach ($requisition->items()->get() as $item) {
|
foreach ($requisition->items()->get() as $item) {
|
||||||
app(EquipmentWorkflow::class)->line($item->toArray(), $requisition->department_id, $requisition->priority);
|
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) {
|
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user);
|
||||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||||
$requisition->load('steps');
|
$requisition->load('steps');
|
||||||
abort_unless($requisition->canDecide($user), 403);
|
abort_unless($requisition->canDecide($user), 403);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class StockWorkflow
|
|||||||
return DB::transaction(function () use ($actor, $data) {
|
return DB::transaction(function () use ($actor, $data) {
|
||||||
// Same first lock as receipts: includes empty balances and concurrent receipts.
|
// Same first lock as receipts: includes empty balances and concurrent receipts.
|
||||||
app(ConflictGuard::class)->lockCompany($actor);
|
app(ConflictGuard::class)->lockCompany($actor);
|
||||||
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Warehouse]);
|
||||||
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
$hash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
||||||
$existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first();
|
$existing = StockOperation::query()->where('idempotency_key', $data['idempotency_key'])->first();
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class SupplierBankWorkflow
|
|||||||
|
|
||||||
return DB::transaction(function () use ($user, $supplier, $data) {
|
return DB::transaction(function () use ($user, $supplier, $data) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Procurement]);
|
||||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||||
$this->actor($user, $supplier);
|
$this->actor($user, $supplier);
|
||||||
$latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
|
$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) {
|
DB::transaction(function () use ($user, $detail, $decision, $reason) {
|
||||||
app(ConflictGuard::class)->lockCompany($user);
|
app(ConflictGuard::class)->lockCompany($user);
|
||||||
|
$user = app(CurrentActor::class)->lock($user, [PurchasingRole::Finance]);
|
||||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($detail->supplier_id);
|
$supplier = Supplier::query()->lockForUpdate()->findOrFail($detail->supplier_id);
|
||||||
$this->actor($user, $supplier);
|
$this->actor($user, $supplier);
|
||||||
$latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->firstOrFail();
|
$latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->firstOrFail();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"tested_at_utc": "2026-10-28T15:16:34+00:00",
|
||||||
|
"database": "compras_receipts_test_0612e6280f36",
|
||||||
|
"separate_database": true,
|
||||||
|
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"name": "role_revoked_while_waiting",
|
||||||
|
"passed": true,
|
||||||
|
"overlapping_lock_waits": 2,
|
||||||
|
"statuses": [403, 403],
|
||||||
|
"receipts": 0,
|
||||||
|
"stock_movements": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passed": true,
|
||||||
|
"test_database_dropped": true,
|
||||||
|
"live_user_count_unchanged": true
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T12:32:47+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
|
|||||||
@@ -23,6 +23,15 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em
|
|||||||
|
|
||||||
## Verificação actual
|
## Verificação actual
|
||||||
|
|
||||||
|
Revisão transversal: **407 aprovados, 2 ignorados (MFA desligado), 2.964 asserções**.
|
||||||
|
32 cenários concorrentes PostgreSQL aprovados; bases temporárias eliminadas.
|
||||||
|
Perfis e departamentos são novamente verificados dentro da transacção;
|
||||||
|
dotações pendentes não apresentam fundos disponíveis. Evidências, âmbito e
|
||||||
|
pendências em [validação técnica](validacao-tecnica.md). Revisão visual,
|
||||||
|
cancelamento, MFA e validação operacional integral continuam pendentes.
|
||||||
|
|
||||||
|
### Histórico da entrega de investimento
|
||||||
|
|
||||||
Regras de investimento entregues por empresa, com parâmetros explícitos e duas
|
Regras de investimento entregues por empresa, com parâmetros explícitos e duas
|
||||||
aprovações administrativas independentes. Dotação classificada, reserva e Finanças
|
aprovações administrativas independentes. Dotação classificada, reserva e Finanças
|
||||||
obrigatórias para equipamento; regra congelada e revalidada antes de emitir.
|
obrigatórias para equipamento; regra congelada e revalidada antes de emitir.
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# Desenvolvimento do SGC
|
# Desenvolvimento do SGC
|
||||||
|
|
||||||
|
## Revisão transversal de autorização e validação
|
||||||
|
|
||||||
|
Corrigidas verificações de perfil que aconteciam antes de esperar pelo bloqueio
|
||||||
|
da empresa: o operador é agora recarregado e bloqueado na transacção nos módulos
|
||||||
|
de facturas, banco, armazém, requisições e compras. Departamento/centro de custo
|
||||||
|
também são revalidados. Dotação não aprovada deixou de mostrar verba disponível.
|
||||||
|
Corrigido um teste indevidamente ignorado com MFA desligado e traduzidos os textos
|
||||||
|
da página de segurança. Configuração de fontes alinhada com a dependência opcional
|
||||||
|
ausente, sem mudança de fonte ou instalação de pacotes.
|
||||||
|
|
||||||
|
Suite: **407 aprovados, 2 ignorados, 2.964 asserções**. Os dois ignorados são de
|
||||||
|
MFA activado, que continua desligado. **32 cenários concorrentes PostgreSQL**
|
||||||
|
aprovados, incluindo revogação real de perfil enquanto dois pedidos aguardam
|
||||||
|
bloqueio: ambos responderam 403, sem movimentos. Bases temporárias eliminadas.
|
||||||
|
Sem migrações ou alterações de contas/dados de negócio reais nesta revisão.
|
||||||
|
|
||||||
|
Detalhes das correcções, evidências e pendências estão em
|
||||||
|
[validação técnica](validacao-tecnica.md). Revisão visual e validação operacional
|
||||||
|
integral permanecem pendentes. Funcionalidades não implementadas, incluindo
|
||||||
|
revisão/cancelamento, não foram tratadas como se já existissem.
|
||||||
|
|
||||||
## Regras de investimento por empresa
|
## Regras de investimento por empresa
|
||||||
|
|
||||||
Implementadas em `/regras-investimento`, sem montantes predefinidos ou regras
|
Implementadas em `/regras-investimento`, sem montantes predefinidos ou regras
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T12:19:17+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T13:24:32+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T12:11:20+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T12:49:13+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T10:56:15+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"database": "compras_receipts_test_84e8492cd8f3",
|
"database": "compras_receipts_test_c9a6d4b5eeb5",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
|
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
@@ -9,19 +9,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 201,
|
|
||||||
"id": 1,
|
|
||||||
"created": true,
|
|
||||||
"error_fields": [],
|
|
||||||
"backend_pid": 4688
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 422,
|
"status": 422,
|
||||||
"id": null,
|
"id": null,
|
||||||
"created": null,
|
"created": null,
|
||||||
"error_fields": ["items"],
|
"error_fields": ["items"],
|
||||||
"backend_pid": 2068
|
"backend_pid": 24512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 1,
|
||||||
|
"created": true,
|
||||||
|
"error_fields": [],
|
||||||
|
"backend_pid": 15424
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"receipts": 1,
|
"receipts": 1,
|
||||||
@@ -34,19 +34,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 201,
|
|
||||||
"id": 2,
|
|
||||||
"created": true,
|
|
||||||
"error_fields": [],
|
|
||||||
"backend_pid": 9064
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"created": false,
|
"created": false,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 1036
|
"backend_pid": 6580
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 2,
|
||||||
|
"created": true,
|
||||||
|
"error_fields": [],
|
||||||
|
"backend_pid": 5964
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"receipts": 1,
|
"receipts": 1,
|
||||||
@@ -59,19 +59,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 201,
|
|
||||||
"id": 4,
|
|
||||||
"created": true,
|
|
||||||
"error_fields": [],
|
|
||||||
"backend_pid": 3020
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 201,
|
"status": 201,
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 24624
|
"backend_pid": 26176
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 4,
|
||||||
|
"created": true,
|
||||||
|
"error_fields": [],
|
||||||
|
"backend_pid": 24244
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"receipts": 2,
|
"receipts": 2,
|
||||||
@@ -84,19 +84,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 422,
|
|
||||||
"id": null,
|
|
||||||
"created": null,
|
|
||||||
"error_fields": ["delivery_note"],
|
|
||||||
"backend_pid": 22120
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 201,
|
"status": 201,
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 4720
|
"backend_pid": 21524
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 422,
|
||||||
|
"id": null,
|
||||||
|
"created": null,
|
||||||
|
"error_fields": ["delivery_note"],
|
||||||
|
"backend_pid": 25700
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"receipts": 1,
|
"receipts": 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T12:59:44+00:00",
|
"tested_at_utc": "2026-10-28T15:07:46+00:00",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"approved_total_minor": 12000,
|
"approved_total_minor": 12000,
|
||||||
"reserved_minor": 10000,
|
"reserved_minor": 0,
|
||||||
"original_allocation_unchanged": true
|
"original_allocation_unchanged": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tested_at_utc": "2026-10-28T11:35:31+00:00",
|
"tested_at_utc": "2026-10-28T15:07:45+00:00",
|
||||||
"database": "compras_receipts_test_d97ad8ad1eb7",
|
"database": "compras_receipts_test_aa453293fa81",
|
||||||
"separate_database": true,
|
"separate_database": true,
|
||||||
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
|
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
@@ -9,19 +9,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 201,
|
|
||||||
"id": 1,
|
|
||||||
"created": true,
|
|
||||||
"error_fields": [],
|
|
||||||
"backend_pid": 5004
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 422,
|
"status": 422,
|
||||||
"id": null,
|
"id": null,
|
||||||
"created": null,
|
"created": null,
|
||||||
"error_fields": ["quantity"],
|
"error_fields": ["quantity"],
|
||||||
"backend_pid": 27024
|
"backend_pid": 23636
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 1,
|
||||||
|
"created": true,
|
||||||
|
"error_fields": [],
|
||||||
|
"backend_pid": 15544
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 400,
|
"source_milli": 400,
|
||||||
@@ -37,14 +37,14 @@
|
|||||||
"id": 2,
|
"id": 2,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 7640
|
"backend_pid": 19896
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"created": false,
|
"created": false,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 22852
|
"backend_pid": 25896
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 400,
|
"source_milli": 400,
|
||||||
@@ -55,19 +55,19 @@
|
|||||||
"passed": true,
|
"passed": true,
|
||||||
"overlapping_lock_waits": 2,
|
"overlapping_lock_waits": 2,
|
||||||
"responses": [
|
"responses": [
|
||||||
{
|
|
||||||
"status": 201,
|
|
||||||
"id": 3,
|
|
||||||
"created": true,
|
|
||||||
"error_fields": [],
|
|
||||||
"backend_pid": 25232
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"created": false,
|
"created": false,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 28856
|
"backend_pid": 22516
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status": 201,
|
||||||
|
"id": 3,
|
||||||
|
"created": true,
|
||||||
|
"error_fields": [],
|
||||||
|
"backend_pid": 24156
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 400,
|
"source_milli": 400,
|
||||||
@@ -83,18 +83,18 @@
|
|||||||
"id": 4,
|
"id": 4,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 5268
|
"backend_pid": 2604
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": 422,
|
"status": 201,
|
||||||
"id": null,
|
"id": 5,
|
||||||
"created": null,
|
"created": true,
|
||||||
"error_fields": ["quantity"],
|
"error_fields": [],
|
||||||
"backend_pid": 4288
|
"backend_pid": 22296
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 400,
|
"source_milli": 500,
|
||||||
"destination_milli": 600
|
"destination_milli": 500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "same_adjustment",
|
"name": "same_adjustment",
|
||||||
@@ -103,17 +103,17 @@
|
|||||||
"responses": [
|
"responses": [
|
||||||
{
|
{
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"id": 6,
|
"id": 7,
|
||||||
"created": false,
|
"created": false,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 13608
|
"backend_pid": 26392
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": 201,
|
"status": 201,
|
||||||
"id": 6,
|
"id": 7,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 2320
|
"backend_pid": 17308
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 800,
|
"source_milli": 800,
|
||||||
@@ -129,14 +129,14 @@
|
|||||||
"id": null,
|
"id": null,
|
||||||
"created": null,
|
"created": null,
|
||||||
"error_fields": ["count_id"],
|
"error_fields": ["count_id"],
|
||||||
"backend_pid": 25516
|
"backend_pid": 24416
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": 422,
|
"status": 422,
|
||||||
"id": null,
|
"id": null,
|
||||||
"created": null,
|
"created": null,
|
||||||
"error_fields": ["count_id"],
|
"error_fields": ["count_id"],
|
||||||
"backend_pid": 23640
|
"backend_pid": 15140
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 900,
|
"source_milli": 900,
|
||||||
@@ -149,17 +149,17 @@
|
|||||||
"responses": [
|
"responses": [
|
||||||
{
|
{
|
||||||
"status": 201,
|
"status": 201,
|
||||||
"id": 9,
|
"id": 10,
|
||||||
"created": true,
|
"created": true,
|
||||||
"error_fields": [],
|
"error_fields": [],
|
||||||
"backend_pid": 18244
|
"backend_pid": 4480
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"status": 422,
|
"status": 422,
|
||||||
"id": null,
|
"id": null,
|
||||||
"created": null,
|
"created": null,
|
||||||
"error_fields": ["quantity"],
|
"error_fields": ["quantity"],
|
||||||
"backend_pid": 2452
|
"backend_pid": 20176
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source_milli": 400,
|
"source_milli": 400,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Validação técnica do SGC — 28 de Outubro de 2026
|
||||||
|
|
||||||
|
Esta revisão cobre os módulos implementados e as pendências documentadas no
|
||||||
|
repositório. Não equivale a validação operacional integral ou certificação de
|
||||||
|
todos os requisitos do sistema. Valores e políticas reais continuam a pertencer
|
||||||
|
a cada empresa; nenhum foi criado nesta revisão.
|
||||||
|
|
||||||
|
## Correcções verificadas
|
||||||
|
|
||||||
|
- Operações que verificavam o perfil antes de esperar pelo bloqueio da empresa
|
||||||
|
passam a recarregar e bloquear o utilizador dentro da transacção. Abrange
|
||||||
|
facturas, dados bancários, stock, recepções, requisições, adjudicação, emissão,
|
||||||
|
validação técnica e dotação inicial. Mudança de perfil ou de empresa durante
|
||||||
|
a espera não é autorizada pelo perfil antigo em memória.
|
||||||
|
- Requisição volta a verificar departamento e centro de custo ao guardar e o
|
||||||
|
departamento do solicitante ao submeter. Decisões departamentais usam o perfil
|
||||||
|
actual; revisão técnica e aprovadores finais são bloqueados durante as
|
||||||
|
verificações transaccionais.
|
||||||
|
- Dotação pendente mostra **zero disponível e zero aprovado**, preservando o
|
||||||
|
valor proposto e o histórico. O servidor já bloqueava a reserva; a interface
|
||||||
|
deixou de apresentar verba proposta como disponível.
|
||||||
|
- Teste da página de segurança com MFA desligado deixou de ser indevidamente
|
||||||
|
ignorado. Textos dessa página e confirmação de alteração de palavra-passe
|
||||||
|
traduzidos para português. Nenhuma palavra-passe real foi alterada.
|
||||||
|
- Configuração de fontes passou a declarar desactivada a optimização opcional
|
||||||
|
que já não era gerada por ausência de `fontaine`. Mantém Instrument Sans e
|
||||||
|
elimina o aviso de dependência opcional sem instalar pacotes novos.
|
||||||
|
|
||||||
|
## Resultados
|
||||||
|
|
||||||
|
**407 testes aprovados, 2 ignorados, 2.964 asserções** na suite completa SQLite
|
||||||
|
em memória. Os dois ignorados dependem de MFA activado, actualmente desligado;
|
||||||
|
não são apresentados como funcionalidades validadas. PHPStan, Pint, TypeScript,
|
||||||
|
compilação e formatação/lint frontend verificados no fecho.
|
||||||
|
|
||||||
|
**32 cenários concorrentes PostgreSQL aprovados**, todos em bases exclusivas
|
||||||
|
de teste, eliminadas no final:
|
||||||
|
|
||||||
|
| Área | Cenários | Evidência |
|
||||||
|
| ------------------------------ | -------: | ------------------------------------------------------ |
|
||||||
|
| Recepções | 4 | [Relatório](recepcao-concorrencia-postgresql.json) |
|
||||||
|
| Stock e peças | 7 | [Relatório](stock-concorrencia-postgresql.json) |
|
||||||
|
| Reservas e compromissos | 5 | [Relatório](orcamento-concorrencia-postgresql.json) |
|
||||||
|
| Facturas e consumo | 3 | [Relatório](facturas-concorrencia-postgresql.json) |
|
||||||
|
| Dados bancários | 3 | [Relatório](bancos-concorrencia-postgresql.json) |
|
||||||
|
| Pagamentos externos | 3 | [Relatório](pagamentos-concorrencia-postgresql.json) |
|
||||||
|
| Reforços | 3 | [Relatório](reforcos-concorrencia-postgresql.json) |
|
||||||
|
| Regras de investimento | 3 | [Relatório](investimento-concorrencia-postgresql.json) |
|
||||||
|
| Perfil revogado durante espera | 1 | [Relatório](autorizacao-concorrencia-postgresql.json) |
|
||||||
|
|
||||||
|
O último ensaio bloqueia a empresa, observa duas ligações simultaneamente à
|
||||||
|
espera e revoga os dois perfis de Armazém antes de libertar o bloqueio. Os dois
|
||||||
|
pedidos pelo kernel HTTP retornam **403**; não criam recepções nem movimentos.
|
||||||
|
Os restantes ensaios verificam saldos, duplicações e integridade conforme os
|
||||||
|
respectivos relatórios. Não são testes de carga ou uma validação de todos os
|
||||||
|
possíveis cruzamentos concorrentes do sistema.
|
||||||
|
|
||||||
|
Sem migrações, seeds, criação de contas, alteração de configuração de ambiente
|
||||||
|
ou escritas de negócio na base real nesta revisão. Acesso HTTP pode actualizar
|
||||||
|
sessões. Nenhuma autorização de compra ou transferência bancária foi emitida.
|
||||||
|
|
||||||
|
## O que permanece por tratar
|
||||||
|
|
||||||
|
| Pendência | Situação |
|
||||||
|
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Revisão visual pública, autenticada e impressão | Bloqueada pelo acesso ao navegador; testes HTTP não a substituem. |
|
||||||
|
| MFA obrigatório e ciclo completo de acessos | Não implementados/activados; dois testes condicionais continuam ignorados. |
|
||||||
|
| Revisão/cancelamento de adjudicações e ordens | Fluxo e política próprios ainda ausentes. Operações incompatíveis continuam bloqueadas. |
|
||||||
|
| Reversões, estornos, notas de crédito e devoluções | Funcionalidades ainda não implementadas; não se apagam lançamentos existentes. |
|
||||||
|
| Regras empresariais | Escalões provisórios, regras fiscais, validade/levantamento de conflitos e excepções exigem decisão da empresa. |
|
||||||
|
| Investimento agregado e fraccionamento | Apenas tecto por processo suportado; sem agregação/detecção automática. |
|
||||||
|
| Outras etapas do roteiro | Multimoeda, reposição, tolerâncias/investigação de inventários, manutenção/activos e requisitos de subscrição/hosting continuam no roteiro. |
|
||||||
|
| Controlos operacionais | Carga, permissões contra SQL administrativo, retenção e recuperação por empresa ainda exigem validação própria. |
|
||||||
|
|
||||||
|
Estas pendências não foram marcadas como concluídas por compilação ou testes
|
||||||
|
funcionais. O próximo incremento de revisão/cancelamento deve definir e guardar
|
||||||
|
uma política explícita por empresa, conservar o passado e exigir novas decisões
|
||||||
|
para processos revistos; não pode autorizar por omissão a alteração de ordens
|
||||||
|
emitidas ou a libertação de compromissos já consumidos.
|
||||||
|
|
||||||
|
Verificação HTTP local no fecho: **18 páginas públicas responderam 200** e
|
||||||
|
**13 rotas protegidas responderam 302 sem sessão**. O navegador voltou a devolver
|
||||||
|
`The browser access host is unavailable`; a revisão visual continua pendente.
|
||||||
@@ -19,15 +19,15 @@ export default function Security(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Security settings" />
|
<Head title="Segurança da conta" />
|
||||||
|
|
||||||
<h1 className="sr-only">Security settings</h1>
|
<h1 className="sr-only">Segurança da conta</h1>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Heading
|
<Heading
|
||||||
variant="small"
|
variant="small"
|
||||||
title="Update password"
|
title="Alterar palavra-passe"
|
||||||
description="Ensure your account is using a long, random password to stay secure"
|
description="Use uma palavra-passe longa e exclusiva para proteger a conta."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -56,7 +56,7 @@ export default function Security(props: Props) {
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="current_password">
|
<Label htmlFor="current_password">
|
||||||
Current password
|
Palavra-passe actual
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -65,14 +65,16 @@ export default function Security(props: Props) {
|
|||||||
name="current_password"
|
name="current_password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Current password"
|
placeholder="Palavra-passe actual"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputError message={errors.current_password} />
|
<InputError message={errors.current_password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">New password</Label>
|
<Label htmlFor="password">
|
||||||
|
Nova palavra-passe
|
||||||
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
@@ -80,7 +82,7 @@ export default function Security(props: Props) {
|
|||||||
name="password"
|
name="password"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="New password"
|
placeholder="Nova palavra-passe"
|
||||||
passwordrules={props.passwordRules}
|
passwordrules={props.passwordRules}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ export default function Security(props: Props) {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password_confirmation">
|
<Label htmlFor="password_confirmation">
|
||||||
Confirm password
|
Confirmar palavra-passe
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
@@ -97,7 +99,7 @@ export default function Security(props: Props) {
|
|||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
className="mt-1 block w-full"
|
className="mt-1 block w-full"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
placeholder="Confirm password"
|
placeholder="Confirmar palavra-passe"
|
||||||
passwordrules={props.passwordRules}
|
passwordrules={props.passwordRules}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export default function Security(props: Props) {
|
|||||||
disabled={processing}
|
disabled={processing}
|
||||||
data-test="update-password-button"
|
data-test="update-password-button"
|
||||||
>
|
>
|
||||||
Save
|
Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
|
||||||
|
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
require __DIR__.'/../../vendor/autoload.php';
|
||||||
|
|
||||||
|
$database = $argv[1] ?? '';
|
||||||
|
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||||
|
fwrite(STDERR, 'Base de testes inválida.');
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(ConsoleKernel::class)->bootstrap();
|
||||||
|
if (! $app->environment('testing')) {
|
||||||
|
throw new LogicException('O worker só pode executar em APP_ENV=testing.');
|
||||||
|
}
|
||||||
|
$config = config('database.connections.pgsql');
|
||||||
|
$config['database'] = $database;
|
||||||
|
$config['url'] = null;
|
||||||
|
config(['database.default' => 'pgsql', 'database.connections.pgsql' => $config]);
|
||||||
|
DB::purge('pgsql');
|
||||||
|
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||||
|
throw new LogicException('A ligação não corresponde à base de testes.');
|
||||||
|
}
|
||||||
|
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_authorization_'.$argv[4]]);
|
||||||
|
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||||
|
auth()->guard()->setUser(User::query()->findOrFail($argv[2]));
|
||||||
|
$payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$request = Request::create('/ordens/'.$argv[3].'/recepcoes', 'POST', $payload, server: ['HTTP_ACCEPT' => 'application/json']);
|
||||||
|
$kernel = $app->make(HttpKernel::class);
|
||||||
|
$response = $kernel->handle($request);
|
||||||
|
$body = json_decode($response->getContent(), true);
|
||||||
|
$kernel->terminate($request, $response);
|
||||||
|
echo json_encode(['status' => $response->getStatusCode(), 'id' => $body['id'] ?? null, 'created' => $body['created'] ?? null, 'error_fields' => array_keys($body['errors'] ?? []), 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\GoodsReceipt;
|
||||||
|
use App\Models\StockMovement;
|
||||||
|
use Illuminate\Contracts\Console\Kernel;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
require __DIR__.'/../../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Process-only settings: the project's .env and live database are never rewritten.
|
||||||
|
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||||
|
putenv($key.'='.$value);
|
||||||
|
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||||
|
}
|
||||||
|
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||||
|
$app->make(Kernel::class)->bootstrap();
|
||||||
|
require __DIR__.'/../Support/ReceiptFixture.php';
|
||||||
|
|
||||||
|
$original = DB::connection();
|
||||||
|
if ($original->getDriverName() !== 'pgsql') {
|
||||||
|
throw new LogicException('Este ensaio exige a ligação PostgreSQL local.');
|
||||||
|
}
|
||||||
|
$originalDatabase = $original->getDatabaseName();
|
||||||
|
$effectiveConfig = $original->getConfig();
|
||||||
|
$admin = $original->getPdo();
|
||||||
|
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||||
|
if ($database === $originalDatabase || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||||
|
throw new LogicException('Nome de base de testes não autorizado.');
|
||||||
|
}
|
||||||
|
$liveUsersBefore = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||||
|
$created = false;
|
||||||
|
$processes = [];
|
||||||
|
$report = ['tested_at_utc' => gmdate('c'), 'database' => $database, 'separate_database' => true, 'transport' => 'Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections', 'scenarios' => []];
|
||||||
|
try {
|
||||||
|
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||||
|
$created = true;
|
||||||
|
$isolated = $effectiveConfig;
|
||||||
|
$isolated['url'] = null;
|
||||||
|
$isolated['database'] = $database;
|
||||||
|
// Replace both PostgreSQL aliases in this process and discard any schema facade
|
||||||
|
// resolved by providers while the live configuration was being read.
|
||||||
|
config(['database.connections.receipt_concurrency' => $isolated, 'database.connections.pgsql' => $isolated]);
|
||||||
|
DB::purge('pgsql');
|
||||||
|
DB::setDefaultConnection('receipt_concurrency');
|
||||||
|
Schema::clearResolvedInstance('db.schema');
|
||||||
|
app()->forgetInstance('db.schema');
|
||||||
|
if (DB::selectOne('select current_database() as name')->name !== $database) {
|
||||||
|
throw new LogicException('Migração recusada: ligação não isolada.');
|
||||||
|
}
|
||||||
|
if (Schema::getConnection()->getDatabaseName() !== $database || DB::connection('pgsql')->getDatabaseName() !== $database) {
|
||||||
|
throw new LogicException('Migração recusada: esquema ou alias PostgreSQL não isolado.');
|
||||||
|
}
|
||||||
|
Artisan::call('migrate', ['--database' => 'receipt_concurrency', '--force' => true]);
|
||||||
|
$workerEnv = [
|
||||||
|
'APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database,
|
||||||
|
'DB_HOST' => $effectiveConfig['host'], 'DB_PORT' => (string) $effectiveConfig['port'],
|
||||||
|
'DB_USERNAME' => $effectiveConfig['username'], 'DB_PASSWORD' => $effectiveConfig['password'],
|
||||||
|
'DB_SSLMODE' => $effectiveConfig['sslmode'] ?? 'prefer',
|
||||||
|
'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array',
|
||||||
|
];
|
||||||
|
foreach (['role_revoked_while_waiting'] as $scenario) {
|
||||||
|
$fixture = receiptFixture();
|
||||||
|
$first = receiptPayload($fixture, '0.600');
|
||||||
|
$second = receiptPayload($fixture, '0.600');
|
||||||
|
$connection = DB::connection();
|
||||||
|
$connection->beginTransaction();
|
||||||
|
// Hold the company: both requests cache their old role before we revoke it.
|
||||||
|
$connection->table('tenants')->where('id', $fixture['tenant']->id)->lockForUpdate()->first();
|
||||||
|
$processes = [];
|
||||||
|
foreach ([$first, $second] as $index => $payload) {
|
||||||
|
$label = $index === 0 ? 'A' : 'B';
|
||||||
|
$user = $index === 0 ? $fixture['receiver'] : $fixture['otherReceiver'];
|
||||||
|
$process = new Process([PHP_BINARY, __DIR__.'/authorization-worker.php', $database, (string) $user->id, (string) $fixture['order']->id, $label], base_path(), $workerEnv);
|
||||||
|
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||||
|
$process->setTimeout(45);
|
||||||
|
$process->start();
|
||||||
|
$processes[] = $process;
|
||||||
|
}
|
||||||
|
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_authorization_A','sgc_authorization_B') AND wait_event_type = 'Lock'");
|
||||||
|
$deadline = microtime(true) + 20;
|
||||||
|
$waits = 0;
|
||||||
|
do {
|
||||||
|
$statement->execute([$database]);
|
||||||
|
$waits = (int) $statement->fetchColumn();
|
||||||
|
if ($waits >= 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
if (! $process->isRunning()) {
|
||||||
|
throw new RuntimeException('Worker terminou antes da sobreposição: '.$process->getOutput().' '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usleep(20000);
|
||||||
|
} while (microtime(true) < $deadline);
|
||||||
|
$connection->table('users')->whereIn('id', [$fixture['receiver']->id, $fixture['otherReceiver']->id])->update(['purchasing_role' => 'requester']);
|
||||||
|
$connection->commit();
|
||||||
|
if ($waits < 2) {
|
||||||
|
throw new RuntimeException('Não foi demonstrada espera simultânea pelos bloqueios.');
|
||||||
|
}
|
||||||
|
$results = [];
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
$process->wait();
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
|
||||||
|
}
|
||||||
|
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
$statuses = array_column($results, 'status');
|
||||||
|
sort($statuses);
|
||||||
|
if ($statuses !== [403, 403] || GoodsReceipt::query()->count() !== 0 || StockMovement::query()->count() !== 0 || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||||
|
throw new RuntimeException('A stale warehouse profile authorised a receipt.');
|
||||||
|
}
|
||||||
|
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'statuses' => $statuses, 'receipts' => 0, 'stock_movements' => 0];
|
||||||
|
}
|
||||||
|
$report['passed'] = true;
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
$report['passed'] = false;
|
||||||
|
$report['failure'] = $error->getMessage();
|
||||||
|
} finally {
|
||||||
|
foreach ($processes as $process) {
|
||||||
|
if ($process->isRunning()) {
|
||||||
|
$process->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||||
|
$connection->rollBack();
|
||||||
|
}
|
||||||
|
DB::disconnect('receipt_concurrency');
|
||||||
|
DB::disconnect('pgsql');
|
||||||
|
if ($created && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database) && $database !== $originalDatabase) {
|
||||||
|
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||||
|
$report['test_database_dropped'] = true;
|
||||||
|
}
|
||||||
|
$report['live_user_count_unchanged'] = $admin->query('SELECT COUNT(*) FROM users')->fetchColumn() === $liveUsersBefore;
|
||||||
|
}
|
||||||
|
file_put_contents(__DIR__.'/../../docs/autorizacao-concorrencia-postgresql.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||||
|
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||||
|
exit(($report['passed'] ?? false) ? 0 : 1);
|
||||||
@@ -149,3 +149,14 @@ test('budget service refuses a requisition belonging to another company', functi
|
|||||||
}
|
}
|
||||||
expect(BudgetEntry::query()->count())->toBe(0);
|
expect(BudgetEntry::query()->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('unapproved allocation displays no authorised or available funds', function () {
|
||||||
|
$this->get('/orcamentos')->assertOk()->assertInertia(fn ($page) => $page->where('budgets.0.amount_minor', 20000)->where('budgets.0.approved_total_minor', 0)->where('budgets.0.available_minor', 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial allocation approval rechecks administrator role inside transaction', function () {
|
||||||
|
$this->actingAs($this->approver);
|
||||||
|
DB::table('users')->where('id', $this->approver->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden();
|
||||||
|
expect($this->budget->refresh()->approved_by)->toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -486,3 +486,21 @@ test('equipment cannot bypass technical finance or investment budget requirement
|
|||||||
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors($missing === 'technical' ? 'technical' : ($missing === 'finance' ? 'approvals' : 'investment'));
|
$this->actingAs($this->buyer)->post($this->awardUrl, $this->selection)->assertSessionHasErrors($missing === 'technical' ? 'technical' : ($missing === 'finance' ? 'approvals' : 'investment'));
|
||||||
expect(PurchaseAward::query()->count())->toBe(0);
|
expect(PurchaseAward::query()->count())->toBe(0);
|
||||||
})->with(['technical', 'finance', 'budget']);
|
})->with(['technical', 'finance', 'budget']);
|
||||||
|
|
||||||
|
test('purchase issuance rechecks a revoked procurement role inside transaction', function () {
|
||||||
|
$award = selectPurchaseAward($this);
|
||||||
|
approvePurchaseAward($this, $award);
|
||||||
|
$this->actingAs($this->buyer);
|
||||||
|
DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/adjudicacoes/'.$award->id.'/emitir')->assertForbidden();
|
||||||
|
expect(PurchaseOrder::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('final approval rechecks a department head who moved departments', function () {
|
||||||
|
$award = selectPurchaseAward($this);
|
||||||
|
$department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Outro departamento fictício']);
|
||||||
|
$this->actingAs($this->head);
|
||||||
|
DB::table('users')->where('id', $this->head->id)->update(['department_id' => $department->id]);
|
||||||
|
$this->post('/adjudicacoes/'.$award->id.'/decidir', ['decision' => 'approved'])->assertForbidden();
|
||||||
|
expect($award->approvals()->where('decision', 'approved')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ test('security page is displayed', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('security page renders without two factor when feature is disabled', function () {
|
test('security page renders without two factor when feature is disabled', function () {
|
||||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
|
||||||
|
|
||||||
config(['fortify.features' => []]);
|
config(['fortify.features' => []]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -36,7 +34,7 @@ test('security page renders without two factor when feature is disabled', functi
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertInertia(fn (Assert $page) => $page
|
->assertInertia(fn (Assert $page) => $page
|
||||||
->component('settings/security')
|
->component('settings/security')
|
||||||
->where('canManageTwoFactor', false)
|
->has('passwordRules')
|
||||||
->missing('twoFactorEnabled')
|
->missing('twoFactorEnabled')
|
||||||
->missing('requiresConfirmation'),
|
->missing('requiresConfirmation'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\PurchasingRole;
|
||||||
|
use App\Models\Budget;
|
||||||
|
use App\Models\BudgetConsumption;
|
||||||
|
use App\Models\Department;
|
||||||
|
use App\Models\GoodsReceipt;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Models\StockOperation;
|
||||||
|
use App\Models\Supplier;
|
||||||
|
use App\Models\SupplierBankDetail;
|
||||||
|
use App\Models\SupplierBankReview;
|
||||||
|
use App\Models\TechnicalReview;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\EquipmentWorkflow;
|
||||||
|
use App\Services\ReceiptWorkflow;
|
||||||
|
use App\Services\RequisitionWorkflow;
|
||||||
|
use App\Services\SupplierBankWorkflow;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
require_once __DIR__.'/../Support/InvoiceFixture.php';
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->f = receiptFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receipt revalidates a warehouse operator whose cached profile was revoked', function () {
|
||||||
|
$this->actingAs($this->f['receiver']);
|
||||||
|
DB::table('users')->where('id', $this->f['receiver']->id)->update(['purchasing_role' => 'requester']);
|
||||||
|
$this->postJson('/ordens/'.$this->f['order']->id.'/recepcoes', receiptPayload($this->f))->assertForbidden();
|
||||||
|
expect(GoodsReceipt::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stock issue revalidates warehouse role inside transaction', function () {
|
||||||
|
$this->actingAs($this->f['receiver']);
|
||||||
|
app(ReceiptWorkflow::class)->confirm($this->f['receiver'], $this->f['order'], receiptPayload($this->f, '1.000'));
|
||||||
|
DB::table('users')->where('id', $this->f['receiver']->id)->update(['purchasing_role' => 'requester']);
|
||||||
|
$this->post('/stock/operacoes', ['kind' => 'issue', 'reference' => 'STALE-STOCK', 'idempotency_key' => (string) Str::uuid(), 'responsible_id' => $this->f['issuer']->id, 'warehouse_id' => $this->f['warehouse']->id, 'stock_item_id' => $this->f['stock']->id, 'destination' => 'Destino fictício', 'quantity' => '0.100', 'reason' => 'Teste de perfil revogado'])->assertForbidden();
|
||||||
|
expect(StockOperation::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invoice registration rechecks finance role after the company lock', function () {
|
||||||
|
$f = invoiceFixture();
|
||||||
|
DB::table('users')->where('id', $f['author']->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->actingAs($f['author'])->post('/ordens/'.$f['order']->id.'/facturas', [...$f['data'], 'number' => 'FT-STALE'])->assertForbidden();
|
||||||
|
expect(Invoice::query()->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invoice validation cannot consume budget with a revoked finance role', function () {
|
||||||
|
$f = invoiceFixture();
|
||||||
|
DB::table('users')->where('id', $f['validator']->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->actingAs($f['validator'])->post('/facturas/'.$f['invoice']->id.'/validar')->assertForbidden();
|
||||||
|
expect($f['invoice']->refresh()->status)->toBe('pending')->and(BudgetConsumption::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bank proposal rechecks procurement role in the transaction', function () {
|
||||||
|
$this->actingAs($this->f['issuer']);
|
||||||
|
DB::table('users')->where('id', $this->f['issuer']->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/fornecedores/'.$this->f['order']->supplier_id.'/dados-bancarios', ['previous_id' => 0, 'bank_name' => 'Banco fictício', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular fictício', 'account_identifier' => 'TEST-STALE-ACCOUNT', 'evidence_reference' => 'Fixture', 'reason' => 'Fixture'])->assertForbidden();
|
||||||
|
expect(SupplierBankDetail::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bank review rechecks finance role before accepting a signature', function () {
|
||||||
|
$this->actingAs($this->f['issuer']);
|
||||||
|
$supplier = Supplier::query()->findOrFail($this->f['order']->supplier_id);
|
||||||
|
$detail = app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $supplier, ['previous_id' => 0, 'bank_name' => 'Banco fictício', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular fictício', 'account_identifier' => 'TEST-STALE-ACCOUNT', 'evidence_reference' => 'Fixture', 'reason' => 'Fixture']);
|
||||||
|
$finance = User::factory()->create(['tenant_id' => $supplier->tenant_id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||||
|
$this->actingAs($finance);
|
||||||
|
DB::table('users')->where('id', $finance->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/fornecedores/'.$supplier->id.'/dados-bancarios/'.$detail->id.'/validar', ['decision' => 'approved', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
|
||||||
|
expect(SupplierBankReview::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('budget creation rechecks current finance or administration role', function () {
|
||||||
|
$finance = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||||
|
$this->actingAs($finance);
|
||||||
|
DB::table('users')->where('id', $finance->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/orcamentos', ['cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount' => '100.00', 'includes_tax' => true, 'reason' => 'Dotação fictícia'])->assertForbidden();
|
||||||
|
expect(Budget::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a moved requester cannot save or submit using the previous department', function () {
|
||||||
|
$actor = $this->f['issuer'];
|
||||||
|
$this->actingAs($actor);
|
||||||
|
$department = Department::query()->create(['tenant_id' => $actor->tenant_id, 'name' => 'Novo departamento fictício']);
|
||||||
|
DB::table('users')->where('id', $actor->id)->update(['department_id' => $department->id]);
|
||||||
|
$data = ['cost_center_id' => $this->f['r']->cost_center_id, 'priority' => 'normal', 'needed_by' => today()->addWeek()->format('Y-m-d'), 'justification' => 'Necessidade fictícia para teste de departamento.', 'items' => [['description' => 'Artigo', 'category' => 'Teste', 'purchase_type' => 'consumable', 'quantity' => '1', 'unit' => 'cx', 'unit_price' => '1.00', 'tax_rate' => '0']]];
|
||||||
|
$this->post('/requisicoes', $data)->assertSessionHasErrors('cost_center_id');
|
||||||
|
$request = $this->f['r'];
|
||||||
|
$request->update(['status' => 'draft']);
|
||||||
|
expect(fn () => app(RequisitionWorkflow::class)->submit($actor, $request))->toThrow(ValidationException::class);
|
||||||
|
expect($request->refresh()->status)->toBe('draft')->and($request->steps()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('technical review revalidates the department head current profile', function () {
|
||||||
|
$request = $this->f['r'];
|
||||||
|
$request->items()->update(['purchase_type' => 'equipment', 'production_area' => 'Linha fictícia']);
|
||||||
|
$request->update(['status' => 'draft']);
|
||||||
|
$request->refresh();
|
||||||
|
$head = User::factory()->create(['tenant_id' => $request->tenant_id, 'department_id' => $request->department_id, 'purchasing_role' => PurchasingRole::DepartmentHead]);
|
||||||
|
$this->actingAs($head);
|
||||||
|
DB::table('users')->where('id', $head->id)->update(['purchasing_role' => 'warehouse']);
|
||||||
|
$this->post('/requisicoes/'.$request->id.'/validacao-tecnica', ['basis_hash' => app(EquipmentWorkflow::class)->basis($request), 'decision' => 'approved', 'reason' => 'Teste'])->assertForbidden();
|
||||||
|
expect(TechnicalReview::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
fonts: [
|
fonts: [
|
||||||
bunny('Instrument Sans', {
|
bunny('Instrument Sans', {
|
||||||
weights: [400, 500, 600],
|
weights: [400, 500, 600],
|
||||||
|
optimizedFallbacks: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user