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" />