Files
compras/app/Services/OrderCancellationWorkflow.php
nala 63d20b10fc
tests / ci (push) Canceled after 0s
commit de armazens
2026-11-04 09:40:26 +02:00

200 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\OrderCancellation;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Tempo\Finance\Budget\BudgetContext;
use Tempo\Finance\OrderCancellation\CommitmentReversal;
class OrderCancellationWorkflow
{
public function canRevise(User $actor, OrderCancellation $record): bool
{
if (! config('order_cancellation.enabled') || $record->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<string,mixed> $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;
});
}
}