From d325b72fad41d20e42deba15dbb51f9e024e61b3 Mon Sep 17 00:00:00 2001
From: nala
Date: Fri, 13 Nov 2026 15:06:11 +0200
Subject: [PATCH] Improve investor quick search with typed counts, product
categories and licensing guidance
---
.../Controllers/GlobalSearchController.php | 158 +++++++++++++++++-
app/Services/FederatedSearch.php | 15 +-
app/Services/SearchTerms.php | 94 ++++++++++-
.../unification/traceability-and-manual.md | 10 ++
.../v2/auditoria-portal/matriz-requisitos.csv | 1 +
resources/js/components/portal-hero.tsx | 6 +-
resources/js/pages/portal/search.tsx | 52 +++++-
tests/Feature/GlobalSearchCountTest.php | 106 ++++++++++++
tests/Feature/GlobalSearchTest.php | 13 ++
tests/Unit/SearchTermsTest.php | 27 +++
tests/frontend/global-search.test.mjs | 47 ++++++
11 files changed, 508 insertions(+), 21 deletions(-)
create mode 100644 tests/Feature/GlobalSearchCountTest.php
diff --git a/app/Http/Controllers/GlobalSearchController.php b/app/Http/Controllers/GlobalSearchController.php
index ca7e97b5..6a0b08f2 100644
--- a/app/Http/Controllers/GlobalSearchController.php
+++ b/app/Http/Controllers/GlobalSearchController.php
@@ -3,31 +3,105 @@
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
+use App\Models\Opportunity;
use App\Models\Sector;
+use App\Services\CorridorAgribusinessOpportunities;
use App\Services\EditorialReading;
use App\Services\FederatedSearch;
+use App\Services\FundingInstitutionDetails;
+use App\Services\FundingProgrammes;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
+use App\Services\LegacyContent;
use App\Services\SearchTerms;
use App\Services\SectorOrganization;
+use App\Services\SectorResourceReferences;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
+use Symfony\Component\HttpKernel\Exception\HttpException;
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'] ?? '');
+ $input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'], 'category' => ['nullable', 'in:all,physical,financial']]);
+ $original = trim($input['q'] ?? '');
+ $intent = $search->requestIntent($original);
+ if (isset($input['category']) && ($intent === null || $intent['type'] === 'products')) {
+ $intent = ['type' => 'products', 'label' => 'Produtos e localização', 'kind' => null,
+ 'filter' => $intent['filter'] ?? $original,
+ 'category' => match ($input['category']) {
+ 'physical' => 'Produtos e localizações', 'financial' => 'Produtos financeiros', default => null,
+ }];
+ }
+ $term = $intent['filter'] ?? $original;
$tokens = $search->tokens($term);
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
- if ($tokens !== []) {
+ if ($tokens !== [] || $intent !== null) {
$factories = [
+ 'Produtos e localizações' => function () use ($search, $tokens, $term) {
+ try {
+ return $this->productReferences($search, $tokens, $term);
+ } catch (\RuntimeException $exception) {
+ throw new HttpException(503, 'Referências de produtos temporariamente indisponíveis.', $exception);
+ }
+ },
+ 'Produtos financeiros' => function () use ($search, $tokens, $term) {
+ $service = app(FundingProgrammes::class);
+ if (! $service->installed()) {
+ return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
+ }
+ $query = $service->publicQuery()->select('p.id', 'v.payload')->selectRaw("v.payload->>'title' AS title");
+ foreach ($tokens as $token) {
+ $query->where(function ($match) use ($search, $token) {
+ $search->orFields($match, ["(v.payload->>'title')",
+ "(SELECT string_agg(product->>'title', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product)",
+ "(SELECT string_agg(place->>'location_text', ' ') FROM jsonb_array_elements(COALESCE(v.payload->'locations','[]'::jsonb)) place)",
+ "(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.locations[*].territory_path[*]') node)",
+ "(SELECT string_agg(node->>'name', ' ') FROM jsonb_path_query(v.payload,'$.products[*].territory_paths[*][*]') node)"], $token);
+ foreach (FundingInstitutionDetails::NEEDS as $code => $label) {
+ $text = in_array($code, ['investment', 'working_capital'], true) ? 'Financiamento '.$label : $label;
+ if ($search->matches($text, [$token])) {
+ $match->orWhereJsonContains('v.payload->institution_services', $code)->orWhereJsonContains('v.payload->funding_needs', $code)
+ ->orWhereRaw("EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(v.payload->'products','[]'::jsonb)) product WHERE product->'needs' @> ?::jsonb)", [json_encode([$code], JSON_THROW_ON_ERROR)]);
+ }
+ }
+ foreach (FundingInstitutionDetails::NATURES as $code => $label) {
+ if ($search->matches($label, [$token])) {
+ $match->orWhere('v.payload->institution_nature', $code);
+ }
+ }
+ });
+ }
+
+ return $this->source($query, $search, $term, $tokens, "(v.payload->>'title')", 'p.id', function ($row) {
+ $p = json_decode($row->payload, true, 512, JSON_THROW_ON_ERROR);
+ $products = array_values(array_unique(array_filter(array_column($p['products'] ?? [], 'title'))));
+ $services = array_values(array_intersect_key(FundingInstitutionDetails::NEEDS, array_flip($p['institution_services'] ?? [])));
+ $places = [];
+ foreach ($p['locations'] ?? [] as $location) {
+ if (! empty($location['location_text'])) {
+ $places[] = $location['location_text'];
+ }
+ foreach ($location['territory_path'] ?? [] as $node) {
+ if ($node['type'] !== 'country') {
+ $places[] = $node['name'];
+ }
+ }
+ }
+ $national = array_any($p['products'] ?? [], fn ($product) => ($product['coverage'] ?? '') === 'national');
+
+ return ['title' => $row->title, 'description' => implode(' · ', $products ?: $services) ?: 'Instituição financeira.',
+ 'location' => $places === [] ? null : 'Presença física: '.implode(' · ', array_unique($places)),
+ 'coverage' => $national ? 'Existe produto com cobertura nacional indicada; consulte as condições.' : 'Cobertura financeira: consultar cada produto.',
+ 'url' => '/fundos/'.$row->id];
+ });
+ },
'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)
@@ -63,9 +137,15 @@ class GlobalSearchController extends Controller
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) {
+ 'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term, $intent) {
$query = InvestmentPortfolioController::visible();
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
+ if ($intent !== null && $intent['kind'] !== null) {
+ [$column, $table] = ['corridor' => ['corridor_id', 'development_corridors'], 'cluster' => ['cluster_id', 'economic_clusters'], 'project' => ['project_id', 'projects']][$intent['kind']];
+ $query->whereNotNull($column)->whereExists(fn ($entity) => $entity->selectRaw('1')->from($table)
+ ->whereColumn($table.'.id', 'investment_dossiers.'.$column)->whereNull($table.'.deleted_at'));
+ // The existing unique entity key already prevents duplicate dossiers.
+ }
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]);
@@ -126,13 +206,75 @@ class GlobalSearchController extends Controller
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
},
];
+ if ($intent !== null) {
+ $categories = $intent['category'] === null ? ['Produtos e localizações', 'Produtos financeiros'] : [$intent['category']];
+ $factories = array_intersect_key($factories, array_flip($categories));
+ }
$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]);
+ $answer = $intent === null ? null : ['type' => $intent['type'], 'label' => $intent['label'], 'filter' => $term,
+ 'count' => $results['unavailable'] === [] ? $results['total'] : null];
+
+ if ($answer !== null && $intent['type'] === 'products') {
+ $answer['category'] = match ($intent['category']) {
+ 'Produtos e localizações' => 'physical', 'Produtos financeiros' => 'financial', default => 'all',
+ };
+ }
+
+ return Inertia::render('portal/search', ['query' => $original, 'results' => $results, 'answer' => $answer]);
}
+ /** @param list $tokens
+ * @return array{count:int,fetch:callable(int,int):iterable>}
+ */
+ private function productReferences(SearchTerms $search, array $tokens, string $term): array
+ {
+ $rows = [];
+ foreach (['mineracao', 'energia-especifica'] as $sector) {
+ if (! Sector::where('slug', $sector)->where('is_active', true)->whereNotNull('published_at')->exists()) {
+ continue;
+ }
+ $data = app(SectorResourceReferences::class)->view($sector);
+ foreach ($data['groups'] as $group) {
+ $locations = array_values(array_filter($group['location_labels'], fn ($name) => $search->matches($term, $search->tokens($name))));
+ $rows[] = ['title' => $group['name'], 'description' => $sector === 'mineracao' ? 'Referência mineral; não identifica uma concessão ou produção disponível.' : 'Segmento energético e cadeia de valor.',
+ 'location' => $group['locations_text'], 'url' => '/sectores/'.$sector.'?'.http_build_query(['resource_group' => $group['id'],
+ ...(count($locations) === 1 ? ['resource_location' => $locations[0]] : [])])];
+ }
+ }
+ if (app(LegacyContent::class)->available() && Sector::where('slug', 'agronegocio')->where('is_active', true)->whereNotNull('published_at')->exists()) {
+ try {
+ app(LegacyContent::class)->article('agronegocio', false);
+ $entries = app(CorridorAgribusinessOpportunities::class)->entries();
+ $published = Opportunity::published()->whereIn('management_creation_key', array_column($entries, 'key'))->pluck('slug', 'management_creation_key');
+ foreach ($entries as $entry) {
+ if (! isset($published[$entry['key']])) {
+ continue;
+ }
+ $rows[] = ['title' => $entry['chain'], 'description' => 'Cadeia agrícola: '.$entry['summary'], 'location' => 'Enquadramento territorial: '.$entry['corridor'],
+ 'url' => '/oportunidades/'.rawurlencode($published[$entry['key']])];
+ }
+ } catch (HttpException $exception) {
+ if ($exception->getStatusCode() !== 404) {
+ throw $exception;
+ }
+ }
+ }
+ $rows = array_values(array_filter($rows, fn ($row) => $search->matches(implode(' ', $row), $tokens)));
+ usort($rows, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) ?: strcmp($a['title'], $b['title']));
+
+ return ['count' => count($rows), 'fetch' => fn ($offset, $limit) => array_slice($rows, $offset, $limit)];
+ }
+
+ /** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
+ * @param list $tokens
+ * @param callable(mixed):array $present
+ * @param literal-string $title
+ * @param literal-string $id
+ * @return array{count:int,fetch:callable(int,int):iterable>}
+ */
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
{
$count = (clone $query)->reorder()->count();
@@ -142,10 +284,14 @@ class GlobalSearchController extends Controller
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. */
+ /** Public service entry points and terms used to find them.
+ * @return list
+ */
private function pages(): array
{
return [
+ ['title' => 'Licenciamento da empresa', 'description' => 'Roteiro de constituição e licenciamento conforme a actividade e localização.', 'url' => '/como-investir#planeamento'],
+ ['title' => 'Preparar licenciamento no dossier', 'description' => 'Continuar a preparação na etapa de licenciamento prévio.', 'url' => '/investimentos/iniciar?step=S08'],
['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'],
diff --git a/app/Services/FederatedSearch.php b/app/Services/FederatedSearch.php
index dfeb49b3..6513be34 100644
--- a/app/Services/FederatedSearch.php
+++ b/app/Services/FederatedSearch.php
@@ -3,11 +3,14 @@
namespace App\Services;
use Illuminate\Database\QueryException;
+use Symfony\Component\HttpKernel\Exception\HttpException;
/** Combines independent, already-authorised sources without loading complete catalogues. */
final class FederatedSearch
{
- /** @param array $factories */
+ /** @param array>}> $factories
+ * @return array{data:list>,total:int,current_page:int,last_page:int,per_page:int,unavailable:list}
+ */
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
{
$sources = [];
@@ -15,7 +18,10 @@ final class FederatedSearch
foreach ($factories as $label => $factory) {
try {
$sources[$label] = $factory();
- } catch (QueryException $exception) {
+ } catch (QueryException|HttpException $exception) {
+ if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
+ throw $exception;
+ }
report($exception);
$unavailable[] = $label;
}
@@ -45,7 +51,10 @@ final class FederatedSearch
if ($batch === []) {
$positions[$label] = $source['count'];
}
- } catch (QueryException $exception) {
+ } catch (QueryException|HttpException $exception) {
+ if ($exception instanceof HttpException && ! in_array($exception->getStatusCode(), [404, 503], true)) {
+ throw $exception;
+ }
report($exception);
$unavailable[] = $label;
unset($sources[$label]);
diff --git a/app/Services/SearchTerms.php b/app/Services/SearchTerms.php
index 6258afed..5f536709 100644
--- a/app/Services/SearchTerms.php
+++ b/app/Services/SearchTerms.php
@@ -7,6 +7,64 @@ use Illuminate\Support\Str;
/** Text matching shared by the database and the built-in page catalogue. */
final class SearchTerms
{
+ /** @return array{type:string,category:?string,label:string,kind:?string,filter:string}|null */
+ public function requestIntent(string $query): ?array
+ {
+ if ($count = $this->countIntent($query)) {
+ return ['type' => 'count', ...$count];
+ }
+ $normalized = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
+ if (preg_match('/^(?:como|onde|quero|preciso de)\s+(?:(?:posso|faco para|devo)\s+)?licenciar(?:$|\s+(?:(?:uma|a|minha|o|um|meu)\s+)?(?:empresa|negocio|actividade|atividade|investimento)\b)/', $normalized)) {
+ return ['type' => 'guide', 'category' => 'Páginas e serviços', 'label' => 'Licenciamento da empresa', 'kind' => null, 'filter' => 'licenciamento'];
+ }
+ $product = null;
+ if (preg_match('/^(?:(?:tipos?|lista|catalogo) de |quais (?:sao )?(?:os )?)?produtos?\b(.*)$/', $normalized, $match)) {
+ $product = $match[1];
+ } elseif (preg_match('/^onde (?:encontrar|produzir|se produz|existem|ha)\s+(.+)$/', $normalized, $match)) {
+ $product = $match[1];
+ }
+ if ($product !== null) {
+ $category = preg_match('/\bfinanceiros?\b/', $product) ? 'Produtos financeiros' : (preg_match('/\bfisicos?\b/', $product) ? 'Produtos e localizações' : null);
+ $product = preg_replace('/\b(?:e (?:a )?(?:sua )?localizacao|e (?:as )?(?:suas )?localizacoes|financeiros?|fisicos?|existem|disponiveis|no portal)\b/', ' ', $product);
+
+ return ['type' => 'products', 'category' => $category, 'label' => 'Produtos e localização', 'kind' => null, 'filter' => trim(preg_replace('/\s+/', ' ', $product))];
+ }
+ if (preg_match('/^(?:listar|mostrar|mostre|ver|quais (?:sao )?(?:os|as))\s+(.+)$/', $normalized, $match)
+ && ($list = $this->countIntent('quantos '.$match[1]))) {
+ return ['type' => 'list', ...$list];
+ }
+
+ return null;
+ }
+
+ /** @return array{category:string,label:string,kind:?string,filter:string}|null */
+ public function countIntent(string $query): ?array
+ {
+ $query = trim(mb_strtolower(Str::ascii($query)), " \t\n\r\0\x0B?!.");
+ $types = [
+ 'corredor|corredores' => ['Carteira: projectos, clusters e corredores', 'corredores', 'corridor'],
+ 'cluster|clusters' => ['Carteira: projectos, clusters e corredores', 'clusters', 'cluster'],
+ 'projectos? ancora|projetos? ancora' => ['Carteira: projectos, clusters e corredores', 'projectos âncora', 'project'],
+ 'oportunidade|oportunidades' => ['Oportunidades', 'oportunidades', null],
+ 'negocio|negocios' => ['Negócios', 'negócios', null],
+ 'documento|documentos' => ['Documentos e legislação', 'documentos', null],
+ 'artigo|artigos' => ['Artigos', 'artigos', null],
+ 'sector|sectores|setor|setores' => ['Sectores', 'sectores', null],
+ 'localizacao|localizacoes' => ['Localizações', 'localizações', null],
+ 'zonas? de investimento|zona|zonas' => ['Zonas de investimento', 'zonas de investimento', null],
+ 'parceria|parcerias' => ['Parcerias', 'parcerias', null],
+ ];
+ foreach ($types as $pattern => [$category, $label, $kind]) {
+ if (preg_match('/^(?:quantos|quantas|numero de|quantidade de|total de|qual (?:e )?o numero de)\s+(?:'.$pattern.')\b(.*)$/u', $query, $match)) {
+ $filter = preg_replace('/\b(?:no portal|no investemoz|em mocambique|de investimento|existem|existe|existentes|existente|ha|temos|estao|sao|disponiveis|disponivel|publicados|publicadas|registados|registadas|cadastrados|cadastradas)\b/u', ' ', $match[1]);
+
+ return ['category' => $category, 'label' => $label, 'kind' => $kind, 'filter' => trim(preg_replace('/\s+/', ' ', $filter))];
+ }
+ }
+
+ return null;
+ }
+
/** @return list */
public function tokens(string $query): array
{
@@ -24,13 +82,16 @@ final class SearchTerms
return $tokens;
}
- /** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
+ /** Known Portuguese spelling and number variants; do not infer semantic equivalence.
+ * @return list
+ */
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) {
+ ['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas'],
+ ['banco', 'bancos'], ['seguro', 'seguros'], ['produto', 'produtos'], ['agricola', 'agricolas']] as $group) {
if (in_array($token, $group, true)) {
return $group;
}
@@ -44,7 +105,10 @@ final class SearchTerms
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
}
- /** Columns are trusted application constants, never user input. */
+ /** Columns are trusted application constants, never user input.
+ * @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
+ * @param list $columns
+ */
public function orFields($query, array $columns, string $token): void
{
foreach ($columns as $column) {
@@ -54,6 +118,10 @@ final class SearchTerms
}
}
+ /** @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
+ * @param list $columns
+ * @param list $tokens
+ */
public function constrain($query, array $columns, array $tokens): void
{
foreach ($tokens as $token) {
@@ -61,6 +129,7 @@ final class SearchTerms
}
}
+ /** @param list $tokens */
public function matches(string $text, array $tokens): bool
{
$text = mb_strtolower(Str::ascii($text));
@@ -73,11 +142,15 @@ final class SearchTerms
return true;
}
+ /** @param literal-string $column
+ * @return literal-string
+ */
private function normalizedColumn(string $column): string
{
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
}
+ /** @param list $tokens */
public function score(string $title, string $phrase, array $tokens): int
{
$title = mb_strtolower(Str::ascii($title));
@@ -92,7 +165,12 @@ final class SearchTerms
return $score;
}
- /** The same title score used for in-memory pages, with bound search terms. */
+ /** The same title score used for in-memory pages, with bound search terms.
+ * @param \Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Query\Builder $query
+ * @param list $tokens
+ * @param literal-string $titleColumn
+ * @param literal-string $tieColumn
+ */
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
{
$column = $this->normalizedColumn($titleColumn);
@@ -107,6 +185,12 @@ final class SearchTerms
}
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
}
- $query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
+ $query->orderByRaw('('.$sql.') DESC', $bindings);
+ if (str_contains($titleColumn, '(')) {
+ $query->orderByRaw($titleColumn);
+ } else {
+ $query->orderBy($titleColumn);
+ }
+ $query->orderBy($tieColumn);
}
}
diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md
index 8f945fc8..073896e8 100644
--- a/docs/investmoz/unification/traceability-and-manual.md
+++ b/docs/investmoz/unification/traceability-and-manual.md
@@ -1,3 +1,13 @@
+## 13/11/2026 — pesquisa rápida: intenções e categorias, implementada em QA
+
+A pesquisa reconhece contagens do catálogo publicado (por exemplo, «quantos corredores existem»), sem misturar projectos ou oportunidades apenas por mencionarem corredores. Zero registos e indisponibilidade de uma fonte têm apresentação distinta. «Como licenciar uma empresa» conduz ao roteiro existente e à preparação de S08 no dossier, sem inventar requisitos oficiais.
+
+Produtos físicos e financeiros aparecem em categorias separadas, com opções Todos / Produtos físicos / Produtos financeiros e preservação do filtro e paginação. Exemplos: «tipo de produtos e sua localização», «onde encontrar grafite em Cabo Delgado» e «produtos financeiros em Nampula». Reutiliza as referências aceites de Mineração/Energia, oportunidades agrícolas publicadas e versões publicadas das instituições. A ligação mineral conserva grupo/local seleccionado; a ligação financeira abre a instituição existente. Presença física e cobertura financeira aparecem separadas. Rascunhos e publicações futuras não são divulgados.
+
+Limites: localização nestes exemplos é correspondência textual aos dados documentados; não representa geocodificação, elegibilidade ou garantia de oferta/financiamento. Só entram conteúdos publicados das fontes efectivamente disponíveis. Não é uma resposta aberta por IA. Erros 404/503 de fontes são identificados sem ocultar os outros resultados; 403 continua recusado.
+
+Verificação focada em QA isolado 55441: integração 4 testes/118 asserções; regressão federada 3 aprovados e 1 ignorado por guarda de ambiente; unidade 8 testes/39 asserções; frontend 7 testes. Tipos TypeScript, PHPStan e compilação aprovados. Provas em `outputs/quick-search-20261113/`. Sem migrações, alterações de dados ou permissões. Conferência visual no navegador e instalação desta candidata ainda pendentes. Preservar as entregas e os doze lotes demográficos já aceites.
+
## 13/11/2026 — erro 500 corrigido e verificado no PHP web, revisão 4988ca89
A recusa de acesso anterior estava corrigida, mas a abertura do instalador revelou outra incompatibilidade: o PHP-FPM bloqueia `proc_open`; a criação de `Symfony Process` na pré-visualização provocava 500. Os registos de 13:49:58 e 13:50:10 identificaram esta causa. Conferência de leitura confirmou que não existiam tabelas demográficas nem recibos de importação no destino.
diff --git a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
index 0c5ff223..6dba54fc 100644
--- a/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
+++ b/docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv
@@ -11127,3 +11127,4 @@ AUD-SIM-L1-P132,"Exigibilidade em uso próprio,comissão,consignação e adianta
AUD-SIM-L1-P133,Saída de regimes suspensivos e operações de viagens,Refinamento Tempo24/10 do saldoL1; origemAUD-SIM-INT02; CIVA2016 PDF8–9 e alterações aplicáveis,Simulador/IVA — cobertura por operacao,/simulador,"P133 instalado a48e9749: Cessação efectiva de regime7(8), distinta de mudança de armazém/transferência; viagens7(9)/RIVA40 pelo primeiro entre integral e início/primeiro serviço, sem subtrair dia nem inferir ordem intradiária. Exigibilidade8 por factura/recebimento separada, incluindo sinal e saldo integral sem dupla soma. Sem montante de imposto, desembaraço, direito ou ICE apurados.",Disponível na homologação a48e9749; delta instalado conferido no âmbito demonstrado.,29testes frontend; API privada1teste/68asserções; tipos/build; Edge gravação/recuperação/relatório;10operações/5períodos/12eventos.,"Demonstrado em QA e entregue na homologação a48e9749, no âmbito do ramo","Sem pendência de entrega neste recorte de margem/eventos. Sem cálculo de IVA devido ou dedução. Continuidade do apuramento/liquidação em AUD-SIM-INT02; deduções/regularizações nos lotes próprios, incluindoL2/L3, não encerradas. Factura/modelo oficial/regularização não executados.","Fontes do regime fiscal e evento específico cotejadas; moedaMZN para base, âmbito completo por operador/período, dados/datas/referências concretos. P104 não bloqueia classificação/eventos independentes.",Condicoes/fonte/periodo desta operacao -> calculo ou nao aplicacao explicada -> gravacao -> recuperacao -> relatorio coerentes; desconhecido nao e zero. Preservar oportunidade e historico.,Saldo L1; sequencia do incremento indicada,,AUD-SIM-INT02,1,,,,,,,,,,"P129/P133 entregues conjuntamente a48e9749 sobre df43aeb1;123fechos preservados; backup/restauro e delta conferidos; limitação ligada a AUD-SIM-INT02. | Histórico: P133 QA concluído no âmbito demonstrado,123fechos preservados,sem repetição de provas aceites. | Histórico: Preparação QA viagens/eventos:123fechos inventariados antes dos testes; nenhuma perda confirmada. | Histórico: 25/10/2026 impedimento: Falta motor de saída de regimes suspensivos e evento das viagens,no regime específico. | Operacao discriminada do saldo anterior; e desenvolvimento nao demonstrado, nao regressao ou perda de fonte aceite.","Manter encerrado no âmbito de margem/eventos demonstrado, reutilizando provas. Conservar ligação ao trabalho fiscal restante em AUD-SIM-INT02: apuramento/liquidação, deduções e regularizações próprias não concluídos por esta entrega.","Snapshot/API/dossier, moeda, datas UTC, histórico e motor de exigibilidade8 existentes; viagem classificada associada à mesma oportunidade, sem copiar edições deliberadas.","Destino autenticado e backup/restauro; sem migração, alteração de permissões, importação/publicação documental ou ensaio sintético no activo.","RIVA Decreto7/2008,38–46,acervoBR16-I16/04/2008PDF6–8/p104–106 (6/7 conferidas visualmente,46(2) texto extraídoPDF8). CIVA Lei13/2016,4(5)/5(2),PDF7/p1426(265);7(8)/(9)/8,PDF9/p1426(267);14(1),PDF13/p1426(271),páginas visualmente conferidas. CIVA17(1),Lei22/2022PDF3,16% usado no divisor da base;Lei10/2025não altera4/7/8/17. Não confundir regime fiscal com licenciamento turístico.","Desenvolvimento, QA e entrega conjunta concluídos no âmbito de margem/eventos; IVA devido/dedução não calculados e trabalho restante rastreável.",Sem esforço de entrega pendente neste recorte; não representa fecho ou estimativa do restante trabalho fiscal.,Cotejo documental e revisao do codigo; candidato544068e8 preservado. Sem ensaio funcional deste ramo. | C:\laragon\www\portal-investidor\outputs\iva-l1-travel-events-20261025\preservation-before-tests.json | docs\investmoz\simulations\iva-l1-travel-events-qa-20261025.json | iva_l1_travel_events_P129_P133_homologation_20261025:24marcadores/6hashes/simuladorHTTP200;174tabelas/369ficheiros/29fichas/28relações/408geometrias;provasQA reutilizadas;fiscal_continuity:AUD-SIM-INT02,Viagens e eventos P129/P133,25/10/2026 — entrega conjunta a48e9749,"{""article"": ""7(8–9)"", ""source"": ""CIVA2016 PDF8–9 e alterações aplicáveis"", ""scope"": ""2026 somente quando condicoes e periodo aplicavel forem determinados"", ""next_increment"": ""L1-R1P: período fiscal efectivo, distinto de mês orçamental""}",,,,,,,,,Nao confundir prova dos dez tratamentos anteriores com cobertura deste ramo.,,,,Entregue na homologação a48e9749 no âmbito demonstrado;L1 aberto
INE-DEMO-01,"Integração nacional de dados demográficos INE na viabilidade existente, importador em duas etapas, séries/versionamento, comparação territorial e adenda nacional.",Pedido colado pelo proprietário nesta sessão; execução expressamente confirmada; Macate/Vanduzi autorizados apenas em QA em 13/11/2026.,Território; viabilidade; percurso; SWOT; simulador,/localizacoes/demografia; /api/v1/investment/demography; /management/demography,DemographicImport; DemographicEvidence; leitor XLSX; cadastro/mapa/comparação/simulador e fontes SWOT existentes reutilizados.,"Homologação:4988ca89 instalado,erro500 de processos corrigido;preparaçõesCLI12/920456 privadas,por confirmaçãoMFA. Local:12lotes/920456 instalados e verificados.","154706 observações reais INE; três originais com scan real/hash; repetição sem duplicação; 72 reconciliações entre exemplares; comparação GONDOLA/GURO. Homologação: backup/restauro190 tabelas/369 ficheiros;11 tabelas de negócio preservadas;168 correspondências+15 distritos autorizados,sem pendências;HTTPS200 e revisão b20068d0 confirmada. Correcção df543a44:3testes/17asserções;HTTPS confirma revisão;ambos os titulares designados passam a autorização de leitura;nenhuma permissão alterada;hashes de código/configuração conferidos. Correcção500:2testes/20asserções sem proc_open;14testes/52asserções consumidores;8preparador;PHPStan aprovado. PHP-FPM real confirmou processos bloqueados,cacheCLI e antimalware disponíveis,hashes correctos,sem actor/sessão ou escrita de negócio;HTTPS confirma4988ca89.",Código instalado e verificado no PHP-FPM real;incorporação remota dos dados pendente da confirmação autenticada.,"Confirmar coortes initial/remaining/zambezia na sessão com MFA e conferir recibos,12 lotes/920456 observações e consulta territorial. Conferência visual administrativa pendente.",PostgreSQL/PostGIS QA; ClamAV; Python stdlib; autenticação e MFA existentes; nenhum novo aprovador.,"Três ficheiros associados aos territórios, valores exactos, séries 2017–2050, consulta sectorial/temporal, duas localizações, fontes/versões, inconsistências e acessos demonstrados em QA. Homologação manual restante registada no relatório.",Alta,,,,,"Consulta pública desktop/mobile conferida; visual administrativa pendente, testes HTTP aprovados.",11 testes de integração aprovados.,Três lotes QA; 154706 observações; idempotência e unicidade verificadas.,Consulta por território/ano/exemplar; desactivação/reaprovação preservam histórico.,"População/idade/residência, vitais, fontes e limitações; sem extrapolação, procura automática ou probabilidade de sucesso.",,,,,"Sem pendência de importação dos ficheiros provinciais fornecidos. Conferência visual administrativa,ressalvas/unidades ausentes e indicadores sem valores mantidos;outros ambientes não instalados por esta operação.","PortalProfileInstallation/MFA existentes; DemographicInstallation; DemographicImport; InvestmentGeography; simulador, mapa/comparação e fontes SWOT existentes.",,Proprietário executou a disponibilização autorizada de Zambezia.xlsx e cinco distritos;recibo autenticado e resultado confirmados sem repetição.,,,"outputs/demography-20261113/zambezia/installation-status.json e verification.json:recibo autenticado09:33:38UTC,hash/lote/count126684,total12lotes/920456,preservação11lotes e oito tabelas de negócio,série provincial e cinco distritos públicos. operational-status.json actualizado;ensaios e backup/restauro anteriores preservados.",demografia-ine,13/11/2026 — instalação autenticada concluída; conferência técnica de leitura sem repetição da operação,,,,,,,,,,,,,,
REL-DOSSIER-20261113,"Agrupar no dossier correspondente os relatórios produzidos pelas etapas, com versão, oportunidade e recuperação.",Pedido expresso do proprietário em 13/11/2026: «tem de ser resolvido».,Percurso do investidor; dossier; relatórios,/investimentos/{id}#relatorios; /investimentos/{id}/relatorios/fiscais/{reportId},"Índice e relatório consolidado no percurso existente; reutiliza os geradores Word/HTML, cálculos e cópias preservadas.",QA implementado; esta alteração ainda não instalada na homologação.,21 testes frontend; recuperação e isolamento com 45 asserções em QA; originais e histórico preservados.,Implementado em QA; instalação pendente,Instalar o delta autorizado e conferir visualmente o uso normal. Não encerra funcionalidades ainda inexistentes noutros módulos.,,"Mesmo dossier/oportunidade → índice por etapa → relatório Word/HTML → acesso a versões APIEX, fiscais e documentos → recuperação sem misturar oportunidades ou expor finanças a gestores.",Alta,,,,,Índice responsivo e exportação consolidada,"Descarga privada da cópia fiscal preservada, autorizada por titular e dossier","Reutiliza os registos existentes; associação fiscal nova conserva âmbito, sem reescrever fontes anteriores.",Aprovada em prova sintética; sem depender de acesso à simulação anónima original.,Versão/data/estado/origem; pendências e âmbito por confirmar explícitos.,,,,,,,,"Acrescentar índice e consolidar saídas existentes; sem módulo paralelo, publicação ou envio automático.",Instalação e conferência; implementação QA concluída,,outputs/dossier-reports-20261113/,,,,,,,,,,,,,,,,
+ACT-20261113-PESQUISA,Pesquisa rápida: contar corredores sem misturar referências; produtos físicos e financeiros separados por categoria e localização; como licenciar empresa.,Pedidos Tempo na conversa de 13/11/2026,Pesquisa rápida,/pesquisa; /,"SearchTerms::requestIntent, GlobalSearchController, FederatedSearch, portal/search, portal-hero; reutiliza catálogos publicados existentes.",Implementado em QA; nova candidata ainda não instalada.,"outputs/quick-search-20261113/: integration.log, regression.log, unit.log, frontend.log, types.log, analysis.log, build.log",QA implementado e testes focados aprovados; instalação e visual pendentes,Instalar após autorização concreta; conferir apresentação no navegador.,,Contagem tipada e zero distinto de falha; produtos separados com localização/cobertura e contexto nos links; licenciamento no roteiro/dossier existentes; só publicados; erros de fontes explícitos; paginação preservada.,Alta,,,,,,,,,,,,,,"Conferência visual e instalação desta candidata; pesquisa territorial é textual, sem inferir elegibilidade.",,,,Implementação QA concluída neste recorte; entrega preparada.,,,,,,,,,,,,,,,,,,
diff --git a/resources/js/components/portal-hero.tsx b/resources/js/components/portal-hero.tsx
index 37f6c798..022c1038 100644
--- a/resources/js/components/portal-hero.tsx
+++ b/resources/js/components/portal-hero.tsx
@@ -259,7 +259,7 @@ export default function PortalHero({
aria-describedby="portal-opportunity-search-help"
value={query}
onChange={(event) => setQuery(event.target.value)}
- placeholder="Ex.: agronegócio, Nampula, legislação..."
+ placeholder="Corredores, produtos e locais, licenciamento…"
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
/>
diff --git a/resources/js/pages/portal/search.tsx b/resources/js/pages/portal/search.tsx
index 0fab017e..6f883e35 100644
--- a/resources/js/pages/portal/search.tsx
+++ b/resources/js/pages/portal/search.tsx
@@ -23,6 +23,8 @@ type Result = {
description: string | null;
url: string;
category: string;
+ location?: string | null;
+ coverage?: string | null;
};
type Results = {
data: Result[];
@@ -33,9 +35,26 @@ type Results = {
unavailable: string[];
};
+type SearchAnswer = {
+ type: 'count' | 'guide' | 'products' | 'list';
+ label: string;
+ count: number | null;
+ filter: string;
+ category?: 'all' | 'physical' | 'financial';
+};
+
+const ANSWER_HELP: Record = {
+ count: 'Contagem dos registos publicados e disponíveis para consulta no InvesteMoz.',
+ list: 'Registos publicados e disponíveis para consulta no InvesteMoz.',
+ guide: 'Consulte o roteiro público e prepare depois o licenciamento no seu dossier. Os requisitos dependem da actividade e localização.',
+ products: 'Produtos físicos e referências de cadeias de valor separados dos produtos financeiros. A presença física de uma instituição não determina a cobertura do financiamento.',
+};
+
type CategoryMeta = { icon: LucideIcon; iconClass: string; badgeClass: string };
const CATEGORY_META: Record = {
+ 'Produtos e localizações': { icon: Factory, iconClass: 'bg-teal-50 text-teal-700', badgeClass: 'bg-teal-50 text-teal-700' },
+ 'Produtos financeiros': { icon: Landmark, iconClass: 'bg-blue-50 text-blue-700', badgeClass: 'bg-blue-50 text-blue-700' },
'Páginas e serviços': {
icon: Compass,
iconClass: 'bg-slate-100 text-slate-700',
@@ -292,9 +311,11 @@ function QuickLinks() {
export default function Search({
query,
results,
+ answer = null,
}: {
query: string;
results: Results;
+ answer?: SearchAnswer | null;
}) {
const [input, setInput] = useState(query);
useEffect(() => setInput(query), [query]);
@@ -308,7 +329,7 @@ export default function Search({
unavailable,
} = results;
const pageUrl = (page: number) =>
- `/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
+ `/pesquisa?${new URLSearchParams({ q: query, page: String(page), ...(answer?.category ? { category: answer.category } : {}) })}`;
const start = total ? (current - 1) * perPage + 1 : 0;
const end = Math.min(start + data.length - 1, total);
const highlight = useMemo(() => buildHighlightRegex(query), [query]);
@@ -340,6 +361,7 @@ export default function Search({
role="search"
className="mt-6 flex max-w-3xl flex-col gap-2 sm:flex-row"
>
+ {answer?.category && }
@@ -376,6 +398,26 @@ export default function Search({
+ {answer && (
+
+
+ {answer.type !== 'count' ? answer.label : answer.count === null ? 'Contagem indisponível de momento' : `${answer.count.toLocaleString('pt-MZ')} ${answer.label}`}
+
+ {ANSWER_HELP[answer.type]}
+ {answer.filter && answer.type !== 'guide' && Correspondência textual: {answer.filter}.
}
+ {answer.type === 'products' && (
+
+ )}
+
+ )}
{unavailable.length > 0 && (
)}
- {query ? (
+ {query || answer ? (
<>
{total > 0
- ? `Resultados ${start}–${end} de ${total} para “${query}”.`
- : `Nenhum resultado nas fontes consultadas para “${query}”.`}
+ ? `Resultados ${start}–${end} de ${total} para “${query || answer?.label}”.`
+ : `Nenhum resultado nas fontes consultadas para “${query || answer?.label}”.`}
{last > 1 && (
@@ -473,6 +515,8 @@ export default function Search({
/>
)}
+ {item.location && {item.location}
}
+ {item.coverage && {item.coverage}
}
withoutVite();
+ $this->get('/pesquisa?'.http_build_query(['q' => 'como licenciar uma empresa']))->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('answer.type', 'guide')->where('results.total', 2)
+ ->where('results.data.0.url', '/como-investir#planeamento')
+ ->where('results.data.1.url', '/investimentos/iniciar?step=S08'));
+ }
+
+ public function test_physical_product_search_keeps_the_product_and_documented_location_in_its_link(): void
+ {
+ $this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
+ $this->withoutVite();
+ config(['portal.legacy_content_enabled' => false, 'portal.legacy_content_qa_enabled' => false]);
+ $sector = Sector::firstOrCreate(['slug' => 'mineracao'], ['name' => 'Mineração']);
+ $sector->forceFill(['is_active' => true, 'published_at' => now()->subMinute()])->save();
+ $this->get('/pesquisa?'.http_build_query(['q' => 'onde encontrar grafite em Cabo Delgado']))->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
+ ->where('results.data.0.title', 'Grafite')->where('results.data.0.category', 'Produtos e localizações')
+ ->where('results.data.0.location', fn ($text) => str_contains($text, 'Cabo Delgado'))
+ ->where('results.data.0.url', fn ($url) => str_contains($url, 'resource_group=') && str_contains($url, 'resource_location=Cabo+Delgado')));
+ $this->get('/pesquisa?'.http_build_query(['q' => 'grafite Cabo Delgado', 'category' => 'physical']))->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('answer.category', 'physical')->where('results.total', 1)
+ ->where('results.data.0.category', 'Produtos e localizações'));
+ $this->get('/pesquisa?category=invalid')->assertRedirect();
+ }
+
+ public function test_financial_product_search_excludes_drafts_and_future_publications_and_distinguishes_coverage(): void
+ {
+ $this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
+ $this->withoutVite();
+ if (! Schema::hasTable('funding_programmes')) {
+ // Only this transaction in the isolated QA database; no active schema changes.
+ (require database_path('migrations/2026_11_02_210000_funding_programme_catalogue.php'))->up();
+ }
+ $actor = User::factory()->create();
+ $marker = 'SYNTHFIN'.Str::random(8);
+ foreach (['published', 'draft', 'future'] as $state) {
+ $id = (string) Str::uuid();
+ $version = (string) Str::uuid();
+ $payload = json_encode(['title' => $marker.' '.$state, 'kind' => 'institution', 'summary' => 'Nota de preparação: ficha e documento gerador.',
+ 'institution_nature' => 'bank', 'institution_services' => ['investment'],
+ 'locations' => [['location_text' => 'Nampula']], 'products' => [['title' => 'Crédito PME', 'coverage' => 'national', 'needs' => ['investment']]]], JSON_THROW_ON_ERROR);
+ DB::table('funding_programmes')->insert(['id' => $id, 'code' => $marker.$state, 'draft' => $payload, 'created_by' => $actor->id]);
+ DB::table('funding_programme_versions')->insert(['id' => $version, 'programme_id' => $id, 'version' => 1, 'payload' => $payload,
+ 'reason' => 'Synthetic QA only', 'actor_id' => $actor->id, 'published_at' => $state === 'future' ? now()->addDay() : now()->subMinute()]);
+ if ($state !== 'draft') {
+ DB::table('funding_programmes')->where('id', $id)->update(['published_version_id' => $version]);
+ }
+ }
+ $this->get('/pesquisa?'.http_build_query(['q' => 'produtos financeiros '.$marker.' em Nampula']))->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('answer.type', 'products')->where('results.total', 1)->where('results.unavailable', [])
+ ->where('results.data.0.category', 'Produtos financeiros')->where('results.data.0.description', 'Crédito PME')
+ ->where('results.data.0.location', 'Presença física: Nampula')
+ ->where('results.data.0.coverage', fn ($text) => str_contains($text, 'nacional')));
+ }
+
+ public function test_corridor_count_excludes_other_types_drafts_and_deleted_entities(): void
+ {
+ $this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
+ $this->withoutVite();
+ $marker = 'SYNTHCOUNT'.Str::random(10);
+ foreach (range(1, 4) as $n) {
+ $corridor = DevelopmentCorridor::create(['code' => $marker.$n, 'name' => $marker.$n, 'status' => 'active', 'data_classification' => 'public']);
+ InvestmentDossier::create(['corridor_id' => $corridor->id, 'title' => $marker.' '.$n,
+ 'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => $n === 3 ? 'draft' : 'published',
+ 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
+ if ($n === 4) {
+ $corridor->delete();
+ }
+ }
+ InvestmentDossier::create(['project_id' => Project::factory()->create()->id, 'title' => $marker.' corredor relacionado',
+ 'content' => [], 'source' => 'Synthetic QA only', 'publication_status' => 'published', 'published_at' => now(),
+ 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA only']);
+ $query = 'quantos corredores existem '.$marker;
+ $this->get('/pesquisa?'.http_build_query(['q' => $query]))->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->component('portal/search')->where('query', $query)->where('answer.count', 2)->where('answer.label', 'corredores')
+ ->where('results.total', 2)->has('results.data', 2)->where('results.unavailable', []));
+ $this->get('/pesquisa?'.http_build_query(['q' => 'quantos corredores existem '.$marker.' inexistente']))
+ ->assertOk()->assertInertia(fn (Assert $page) => $page->where('answer.count', 0)->where('results.total', 0));
+ $this->get('/pesquisa?q=quantos+corredores+existem')->assertOk()->assertInertia(fn (Assert $page) => $page
+ ->where('answer.label', 'corredores')->where('answer.count', fn ($count) => $count >= 2)
+ ->where('results.unavailable', []));
+ }
+}
diff --git a/tests/Feature/GlobalSearchTest.php b/tests/Feature/GlobalSearchTest.php
index 10660758..9b25c854 100644
--- a/tests/Feature/GlobalSearchTest.php
+++ b/tests/Feature/GlobalSearchTest.php
@@ -7,6 +7,7 @@ use App\Services\FederatedSearch;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia as Assert;
+use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
final class GlobalSearchTest extends TestCase
@@ -35,6 +36,18 @@ final class GlobalSearchTest extends TestCase
$this->assertSame('Como investir', $result['data'][0]['title']);
}
+ public function test_unavailable_public_reference_does_not_hide_other_sources_or_swallow_forbidden_access(): void
+ {
+ $result = app(FederatedSearch::class)->page([
+ 'Referências' => fn () => throw new HttpException(503),
+ 'Páginas' => fn () => ['count' => 1, 'fetch' => fn () => [['title' => 'Como investir', 'url' => '/como-investir']]],
+ ], 1);
+ $this->assertSame(1, $result['total']);
+ $this->assertSame(['Referências'], $result['unavailable']);
+ $this->expectException(HttpException::class);
+ app(FederatedSearch::class)->page(['Restrito' => fn () => throw new HttpException(403)], 1);
+ }
+
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
{
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
diff --git a/tests/Unit/SearchTermsTest.php b/tests/Unit/SearchTermsTest.php
index bdfbd557..ec0f9dff 100644
--- a/tests/Unit/SearchTermsTest.php
+++ b/tests/Unit/SearchTermsTest.php
@@ -9,6 +9,33 @@ use PHPUnit\Framework\TestCase;
final class SearchTermsTest extends TestCase
{
+ public function test_count_questions_select_a_category_instead_of_searching_question_words(): void
+ {
+ $search = new SearchTerms;
+ $intent = $search->countIntent('Quantos corredores existem?');
+ $this->assertSame('corridor', $intent['kind']);
+ $this->assertSame('', $intent['filter']);
+ $this->assertSame('corredores', $intent['label']);
+ $this->assertSame('Oportunidades', $search->countIntent('Quantas oportunidades existem no portal?')['category']);
+ $this->assertSame('em nampula', $search->countIntent('Quantos corredores existem em Nampula?')['filter']);
+ $this->assertSame('cluster', $search->countIntent('Qual é o número de clusters disponíveis?')['kind']);
+ $this->assertNull($search->countIntent('oportunidades nos corredores'));
+ $this->assertNull($search->countIntent('documentos sobre quantos corredores existem'));
+ }
+
+ public function test_investor_requests_distinguish_products_guidance_and_lists(): void
+ {
+ $search = new SearchTerms;
+ $this->assertSame('guide', $search->requestIntent('como licenciar uma empresa')['type']);
+ $this->assertSame('guide', $search->requestIntent('como faço para licenciar a empresa em Nampula?')['type']);
+ $this->assertNull($search->requestIntent('como licenciar software'));
+ $this->assertSame('list', $search->requestIntent('quais são os corredores existentes')['type']);
+ $this->assertSame('Produtos financeiros', $search->requestIntent('produtos financeiros em Nampula')['category']);
+ $this->assertNull($search->requestIntent('tipo de produtos e sua localização')['category']);
+ $this->assertSame('', $search->requestIntent('tipo de produtos e sua localização')['filter']);
+ $this->assertSame('grafite em cabo delgado', $search->requestIntent('onde encontrar grafite em Cabo Delgado?')['filter']);
+ }
+
public function test_matches_all_words_in_any_order_without_case_or_accents(): void
{
$search = new SearchTerms;
diff --git a/tests/frontend/global-search.test.mjs b/tests/frontend/global-search.test.mjs
index a374ac3e..d3c0a497 100644
--- a/tests/frontend/global-search.test.mjs
+++ b/tests/frontend/global-search.test.mjs
@@ -35,6 +35,53 @@ new Function('require', 'module', 'exports', compiled)(
);
const Search = module.exports.default;
+test('product categories preserve the location filter and pagination even without a query', () => {
+ const results = { data: [{ title: 'Grafite', description: '', category: 'Produtos e localizações', url: '/sectores/mineracao' }], total: 6, current_page: 1, last_page: 2, per_page: 5, unavailable: [] };
+ const html = renderToStaticMarkup(React.createElement(Search, {
+ query: 'Nampula', results,
+ answer: { type: 'products', label: 'Produtos e localização', count: 6, filter: 'Nampula', category: 'physical' },
+ }));
+ assert.match(html, /q=Nampula&category=financial/);
+ assert.match(html, /q=Nampula&page=2&category=physical/);
+ assert.match(html, /name="category" value="physical"/);
+ const all = renderToStaticMarkup(React.createElement(Search, {
+ query: '', results, answer: { type: 'products', label: 'Produtos e localização', count: 6, filter: '', category: 'all' },
+ }));
+ assert.match(all, /href="\/sectores\/mineracao"/);
+});
+
+test('count answer is explicit and zero is distinct from an unavailable count', () => {
+ const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: [] };
+ const html = renderToStaticMarkup(React.createElement(Search, {
+ query: 'quantos corredores existem', results,
+ answer: { type: 'count', label: 'corredores', count: 0, filter: '' },
+ }));
+ assert.match(html, /0 corredores/);
+ assert.match(html, /registos publicados/);
+ const failed = renderToStaticMarkup(React.createElement(Search, {
+ query: 'quantos corredores existem', results,
+ answer: { type: 'count', label: 'corredores', count: null, filter: '' },
+ }));
+ assert.match(failed, /Contagem indisponível/);
+ assert.doesNotMatch(failed, /0 corredores/);
+});
+
+test('products show their location separately from financial coverage and guidance has no invented count', () => {
+ const results = { data: [{ title: 'Banco de ensaio', description: 'Crédito PME', location: 'Presença física: Maputo', coverage: 'Cobertura nacional', category: 'Produtos financeiros', url: '/fundos/qa' }], total: 1, current_page: 1, last_page: 1, per_page: 5, unavailable: [] };
+ const html = renderToStaticMarkup(React.createElement(Search, {
+ query: 'produtos financeiros', results, answer: { type: 'products', label: 'Produtos e localização', count: 1, filter: '' },
+ }));
+ assert.match(html, /Produtos financeiros/);
+ assert.match(html, /Presença física: Maputo/);
+ assert.match(html, /Cobertura nacional/);
+ assert.match(html, /não determina a cobertura/);
+ const guide = renderToStaticMarkup(React.createElement(Search, {
+ query: 'como licenciar empresa', results: { ...results, data: [] }, answer: { type: 'guide', label: 'Licenciamento da empresa', count: 2, filter: 'licenciamento' },
+ }));
+ assert.match(guide, /Licenciamento da empresa/);
+ assert.doesNotMatch(guide, /2 Licenciamento/);
+});
+
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
const results = {
data: [