commit2
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-28 14:35:06 +02:00
parent 332cb9f1b7
commit 70163c0184
18 changed files with 1036 additions and 2 deletions
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\Supplier;
use App\Models\SupplierBankDetail;
use App\Models\SupplierBankReview;
use App\Services\RequisitionWorkflow;
use App\Services\SupplierBankWorkflow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class SupplierBankController extends Controller
{
public function show(Request $request, Supplier $supplier, SupplierBankWorkflow $workflow): Response
{
abort_unless(in_array($request->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();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/** @property int $id
* @property int $tenant_id
* @property int $supplier_id
* @property int $created_by
* @property int $revision
* @property string $account_identifier
* @property string $beneficiary
*/
class SupplierBankDetail extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected $hidden = ['account_identifier', 'beneficiary', 'evidence_reference', 'reason'];
protected function casts(): array
{
return ['account_identifier' => 'encrypted', 'beneficiary' => 'encrypted', 'evidence_reference' => 'encrypted', 'reason' => 'encrypted'];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class SupplierBankReview extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected $hidden = ['reason'];
protected function casts(): array
{
return ['reason' => 'encrypted'];
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\Supplier;
use App\Models\SupplierBankDetail;
use App\Models\SupplierBankReview;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class SupplierBankWorkflow
{
private function actor(User $user, Supplier $supplier): void
{
abort_unless(auth()->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<string,mixed> $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';
}
}