Regista abertura de propostas e actas imutaveis por versao

This commit is contained in:
2026-10-28 18:50:02 +02:00
parent 513f430edf
commit 07e775c492
21 changed files with 1404 additions and 4 deletions
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PurchasingRole;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\Requisition;
use App\Services\ConflictGuard;
use App\Services\ProposalOpeningWorkflow;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
class ProposalOpeningController extends Controller
{
private function access(Request $request, int $requisitionId): Requisition
{
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance, PurchasingRole::DepartmentHead]), 403);
return Requisition::query()->visibleTo($request->user())->findOrFail($requisitionId);
}
public function show(Request $request, Requisition $requisition, ProposalOpeningWorkflow $workflow): Response
{
$this->access($request, $requisition->id);
$proposals = $workflow->proposals($requisition);
$query = ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision');
$latest = (clone $query)->first();
$openings = $query->paginate(10)->withQueryString();
Inertia::encryptHistory();
$response = Inertia::render('purchases/opening', [
'requisition' => $requisition->only(['id', 'number', 'justification']), 'proposals' => $proposals, 'basisHash' => $workflow->basis($proposals), 'openings' => $openings,
'sourceChanged' => $latest && $workflow->basis($proposals) !== $latest->basis_hash,
'latestId' => $latest->id ?? 0,
'canRecord' => $request->user()->purchasing_role === PurchasingRole::Procurement && $requisition->status === 'approved' && ! PurchaseAward::query()->where('requisition_id', $requisition->id)->exists() && ! app(ConflictGuard::class)->participationBlocked($request->user(), $requisition),
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
public function store(Request $request, Requisition $requisition, ProposalOpeningWorkflow $workflow): RedirectResponse
{
$this->access($request, $requisition->id);
$data = $request->validate([
'previous_id' => 'required|integer|min:0', 'basis_hash' => 'required|string|size:64',
'opened_at' => 'required|date_format:Y-m-d\TH:i',
'timezone' => 'required|in:Africa/Maputo,Africa/Johannesburg,UTC',
'submission_deadline' => 'nullable|date_format:Y-m-d\TH:i|before_or_equal:opened_at',
'reference' => 'required|string|min:3|max:150', 'location' => 'required|string|min:3|max:300',
'reason' => 'required|string|min:10|max:4000', 'notes' => 'nullable|string|max:6000',
'quotation_ids' => 'required|array|min:1|max:100', 'quotation_ids.*' => 'required|integer|distinct',
'participants' => 'required|array|min:1|max:50', 'participants.*.name' => 'required|string|min:2|max:150',
'participants.*' => 'required|array:name,capacity,group',
'participants.*.capacity' => 'required|string|min:2|max:150', 'participants.*.group' => 'required|in:jury,participant',
'annexes' => 'nullable|array|max:30', 'annexes.*' => 'required|string|min:3|max:300',
'snapshot' => 'prohibited', 'tenant_id' => 'prohibited', 'created_by' => 'prohibited',
'confirmed' => 'accepted',
]);
$opened = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $data['opened_at'], $data['timezone']);
if (! $opened || $opened->isFuture()) {
throw ValidationException::withMessages(['opened_at' => 'A sessão não pode estar no futuro no fuso horário indicado.']);
}
$data['opened_at'] = $opened->utc()->format('Y-m-d H:i:s');
$data['submission_deadline'] = empty($data['submission_deadline']) ? null : CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $data['submission_deadline'], $data['timezone'])->utc()->format('Y-m-d H:i:s');
$data['quotation_ids'] = array_map(intval(...), $data['quotation_ids']);
$workflow->record($request->user(), $requisition, $data);
return back();
}
public function print(Request $request, ProposalOpening $opening): Response
{
$this->access($request, $opening->requisition_id);
$response = response()->view('purchases.opening', ['opening' => $opening, 'snapshot' => $opening->snapshot]);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
return $response;
}
}
@@ -9,6 +9,7 @@ use App\Models\BudgetEntry;
use App\Models\ExternalPayment;
use App\Models\GoodsReceipt;
use App\Models\Invoice;
use App\Models\ProposalOpening;
use App\Models\PurchaseAward;
use App\Models\PurchaseOrder;
use App\Models\PurchasePlanItem;
@@ -35,6 +36,10 @@ class PurchaseProcessController extends Controller
$base = '/requisicoes/'.$requisition->id;
$stages = [];
$documents = [['label' => $requisition->number, 'kind' => 'Requisição', 'href' => $base]];
$opening = $purchases ? ProposalOpening::query()->where('requisition_id', $requisition->id)->latest('revision')->first(['id', 'revision']) : null;
if ($opening) {
$documents[] = ['label' => 'Acta de abertura · Versão '.$opening->revision, 'kind' => 'Abertura', 'href' => $base.'/abertura'];
}
$planning = PurchasePlanItem::query()->visibleTo($actor)->where('requisition_id', $requisition->id)->first(['id', 'title']);
if ($planning) {
$documents[] = ['label' => $planning->title, 'kind' => 'Planeamento', 'href' => '/planeamento?item='.$planning->id];
@@ -98,7 +103,7 @@ class PurchaseProcessController extends Controller
}
$actions = ['requisition.created', 'requisition.updated', 'requisition.submitted', 'approval.approved', 'approval.rejected', 'equipment.technical_approved', 'equipment.technical_rejected', 'requisition.revision_created'];
if ($purchases) {
$actions = [...$actions, 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
$actions = [...$actions, 'opening.recorded', 'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued', 'cancellation.requested', 'cancellation.approved', 'cancellation.rejected'];
}
if ($inventory) {
$actions[] = 'receipt.confirmed';
@@ -113,7 +118,7 @@ class PurchaseProcessController extends Controller
'stages' => $stages, 'documents' => $documents, 'decisions' => $decisions, 'related' => $related,
'cancellation' => $cancellation ? ['status' => $cancellation->status, 'href' => '/revisao-compras'] : null,
'history' => AuditLog::query()->where('requisition_id', $requisition->id)->whereIn('action', $actions)->orderBy('id')->get(['id', 'action', 'created_at']),
'future' => ['Sessão de abertura e acta', 'Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
'future' => ['Qualificação específica do concurso', 'Relatório de recomendação em etapas'],
])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Models\Concerns\AppendOnlyInventoryRecord;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class ProposalOpening extends Model
{
use AppendOnlyInventoryRecord, BelongsToTenant;
public $timestamps = false;
protected $guarded = ['id'];
protected function casts(): array
{
return ['participants' => 'array', 'annexes' => 'array', 'snapshot' => 'array'];
}
}
+1
View File
@@ -84,6 +84,7 @@ class ConflictGuard
'requisition.created', 'requisition.updated', 'requisition.submitted',
'approval.approved', 'approval.rejected', 'quotation.created', 'quotation.evaluated',
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
'opening.recorded',
'equipment.technical_approved', 'equipment.technical_rejected',
'invoice.created', 'invoice.validated',
'payment.registered', 'payment.confirmed', 'payment.rejected',
+77
View File
@@ -0,0 +1,77 @@
<?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;
});
}
}