149 lines
9.5 KiB
PHP
149 lines
9.5 KiB
PHP
<?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();
|
|
}
|
|
}
|