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();
@@ -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 -1
View File
@@ -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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
+9
View File
@@ -23,6 +23,15 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em
## 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
aprovações administrativas independentes. Dotação classificada, reserva e Finanças
obrigatórias para equipamento; regra congelada e revalidada antes de emitir.
+21
View File
@@ -1,5 +1,26 @@
# 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
Implementadas em `/regras-investimento`, sem montantes predefinidos ou regras
+1 -1
View File
@@ -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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
+1 -1
View File
@@ -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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
+1 -1
View File
@@ -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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
+34 -34
View File
@@ -1,6 +1,6 @@
{
"tested_at_utc": "2026-10-28T10:56:15+00:00",
"database": "compras_receipts_test_84e8492cd8f3",
"tested_at_utc": "2026-10-28T15:07:45+00:00",
"database": "compras_receipts_test_c9a6d4b5eeb5",
"separate_database": true,
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
"scenarios": [
@@ -9,19 +9,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 201,
"id": 1,
"created": true,
"error_fields": [],
"backend_pid": 4688
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["items"],
"backend_pid": 2068
"backend_pid": 24512
},
{
"status": 201,
"id": 1,
"created": true,
"error_fields": [],
"backend_pid": 15424
}
],
"receipts": 1,
@@ -34,19 +34,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 201,
"id": 2,
"created": true,
"error_fields": [],
"backend_pid": 9064
},
{
"status": 200,
"id": 2,
"created": false,
"error_fields": [],
"backend_pid": 1036
"backend_pid": 6580
},
{
"status": 201,
"id": 2,
"created": true,
"error_fields": [],
"backend_pid": 5964
}
],
"receipts": 1,
@@ -59,19 +59,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 201,
"id": 4,
"created": true,
"error_fields": [],
"backend_pid": 3020
},
{
"status": 201,
"id": 3,
"created": true,
"error_fields": [],
"backend_pid": 24624
"backend_pid": 26176
},
{
"status": 201,
"id": 4,
"created": true,
"error_fields": [],
"backend_pid": 24244
}
],
"receipts": 2,
@@ -84,19 +84,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["delivery_note"],
"backend_pid": 22120
},
{
"status": 201,
"id": 5,
"created": true,
"error_fields": [],
"backend_pid": 4720
"backend_pid": 21524
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["delivery_note"],
"backend_pid": 25700
}
],
"receipts": 1,
+2 -2
View File
@@ -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,
"transport": "Independent PHP service transactions and PostgreSQL connections",
"scenarios": [
@@ -24,7 +24,7 @@
"passed": true,
"overlapping_lock_waits": 2,
"approved_total_minor": 12000,
"reserved_minor": 10000,
"reserved_minor": 0,
"original_allocation_unchanged": true
}
],
+37 -37
View File
@@ -1,6 +1,6 @@
{
"tested_at_utc": "2026-10-28T11:35:31+00:00",
"database": "compras_receipts_test_d97ad8ad1eb7",
"tested_at_utc": "2026-10-28T15:07:45+00:00",
"database": "compras_receipts_test_aa453293fa81",
"separate_database": true,
"transport": "Laravel HTTP kernel in independent PHP processes; real PostgreSQL connections",
"scenarios": [
@@ -9,19 +9,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 201,
"id": 1,
"created": true,
"error_fields": [],
"backend_pid": 5004
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["quantity"],
"backend_pid": 27024
"backend_pid": 23636
},
{
"status": 201,
"id": 1,
"created": true,
"error_fields": [],
"backend_pid": 15544
}
],
"source_milli": 400,
@@ -37,14 +37,14 @@
"id": 2,
"created": true,
"error_fields": [],
"backend_pid": 7640
"backend_pid": 19896
},
{
"status": 200,
"id": 2,
"created": false,
"error_fields": [],
"backend_pid": 22852
"backend_pid": 25896
}
],
"source_milli": 400,
@@ -55,19 +55,19 @@
"passed": true,
"overlapping_lock_waits": 2,
"responses": [
{
"status": 201,
"id": 3,
"created": true,
"error_fields": [],
"backend_pid": 25232
},
{
"status": 200,
"id": 3,
"created": false,
"error_fields": [],
"backend_pid": 28856
"backend_pid": 22516
},
{
"status": 201,
"id": 3,
"created": true,
"error_fields": [],
"backend_pid": 24156
}
],
"source_milli": 400,
@@ -83,18 +83,18 @@
"id": 4,
"created": true,
"error_fields": [],
"backend_pid": 5268
"backend_pid": 2604
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["quantity"],
"backend_pid": 4288
"status": 201,
"id": 5,
"created": true,
"error_fields": [],
"backend_pid": 22296
}
],
"source_milli": 400,
"destination_milli": 600
"source_milli": 500,
"destination_milli": 500
},
{
"name": "same_adjustment",
@@ -103,17 +103,17 @@
"responses": [
{
"status": 200,
"id": 6,
"id": 7,
"created": false,
"error_fields": [],
"backend_pid": 13608
"backend_pid": 26392
},
{
"status": 201,
"id": 6,
"id": 7,
"created": true,
"error_fields": [],
"backend_pid": 2320
"backend_pid": 17308
}
],
"source_milli": 800,
@@ -129,14 +129,14 @@
"id": null,
"created": null,
"error_fields": ["count_id"],
"backend_pid": 25516
"backend_pid": 24416
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["count_id"],
"backend_pid": 23640
"backend_pid": 15140
}
],
"source_milli": 900,
@@ -149,17 +149,17 @@
"responses": [
{
"status": 201,
"id": 9,
"id": 10,
"created": true,
"error_fields": [],
"backend_pid": 18244
"backend_pid": 4480
},
{
"status": 422,
"id": null,
"created": null,
"error_fields": ["quantity"],
"backend_pid": 2452
"backend_pid": 20176
}
],
"source_milli": 400,
+83
View File
@@ -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.
+13 -11
View File
@@ -19,15 +19,15 @@ export default function Security(props: Props) {
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">
<Heading
variant="small"
title="Update password"
description="Ensure your account is using a long, random password to stay secure"
title="Alterar palavra-passe"
description="Use uma palavra-passe longa e exclusiva para proteger a conta."
/>
<Form
@@ -56,7 +56,7 @@ export default function Security(props: Props) {
<>
<div className="grid gap-2">
<Label htmlFor="current_password">
Current password
Palavra-passe actual
</Label>
<PasswordInput
@@ -65,14 +65,16 @@ export default function Security(props: Props) {
name="current_password"
className="mt-1 block w-full"
autoComplete="current-password"
placeholder="Current password"
placeholder="Palavra-passe actual"
/>
<InputError message={errors.current_password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">New password</Label>
<Label htmlFor="password">
Nova palavra-passe
</Label>
<PasswordInput
id="password"
@@ -80,7 +82,7 @@ export default function Security(props: Props) {
name="password"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="New password"
placeholder="Nova palavra-passe"
passwordrules={props.passwordRules}
/>
@@ -89,7 +91,7 @@ export default function Security(props: Props) {
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm password
Confirmar palavra-passe
</Label>
<PasswordInput
@@ -97,7 +99,7 @@ export default function Security(props: Props) {
name="password_confirmation"
className="mt-1 block w-full"
autoComplete="new-password"
placeholder="Confirm password"
placeholder="Confirmar palavra-passe"
passwordrules={props.passwordRules}
/>
@@ -111,7 +113,7 @@ export default function Security(props: Props) {
disabled={processing}
data-test="update-password-button"
>
Save
Guardar
</Button>
</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);
+141
View File
@@ -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);
+11
View File
@@ -149,3 +149,14 @@ test('budget service refuses a requisition belonging to another company', functi
}
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();
});
+18
View File
@@ -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'));
expect(PurchaseAward::query()->count())->toBe(0);
})->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);
});
+1 -3
View File
@@ -25,8 +25,6 @@ test('security page is displayed', function () {
});
test('security page renders without two factor when feature is disabled', function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
config(['fortify.features' => []]);
$user = User::factory()->create();
@@ -36,7 +34,7 @@ test('security page renders without two factor when feature is disabled', functi
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('settings/security')
->where('canManageTwoFactor', false)
->has('passwordRules')
->missing('twoFactorEnabled')
->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);
});
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
fonts: [
bunny('Instrument Sans', {
weights: [400, 500, 600],
optimizedFallbacks: false,
}),
],
}),