tenant_id !== $actor->tenant_id || $record->status !== 'approved' || $record->revised_requisition_id || app(OrganizationAccess::class)->maintenanceOnly($actor) || ! app(OrganizationAccess::class)->canRequest($actor)) { return false; } $order = PurchaseOrder::query()->visibleTo($actor)->find($record->purchase_order_id); return $order && Requisition::query()->whereKey($order->requisition_id)->where('requester_id', $actor->id)->where('department_id', $actor->department_id)->exists(); } public function canRequest(User $actor, PurchaseOrder $order): bool { if (! config('order_cancellation.enabled') || ! Schema::hasTable('order_cancellations') || ! $actor->hasFunctions([PurchasingRole::Procurement]) || app(OrganizationAccess::class)->maintenanceOnly($actor) || $actor->tenant_id !== $order->tenant_id || $this->state($order) !== 'issued') { return false; } $request = Requisition::query()->visibleTo($actor)->find($order->requisition_id); $policy = app(OrderCancellationPolicyWorkflow::class)->current(); return $request && $request->requester_id !== $actor->id && $policy && $policy->enabled && $policy->mode === 'issued_order' && $policy->valid_until >= today()->format('Y-m-d') && ! GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists() && ! Invoice::query()->where('purchase_order_id', $order->id)->exists() && ! app(ConflictGuard::class)->processBlocked($request, $order->supplier_id, $actor); } public function canDecide(User $actor, OrderCancellation $record): bool { if (! config('order_cancellation.enabled') || $record->tenant_id !== $actor->tenant_id || $record->status !== 'pending' || ! $actor->hasFunctions([PurchasingRole::Finance]) || app(OrganizationAccess::class)->maintenanceOnly($actor)) { return false; } $order = PurchaseOrder::query()->visibleTo($actor)->find($record->purchase_order_id); $request = $order ? Requisition::query()->find($order->requisition_id) : null; $award = $order ? PurchaseAward::query()->find($order->purchase_award_id) : null; return $order && $request && $award && ! in_array($actor->id, [$record->created_by, $request->requester_id, $award->created_by, $order->issued_by], true); } private function enabled(): void { abort_unless(config('order_cancellation.enabled') && Schema::hasTable('order_cancellations'), 503, 'Cancelamento de ordens não activado.'); } public function state(PurchaseOrder $order): string { if (! Schema::hasTable('order_cancellations')) { return $order->status; } $states = OrderCancellation::query()->where('purchase_order_id', $order->id)->pluck('status'); return $states->contains('approved') ? 'cancelled' : ($states->contains('pending') ? 'cancellation_pending' : $order->status); } /** Holds survive disabling the feature; hiding a command must not unblock an order. */ public function assertOperational(PurchaseOrder $order): void { if (in_array($this->state($order), ['cancelled', 'cancellation_pending'], true)) { throw ValidationException::withMessages(['order' => 'Ordem cancelada ou com pedido de cancelamento pendente. Não é permitida nova execução.']); } } private function source(PurchaseOrder $order): void { if ($order->getRawOriginal('status') !== 'issued' || $order->currency !== 'MZN' || GoodsReceipt::query()->where('purchase_order_id', $order->id)->exists() || Invoice::query()->where('purchase_order_id', $order->id)->exists()) { throw ValidationException::withMessages(['order' => 'Este circuito exige ordem emitida em MZN sem qualquer recepção, factura ou pagamento.']); } } private function basis(PurchaseOrder $order): string { return hash('sha256', json_encode(['order' => $order->getAttributes(), 'items' => $order->items()->orderBy('id')->get()->toArray(), 'budget' => DB::table('budget_entries')->where('tenant_id', $order->tenant_id)->where('requisition_id', $order->requisition_id)->orderBy('id')->get()->toArray()], JSON_THROW_ON_ERROR)); } /** @param array $input */ public function request(User $actor, PurchaseOrder $order, array $input): OrderCancellation { $this->enabled(); $data = validator($input, ['operation_key' => 'required|uuid', 'reason' => 'required|string|min:10|max:4000', 'evidence' => 'required|string|min:10|max:4000'])->validate(); $data['operation_key'] = strtolower($data['operation_key']); $hash = hash('sha256', json_encode([$order->id, $data], JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $order, $data, $hash) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]); abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $order = PurchaseOrder::query()->visibleTo($actor)->lockForUpdate()->findOrFail($order->id); $request = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id); abort_if($actor->id === $request->requester_id, 403); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $actor); $old = OrderCancellation::query()->where('created_by', $actor->id)->where('operation_key', $data['operation_key'])->first(); if ($old) { abort_unless($old->input_hash === $hash, 422, 'Chave utilizada com outro conteúdo.'); return $old; } $this->assertOperational($order); $this->source($order); $policy = app(OrderCancellationPolicyWorkflow::class)->requireCurrent(); $record = OrderCancellation::query()->create(['tenant_id' => $actor->tenant_id, 'purchase_order_id' => $order->id, 'policy_id' => $policy->id, 'created_by' => $actor->id, ...$data, 'input_hash' => $hash, 'basis_hash' => $this->basis($order), 'snapshot' => ['order_number' => $order->number, 'policy' => $policy->toArray(), 'original_order_status' => $order->status]]); app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.requested', $request, null, ['cancellation_id' => $record->id, 'order_id' => $order->id]); return $record; }); } public function decide(User $actor, OrderCancellation $record, string $decision, string $reason): void { $this->enabled(); validator(['decision' => $decision, 'reason' => $reason], ['decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:10|max:4000'])->validate(); DB::transaction(function () use ($actor, $record, $decision, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Finance]); abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $record = OrderCancellation::query()->lockForUpdate()->findOrFail($record->id); $order = PurchaseOrder::query()->visibleTo($actor)->lockForUpdate()->findOrFail($record->purchase_order_id); $request = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id); $award = PurchaseAward::query()->findOrFail($order->purchase_award_id); abort_if(in_array($actor->id, [$record->created_by, $request->requester_id, $award->created_by, $order->issued_by], true), 403); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $actor); if ($record->status !== 'pending') { abort_unless($record->status === $decision && $record->review_reason === $reason && $record->reviewed_by === $actor->id, 409, 'Decisão imutável.'); return; } $entry = null; if ($decision === 'approved') { $this->source($order); abort_unless($record->basis_hash === $this->basis($order), 409, 'A base da ordem ou do compromisso mudou.'); $policy = app(OrderCancellationPolicyWorkflow::class)->requireCurrent(); abort_unless($policy->id === $record->policy_id, 409, 'Política alterada; rejeite o pedido para retomar o circuito.'); abort_unless(DB::table('tenants')->where('id', $actor->tenant_id)->value('currency') === 'MZN', 422); if (! empty($order->snapshot['budget_control']) && ! DB::table('budget_entries')->where('tenant_id', $actor->tenant_id)->where('requisition_id', $request->id)->exists()) { throw ValidationException::withMessages(['budget' => 'A ordem indica controlo orçamental, mas o compromisso não está disponível.']); } $path = base_path('packages/order-cancellation/src/CommitmentReversal.php'); abort_unless(is_file($path) && hash_file('sha256', $path) === config('order_cancellation.reversal_sha256'), 503, 'Capacidade de reversão indisponível.'); require_once $path; try { $entry = (new CommitmentReversal)->reverse(DB::connection(), new BudgetContext($actor->id, ['tenant_id' => (int) $actor->tenant_id], (int) now()->year, 'MZN'), $request->id, $order->id); } catch (\LogicException $error) { throw ValidationException::withMessages(['budget' => 'O compromisso não está integralmente disponível para reversão ou a sua base é inconsistente. Reveja o controlo orçamental.']); } $request->update(['status' => 'cancelled']); } $record->update(['status' => $decision, 'reviewed_by' => $actor->id, 'review_reason' => $reason, 'reviewed_at' => now()]); app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.'.$decision, $request, null, ['cancellation_id' => $record->id, 'order_id' => $order->id, 'budget_reversal_entry_id' => $entry]); }); } public function revise(User $actor, OrderCancellation $record, string $neededBy, string $reason): Requisition { $this->enabled(); validator(['needed_by' => $neededBy, 'reason' => $reason], ['needed_by' => 'required|date_format:Y-m-d|after_or_equal:today', 'reason' => 'required|string|min:10|max:4000'])->validate(); return DB::transaction(function () use ($actor, $record, $neededBy, $reason) { app(ConflictGuard::class)->lockCompany($actor); $actor = app(CurrentActor::class)->lock($actor); abort_if(app(OrganizationAccess::class)->maintenanceOnly($actor), 403); $record = OrderCancellation::query()->lockForUpdate()->findOrFail($record->id); $order = PurchaseOrder::query()->findOrFail($record->purchase_order_id); $source = Requisition::query()->lockForUpdate()->findOrFail($order->requisition_id); abort_unless($actor->id === $source->requester_id && $actor->department_id === $source->department_id && $record->status === 'approved', 403); if ($record->revised_requisition_id) { return Requisition::query()->findOrFail($record->revised_requisition_id); } $items = $source->items()->get()->map(fn ($item) => [...$item->only(['description', 'category', 'purchase_type', 'quantity', 'unit', 'tax_rate', 'production_area', 'equipment_id', 'spare_part_id']), 'unit_price' => BigDecimal::of($item->unit_price_minor)->dividedBy(100, 2)->__toString()])->all(); $revised = app(RequisitionWorkflow::class)->save($actor, ['cost_center_id' => $source->cost_center_id, 'priority' => $source->priority, 'needed_by' => $neededBy, 'justification' => $reason, 'items' => $items]); $record->update(['revised_requisition_id' => $revised->id]); app(RequisitionWorkflow::class)->audit($actor, 'order_cancellation.revision_created', $revised, null, ['source_requisition_id' => $source->id, 'cancellation_id' => $record->id]); return $revised; }); } }