@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PurchasingRole: string
|
||||
{
|
||||
case Requester = 'requester';
|
||||
case DepartmentHead = 'department_head';
|
||||
case Procurement = 'procurement';
|
||||
case Finance = 'finance';
|
||||
case Warehouse = 'warehouse';
|
||||
case Admin = 'admin';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Requester => 'Solicitante',
|
||||
self::DepartmentHead => 'Chefe de departamento',
|
||||
self::Procurement => 'Procurement',
|
||||
self::Finance => 'Finanças',
|
||||
self::Warehouse => 'Recepção / Armazém',
|
||||
self::Admin => 'Administrador',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Department;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CompanyController extends Controller
|
||||
{
|
||||
public function setup(Request $request): Response|RedirectResponse
|
||||
{
|
||||
return $request->user()->tenant_id ? to_route('dashboard') : Inertia::render('company/setup');
|
||||
}
|
||||
|
||||
public function store(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['name' => 'required|string|max:150', 'department' => 'required|string|max:100']);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$user = User::query()->lockForUpdate()->findOrFail($request->user()->id);
|
||||
abort_if($user->tenant_id !== null, 403);
|
||||
$tenant = Tenant::query()->create(['name' => $data['name']]);
|
||||
$department = Department::query()->create(['tenant_id' => $tenant->id, 'name' => $data['department']]);
|
||||
CostCenter::query()->create(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'code' => 'GERAL', 'name' => 'Centro de custo geral']);
|
||||
$user->forceFill(['tenant_id' => $tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Admin])->save();
|
||||
$workflow->audit($user, 'company.created', null, null, ['name' => $data['name']]);
|
||||
});
|
||||
|
||||
return to_route('dashboard');
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->admin($request);
|
||||
|
||||
return Inertia::render('company/index', [
|
||||
'company' => Tenant::query()->findOrFail($request->user()->tenant_id),
|
||||
'departments' => Department::query()->orderBy('name')->get(),
|
||||
'costCenters' => CostCenter::query()->orderBy('code')->get(),
|
||||
'users' => User::query()->where('tenant_id', $request->user()->tenant_id)->orderBy('name')->get(['id', 'name', 'email', 'department_id', 'purchasing_role']),
|
||||
'roles' => collect(PurchasingRole::cases())->map(fn (PurchasingRole $role) => ['value' => $role->value, 'label' => $role->label()]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function department(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('departments')->where('tenant_id', $request->user()->tenant_id)]]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$department = Department::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
|
||||
$workflow->audit($request->user(), 'department.created', null, null, $department->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function costCenter(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate([
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'code' => ['required', 'string', 'max:30', Rule::unique('cost_centers')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'name' => 'required|string|max:100',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$center = CostCenter::query()->create(['tenant_id' => $request->user()->tenant_id, ...$data]);
|
||||
$workflow->audit($request->user(), 'cost_center.created', null, null, $center->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function user(Request $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->admin($request);
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100', 'email' => 'required|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:12|confirmed',
|
||||
'department_id' => ['required', Rule::exists('departments', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'purchasing_role' => ['required', Rule::enum(PurchasingRole::class)],
|
||||
]);
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$user = new User;
|
||||
$user->forceFill($data + ['tenant_id' => $request->user()->tenant_id])->save();
|
||||
$workflow->audit($request->user(), 'user.created', null, null, $user->only(['id', 'name', 'email', 'department_id', 'purchasing_role']));
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
private function admin(Request $request): void
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConflictDeclarationController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('conflicts/index', [
|
||||
'declarations' => ConflictDeclaration::query()->visibleTo($request->user())
|
||||
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number', 'successor:id,previous_id'])
|
||||
->latest('id')->paginate(15),
|
||||
// These minimal choices allow personal declarations without granting supplier-management access.
|
||||
'suppliers' => Supplier::query()->orderBy('name')->get(['id', 'name']),
|
||||
'requisitions' => Requisition::query()->visibleTo($request->user())->latest()->get(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'scope' => ['required', Rule::in(['supplier', 'process', 'process_supplier'])],
|
||||
'supplier_id' => ['nullable', 'required_if:scope,supplier,process_supplier', 'prohibited_if:scope,process', 'integer', Rule::exists('suppliers', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'requisition_id' => ['nullable', 'required_if:scope,process,process_supplier', 'prohibited_if:scope,supplier', 'integer', Rule::exists('requisitions', 'id')->where('tenant_id', $request->user()->tenant_id)],
|
||||
'has_conflict' => 'required|boolean', 'known_since' => 'required|date_format:Y-m-d|before_or_equal:today',
|
||||
'reason' => 'required|string|min:10|max:5000',
|
||||
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
|
||||
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $guard, $workflow, $data) {
|
||||
$guard->lockCompany($request->user());
|
||||
if (! empty($data['requisition_id'])) {
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($data['requisition_id']);
|
||||
}
|
||||
$declaration = ConflictDeclaration::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'user_id' => $request->user()->id,
|
||||
'supplier_id' => $data['supplier_id'] ?? null, 'requisition_id' => $data['requisition_id'] ?? null,
|
||||
'has_conflict' => $data['has_conflict'], 'known_since' => $data['known_since'],
|
||||
'reason' => $data['reason'], 'declared_at' => now(),
|
||||
]);
|
||||
$workflow->audit($request->user(), 'conflict.declared', null, null, $declaration->toArray());
|
||||
});
|
||||
|
||||
return to_route('conflicts.index');
|
||||
}
|
||||
|
||||
public function show(Request $request, ConflictDeclaration $declaration): Response
|
||||
{
|
||||
ConflictDeclaration::query()->visibleTo($request->user())->findOrFail($declaration->id);
|
||||
$rootId = $declaration->root_id ?? $declaration->id;
|
||||
$history = ConflictDeclaration::query()->where(fn ($query) => $query->whereKey($rootId)->orWhere('root_id', $rootId))
|
||||
->with(['author:id,name', 'supplier:id,name', 'requisition:id,number'])->orderBy('id')->get();
|
||||
$latest = $history->last();
|
||||
|
||||
return Inertia::render('conflicts/show', ['history' => $history, 'canCorrect' => $latest?->user_id === $request->user()->id]);
|
||||
}
|
||||
|
||||
public function update(Request $request, ConflictDeclaration $declaration, ConflictGuard $guard, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($declaration->user_id === $request->user()->id, 403);
|
||||
$data = $request->validate([
|
||||
'known_since' => 'required|date_format:Y-m-d|before_or_equal:today', 'reason' => 'required|string|min:10|max:5000',
|
||||
'has_conflict' => 'prohibited', 'supplier_id' => 'prohibited', 'requisition_id' => 'prohibited',
|
||||
'user_id' => 'prohibited', 'tenant_id' => 'prohibited', 'declared_at' => 'prohibited',
|
||||
'root_id' => 'prohibited', 'previous_id' => 'prohibited',
|
||||
]);
|
||||
DB::transaction(function () use ($request, $guard, $workflow, $declaration, $data) {
|
||||
$guard->lockCompany($request->user());
|
||||
$declaration = ConflictDeclaration::query()->lockForUpdate()->findOrFail($declaration->id);
|
||||
if ($declaration->successor()->exists()) {
|
||||
throw ValidationException::withMessages(['history' => 'Já existe uma correcção mais recente. Reabra o histórico antes de corrigir.']);
|
||||
}
|
||||
$revision = ConflictDeclaration::query()->create([
|
||||
...$declaration->only(['tenant_id', 'user_id', 'supplier_id', 'requisition_id', 'has_conflict']),
|
||||
...$data, 'declared_at' => now(), 'root_id' => $declaration->root_id ?? $declaration->id, 'previous_id' => $declaration->id,
|
||||
]);
|
||||
$workflow->audit($request->user(), 'conflict.corrected', null, $declaration->toArray(), $revision->toArray());
|
||||
});
|
||||
|
||||
return to_route('conflicts.show', $declaration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\PurchaseWorkflow;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
}
|
||||
|
||||
public function award(Request $request, Requisition $requisition, ProposalComparison $comparison, PurchaseWorkflow $workflow): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$award = PurchaseAward::query()->where('requisition_id', $requisition->id)->with('approvals')->first();
|
||||
|
||||
return Inertia::render('purchases/award', [
|
||||
'requisition' => $requisition, 'comparison' => $comparison->inspect($requisition), 'award' => $award,
|
||||
'canSelect' => $request->user()->purchasing_role === PurchasingRole::Procurement && $award === null && $request->user()->id !== $requisition->requester_id,
|
||||
'canDecide' => $award !== null && $workflow->canDecide($request->user(), $award, $requisition),
|
||||
'canIssue' => $award?->status === 'approved' && $request->user()->purchasing_role === PurchasingRole::Procurement,
|
||||
'order' => PurchaseOrder::query()->where('requisition_id', $requisition->id)->first(['id', 'number']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function select(Request $request, Requisition $requisition, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$data = $request->validate([
|
||||
'quotation_id' => 'required|integer', 'justification' => 'required|string|min:10|max:5000',
|
||||
'delivery_date' => 'required|date_format:Y-m-d|after_or_equal:today', 'delivery_location' => 'required|string|min:3|max:500',
|
||||
'status' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'approval_snapshot' => 'prohibited', 'decision_matrix' => 'prohibited', 'created_by' => 'prohibited', 'tenant_id' => 'prohibited',
|
||||
'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$workflow->select($request->user(), $requisition, $data);
|
||||
|
||||
return to_route('purchases.award', $requisition);
|
||||
}
|
||||
|
||||
public function decide(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$data = $request->validate(['decision' => ['required', Rule::in(['approved', 'rejected'])], 'comment' => 'nullable|required_if:decision,rejected|string|max:2000']);
|
||||
$workflow->decide($request->user(), $award, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return to_route('purchases.award', $award->requisition_id);
|
||||
}
|
||||
|
||||
public function issue(Request $request, PurchaseAward $award, PurchaseWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
$request->validate([
|
||||
'status' => 'prohibited', 'snapshot' => 'prohibited', 'total_minor' => 'prohibited', 'subtotal_minor' => 'prohibited', 'tax_minor' => 'prohibited',
|
||||
'issued_by' => 'prohibited', 'tenant_id' => 'prohibited', 'quotation_id' => 'prohibited', 'override' => 'prohibited', 'exception' => 'prohibited',
|
||||
]);
|
||||
$order = $workflow->issue($request->user(), $award);
|
||||
|
||||
return to_route('orders.show', $order);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
|
||||
return Inertia::render('purchases/index', [
|
||||
'orders' => PurchaseOrder::query()->visibleTo($request->user())->latest('id')->paginate(15),
|
||||
'awards' => PurchaseAward::query()->whereIn('requisition_id', Requisition::query()->visibleTo($request->user())->select('id'))->latest()->paginate(15, ['*'], 'awards_page'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, PurchaseOrder $order): Response
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
|
||||
return Inertia::render('purchases/show', [
|
||||
'order' => $order->load('items'),
|
||||
'history' => AuditLog::query()->where('requisition_id', $order->requisition_id)->whereIn('action', ['award.created', 'award.approved', 'award.rejected', 'purchase_order.issued'])->orderBy('id')->get(['id', 'user_id', 'action', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(Request $request, PurchaseOrder $order): View
|
||||
{
|
||||
$this->access($request);
|
||||
PurchaseOrder::query()->visibleTo($request->user())->findOrFail($order->id);
|
||||
|
||||
return view('purchases.print', ['order' => $order->load('items'), 'snapshot' => $order->snapshot]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\ProposalComparison;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class QuotationController extends Controller
|
||||
{
|
||||
private function access(Request $request): void
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->access($request);
|
||||
|
||||
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($request->user())->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
$this->access($request);
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$guard = app(ConflictGuard::class);
|
||||
$result = app(ProposalComparison::class)->inspect($requisition);
|
||||
$scores = array_map(fn ($row) => [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $request->user())], $result['rows']);
|
||||
$closed = PurchaseAward::query()->where('requisition_id', $requisition->id)->exists();
|
||||
|
||||
return Inertia::render('quotations/show', [
|
||||
'requisition' => $requisition->load('items'), 'quotations' => $scores,
|
||||
'suppliers' => Supplier::query()->where('status', 'active')->where('blocked', false)->whereDate('next_review', '>=', today())->orderBy('name')->get(['id', 'name']),
|
||||
'canCreate' => ! $closed && $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'canEvaluate' => ! $closed && $request->user()->purchasing_role !== PurchasingRole::Admin && $request->user()->id !== $requisition->requester_id && $requisition->status === 'approved' && ! $guard->participationBlocked($request->user(), $requisition),
|
||||
'ready' => $result['ready'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
$data = $request->validate([
|
||||
'supplier_id' => 'required|integer', 'reference' => 'required|string|max:100',
|
||||
'delivery_days' => 'required|integer|min:1|max:3650', 'valid_until' => 'required|date_format:Y-m-d|after_or_equal:today',
|
||||
'payment_terms' => 'required|string|min:3|max:500', 'no_conflict' => 'accepted',
|
||||
'items' => 'required|array|min:1|max:100', 'items.*.requisition_item_id' => 'required|integer|distinct',
|
||||
'items.*.unit_price' => ['required', 'numeric', 'decimal:0,2', 'min:0.01', 'max:1000000000'],
|
||||
]);
|
||||
DB::transaction(function () use ($request, $requisition, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->status === 'approved', 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As propostas estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
|
||||
$guard->assertProcess($requisition, $supplier->id, $request->user());
|
||||
if (! $supplier->eligible()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'O fornecedor precisa de estar activo, sem impedimento e com revisão válida.']);
|
||||
}
|
||||
if (Quotation::query()->where('requisition_id', $requisition->id)->where('supplier_id', $supplier->id)->exists()) {
|
||||
throw ValidationException::withMessages(['supplier_id' => 'Já existe uma proposta deste fornecedor neste processo.']);
|
||||
}
|
||||
$lines = $requisition->items()->get()->keyBy('id');
|
||||
if ($lines->count() !== count($data['items'])) {
|
||||
throw ValidationException::withMessages(['items' => 'A proposta deve conter todas as linhas da requisição.']);
|
||||
}
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
foreach ($data['items'] as $input) {
|
||||
$line = $lines->get($input['requisition_item_id']);
|
||||
if (! $line) {
|
||||
throw ValidationException::withMessages(['items' => 'Artigo não pertence a esta requisição.']);
|
||||
}
|
||||
$price = BigDecimal::of($input['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$amount = BigDecimal::of($line->quantity)->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$vat = BigDecimal::of($amount)->multipliedBy($line->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
$subtotal += $amount;
|
||||
$tax += $vat;
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'Valor total acima do limite suportado.']);
|
||||
}
|
||||
$items[] = ['tenant_id' => $request->user()->tenant_id, 'requisition_item_id' => $line->id, 'description' => $line->description, 'quantity' => $line->quantity, 'unit_price_minor' => $price, 'tax_rate' => $line->tax_rate, 'subtotal_minor' => $amount, 'tax_minor' => $vat, 'total_minor' => $amount + $vat];
|
||||
}
|
||||
if ($subtotal <= 0) {
|
||||
throw ValidationException::withMessages(['items' => 'O total arredondado deve ser pelo menos 0,01 MZN.']);
|
||||
}
|
||||
$quote = Quotation::query()->create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $request->user()->id,
|
||||
'reference' => $data['reference'], 'delivery_days' => $data['delivery_days'], 'valid_until' => $data['valid_until'], 'payment_terms' => $data['payment_terms'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax, 'currency' => 'MZN',
|
||||
'weights' => ['price' => 40, 'quality' => 20, 'delivery' => 15, 'history' => 15, 'payment' => 10],
|
||||
]);
|
||||
$quote->items()->createMany($items);
|
||||
$workflow->audit($request->user(), 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$this->access($request);
|
||||
abort_if($request->user()->purchasing_role === PurchasingRole::Admin, 403);
|
||||
$data = $request->validate(['quality' => 'required|numeric|decimal:0,2|between:0,10', 'history' => 'required|numeric|decimal:0,2|between:0,10', 'payment' => 'required|numeric|decimal:0,2|between:0,10', 'comment' => 'required|string|min:10|max:2000', 'no_conflict' => 'accepted']);
|
||||
DB::transaction(function () use ($request, $quotation, $data, $workflow) {
|
||||
$guard = app(ConflictGuard::class);
|
||||
$guard->lockCompany($request->user());
|
||||
$requisition = Requisition::query()->visibleTo($request->user())->lockForUpdate()->findOrFail($quotation->requisition_id);
|
||||
abort_unless($requisition->status === 'approved' && $requisition->requester_id !== $request->user()->id, 403);
|
||||
if (PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
||||
throw ValidationException::withMessages(['award' => 'As avaliações estão fechadas pela adjudicação deste processo.']);
|
||||
}
|
||||
$guard->assertParticipation($request->user(), $requisition);
|
||||
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
|
||||
$guard->assertProcess($requisition, $quotation->supplier_id, $request->user());
|
||||
if ($quotation->valid_until < today() || ! $quotation->supplier?->eligible()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Proposta expirada ou fornecedor não elegível.']);
|
||||
}
|
||||
if ($quotation->evaluations()->where('evaluator_id', $request->user()->id)->exists()) {
|
||||
throw ValidationException::withMessages(['evaluation' => 'Já avaliou esta proposta.']);
|
||||
}
|
||||
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $request->user()->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $request->user()->id]);
|
||||
$workflow->audit($request->user(), 'quotation.evaluated', $requisition, null, $evaluation->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreRequisitionRequest;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\CostCenter;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\ConflictGuard;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class RequisitionController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100', 'status' => ['nullable', Rule::in(['draft', 'pending', 'approved', 'rejected'])]]);
|
||||
$query = Requisition::query()->visibleTo($request->user())->with(['requester:id,name', 'department:id,name', 'steps']);
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where('number', 'like', '%'.$filters['search'].'%');
|
||||
}
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
return Inertia::render('requisitions/index', [
|
||||
'requisitions' => $query->latest()->paginate(15)->withQueryString(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
return Inertia::render('requisitions/form', [
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
'requisition' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreRequisitionRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$requisition = $workflow->save($request->user(), $request->validated());
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function show(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
Requisition::query()->visibleTo($request->user())->findOrFail($requisition->id);
|
||||
$requisition->load(['items', 'steps', 'requester:id,name', 'department:id,name', 'costCenter']);
|
||||
|
||||
return Inertia::render('requisitions/show', [
|
||||
'requisition' => $requisition,
|
||||
'canEdit' => $requisition->requester_id === $request->user()->id && $requisition->status === 'draft',
|
||||
'canDecide' => $requisition->canDecide($request->user()),
|
||||
'conflictBlocked' => app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
|
||||
'audit' => AuditLog::query()->where('requisition_id', $requisition->id)->orderBy('id')->get(['id', 'action', 'user_id', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Request $request, Requisition $requisition): Response
|
||||
{
|
||||
abort_unless($requisition->requester_id === $request->user()->id && $requisition->status === 'draft', 403);
|
||||
|
||||
return Inertia::render('requisitions/form', [
|
||||
'requisition' => $requisition->load('items'),
|
||||
'costCenters' => CostCenter::query()->where('department_id', $request->user()->department_id)->orderBy('code')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(StoreRequisitionRequest $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->save($request->user(), $request->validated(), $requisition);
|
||||
|
||||
return to_route('requisitions.show', $requisition);
|
||||
}
|
||||
|
||||
public function submit(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$workflow->submit($request->user(), $requisition);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function decide(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'decision' => ['required', Rule::in(['approved', 'rejected'])],
|
||||
'comment' => ['nullable', 'required_if:decision,rejected', 'string', 'max:2000'],
|
||||
]);
|
||||
$workflow->decide($request->user(), $requisition, $data['decision'], $data['comment'] ?? null);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function dashboard(Request $request): Response
|
||||
{
|
||||
$visible = Requisition::query()->visibleTo($request->user());
|
||||
$pending = (clone $visible)->where('status', 'pending')->with('steps')->get();
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'company' => $request->user()->tenant_id ? Tenant::query()->find($request->user()->tenant_id) : null,
|
||||
'stats' => [
|
||||
'total' => (clone $visible)->count(),
|
||||
'pending' => $pending->count(),
|
||||
'approved' => (clone $visible)->where('status', 'approved')->count(),
|
||||
'approvedMinor' => (clone $visible)->where('status', 'approved')->sum('total_minor'),
|
||||
'awaitingMe' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->count(),
|
||||
],
|
||||
'recent' => (clone $visible)->with(['requester:id,name', 'department:id,name'])->latest()->limit(6)->get(),
|
||||
'approvals' => $pending->filter(fn (Requisition $r) => $r->canDecide($request->user()))->take(5)->values(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -50,6 +51,12 @@ class ProfileController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->tenant_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => 'Uma conta associada a uma empresa não pode ser eliminada desta forma, para preservar os registos de compras e auditoria.',
|
||||
]);
|
||||
}
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Http\Requests\SaveSupplierRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
|
||||
$filters = $request->validate(['search' => 'nullable|string|max:100']);
|
||||
$query = Supplier::query();
|
||||
if (! empty($filters['search'])) {
|
||||
$query->where(fn ($q) => $q->where('name', 'like', '%'.$filters['search'].'%')->orWhere('nuit', 'like', '%'.$filters['search'].'%'));
|
||||
}
|
||||
|
||||
return Inertia::render('suppliers/index', ['suppliers' => $query->orderBy('name')->paginate(15)->withQueryString(), 'filters' => $filters, 'canCreate' => $request->user()->purchasing_role === PurchasingRole::Procurement, 'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance]);
|
||||
}
|
||||
|
||||
public function store(SaveSupplierRequest $request, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $data, $workflow) {
|
||||
$supplier = Supplier::query()->create([...$data, 'tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'last_changed_by' => $request->user()->id, 'status' => 'pending']);
|
||||
$workflow->audit($request->user(), 'supplier.created', null, null, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, Supplier $supplier): Response
|
||||
{
|
||||
Gate::authorize('view', $supplier);
|
||||
$canDocuments = $request->user()->can('viewDocuments', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/show', [
|
||||
'supplier' => $supplier,
|
||||
'documents' => $canDocuments ? $supplier->documents()->latest('id')->get() : [],
|
||||
'canEdit' => $request->user()->can('update', $supplier),
|
||||
'canDocuments' => $canDocuments,
|
||||
'canReview' => $request->user()->purchasing_role === PurchasingRole::Finance,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier): Response
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
|
||||
return Inertia::render('suppliers/edit', ['supplier' => $supplier]);
|
||||
}
|
||||
|
||||
public function update(SaveSupplierRequest $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
$data = $request->validated();
|
||||
DB::transaction(function () use ($request, $supplier, $workflow, $data) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha foi alterada por outro utilizador. Reabra a edição para carregar os dados actuais.']);
|
||||
}
|
||||
$before = $supplier->toArray();
|
||||
unset($data['version']);
|
||||
$supplier->fill($data);
|
||||
if ($supplier->isDirty()) {
|
||||
$supplier->fill(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1])->save();
|
||||
$workflow->audit($request->user(), 'supplier.updated', null, $before, $supplier->toArray());
|
||||
}
|
||||
});
|
||||
|
||||
return to_route('suppliers.show', $supplier);
|
||||
}
|
||||
|
||||
public function review(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
$data = $request->validate(['status' => ['required', Rule::in(['active', 'suspended'])], 'comment' => 'required|string|min:10|max:2000', 'version' => 'required|integer|min:1']);
|
||||
DB::transaction(function () use ($request, $supplier, $data, $workflow) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
abort_if($supplier->created_by === $request->user()->id, 403);
|
||||
abort_if($supplier->last_changed_by === $request->user()->id, 403);
|
||||
if ($supplier->version !== (int) $data['version']) {
|
||||
throw ValidationException::withMessages(['version' => 'A ficha ou os documentos foram alterados. Reabra a ficha antes de validar.']);
|
||||
}
|
||||
if ($data['status'] === 'active' && ($supplier->blocked || $supplier->next_review < today())) {
|
||||
throw ValidationException::withMessages(['status' => 'Fornecedor impedido ou com revisão vencida. Não pode ser activado.']);
|
||||
}
|
||||
$before = $supplier->toArray();
|
||||
$supplier->update(['status' => $data['status'], 'review_comment' => $data['comment'], 'reviewed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($request->user(), 'supplier.reviewed', null, $before, $supplier->toArray());
|
||||
});
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierDocument;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
class SupplierDocumentController extends Controller
|
||||
{
|
||||
public function store(Request $request, Supplier $supplier, RequisitionWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
Gate::authorize('update', $supplier);
|
||||
$data = $request->validate([
|
||||
'document_type' => ['required', Rule::in(['license', 'tax', 'conflict', 'other'])],
|
||||
'file' => ['required', 'file', 'mimes:pdf,jpg,jpeg,png', 'extensions:pdf,jpg,jpeg,png', 'min:1', 'max:2048'],
|
||||
]);
|
||||
$file = $request->file('file');
|
||||
abort_unless($file instanceof UploadedFile, 422);
|
||||
$path = $file->store($supplier->tenant_id.'/'.$supplier->id, 'supplier_documents');
|
||||
abort_if($path === false, 500);
|
||||
try {
|
||||
DB::transaction(function () use ($request, $supplier, $workflow, $data, $file, $path) {
|
||||
$supplier = Supplier::query()->lockForUpdate()->findOrFail($supplier->id);
|
||||
$before = $supplier->toArray();
|
||||
$name = Str::substr(Str::replaceMatches('/[^\pL\pN ._()-]/u', '_', basename(str_replace('\\', '/', $file->getClientOriginalName()))), 0, 180);
|
||||
$document = $supplier->documents()->create([
|
||||
'tenant_id' => $supplier->tenant_id, 'uploaded_by' => $request->user()->id,
|
||||
'document_type' => $data['document_type'], 'original_name' => $name,
|
||||
'path' => $path, 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(),
|
||||
'sha256' => hash_file('sha256', $file->getPathname()),
|
||||
]);
|
||||
$supplier->update(['status' => 'pending', 'reviewed_by' => null, 'review_comment' => null, 'last_changed_by' => $request->user()->id, 'version' => $supplier->version + 1]);
|
||||
$workflow->audit($request->user(), 'supplier.document_uploaded', null, $before, ['supplier' => $supplier->toArray(), 'document' => $document->toArray()]);
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
Storage::disk('supplier_documents')->delete($path);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function download(Request $request, Supplier $supplier, SupplierDocument $document, RequisitionWorkflow $workflow): StreamedResponse
|
||||
{
|
||||
Gate::authorize('viewDocuments', $supplier);
|
||||
abort_unless($document->supplier_id === $supplier->id, 404);
|
||||
$disk = Storage::disk('supplier_documents');
|
||||
abort_unless($disk->exists($document->path), 404);
|
||||
$workflow->audit($request->user(), 'supplier.document_downloaded', null, null, ['supplier_id' => $supplier->id, 'document_id' => $document->id, 'name' => $document->original_name]);
|
||||
|
||||
return $disk->download($document->path, $document->original_name, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Cache-Control' => 'private, no-store',
|
||||
'Content-Security-Policy' => "default-src 'none'; sandbox",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureCompanyIsConfigured
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user()?->tenant_id) {
|
||||
return redirect()->route('company.setup');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SaveSupplierRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->purchasing_role === PurchasingRole::Procurement;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$supplier = $this->route('supplier');
|
||||
$uniqueNuit = Rule::unique('suppliers')->where('tenant_id', $this->user()?->tenant_id);
|
||||
if ($supplier instanceof Supplier) {
|
||||
$uniqueNuit->ignore($supplier->id);
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => 'required|string|max:200',
|
||||
'nuit' => ['required', 'regex:/^\d{9}$/', $uniqueNuit],
|
||||
'contact' => 'required|string|max:200', 'email' => 'required|email|max:200',
|
||||
'phone' => 'required|string|max:50', 'address' => 'required|string|max:500',
|
||||
'category' => 'required|string|max:200', 'beneficiaries' => 'required|string|min:3|max:2000',
|
||||
'document_references' => 'required|string|min:5|max:3000', 'diligence_notes' => 'required|string|min:10|max:3000',
|
||||
'blocked' => 'required|boolean', 'next_review' => 'required|date_format:Y-m-d|after_or_equal:today',
|
||||
'version' => $supplier instanceof Supplier ? 'required|integer|min:1' : 'prohibited',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreRequisitionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user !== null && $user->tenant_id !== null && $user->department_id !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cost_center_id' => ['required', 'integer', Rule::exists('cost_centers', 'id')->where('tenant_id', $this->user()?->tenant_id)->where('department_id', $this->user()?->department_id)],
|
||||
'priority' => ['required', Rule::in(['normal', 'high', 'urgent'])],
|
||||
'needed_by' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'],
|
||||
'justification' => ['required', 'string', 'min:10', 'max:5000'],
|
||||
'items' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'items.*.description' => ['required', 'string', 'max:255'],
|
||||
'items.*.category' => ['required', 'string', 'max:100'],
|
||||
'items.*.purchase_type' => ['required', Rule::in(['consumable', 'service', 'other'])],
|
||||
'items.*.quantity' => ['required', 'numeric', 'min:0.001', 'max:1000000', 'regex:/^\d+(\.\d{1,3})?$/'],
|
||||
'items.*.unit' => ['required', 'string', 'max:30'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0.01', 'max:100000000', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'items.*.tax_rate' => ['required', 'numeric', 'between:0,100', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'cost_center_id.exists' => 'Seleccione um centro de custo do seu departamento.',
|
||||
'needed_by.after_or_equal' => 'A entrega pretendida deve ser hoje ou numa data futura.',
|
||||
'justification.min' => 'Descreva a necessidade da compra com pelo menos 10 caracteres.',
|
||||
'items.*.quantity.regex' => 'Use até três casas decimais na quantidade.',
|
||||
'items.*.unit_price.regex' => 'Use até duas casas decimais no preço.',
|
||||
'items.*.tax_rate.regex' => 'Use até duas casas decimais na taxa de IVA.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $role
|
||||
* @property string $decision
|
||||
* @property int|null $approver_id
|
||||
*/
|
||||
class ApprovalStep extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use LogicException;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['before' => 'array', 'after' => 'array'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('O histórico de auditoria não pode ser alterado.'));
|
||||
static::deleting(fn () => throw new LogicException('O histórico de auditoria não pode ser eliminado.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $query) {
|
||||
$tenantId = auth()->check() ? auth()->user()->tenant_id : null;
|
||||
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId ?? 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use LogicException;
|
||||
|
||||
trait ImmutablePurchaseRecord
|
||||
{
|
||||
protected static function bootImmutablePurchaseRecord(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('O documento emitido não pode ser reescrito.'));
|
||||
static::deleting(fn () => throw new LogicException('O documento emitido deve ser preservado.'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $user_id
|
||||
* @property int|null $supplier_id
|
||||
* @property int|null $requisition_id
|
||||
* @property int|null $root_id
|
||||
* @property bool $has_conflict
|
||||
*/
|
||||
class ConflictDeclaration extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['has_conflict' => 'boolean', 'known_since' => 'date:Y-m-d', 'declared_at' => 'datetime'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(fn () => throw new LogicException('As declarações são preservadas; registe uma correcção.'));
|
||||
static::deleting(fn () => throw new LogicException('As declarações de conflito não podem ser eliminadas.'));
|
||||
}
|
||||
|
||||
/** @param Builder<ConflictDeclaration> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
if (! in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance])) {
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Supplier, $this> */
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Requisition, $this> */
|
||||
public function requisition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Requisition::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<ConflictDeclaration, $this> */
|
||||
public function successor(): HasOne
|
||||
{
|
||||
return $this->hasOne(self::class, 'previous_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $department_id
|
||||
*/
|
||||
class CostCenter extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id */
|
||||
class Department extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $quality
|
||||
* @property string $history
|
||||
* @property string $payment
|
||||
*/
|
||||
class ProposalEvaluation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['no_conflict' => 'boolean', 'quality' => 'decimal:2', 'history' => 'decimal:2', 'payment' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $requisition_id
|
||||
* @property int $quotation_id
|
||||
* @property int $created_by
|
||||
* @property string $status
|
||||
* @property string $comparison_hash
|
||||
* @property array<string, mixed> $approval_snapshot
|
||||
* @property list<array<string, mixed>> $decision_matrix
|
||||
*/
|
||||
class PurchaseAward extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'decision_matrix' => 'array', 'delivery_date' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<PurchaseAwardApproval, $this> */
|
||||
public function approvals(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseAwardApproval::class)->orderBy('position');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseAwardApproval extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $requisition_id
|
||||
* @property string $number
|
||||
* @property array<string, mixed> $snapshot
|
||||
*/
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
use BelongsToTenant, ImmutablePurchaseRecord;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['snapshot' => 'array', 'issued_at' => 'datetime'];
|
||||
}
|
||||
|
||||
/** @return HasMany<PurchaseOrderItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
|
||||
/** @param Builder<PurchaseOrder> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
$query->whereIn('requisition_id', Requisition::query()->visibleTo($user)->select('id'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Models\Concerns\ImmutablePurchaseRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
use BelongsToTenant, ImmutablePurchaseRecord;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/** @property array<string, mixed> $weights */
|
||||
class Quotation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['weights' => 'array', 'valid_until' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Supplier, $this> */
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<QuotationItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuotationItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ProposalEvaluation, $this> */
|
||||
public function evaluations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProposalEvaluation::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property string $quantity
|
||||
* @property string $tax_rate
|
||||
*/
|
||||
class QuotationItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use App\Services\ConflictGuard;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $department_id
|
||||
* @property int $requester_id
|
||||
* @property string $number
|
||||
* @property string $status
|
||||
* @property int $subtotal_minor
|
||||
* @property int $tax_minor
|
||||
* @property int $total_minor
|
||||
* @property Collection<int, ApprovalStep> $steps
|
||||
* @property Collection<int, RequisitionItem> $items
|
||||
*/
|
||||
class Requisition extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_snapshot' => 'array', 'submitted_at' => 'datetime', 'needed_by' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
/** @return HasMany<RequisitionItem, $this> */
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(RequisitionItem::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ApprovalStep, $this> */
|
||||
public function steps(): HasMany
|
||||
{
|
||||
return $this->hasMany(ApprovalStep::class)->orderBy('position');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function requester(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requester_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Department, $this> */
|
||||
public function department(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Department::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CostCenter, $this> */
|
||||
public function costCenter(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CostCenter::class);
|
||||
}
|
||||
|
||||
/** @param Builder<Requisition> $query */
|
||||
public function scopeVisibleTo(Builder $query, User $user): void
|
||||
{
|
||||
match ($user->purchasing_role) {
|
||||
PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance => null,
|
||||
PurchasingRole::DepartmentHead => $query->where('department_id', $user->department_id),
|
||||
default => $query->where('requester_id', $user->id),
|
||||
};
|
||||
}
|
||||
|
||||
public function canDecide(User $user): bool
|
||||
{
|
||||
if ($this->tenant_id !== $user->tenant_id || $this->status !== 'pending' || $this->requester_id === $user->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->steps->contains('approver_id', $user->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$next = $this->steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value
|
||||
&& ($next->role !== 'department_head' || $this->department_id === $user->department_id)
|
||||
&& ! app(ConflictGuard::class)->participationBlocked($user, $this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Decimal casts expose exact decimal strings for monetary calculations.
|
||||
*
|
||||
* @property string $quantity
|
||||
* @property string $tax_rate
|
||||
*/
|
||||
class RequisitionItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['quantity' => 'decimal:3', 'tax_rate' => 'decimal:2'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $created_by
|
||||
* @property int|null $last_changed_by
|
||||
* @property int $version
|
||||
*/
|
||||
class Supplier extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['version' => 1];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['blocked' => 'boolean', 'next_review' => 'date:Y-m-d'];
|
||||
}
|
||||
|
||||
public function eligible(): bool
|
||||
{
|
||||
return $this->status === 'active' && ! $this->blocked && $this->next_review >= today();
|
||||
}
|
||||
|
||||
/** @return HasMany<SupplierDocument, $this> */
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(SupplierDocument::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $supplier_id
|
||||
* @property string $path
|
||||
* @property string $original_name
|
||||
*/
|
||||
class SupplierDocument extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['path', 'tenant_id'];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property int $first_limit_minor
|
||||
* @property int $second_limit_minor
|
||||
* @property bool $approval_includes_tax
|
||||
* @property bool $rules_provisional
|
||||
*/
|
||||
class Tenant extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['approval_includes_tax' => 'boolean', 'rules_provisional' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -12,6 +13,9 @@ use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $tenant_id
|
||||
* @property int|null $department_id
|
||||
* @property PurchasingRole $purchasing_role
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
@@ -38,6 +42,7 @@ class User extends Authenticatable
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'purchasing_role' => PurchasingRole::class,
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
|
||||
class SupplierPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->tenant_id !== null && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
|
||||
}
|
||||
|
||||
public function view(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $this->viewAny($user) && $user->tenant_id === $supplier->tenant_id;
|
||||
}
|
||||
|
||||
public function update(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $user->purchasing_role === PurchasingRole::Procurement && $user->tenant_id === $supplier->tenant_id;
|
||||
}
|
||||
|
||||
public function viewDocuments(User $user, Supplier $supplier): bool
|
||||
{
|
||||
return $this->view($user, $supplier) && in_array($user->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\ConflictDeclaration;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseAwardApproval;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ConflictGuard
|
||||
{
|
||||
/** Call first inside a transaction, before locking a requisition or supplier. */
|
||||
public function lockCompany(User $user): void
|
||||
{
|
||||
Tenant::query()->whereKey($user->tenant_id)->lockForUpdate()->firstOrFail();
|
||||
}
|
||||
|
||||
public function hasConflict(int $userId, ?int $supplierId, ?int $requisitionId): bool
|
||||
{
|
||||
return ConflictDeclaration::query()->where('user_id', $userId)->where('has_conflict', true)
|
||||
->where(function ($scopes) use ($supplierId, $requisitionId) {
|
||||
// Supplier-wide statements apply only once that supplier is known.
|
||||
$scopes->where(function ($supplierScope) use ($supplierId) {
|
||||
$supplierScope->whereNull('requisition_id')->where('supplier_id', $supplierId ?? 0);
|
||||
});
|
||||
if ($requisitionId !== null) {
|
||||
$scopes->orWhere(function ($processScope) use ($supplierId, $requisitionId) {
|
||||
$processScope->where('requisition_id', $requisitionId)->where(function ($supplier) use ($supplierId) {
|
||||
$supplier->whereNull('supplier_id');
|
||||
if ($supplierId !== null) {
|
||||
$supplier->orWhere('supplier_id', $supplierId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})->exists();
|
||||
}
|
||||
|
||||
public function participationBlocked(User $user, Requisition $requisition): bool
|
||||
{
|
||||
if ($this->hasConflict($user->id, null, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
foreach ($this->participants($requisition) as $userId) {
|
||||
if ($this->hasConflict($userId, null, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
foreach (Quotation::query()->where('requisition_id', $requisition->id)->pluck('supplier_id') as $supplierId) {
|
||||
if ($this->processBlocked($requisition, $supplierId, $user)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertParticipation(User $user, Requisition $requisition): void
|
||||
{
|
||||
if ($this->participationBlocked($user, $requisition)) {
|
||||
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo que impede a sua participação neste processo.']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private function participants(Requisition $requisition): array
|
||||
{
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id);
|
||||
$awards = PurchaseAward::query()->where('requisition_id', $requisition->id);
|
||||
|
||||
$participants = collect([$requisition->requester_id])
|
||||
->merge($requisition->steps()->whereNotNull('approver_id')->pluck('approver_id'))
|
||||
->merge((clone $quotes)->pluck('created_by'))
|
||||
->merge(ProposalEvaluation::query()->whereIn('quotation_id', (clone $quotes)->select('id'))->pluck('evaluator_id'))
|
||||
->merge((clone $awards)->pluck('created_by'))
|
||||
->merge(PurchaseAwardApproval::query()->whereIn('purchase_award_id', (clone $awards)->select('id'))->whereNotNull('approver_id')->pluck('approver_id'))
|
||||
->merge(AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', [
|
||||
'requisition.created', 'requisition.updated', 'requisition.submitted',
|
||||
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
])->pluck('user_id'))
|
||||
->unique()->values()->all();
|
||||
$ids = [];
|
||||
foreach ($participants as $participant) {
|
||||
$id = filter_var($participant, FILTER_VALIDATE_INT);
|
||||
if ($id === false) {
|
||||
throw new \LogicException('Identificador inválido no histórico de intervenientes.');
|
||||
}
|
||||
$ids[] = $id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function processBlocked(Requisition $requisition, int $supplierId, ?User $actor = null): bool
|
||||
{
|
||||
$participants = $this->participants($requisition);
|
||||
if ($actor !== null) {
|
||||
$participants[] = $actor->id;
|
||||
}
|
||||
foreach (array_unique($participants) as $userId) {
|
||||
if ($this->hasConflict($userId, $supplierId, $requisition->id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function assertProcess(Requisition $requisition, int $supplierId, User $actor): void
|
||||
{
|
||||
if ($this->processBlocked($requisition, $supplierId, $actor)) {
|
||||
throw ValidationException::withMessages(['conflict' => 'Existe um conflito de interesses activo de um interveniente neste processo com este fornecedor. A operação está impedida, mesmo com declaração de ausência de conflito.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ProposalEvaluation;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\BigRational;
|
||||
use Brick\Math\Exception\MathException;
|
||||
use Brick\Math\RoundingMode;
|
||||
|
||||
class ProposalComparison
|
||||
{
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
public function inspect(Requisition $requisition): array
|
||||
{
|
||||
$quotes = Quotation::query()->where('requisition_id', $requisition->id)
|
||||
->with(['supplier', 'items' => fn ($q) => $q->orderBy('id'), 'evaluations' => fn ($q) => $q->orderBy('id')])->orderBy('id')->get();
|
||||
$guard = app(ConflictGuard::class);
|
||||
$referenceWeights = $quotes->first()?->weights;
|
||||
$rows = [];
|
||||
foreach ($quotes as $quote) {
|
||||
$reasons = [];
|
||||
if (! $quote->supplier?->eligible() || $quote->valid_until < today()) {
|
||||
$reasons[] = 'Fornecedor não elegível ou proposta expirada.';
|
||||
}
|
||||
if (! $this->validWeights($quote->weights) || $quote->weights != $referenceWeights) {
|
||||
$reasons[] = 'As ponderações das propostas não são comparáveis.';
|
||||
}
|
||||
if (! $this->validAmounts($quote, $requisition)) {
|
||||
$reasons[] = 'Moeda, linhas ou valores inconsistentes com a requisição.';
|
||||
}
|
||||
if ($guard->processBlocked($requisition, $quote->supplier_id)) {
|
||||
$reasons[] = 'Conflito de interesses de um interveniente.';
|
||||
}
|
||||
$evaluations = $quote->evaluations->filter(fn ($evaluation) => $this->eligibleEvaluator($evaluation, $requisition))->values();
|
||||
$rows[] = [
|
||||
...$quote->toArray(), 'id' => $quote->id, 'supplier_id' => $quote->supplier_id,
|
||||
'weights' => $quote->weights, 'subtotal_minor' => $quote->subtotal_minor, 'delivery_days' => $quote->delivery_days,
|
||||
'items' => $quote->items->toArray(), 'evaluations' => $quote->evaluations->toArray(),
|
||||
'eligible' => $reasons === [], 'ineligibility_reasons' => $reasons,
|
||||
'qualified_evaluations' => $evaluations->toArray(), 'evaluation_count' => $evaluations->count(),
|
||||
'score' => null, 'score_minor' => null,
|
||||
];
|
||||
}
|
||||
$eligible = array_values(array_filter($rows, fn ($row) => $row['eligible']));
|
||||
$minimum = null;
|
||||
$days = null;
|
||||
foreach ($eligible as $row) {
|
||||
$minimum = $minimum === null ? $row['subtotal_minor'] : min($minimum, $row['subtotal_minor']);
|
||||
$days = $days === null ? $row['delivery_days'] : min($days, $row['delivery_days']);
|
||||
}
|
||||
$best = null;
|
||||
$topIds = [];
|
||||
foreach ($rows as &$row) {
|
||||
if ($row['eligible'] && $row['evaluation_count'] >= 2) {
|
||||
$weights = $row['weights'];
|
||||
$score = BigRational::of($minimum)->dividedBy($row['subtotal_minor'])->multipliedBy(10 * $weights['price'])
|
||||
->plus(BigRational::of($days)->dividedBy($row['delivery_days'])->multipliedBy(10 * $weights['delivery']));
|
||||
foreach (['quality', 'history', 'payment'] as $criterion) {
|
||||
$sum = BigRational::zero();
|
||||
foreach ($row['qualified_evaluations'] as $evaluation) {
|
||||
$sum = $sum->plus($evaluation[$criterion]);
|
||||
}
|
||||
$score = $score->plus($sum->dividedBy($row['evaluation_count'])->multipliedBy($weights[$criterion]));
|
||||
}
|
||||
$minor = $score->dividedBy(100)->toScale(2, RoundingMode::HalfUp)->multipliedBy(100)->toInt();
|
||||
$row['score_minor'] = $minor;
|
||||
$row['score'] = $minor / 100;
|
||||
if ($best === null || $minor > $best) {
|
||||
$best = $minor;
|
||||
$topIds = [$row['id']];
|
||||
} elseif ($minor === $best) {
|
||||
$topIds[] = $row['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$basis = [];
|
||||
foreach ($rows as $row) {
|
||||
$basis[] = [
|
||||
'quotation' => array_intersect_key($row, array_flip(['id', 'supplier_id', 'reference', 'delivery_days', 'valid_until', 'payment_terms', 'subtotal_minor', 'tax_minor', 'total_minor', 'currency', 'weights'])),
|
||||
'items' => $row['items'], 'evaluations' => $row['evaluations'],
|
||||
'qualified_ids' => array_column($row['qualified_evaluations'], 'id'),
|
||||
'eligible' => $row['eligible'], 'score_minor' => $row['score_minor'],
|
||||
];
|
||||
}
|
||||
$ready = count($eligible) >= 3 && count(array_unique(array_column($eligible, 'supplier_id'))) >= 3;
|
||||
foreach ($eligible as $row) {
|
||||
$ready = $ready && $row['evaluation_count'] >= 2;
|
||||
}
|
||||
|
||||
return ['rows' => $rows, 'ready' => $ready, 'top_ids' => $topIds, 'fingerprint' => hash('sha256', json_encode($basis, JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
|
||||
public function eligibleEvaluator(ProposalEvaluation $evaluation, Requisition $requisition): bool
|
||||
{
|
||||
$user = User::query()->where('tenant_id', $requisition->tenant_id)->find($evaluation->evaluator_id);
|
||||
if (! $user || ! $evaluation->no_conflict || $user->id === $requisition->requester_id) {
|
||||
return false;
|
||||
}
|
||||
if (! $this->eligibleRole($user, $requisition)) {
|
||||
return false;
|
||||
}
|
||||
foreach (['quality', 'history', 'payment'] as $criterion) {
|
||||
if (! is_numeric($evaluation->$criterion) || $evaluation->$criterion < 0 || $evaluation->$criterion > 10) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function eligibleRole(User $user, Requisition $requisition): bool
|
||||
{
|
||||
return $user->tenant_id === $requisition->tenant_id &&
|
||||
(in_array($user->purchasing_role, [PurchasingRole::Procurement, PurchasingRole::Finance]) ||
|
||||
($user->purchasing_role === PurchasingRole::DepartmentHead && $user->department_id === $requisition->department_id));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $weights */
|
||||
private function validWeights(array $weights): bool
|
||||
{
|
||||
$keys = ['price', 'quality', 'delivery', 'history', 'payment'];
|
||||
if (count($weights) !== count($keys) || array_sum($weights) !== 100) {
|
||||
return false;
|
||||
}
|
||||
foreach ($keys as $key) {
|
||||
if (! isset($weights[$key]) || ! is_int($weights[$key]) || $weights[$key] < 0 || $weights[$key] > 100) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function validAmounts(Quotation $quote, Requisition $requisition): bool
|
||||
{
|
||||
if ($quote->currency !== 'MZN' || $quote->delivery_days < 1 || $quote->subtotal_minor <= 0 || $quote->total_minor > 100000000000000) {
|
||||
return false;
|
||||
}
|
||||
$requested = $requisition->items()->get()->keyBy('id');
|
||||
if ($requested->isEmpty() || $requested->count() !== $quote->items->count()) {
|
||||
return false;
|
||||
}
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
$seen = [];
|
||||
try {
|
||||
foreach ($quote->items as $item) {
|
||||
$original = $requested->get($item->requisition_item_id);
|
||||
if (! $original || in_array($item->requisition_item_id, $seen) || $item->unit_price_minor < 1 ||
|
||||
$item->quantity <= 0 || $item->tax_rate < 0 || $item->tax_rate > 100 ||
|
||||
! BigDecimal::of($item->quantity)->isEqualTo($original->quantity) ||
|
||||
! BigDecimal::of($item->tax_rate)->isEqualTo($original->tax_rate)) {
|
||||
return false;
|
||||
}
|
||||
$seen[] = $item->requisition_item_id;
|
||||
$amount = BigDecimal::of($item->quantity)->multipliedBy($item->unit_price_minor)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$vat = BigDecimal::of($amount)->multipliedBy($item->tax_rate)->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
if ($amount !== $item->subtotal_minor || $vat !== $item->tax_minor || $amount + $vat !== $item->total_minor) {
|
||||
return false;
|
||||
}
|
||||
$subtotal += $amount;
|
||||
$tax += $vat;
|
||||
}
|
||||
} catch (MathException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $subtotal === $quote->subtotal_minor && $tax === $quote->tax_minor && $subtotal + $tax === $quote->total_minor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\PurchaseAward;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Quotation;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseWorkflow
|
||||
{
|
||||
public function __construct(private ConflictGuard $guard, private ProposalComparison $comparison, private RequisitionWorkflow $audit) {}
|
||||
|
||||
/** @param array{quotation_id: int, justification: string, delivery_date: string, delivery_location: string} $data */
|
||||
public function select(User $user, Requisition $requisition, array $data): PurchaseAward
|
||||
{
|
||||
return DB::transaction(function () use ($user, $requisition, $data) {
|
||||
$this->guard->lockCompany($user);
|
||||
$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()) {
|
||||
throw ValidationException::withMessages(['award' => 'Este processo já tem uma adjudicação. Não é permitida uma segunda selecção.']);
|
||||
}
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($data['quotation_id']);
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
$award = PurchaseAward::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'quotation_id' => $quote->id,
|
||||
'created_by' => $user->id, 'justification' => $data['justification'], 'delivery_date' => $data['delivery_date'],
|
||||
'delivery_location' => $data['delivery_location'], 'status' => 'pending',
|
||||
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
|
||||
'approval_snapshot' => $rules, 'decision_matrix' => $result['rows'], 'comparison_hash' => $result['fingerprint'],
|
||||
]);
|
||||
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $quote->total_minor : $quote->subtotal_minor);
|
||||
foreach ($roles as $position => $role) {
|
||||
$award->approvals()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
|
||||
}
|
||||
$this->audit->audit($user, 'award.created', $requisition, null, $award->load('approvals')->toArray());
|
||||
|
||||
return $award;
|
||||
});
|
||||
}
|
||||
|
||||
public function canDecide(User $user, PurchaseAward $award, Requisition $requisition): bool
|
||||
{
|
||||
if ($award->status !== 'pending' || $user->tenant_id !== $award->tenant_id || in_array($user->id, [$requisition->requester_id, $award->created_by])) {
|
||||
return false;
|
||||
}
|
||||
$steps = $award->approvals()->get();
|
||||
$next = $steps->firstWhere('decision', 'pending');
|
||||
|
||||
return $next !== null && $next->role === $user->purchasing_role->value &&
|
||||
($next->role !== 'department_head' || $user->department_id === $requisition->department_id) &&
|
||||
! $steps->contains('approver_id', $user->id) && ! $this->guard->participationBlocked($user, $requisition);
|
||||
}
|
||||
|
||||
public function decide(User $user, PurchaseAward $award, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $award, $decision, $comment) {
|
||||
$this->guard->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
abort_unless($this->canDecide($user, $award, $requisition), 403);
|
||||
if ($decision === 'approved') {
|
||||
$this->currentBasis($user, $award, $requisition);
|
||||
}
|
||||
$step = $award->approvals()->where('decision', 'pending')->firstOrFail();
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($award->approvals()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$award->update(['status' => $status]);
|
||||
$this->audit->audit($user, 'award.'.$decision, $requisition, ['status' => 'pending'], ['award_id' => $award->id, 'status' => $status, 'approval' => $step->toArray()]);
|
||||
});
|
||||
}
|
||||
|
||||
public function issue(User $user, PurchaseAward $award): PurchaseOrder
|
||||
{
|
||||
return DB::transaction(function () use ($user, $award) {
|
||||
$this->guard->lockCompany($user);
|
||||
abort_unless($user->purchasing_role === PurchasingRole::Procurement, 403);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id);
|
||||
$award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id);
|
||||
// An already committed issue is returned, never re-created or re-snapshotted.
|
||||
$existing = PurchaseOrder::query()->where('requisition_id', $requisition->id)->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
if ($award->status !== 'approved') {
|
||||
throw ValidationException::withMessages(['approvals' => 'Conclua as aprovações do valor adjudicado antes de emitir a ordem.']);
|
||||
}
|
||||
$quote = $this->currentBasis($user, $award, $requisition);
|
||||
$this->validateFinalApprovals($award, $requisition);
|
||||
if ($award->delivery_date < today()) {
|
||||
throw ValidationException::withMessages(['delivery_date' => 'A data de entrega da adjudicação já passou. Não é possível emitir esta ordem.']);
|
||||
}
|
||||
$requisition->load(['department', 'costCenter', 'requester']);
|
||||
$supplier = $quote->supplier;
|
||||
abort_unless($supplier !== null && $requisition->department !== null && $requisition->costCenter !== null, 409);
|
||||
$lines = [];
|
||||
$requested = $requisition->items()->get()->keyBy('id');
|
||||
foreach ($quote->items()->orderBy('id')->get() as $item) {
|
||||
$source = $requested->get($item->requisition_item_id);
|
||||
abort_if($source === null, 409);
|
||||
$lines[] = [
|
||||
...$item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']),
|
||||
...$source->only(['unit', 'category', 'purchase_type']), 'tenant_id' => $user->tenant_id,
|
||||
];
|
||||
}
|
||||
$snapshot = [
|
||||
'development_only' => true, 'budget_checked' => false,
|
||||
'company' => Tenant::query()->findOrFail($user->tenant_id)->only(['id', 'name']),
|
||||
'requisition' => $requisition->only(['id', 'number', 'justification']),
|
||||
'department' => $requisition->department->only(['id', 'name']),
|
||||
'cost_center' => $requisition->costCenter->only(['id', 'code', 'name']),
|
||||
'requester' => $requisition->requester?->only(['id', 'name']),
|
||||
'supplier' => $supplier->only(['id', 'name', 'nuit', 'contact', 'email', 'phone', 'address']),
|
||||
'quotation' => $quote->only(['id', 'reference', 'currency', 'payment_terms', 'delivery_days', 'valid_until']),
|
||||
'award' => $award->only(['id', 'justification', 'delivery_date', 'delivery_location']),
|
||||
'issuer' => $user->only(['id', 'name']), 'approval_rules' => $award->approval_snapshot,
|
||||
'estimate_approvals' => $requisition->steps()->get()->toArray(),
|
||||
'final_approvals' => $award->approvals()->get()->toArray(),
|
||||
'decision_matrix' => $award->decision_matrix, 'items' => $lines,
|
||||
];
|
||||
$order = PurchaseOrder::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'requisition_id' => $requisition->id, 'purchase_award_id' => $award->id,
|
||||
'supplier_id' => $supplier->id, 'issued_by' => $user->id, 'currency' => $quote->currency,
|
||||
'subtotal_minor' => $quote->subtotal_minor, 'tax_minor' => $quote->tax_minor, 'total_minor' => $quote->total_minor,
|
||||
'snapshot' => $snapshot, 'issued_at' => now(),
|
||||
]);
|
||||
// Complete the generated reference before this transaction exposes the immutable document.
|
||||
DB::table('purchase_orders')->where('id', $order->id)->update(['number' => sprintf('OC-%s-%06d', now()->format('Y'), $order->id)]);
|
||||
$order->refresh();
|
||||
$order->items()->createMany($lines);
|
||||
$this->audit->audit($user, 'purchase_order.issued', $requisition, null, $order->load('items')->toArray());
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
private function currentBasis(User $user, PurchaseAward $award, Requisition $requisition): Quotation
|
||||
{
|
||||
$rules = $this->rules($requisition);
|
||||
$this->sourceApproved($requisition, $rules);
|
||||
$this->supportedItems($requisition);
|
||||
$quote = Quotation::query()->where('requisition_id', $requisition->id)->lockForUpdate()->findOrFail($award->quotation_id);
|
||||
$result = $this->checkComparison($user, $requisition, $quote);
|
||||
if ($result['fingerprint'] !== $award->comparison_hash || $award->approval_snapshot !== $rules ||
|
||||
$quote->subtotal_minor !== $award->subtotal_minor || $quote->tax_minor !== $award->tax_minor || $quote->total_minor !== $award->total_minor) {
|
||||
throw ValidationException::withMessages(['comparison' => 'A base da adjudicação mudou. As aprovações anteriores não autorizam a emissão com dados diferentes.']);
|
||||
}
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
/** @return array{rows: list<array<string, mixed>>, ready: bool, top_ids: list<int>, fingerprint: string} */
|
||||
private function checkComparison(User $user, Requisition $requisition, Quotation $quote): array
|
||||
{
|
||||
$this->guard->assertParticipation($user, $requisition);
|
||||
$this->guard->assertProcess($requisition, $quote->supplier_id, $user);
|
||||
$result = $this->comparison->inspect($requisition);
|
||||
if (! $result['ready']) {
|
||||
throw ValidationException::withMessages(['comparison' => 'São exigidas três propostas elegíveis de fornecedores distintos, com pelo menos duas avaliações elegíveis cada. Excepções sem política aprovada estão bloqueadas.']);
|
||||
}
|
||||
if (! in_array($quote->id, $result['top_ids'], true)) {
|
||||
throw ValidationException::withMessages(['quotation_id' => 'Escolha uma proposta de maior pontuação. A escolha fora do topo exige política de escalão superior ainda não aprovada.']);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} */
|
||||
private function rules(Requisition $requisition): array
|
||||
{
|
||||
$rules = $requisition->getAttribute('approval_snapshot');
|
||||
if (! is_array($rules) || ! isset($rules['first_limit_minor'], $rules['second_limit_minor'], $rules['approval_includes_tax'], $rules['rules_provisional']) ||
|
||||
! is_int($rules['first_limit_minor']) || ! is_int($rules['second_limit_minor']) || ! is_bool($rules['approval_includes_tax']) || ! is_bool($rules['rules_provisional']) ||
|
||||
$rules['first_limit_minor'] < 0 || $rules['second_limit_minor'] < $rules['first_limit_minor']) {
|
||||
throw ValidationException::withMessages(['policy' => 'O processo não tem regras de aprovação guardadas e válidas. Não é possível presumir limites ou a base de IVA.']);
|
||||
}
|
||||
|
||||
return ['first_limit_minor' => $rules['first_limit_minor'], 'second_limit_minor' => $rules['second_limit_minor'], 'approval_includes_tax' => $rules['approval_includes_tax'], 'rules_provisional' => $rules['rules_provisional']];
|
||||
}
|
||||
|
||||
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules
|
||||
* @return list<string>
|
||||
*/
|
||||
private function roles(array $rules, int $amount): array
|
||||
{
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $rules['first_limit_minor']) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $rules['second_limit_minor']) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/** @param array{first_limit_minor: int, second_limit_minor: int, approval_includes_tax: bool, rules_provisional: bool} $rules */
|
||||
private function sourceApproved(Requisition $requisition, array $rules): void
|
||||
{
|
||||
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $requisition->total_minor : $requisition->subtotal_minor);
|
||||
$steps = $requisition->steps()->get();
|
||||
$seen = [$requisition->requester_id];
|
||||
if ($requisition->status !== 'approved' || $steps->count() !== count($roles)) {
|
||||
throw ValidationException::withMessages(['approvals' => 'A requisição deve ter todas as aprovações obrigatórias da estimativa, preservadas no histórico.']);
|
||||
}
|
||||
foreach ($steps as $index => $step) {
|
||||
if ($step->decision !== 'approved' || $step->role !== $roles[$index] || $step->approver_id === null || in_array($step->approver_id, $seen, true) ||
|
||||
! User::query()->where('tenant_id', $requisition->tenant_id)->whereKey($step->approver_id)->exists()) {
|
||||
throw ValidationException::withMessages(['approvals' => 'O histórico de aprovação da estimativa está incompleto ou inconsistente.']);
|
||||
}
|
||||
$seen[] = $step->approver_id;
|
||||
}
|
||||
}
|
||||
|
||||
private function validateFinalApprovals(PurchaseAward $award, Requisition $requisition): void
|
||||
{
|
||||
$rules = $this->rules($requisition);
|
||||
$roles = $this->roles($rules, $rules['approval_includes_tax'] ? $award->total_minor : $award->subtotal_minor);
|
||||
$steps = $award->approvals()->get();
|
||||
$seen = [$requisition->requester_id, $award->created_by];
|
||||
if ($steps->count() !== count($roles)) {
|
||||
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);
|
||||
if (! $user || $step->decision !== 'approved' || $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.']);
|
||||
}
|
||||
$this->guard->assertProcess($requisition, Quotation::query()->findOrFail($award->quotation_id)->supplier_id, $user);
|
||||
$seen[] = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
private function supportedItems(Requisition $requisition): void
|
||||
{
|
||||
if ($requisition->items()->whereNotIn('purchase_type', ['consumable', 'service', 'other'])->exists()) {
|
||||
throw ValidationException::withMessages(['purchase_type' => 'Equipamento e peças exigem regras adicionais ainda não suportadas. A emissão para estes tipos está bloqueada.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RequisitionWorkflow
|
||||
{
|
||||
/** @param array<string, mixed> $data */
|
||||
public function save(User $user, array $data, ?Requisition $existing = null): Requisition
|
||||
{
|
||||
return DB::transaction(function () use ($user, $data, $existing) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = $existing ? Requisition::query()->lockForUpdate()->findOrFail($existing->id) : new Requisition;
|
||||
if ($existing) {
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
}
|
||||
$before = $existing ? $requisition->load('items')->toArray() : null;
|
||||
$items = [];
|
||||
$subtotal = 0;
|
||||
$tax = 0;
|
||||
/** @var list<array{description: string, category: string, purchase_type: string, quantity: string, unit: string, unit_price: string, tax_rate: string}> $lines */
|
||||
$lines = $data['items'];
|
||||
foreach ($lines as $line) {
|
||||
$price = BigDecimal::of($line['unit_price'])->multipliedBy(100)->toScale(0)->toInt();
|
||||
$lineSubtotal = BigDecimal::of($line['quantity'])->multipliedBy($price)->toScale(0, RoundingMode::HalfUp)->toInt();
|
||||
$lineTax = BigDecimal::of($lineSubtotal)->multipliedBy($line['tax_rate'])->dividedBy(100, 0, RoundingMode::HalfUp)->toInt();
|
||||
$items[] = [
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'description' => $line['description'], 'category' => $line['category'],
|
||||
'purchase_type' => $line['purchase_type'], 'quantity' => $line['quantity'], 'unit' => $line['unit'],
|
||||
'unit_price_minor' => $price, 'tax_rate' => $line['tax_rate'],
|
||||
'subtotal_minor' => $lineSubtotal, 'tax_minor' => $lineTax, 'total_minor' => $lineSubtotal + $lineTax,
|
||||
];
|
||||
$subtotal += $lineSubtotal;
|
||||
$tax += $lineTax;
|
||||
if ($subtotal + $tax > 100000000000000) {
|
||||
throw ValidationException::withMessages(['items' => 'O total da requisição ultrapassa o limite suportado de 1.000.000.000.000 MZN.']);
|
||||
}
|
||||
}
|
||||
$requisition->fill([
|
||||
'tenant_id' => $user->tenant_id, 'requester_id' => $user->id, 'department_id' => $user->department_id,
|
||||
'cost_center_id' => $data['cost_center_id'], 'priority' => $data['priority'],
|
||||
'needed_by' => $data['needed_by'], 'justification' => $data['justification'],
|
||||
'subtotal_minor' => $subtotal, 'tax_minor' => $tax, 'total_minor' => $subtotal + $tax,
|
||||
])->save();
|
||||
if (! $existing) {
|
||||
$requisition->update(['number' => sprintf('REQ-%s-%06d', now()->format('Y'), $requisition->id)]);
|
||||
}
|
||||
$requisition->items()->delete();
|
||||
$requisition->items()->createMany($items);
|
||||
$requisition->load('items');
|
||||
$this->audit($user, $existing ? 'requisition.updated' : 'requisition.created', $requisition, $before, $requisition->toArray());
|
||||
|
||||
return $requisition;
|
||||
});
|
||||
}
|
||||
|
||||
public function submit(User $user, Requisition $requisition): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $requisition) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
abort_unless($requisition->requester_id === $user->id && $requisition->status === 'draft', 403);
|
||||
app(ConflictGuard::class)->assertParticipation($user, $requisition);
|
||||
if ($requisition->needed_by < today()) {
|
||||
throw ValidationException::withMessages(['submission' => 'Actualize a data de entrega antes de submeter.']);
|
||||
}
|
||||
$tenant = Tenant::query()->findOrFail($user->tenant_id);
|
||||
$amount = $tenant->approval_includes_tax ? $requisition->total_minor : $requisition->subtotal_minor;
|
||||
$roles = ['department_head'];
|
||||
if ($amount > $tenant->first_limit_minor) {
|
||||
$roles[] = 'procurement';
|
||||
}
|
||||
if ($amount > $tenant->second_limit_minor) {
|
||||
$roles[] = 'finance';
|
||||
}
|
||||
foreach ($roles as $position => $role) {
|
||||
$requisition->steps()->create(['tenant_id' => $user->tenant_id, 'position' => $position + 1, 'role' => $role]);
|
||||
}
|
||||
$snapshot = $tenant->only(['first_limit_minor', 'second_limit_minor', 'approval_includes_tax', 'rules_provisional']);
|
||||
$requisition->update(['status' => 'pending', 'submitted_at' => now(), 'approval_snapshot' => $snapshot]);
|
||||
$this->audit($user, 'requisition.submitted', $requisition, ['status' => 'draft'], ['status' => 'pending', 'rules' => $snapshot, 'roles' => $roles]);
|
||||
});
|
||||
}
|
||||
|
||||
public function decide(User $user, Requisition $requisition, string $decision, ?string $comment): void
|
||||
{
|
||||
DB::transaction(function () use ($user, $requisition, $decision, $comment) {
|
||||
app(ConflictGuard::class)->lockCompany($user);
|
||||
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
||||
$requisition->load('steps');
|
||||
abort_unless($requisition->canDecide($user), 403);
|
||||
$step = $requisition->steps->firstWhere('decision', 'pending');
|
||||
abort_if($step === null, 409);
|
||||
$step->update(['decision' => $decision, 'approver_id' => $user->id, 'comment' => $comment, 'decided_at' => now()]);
|
||||
$status = $decision === 'rejected' ? 'rejected' : ($requisition->steps()->where('decision', 'pending')->exists() ? 'pending' : 'approved');
|
||||
$requisition->update(['status' => $status]);
|
||||
$this->audit($user, 'approval.'.$decision, $requisition, ['status' => 'pending'], ['status' => $status, 'step_id' => $step->id, 'role' => $step->role, 'comment' => $comment]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $before
|
||||
* @param array<string, mixed> $after
|
||||
*/
|
||||
public function audit(User $user, string $action, ?Requisition $requisition, ?array $before, array $after): void
|
||||
{
|
||||
AuditLog::query()->create([
|
||||
'tenant_id' => $user->tenant_id, 'user_id' => $user->id,
|
||||
'requisition_id' => $requisition?->id, 'action' => $action,
|
||||
'ip_address' => request()->ip(), 'before' => $before, 'after' => $after,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user