diff --git a/app/Http/Controllers/SupplierBankController.php b/app/Http/Controllers/SupplierBankController.php new file mode 100644 index 0000000..f88702b --- /dev/null +++ b/app/Http/Controllers/SupplierBankController.php @@ -0,0 +1,55 @@ +user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]), 403); + $details = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->get(); + $current = $details->first(); + $canRead = in_array($request->user()->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]); + $data = null; + if ($current && $canRead) { + app(RequisitionWorkflow::class)->audit($request->user(), 'supplier.bank_viewed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $current->id]); + $data = [...$current->toArray(), ...$current->only(['account_identifier', 'beneficiary', 'evidence_reference', 'reason'])]; + } + + Inertia::encryptHistory(); + $response = Inertia::render('suppliers/bank', ['supplier' => $supplier->only(['id', 'name']), 'current' => $data, 'currentId' => $current->id ?? 0, 'state' => $workflow->state($supplier), 'history' => $details->map(fn ($detail) => $detail->only(['id', 'revision', 'created_by', 'created_at'])), 'reviews' => SupplierBankReview::query()->whereIn('supplier_bank_detail_id', $details->pluck('id'))->orderBy('id')->get(), 'canPropose' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $current && $request->user()->purchasing_role === PurchasingRole::Finance && $current->created_by !== $request->user()->id && ! SupplierBankReview::query()->where('supplier_bank_detail_id', $current->id)->where('reviewed_by', $request->user()->id)->exists()])->toResponse($request); + $response->headers->set('Cache-Control', 'private, no-store, max-age=0'); + + return $response; + } + + public function store(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): RedirectResponse + { + $request->merge(['country' => Str::upper(trim($request->string('country')->toString())), 'currency' => Str::upper(trim($request->string('currency')->toString())), 'account_identifier' => Str::upper(preg_replace('/\s+/', '', $request->string('account_identifier')->toString()) ?? '')]); + $data = $request->validate(['previous_id' => 'required|integer|min:0', 'bank_name' => 'required|string|max:200', 'country' => ['required', 'regex:/^[A-Z]{2}$/'], 'currency' => ['required', 'regex:/^[A-Z]{3}$/'], 'beneficiary' => 'required|string|max:200', 'account_identifier' => ['required', 'regex:/^[A-Z0-9.\/-]{4,100}$/'], 'evidence_reference' => 'required|string|max:1000', 'reason' => 'required|string|max:2000']); + $workflow->propose($request->user(), $supplier, $data); + + return back(); + } + + public function review(Request $request, Supplier $supplier, SupplierBankDetail $detail, SupplierBankWorkflow $workflow): RedirectResponse + { + abort_unless($detail->supplier_id === $supplier->id, 404); + $data = $request->validate(['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']); + $workflow->review($request->user(), $detail, $data['decision'], $data['reason']); + + return back(); + } +} diff --git a/app/Models/SupplierBankDetail.php b/app/Models/SupplierBankDetail.php new file mode 100644 index 0000000..5ec9d51 --- /dev/null +++ b/app/Models/SupplierBankDetail.php @@ -0,0 +1,31 @@ + 'encrypted', 'beneficiary' => 'encrypted', 'evidence_reference' => 'encrypted', 'reason' => 'encrypted']; + } +} diff --git a/app/Models/SupplierBankReview.php b/app/Models/SupplierBankReview.php new file mode 100644 index 0000000..65c2f04 --- /dev/null +++ b/app/Models/SupplierBankReview.php @@ -0,0 +1,23 @@ + 'encrypted']; + } +} diff --git a/app/Services/SupplierBankWorkflow.php b/app/Services/SupplierBankWorkflow.php new file mode 100644 index 0000000..835a203 --- /dev/null +++ b/app/Services/SupplierBankWorkflow.php @@ -0,0 +1,100 @@ +id() === $user->id && $user->tenant_id !== null && $supplier->tenant_id === $user->tenant_id, 403); + if (app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) { + throw ValidationException::withMessages(['bank' => 'Conflito de interesses com este fornecedor.']); + } + } + + /** @param array $data */ + public function propose(User $user, Supplier $supplier, array $data): SupplierBankDetail + { + $this->actor($user, $supplier); + abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403); + + return DB::transaction(function () use ($user, $supplier, $data) { + app(ConflictGuard::class)->lockCompany($user); + $supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id); + $this->actor($user, $supplier); + $latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first(); + if ((int) $data['previous_id'] !== ($latest->id ?? 0)) { + throw ValidationException::withMessages(['previous_id' => 'Dados bancários alterados. Reabra a ficha antes de propor outra versão.']); + } + $detail = SupplierBankDetail::query()->create(['tenant_id' => $user->tenant_id, 'supplier_id' => $supplier->id, 'created_by' => $user->id, 'revision' => ($latest->revision ?? 0) + 1, ...array_intersect_key($data, array_flip(['bank_name', 'country', 'currency', 'beneficiary', 'account_identifier', 'evidence_reference', 'reason'])), 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($user, 'supplier.bank_proposed', null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $detail->id, 'revision' => $detail->revision]); + + return $detail; + }); + } + + public function review(User $user, SupplierBankDetail $detail, string $decision, string $reason): void + { + abort_unless($user->purchasing_role === PurchasingRole::Finance && $detail->tenant_id === $user->tenant_id && $detail->created_by !== $user->id, 403); + if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { + throw ValidationException::withMessages(['bank' => 'Decisão e fundamento obrigatórios.']); + } + DB::transaction(function () use ($user, $detail, $decision, $reason) { + app(ConflictGuard::class)->lockCompany($user); + $supplier = Supplier::query()->lockForUpdate()->findOrFail($detail->supplier_id); + $this->actor($user, $supplier); + $latest = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->firstOrFail(); + if ($latest->id !== $detail->id) { + throw ValidationException::withMessages(['bank' => 'Versão substituída. Reveja os dados bancários actuais.']); + } + $existing = SupplierBankReview::query()->where('supplier_bank_detail_id', $detail->id)->where('reviewed_by', $user->id)->first(); + if ($existing) { + if ($existing->decision !== $decision || $existing->reason !== $reason) { + throw ValidationException::withMessages(['bank' => 'A decisão anterior é imutável. Proponha nova versão para corrigir dados.']); + } + + return; + } + if (! $supplier->eligible() || SupplierBankReview::query()->where('supplier_bank_detail_id', $detail->id)->where('decision', 'rejected')->exists() || SupplierBankReview::query()->where('supplier_bank_detail_id', $detail->id)->count() >= 2) { + throw ValidationException::withMessages(['bank' => 'Fornecedor não elegível ou versão já decidida.']); + } + SupplierBankReview::query()->create(['tenant_id' => $user->tenant_id, 'supplier_bank_detail_id' => $detail->id, 'reviewed_by' => $user->id, 'decision' => $decision, 'reason' => $reason, 'created_at' => now()]); + app(RequisitionWorkflow::class)->audit($user, 'supplier.bank_'.$decision, null, null, ['supplier_id' => $supplier->id, 'bank_detail_id' => $detail->id]); + }); + } + + /** Re-evaluate inside the payment transaction in the future; UI readiness is not authorisation. */ + public function state(Supplier $supplier): string + { + $detail = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first(); + if (! $detail) { + return 'missing'; + } + $reviews = SupplierBankReview::query()->where('supplier_bank_detail_id', $detail->id)->get(); + if ($reviews->contains('decision', 'rejected')) { + return 'rejected'; + } + if ($reviews->count() < 2) { + return 'pending'; + } + if (! $supplier->eligible() || app(ConflictGuard::class)->hasConflict($detail->created_by, $supplier->id, null)) { + return 'blocked'; + } + foreach ($reviews as $review) { + $user = User::query()->where('tenant_id', $supplier->tenant_id)->find($review->reviewed_by); + if (! $user || $user->purchasing_role !== PurchasingRole::Finance || $user->id === $detail->created_by || app(ConflictGuard::class)->hasConflict($user->id, $supplier->id, null)) { + return 'blocked'; + } + } + + return 'approved'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index ba5cf4c..e6ddabf 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -24,6 +24,7 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->dontFlash(['account_identifier', 'beneficiary', 'evidence_reference', 'reason']); $exceptions->shouldRenderJsonWhen( fn (Request $request) => $request->is('api/*') || $request->expectsJson(), ); diff --git a/database/migrations/2026_10_28_000011_create_supplier_bank_details.php b/database/migrations/2026_10_28_000011_create_supplier_bank_details.php new file mode 100644 index 0000000..4b9750b --- /dev/null +++ b/database/migrations/2026_10_28_000011_create_supplier_bank_details.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('supplier_id')->constrained()->restrictOnDelete(); + $table->foreignId('created_by')->constrained('users')->restrictOnDelete(); + $table->unsignedInteger('revision'); + $table->string('bank_name', 200); + $table->string('country', 2); + $table->string('currency', 3); + $table->text('beneficiary'); + $table->text('account_identifier'); + $table->text('evidence_reference'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['supplier_id', 'revision']); + }); + Schema::create('supplier_bank_reviews', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->restrictOnDelete(); + $table->foreignId('supplier_bank_detail_id')->constrained()->restrictOnDelete(); + $table->foreignId('reviewed_by')->constrained('users')->restrictOnDelete(); + $table->string('decision'); + $table->text('reason'); + $table->timestamp('created_at'); + $table->unique(['supplier_bank_detail_id', 'reviewed_by']); + }); + } + + public function down(): void + { + throw new LogicException('Preservar versões bancárias e validações.'); + } +}; diff --git a/docs/bancos-concorrencia-postgresql.json b/docs/bancos-concorrencia-postgresql.json new file mode 100644 index 0000000..0c6eeac --- /dev/null +++ b/docs/bancos-concorrencia-postgresql.json @@ -0,0 +1,30 @@ +{ + "tested_at_utc": "2026-10-28T12:32:47+00:00", + "separate_database": true, + "transport": "Independent PHP service transactions and PostgreSQL connections", + "scenarios": [ + { + "name": "distinct_approvals", + "passed": true, + "overlapping_lock_waits": 2, + "state": "approved", + "review_count": 2 + }, + { + "name": "same_approval", + "passed": true, + "overlapping_lock_waits": 2, + "state": "pending", + "review_count": 1 + }, + { + "name": "competing_revisions", + "passed": true, + "overlapping_lock_waits": 2, + "state": "pending", + "review_count": 0 + } + ], + "passed": true, + "test_database_dropped": true +} diff --git a/docs/bancos-migracao-000011.json b/docs/bancos-migracao-000011.json new file mode 100644 index 0000000..453d63a --- /dev/null +++ b/docs/bancos-migracao-000011.json @@ -0,0 +1,16 @@ +{ + "verified_at_utc": "2026-10-28T12:33:53+00:00", + "migration": "2026_10_28_000011_create_supplier_bank_details", + "batch": 12, + "backup_created": true, + "backup_bytes": 230372, + "restore_verified": true, + "restored_tables": 42, + "restore_database_dropped": true, + "business_records_unchanged": true, + "existing_constraints_preserved": true, + "env_unchanged": true, + "bank_details_count": 0, + "bank_reviews_count": 0, + "operational_validation": false +} diff --git a/resources/js/lib/preview-navigation.ts b/resources/js/lib/preview-navigation.ts index a25cc09..61d71b8 100644 --- a/resources/js/lib/preview-navigation.ts +++ b/resources/js/lib/preview-navigation.ts @@ -10,5 +10,6 @@ export const previewPaths: Record = { operations: '/consulta/operacoes-stock', equipment: '/consulta/equipamentos', invoices: '/consulta/facturas', + bank: '/consulta/dados-bancarios', appearance: '/consulta/aparencia', }; diff --git a/resources/js/pages/preview/index.tsx b/resources/js/pages/preview/index.tsx index b04df39..fbe6456 100644 --- a/resources/js/pages/preview/index.tsx +++ b/resources/js/pages/preview/index.tsx @@ -119,6 +119,7 @@ const navigation = [ { id: 'operations', title: 'Saídas e inventários', icon: Warehouse }, { id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse }, { id: 'invoices', title: 'Facturas e conferência', icon: FileText }, + { id: 'bank', title: 'Dados bancários', icon: ShieldCheck }, { id: 'appearance', title: 'Cores e componentes', icon: Palette }, ]; type Detail = { title: string; subtitle: string; rows: [string, string][] }; @@ -127,7 +128,7 @@ const navigationGroups = [ { title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] }, { title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] }, { title: 'Controlo', ids: ['conflicts'] }, - { title: 'Finanças', ids: ['invoices'] }, + { title: 'Finanças', ids: ['invoices', 'bank'] }, { title: 'Apresentação', ids: ['appearance'] }, ]; export default function Preview({ @@ -663,6 +664,39 @@ export default function Preview({ )} {screen === 'operations' && } {screen === 'equipment' && } + {screen === 'bank' && ( + +
+

+ Procurement propõe uma versão com + evidência. Finanças A confere e aprova; + Finanças B confere de forma + independente. +

+

+ Uma aprovação: pendente. Duas pessoas + distintas: duplamente validado. Repetir + a decisão da mesma pessoa não conta duas + vezes. +

+

+ Nova versão: disponibilidade anterior + substituída e duas novas validações + obrigatórias. Rejeição, conflito ou + perda de elegibilidade bloqueiam o + estado. +

+

+ Cada empresa mantém os seus fornecedores + e dados. Não autoriza transferências nem + confirma titularidade junto do banco. +

+
+
+ )} {screen === 'invoices' && ( = { + missing: 'Sem dados', + pending: 'Pendente de duas validações', + approved: 'Duplamente validado', + rejected: 'Rejeitado', + blocked: 'Bloqueado por elegibilidade ou conflito', + }; + return ( + <> + +
+ + + ← Ficha do fornecedor + + + {current && ( +
+ {Object.entries(labels).map(([key, label]) => ( +
+
+ {label} +
+
+ {current[key as keyof typeof labels]} +
+
+ ))} +
+ )} + {!current && ( +

+ Os identificadores bancários são apresentados apenas + a Procurement e Finanças. Administração consulta o + estado e o histórico. +

+ )} +
+ {canReview && current && state === 'pending' && ( + +
{ + e.preventDefault(); + review.post( + `/fornecedores/${supplier.id}/dados-bancarios/${current.id}/validar`, + ); + }} + > + + +