Files
compras/app/Services/PurchaseRevisionWorkflow.php
T

143 lines
8.9 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PurchasingRole;
use App\Models\AwardCancellation;
use App\Models\BudgetEntry;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchaseRevision;
use App\Models\Requisition;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PurchaseRevisionWorkflow
{
private function invalid(string $message): never
{
throw ValidationException::withMessages(['cancellation' => $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;
});
}
}