feat: add QA tender and announcement publishing workflow

This commit is contained in:
2026-10-22 22:41:28 +02:00
parent ef0c2acd36
commit 1bcf34c0c0
19 changed files with 2247 additions and 0 deletions
@@ -0,0 +1,138 @@
<?php
namespace App\Http\Controllers;
use App\Services\Procurement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
class ProcurementController extends Controller
{
private const STATE_SQL = "CASE WHEN v.payload->>'procedural_state' IN ('cancelled','results') THEN v.payload->>'procedural_state' WHEN ((v.payload->>'deadline')::timestamp AT TIME ZONE (v.payload->>'timezone'))<=CURRENT_TIMESTAMP THEN 'deadline_ended' WHEN v.payload->>'extended'='true' THEN 'extended' ELSE 'open' END";
private function query(Procurement $service)
{
abort_unless($service->available(), 404);
return DB::table('portal_tenders as t')->join('portal_tender_versions as v', fn ($q) => $q->on('v.id', '=', 't.published_version_id')->on('v.tender_id', '=', 't.id'))->whereNotNull('v.published_at');
}
public function index(Request $request, Procurement $service)
{
$filters = $request->validate(['q' => 'nullable|string|max:150', 'entity' => 'nullable|uuid', 'sector' => 'nullable|integer', 'territory' => 'nullable|uuid', 'modality' => 'nullable|string|max:200', 'financier' => 'nullable|string|max:200', 'state' => 'nullable|in:open,extended,deadline_ended,cancelled,results', 'deadline_before' => 'nullable|date_format:Y-m-d', 'regime' => 'nullable|in:state,external,private,other']);
$filters = array_filter($filters, fn ($value) => $value !== null && $value !== '');
$query = $this->query($service);
if (! empty($filters['q'])) {
$query->where(fn ($q) => $q->whereRaw("v.payload->>'title' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'object' ILIKE ?", ['%'.$filters['q'].'%'])->orWhereRaw("v.payload->>'reference' ILIKE ?", ['%'.$filters['q'].'%']));
}
foreach (['entity' => 'organization_id', 'sector' => 'sector_id', 'modality' => 'modality', 'financier' => 'financier', 'regime' => 'regime'] as $filter => $field) {
if (! empty($filters[$filter])) {
$query->whereRaw('v.payload->>? = ?', [$field, (string) $filters[$filter]]);
}
}
if (! empty($filters['territory'])) {
$query->whereRaw("v.payload->>'territory_id' IN (WITH RECURSIVE scope(id,depth) AS (SELECT id,0 FROM geo_locations WHERE id=? AND deleted_at IS NULL UNION ALL SELECT c.id,s.depth+1 FROM geo_locations c JOIN scope s ON c.parent_id=s.id WHERE c.deleted_at IS NULL AND s.depth<4) SELECT id::text FROM scope)", [$filters['territory']]);
}
if (! empty($filters['state'])) {
$query->whereRaw(self::STATE_SQL.' = ?', [$filters['state']]);
}
if (! empty($filters['deadline_before'])) {
$query->whereRaw("left(v.payload->>'deadline',10)<=?", [$filters['deadline_before']]);
}
$rows = $query->select('t.id', 'v.id as vid')->orderByDesc('v.published_at')->paginate(20)->withQueryString();
$rows->through(fn ($r) => ['id' => $r->id, ...$this->summary($service, $r->vid)]);
$facets = $this->query($service)->get(['v.payload'])->map(fn ($r) => json_decode($r->payload, true));
$options = [];
foreach (['entity' => ['organization_id', 'organization_name'], 'sector' => ['sector_id', 'sector_name'], 'territory' => ['territory_id', 'territory_name'], 'modality' => ['modality', 'modality'], 'financier' => ['financier', 'financier']] as $name => [$value,$label]) {
$options[$name] = $facets->filter(fn ($p) => ! empty($p[$value]))->map(fn ($p) => ['id' => (string) $p[$value], 'name' => $p[$label]])->unique('id')->values();
}
return Inertia::render('procurement/index', ['items' => $rows, 'filters' => $filters, 'options' => $options, 'regimes' => config('procurement.regimes'), 'management' => $service->access($request->user())])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
private function summary(Procurement $service, string $vid): array
{
$v = $service->publishedVersion($vid);
return ['publication' => $v, 'state' => $service->state($v)];
}
public function show(Request $request, string $id, Procurement $service)
{
return $this->detail($request, $id, $service, false);
}
public function print(Request $request, string $id, Procurement $service)
{
return $this->detail($request, $id, $service, true);
}
private function detail(Request $request, string $id, Procurement $service, bool $print)
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['t.id', 'v.id as vid']);
$filters = $request->only(['q', 'entity', 'sector', 'territory', 'modality', 'financier', 'state', 'deadline_before', 'regime', 'page']);
return Inertia::render('procurement/show', ['id' => $id, ...$this->summary($service, $row->vid), 'printMode' => $print, 'backUrl' => '/concursos'.($filters ? '?'.http_build_query($filters) : ''),
'history' => DB::table('portal_tender_versions')->where('tender_id', $id)->whereNotNull('published_at')->orderBy('version')->pluck('id')->map(fn ($vid) => $service->publishedVersion($vid)),
'watching' => $request->user() && DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->exists()])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function manage(Request $request, Procurement $service)
{
$service->guard($request->user());
return Inertia::render('procurement/manage', ['items' => DB::table('portal_tenders')->orderByDesc('updated_at')->get(['id', 'draft', 'version', 'published_version_id'])->map(fn ($r) => ['id' => $r->id, 'title' => json_decode($r->draft, true)['title'] ?? 'Rascunho sem título', 'version' => $r->version, 'published' => (bool) $r->published_version_id])])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function edit(Request $request, Procurement $service, ?string $id = null)
{
$service->guard($request->user());
$r = $id ? $service->record($id) : null;
return Inertia::render('procurement/edit', ['record' => $r ? ['id' => $id, 'version' => $r->version, 'draft' => json_decode($r->draft, true), 'published' => (bool) $r->published_version_id] : null,
'references' => $service->references($request->user()), 'history' => $id ? DB::table('portal_tender_versions')->where('tender_id', $id)->orderByDesc('version')->get(['version', 'act', 'reason', 'actor_id', 'published_at', 'created_at']) : []])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
public function save(Request $request, Procurement $service, ?string $id = null)
{
$id = $service->save($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Rascunho guardado. A publicação anterior permanece intacta.');
}
public function preview(Request $request, string $id, Procurement $service)
{
return response()->json($service->preview($request->user(), $id, (string) $request->input('act')))->header('Cache-Control', 'private, no-store');
}
public function publish(Request $request, string $id, Procurement $service)
{
$service->publish($request->user(), $id, $request->all());
return redirect('/management/concursos/'.$id)->with('success', 'Acto publicado, com versão e auditoria. Nenhuma proposta recebida pelo portal.');
}
public function watch(Request $request, string $id, Procurement $service)
{
$row = $this->query($service)->where('t.id', $id)->firstOrFail(['v.id']);
DB::table('portal_tender_watches')->updateOrInsert(['tender_id' => $id, 'user_id' => $request->user()->id], ['seen_version_id' => $row->id, 'created_at' => now(), 'updated_at' => now()]);
return back()->with('success', 'Concurso guardado para acompanhamento. Isto não apresenta uma proposta.');
}
public function unwatch(Request $request, string $id, Procurement $service)
{
abort_unless($service->available(), 404);
DB::table('portal_tender_watches')->where('tender_id', $id)->where('user_id', $request->user()->id)->delete();
return back();
}
public function watched(Request $request, Procurement $service)
{
$rows = $this->query($service)->join('portal_tender_watches as w', 'w.tender_id', '=', 't.id')->where('w.user_id', $request->user()->id)->get(['t.id', 'v.id as vid', 'w.seen_version_id'])->map(fn ($r) => ['id' => $r->id, 'changed' => $r->seen_version_id !== $r->vid, ...$this->summary($service, $r->vid)]);
return Inertia::render('procurement/manage', ['watched' => true, 'items' => $rows])->toResponse($request)->header('Cache-Control', 'private, no-store');
}
}
@@ -51,6 +51,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'name' => config('app.name'),
'procurementEnabled' => app(\App\Services\Procurement::class)->available(),
'homologationReserved' => (bool) config('portal.homologation_only', false),
'profileAccess' => app(PortalProfileAccess::class)->navigation($request->user()),
'contentManagementHref' => app(\App\Services\ContentManagement::class)->access($request->user()) !== [] ? '/gestao-conteudos' : null,
+1
View File
@@ -19,6 +19,7 @@ final class ContentManagement
$editor = $documents->full($actor) || $documents->editorial($actor);
$upload = $editor || $documents->limited($actor);
$result = [];
if (app(Procurement::class)->available() && app(Procurement::class)->access($actor)) $result['procurement'] = ['index' => '/management/concursos', 'create' => '/management/concursos/novo'];
if ($legacy || ($navigation['mine'] ?? null) || ($navigation['review'] ?? null)) {
$result['opportunities'] = ['index' => $legacy ? '/management/opportunities' : '/minhas-oportunidades',
'create' => $legacy ? '/management/opportunities/create' : ($navigation['create'] ?? null), 'profileWorkflow' => ! $legacy];
+287
View File
@@ -0,0 +1,287 @@
<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Publication of notices; never receives bids or changes the linked investment. */
final class Procurement
{
public const TEXT = ['title', 'reference', 'responsible_unit', 'modality', 'object', 'object_kind', 'quantities', 'financier', 'instrument', 'edition', 'rule_source', 'financing_agreement', 'eligibility', 'requirements', 'proposal_validity', 'guarantee_status', 'guarantees', 'delivery_channel', 'delivery_address', 'delivery_url', 'public_contact', 'dissemination', 'origin_url', 'origin_published_at', 'clarifications_at', 'deadline', 'opening_at', 'timezone', 'minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis', 'result', 'internal_notes', 'public_reason', 'minimum_working_days', 'minimum_holidays', 'minimum_calendar_source'];
public function available(): bool
{
return config('procurement.enabled') && Schema::hasTable('portal_tenders');
}
public function access(?User $actor): bool
{
if (! $actor || ! $actor->hasVerifiedEmail()) {
return false;
}
$docs = app(DocumentManagementAccess::class);
return ! app(PortalProfileAccess::class)->has($actor, 'auditor') && ($docs->full($actor) || $docs->editorial($actor));
}
public function guard(?User $actor): void
{
abort_unless($this->available(), 404);
abort_unless($this->access($actor), 403, 'Use a autorização editorial já atribuída.');
}
public function references(User $actor): array
{
$this->guard($actor);
$editable = app(DocumentManagementAccess::class)->documents($actor)->select('id');
return ['regimes' => config('procurement.regimes'), 'acts' => config('procurement.acts'),
'organizations' => DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->get(['id', 'name']),
'sectors' => DB::table('sectors')->orderBy('name')->get(['id', 'name']),
'territories' => app(InvestmentGeography::class)->references(true)->orderBy('name')->get(['id', 'name', 'location_type']),
'opportunities' => app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->orderBy('title')->get(['id', 'title']),
'projects' => DB::table('projects')->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->orderBy('name')->get(['id', 'name']),
'documents' => DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->whereNull('d.deleted_at')
->where(fn ($q) => $q->whereIn('d.id', $editable)->orWhere(fn ($p) => $p->where('r.published', true)->where('d.data_classification', 'public')))
->orderBy('r.title')->get(['r.id', 'r.title', 'r.published', 'd.malware_scan_status'])];
}
public function record(string $id): object
{
abort_unless($this->available(), 404);
return DB::table('portal_tenders')->where('id', $id)->firstOrFail();
}
public function save(User $actor, ?string $id, array $input): string
{
$this->guard($actor);
$v = Validator::make($input, ['version' => 'required|integer|min:0', 'reason' => 'required|string|min:20|max:1000', 'draft' => 'required|array']);
$v->validate();
$draft = $this->normalize($actor, $input['draft']);
return DB::transaction(function () use ($actor, $id, $input, $draft) {
$old = $id ? DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail() : null;
abort_unless((int) ($old->version ?? 0) === (int) $input['version'], 409, 'A versão mudou; recupere o concurso antes de guardar.');
$id ??= (string) Str::uuid();
$version = ($old->version ?? 0) + 1;
if ($old) {
DB::table('portal_tenders')->where('id', $id)->update(['draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'updated_at' => now()]);
} else {
DB::table('portal_tenders')->insert(['id' => $id, 'draft' => json_encode($draft, JSON_THROW_ON_ERROR), 'version' => $version, 'created_by' => $actor->id, 'created_at' => now(), 'updated_at' => now()]);
}
$this->version($actor, $id, $version, 'draft', $draft, $input['reason'], false);
return $id;
});
}
private function normalize(User $actor, array $input): array
{
$rules = array_fill_keys(self::TEXT, 'nullable|string|max:12000');
$rules = [...$rules, 'regime' => 'nullable|in:state,external,private,other', 'organization_id' => 'nullable|uuid|exists:organizations,id',
'opportunity_id' => 'nullable|integer|exists:opportunities,id', 'project_id' => 'nullable|uuid|exists:projects,id', 'sector_id' => 'nullable|integer|exists:sectors,id',
'territory_id' => 'nullable|uuid|exists:geo_locations,id', 'contact_authorized' => 'boolean', 'minimum_days' => 'nullable|integer|min:0|max:3650',
'lots' => 'array|max:100', 'lots.*' => 'array:code,description,quantities', 'lots.*.code' => 'required|string|max:80|distinct', 'lots.*.description' => 'required|string|max:3000', 'lots.*.quantities' => 'nullable|string|max:1500',
'attachments' => 'array|max:100', 'attachments.*' => 'array:resource_id,public,purpose', 'attachments.*.resource_id' => 'required|integer|exists:portal_resources,id', 'attachments.*.public' => 'required|boolean', 'attachments.*.purpose' => 'required|string|max:150'];
foreach (['origin_url', 'delivery_url', 'rule_source'] as $url) {
$rules[$url] = 'nullable|url:http,https|max:2000';
}
$data = Validator::make($input, $rules)->validate();
if (! empty($data['opportunity_id'])) {
abort_unless(app(PortalProfileAccess::class)->scope(Opportunity::query(), $actor)->whereKey($data['opportunity_id'])->exists(), 403);
}
if (! empty($data['project_id'])) {
abort_unless(DB::table('projects')->where('id', $data['project_id'])->whereNull('deleted_at')->whereIn('data_classification', ['internal', 'public'])->exists(), 422);
}
foreach ($data['attachments'] ?? [] as $attachment) {
$resource = DB::table('portal_resources')->where('id', $attachment['resource_id'])->firstOrFail();
abort_unless($this->publicDocuments()->where('r.id', $resource->id)->exists() || app(DocumentManagementAccess::class)->documents($actor)->where('id', $resource->document_id)->exists(), 403);
}
return $data;
}
public function preview(User $actor, string $id, string $act): array
{
$this->guard($actor);
$record = $this->record($id);
abort_unless(array_key_exists($act, config('procurement.acts')), 422);
$p = json_decode($record->draft, true, 512, JSON_THROW_ON_ERROR);
Validator::make($p, array_fill_keys(['title', 'reference', 'organization_id', 'modality', 'object', 'regime', 'instrument', 'edition', 'rule_source', 'origin_published_at', 'deadline', 'delivery_channel', 'timezone'], 'required'))->validate();
Validator::make($p, ['timezone' => 'timezone', 'object_kind' => 'nullable|in:works,goods,services,mixed,other', 'guarantee_status' => 'nullable|in:required,not_required,unknown'])->validate();
Validator::make($p, ['public_reason' => 'required|string|min:20|max:1000'])->validate();
$dates = [];
foreach (['origin_published_at', 'clarifications_at', 'deadline', 'opening_at'] as $field) {
if (empty($p[$field])) {
continue;
}
$validator = Validator::make([$field => $p[$field]], [$field => 'date_format:Y-m-d\TH:i']);
$validator->validate();
$dates[$field] = CarbonImmutable::createFromFormat('!Y-m-d\TH:i', $p[$field], $p['timezone']);
}
$this->require($dates['deadline']->greaterThan($dates['origin_published_at']), 'deadline', 'Entrega anterior à publicação de origem.');
if (isset($dates['clarifications_at'])) {
$this->require($dates['clarifications_at']->betweenIncluded($dates['origin_published_at'], $dates['deadline']), 'clarifications_at', 'Esclarecimentos fora do período de publicação/entrega.');
}
if (isset($dates['opening_at'])) {
$this->require($dates['opening_at']->greaterThanOrEqualTo($dates['deadline']), 'opening_at', 'A abertura indicada é anterior à entrega.');
}
if (($p['guarantee_status'] ?? '') === 'required') {
$this->require(! empty($p['guarantees']), 'guarantees', 'Descreva a garantia documentada exigida.');
}
if (isset($p['minimum_days'])) {
foreach (['minimum_source', 'minimum_edition', 'minimum_modality', 'minimum_day_basis'] as $field) {
$this->require(! empty($p[$field]), $field, 'Identifique fonte, edição, modalidade e contagem do prazo mínimo.');
}
$this->require($p['minimum_modality'] === $p['modality'], 'minimum_modality', 'A regra indicada pertence a outra modalidade.');
$earliest = $dates['origin_published_at'];
if ($p['minimum_day_basis'] === 'business') {
$this->require(! empty($p['minimum_calendar_source']), 'minimum_calendar_source', 'Identifique a fonte do calendário de dias úteis e feriados.');
$weekdays = array_filter(array_map('trim', explode(',', (string) ($p['minimum_working_days'] ?? ''))));
$this->require(count($weekdays) > 0 && ! array_diff($weekdays, ['1', '2', '3', '4', '5', '6', '7']), 'minimum_working_days', 'Indique os dias de trabalho documentados (1=segunda, 7=domingo).');
$holidays = array_values(array_filter(preg_split('/[\s,;]+/', (string) ($p['minimum_holidays'] ?? ''))));
Validator::make(['dates' => $holidays], ['dates' => 'array|max:1000', 'dates.*' => 'date_format:Y-m-d'])->validate();
for ($left = (int) $p['minimum_days'],$iterations = 0; $left > 0; $iterations++) {
$this->require($iterations < 36500, 'minimum_days', 'Calendário insuficiente para calcular o prazo indicado.');
$earliest = $earliest->addDay();
if (in_array((string) $earliest->isoWeekday(), $weekdays, true) && ! in_array($earliest->format('Y-m-d'), $holidays, true)) {
$left--;
}
}
} else {
$this->require($p['minimum_day_basis'] === 'calendar', 'minimum_day_basis', 'Identifique dias corridos ou calendário de dias úteis documentado.');
$earliest = $earliest->addDays((int) $p['minimum_days']);
}
$this->require($dates['deadline']->greaterThanOrEqualTo($earliest), 'deadline', 'Prazo inferior ao mínimo documentado para esta modalidade/edição/calendário.');
}
$previous = $record->published_version_id ? $this->publishedVersion($record->published_version_id) : null;
$this->require($previous ? $act !== 'initial' : $act === 'initial', 'act', $previous ? 'O anúncio inicial já existe; publique um acto associado.' : 'Publique primeiro o anúncio inicial.');
if ($previous) {
$before = $previous['payload'];
if (($before['deadline'] ?? '') !== $p['deadline'] || ($before['timezone'] ?? '') !== $p['timezone']) {
$this->require($act === 'extension', 'act', 'Uma mudança do prazo exige acto de prorrogação, com data anterior e nova.');
}
if ($act === 'extension') {
$this->require($dates['deadline']->greaterThan(CarbonImmutable::parse($before['deadline'], $before['timezone'])), 'deadline', 'A prorrogação deve avançar o prazo anterior.');
$p['deadline_previous'] = $before['deadline'];
$p['timezone_previous'] = $before['timezone'];
}
$p['extended'] = ($before['extended'] ?? false) || $act === 'extension';
$p['procedural_state'] = $before['procedural_state'] ?? null;
}
if ($act === 'award') {
$p['procedural_state'] = 'results';
}
if ($act === 'cancellation') {
$p['procedural_state'] = 'cancelled';
}
if (in_array($act, ['award', 'cancellation', 'other'], true)) {
$this->require(! empty($p['result']), 'result', 'Descreva o acto ou resultado documentado.');
}
$p['organization_name'] = DB::table('organizations')->where('id', $p['organization_id'])->whereNull('deleted_at')->value('name');
$this->require((bool) $p['organization_name'], 'organization_id', 'Entidade eliminada; recupere a referência.');
$p['sector_name'] = empty($p['sector_id']) ? null : DB::table('sectors')->where('id', $p['sector_id'])->value('name');
$p['territory_name'] = empty($p['territory_id']) ? null : app(InvestmentGeography::class)->references(true)->where('id', $p['territory_id'])->value('name');
if (! empty($p['territory_id'])) {
$this->require((bool) $p['territory_name'], 'territory_id', 'Referência territorial indisponível.');
}
unset($p['internal_notes']);
if (! ($p['contact_authorized'] ?? false)) {
unset($p['public_contact']);
}
$attachments = [];
foreach ($p['attachments'] ?? [] as $item) {
if (! $item['public']) {
continue;
}
$doc = $this->publicDocuments()->where('r.id', $item['resource_id'])->first(['r.id', 'r.title', 'd.sha256', 'd.id as document_id']);
$this->require((bool) $doc, 'attachments', 'Anexo público exige publicação autorizada, scan limpo e direitos sem restrição na biblioteca.');
$attachments[] = [...$item, 'title' => $doc->title, 'sha256' => trim($doc->sha256), 'document_id' => $doc->document_id];
}
$p['attachments'] = $attachments;
if (! empty($p['opportunity_id'])) {
$o = Opportunity::published()->whereKey($p['opportunity_id'])->first(['id', 'title', 'slug']);
$p['opportunity'] = $o ? ['title' => $o->title, 'url' => '/oportunidades/'.$o->slug] : null;
}
if (! empty($p['project_id'])) {
$p['project_name'] = DB::table('projects')->where('id', $p['project_id'])->whereNull('deleted_at')->where('data_classification', 'public')->value('name');
}
unset($p['opportunity_id'],$p['project_id']);
$plan = ['id' => $id, 'version' => $record->version, 'act' => $act, 'payload' => $p];
$plan['hash'] = hash_hmac('sha256', json_encode($plan, JSON_THROW_ON_ERROR), config('app.key'));
return $plan;
}
public function publish(User $actor, string $id, array $input): void
{
$this->guard($actor);
Validator::make($input, ['hash' => 'required|string|size:64', 'act' => 'required|string', 'reason' => 'required|string|min:20|max:1000'])->validate();
DB::transaction(function () use ($actor, $id, $input) {
$record = DB::table('portal_tenders')->where('id', $id)->lockForUpdate()->firstOrFail();
$plan = $this->preview($actor, $id, $input['act']);
abort_unless(hash_equals($plan['hash'], $input['hash']), 409, 'Prévia alterada; reveja o conteúdo antes de publicar.');
abort_unless($input['reason'] === $plan['payload']['public_reason'], 409, 'O motivo público deve coincidir com a prévia.');
$version = $record->version + 1;
$vid = $this->version($actor, $id, $version, $input['act'], $plan['payload'], $input['reason'], true);
DB::table('portal_tenders')->where('id', $id)->update(['published_version_id' => $vid, 'version' => $version, 'updated_at' => now()]);
});
}
private function version(User $actor, string $id, int $version, string $act, array $payload, string $reason, bool $published): string
{
$vid = (string) Str::uuid();
DB::table('portal_tender_versions')->insert(['id' => $vid, 'tender_id' => $id, 'version' => $version, 'act' => $act, 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'reason' => $reason, 'actor_id' => $actor->id, 'published_at' => $published ? now() : null, 'created_at' => now()]);
DB::table('audit_events')->insert(['source_system' => 'procurement', 'entity_type' => 'portal_tenders', 'entity_id' => $id, 'actor_user_id' => $actor->id, 'action' => $published ? 'notice_published' : 'tender_draft_saved', 'data_classification' => 'internal', 'after_data' => json_encode(['version_id' => $vid, 'act' => $act, 'reason' => $reason], JSON_THROW_ON_ERROR)]);
return $vid;
}
public function publicDocuments(): Builder
{
return DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')->where('d.data_classification', 'public')->where('d.status', 'active')->whereNull('d.deleted_at')->where('d.malware_scan_status', 'clean')->whereColumn('d.sha256', 'd.malware_scanned_sha256')
->where(fn ($q) => $q->whereNull('d.source_system')->orWhereNotIn('d.source_system', ['opportunity-resources', 'opportunity-image-derivatives']))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_documents as od')->whereColumn('od.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(od.payload,'{}'::jsonb),'library_source')"))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('opportunity_images as oi')->whereColumn('oi.document_id', 'd.id')->whereRaw("NOT jsonb_exists(COALESCE(oi.payload,'{}'::jsonb),'library_source')"));
}
public function publishedVersion(string $id): array
{
$v = DB::table('portal_tender_versions')->where('id', $id)->whereNotNull('published_at')->firstOrFail();
$p = json_decode($v->payload, true, 512, JSON_THROW_ON_ERROR);
foreach ($p['attachments'] ?? [] as $i => $item) {
$p['attachments'][$i]['available'] = $this->publicDocuments()->where('r.id', $item['resource_id'])->where('d.id', $item['document_id'])->where('d.sha256', $item['sha256'])->exists();
}
return ['id' => $v->id, 'version' => $v->version, 'act' => $v->act, 'payload' => $p, 'published_at' => $v->published_at, 'reason' => $v->reason];
}
public function state(array $version): string
{
$p = $version['payload'];
if (in_array($p['procedural_state'] ?? '', ['cancelled', 'results'], true)) {
return $p['procedural_state'];
}
if (CarbonImmutable::parse($p['deadline'], $p['timezone'])->isPast()) {
return 'deadline_ended';
}
return ($p['extended'] ?? false) ? 'extended' : 'open';
}
private function require(bool $condition, string $field, string $message): void
{
if (! $condition) {
throw ValidationException::withMessages([$field => $message]);
}
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'enabled' => (bool) env('PROCUREMENT_ENABLED', false),
'regimes' => ['state' => 'Contratação pública do Estado', 'external' => 'Financiamento externo', 'private' => 'Concurso privado', 'other' => 'Outro regime documentado'],
'acts' => ['initial' => 'Anúncio inicial', 'clarification' => 'Esclarecimento', 'correction' => 'Rectificação', 'extension' => 'Prorrogação', 'award' => 'Resultado / adjudicação', 'cancellation' => 'Cancelamento', 'other' => 'Outro acto'],
];
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('portal_tenders', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->jsonb('draft');
$t->unsignedInteger('version')->default(1);
$t->uuid('published_version_id')->nullable();
$t->foreignId('created_by')->constrained('users')->restrictOnDelete();
$t->timestampsTz();
});
Schema::create('portal_tender_versions', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('tender_id')->constrained('portal_tenders')->restrictOnDelete();
$t->unsignedInteger('version');
$t->string('act', 24);
$t->jsonb('payload');
$t->text('reason');
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->timestampTz('published_at')->nullable();
$t->timestampTz('created_at')->useCurrent();
$t->unique(['tender_id', 'version']);
});
Schema::create('portal_tender_watches', function (Blueprint $t) {
$t->foreignUuid('tender_id')->constrained('portal_tenders')->restrictOnDelete();
$t->foreignId('user_id')->constrained('users')->restrictOnDelete();
$t->uuid('seen_version_id')->nullable();
$t->timestampsTz();
$t->primary(['tender_id', 'user_id']);
});
}
public function down(): void
{
if (DB::table('portal_tenders')->exists()) {
throw new RuntimeException('Preserve procedures, acts and history; use a reviewed data reversal.');
}
Schema::dropIfExists('portal_tender_watches');
Schema::dropIfExists('portal_tender_versions');
Schema::dropIfExists('portal_tenders');
}
};
@@ -325,3 +325,7 @@ AUD-GEO-02/03: Recuperação da demarcação territorial — QA, 22/10/2026. 408
AUD-GEO-02/03 - 22/10/2026: Demarcação territorial reposta na homologação — 22/10/2026. 408 limites (11/133/264) importados pela conta real/MFA, com tres recibos auditados; backup/restauro de 171 tabelas conferido, 333 ficheiros verificados. Consulta do mapa demonstrada na oportunidade real #30, sem a alterar. Acessos previstos e originais privados preservados. Reposicao no destino concluida; 26+147 correspondencias e ausencia de poligonos proprios de localidades mantidas como lacunas reais. Ver fecho e guia no manual existente; sem repetir provas QA aceites.
### Incremento Concursos e anúncios — QA, 22/10/2026
Nos requisitos existentes de gestão/publicação `MASTER-9f6dd1d9-0001`, `MASTER-9f6dd1d9-0003` e `MASTER-02f44baf-0002`, registar o recorte solicitado pela Tempo: procedimentos com regime/edição/fontes, lotes, rascunhos recuperáveis, prévia, actos versionados, catálogo/ficha/anexos anónimos, filtros, impressão e acompanhamento por conta simples. Evidência: secção «Concursos e anúncios — implementação em QA» do manual existente e `tests/Feature/ProcurementTest.php`, com passagem adicional no navegador dos três regimes, prorrogação, cancelamento, download e acompanhamento. Reutilizadas autorizações e biblioteca; não foram criados papéis ou aprovadores. Origem funcional: pedido expresso da Tempo; flag, prévia assinada e controlo de versão são escolhas técnicas. Entrega delimitada a QA: integração em main, migração/activação no destino e anúncios reais seguem o processo existente. Não fecha requisitos integrais de contratação/recepção de propostas, nem declara parametrizado todo o articulado legal. Geometrias permanecem no recorte separado já entregue.
@@ -2331,3 +2331,41 @@ Entrar em [Gestão de conteúdos e oportunidades](https://teste-investidor.tempo
**Reposição concluída neste recorte.** Mantidas as 26 correspondências de distritos e 147 de postos pendentes, com nomes nos documentos/prévias existentes. O exemplar não contém polígonos próprios das localidades ou parcelas. Essas lacunas não bloqueiam os 408 limites recuperados. A data de eventual desaparecimento anterior no mesmo endereço continua não determinada. A divulgação promocional mantém o seu incremento separado; este fecho não a declara integrada.
Provas: `homologation-restored-readonly.json`, `staging-receipt.json`, `preservation-receipt.json`, `http-access-verification.json`, captura e recibos locais na pasta desta recuperação; backup/restauro e prova de activação em caminhos privados. Uma interrupção do transporte local foi resolvida por escrita atómica da mensagem, antes de qualquer importação. Não ficaram passos técnicos pendentes para esta reposição; futuras importações usam as salvaguardas de backup/MFA já existentes.
## Concursos e anúncios — implementação em QA, 22/10/2026
Origem: pedido expresso da Tempo no anexo «O InvesteMoz deve ter uma secção pública Concursos e anúncios», com continuação autónoma autorizada. Aplicam-se as decisões permanentes da Tempo de 12/10 e 22/10/2026. Este incremento trata a divulgação de procedimentos; não abre recepção electrónica de propostas nem altera os investimentos associados. A estimativa apresentada antes da implementação foi de 16–24 horas; não representa tempo efectivamente consumido.
Reutilizados: entrada `/gestao-conteudos`, autenticação/MFA, autorização editorial e documental, entidades, oportunidades, projectos, sectores, território, biblioteca, originais privados, análise ClamAV, download verificado e `audit_events`. Acrescentados apenas três cadastros: procedimentos com rascunho, versões/actos imutáveis e acompanhamento individual. Uma oportunidade/projecto pode ter vários procedimentos; cada um admite lotes e actos associados. Não foram criados papéis, designações de funcionários ou aprovadores. Contas ainda não criadas não bloqueiam as contas já autorizadas.
### Fontes, âmbito e escolhas de implementação
- A UFSA identifica o Decreto n.º 79/2022 na [página oficial do regulamento](https://www.ufsa.gov.mz/divnovoreg.php). O [quadro oficial do Banco Mundial](https://www.worldbank.org/ext/en/what-we-do/project-procurement/framework) disponibiliza regulamentos e documentos normalizados, incluindo a edição de Setembro/2025. Cada procedimento regista a sua edição e acordo aplicáveis; a edição mais recente não é imposta a acordos anteriores.
- Inventário deste recorte: instrução anexada, páginas oficiais referidas, pesquisa dos ficheiros acessíveis do repositório e referências da biblioteca existentes. Não foi identificado neste recorte um modelo oficial de concurso já fornecido como original local utilizável. Não se declarou importado ou analisado o articulado integral/modelos que não foram obtidos. Os documentos de demonstração são explicitamente sintéticos, sem classificação de autenticidade oficial. Os modelos disponibilizados pela Tempo usam a biblioteca existente, sem segunda validação genérica da credibilidade de fontes oficiais aceites.
- Decisão expressa da Tempo: separar regimes Estado/externo/privado/outro; não generalizar UGEA, Cadastro Único, garantia ou abertura pública; guardar preparação incompleta; distinguir publicação de origem e publicação do portal; preservar actos, privacidade e auditoria.
- Escolhas técnicas: só a prévia de publicação exige identificação, entidade, objecto, modalidade, instrumento/edição/fonte, calendário coerente, canal e motivo público. Rascunhos permanecem incompletos. Prazos mínimos são opcionais, citados por fonte/edição/modalidade; dias úteis usam exclusivamente o calendário documentado no registo. Não foi parametrizada uma tabela universal de mínimos legais nem presumido um calendário de feriados.
- Escolhas técnicas de preservação: concorrência por versão, prévia assinada pelo servidor, nova verificação dos anexos na publicação, documentos por ID/hash e histórico imutável. Retirar ou substituir um binário na biblioteca torna indisponível o anexo antigo, sem apontar silenciosamente para outros bytes. Contacto sem autorização, notas internas e anexos privados não integram o conteúdo público. Ligações a oportunidades/projectos privados não divulgam os seus títulos ou fichas.
- Autorizações reutilizadas: edição/publicação por contas já elegíveis para gestão documental completa ou edição editorial; auditor e conta simples não obtêm escrita. Acompanhamento exige apenas autenticação, incluindo conta sem email verificado. Estas escolhas não atribuem novos papéis e não dispensam os requisitos existentes de cada autorização.
### Guia curto da equipa
**Entrada QA:** [Gestão de conteúdos e oportunidades](http://127.0.0.1:18580/gestao-conteudos). Abrir **Novo concurso/anúncio**; escolher a entidade já cadastrada e, quando aplicável, oportunidade/projecto, sector e território. Indicar o regime e a edição do instrumento aplicável. Guardar mesmo com preparação incompleta; recuperar em **Gerir concursos e anúncios**. O motivo interno fica no histórico de gestão.
1. Preencher objecto, lotes, participação, financiamento, calendário, fuso horário e canal efectivo de apresentação. A data de origem é a referência do procedimento; o portal regista separadamente a sua publicação.
2. Usar **Carregar / gerir documentos e scan na biblioteca**, que abre outra página sem perder o editor. Carregar o original, acompanhar o scan, editar os metadados e disponibilizar explicitamente os documentos autorizados. Voltar ao concurso, usar **Actualizar op??es da biblioteca** e seleccionar os anexos. Marcar públicos só os destinados à divulgação; os restantes permanecem privados.
3. Guardar, escolher **Anúncio inicial** e **Preparar prévia pública**. Rever contacto, documentos, objecto, entidade, prazo e canal. O motivo público aparece também no histórico público. Usar **Publicar este acto**; alterações posteriores no rascunho não modificam essa publicação.
4. Para esclarecer ou rectificar, guardar a revisão e publicar o acto correspondente. Para prorrogar, indicar o novo prazo, guardar e escolher **Prorrogação**; a prévia mostra o anterior e o novo. Resultados/adjudicação ou cancelamento usam o mesmo percurso, com texto e motivo documentados. O fim do prazo muda a indicação para **Prazo de propostas terminado**, sem adjudicação automática. Actos informativos posteriores não reabrem um cancelamento/resultado.
5. Consultar o [catálogo público QA](http://127.0.0.1:18580/concursos); pesquisar por palavras, entidade, sector, localização, modalidade, financiador, regime, estado ou prazo. Abrir a ficha e **Voltar aos resultados** conserva os filtros. **Relatório imprimível → Imprimir anúncio** permite imprimir/guardar pelo navegador, incluindo o histórico dos actos.
6. Uma conta simples usa **Acompanhar concurso** e **Os concursos que acompanho**; a lista identifica novos actos desde o registo de acompanhamento. **Deixar de acompanhar** remove só a subscrição individual. Não há promessa de alerta por email nem entrega de proposta: apresentar a proposta no canal indicado no anúncio/documentos.
Publicação documental disponibiliza um original da biblioteca; publicação de oportunidade disponibiliza a ficha pelas etapas existentes; divulgação promocional divulga o resumo autorizado de preparação; publicação de concurso divulga um procedimento e os seus actos. Nenhuma dessas operações autoriza execução do investimento, activa cálculo fiscal ou comprova envio à UFSA/financiador.
### Evidência e limites de entrega
Base exclusivamente QA `simulator_fiscal_delta_20261021_test`, porta 55441, com guarda de identidade antes de migração/fixtures/testes. Testes transaccionais preservam a base existente. `ProcurementTest` cobre preparação incompleta, concorrência, três regimes, filtros e retorno, prévia desactualizada, publicação anónima, prorrogação, expiração sem adjudicação, resultados, cancelamento persistente, conta simples, recusas de escrita, scan real, anexo privado, download anónimo e retirada documental. Ver resultado final em `outputs/procurement-20261022/tests.log`. Navegação existente: seis testes aprovados; compilação QA e TypeScript aprovados.
Exemplos persistentes, todos **SINTÉTICO QA**, sem concursos reais: estatal `17f44d8d-d077-4dca-9a63-5eb1c85a6926` (inicial + prorrogação, prazo de 20/11 15h para 16/12 17h, Africa/Maputo); externo `e85861cd-ffbb-4348-9cce-d588a6e09b86` (aberto); privado `fe271b02-0bfd-4fe2-8db2-856567b5111f` (inicial + cancelamento). Os rascunhos foram preparados por fixture identificada; guardar/reabrir, prévias e publicações destes actos foram executados no navegador pela conta sintética existente, não por comando de publicação. Resultado/adjudicação e expiração foram demonstrados pelos testes HTTP, não por adjudicação de um registo real.
Consulta anónima confirmada depois de terminar sessão: três regimes/estados no catálogo, ficha prorrogada, filtros preservados, relatório e download real do anexo 38. Anexo 39 e contacto sem autorização ficam privados. Acompanhamento confirmado pela conta simples já existente, sem MFA ou papel editorial. Evidências locais: `management.png`, `catalogue.png`, `print-notice.png`, `watch.png`, `fixtures.json` e resultados em `outputs/procurement-20261022/`. Estes artefactos QA não contêm credenciais na entrega Git.
Operações correntes deste incremento são feitas pela interface: cadastrar, guardar, recuperar, carregar/editar documentos pela biblioteca, pré-visualizar, publicar, actualizar/prorrogar, publicar resultado/cancelamento, consultar/imprimir e acompanhar. Não requerem executor privado ou Codex por concurso. **Pendente técnico de disponibilização fora de QA:** revisão e integração Git, migração pelo mecanismo de entrega e activação de `PROCUREMENT_ENABLED` no destino autorizado. O código usa flag desligada por defeito. Nenhum anúncio real foi publicado, nenhuma base activa foi migrada e não há integração automática com UFSA/financiadores. Geometrias não foram alteradas.
@@ -48,6 +48,7 @@ export function PortalGroupedMenu() {
existingBusinessEnabled?: boolean;
businessReviewUrl?: string | null;
educationEnabled?: boolean;
procurementEnabled?: boolean;
aiEnabled?: boolean;
legacyContentEnabled?: boolean;
profileAccess?: ProfileAccess | null;
@@ -66,6 +67,7 @@ export function PortalGroupedMenu() {
businesses: props.existingBusinessEnabled,
businessReviewUrl: props.businessReviewUrl,
education: props.educationEnabled,
procurement: props.procurementEnabled,
ai: props.aiEnabled,
legacyContent: props.legacyContentEnabled,
opportunityManagement: props.opportunityManagement,
+2
View File
@@ -8,6 +8,7 @@ export type PortalNavItem = {
children?: PortalNavItem[];
};
export type PortalNavAccess = {
procurement?: boolean;
authenticated: boolean;
manager: boolean;
businesses?: boolean;
@@ -461,6 +462,7 @@ export function portalNavigation(access: PortalNavAccess): PortalNavItem[] {
),
link('sectors', 'Sectores de investimento', '/sectores'),
link('locations', 'Localizações', '/oportunidades?view=map'),
...(access.procurement ? [link('procurement','Concursos e anúncios','/concursos')] : []),
{
id: 'related-catalogues',
label: 'Catálogos relacionados',
@@ -3,6 +3,7 @@ import { Frame, card, button } from '@/pages/investment/shared';
import { states } from './shared';
type Access = {
procurement?: {index:string;create:string};
opportunities?: {index: string; create: string | null; profileWorkflow: boolean};
documents?: {index: string; edit: boolean};
};
@@ -10,6 +11,7 @@ export default function ContentHub({access, counts}: {access: Access; counts: Re
return <Frame title="Gestão de conteúdos e oportunidades">
<p>Use os cadastros e a biblioteca existentes. Guardar um rascunho, analisar um ficheiro e publicar são operações distintas. Cada acção respeita a sua autorização actual.</p>
<div className="grid gap-4 md:grid-cols-2">
{access.procurement && <section className={card}><h2 className="text-xl font-semibold">Concursos e anúncios</h2><p>Cadastre o procedimento e os lotes, guarde e recupere a preparação, reveja a prévia e publique os actos autorizados. Esclarecimentos, prorrogações, resultados e cancelamentos conservam versões e histórico. Divulgação de oportunidade e abertura de concurso são operações distintas.</p><Link className={button} href={access.procurement.create}>Novo concurso/anúncio</Link><Link className="block underline" href={access.procurement.index}>Gerir concursos e anúncios</Link><Link className="block underline" href="/concursos">Catálogo público</Link></section>}
{access.opportunities && <section className={card}>
<h2 className="text-xl font-semibold">Oportunidades</h2>
<p>Escolha a modalidade, o sector e a localização; preencha, guarde o rascunho e siga as acções de submissão, revisão e publicação disponíveis no seu perfil.</p>
+832
View File
@@ -0,0 +1,832 @@
import { Link, router, useForm } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Frame, card, button } from '@/pages/investment/shared';
import {
Field,
control,
labels,
acts,
NoticeContent,
type Draft,
type Lot,
type Attachment,
type Payload,
} from './shared';
type Choice = {
id: string | number;
name?: string;
title?: string;
location_type?: string;
published?: boolean;
malware_scan_status?: string;
};
type References = {
regimes: Record<string, string>;
acts: Record<string, string>;
organizations: Choice[];
sectors: Choice[];
territories: Choice[];
opportunities: Choice[];
projects: Choice[];
documents: Choice[];
};
type RecordData = {
id: string;
version: number;
draft: Draft;
published: boolean;
};
type Plan = {
id: string;
version: number;
act: string;
payload: Payload;
hash: string;
};
const dates = [
'origin_published_at',
'clarifications_at',
'deadline',
'opening_at',
];
const groups: [string, string[]][] = [
[
'Identificação e objecto',
['title', 'reference', 'modality', 'object', 'quantities'],
],
[
'Regime e financiamento',
[
'financier',
'instrument',
'edition',
'rule_source',
'financing_agreement',
],
],
['Participação', ['eligibility', 'requirements', 'proposal_validity']],
[
'Calendário e entrega',
[
'origin_published_at',
'clarifications_at',
'deadline',
'opening_at',
'delivery_channel',
'delivery_address',
'delivery_url',
],
],
['Divulgação e contacto', ['origin_url', 'dissemination']],
];
export default function Edit({
record,
references,
history,
}: {
record: RecordData | null;
references: References;
history: {
version: number;
act: string;
reason: string;
actor_id: number;
published_at: string | null;
created_at: string;
}[];
}) {
const empty: Draft = {
timezone: 'Africa/Maputo',
lots: [],
attachments: [],
contact_authorized: false,
};
const form = useForm({
version: record?.version ?? 0,
reason: '',
draft: record?.draft ?? empty,
});
const [act, setAct] = useState(
record?.published ? 'clarification' : 'initial',
);
const [preview, setPreview] = useState<Plan | null>(null);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(true);
useEffect(() => {
form.setData({
version: record?.version ?? 0,
reason: '',
draft: record?.draft ?? empty,
});
setPreview(null);
setSaved(true);
}, [record?.id, record?.version]); // A server version is the recovery boundary.
const change = (key: string, value: Draft[string]) => {
form.setData('draft', { ...form.data.draft, [key]: value });
setPreview(null);
setSaved(false);
};
const value = (key: string) => String(form.data.draft[key] ?? '');
const select = (key: string, label: string, choices: Choice[]) => (
<Field label={label}>
<select
className={control}
value={value(key)}
onChange={(e) =>
change(
key,
e.target.value
? key === 'sector_id' || key === 'opportunity_id'
? Number(e.target.value)
: e.target.value
: null,
)
}
>
<option value="">Por indicar</option>
{choices.map((o) => (
<option key={o.id} value={o.id}>
{o.name ?? o.title}
{o.location_type ? ` · ${o.location_type}` : ''}
</option>
))}
</select>
</Field>
);
const input = (key: string, label?: string) => (
<Field key={key} label={label ?? labels[key] ?? key}>
{dates.includes(key) ? (
<input
type="datetime-local"
className={control}
value={value(key)}
onChange={(e) => change(key, e.target.value)}
/>
) : (
<textarea
className={control}
rows={key === 'object' ? 4 : 2}
value={value(key)}
onChange={(e) => change(key, e.target.value)}
/>
)}
</Field>
);
const lots = (form.data.draft.lots ?? []) as Lot[];
const attachments = (form.data.draft.attachments ?? []) as Attachment[];
const makePreview = async () => {
setBusy(true);
setMessage('');
try {
const token = document.cookie
.split('; ')
.find((v) => v.startsWith('XSRF-TOKEN='))
?.slice(11);
const response = await fetch(
`/management/concursos/${record!.id}/previa`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(token ?? ''),
},
body: JSON.stringify({ act }),
},
);
const body = await response.json();
if (!response.ok)
throw new Error(
Object.values(body.errors ?? {})
.flat()
.join(' ') ||
body.message ||
'Não foi possível preparar a prévia.',
);
setPreview(body);
} catch (error) {
setMessage(
error instanceof Error ? error.message : 'Erro de ligação.',
);
} finally {
setBusy(false);
}
};
return (
<Frame
title={record ? 'Editar concurso/anúncio' : 'Novo concurso/anúncio'}
>
<div className="flex flex-wrap gap-3">
<Link className="underline" href="/management/concursos">
Recuperar concursos
</Link>
<Link className="underline" href="/gestao-conteudos">
Entrada única de gestão
</Link>
<a
className="underline"
href="/management/portal-services?intent=document#receber"
target="_blank"
rel="noopener noreferrer"
>
Carregar / gerir documentos e scan na biblioteca (outra
página)
</a>
<button
className="underline"
type="button"
onClick={() => router.reload({ only: ['references'] })}
>
Actualizar opções da biblioteca
</button>
</div>
<p>
Os campos destinam-se à divulgação após revisão da prévia,
excepto as notas internas, os anexos não públicos e o contacto
sem autorização. O vínculo a um cadastro privado não publica os
seus dados.
</p>
<p>
O rascunho pode ficar incompleto. Alterações guardadas não
substituem o acto público; reveja a prévia e publique
explicitamente. Publicar concurso não publica a oportunidade nem
recebe propostas.
</p>
<p role="status">
{record
? `Versão guardada ${record.version}. ${saved ? 'Preparação recuperada do servidor.' : 'Há alterações por guardar.'}`
: 'Preparação ainda não guardada.'}
</p>
{!!Object.keys(form.errors).length && (
<div role="alert">
{Object.values(form.errors).map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
setPreview(null);
record
? form.put(`/management/concursos/${record.id}`)
: form.post('/management/concursos');
}}
>
<section className={`${card} grid gap-3 md:grid-cols-2`}>
{select(
'organization_id',
'Entidade contratante existente',
references.organizations,
)}
{select(
'opportunity_id',
'Oportunidade relacionada existente',
references.opportunities,
)}
{select(
'project_id',
'Projecto existente',
references.projects,
)}
{select('sector_id', 'Sector', references.sectors)}
{select(
'territory_id',
'Localização existente',
references.territories,
)}
<p>
Os vínculos reutilizam os cadastros; não criam nova
entidade, oportunidade, projecto ou limite. Referências
privadas não são transformadas em conteúdo público.
</p>
</section>
<section className={card}>
<h2 className="text-xl font-semibold">
Regime do procedimento
</h2>
<Field label="Regime">
<select
className={control}
value={value('regime')}
onChange={(e) => change('regime', e.target.value)}
>
<option value="">Por indicar</option>
{Object.entries(references.regimes).map(
([v, l]) => (
<option key={v} value={v}>
{l}
</option>
),
)}
</select>
</Field>
{value('regime') === 'state' ? (
<>
<p>
Contratação do Estado: identifique a modalidade,
o instrumento e a edição aplicáveis.{' '}
<a
className="underline"
href="https://www.ufsa.gov.mz/divnovoreg.php"
rel="noopener noreferrer"
>
UFSA — Decreto n.º 79/2022
</a>
. A referência não atribui automaticamente
requisitos ou prazos a todos os procedimentos.
</p>
{input(
'responsible_unit',
'UGEA / unidade responsável quando aplicável',
)}
</>
) : (
<>
<p>
{value('regime') === 'external' ? (
<>
<a
className="underline"
href="https://www.worldbank.org/ext/en/what-we-do/project-procurement/framework"
>
Quadro e documentos normalizados do
Banco Mundial
</a>{' '}
são referências; indique a edição e o
acordo efectivamente aplicáveis.
</>
) : (
'Use as regras documentadas da entidade e deste procedimento.'
)}{' '}
UGEA, Cadastro Único, garantias e abertura
pública não são impostos a este regime.
</p>
{input(
'responsible_unit',
'Unidade responsável (quando aplicável)',
)}
</>
)}
<Field label="Obras, bens ou serviços">
<select
className={control}
value={value('object_kind')}
onChange={(e) =>
change('object_kind', e.target.value)
}
>
<option value="">Por indicar</option>
{Object.entries({
works: 'Obras',
goods: 'Bens',
services: 'Serviços',
mixed: 'Misto',
other: 'Outro',
}).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
</section>
{groups.map(([title, fields]) => (
<section className={card} key={title}>
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
<div className="grid gap-3 md:grid-cols-2">
{fields.map((key) =>
input(
key,
key === 'title'
? 'Título do concurso/anúncio'
: undefined,
),
)}
</div>
</section>
))}
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Garantias e prazo mínimo documentado
</h2>
<Field label="Garantias exigidas neste procedimento">
<select
className={control}
value={value('guarantee_status')}
onChange={(e) =>
change('guarantee_status', e.target.value)
}
>
<option value="unknown">
Por determinar nos documentos
</option>
<option value="required">
Exigidas nos documentos
</option>
<option value="not_required">Não exigidas</option>
</select>
</Field>
{value('guarantee_status') === 'required' &&
input('guarantees')}
<Field label="Fuso horário IANA do procedimento">
<input
className={control}
value={value('timezone')}
onChange={(e) => change('timezone', e.target.value)}
placeholder="Africa/Maputo"
/>
</Field>
<p>
As datas e horas introduzidas pertencem a este fuso.
Publicação no InvesteMoz é registada separadamente pelo
servidor; não reinicia o prazo.
</p>
<Field label="Prazo mínimo documentado em dias (opcional)">
<input
className={control}
type="number"
min="0"
value={value('minimum_days')}
onChange={(e) =>
change(
'minimum_days',
e.target.value === ''
? null
: Number(e.target.value),
)
}
/>
</Field>
{form.data.draft.minimum_days !== null &&
form.data.draft.minimum_days !== undefined && (
<div className="grid gap-3 md:grid-cols-2">
{input('minimum_source')}
{input('minimum_edition')}
{input('minimum_modality')}
<Field label="Contagem documentada">
<select
className={control}
value={value('minimum_day_basis')}
onChange={(e) =>
change(
'minimum_day_basis',
e.target.value,
)
}
>
<option value="">Por indicar</option>
<option value="calendar">
Dias corridos
</option>
<option value="business">
Dias úteis — calendário documentado
</option>
</select>
</Field>
{value('minimum_day_basis') === 'business' && (
<>
{input(
'minimum_calendar_source',
'Fonte do calendário aplicável e abrangência de feriados',
)}
{input(
'minimum_working_days',
'Dias de trabalho documentados: 1=segunda a 7=domingo, separados por vírgula',
)}
{input(
'minimum_holidays',
'Feriados / exclusões documentados (AAAA-MM-DD, separados por espaço ou vírgula)',
)}
</>
)}
</div>
)}
<p>
Sem prazo parametrizado não é presumido um mínimo geral.
A contagem em dias úteis usa exclusivamente os dias de
trabalho e exclusões do calendário documentado neste
registo; não adopta um calendário nacional por
suposição.
</p>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">Lotes</h2>
{lots.map((lot, i) => (
<fieldset
className="grid gap-3 rounded border p-3 md:grid-cols-3"
key={i}
>
<legend>Lote {i + 1}</legend>
{(
['code', 'description', 'quantities'] as const
).map((key) => (
<Field
key={key}
label={
{
code: 'Referência do lote',
description: 'Objecto do lote',
quantities: 'Quantidades do lote',
}[key]
}
>
<textarea
className={control}
value={lot[key]}
onChange={(e) =>
change(
'lots',
lots.map((r, n) =>
n === i
? {
...r,
[key]: e.target
.value,
}
: r,
),
)
}
/>
</Field>
))}
<button
type="button"
className="underline"
onClick={() =>
change(
'lots',
lots.filter((_, n) => n !== i),
)
}
>
Retirar lote do rascunho
</button>
</fieldset>
))}
<button
type="button"
className={button}
onClick={() =>
change('lots', [
...lots,
{ code: '', description: '', quantities: '' },
])
}
>
Acrescentar lote
</button>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Documentos e anexos
</h2>
<p>
Carregue e classifique os originais na biblioteca
existente pelo link acima. Reutilize-os aqui: anúncio,
caderno, termos de referência, formulários, anexos ou
comprovativos. Publicar exige que cada anexo marcado
público já esteja autorizado, publicado e com scan limpo
na biblioteca. Os restantes ficam privados no cadastro.
</p>
{attachments.map((item, i) => (
<fieldset
key={i}
className="space-y-2 rounded border p-3"
>
<legend>Anexo {i + 1}</legend>
<Field label={`Documento do anexo ${i + 1}`}>
<select
className={control}
value={item.resource_id || ''}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
resource_id: Number(
e.target.value,
),
}
: r,
),
)
}
>
<option value="">
Seleccione da biblioteca
</option>
{references.documents.map((d) => (
<option key={d.id} value={d.id}>
{d.title} · {d.malware_scan_status}{' '}
·{' '}
{d.published
? 'publicado'
: 'privado / rascunho'}
</option>
))}
</select>
</Field>
<Field label={`Finalidade do anexo ${i + 1}`}>
<input
className={control}
value={item.purpose}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
purpose:
e.target.value,
}
: r,
),
)
}
/>
</Field>
<label>
<input
type="checkbox"
checked={item.public}
onChange={(e) =>
change(
'attachments',
attachments.map((r, n) =>
n === i
? {
...r,
public: e.target
.checked,
}
: r,
),
)
}
/>{' '}
Incluir este anexo na divulgação pública
</label>
<button
type="button"
className="ml-3 underline"
onClick={() =>
change(
'attachments',
attachments.filter((_, n) => n !== i),
)
}
>
Retirar anexo do rascunho
</button>
</fieldset>
))}
<button
type="button"
className={button}
onClick={() =>
change('attachments', [
...attachments,
{ resource_id: 0, purpose: '', public: false },
])
}
>
Reutilizar documento
</button>
</section>
<section className={`${card} space-y-3`}>
<h2 className="text-xl font-semibold">
Contacto e acto a divulgar
</h2>
{input('public_contact')}
<label className="block">
<input
type="checkbox"
checked={Boolean(
form.data.draft.contact_authorized,
)}
onChange={(e) =>
change('contact_authorized', e.target.checked)
}
/>{' '}
Contacto autorizado para divulgação pública
</label>
{input('result')}
{input(
'public_reason',
'Motivo público do acto — aparece na prévia e no histórico público',
)}
{input(
'internal_notes',
'Notas internas — nunca incluídas na divulgação',
)}
<Field label="Motivo interno da gravação (20–1000 caracteres)">
<textarea
className={control}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</Field>
<button className={button} disabled={form.processing}>
Guardar rascunho
</button>
</section>
</form>
{record && (
<section className={`${card} my-5 space-y-3`}>
<h2 className="text-xl font-semibold">
Pré-visualizar e publicar
</h2>
<Field label="Acto a publicar">
<select
className={control}
value={act}
onChange={(e) => {
setAct(e.target.value);
setPreview(null);
}}
>
{Object.entries(references.acts).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
<button
className={button}
disabled={!saved || busy}
onClick={makePreview}
>
Preparar prévia pública
</button>
<p>
Guarde antes da prévia. Uma prorrogação identifica o
prazo anterior e o novo; o fim do prazo não é
adjudicação.
</p>
{message && <p role="alert">{message}</p>}
{preview && (
<div className="space-y-3 rounded border p-4">
<h3 className="text-xl font-semibold">
Prévia do conteúdo público — {acts[preview.act]}
</h3>
<p>
Motivo público:{' '}
{String(preview.payload.public_reason)}
</p>
<NoticeContent p={preview.payload} />
<button
className={button}
onClick={() =>
router.post(
`/management/concursos/${record.id}/publicar`,
{
act: preview.act,
hash: preview.hash,
reason: String(
preview.payload.public_reason,
),
},
{ onSuccess: () => setPreview(null) },
)
}
>
Publicar este acto
</button>
</div>
)}
{record.published && (
<Link
className="block underline"
href={`/concursos/${record.id}`}
>
Consultar anúncio público
</Link>
)}
</section>
)}
<section className={card}>
<h2 className="text-xl font-semibold">
Histórico de preparação e publicação
</h2>
{history.map((v) => (
<p key={v.version}>
Versão {v.version} ·{' '}
{v.act === 'draft' ? 'Rascunho' : acts[v.act]} ·
responsável {v.actor_id} ·{' '}
{v.published_at ?? v.created_at} · {v.reason}
</p>
))}
</section>
</Frame>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { Link, useForm } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { Field, control, states, type Publication } from './shared';
type Choice = { id: string; name: string };
export default function Index({
items,
filters,
options,
regimes,
management,
}: {
items: {
data: { id: string; publication: Publication; state: string }[];
current_page: number;
links: { url: string | null; label: string; active: boolean }[];
};
filters: Record<string, string>;
options: Record<string, Choice[]>;
regimes: Record<string, string>;
management: boolean;
}) {
const form = useForm({
q: filters.q ?? '',
entity: filters.entity ?? '',
sector: filters.sector ?? '',
territory: filters.territory ?? '',
modality: filters.modality ?? '',
financier: filters.financier ?? '',
state: filters.state ?? '',
deadline_before: filters.deadline_before ?? '',
regime: filters.regime ?? '',
});
const query = new URLSearchParams(
Object.entries({ ...filters, page: String(items.current_page) }).filter(
([, v]) => v != null && v !== '',
),
).toString();
return (
<Frame title="Concursos e anúncios">
<p>
Procedimentos e actos divulgados pelas entidades responsáveis.
Uma oportunidade divulgada não é, por si, um concurso aberto.
</p>
<div className="flex flex-wrap gap-3">
<Link className="underline" href="/concursos/acompanhar">
Os concursos que acompanho
</Link>
{management && (
<Link className={button} href="/management/concursos">
Gerir concursos e anúncios
</Link>
)}
</div>
<form
className={`${card} grid gap-3 md:grid-cols-3`}
onSubmit={(e) => {
e.preventDefault();
form.get('/concursos', { preserveState: true });
}}
>
<Field label="Palavras-chave">
<input
className={control}
value={form.data.q}
onChange={(e) => form.setData('q', e.target.value)}
/>
</Field>
{(
[
'entity',
'sector',
'territory',
'modality',
'financier',
] as const
).map((key) => (
<Field
key={key}
label={
{
entity: 'Entidade',
sector: 'Sector',
territory: 'Localização',
modality: 'Modalidade',
financier: 'Financiador',
}[key]
}
>
<select
className={control}
value={form.data[key]}
onChange={(e) => form.setData(key, e.target.value)}
>
<option value="">Todos</option>
{(options[key] ?? []).map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
))}
<Field label="Regime">
<select
className={control}
value={form.data.regime}
onChange={(e) => form.setData('regime', e.target.value)}
>
<option value="">Todos</option>
{Object.entries(regimes).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
<Field label="Estado">
<select
className={control}
value={form.data.state}
onChange={(e) => form.setData('state', e.target.value)}
>
<option value="">Todos</option>
{Object.entries(states).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</Field>
<Field label="Prazo até (data local do procedimento)">
<input
className={control}
type="date"
value={form.data.deadline_before}
onChange={(e) =>
form.setData('deadline_before', e.target.value)
}
/>
</Field>
<button className={button}>Pesquisar</button>
</form>
{!items.data.length && (
<p role="status">Nenhum anúncio corresponde aos filtros.</p>
)}
<div className="grid gap-4 md:grid-cols-2">
{items.data.map((item) => (
<article key={item.id} className={card}>
<h2 className="text-xl font-semibold">
<Link
className="underline"
href={`/concursos/${item.id}${query ? '?' + query : ''}`}
>
{String(item.publication.payload.title)}
</Link>
</h2>
<p>
{String(item.publication.payload.organization_name)}{' '}
· {states[item.state]}
</p>
<p>
Entrega:{' '}
{String(item.publication.payload.deadline).replace(
'T',
' ',
)}{' '}
· {String(item.publication.payload.timezone)}
</p>
<p>{String(item.publication.payload.object)}</p>
</article>
))}
</div>
<nav aria-label="Paginação">
{items.links.map((link, i) =>
link.url ? (
<Link
key={i}
className={`mx-2 underline ${link.active ? 'font-bold' : ''}`}
href={link.url}
>
{link.label
.replace('&laquo;', '«')
.replace('&raquo;', '»')}
</Link>
) : (
<span key={i} className="mx-2">
{link.label
.replace('&laquo;', '«')
.replace('&raquo;', '»')}
</span>
),
)}
</nav>
</Frame>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Link } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { states, type Publication } from './shared';
export default function Manage({
items,
watched = false,
}: {
items: {
id: string;
title?: string;
version?: number;
published?: boolean;
publication?: Publication;
state?: string;
changed?: boolean;
}[];
watched?: boolean;
}) {
return (
<Frame
title={
watched
? 'Concursos que acompanho'
: 'Gestão de concursos e anúncios'
}
>
<Link className="underline" href="/concursos">
Catálogo público
</Link>
{!watched && (
<>
<Link className={button} href="/management/concursos/novo">
Novo concurso/anúncio
</Link>
<Link className="ml-3 underline" href="/gestao-conteudos">
Entrada única de gestão
</Link>
<p>
Guardar → recuperar → pré-visualizar → publicar →
actualizar/prorrogar → publicar resultado ou
cancelamento. Carregue documentos na biblioteca
existente, acompanhe o scan e autorize separadamente os
anexos públicos. O rascunho pode ficar incompleto.
</p>
<p>
Para actualizar, reabra o concurso, guarde as
alterações, escolha o acto e confira a prévia. A data de
publicação no portal não substitui a data de origem.
Acompanhar não apresenta proposta.
</p>
</>
)}
{!items.length && <p>Sem registos.</p>}
{items.map((item) => (
<article key={item.id} className={`${card} my-3`}>
<h2 className="font-semibold">
{item.title ?? String(item.publication?.payload.title)}
</h2>
{watched ? (
<>
<p>
{states[item.state ?? '']} ·{' '}
{item.changed
? 'Há novos actos desde que começou a acompanhar'
: 'Sem novos actos desde o registo de acompanhamento'}
</p>
<p>
O acompanhamento é consultado nesta área; não
afirma envio de alertas por email.
</p>
<Link
className="underline"
href={`/concursos/${item.id}`}
>
Consultar concurso
</Link>
</>
) : (
<>
<p>
Versão {item.version} ·{' '}
{item.published
? 'Com acto publicado; alterações em preparação são separadas'
: 'Rascunho privado'}
</p>
<Link
className="underline"
href={`/management/concursos/${item.id}`}
>
Recuperar e editar
</Link>
{item.published && (
<Link
className="ml-3 underline"
href={`/concursos/${item.id}`}
>
Consultar publicação
</Link>
)}
</>
)}
</article>
))}
</Frame>
);
}
+253
View File
@@ -0,0 +1,253 @@
import type { ReactNode } from 'react';
export type Lot = { code: string; description: string; quantities: string };
export type Attachment = {
resource_id: number;
public: boolean;
purpose: string;
title?: string;
available?: boolean;
};
export type Draft = {
[key: string]: string | number | boolean | null | Lot[] | Attachment[];
};
export type Payload = Draft & {
opportunity?: { title: string; url: string } | null;
};
export type Publication = {
id: string;
version: number;
act: string;
payload: Payload;
published_at: string;
reason: string;
};
export const states: Record<string, string> = {
open: 'Aberto',
extended: 'Prorrogado',
deadline_ended: 'Prazo de propostas terminado',
cancelled: 'Cancelado',
results: 'Resultados publicados',
};
export const acts: Record<string, string> = {
initial: 'Anúncio inicial',
clarification: 'Esclarecimento',
correction: 'Rectificação',
extension: 'Prorrogação',
award: 'Resultado / adjudicação',
cancellation: 'Cancelamento',
other: 'Outro acto',
};
export const labels: Record<string, string> = {
reference: 'Referência',
object: 'Objecto',
responsible_unit: 'Unidade responsável / UGEA quando aplicável',
modality: 'Modalidade',
object_kind: 'Natureza do objecto',
quantities: 'Quantidades essenciais',
financier: 'Financiador',
instrument: 'Instrumento aplicável',
edition: 'Edição aplicável',
rule_source: 'Fonte das regras',
financing_agreement: 'Acordo de financiamento',
eligibility: 'Elegibilidade',
requirements: 'Requisitos de participação',
proposal_validity: 'Validade da proposta',
guarantee_status: 'Aplicabilidade de garantias',
guarantees: 'Garantias documentadas',
delivery_channel: 'Canal de entrega',
delivery_address: 'Endereço / instruções para apresentar proposta',
delivery_url: 'Plataforma de entrega',
public_contact: 'Contacto público autorizado',
dissemination: 'Meios de divulgação exigidos / adoptados e comprovativos',
origin_url: 'Anúncio de origem',
origin_published_at: 'Publicação de origem — referência do prazo',
clarifications_at: 'Limite para esclarecimentos',
deadline: 'Entrega de propostas',
opening_at: 'Abertura (quando aplicável)',
timezone: 'Fuso horário do procedimento',
minimum_days: 'Prazo mínimo documentado (dias)',
minimum_source: 'Fonte / citação do prazo mínimo',
minimum_edition: 'Edição da regra de prazo',
minimum_modality: 'Modalidade da regra de prazo',
minimum_day_basis: 'Base de contagem do prazo',
minimum_calendar_source: 'Fonte do calendário de dias úteis',
minimum_working_days: 'Dias de trabalho (1=segunda, 7=domingo)',
minimum_holidays: 'Feriados e exclusões documentados',
result: 'Acto ou resultado documentado',
};
export const control =
'w-full rounded border border-slate-400 bg-background p-2 text-foreground';
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="block space-y-1">
<span className="font-medium">{label}</span>
{children}
</label>
);
}
export function NoticeContent({ p }: { p: Payload }) {
const links = ['rule_source', 'origin_url', 'delivery_url'];
const names: Record<string, string> = {
works: 'Obras',
goods: 'Bens',
services: 'Serviços',
mixed: 'Misto',
other: 'Outro',
required: 'Exigidas',
not_required: 'Não exigidas',
unknown: 'Por determinar',
calendar: 'Dias corridos',
business: 'Dias úteis',
};
return (
<div className="space-y-5">
<h2 className="text-2xl font-semibold">{String(p.title ?? '')}</h2>
<p>
<strong>Entidade:</strong>{' '}
{String(p.organization_name ?? 'Por indicar')}
</p>
<p>
Regime:{' '}
{(
{
state: 'Contratação pública do Estado',
external: 'Financiamento externo',
private: 'Concurso privado',
other: 'Outro regime documentado',
} as Record<string, string>
)[String(p.regime)] ?? 'Por indicar'}
</p>
<section>
<h3 className="text-lg font-semibold">
Objecto e como participar
</h3>
<p className="whitespace-pre-wrap">{String(p.object ?? '')}</p>
<p className="my-2 rounded border p-3">
<strong>Entrega:</strong>{' '}
{String(p.deadline ?? 'Por indicar').replace('T', ' ')} ·{' '}
{String(p.timezone ?? '')}
<br />
{String(p.delivery_channel ?? '')} ·{' '}
{String(p.delivery_address ?? '')}
</p>
<p>
Sector: {String(p.sector_name ?? 'Por indicar')} ·
Localização: {String(p.territory_name ?? 'Por indicar')}
</p>
{p.project_name && (
<p>
Projecto relacionado: {String(p.project_name)}. Esta
relação não confirma execução ou contratação.
</p>
)}
{p.opportunity && (
<a className="underline" href={p.opportunity.url}>
Oportunidade relacionada: {p.opportunity.title}
</a>
)}
</section>
{p.deadline_previous && (
<p className="rounded border p-3">
<strong>Prorrogação:</strong> prazo anterior{' '}
{String(p.deadline_previous).replace('T', ' ')} (
{String(p.timezone_previous)}) → novo prazo{' '}
{String(p.deadline).replace('T', ' ')} ({String(p.timezone)}
).
</p>
)}
<dl className="grid gap-3 md:grid-cols-2">
{Object.entries(labels)
.filter(
([key]) =>
p[key] !== null &&
p[key] !== undefined &&
p[key] !== '',
)
.map(([key, label]) => (
<div
key={key}
className="rounded border p-3 break-words"
>
<dt className="font-semibold">{label}</dt>
<dd className="whitespace-pre-wrap">
{links.includes(key) ? (
<a
className="underline"
href={String(p[key])}
rel="noopener noreferrer"
>
{String(p[key])}
</a>
) : (
(
names[String(p[key])] ?? String(p[key])
).replace(/^(\d{4}-\d{2}-\d{2})T/, '$1 ')
)}
</dd>
</div>
))}
</dl>
<section>
<h3 className="text-lg font-semibold">Lotes</h3>
{((p.lots ?? []) as Lot[]).length ? (
<ul>
{(p.lots as Lot[]).map((lot, i) => (
<li key={i}>
{lot.code} — {lot.description} ·{' '}
{lot.quantities}
</li>
))}
</ul>
) : (
<p>Sem lotes indicados.</p>
)}
</section>
<section>
<h3 className="text-lg font-semibold">
Documentos do concurso
</h3>
<ul>
{((p.attachments ?? []) as Attachment[]).map((item, i) => (
<li key={i}>
{item.available === false ? (
<span>
{item.title} — documento actualmente
indisponível
</span>
) : (
<a
className="underline"
href={`/documentos/${item.resource_id}/download`}
>
{item.title ??
`Documento ${item.resource_id}`}{' '}
— {item.purpose}
</a>
)}
</li>
))}
</ul>
{!((p.attachments as Attachment[]) ?? []).length && (
<p>
Sem anexos públicos neste acto. Consulte o canal e o
anúncio de origem.
</p>
)}
</section>
<p className="rounded border p-3">
O InvesteMoz divulga este acto e não recebe propostas nesta
secção. Apresente a proposta no canal indicado nos documentos.
Esta divulgação não substitui os meios oficiais exigidos,
não comprova envio à UFSA ou ao financiador e não equivale à
execução do investimento.
</p>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Frame, card, button } from '@/pages/investment/shared';
import { NoticeContent, states, acts, type Publication } from './shared';
export default function Show({
id,
publication,
state,
history,
watching,
backUrl,
printMode,
}: {
id: string;
publication: Publication;
state: string;
history: Publication[];
watching: boolean;
backUrl: string;
printMode: boolean;
}) {
const { auth } = usePage<{ auth: { user: unknown | null } }>().props;
return (
<Frame title="Concurso e anúncio">
<Head title={String(publication.payload.title)} />
<style>
{
'@media print { nav, header, aside, footer, [data-slot="sidebar"], .no-print {display:none!important} main {margin:0!important; width:100%!important} details {display:block} }'
}
</style>
<div className="no-print flex flex-wrap gap-4">
<Link className="underline" href={backUrl}>
Voltar aos resultados
</Link>
<Link className="underline" href={`/concursos/${id}/imprimir`}>
Relatório imprimível
</Link>
{printMode && (
<button className={button} onClick={() => window.print()}>
Imprimir anúncio
</button>
)}
{auth?.user ? (
<button
className={button}
onClick={() =>
watching
? router.delete(`/concursos/${id}/acompanhar`)
: router.put(`/concursos/${id}/acompanhar`)
}
>
{watching
? 'Deixar de acompanhar'
: 'Acompanhar concurso'}
</button>
) : (
<Link className="underline" href="/login">
Entrar para acompanhar — conta simples
</Link>
)}
</div>
<p className="rounded border p-3">
<strong>{states[state]}</strong> · {acts[publication.act]} ·
Versão {publication.version}
</p>
<p>
Publicação deste acto no InvesteMoz: {publication.published_at}{' '}
(UTC). Esta data não reinicia o prazo de origem.
</p>
<article className={card}>
<NoticeContent p={publication.payload} />
</article>
<section className={card}>
<h2 className="text-xl font-semibold">
Actos publicados e histórico
</h2>
{history.map((v) => (
<details key={v.id} open={printMode}>
<summary>
{acts[v.act]} · versão {v.version} ·{' '}
{v.published_at} (UTC)
</summary>
<p className="my-2">Motivo público: {v.reason}</p>
<NoticeContent p={v.payload} />
</details>
))}
</section>
</Frame>
);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
use App\Http\Controllers\ProcurementController as Procurement;
use Illuminate\Support\Facades\Route;
Route::get('/concursos', [Procurement::class, 'index'])->name('procurement.index');
Route::get('/concursos/acompanhar', [Procurement::class, 'watched'])->middleware('auth');
Route::get('/concursos/{id}', [Procurement::class, 'show'])->whereUuid('id')->name('procurement.show');
Route::get('/concursos/{id}/imprimir', [Procurement::class, 'print'])->whereUuid('id');
Route::put('/concursos/{id}/acompanhar', [Procurement::class, 'watch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
Route::delete('/concursos/{id}/acompanhar', [Procurement::class, 'unwatch'])->whereUuid('id')->middleware(['auth', 'throttle:30,1']);
Route::middleware(['auth', 'verified'])->prefix('management/concursos')->group(function () {
Route::get('/', [Procurement::class, 'manage']);
Route::get('/novo', [Procurement::class, 'edit']);
Route::post('/', [Procurement::class, 'save']);
Route::get('/{id}', [Procurement::class, 'edit'])->whereUuid('id');
Route::put('/{id}', [Procurement::class, 'save'])->whereUuid('id');
Route::post('/{id}/previa', [Procurement::class, 'preview'])->whereUuid('id');
Route::post('/{id}/publicar', [Procurement::class, 'publish'])->whereUuid('id');
});
+2
View File
@@ -1,5 +1,7 @@
<?php
require __DIR__.'/procurement.php';
require __DIR__.'/existing-business.php';
require __DIR__.'/territorial-registry.php';
require __DIR__.'/partnerships.php';
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProcurementTest extends TestCase
{
use DatabaseTransactions;
private User $editor;
private string $organization;
protected function setUp(): void
{
parent::setUp();
$identity = DB::selectOne('select current_database() as database,inet_server_port() as port');
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->database);
$this->assertSame(55441, (int) $identity->port);
$this->withoutVite();
$this->editor = User::factory()->create();
config(['procurement.enabled' => true, 'portal.managers' => [$this->editor->email]]);
$this->organization = (string) Str::uuid();
DB::table('organizations')->insert(['id' => $this->organization, 'name' => 'SINTÉTICO QA — Entidade de ensaio', 'organization_type' => 'private']);
$this->actingAs($this->editor);
}
private function payload(string $regime = 'state'): array
{
return ['title' => 'SINTÉTICO QA — '.$regime, 'reference' => 'QA-'.Str::uuid(), 'organization_id' => $this->organization,
'regime' => $regime, 'modality' => 'Modalidade documentada de ensaio', 'object' => 'Objecto sintético de concurso para ensaio QA; não corresponde a contratação real.',
'instrument' => $regime === 'state' ? 'Decreto n.º 79/2022 — referência do âmbito estatal' : 'Regras documentadas do procedimento sintético',
'edition' => $regime === 'external' ? 'Banco Mundial, Setembro/2025 — exemplar de referência' : 'QA-2026',
'rule_source' => $regime === 'state' ? 'https://www.ufsa.gov.mz/divnovoreg.php' : 'https://example.invalid/qa-rules',
'origin_published_at' => '2026-10-20T09:00', 'deadline' => '2026-11-20T15:00', 'timezone' => 'Africa/Maputo',
'delivery_channel' => 'Canal externo documentado', 'delivery_address' => 'Endereço sintético para ensaio; não enviar propostas.',
'public_contact' => 'contacto-interno-que-nao-deve-sair@example.test', 'contact_authorized' => false, 'internal_notes' => 'SEGREDO-INTERNO-QA',
'public_reason' => 'Divulgação de anúncio sintético para ensaio QA, sem contratação real.',
'lots' => [['code' => 'L1', 'description' => 'Lote de bens sintéticos', 'quantities' => '10 unidades de ensaio']], 'attachments' => []];
}
private function create(array $payload): string
{
$response = $this->post('/management/concursos', ['version' => 0, 'reason' => 'Preparação sintética documentada para ensaio QA.', 'draft' => $payload])->assertSessionHasNoErrors()->assertRedirect();
return basename(parse_url($response->headers->get('Location'), PHP_URL_PATH));
}
private function save(string $id, array $payload): void
{
$this->put('/management/concursos/'.$id, ['version' => DB::table('portal_tenders')->where('id', $id)->value('version'), 'reason' => 'Actualização sintética de ensaio com histórico conservado.', 'draft' => $payload])->assertRedirect()->assertSessionHasNoErrors();
}
private function publish(string $id, string $act = 'initial'): array
{
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => $act])->assertOk()->json();
$this->post('/management/concursos/'.$id.'/publicar', ['act' => $act, 'hash' => $plan['hash'], 'reason' => $plan['payload']['public_reason']])->assertRedirect()->assertSessionHasNoErrors();
return $plan;
}
public function test_incomplete_draft_recovers_without_becoming_public_and_rejects_stale_save(): void
{
$id = $this->create(['title' => 'Preparação incompleta']);
$this->get('/management/concursos/'.$id)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertInertia(fn (Assert $p) => $p->component('procurement/edit')->where('record.draft.title', 'Preparação incompleta'));
$this->get('/concursos/'.$id)->assertNotFound();
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors(['organization_id', 'deadline', 'regime']);
$this->putJson('/management/concursos/'.$id, ['version' => 0, 'reason' => 'Pedido concorrente obsoleto, sem sobrescrever o rascunho.', 'draft' => ['title' => 'Não guardar']])->assertConflict();
$this->assertSame(1, DB::table('portal_tender_versions')->where('tender_id', $id)->count());
}
public function test_state_external_private_regimes_and_filters_are_public_without_ug_ea_or_guarantee_defaults(): void
{
$ids = [];
foreach (['state', 'external', 'private'] as $regime) {
$p = $this->payload($regime);
$ids[$regime] = $this->create($p);
$plan = $this->publish($ids[$regime]);
$this->assertArrayNotHasKey('responsible_unit', $plan['payload']);
$this->assertArrayNotHasKey('internal_notes', $plan['payload']);
$this->assertArrayNotHasKey('public_contact', $plan['payload']);
}
Auth::logout();
$this->get('/concursos?regime=external&entity='.$this->organization.'&q=SINT')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/index')->where('filters.regime', 'external')->has('items.data', 1)->where('items.data.0.id', $ids['external']));
$this->get('/concursos?q=&entity=&sector=&state=open')->assertOk()->assertInertia(fn (Assert $p) => $p->where('filters', ['state' => 'open']));
$this->get('/concursos/'.$ids['private'].'?regime=private&q=SINT&page=2')->assertOk()->assertInertia(fn (Assert $p) => $p->component('procurement/show')->where('backUrl', '/concursos?q=SINT&regime=private&page=2')->where('publication.payload.regime', 'private')->missing('publication.payload.internal_notes')->missing('publication.payload.public_contact')->has('history', 1));
$this->get('/concursos/'.$ids['state'].'/imprimir')->assertOk()->assertInertia(fn (Assert $p) => $p->where('printMode', true));
}
public function test_extension_preserves_publication_origin_and_history_and_expiry_is_not_an_award(): void
{
$p = $this->payload();
$id = $this->create($p);
$this->publish($id);
$first = DB::table('portal_tender_versions')->where('tender_id', $id)->where('act', 'initial')->first();
$p['deadline'] = '2026-11-25T16:00';
$this->save($id, $p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'correction'])->assertUnprocessable()->assertJsonValidationErrors('act');
$plan = $this->publish($id, 'extension');
$this->assertSame('2026-11-20T15:00', $plan['payload']['deadline_previous']);
$this->assertSame('2026-10-20T09:00', $plan['payload']['origin_published_at']);
$this->assertSame($first->payload, DB::table('portal_tender_versions')->where('id', $first->id)->value('payload'));
$this->travelTo(now()->setDate(2026, 11, 26));
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'deadline_ended')->where('publication.act', 'extension')->has('history', 2));
$this->travelBack();
$p['result'] = 'Resultado sintético de ensaio, não adjudicação real.';
$this->save($id, $p);
$this->publish($id, 'award');
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'results')->has('history', 3));
}
public function test_cancellation_and_later_clarification_do_not_reopen_the_procedure(): void
{
$p = $this->payload('private');
$id = $this->create($p);
$this->publish($id);
$p['result'] = 'Cancelamento sintético fundamentado para ensaio.';
$this->save($id, $p);
$this->publish($id, 'cancellation');
$p['object'] .= ' Esclarecimento posterior ao cancelamento.';
$this->save($id, $p);
$this->publish($id, 'clarification');
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('state', 'cancelled')->has('history', 3));
}
public function test_stale_preview_cannot_publish_new_draft_and_public_snapshot_stays_unchanged_until_explicit_publication(): void
{
$p = $this->payload();
$id = $this->create($p);
$plan = $this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk()->json();
$p['title'] = 'Nova edição ainda privada';
$this->save($id, $p);
$this->postJson('/management/concursos/'.$id.'/publicar', ['act' => 'initial', 'hash' => $plan['hash'], 'reason' => $p['public_reason']])->assertConflict();
$this->publish($id);
$p['title'] = 'Rascunho futuro privado';
$this->save($id, $p);
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.title', 'Nova edição ainda privada'));
}
public function test_documented_minimum_dates_and_business_calendar_are_specific_to_the_procedure(): void
{
$p = $this->payload();
$p['deadline'] = '2026-10-19T15:00';
$id = $this->create($p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
$p['origin_published_at'] = '2026-10-23T09:00';
$p['deadline'] = '2026-10-27T09:00';
$p += ['minimum_days' => 2, 'minimum_source' => 'Regra sintética de contagem, cláusula QA-1', 'minimum_edition' => 'QA-2026', 'minimum_modality' => $p['modality'], 'minimum_day_basis' => 'business', 'minimum_working_days' => '1,2,3,4,5', 'minimum_holidays' => '2026-10-26', 'minimum_calendar_source' => 'Calendário sintético QA: segunda a sexta, exclui 26/10. Não é calendário oficial.'];
$this->save($id, $p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('deadline');
$p['deadline'] = '2026-10-28T09:00';
$this->save($id, $p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertOk();
$p['minimum_modality'] = 'Outra modalidade';
$this->save($id, $p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('minimum_modality');
}
public function test_simple_account_can_follow_but_cannot_edit_or_publish_and_tracking_is_not_a_bid(): void
{
$id = $this->create($this->payload());
$this->publish($id);
$reader = User::factory()->unverified()->create();
$before = DB::table('portal_role_assignments')->count();
$this->actingAs($reader)->put('/concursos/'.$id.'/acompanhar')->assertRedirect();
$this->get('/concursos/acompanhar')->assertOk()->assertInertia(fn (Assert $p) => $p->where('watched', true)->has('items', 1)->where('items.0.changed', false));
$this->actingAs($this->editor);
$p = $this->payload();
$this->save($id, $p);
$this->publish($id, 'clarification');
$reader->forceFill(['email_verified_at' => now()])->save();
$this->actingAs($reader);
$this->get('/concursos/acompanhar')->assertInertia(fn (Assert $p) => $p->where('items.0.changed', true));
$this->get('/management/concursos')->assertForbidden();
$this->postJson('/management/concursos/'.$id.'/publicar', [])->assertForbidden();
$this->delete('/concursos/'.$id.'/acompanhar')->assertRedirect();
$this->assertSame($before, DB::table('portal_role_assignments')->count());
}
public function test_real_scanned_library_attachment_private_redaction_and_withdrawal_are_reused(): void
{
Storage::fake('local');
config(['queue.default' => 'sync', 'services.malware.scanner' => 'clamd', 'services.malware.socket' => null, 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]);
$this->assertTrue(app(MalwareScanner::class)->health()['available'], 'Current real scanner required');
$name = 'synthetic-tender-'.Str::uuid().'.txt';
$this->post('/management/portal-services/upload', ['file' => UploadedFile::fake()->createWithContent($name, 'Exemplo sintético de anúncio QA, sem valor jurídico.')])->assertSessionHasNoErrors();
$doc = DB::table('documents')->where('file_name', $name)->first();
$this->assertSame('clean', $doc->malware_scan_status);
$metadata = ['document_id' => $doc->id, 'title' => $name, 'category' => 'Synthetic QA', 'topic' => 'Tender QA', 'document_type' => 'unknown', 'issuer' => 'Synthetic QA only', 'source_url' => 'https://example.invalid/qa', 'language' => 'pt', 'version_label' => 'QA-1', 'territorial_scope' => 'national', 'source_confirmation' => 'identified', 'legal_status' => 'unknown', 'rights_status' => 'authorized', 'data_classification' => 'public', 'source_reference' => 'Synthetic QA only, not an official notice.', 'legislation' => false, 'essential' => false, 'independent_review' => false, 'associations' => []];
$this->post('/management/portal-services/resources', $metadata)->assertSessionHasNoErrors();
$resource = DB::table('portal_resources')->where('document_id', $doc->id)->first();
$this->assertNotNull($resource);
$p = $this->payload('external');
$p['attachments'] = [['resource_id' => $resource->id, 'public' => true, 'purpose' => 'Anúncio de ensaio']];
$id = $this->create($p);
$this->postJson('/management/concursos/'.$id.'/previa', ['act' => 'initial'])->assertUnprocessable()->assertJsonValidationErrors('attachments');
$this->post('/management/portal-services/resources/'.$resource->id, ['published' => true])->assertSessionHasNoErrors();
$this->publish($id);
Auth::logout();
$this->get('/documentos/'.$resource->id.'/download')->assertOk()->assertDownload($name);
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', true));
$this->actingAs($this->editor)->post('/management/portal-services/resources/'.$resource->id, ['published' => false])->assertSessionHasNoErrors();
Auth::logout();
$this->get('/documentos/'.$resource->id.'/download')->assertNotFound();
$this->get('/concursos/'.$id)->assertInertia(fn (Assert $p) => $p->where('publication.payload.attachments.0.available', false));
}
}