pesquisa avancada no portal

This commit is contained in:
2026-11-04 12:38:21 +02:00
parent bf2c054943
commit c5756c0282
15 changed files with 639 additions and 7 deletions
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\Sector;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class GlobalSearchController extends Controller
{
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
{
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$term = trim($input['q'] ?? '');
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
if ($tokens !== []) {
$factories = [
'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
},
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
$query = $catalogue->query(['include_national' => true]);
foreach ($tokens as $token) {
$query->where(function ($match) use ($catalogue, $search, $token) {
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
if (preg_match('/^[a-z0-9]+$/', $token)) {
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
}
});
}
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
},
'Negócios' => function () use ($search, $tokens, $term) {
$query = ExistingBusiness::query()->published();
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
},
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
},
'Documentos e legislação' => function () use ($search, $tokens, $term) {
$query = 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.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
$query->select('r.id', 'r.title', 'r.description');
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
},
'Artigos' => function () use ($search, $tokens, $term) {
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
}
$query = app(EditorialReading::class)->publicResources()
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
},
'Sectores' => function () use ($search, $tokens, $term) {
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
},
'Localizações' => function () use ($search, $tokens, $term) {
$query = app(InvestmentGeography::class)->publicReferences()
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
},
'Zonas de investimento' => function () use ($search, $tokens, $term) {
$query = app(InvestmentZones::class)->visible();
$search->constrain($query, ['name', 'code'], $tokens);
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
},
'Parcerias' => function () use ($search, $tokens, $term) {
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
fn ($item) => $search->score($item['title'], $term, $tokens));
}
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
}
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
$query->reorder();
$search->orderByRelevance($query, $title, $term, $tokens, $id);
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
}
/** Public service entry points and terms used to find them. */
private function pages(): array
{
return [
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
];
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services;
use Illuminate\Database\QueryException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
$unavailable = [];
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
} catch (QueryException $exception) {
report($exception);
$unavailable[] = $label;
}
}
// If a source fails after its count, remove it and recompute the page offsets.
do {
$failed = false;
$total = array_sum(array_column($sources, 'count'));
$last = max(1, (int) ceil($total / $size));
$page = min(max(1, $requestedPage), $last);
$first = ($page - 1) * $size;
$items = [];
$positions = array_fill_keys(array_keys($sources), 0);
$buffers = array_fill_keys(array_keys($sources), []);
for ($position = 0; $position < min($total, $first + $size); $position++) {
$best = null;
$bestScore = PHP_INT_MIN;
foreach ($sources as $label => $source) {
if ($buffers[$label] === [] && $positions[$label] < $source['count']) {
try {
$batch = iterator_to_array((function () use ($source, $positions, $label, $size) {
yield from ($source['fetch'])($positions[$label], $size);
})(), false);
$positions[$label] += count($batch);
$buffers[$label] = $batch;
if ($batch === []) {
$positions[$label] = $source['count'];
}
} catch (QueryException $exception) {
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
$failed = true;
break;
}
}
if ($buffers[$label] === []) {
continue;
}
$candidateScore = $score ? $score($buffers[$label][0]) : 0;
if ($best === null || $candidateScore > $bestScore) {
$best = $label;
$bestScore = $candidateScore;
}
}
if ($failed || $best === null) {
break;
}
$item = array_shift($buffers[$best]);
if ($position >= $first) {
$items[] = [...$item, 'category' => $best];
}
}
} while ($failed);
return ['data' => $items, 'total' => $total, 'current_page' => $page, 'last_page' => $last,
'per_page' => $size, 'unavailable' => $unavailable];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
/** @return list<string> */
public function tokens(string $query): array
{
$normalized = mb_strtolower(Str::ascii($query));
$tokens = array_values(array_filter(array_unique(array_map(
fn ($word) => trim($word, ".,;:!?\"'()[]{}"),
preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: []
)), fn ($word) => $word !== ''));
if (count($tokens) > 1) {
$meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma']));
return $meaningful === [] ? $tokens : $meaningful;
}
return $tokens;
}
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
public function variants(string $token): array
{
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
}
return [$token];
}
public function pattern(string $token): string
{
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
/** Columns are trusted application constants, never user input. */
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
foreach ($this->variants($token) as $variant) {
$query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]);
}
}
}
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
$query->where(fn ($match) => $this->orFields($match, $columns, $token));
}
}
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
foreach ($tokens as $token) {
if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) {
return false;
}
}
return true;
}
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0);
foreach ($tokens as $token) {
if ($this->matches($title, [$token])) {
$score += 20;
}
}
return $score;
}
/** The same title score used for in-memory pages, with bound search terms. */
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, $this->pattern($phrase)];
foreach ($tokens as $token) {
$conditions = [];
foreach ($this->variants($token) as $variant) {
$conditions[] = "{$column} LIKE ? ESCAPE '!'";
$bindings[] = $this->pattern($variant);
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END';
}
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
}
}