78 lines
5.0 KiB
PHP
78 lines
5.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\PurchasingRole;
|
|
use App\Models\ProposalOpening;
|
|
use App\Models\PurchaseAward;
|
|
use App\Models\Quotation;
|
|
use App\Models\QuotationItem;
|
|
use App\Models\Requisition;
|
|
use App\Models\Supplier;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class ProposalOpeningWorkflow
|
|
{
|
|
/** @return list<array<string,mixed>> */
|
|
public function proposals(Requisition $requisition): array
|
|
{
|
|
return array_values(Quotation::query()->where('requisition_id', $requisition->id)->with(['supplier', 'items' => fn ($query) => $query->orderBy('id')])->orderBy('id')->get()->map(fn (Quotation $quote) => [
|
|
...$quote->only(['id', 'reference', 'currency', 'subtotal_minor', 'tax_minor', 'total_minor', 'delivery_days', 'valid_until', 'payment_terms']),
|
|
'supplier' => $quote->supplier?->only(['id', 'name', 'nuit']),
|
|
'items' => $quote->items->map(fn ($item) => $item->only(['requisition_item_id', 'description', 'quantity', 'unit_price_minor', 'tax_rate', 'subtotal_minor', 'tax_minor', 'total_minor']))->all(),
|
|
])->all());
|
|
}
|
|
|
|
/** @param list<array<string,mixed>> $proposals */
|
|
public function basis(array $proposals): string
|
|
{
|
|
return hash('sha256', json_encode($proposals, JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function record(User $actor, Requisition $requisition, array $data): ProposalOpening
|
|
{
|
|
return DB::transaction(function () use ($actor, $requisition, $data) {
|
|
app(ConflictGuard::class)->lockCompany($actor);
|
|
$actor = app(CurrentActor::class)->lock($actor, [PurchasingRole::Procurement]);
|
|
$requisition = Requisition::query()->lockForUpdate()->findOrFail($requisition->id);
|
|
app(ConflictGuard::class)->assertParticipation($actor, $requisition);
|
|
if ($requisition->status !== 'approved' || PurchaseAward::query()->where('requisition_id', $requisition->id)->exists()) {
|
|
throw ValidationException::withMessages(['opening' => 'Registe a abertura num pedido aprovado, antes da adjudicação. O histórico posterior não é reescrito.']);
|
|
}
|
|
$latest = ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first();
|
|
if ((int) $data['previous_id'] !== ($latest->id ?? 0)) {
|
|
throw ValidationException::withMessages(['previous_id' => 'A acta mudou. Actualize a página antes de registar outra versão.']);
|
|
}
|
|
$quotes = Quotation::query()->where('requisition_id', $requisition->id)->orderBy('id')->lockForUpdate()->get(['id', 'supplier_id']);
|
|
Supplier::query()->whereIn('id', $quotes->pluck('supplier_id'))->orderBy('id')->lockForUpdate()->get();
|
|
QuotationItem::query()->whereIn('quotation_id', $quotes->pluck('id'))->orderBy('id')->lockForUpdate()->get();
|
|
$proposals = $this->proposals($requisition);
|
|
if ($data['basis_hash'] !== $this->basis($proposals)) {
|
|
throw ValidationException::withMessages(['basis_hash' => 'As propostas mudaram desde a consulta. Confira os dados actualizados antes de registar.']);
|
|
}
|
|
$selected = array_values(array_filter($proposals, fn ($proposal) => in_array($proposal['id'], $data['quotation_ids'], true)));
|
|
if ($selected === [] || count($selected) !== count($data['quotation_ids'])) {
|
|
throw ValidationException::withMessages(['quotation_ids' => 'Seleccione apenas propostas deste processo, sem repetições.']);
|
|
}
|
|
foreach ($selected as $proposal) {
|
|
app(ConflictGuard::class)->assertProcess($requisition, $proposal['supplier']['id'], $actor);
|
|
}
|
|
$opening = ProposalOpening::query()->create([
|
|
'tenant_id' => $actor->tenant_id, 'requisition_id' => $requisition->id, 'created_by' => $actor->id, 'revision' => ($latest->revision ?? 0) + 1,
|
|
'opened_at' => $data['opened_at'], 'timezone' => $data['timezone'], 'submission_deadline' => $data['submission_deadline'] ?? null,
|
|
'reference' => $data['reference'], 'location' => $data['location'], 'reason' => $data['reason'], 'notes' => $data['notes'] ?? null,
|
|
'participants' => $data['participants'], 'annexes' => $data['annexes'] ?? [], 'basis_hash' => $this->basis($selected),
|
|
'snapshot' => ['company' => Tenant::query()->findOrFail($actor->tenant_id)->only(['id', 'name']), 'requisition' => $requisition->only(['id', 'number', 'justification']), 'author' => $actor->only(['id', 'name']), 'proposals' => $selected],
|
|
'created_at' => now(),
|
|
]);
|
|
app(RequisitionWorkflow::class)->audit($actor, 'opening.recorded', $requisition, null, ['opening_id' => $opening->id, 'revision' => $opening->revision, 'previous_id' => $latest?->id, 'quotation_ids' => $data['quotation_ids']]);
|
|
|
|
return $opening;
|
|
});
|
|
}
|
|
}
|