Improve investor quick search with typed counts, product categories and licensing guidance
This commit is contained in:
@@ -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<string> $tokens
|
||||
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
|
||||
*/
|
||||
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<string> $tokens
|
||||
* @param callable(mixed):array<string,mixed> $present
|
||||
* @param literal-string $title
|
||||
* @param literal-string $id
|
||||
* @return array{count:int,fetch:callable(int,int):iterable<array<string,mixed>>}
|
||||
*/
|
||||
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<array{title:string,description:string,url:string}>
|
||||
*/
|
||||
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'],
|
||||
|
||||
@@ -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<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
|
||||
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable<array<string,mixed>>}> $factories
|
||||
* @return array{data:list<array<string,mixed>>,total:int,current_page:int,last_page:int,per_page:int,unavailable:list<string>}
|
||||
*/
|
||||
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]);
|
||||
|
||||
@@ -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<string> */
|
||||
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<string>
|
||||
*/
|
||||
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<literal-string> $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<literal-string> $columns
|
||||
* @param list<string> $tokens
|
||||
*/
|
||||
public function constrain($query, array $columns, array $tokens): void
|
||||
{
|
||||
foreach ($tokens as $token) {
|
||||
@@ -61,6 +129,7 @@ final class SearchTerms
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $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<string> $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<string> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.,,,,,,,,,,,,,,,,,,
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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"
|
||||
/>
|
||||
<button
|
||||
@@ -274,8 +274,8 @@ export default function PortalHero({
|
||||
id="portal-opportunity-search-help"
|
||||
className="mt-2 text-left text-sm text-white/90"
|
||||
>
|
||||
Combine palavras-chave, sector e localização. Ex.:
|
||||
plantio de algodão ou agronegócio Nampula.
|
||||
Ex.: quantos corredores existem; grafite Cabo Delgado;
|
||||
como licenciar uma empresa.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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<SearchAnswer['type'], string> = {
|
||||
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<string, CategoryMeta> = {
|
||||
'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 && <input type="hidden" name="category" value={answer.category} />}
|
||||
<label htmlFor="search-query" className="sr-only">
|
||||
Pesquisar no InvesteMoz
|
||||
</label>
|
||||
@@ -376,6 +398,26 @@ export default function Search({
|
||||
</section>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
||||
{answer && (
|
||||
<section aria-label="Resposta à pesquisa" className="mb-6 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<p className="text-xl font-semibold text-[#101820]" aria-live="polite">
|
||||
{answer.type !== 'count' ? answer.label : answer.count === null ? 'Contagem indisponível de momento' : `${answer.count.toLocaleString('pt-MZ')} ${answer.label}`}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-600">{ANSWER_HELP[answer.type]}</p>
|
||||
{answer.filter && answer.type !== 'guide' && <p className="mt-1 text-sm text-slate-600">Correspondência textual: {answer.filter}.</p>}
|
||||
{answer.type === 'products' && (
|
||||
<nav aria-label="Categoria de produtos" className="mt-4 flex flex-wrap gap-2">
|
||||
{([['all', 'Todos'], ['physical', 'Produtos físicos'], ['financial', 'Produtos financeiros']] as const).map(([category, label]) => (
|
||||
<Link key={category} href={`/pesquisa?${new URLSearchParams({ q: answer.filter, category })}`}
|
||||
aria-current={(answer.category ?? 'all') === category ? 'page' : undefined}
|
||||
className={`rounded-full border px-3 py-2 text-sm font-semibold ${(answer.category ?? 'all') === category ? 'border-slate-800 bg-slate-800 text-white' : 'border-slate-200 text-slate-700 hover:bg-slate-100'}`}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{unavailable.length > 0 && (
|
||||
<p
|
||||
role="alert"
|
||||
@@ -387,7 +429,7 @@ export default function Search({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{query ? (
|
||||
{query || answer ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
@@ -395,8 +437,8 @@ export default function Search({
|
||||
className="text-sm text-slate-600"
|
||||
>
|
||||
{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}”.`}
|
||||
</p>
|
||||
{last > 1 && (
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
|
||||
@@ -473,6 +515,8 @@ export default function Search({
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{item.location && <p className="mt-2 text-sm text-slate-700"><MapPin size={14} className="mr-1 inline" aria-hidden="true" />{item.location}</p>}
|
||||
{item.coverage && <p className="mt-1 text-xs text-slate-600">{item.coverage}</p>}
|
||||
</div>
|
||||
<ArrowUpRight
|
||||
size={20}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\DevelopmentCorridor;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sector;
|
||||
use App\Models\User;
|
||||
use App\Support\IsolatedQaDatabase;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class GlobalSearchCountTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_licensing_question_opens_existing_guidance_without_mixing_catalogue_matches(): void
|
||||
{
|
||||
$this->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', []));
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user