143 lines
8.9 KiB
PHP
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;
|
|
});
|
|
}
|
|
}
|