200 lines
13 KiB
PHP
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;
|
|
});
|
|
}
|
|
}
|