@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user