Files
compras/app/Http/Controllers/QuotationController.php
T
2026-11-02 20:27:11 +02:00

166 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\Supplier;
use App\Models\User;
use App\Services\ComparisonPolicy;
use App\Services\ConflictGuard;
use App\Services\CurrentActor;
use App\Services\ProposalComparison;
use App\Services\QuotationParticipation;
use App\Services\QuotationPricing;
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\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
class QuotationController extends Controller
{
private function access(Request $request): User
{
$actor = $request->user('web');
abort_unless($actor instanceof User && $actor->hasFunctions([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return $actor;
}
public function index(Request $request): Response
{
$actor = $this->access($request);
return Inertia::render('quotations/index', ['requisitions' => Requisition::query()->visibleTo($actor)->where('status', 'approved')->with('department:id,name')->latest()->paginate(15)]);
}
public function show(Request $request, Requisition $requisition): HttpResponse
{
$actor = $this->access($request);
Requisition::query()->visibleTo($actor)->findOrFail($requisition->id);
$filters = $request->validate(['quotation_id' => ['nullable', 'integer', Rule::exists('quotations', 'id')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $requisition->id)], 'prepare' => 'nullable|boolean']);
$access = app(QuotationParticipation::class);
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->with('supplier')->get()->keyBy('id');
$guard = app(ConflictGuard::class);
$result = app(ProposalComparison::class)->inspect($requisition);
$scores = array_map(function ($row) use ($quotes, $actor, $access, $requisition, $guard) {
$quote = $quotes->get($row['id']);
return [...$row, 'conflict_blocked' => $guard->processBlocked($requisition, $row['supplier_id'], $actor), 'canEvaluate' => $row['eligible'] && $quote !== null && $access->canEvaluate($actor, $requisition, $quote)];
}, $result['rows']);
Inertia::encryptHistory();
$response = 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' => $access->canPrepare($actor, $requisition),
'canEvaluate' => $access->canEvaluateProcess($actor, $requisition),
'focusQuotationId' => isset($filters['quotation_id']) ? (int) $filters['quotation_id'] : null,
'prepare' => (bool) ($filters['prepare'] ?? false),
'ready' => $result['ready'],
'topIds' => $result['top_ids'],
'comparisonPolicy' => $result['policy'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, Requisition $requisition, RequisitionWorkflow $workflow): RedirectResponse
{
$actor = $this->access($request);
abort_unless($actor->hasFunctions([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'],
'items.*.discount_type' => 'sometimes|required|in:none,percent,amount',
'items.*.discount_value' => 'required_with:items.*.discount_type|numeric|decimal:0,2|min:0|max:1000000000',
]);
DB::transaction(function () use ($actor, $requisition, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
app(QuotationParticipation::class)->assertPrepare($actor, $requisition);
$supplier = Supplier::query()->whereKey($data['supplier_id'])->lockForUpdate()->firstOrFail();
$guard->assertProcess($requisition, $supplier->id, $actor);
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 = [];
$terms = [];
$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.']);
}
$pricing = app(QuotationPricing::class)->unit((string) $input['unit_price'], $input['discount_type'] ?? 'none', (string) ($input['discount_value'] ?? '0'));
$terms[] = ['requisition_item_id' => $line->id, ...$pricing];
$price = $pricing['net_unit_minor'];
$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' => $actor->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' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'supplier_id' => $supplier->id, 'created_by' => $actor->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' => app(ComparisonPolicy::class)->forRequest($requisition)['weights'],
'commercial_terms' => array_filter($terms, fn ($term) => $term['discount_type'] !== 'none') === [] ? null : ['version' => 1, 'discount_basis' => 'unit_before_vat', 'rounding' => 'net_unit_half_up_cent', 'lines' => $terms],
]);
$quote->items()->createMany($items);
$workflow->audit($actor, 'quotation.created', $requisition, null, [...$quote->load('items')->toArray(), 'no_conflict' => true]);
});
return back();
}
public function evaluate(Request $request, Quotation $quotation, RequisitionWorkflow $workflow): RedirectResponse
{
$actor = $this->access($request);
abort_unless($actor->hasFunctions([PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 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 ($actor, $quotation, $data, $workflow) {
$guard = app(ConflictGuard::class);
$guard->lockCompany($actor);
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]);
$requisition = Requisition::query()->visibleTo($actor)->lockForUpdate()->findOrFail($quotation->requisition_id);
$quotation = Quotation::query()->lockForUpdate()->findOrFail($quotation->id);
app(QuotationParticipation::class)->assertEvaluate($actor, $requisition, $quotation);
$evaluation = ProposalEvaluation::query()->create([...$data, 'no_conflict' => true, 'tenant_id' => $actor->tenant_id, 'quotation_id' => $quotation->id, 'evaluator_id' => $actor->id]);
$workflow->audit($actor, 'quotation.evaluated', $requisition, null, $evaluation->toArray());
});
return back();
}
}