$message]); } private function noOrder(PurchaseAward $award): void { if (PurchaseOrder::query()->where(fn ($query) => $query->where('purchase_award_id', $award->id)->orWhere('requisition_id', $award->requisition_id))->exists()) { $this->invalid('Existe ordem emitida. Este circuito não cancela ordens, recepções, facturas ou pagamentos.'); } } private function basis(PurchaseAward $award): string { return hash('sha256', json_encode($award->only(['id', 'requisition_id', 'quotation_id', 'status', 'subtotal_minor', 'tax_minor', 'total_minor', 'comparison_hash', 'approval_snapshot', 'investment_snapshot', 'delivery_date', 'delivery_location']), JSON_THROW_ON_ERROR)); } public function request(User $actor, PurchaseAward $award, string $reason): AwardCancellation { abort_unless($actor->tenant_id === $award->tenant_id, 403); return DB::transaction(function () use ($actor, $award, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); $request = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id); $award = PurchaseAward::query()->lockForUpdate()->findOrFail($award->id); abort_if($request->requester_id === $actor->id, 403); app(ConflictGuard::class)->assertParticipation($actor, $request); $this->noOrder($award); $existing = AwardCancellation::query()->where('purchase_award_id', $award->id)->first(); if ($existing) { if ($existing->created_by !== $actor->id || $existing->reason !== trim($reason)) { $this->invalid('Já existe pedido ou decisão para esta adjudicação. O histórico não pode ser substituído.'); } return $existing; } $policy = app(PurchaseControlPolicyWorkflow::class)->requireCurrent(); if (! in_array($award->status, ['pending', 'approved', 'rejected']) || trim($reason) === '') { $this->invalid('Estado ou fundamento inválido para cancelamento.'); } $cancellation = AwardCancellation::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_award_id' => $award->id, 'purchase_control_policy_id' => $policy->id, 'created_by' => $actor->id, 'reason' => trim($reason), 'basis_hash' => $this->basis($award), 'snapshot' => ['policy' => $policy->only(['id', 'revision', 'mode', 'enabled', 'valid_from', 'valid_until']), 'award' => $award->toArray(), 'requisition_id' => $request->id, 'development_only' => true]]); app(RequisitionWorkflow::class)->audit($actor, 'cancellation.requested', $request, null, ['cancellation_id' => $cancellation->id, 'award_id' => $award->id, 'policy_id' => $policy->id]); return $cancellation; }); } public function review(User $actor, AwardCancellation $cancellation, string $decision, string $reason): void { abort_unless($actor->tenant_id === $cancellation->tenant_id, 403); if (! in_array($decision, ['approved', 'rejected']) || trim($reason) === '') { $this->invalid('Decisão e fundamento obrigatórios.'); } DB::transaction(function () use ($actor, $cancellation, $decision, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance]); $source = PurchaseAward::query()->findOrFail($cancellation->purchase_award_id); $request = Requisition::query()->lockForUpdate()->findOrFail($source->requisition_id); $award = PurchaseAward::query()->lockForUpdate()->findOrFail($source->id); $cancellation = AwardCancellation::query()->lockForUpdate()->findOrFail($cancellation->id); abort_if(in_array($actor->id, [$cancellation->created_by, $request->requester_id, $award->created_by], true), 403); app(ConflictGuard::class)->assertParticipation($actor, $request); if ($cancellation->status !== 'pending') { if ($cancellation->status !== $decision || $cancellation->review_reason !== trim($reason)) { $this->invalid('Decisão de cancelamento imutável.'); } return; } $this->noOrder($award); if ($cancellation->basis_hash !== $this->basis($award)) { $this->invalid('A base da adjudicação mudou. Não é permitido cancelar com uma base desactualizada.'); } if ($decision === 'approved') { $policy = app(PurchaseControlPolicyWorkflow::class)->requireCurrent(); if ($policy->id !== $cancellation->purchase_control_policy_id) { $this->invalid('A política da empresa mudou. A aprovação deste pedido está bloqueada; pode rejeitá-lo para retomar o processo.'); } if ((int) BudgetEntry::query()->where('requisition_id', $request->id)->sum('committed_minor') !== 0) { $this->invalid('Existe compromisso financeiro. Este circuito só liberta reservas não comprometidas.'); } if (BudgetEntry::query()->where('requisition_id', $request->id)->exists()) { app(BudgetWorkflow::class)->record($actor, $request, 'release'); } $award->update(['status' => 'cancelled']); $request->update(['status' => 'cancelled']); } $cancellation->update(['status' => $decision, 'reviewed_by' => $actor->id, 'review_reason' => trim($reason), 'reviewed_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'cancellation.'.$decision, $request, null, ['cancellation_id' => $cancellation->id, 'award_id' => $award->id, 'reservation_released' => $decision === 'approved']); }); } public function revise(User $actor, AwardCancellation $cancellation, string $neededBy, string $justification): Requisition { abort_unless($actor->tenant_id === $cancellation->tenant_id, 403); return DB::transaction(function () use ($actor, $cancellation, $neededBy, $justification) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor); $cancellation = AwardCancellation::query()->lockForUpdate()->findOrFail($cancellation->id); $award = PurchaseAward::query()->findOrFail($cancellation->purchase_award_id); $source = Requisition::query()->lockForUpdate()->findOrFail($award->requisition_id); $this->noOrder($award); abort_unless($source->requester_id === $actor->id && $source->department_id === $actor->department_id, 403); if ($cancellation->status !== 'approved' || $source->status !== 'cancelled' || $award->status !== 'cancelled') { $this->invalid('Só o solicitante pode preparar revisão após cancelamento aprovado.'); } $existing = PurchaseRevision::query()->where('award_cancellation_id', $cancellation->id)->first(); if ($existing) { return Requisition::query()->findOrFail($existing->requisition_id); } $items = []; foreach ($source->items()->orderBy('id')->get() as $line) { $items[] = [...$line->only(['description', 'category', 'purchase_type', 'quantity', 'unit', 'tax_rate', 'equipment_id', 'spare_part_id', 'production_area']), 'unit_price' => (string) BigDecimal::of($line->unit_price_minor)->dividedBy(100, 2), 'downtime_started_at' => $line->downtime_started_at ? substr(str_replace(' ', 'T', $line->downtime_started_at), 0, 16) : null]; } $revised = app(RequisitionWorkflow::class)->save($actor, ['cost_center_id' => $source->cost_center_id, 'priority' => $source->priority, 'needed_by' => $neededBy, 'justification' => $justification, 'items' => $items]); PurchaseRevision::query()->create(['tenant_id' => $actor->tenant_id, 'award_cancellation_id' => $cancellation->id, 'source_requisition_id' => $source->id, 'requisition_id' => $revised->id, 'created_by' => $actor->id, 'created_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'requisition.revision_created', $revised, null, ['source_requisition_id' => $source->id, 'cancellation_id' => $cancellation->id]); return $revised; }); } }