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>, ready: bool, top_ids: list, 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 */ 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.']); } } }