Compare commits

..
Author SHA1 Message Date
nala e69ab1499f docs: comprovar guias na biblioteca QA existente
tests / ci (push) Canceled after 0s
2026-10-20 19:49:55 +02:00
nala 3258a25f76 feat: apresentar guias educativos com imagem resumo e contexto 2026-10-20 19:45:45 +02:00
15 changed files with 357 additions and 72 deletions
@@ -27,6 +27,7 @@ class InvestmentDiscoveryController extends Controller
return Inertia::render('investment/catalogue', ['filters' => $filters, 'catalogueUrl' => $request->routeIs('investment.education') ? '/educacao' : '/oportunidades', 'references' => $catalogue->references(false, $filters),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'guides' => app(\App\Services\SectorInvestment::class)->guides(\App\Models\Sector::where('slug', $filters['sector'] ?? '')->where('is_active', true)->whereNotNull('published_at')->first()),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -106,6 +107,7 @@ class InvestmentDiscoveryController extends Controller
unset($returnFilters['zone_id'], $returnFilters['from_dossier']);
return Inertia::render('investment/detail', ['record' => $record, 'context' => $catalogue->context($record->id), 'zone_origin' => $origin,
'guides' => app(\App\Services\SectorInvestment::class)->guides($record->sector),
'dossierOrigin' => $dossierOrigin ? ['id' => $dossierOrigin->id, 'title' => $dossierOrigin->title] : null,
'returnUrl' => $returnFilters === [] ? '/oportunidades' : '/oportunidades?'.http_build_query($returnFilters)]);
}
+32 -3
View File
@@ -35,7 +35,7 @@ final class LegacyContent
}
/** @return array<string, mixed> */
public function article(string $slug = 'agronegocio'): array
public function article(string $slug = 'agronegocio', bool $includeRelated = true): array
{
abort_unless($this->available(), 404);
$identity = config('legacy_content.articles.'.$slug);
@@ -80,13 +80,26 @@ final class LegacyContent
}
preg_match_all('~href="([^"]+)"~i', $localHtml, $existingLinks);
$alreadyLinked = array_map(fn ($href) => html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $existingLinks[1]);
$relatedArticles = array_values(array_filter($this->related($identity['group'], $slug), fn ($item) => !in_array($item['href'], $alreadyLinked, true)));
$relatedArticles = [];
if ($includeRelated) {
foreach ($this->related($identity['group'], $slug) as $item) {
if (in_array($item['href'], $alreadyLinked, true)) continue;
try {
$relatedArticles[] = $this->article($item['slug'], false);
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $error) {
if ($error->getStatusCode() !== 404) throw $error;
}
}
}
return ['id' => (int) $row->resource_id, 'document_id' => $row->id, 'version' => (int) $row->metadata_version,
'sha256' => trim($row->sha256), 'title' => $source['title'], 'author' => $source['author'],
'legacy_id' => $source['legacy_id'], 'legacy_category' => $source['legacy_category'],
'published_at' => $source['published_at'], 'modified_at' => $source['modified_at'], 'source_url' => $source['source_url'],
'href' => '/'.$slug, 'html' => $localHtml,
'href' => '/'.$slug, 'html' => $localHtml, 'summary' => self::excerpt($localHtml),
'opportunities_href' => $sector ? '/oportunidades?sector='.rawurlencode($sector->slug) : null,
'opportunities_label' => $sector ? 'Ver oportunidades de '.$sector->name : null,
'has_opportunities' => count($opportunities) > 0,
'landing_href' => '/explorar/'.$identity['group'], 'start_href' => '/investimentos/a-partir-de/'.$slug,
'group' => config('legacy_content.groups.'.$identity['group']),
'related_articles' => $relatedArticles,
@@ -101,6 +114,22 @@ final class LegacyContent
'consultation_state' => 'public', 'legal_state' => 'not_applicable', 'calculation_use' => false];
}
/** An excerpt of the displayed edition, never a claim about returns or availability. */
public static function excerpt(string $html): string
{
$plain = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (str_contains($plain, 'Hotelaria e resorts') && str_contains($plain, 'Ecoturismo')
&& str_contains($plain, 'Infraestruturas turísticas') && str_contains($plain, 'Oportunidades Regionais')) {
return 'Conheça os temas de turismo e hospitalidade abordados no guia: hotelaria, ecoturismo, infraestruturas de apoio e referências regionais. Use este enquadramento para preparar a sua ideia de investimento.';
}
preg_match_all('~<p\b[^>]*>(.*?)</p>~isu', $html, $paragraphs);
foreach ([...($paragraphs[1] ?? []), $html] as $paragraph) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags($paragraph), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
if (mb_strlen($text) >= 60) return \Illuminate\Support\Str::limit($text, 320);
}
return trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Resolve a library identity, preserving the already stored source URLs and versions. */
/** @return array<string, mixed> */
public function articleForResource(int $resourceId): array
+21 -13
View File
@@ -37,19 +37,7 @@ final class SectorInvestment
public function publicView(Sector $sector): array
{
$query = SectorOrganization::opportunities($sector);
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug);
$articles[] = ['title' => $a['title'], 'href' => $a['href'], 'image' => $a['image'], 'sha256' => $a['sha256'], 'scope' => $slug === 'tech-fintech' && $sector->slug !== 'fintech' ? 'Especialização Fintech; não representa todo o setor.' : 'Conteúdo educativo relacionado; não é uma oportunidade concreta.'];
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
$articles = $this->guides($sector);
$relations = [];
foreach (['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'] as $table => $foreign) {
$ids = DB::table('opportunity_'.$table)->whereIn('opportunity_id', (clone $query)->select('id'))->pluck($foreign);
@@ -74,6 +62,26 @@ final class SectorInvestment
'content_notice' => $sector->slug === 'mercado-financeiro' ? 'O artigo geral de Mercado Financeiro permanece vazio e não foi publicado. A preparação de negócios neste setor está disponível.' : null];
}
/** Published guides for an explicit sector; never inferred from proximity. */
public function guides(?Sector $sector): array
{
if (! $sector) return [];
$articles = [];
if (app(LegacyContent::class)->available()) {
foreach (self::CONTENT[$sector->slug] ?? [] as $slug) {
try {
$a = app(LegacyContent::class)->article($slug, false);
$articles[] = \Illuminate\Support\Arr::only($a, ['title', 'href', 'image', 'sha256', 'summary', 'author', 'opportunities_href', 'opportunities_label', 'has_opportunities']);
} catch (HttpException $e) {
if ($e->getStatusCode() !== 404) {
throw $e;
}
}
}
}
return $articles;
}
public function attach(User $actor, string $id, array $input): void
{
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'sector_slug' => 'required|string|max:150'])->validate();
@@ -0,0 +1,84 @@
{
"branch": "codex/educational-content-cards",
"base": "0e7d51f18fb424857fdaad48498a2fc43c044749",
"scope": "New QA delta; separate Tempo review before main",
"files": [
{
"path": "app/Http/Controllers/InvestmentDiscoveryController.php",
"sha256": "693d1217ead894747c266a7295774b26a8b987f8faf06003ed60a6d3e15aca6c"
},
{
"path": "app/Services/LegacyContent.php",
"sha256": "e856ba2a042e971933a401fd9544a50f6bc5c1541c28062719d840a8277da7b8"
},
{
"path": "app/Services/SectorInvestment.php",
"sha256": "a0ee1c9bf864ef765ed70fc1bcdc839781d0bed7d705d991c801bfaa9bf7e4a0"
},
{
"path": "docs/investmoz/unification/traceability-and-manual.md",
"sha256": "bbbb79e84cae659d1921dfa4440e3fcc192521e0976cace0fa2694828219ec00"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog-priorizado.csv",
"sha256": "420369f562508f68ea50e78d386721d6b62fa730893c4e6ccd06817f4dfb8a27"
},
{
"path": "docs/investmoz/v2/auditoria-portal/backlog.md",
"sha256": "d77e4931e8f1a0fcbf0f1d80be83a54b22682ffdc0286852d3ad167652056156"
},
{
"path": "docs/investmoz/v2/auditoria-portal/matriz-requisitos.csv",
"sha256": "757d457a295eb9a516abd1eec6002a52aa1d9a35faea291046e3c99681a1cdcb"
},
{
"path": "resources/js/components/educational-article-card.tsx",
"sha256": "03a298406702cd3211c44fad1fdf79d455ad029d997d417a416f450d6d420b6e"
},
{
"path": "resources/js/components/educational-guides.tsx",
"sha256": "2a2a52e50694e336f05544d51fc776a3bbc2b06d2ab61a1b3cdf1d261afca474"
},
{
"path": "resources/js/components/sector-entry.tsx",
"sha256": "39004abf04f096b987d852d834ce9b27fdf557d2283db3b5868678d3ff0bff1a"
},
{
"path": "resources/js/pages/investment/catalogue.tsx",
"sha256": "ced5e8d4b2910aeeaeb709d6ff0eb2d492f96d9b671d6ff832eea66d022a1f70"
},
{
"path": "resources/js/pages/investment/detail.tsx",
"sha256": "719b3c008887676dd0b69ffe48bd5a64426de92362d92da8ad39412a578c51bc"
},
{
"path": "resources/js/pages/portal/legacy.tsx",
"sha256": "dcd8462e940614f5935efa19d9c9444fe3432fbc704e9c05144508d5c944c90d"
},
{
"path": "tests/Feature/EducationalGuidesTest.php",
"sha256": "aa9e3f62890f1d75f639ad4e7e05713c18112ec96a7fd49c864e5e007807d3cf"
}
],
"build_manifest_sha256": "89c3ce0c67d9faf8cb3c5f7e9d74b3ad5d0ad70dff81ca2e37ca5cac7a471620",
"migrations": [],
"imports": [],
"publication": false,
"remote_operations": [],
"ai": false,
"evidence": {
"backend": "1 test, 8 assertions; exact isolated QA guard and transactions",
"types": "passed",
"build": "passed",
"desktop_width": 2048,
"mobile_width": 390,
"mobile_document_width": 375,
"keyboard": "visible focus and Enter opened guide",
"visual_data": "Read-only copy of existing public Tourism article/image; not synthetic",
"real_library": "Normal sector -> local image 25 and source summary -> normal article /turismo -> library resource 10; existing isolated QA; no import"
},
"limitations": [
"Tempo visual review pending before main integration; prior accepted dossier proof retained, not repeated"
],
"rollback": "Restore previous sources/build through existing deployment chain, preserving database/uploads/permissions and later data"
}
@@ -1521,3 +1521,25 @@ Rever a branch e o manifesto nominal; reconciliar novos commits dos programadore
Não há migrations, importação ou publicação neste pacote. A reversão repõe as fontes/build da revisão anterior através da cadeia existente, preservando a base, uploads, referências, permissões e dados posteriores. Os helpers de contexto só transportam query local; não aceitam URL externa como destino de retorno. O manifesto lista fontes e hash do build QA; não inclui `.env`, credenciais, dumps, vendor/node_modules ou fixtures persistentes.
Conferência final dos consumidores: negócios também conferido no navegador com pesquisa vazia; retirado o resumo local repetido, conservando o resumo partilhado. Fintech é identificado como especialização do Mercado Financeiro também nesse selector. Os cartões de zonas traduzem categoria, desenvolvimento, disponibilidade e estado de actividade, preservando os códigos e significados persistidos. A vista combinada não repete na lista de não localizados a ficha que já aparece na lista principal.
## Guias educativos relacionados — 20/10/2026
Delta independente de `0e7d51f18fb4`, que já foi integrado e confirmado na homologação. Este incremento permanece na branch `codex/educational-content-cards`, em QA, sujeito a revisão própria. Origem: pedido expresso da Tempo; nenhuma nova regra organizacional.
O componente `EducationalArticleCard` usa imagem pública autorizada, título original, resumo da edição apresentada, autoria e «Ler o guia». Turismo descreve hotelaria, ecoturismo, infraestruturas e referências regionais presentes no artigo, sem repetir metas financeiras como promessa. Outros artigos usam um excerto do texto efectivo. A apresentação não altera originais, fontes, autoria, direitos, versões, publicação ou scan. A imagem continua sujeita aos filtros públicos existentes. A acção secundária só surge quando a consulta de oportunidades públicas do sector tem resultados; não se infere disponibilidade por menção no artigo.
Consumidores: sector (`SectorEntry`), exploração editorial e leituras relacionadas (`portal/legacy`), catálogo de oportunidades com sector explícito (incluindo vista territorial/mapa) e ficha de oportunidade com sector. Um filtro de localização sem sector não determina um artigo; não se inventaram associações territoriais. Nos restantes catálogos territoriais não foi encontrado um cartão editorial equivalente a substituir. Não se alteraram cartões de anúncios, documentos ou entidades que não sejam recomendações de artigos.
Após a leitura, mantém-se «Iniciar ou continuar uma ideia», com `/investimentos/a-partir-de/{slug}` e o contexto autorizado do dossier quando existente. Não há novo percurso, conta ou privilégio. As provas anteriores de gravação documental mantêm-se; não foram repetidas.
Provas novas: `EducationalGuidesTest`, isolado por guarda QA, transacções e armazenamento fictício do teste, 1 teste / 8 asserções (excerto, autoria, destino, percurso, ausência de resultados, fonte não publicada e scan pendente). TypeScript, sintaxe PHP e build passaram. Capturas a 2048 px e 390 px; a 390 px, conteúdo com 375 px, sem transbordo horizontal, foco visível e Enter no botão «Ler o guia». Imagem real carregada com 900 px de largura natural. A prévia HTTP `http://127.0.0.1:18570/qa/guias/turismo` usa uma cópia por leitura do artigo/imagem já públicos no destino; não usa texto ou imagem sintéticos nem escreve registos. O router privado e a cópia ficam fora do Git. O destino não foi alterado.
Limitação: o artigo real não existe na base QA actual. A prévia permite conferir cartão e leitura; não comprova recuperação desse artigo num dossier autenticado nessa base. O URL original do percurso foi preservado e conferido pelo teste. Não se importaram dados ou fabricaram resultados para preencher essa lacuna. Capturas em `outputs/educational-guides-20261020/desktop.jpg` e `mobile.jpg` (ficheiros locais de prova).
Gestão: editar e publicar pelos ecrãs existentes da biblioteca/editorial; o resumo acompanha a edição apresentada. A acção de oportunidades é calculada pelos registos publicados, não pelo editor. Disponibilização futura: revisão própria e integração em `main` pelo processo automático, após decisão. Sem migrations, importação ou publicação neste delta. Reversão pelas fontes/build anteriores, conservando base, ficheiros, permissões e dados posteriores. IA, oito lacunas contratuais e P7 separados.
### Library integration evidence supplement - 20/10/2026
Functional code remains 3258a25f7659. Existing QA database legacy_trio_20261015_test and its original local storage were selected by an isolated loopback harness; no import, publication or application route override. Normal /sectores/turismo rendered the source-derived summary and image /documentos/25/view (HTTP 200; 900px natural width). Keyboard Enter on Ler o guia opened normal /turismo; the article links to library resource /documentos/10 (source SHA256 8cab862be245382dd4c1fd5fcb423c47aed7be3a22e0a7f8d5521510cc36bc72). Thus the real-library integration limitation of the earlier copied preview is resolved. URLs: http://127.0.0.1:18572/sectores/turismo and /turismo. Evidence: outputs/educational-guides-20261020/real-library-receipt.json, real-library-sector.jpg and real-library-guide.jpg. No dossier path/context change; previous accepted dossier proof retained, not repeated. Tempo visual review remains pending before main integration. AI excluded. This supplement changes evidence/documentation only, not functional code.
@@ -7381,3 +7381,4 @@ P1,AUD-SEARCH-T08,QA — revisão Tempo pendente,Nenhuma alteração de relaçõ
P1,AUD-SEARCH-T09,QA — revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,investment/catalogue,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-SEARCH-T10,QA — revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,"Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-SEARCH-T11,QA — revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,,,navegação/pesquisa,"Conversa Tempo 20/10/2026; manual, secção Pesquisa transversal QA",Consulta coerente e compreensível; nenhuma alteração de dados para coincidir contagens.,portal/services; portal/document,"Situação jurídica, consulta e cálculo continuam independentes.",,Rever a branch antes de integrar em main; sem disponibilização remota nesta execução.,,,Pesquisa transversal QA 20/10/2026,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,,,,Apenas delta identificado; não certifica funcionalidades não alteradas.,
P1,AUD-GUIDE-CARD-20261020,QA review pending,New delta; not approved for main,Common educational card and existing source/context adapter,,Desktop/mobile/keyboard; public-state and source checks,,,,,,educational presentation,Tempo 20/10/2026; same manual,,,Tempo visual review; library integration verified on existing QA; previous dossier proof retained,,Integration approval for new delta,,,,1 test / 8 assertions; TypeScript/build; real public-source visual preview,,,,No import; no remote changes; no claim of new dossier proof,
Can't render this file because it is too large.
@@ -2990,3 +2990,10 @@ Provas: outputs/investmoz-git-delivery-20261020/RECIBO-DISPONIBILIZACAO-GIT-MAIN
## Pesquisa transversal QA — 20/10/2026
Inventário finito AUD-SEARCH-T01–T11 na mesma matriz/CSV e comparação no manual. Correcções manuais na branch `codex/opportunity-search`, sem integração/remoto/publicação. Assistência NLP ainda não implementada: interpretador e prévia/confirmar da mesma query manual, 6–10 h para recorte local delimitado; modelo externo sujeito a decisão. As oito lacunas contratuais e P7 mantêm-se separados. Revisão Tempo pendente; as provas comuns não encerram requisitos de gestão ou publicação não alterados.
## Guias educativos relacionados — delta QA após 0e7d51f18fb4
Verificação complementar concluída: código funcional `3258a25f7659` ligado ao acervo QA existente `legacy_trio_20261015_test`, sem importação. Página normal `/sectores/turismo` apresenta imagem local `/documentos/25/view` (HTTP 200; 900 px naturais) e resumo; «Ler o guia» abre `/turismo`, ligado ao exemplar real `/documentos/10`. A limitação de integração com biblioteca indicada na primeira prévia fica superada por esta prova. Mantém-se a prova anterior do dossier, pois código/contexto não mudaram. Falta apenas revisão visual da Tempo antes de integrar. IA fora do lote.
AUD-GUIDE-CARD-20261020: componente comum com imagem autorizada, título, resumo do artigo publicado, autoria e «Ler o guia». Reutilizado em sectores, exploração editorial e leituras relacionadas; recomendado no catálogo por sector explícito e nas fichas de oportunidades. Sem associação territorial inferida. Botão secundário apenas com oportunidades públicas. QA: teste isolado 1/8, TypeScript/build e exemplos computador/móvel. Falta decisão sobre integração deste novo delta; a prévia visual de Turismo usa uma cópia por leitura do conteúdo público, sem importação. Recuperação documental num dossier com este acervo não demonstrada, pois o artigo não existe na base QA actual. Percurso e regras anteriores preservados; IA fora do lote.
@@ -10650,3 +10650,4 @@ AUD-SEARCH-T08,Consistência transversal da pesquisa e navegação — Corredore
AUD-SEARCH-T09,Consistência transversal da pesquisa e navegação — Educação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/educacao,investment/catalogue — Reutiliza integralmente a pesquisa corrigida com âmbito educação mantido.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,QA — delta delimitado; revisão Tempo pendente,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Herda formulário e contagens concorrentes das oportunidades.,Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,investment/catalogue; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: results.total=0 com texto inexistente; filtros e componente comuns.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Gestão de procura/capacidade permanece separada e protegida; sem alteração de permissões.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local.
AUD-SEARCH-T10,Consistência transversal da pesquisa e navegação — Biblioteca,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/documentos,"portal/services; portal/document — Resumo único nomeado Documentos, chips removíveis, controlos vazios ocultos sem apagar valores seleccionados, rodapé comum.",Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,QA — delta delimitado; revisão Tempo pendente,Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,"Dois resumos de contagem, filtros activos sem remoção individual, controlos sem opções e rodapé local repetido.",Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia e navegador: vazio e termos preservados; retorno documental existente conservado.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Não houve importação/publicação; a cópia QA usada não contém recursos públicos reais para nova prova positiva.,,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local.
AUD-SEARCH-T11,Consistência transversal da pesquisa e navegação — Legislação,Instruções expressas da Tempo na conversa de 20/10/2026; AGENTS.md; RF-MN-010 e AUD-GEO-01 preservados.,navegação/pesquisa,/legislacao,portal/services; portal/document — Reutiliza a correcção da biblioteca e preserva filtros jurídicos/documentais pertinentes.,Sem alteração remota nesta execução; revisão apenas na branch codex/opportunity-search.,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,QA — delta delimitado; revisão Tempo pendente,"Situação jurídica, consulta e cálculo continuam independentes.",,"Filtros, contagem, resultados e URL coerentes; universos identificados; erro distinto de vazio; permissões e contexto conservados.",P1,,RF-MN-010; AUD-GEO-01,,,,,,,,,,,Mesmo padrão da biblioteca; não deve expor um universo concorrente.,"Situação jurídica, consulta e cálculo continuam independentes.",portal/services; portal/document; SearchState; SearchFeedback; TerritoryFields; portalNavigation,,"Integração em main apenas após revisão; assistência NLP, contratos e P7 separados.",Correcção/simplificação em QA,,HTTP/Inertia: resources.total=0; consumidor e query pública partilhados.,Pesquisa transversal QA 20/10/2026,20/10/2026,,,,,,,,,,"Provas comuns cobrem o componente reutilizado; HTTP vazio não demonstra operações de gestão ou publicação. Situação jurídica, consulta e cálculo continuam independentes.",,falha funcional; navegação; apresentação visual,,Disponível para revisão na aplicação QA local.
AUD-GUIDE-CARD-20261020,"Related educational cards: image, source summary, clear actions",Tempo 20/10/2026; independent delta after 0e7d51f18fb4,educational presentation,/sectores/{slug}; /explorar/{group}; articles; /oportunidades and detail,EducationalArticleCard; EducationalGuides; LegacyContent; SectorInvestment,QA only,1 test / 8 assertions; PHP syntax; TypeScript/build; desktop2048/mobile390; keyboard,QA review pending,Normal QA sector and article verified against existing library resource 10 and local image 25; previous dossier proof retained,,Source-derived summary; public images; conditional opportunity action; context preserved,P1,,,,,,,,,,,,,,,,,,presentation,,,,20/10/2026,,,,,,,,,,,,content; navigation; visual presentation,,Ready for QA review
Can't render this file because it is too large.
@@ -0,0 +1,73 @@
import { Link } from '@inertiajs/react';
export type EducationalArticle = {
title: string;
href: string;
summary: string;
author?: string;
image: { href: string; alt: string } | null;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
};
export default function EducationalArticleCard({
article,
}: {
article: EducationalArticle;
}) {
const focus =
'focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-emerald-800';
return (
<article className="flex min-w-0 flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-900 shadow-sm">
{article.image && (
<img
className="aspect-[16/9] w-full object-cover"
src={article.image.href}
alt={article.image.alt}
loading="lazy"
width={800}
height={450}
/>
)}
<div className="flex flex-1 flex-col items-start gap-4 p-5 sm:p-6">
<p className="text-sm font-medium text-emerald-800">
Guia sectorial
</p>
<h3 className="text-2xl leading-tight font-bold">
<Link href={article.href} className={focus}>
{article.title}
</Link>
</h3>
{article.summary && (
<p className="text-base leading-relaxed text-slate-700">
{article.summary}
</p>
)}
{article.author && (
<p className="text-sm text-slate-600">
Por {article.author}
</p>
)}
<div className="mt-auto flex flex-wrap items-center gap-x-5 gap-y-3 pt-2">
<Link
href={article.href}
className={`inline-flex min-h-11 items-center rounded-lg bg-emerald-900 px-5 py-3 font-semibold text-white hover:bg-emerald-800 ${focus}`}
>
Ler o guia
</Link>
{article.has_opportunities &&
article.opportunities_href && (
<Link
href={article.opportunities_href}
className={`inline-flex min-h-11 items-center py-2 font-semibold text-emerald-900 underline underline-offset-4 ${focus}`}
>
{article.opportunities_label ??
'Ver oportunidades'}
</Link>
)}
</div>
</div>
</article>
);
}
@@ -0,0 +1,27 @@
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export default function EducationalGuides({
articles,
}: {
articles: EducationalArticle[];
}) {
if (!articles.length) return null;
return (
<section
aria-label="Guias para preparar o investimento"
className="space-y-5"
>
<h2 className="text-2xl font-bold">Descubra onde investir</h2>
<div className="grid gap-6 md:grid-cols-2">
{articles.map((article) => (
<EducationalArticleCard
key={article.href}
article={article}
/>
))}
</div>
</section>
);
}
+6 -25
View File
@@ -1,11 +1,9 @@
import { Link } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from './educational-article-card';
export type SectorEntryData = {
articles: {
title: string;
href: string;
image: { href: string; alt: string } | null;
scope: string;
}[];
articles: EducationalArticle[];
relations: {
id: string;
title: string;
@@ -66,9 +64,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
perfil e a modalidade já autorizados; se ainda não tem perfil,
pode preparar a ideia e pedir o perfil de promotor.
</p>
<h2 className="text-xl font-bold">
Conteúdo e possibilidades de negócio
</h2>
<h2 className="text-xl font-bold">Descubra onde investir</h2>
{data.content_notice && <p>{data.content_notice}</p>}
{!data.articles.length && (
<p>
@@ -78,22 +74,7 @@ export default function SectorEntry({ data }: { data: SectorEntryData }) {
)}
<div className="grid gap-4 md:grid-cols-2">
{data.articles.map((a) => (
<article
className="min-w-0 rounded border p-4"
key={a.href}
>
{a.image && (
<img
className="mb-3 h-36 w-full object-cover"
src={a.image.href}
alt={a.image.alt}
/>
)}
<Link className="font-semibold underline" href={a.href}>
{a.title}
</Link>
<p>{a.scope}</p>
</article>
<EducationalArticleCard key={a.href} article={a} />
))}
</div>
{data.specialization_href && (
@@ -1,3 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import type { EducationalArticle } from '@/components/educational-article-card';
import OpportunitySearchFilters from '@/components/opportunity-search-filters';
import SearchState from '@/components/search-state';
import { Link, router, useForm, usePage } from '@inertiajs/react';
@@ -14,6 +16,7 @@ type Feature = {
properties: { id: number; title: string; location: string; url: string };
};
export default function Catalogue({
guides = [],
filters,
references,
results,
@@ -22,6 +25,7 @@ export default function Catalogue({
territorySelection,
catalogueUrl,
}: {
guides?: EducationalArticle[];
filters: Record<string, string | string[] | boolean>;
references: References;
results: {
@@ -712,6 +716,7 @@ export default function Catalogue({
</div>
</section>
)}
<EducationalGuides articles={guides} />
</Frame>
);
}
+5
View File
@@ -1,3 +1,5 @@
import EducationalGuides from '@/components/educational-guides';
import type { EducationalArticle } from '@/components/educational-article-card';
import { ClassificationSummary } from '@/components/opportunity-classification';
import { Link, router, usePage } from '@inertiajs/react';
import { useState } from 'react';
@@ -10,12 +12,14 @@ import TerritorialSources from '@/components/territorial-source-evidence';
import { Frame, card, button, money, groupLabels } from './shared';
import type { Opportunity, Context } from './shared';
export default function Detail({
guides = [],
record: r,
context: c,
zone_origin,
returnUrl = '/oportunidades',
dossierOrigin,
}: {
guides?: EducationalArticle[];
record: Opportunity;
context: Context;
zone_origin?: { id: string; name: string } | null;
@@ -309,6 +313,7 @@ export default function Detail({
</p>
)}
</section>
<EducationalGuides articles={guides} />
</Frame>
);
}
+12 -31
View File
@@ -1,6 +1,13 @@
import { Head, Link, useForm } from '@inertiajs/react';
import EducationalArticleCard, {
type EducationalArticle,
} from '@/components/educational-article-card';
export type LegacyArticle = {
summary: string;
opportunities_href?: string | null;
opportunities_label?: string | null;
has_opportunities?: boolean;
id: number;
title: string;
author: string;
@@ -13,12 +20,7 @@ export type LegacyArticle = {
landing_href: string;
start_href: string;
group: { label: string; heading: string };
related_articles: {
id: number;
slug: string;
title: string;
href: string;
}[];
related_articles: (EducationalArticle & { id: number })[];
html: string;
library_href: string;
image: { href: string; alt: string; source_url: string } | null;
@@ -153,29 +155,10 @@ export default function LegacySector({
{match ? (
<div className="grid gap-4 sm:grid-cols-2">
{articles.map((item) => (
<article
<EducationalArticleCard
key={item.id}
className="space-y-3 rounded-xl border bg-white p-5"
>
<h3 className="text-xl font-semibold">
<Link
className="underline"
href={item.href}
>
{item.title}
</Link>
</h3>
<p>
Conteúdo editorial preservado do portal
Tempo.
</p>
<Link
className="inline-block rounded bg-[#10352b] px-5 py-3 text-white"
href={item.href}
>
Ler o artigo
</Link>
</article>
article={item}
/>
))}
</div>
) : (
@@ -199,9 +182,7 @@ export default function LegacySector({
<ul className="grid gap-3 sm:grid-cols-2">
{article.related_articles.map((item) => (
<li key={item.id}>
<Link className="underline" href={item.href}>
{item.title}
</Link>
<EducationalArticleCard article={item} />
</li>
))}
</ul>
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Tests\Feature;
use App\Services\LegacyContent;
use App\Services\SectorInvestment;
use App\Models\Sector;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class EducationalGuidesTest extends TestCase
{
use DatabaseTransactions;
public function createApplication(): Application
{
$app = parent::createApplication();
if (! IsolatedQaDatabase::allowsSchemaChanges()) throw new \LogicException('Isolated QA required');
return $app;
}
public function test_guide_uses_public_edition_and_hides_unpublished_or_unscanned_sources(): void
{
Storage::fake('local');
config(['portal.legacy_content_qa_enabled' => true]);
$identity = config('legacy_content.articles.turismo');
$body = '<p>Conteúdo sintético para conferir a apresentação do guia e preservar o texto da edição consultada.</p>';
$bytes = json_encode(['format' => 'investmoz-legacy-article-v1', 'source_url' => $identity['source_url'], 'content_html' => $body,
'title' => 'Guia sintético', 'author' => 'Autor QA', 'legacy_id' => 171990, 'legacy_category' => 'Turismo',
'published_at' => '2026-06-16', 'modified_at' => '2026-06-16'], JSON_THROW_ON_ERROR);
$id = (string) Str::uuid();
Storage::disk('local')->put('guides/'.$id, $bytes);
DB::table('documents')->insert(['id' => $id, 'external_id' => $identity['external_id'], 'source_system' => 'synthetic-test',
'file_name' => 'guide.json', 'media_type' => 'application/json', 'storage_key' => 'guides/'.$id, 'size_bytes' => strlen($bytes),
'sha256' => hash('sha256', $bytes), 'malware_scanned_sha256' => hash('sha256', $bytes), 'malware_scan_status' => 'clean',
'status' => 'active', 'rights_status' => 'authorized', 'data_classification' => 'public']);
$resource = DB::table('portal_resources')->insertGetId(['document_id' => $id, 'title' => 'Guia sintético', 'category' => 'education',
'topic' => 'turismo', 'published' => true, 'rights_status' => 'authorized', 'source_url' => $identity['source_url'], 'source_reference' => 'Synthetic isolated QA fixture', 'metadata_version' => 1]);
$sector = Sector::where('slug', 'turismo')->firstOrFail();
$guides = app(SectorInvestment::class)->guides($sector);
$this->assertSame(strip_tags($body), $guides[0]['summary']);
$this->assertSame('Autor QA', $guides[0]['author']);
$this->assertSame('/turismo', $guides[0]['href']);
$this->assertSame('/oportunidades?sector=turismo', $guides[0]['opportunities_href']);
$this->assertSame('/investimentos/a-partir-de/turismo', app(LegacyContent::class)->article('turismo')['start_href']);
DB::table('opportunities')->where('sector_id', $sector->id)->update(['status' => 'draft']);
$this->assertFalse(app(SectorInvestment::class)->guides($sector)[0]['has_opportunities']);
DB::table('portal_resources')->where('id', $resource)->update(['published' => false]);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
DB::table('portal_resources')->where('id', $resource)->update(['published' => true]);
DB::table('documents')->where('id', $id)->update(['malware_scan_status' => 'pending']);
$this->assertSame([], app(SectorInvestment::class)->guides($sector));
}
}