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(); } }