feat: add public resource and infrastructure evidence layers to catalogue map

This commit is contained in:
2026-11-04 11:16:01 +02:00
parent bbc05f1df6
commit 21ebad73e7
10 changed files with 860 additions and 5 deletions
+11 -3
View File
@@ -522,11 +522,11 @@ class InvestmentCatalogue
->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))
->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id');
}
$features = $missing = [];
$features = $missing = $thematic = [];
$areas = (clone $spatial)->whereIn('shape.geometry_type', ['polygon', 'multipolygon'])->orderBy('shape.id')
->get(['o.id', 'o.title', 'o.slug', 'g.name', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")])
->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
->map(fn ($area) => ['opportunity_id' => (int) $area->id, 'geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null,
'url' => '/oportunidades/'.$area->slug.($filters === [] ? '' : '?'.http_build_query($filters))])->all();
$located = 0;
@@ -536,6 +536,14 @@ class InvestmentCatalogue
foreach ($relations as $relation => $groups) {
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
}
$public = app(ProactiveInvestment::class)->publicData($record);
$evidence = array_values(array_filter(StructuredData::collectionItems($public['evidence'] ?? []),
fn ($proof) => in_array($proof['category'] ?? null, ['resource', 'infrastructure'], true) && ($proof['purpose'] ?? null) === 'local'));
if ($evidence !== []) {
$thematic[] = ['id' => $record->id, 'title' => $record->title, 'url' => $properties['url'], 'location' => $properties['location'],
'state' => $public['state'] ?? 'hypothesis', 'limitations' => $public['limitations'] ?? null,
'evidence' => array_map(fn ($proof) => array_intersect_key($proof, array_flip(['id', 'category', 'statement', 'source', 'locator', 'period', 'scope', 'coverage', 'verified_on'])), $evidence)];
}
$sites = collect($points->get($record->id) ?? []);
if ($sites->isNotEmpty()) {
$located++;
@@ -552,6 +560,6 @@ class InvestmentCatalogue
return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(),
'georeferenced_count' => $located, 'marker_count' => count($features), 'unlocated' => $missing, 'features' => $features,
'areas' => $areas, 'complete' => $total === $located];
'thematic_records' => $thematic, 'areas' => $areas, 'complete' => $total === $located];
}
}
@@ -2228,3 +2228,18 @@ Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis d
Conferência visual no Edge, exclusivamente QA `127.0.0.2:18644`/`main_visual_20261104_test`: Maputo Província mostra zero limites de oito referências distritais acessíveis, lacuna explicitada, nove oportunidades mantidas sem ponto inventado. O snapshot não contém limites públicos para demonstrar visualmente os polígonos inferiores; estes foram exercitados por testes sintéticos. Recibo/hashes: `docs/investmoz/unification/map-subdivision-local-20261104.json`; captura em `outputs/map-subdivision-20261104/`.
**ACT-02 permanece parcial.** Camadas factuais de recursos, infra-estruturas, mão-de-obra e potencial económico, cobertura de dados/geometrias e conferência no portal de destino continuam pendentes. Este recorte não encerra V2-07/V2-10 nem substitui aceitação da equipa. Sem push, migração, escrita activa, importação, alteração de permissões ou reinício de serviços.
### 04/11/2026 — ACT-20261029-02: camadas de evidência pública de recursos e infra-estruturas
Incremento sequencial na main local sobre `bbc05f1d`, reutilizando V2-07/V2-10, mapa único, filtros, pontos/áreas públicos e contexto administrativo já demonstrados. Inventário de código e cópia QA antes de fixtures: 48 oportunidades publicadas e duas sínteses proactivas públicas, mas nenhuma evidência pública local das categorias resource/infrastructure. Os campos existentes de zonas e fontes editoriais não foram convertidos automaticamente em disponibilidade ou geometrias temáticas; evidências privadas não foram publicadas. Não é perda confirmada de dados nem diagnóstico do remoto. Conferidos por SHA256 os 23 artefactos dos dois recibos visuais/administrativos anteriores, todos inalterados.
A API existente de mapa acrescenta `thematic_records` a partir de `ProactiveInvestment::publicData`: somente evidência seleccionada na síntese publicada, categorias recursos/infra-estruturas e propósito local. Conserva facto, fonte, localizador, período, âmbito, cobertura, verificação registada, situação e limitações. Exclui metodologia e outras categorias; não copia o rascunho privado. Mantém a consulta territorial, limite/truncagem e autorização pública anteriores. As áreas acrescentam o identificador da oportunidade para filtragem, sem atribuir a geometria à evidência.
No mesmo mapa, o selector permite todas as oportunidades, recursos documentados ou infra-estruturas documentadas. A camada filtra apenas pontos/áreas existentes das fichas correspondentes; não alarga a pesquisa geral, não altera CSV nem cria posições. Fichas sem ponto continuam consultáveis no painel de evidências/fontes. Contagens referem fichas, não quantidade/capacidade de recursos. Âmbito da evidência distingue-se da localização da oportunidade; marcadores não representam posição exacta ou disponibilidade de recurso/infra-estrutura. Escolha recuperada na sessão do navegador, com tolerância a armazenamento indisponível/inválido. Falta de evidência, período ou fonte é explícita, sem zero presumido.
Delta aprovado: oito testes de BD/438 asserções (quatro novos e quatro de filtros provinciais), 14 testes frontend (cinco novos e nove existentes), PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de rascunhos/publicação futura/acesso restrito, leitura apenas da síntese pública, fonte/período/limites, província/distrito, reutilização das coordenadas existentes, alternância/reposição de camadas, fichas sem ponto, carregamento/ausência/truncagem, escape de texto e recuperação da escolha. Provas gerais anteriores preservadas, sem repetir a bateria completa.
Conferência visual no Edge exclusivamente em `127.0.0.2:18644`, base `main_visual_20261104_test`: duas fixtures claramente sintéticas criadas apenas em QA, uma por categoria; nenhuma coordenada/geometria criada. Hash de todas as oportunidades anteriores idêntico antes/depois. Alternância entre ambas conserva pesquisa provincial e duas fichas gerais; cada camada mostra a sua ficha, fonte/período/âmbito/limites e ausência de ponto. Infra-estruturas recuperada após recarregar. Recibo: `docs/investmoz/unification/thematic-map-local-20261104.json`; inventário, hashes, DOM e capturas em `outputs/thematic-map-20261104/`. Demonstração sintética não constitui cobertura económica factual ou publicação de dados reais.
**ACT-02 permanece parcial.** Cobertura factual de recursos/infra-estruturas no destino, georreferenciação própria quando documentada, mão-de-obra, potencial económico quantitativo, restantes fontes temáticas e lacunas cartográficas continuam pendentes. Não se inferem estas camadas de descrições nem do número de oportunidades. Mantêm-se conferência remota e aceitação da equipa; V2-07/V2-10 não encerrados. Sem push, alteração activa, migração, publicação automática ou alteração de permissões/serviços.
@@ -0,0 +1,151 @@
{
"date": "2026-11-04",
"base_commit": "bbc05f1d",
"requirement": "ACT-20261029-02; V2-07/V2-10",
"scope": "Camadas de evidência temática pública de recursos e infra-estruturas no catálogo existente; main local",
"inventory_before_fixtures": {
"database": "main_visual_20261104_test",
"published_opportunities": 48,
"public_proactive_summaries": 2,
"public_local_evidence": {
"resource": 0,
"infrastructure": 0
},
"methodology_evidence_not_mapped": 0,
"sources": [],
"other_existing_sources": {
"investment_zones.resources/source_evidence": "Textual scope; not converted to georeferenced resource availability",
"portal_resources/legacy_content": "Editorial and document sources; no automatic thematic geometry",
"private proactive evidence": "Not consulted or published by this increment",
"administrative boundaries": "Existing separate layer retained"
}
},
"reuse": [
"ProactiveInvestment::publicData: síntese já publicada/seleccionada, sem ler rascunho privado para divulgação.",
"Categorias resource/infrastructure com propósito local; metodologia e outras categorias excluídas.",
"InvestmentCatalogue::query e map: mesmos filtros, geometrias públicas e limite de 1000 oportunidades.",
"Mapa único e contexto administrativo do incremento bbc05f1d preservados."
],
"contract": {
"route": "GET /api/v1/investment/map",
"new_field": "thematic_records; id/title/url/location/state/limitations/evidence",
"evidence_fields": [
"id",
"category",
"statement",
"source",
"locator",
"period",
"scope",
"coverage",
"verified_on"
],
"area_field": "opportunity_id para filtrar áreas da mesma oportunidade sem atribuir geometria à evidência"
},
"behaviour": [
"Todas as oportunidades conserva os pontos/áreas anteriores.",
"Seleccionar uma categoria apresenta apenas locais/áreas das oportunidades com evidência temática pública local; sem criar posições.",
"Fichas sem ponto continuam consultáveis com as evidências e fontes; ausência não equivale a inexistência ou zero.",
"Escolha da camada recuperada na sessão do navegador; falha/valor inválido de armazenamento não bloqueia mapa.",
"Contagens gerais da pesquisa distinguem-se do subconjunto da camada; CSV e resultados gerais mantêm filtros anteriores.",
"Sem inferir mão-de-obra ou potencial económico de descrições ou contagens."
],
"checks": {
"database_tests": 8,
"database_assertions": 438,
"new_database_tests": 4,
"existing_province_regression_tests": 4,
"frontend_tests": 14,
"new_frontend_tests": 5,
"phpstan_errors": 0,
"types": "passed",
"pint": "passed",
"lint": "passed",
"qa_build": "passed; avisos de fonte opcional e dimensão de chunks"
},
"visual": {
"target": "http://127.0.0.2:18644",
"database": "main_visual_20261104_test",
"data": "Duas fixtures exclusivamente sintéticas; base QA sem evidência temática local publicada antes das fixtures.",
"resource_selected": true,
"infrastructure_selected": true,
"infrastructure_selection_recovered_after_reload": true,
"source_period_scope_limits_visible": true,
"points_created": false,
"geometry_created": false,
"existing_opportunities_unchanged_sha256": "c9d156f8a71d9196b4f36c3558b830267232b2d407c7abcf9b92bc027bce99d3"
},
"preservation": {
"prior_artifacts_verified_unchanged": 23,
"accepted_database_suite_tests_preserved_not_reexecuted": 1325,
"prior_records_and_pending_items_retained": true
},
"pending": [
"Cobertura factual real de recursos e infra-estruturas depende de evidências públicas locais existentes no destino; nada foi publicado automaticamente.",
"Posição exacta, capacidade e disponibilidade dos recursos/infra-estruturas não foram deduzidas da posição da oportunidade.",
"Mão-de-obra, potencial económico quantitativo, restantes fontes temáticas e cobertura cartográfica continuam pendentes.",
"Sem conferência remota ou encerramento de ACT-02/V2-07/V2-10."
],
"safety": {
"branch": "main",
"push": false,
"active_database_changed": false,
"schema_or_permissions_changed": false,
"automatic_publication": false,
"other_services_restarted": false
},
"proofs": {
"outputs/thematic-map-20261104/source-inventory-before-fixtures.json": {
"sha256": "7366d737a5cd516c9667a43d62909d1529928285f4da7197c8f0bddab7f85503",
"bytes": 700
},
"outputs/thematic-map-20261104/fixture-receipt.json": {
"sha256": "dcf66ccf5773a694638e86cde14bc40e7d2fec56867d6146ac8c9077fc03659d",
"bytes": 417
},
"outputs/thematic-map-20261104/resources.jpg": {
"sha256": "f7c3d03e09728d320662a648b083b96ad12bc5bc40825f22529c2d9c882c164f",
"bytes": 162214
},
"outputs/thematic-map-20261104/resources.dom.txt": {
"sha256": "6816332137f2fc2a0c840e213997e9f64589166e3ce0e6ebd1619b27bbde5de9",
"bytes": 14232
},
"outputs/thematic-map-20261104/infrastructure-reopened.jpg": {
"sha256": "f97455f86832e72eaffd04ce01f53d0a339b966d658c68ba62cfc1d5eb4bc5ba",
"bytes": 153964
},
"outputs/thematic-map-20261104/infrastructure-reopened.dom.txt": {
"sha256": "c5fc7b92df9a69c1026a0f7e61217ce96ac5f27b6e3b15d56a2a53a3c3ffb15f",
"bytes": 14254
},
"outputs/main-db-qa-20261104/thematic-map-delta.log": {
"sha256": "ae49afa4a4f8af69ad3ae88470da9d556322b8b6b57b035e2e6476a24d287b1a",
"bytes": 96
},
"outputs/main-db-qa-20261104/thematic-map-delta.junit.xml": {
"sha256": "cc3e177001c7c254832c4eebfa8e13cf5b9a3067121f1fb1efe517fe4d4b8d6e",
"bytes": 3756
},
"outputs/main-db-qa-20261104/thematic-map-frontend.log": {
"sha256": "5523fa5f62f0d40b0ada1ea0faf9d90e649c797dd1b6df89b4fa7086b7e4a6bd",
"bytes": 1970
},
"outputs/main-db-qa-20261104/thematic-map-phpstan.log": {
"sha256": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2",
"bytes": 49
},
"outputs/main-db-qa-20261104/thematic-map-types.log": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bytes": 0
},
"outputs/main-db-qa-20261104/thematic-map-lint.log": {
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"bytes": 0
},
"outputs/main-db-qa-20261104/thematic-map-build.log": {
"sha256": "2f452020e952def0eb1fd9c4469880f35016b23d3757054d84520a5d5d4ef1a1",
"bytes": 31606
}
}
}
@@ -9124,3 +9124,18 @@ Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis d
Conferência visual no Edge, exclusivamente QA `127.0.0.2:18644`/`main_visual_20261104_test`: Maputo Província mostra zero limites de oito referências distritais acessíveis, lacuna explicitada, nove oportunidades mantidas sem ponto inventado. O snapshot não contém limites públicos para demonstrar visualmente os polígonos inferiores; estes foram exercitados por testes sintéticos. Recibo/hashes: `docs/investmoz/unification/map-subdivision-local-20261104.json`; captura em `outputs/map-subdivision-20261104/`.
**ACT-02 permanece parcial.** Camadas factuais de recursos, infra-estruturas, mão-de-obra e potencial económico, cobertura de dados/geometrias e conferência no portal de destino continuam pendentes. Este recorte não encerra V2-07/V2-10 nem substitui aceitação da equipa. Sem push, migração, escrita activa, importação, alteração de permissões ou reinício de serviços.
### 04/11/2026 — ACT-20261029-02: camadas de evidência pública de recursos e infra-estruturas
Incremento sequencial na main local sobre `bbc05f1d`, reutilizando V2-07/V2-10, mapa único, filtros, pontos/áreas públicos e contexto administrativo já demonstrados. Inventário de código e cópia QA antes de fixtures: 48 oportunidades publicadas e duas sínteses proactivas públicas, mas nenhuma evidência pública local das categorias resource/infrastructure. Os campos existentes de zonas e fontes editoriais não foram convertidos automaticamente em disponibilidade ou geometrias temáticas; evidências privadas não foram publicadas. Não é perda confirmada de dados nem diagnóstico do remoto. Conferidos por SHA256 os 23 artefactos dos dois recibos visuais/administrativos anteriores, todos inalterados.
A API existente de mapa acrescenta `thematic_records` a partir de `ProactiveInvestment::publicData`: somente evidência seleccionada na síntese publicada, categorias recursos/infra-estruturas e propósito local. Conserva facto, fonte, localizador, período, âmbito, cobertura, verificação registada, situação e limitações. Exclui metodologia e outras categorias; não copia o rascunho privado. Mantém a consulta territorial, limite/truncagem e autorização pública anteriores. As áreas acrescentam o identificador da oportunidade para filtragem, sem atribuir a geometria à evidência.
No mesmo mapa, o selector permite todas as oportunidades, recursos documentados ou infra-estruturas documentadas. A camada filtra apenas pontos/áreas existentes das fichas correspondentes; não alarga a pesquisa geral, não altera CSV nem cria posições. Fichas sem ponto continuam consultáveis no painel de evidências/fontes. Contagens referem fichas, não quantidade/capacidade de recursos. Âmbito da evidência distingue-se da localização da oportunidade; marcadores não representam posição exacta ou disponibilidade de recurso/infra-estrutura. Escolha recuperada na sessão do navegador, com tolerância a armazenamento indisponível/inválido. Falta de evidência, período ou fonte é explícita, sem zero presumido.
Delta aprovado: oito testes de BD/438 asserções (quatro novos e quatro de filtros provinciais), 14 testes frontend (cinco novos e nove existentes), PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de rascunhos/publicação futura/acesso restrito, leitura apenas da síntese pública, fonte/período/limites, província/distrito, reutilização das coordenadas existentes, alternância/reposição de camadas, fichas sem ponto, carregamento/ausência/truncagem, escape de texto e recuperação da escolha. Provas gerais anteriores preservadas, sem repetir a bateria completa.
Conferência visual no Edge exclusivamente em `127.0.0.2:18644`, base `main_visual_20261104_test`: duas fixtures claramente sintéticas criadas apenas em QA, uma por categoria; nenhuma coordenada/geometria criada. Hash de todas as oportunidades anteriores idêntico antes/depois. Alternância entre ambas conserva pesquisa provincial e duas fichas gerais; cada camada mostra a sua ficha, fonte/período/âmbito/limites e ausência de ponto. Infra-estruturas recuperada após recarregar. Recibo: `docs/investmoz/unification/thematic-map-local-20261104.json`; inventário, hashes, DOM e capturas em `outputs/thematic-map-20261104/`. Demonstração sintética não constitui cobertura económica factual ou publicação de dados reais.
**ACT-02 permanece parcial.** Cobertura factual de recursos/infra-estruturas no destino, georreferenciação própria quando documentada, mão-de-obra, potencial económico quantitativo, restantes fontes temáticas e lacunas cartográficas continuam pendentes. Não se inferem estas camadas de descrições nem do número de oportunidades. Mantêm-se conferência remota e aceitação da equipa; V2-07/V2-10 não encerrados. Sem push, alteração activa, migração, publicação automática ou alteração de permissões/serviços.
@@ -3432,3 +3432,18 @@ Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis d
Conferência visual no Edge, exclusivamente QA `127.0.0.2:18644`/`main_visual_20261104_test`: Maputo Província mostra zero limites de oito referências distritais acessíveis, lacuna explicitada, nove oportunidades mantidas sem ponto inventado. O snapshot não contém limites públicos para demonstrar visualmente os polígonos inferiores; estes foram exercitados por testes sintéticos. Recibo/hashes: `docs/investmoz/unification/map-subdivision-local-20261104.json`; captura em `outputs/map-subdivision-20261104/`.
**ACT-02 permanece parcial.** Camadas factuais de recursos, infra-estruturas, mão-de-obra e potencial económico, cobertura de dados/geometrias e conferência no portal de destino continuam pendentes. Este recorte não encerra V2-07/V2-10 nem substitui aceitação da equipa. Sem push, migração, escrita activa, importação, alteração de permissões ou reinício de serviços.
### 04/11/2026 — ACT-20261029-02: camadas de evidência pública de recursos e infra-estruturas
Incremento sequencial na main local sobre `bbc05f1d`, reutilizando V2-07/V2-10, mapa único, filtros, pontos/áreas públicos e contexto administrativo já demonstrados. Inventário de código e cópia QA antes de fixtures: 48 oportunidades publicadas e duas sínteses proactivas públicas, mas nenhuma evidência pública local das categorias resource/infrastructure. Os campos existentes de zonas e fontes editoriais não foram convertidos automaticamente em disponibilidade ou geometrias temáticas; evidências privadas não foram publicadas. Não é perda confirmada de dados nem diagnóstico do remoto. Conferidos por SHA256 os 23 artefactos dos dois recibos visuais/administrativos anteriores, todos inalterados.
A API existente de mapa acrescenta `thematic_records` a partir de `ProactiveInvestment::publicData`: somente evidência seleccionada na síntese publicada, categorias recursos/infra-estruturas e propósito local. Conserva facto, fonte, localizador, período, âmbito, cobertura, verificação registada, situação e limitações. Exclui metodologia e outras categorias; não copia o rascunho privado. Mantém a consulta territorial, limite/truncagem e autorização pública anteriores. As áreas acrescentam o identificador da oportunidade para filtragem, sem atribuir a geometria à evidência.
No mesmo mapa, o selector permite todas as oportunidades, recursos documentados ou infra-estruturas documentadas. A camada filtra apenas pontos/áreas existentes das fichas correspondentes; não alarga a pesquisa geral, não altera CSV nem cria posições. Fichas sem ponto continuam consultáveis no painel de evidências/fontes. Contagens referem fichas, não quantidade/capacidade de recursos. Âmbito da evidência distingue-se da localização da oportunidade; marcadores não representam posição exacta ou disponibilidade de recurso/infra-estrutura. Escolha recuperada na sessão do navegador, com tolerância a armazenamento indisponível/inválido. Falta de evidência, período ou fonte é explícita, sem zero presumido.
Delta aprovado: oito testes de BD/438 asserções (quatro novos e quatro de filtros provinciais), 14 testes frontend (cinco novos e nove existentes), PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de rascunhos/publicação futura/acesso restrito, leitura apenas da síntese pública, fonte/período/limites, província/distrito, reutilização das coordenadas existentes, alternância/reposição de camadas, fichas sem ponto, carregamento/ausência/truncagem, escape de texto e recuperação da escolha. Provas gerais anteriores preservadas, sem repetir a bateria completa.
Conferência visual no Edge exclusivamente em `127.0.0.2:18644`, base `main_visual_20261104_test`: duas fixtures claramente sintéticas criadas apenas em QA, uma por categoria; nenhuma coordenada/geometria criada. Hash de todas as oportunidades anteriores idêntico antes/depois. Alternância entre ambas conserva pesquisa provincial e duas fichas gerais; cada camada mostra a sua ficha, fonte/período/âmbito/limites e ausência de ponto. Infra-estruturas recuperada após recarregar. Recibo: `docs/investmoz/unification/thematic-map-local-20261104.json`; inventário, hashes, DOM e capturas em `outputs/thematic-map-20261104/`. Demonstração sintética não constitui cobertura económica factual ou publicação de dados reais.
**ACT-02 permanece parcial.** Cobertura factual de recursos/infra-estruturas no destino, georreferenciação própria quando documentada, mão-de-obra, potencial económico quantitativo, restantes fontes temáticas e lacunas cartográficas continuam pendentes. Não se inferem estas camadas de descrições nem do número de oportunidades. Mantêm-se conferência remota e aceitação da equipa; V2-07/V2-10 não encerrados. Sem push, alteração activa, migração, publicação automática ou alteração de permissões/serviços.
@@ -24,6 +24,7 @@ export type InvestmentMapFeature = {
};
};
export type InvestmentMapArea = {
opportunity_id?: number;
selected_territory?: boolean;
child_territory?: boolean;
location_type?: string;
@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react';
import CatalogueInvestmentMap from './catalogue-investment-map';
import type {
InvestmentMapArea,
InvestmentMapFeature,
} from './interactive-investment-map';
export type ThematicMapRecord = {
id: number;
title: string;
url: string;
location: string;
state: string;
limitations: string | null;
evidence: {
id: string;
category: 'resource' | 'infrastructure';
statement?: string | null;
source?: string | null;
locator?: string | null;
period?: string | null;
scope?: string | null;
coverage?: string | null;
verified_on?: string | null;
}[];
};
const labels = {
resource: 'Recursos documentados',
infrastructure: 'Infra-estruturas documentadas',
};
const value = (text?: string | null) => text?.trim() || 'Não indicado';
export default function ThematicInvestmentMap({
points,
areas,
records,
territoryId,
truncated,
}: {
points: InvestmentMapFeature[] | null;
areas: InvestmentMapArea[];
records: ThematicMapRecord[];
territoryId?: string;
truncated: boolean;
}) {
const [theme, setTheme] = useState('all');
useEffect(() => {
try {
const saved = window.sessionStorage.getItem(
'investmoz-map-theme-v1',
);
if (saved === 'resource' || saved === 'infrastructure')
setTheme(saved);
} catch {
// The server and first client render use the same default layer.
}
}, []);
const selectTheme = (next: string) => {
setTheme(next);
try {
window.sessionStorage.setItem('investmoz-map-theme-v1', next);
} catch {
// Keep the map usable when session storage is unavailable.
}
};
const selected = records
.map((record) => ({
...record,
evidence: record.evidence.filter(
(proof) => theme === 'all' || proof.category === theme,
),
}))
.filter((record) => record.evidence.length > 0);
const ids = new Set(selected.map((record) => record.id));
const visiblePoints =
theme === 'all'
? points
: (points?.filter((point) => ids.has(point.properties.id)) ?? null);
const visibleAreas =
theme === 'all'
? areas
: areas.filter(
(area) =>
area.opportunity_id !== undefined &&
ids.has(area.opportunity_id),
);
const countLabel = (count: number) =>
`${count} ${count === 1 ? 'ficha' : 'fichas'}`;
const located = new Set((points ?? []).map((point) => point.properties.id));
return (
<div className="space-y-3">
<section
className="space-y-3 rounded border p-3"
aria-label="Camadas temáticas documentadas"
>
<label
className="block font-semibold"
htmlFor="investment-map-theme"
>
Camada temática do mapa
</label>
<select
id="investment-map-theme"
className="w-full rounded border p-2"
value={theme}
disabled={points === null}
onChange={(event) => selectTheme(event.target.value)}
>
<option value="all">Todas as oportunidades</option>
{Object.entries(labels).map(([key, label]) => (
<option key={key} value={key}>
{label} (
{countLabel(
records.filter((record) =>
record.evidence.some(
(proof) => proof.category === key,
),
).length,
)}
)
</option>
))}
</select>
<p>
A camada filtra os locais e áreas das oportunidades com
evidência pública seleccionada. Os marcadores localizam a
oportunidade; não representam a posição exacta, capacidade
ou disponibilidade do recurso ou da infra-estrutura. A
pesquisa geral mantém os seus filtros e resultados.
</p>
<p>
Mão-de-obra e potencial económico: sem camada quantitativa
nesta consulta; não são inferidos de descrições ou do número
de oportunidades.
</p>
{truncated && (
<p role="note">
A resposta está limitada; a cobertura temática refere-se
apenas às fichas recebidas.
</p>
)}
{points === null ? (
<p role="status">A carregar evidências temáticas…</p>
) : (
<>
<p role="status">
{countLabel(selected.length)} com evidência temática
pública nesta resposta;{' '}
{
selected.filter(
(record) => !located.has(record.id),
).length
}{' '}
sem ponto público utilizável. Ausência de evidência
não significa ausência do recurso.
</p>
{selected.length === 0 && (
<p>
Sem evidência pública local de recursos ou
infra-estruturas para esta camada e estes
filtros. Não foram criadas posições no mapa.
</p>
)}
{selected.length > 0 && (
<details className="space-y-2">
<summary>
Evidências e fontes da camada (
{countLabel(selected.length)})
</summary>
{selected.map((record) => (
<details
key={record.id}
className="rounded border p-2"
>
<summary>
{record.title} —{' '}
{located.has(record.id)
? 'Com ponto da oportunidade'
: 'Sem ponto público utilizável'}
</summary>
<a
className="underline"
href={record.url}
>
Consultar ficha
</a>
<p>
Localização da ficha:{' '}
{value(record.location)}. O âmbito
de cada evidência está indicado
abaixo.
</p>
<p>
Situação publicada:{' '}
{record.state === 'confirmed'
? 'Confirmada no âmbito da síntese publicada'
: 'Hipótese'}
. Limitações:{' '}
{value(record.limitations)}
</p>
<ul>
{record.evidence.map((proof) => (
<li
key={proof.id}
className="my-2 space-y-1 border-t pt-2"
>
<p className="font-semibold">
{labels[proof.category]}
</p>
<p>
{value(proof.statement)}
</p>
<p>
Fonte:{' '}
{value(proof.source)} ·
Localizador:{' '}
{value(proof.locator)}
</p>
<p>
Período:{' '}
{value(proof.period)} ·
Verificação registada:{' '}
{value(
proof.verified_on,
)}
</p>
<p>
Âmbito:{' '}
{value(proof.scope)} ·
Cobertura e limites:{' '}
{value(proof.coverage)}
</p>
</li>
))}
</ul>
</details>
))}
</details>
)}
</>
)}
</section>
<CatalogueInvestmentMap
points={visiblePoints}
areas={visibleAreas}
territoryId={territoryId}
/>
</div>
);
}
+11 -2
View File
@@ -15,7 +15,9 @@ import OpportunityRelatedResults, {
} from '@/components/opportunity-related-results';
import { Link, router, useForm, usePage } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import Map from '@/components/catalogue-investment-map';
import Map, {
type ThematicMapRecord,
} from '@/components/thematic-investment-map';
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
import { type TerritoryOption } from '@/components/investment-territory';
import { Frame, button, card, money, api } from './shared';
@@ -178,6 +180,9 @@ export default function Catalogue({
});
}, [comparison, view]);
const [points, setPoints] = useState<Feature[] | null>(null);
const [thematicRecords, setThematicRecords] = useState<ThematicMapRecord[]>(
[],
);
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
const [unlocated, setUnlocated] = useState<
{ id: number; title: string; location: string; url: string }[]
@@ -320,6 +325,7 @@ export default function Catalogue({
}
setPoints(null);
setAreas([]);
setThematicRecords([]);
setUnlocated([]);
setMapContext([]);
setTruncated(false);
@@ -329,6 +335,7 @@ export default function Catalogue({
if (active) {
setPoints(data.features);
setAreas(data.areas ?? []);
setThematicRecords(data.thematic_records ?? []);
setUnlocated(data.unlocated ?? []);
setMapContext(data.territorial_context ?? []);
setTruncated(Boolean(data.truncated));
@@ -637,7 +644,7 @@ export default function Catalogue({
) : points === null ? (
'A carregar o mapa…'
) : (
`${points.length} ${points.length === 1 ? 'local' : 'locais'} no mapa; ${unlocated.length} ${unlocated.length === 1 ? 'oportunidade' : 'oportunidades'} sem coordenadas confirmadas.`
`Na pesquisa: ${points.length} ${points.length === 1 ? 'local com coordenadas' : 'locais com coordenadas'}; ${unlocated.length} ${unlocated.length === 1 ? 'oportunidade' : 'oportunidades'} sem coordenadas confirmadas.`
)}
</div>
)}
@@ -647,6 +654,8 @@ export default function Catalogue({
<Map
points={error ? [] : points}
areas={areas}
records={thematicRecords}
truncated={truncated}
territoryId={
String(
filters.locality_id ||
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\User;
use App\Services\InvestmentGeography;
use App\Support\IsolatedQaDatabase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentThematicMapTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(IsolatedQaDatabase::allowsSchemaChanges());
$this->withoutVite();
$this->withoutMiddleware(ThrottleRequests::class);
}
private function proof(string $category = 'resource', string $purpose = 'local'): array
{
return ['id' => (string) Str::uuid(), 'category' => $category, 'purpose' => $purpose,
'statement' => 'SYNTHETIC QA documented observation', 'source' => 'Synthetic public source',
'locator' => 'Table QA', 'period' => '2026', 'scope' => 'Synthetic district',
'coverage' => 'Partial observation; no availability confirmed', 'verified_on' => '2026-10-01'];
}
private function record(string $prefix, array $proofs, ?string $territory = null): Opportunity
{
$record = Opportunity::create(['sector_id' => 1, 'reference' => 'QA-'.Str::uuid(), 'slug' => 'qa-'.Str::uuid(),
'title' => $prefix.' synthetic thematic map', 'status' => 'published', 'published_at' => now()->subMinute(),
'primary_territory_id' => $territory]);
$record->forceFill(['workflow_context' => ['promotional_summary' => ['projection' => ['proactive' => [
'evidence' => $proofs, 'state' => 'hypothesis', 'limitations' => 'SYNTHETIC QA scope only']]],
'private_evidence' => 'PRIVATE-DRAFT-MUST-NOT-LEAK']])->save();
return $record;
}
private function url(string $prefix, array $filters = []): string
{
return '/api/v1/investment/map?'.http_build_query(['q' => $prefix, ...$filters]);
}
public function test_only_selected_public_local_thematic_evidence_is_exposed_with_its_sources_and_limits(): void
{
$prefix = 'THEME-'.Str::uuid();
$record = $this->record($prefix, [$this->proof(), $this->proof('infrastructure'), $this->proof('resource', 'methodology'), $this->proof('demand')]);
$before = $record->fresh()->getRawOriginal('workflow_context');
$response = $this->getJson($this->url($prefix))->assertOk()->assertJsonPath('matched_count', 1)
->assertJsonPath('thematic_records.0.id', $record->id)->assertJsonCount(2, 'thematic_records.0.evidence')
->assertJsonPath('thematic_records.0.evidence.0.source', 'Synthetic public source')
->assertJsonPath('thematic_records.0.evidence.0.period', '2026')
->assertJsonPath('thematic_records.0.evidence.0.scope', 'Synthetic district')
->assertJsonPath('thematic_records.0.state', 'hypothesis')->assertJsonCount(0, 'features')->assertJsonCount(1, 'unlocated');
$this->assertStringNotContainsString('PRIVATE-DRAFT-MUST-NOT-LEAK', $response->getContent());
$this->assertSame($before, $record->fresh()->getRawOriginal('workflow_context'));
}
public function test_unpublished_future_restricted_and_unselected_private_evidence_are_not_disclosed(): void
{
$prefix = 'THEME-'.Str::uuid();
$draft = $this->record($prefix, [$this->proof()]);
$draft->update(['status' => 'draft']);
$future = $this->record($prefix, [$this->proof()]);
$future->update(['published_at' => now()->addDay()]);
$restricted = $this->record($prefix, [$this->proof()]);
$restricted->forceFill(['owner_user_id' => User::factory()->create()->id])->save();
$private = $this->record($prefix, []);
$private->forceFill(['workflow_context' => ['private_evidence' => [$this->proof()]]])->save();
$this->getJson($this->url($prefix))->assertOk()->assertJsonPath('matched_count', 1)->assertJsonCount(0, 'thematic_records');
}
public function test_thematic_records_follow_the_same_province_and_district_filters_without_inventing_coordinates(): void
{
$prefix = 'THEME-'.Str::uuid();
$geo = app(InvestmentGeography::class);
$districts = $geo->publicReferences()->where('location_type', 'district')->get(['id', 'parent_id']);
$district = $districts->first();
$other = $districts->first(fn ($row) => $row->parent_id !== $district->parent_id);
$this->assertNotNull($other);
$record = $this->record($prefix, [$this->proof()], $district->id);
$this->record($prefix, [$this->proof('infrastructure')], $other->id);
foreach ([['province_id' => $district->parent_id], ['province_id' => $district->parent_id, 'district_id' => $district->id]] as $filters) {
$this->getJson($this->url($prefix, $filters))->assertOk()->assertJsonPath('matched_count', 1)
->assertJsonCount(1, 'thematic_records')->assertJsonPath('thematic_records.0.id', $record->id)
->assertJsonCount(0, 'features')->assertJsonCount(1, 'unlocated');
}
$this->assertNull($record->fresh()->district);
}
public function test_thematic_evidence_reuses_existing_site_coordinates_without_relocating_the_resource(): void
{
$prefix = 'THEME-'.Str::uuid();
$record = $this->record($prefix, [$this->proof()]);
$site = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $site, 'name' => 'Synthetic opportunity site', 'code' => $site,
'location_type' => 'site', 'data_classification' => 'public', 'source_system' => 'investment-site-editor']);
DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'point',ST_SetSRID(ST_MakePoint(32.5,-25.9),4326),'{}'::jsonb,'public')", [(string) Str::uuid(), $site]);
$record->update(['primary_territory_id' => $site]);
$this->getJson($this->url($prefix))->assertOk()->assertJsonCount(1, 'features')
->assertJsonPath('features.0.geometry.coordinates', [32.5, -25.9])
->assertJsonPath('features.0.properties.id', $record->id)->assertJsonPath('thematic_records.0.id', $record->id);
$this->assertSame($site, $record->fresh()->primary_territory_id);
}
}
@@ -0,0 +1,278 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const Map = () => React.createElement('div', { 'data-test-map': true });
function setup() {
let theme;
let mounted = false;
const source = fs.readFileSync(
new URL(
'../../resources/js/components/thematic-investment-map.tsx',
import.meta.url,
),
'utf8',
);
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const module = { exports: {} };
compileFunction(output, ['require', 'module', 'exports'])(
(name) =>
name === 'react'
? {
...React,
useEffect: (effect) => {
if (!mounted) {
mounted = true;
effect();
}
},
useState: (initial) => {
if (theme === undefined)
theme =
typeof initial === 'function'
? initial()
: initial;
return [
theme,
(next) => {
theme = next;
},
];
},
}
: name === './catalogue-investment-map'
? { default: Map }
: require(name),
module,
module.exports,
);
return (...args) => {
module.exports.default(...args);
return module.exports.default(...args);
};
}
const flat = (node) =>
!node || typeof node !== 'object'
? []
: [node, ...React.Children.toArray(node.props.children).flatMap(flat)];
const proof = (category) => ({
id: category,
category,
statement: '<script>synthetic</script>',
source: 'Synthetic source',
locator: 'Table 4',
period: '2024',
scope: 'District A',
coverage: 'Partial coverage',
verified_on: '2026-10-01',
});
const records = [
{
id: 1,
title: 'Resource fixture',
url: '/fixture/1',
location: 'District A',
state: 'hypothesis',
limitations: 'No availability confirmed',
evidence: [proof('resource')],
},
{
id: 2,
title: 'Infrastructure fixture',
url: '/fixture/2',
location: 'Province B',
state: 'hypothesis',
limitations: null,
evidence: [proof('infrastructure')],
},
];
const points = [1, 3].map((id) => ({
geometry: { coordinates: [32, -25] },
properties: {
id,
title: 'Site ' + id,
url: '/fixture/' + id,
location: 'Confirmed opportunity site',
},
}));
const areas = [1, 2, 3].map((id) => ({
opportunity_id: id,
title: 'Area ' + id,
geometry: { type: 'Polygon', coordinates: [] },
}));
const props = {
points,
areas,
records,
territoryId: 'province-a',
truncated: false,
};
test('default preserves every opportunity and one map, with sourced thematic evidence and no new location claims', () => {
const Component = setup(),
tree = Component(props),
nodes = flat(tree);
const map = nodes.find((node) => node.type === Map);
assert.equal(nodes.filter((node) => node.type === Map).length, 1);
assert.equal(map.props.points, points);
assert.equal(map.props.areas, areas);
assert.equal(map.props.territoryId, 'province-a');
const html = renderToStaticMarkup(tree);
for (const text of [
'Synthetic source',
'Table 4',
'2024',
'District A',
'Partial coverage',
'2026-10-01',
'não representam a posição exacta',
'não são inferidos',
])
assert.ok(html.includes(text), text);
assert.match(html, /2 fichas com evidência temática pública/);
assert.match(html, /1 sem ponto público utilizável/);
assert.doesNotMatch(html, /<script>/);
});
test('choosing resources filters existing points and opportunity areas, retaining administrative identity and source context', () => {
const Component = setup();
flat(Component(props))
.find((node) => node.type === 'select')
.props.onChange({ target: { value: 'resource' } });
const tree = Component(props),
map = flat(tree).find((node) => node.type === Map);
assert.deepEqual(map.props.points, [points[0]]);
assert.deepEqual(map.props.areas, [areas[0]]);
assert.equal(map.props.territoryId, 'province-a');
const html = renderToStaticMarkup(tree);
assert.match(html, /Resource fixture/);
assert.doesNotMatch(html, /Infrastructure fixture/);
flat(tree)
.find((node) => node.type === 'select')
.props.onChange({ target: { value: 'all' } });
assert.equal(
flat(Component(props)).find((node) => node.type === Map).props.points,
points,
);
});
test('infrastructure without confirmed coordinates remains consultable without borrowing another opportunity point', () => {
const Component = setup();
flat(Component(props))
.find((node) => node.type === 'select')
.props.onChange({ target: { value: 'infrastructure' } });
const tree = Component(props),
map = flat(tree).find((node) => node.type === Map);
assert.deepEqual(map.props.points, []);
assert.deepEqual(map.props.areas, [areas[1]]);
assert.match(renderToStaticMarkup(tree), /Infrastructure fixture/);
assert.match(renderToStaticMarkup(tree), /Sem ponto público utilizável/);
assert.ok(
flat(tree).some(
(node) => node.type === 'a' && node.props.href === '/fixture/2',
),
);
});
test('changing query data never retains previous evidence; loading and incomplete coverage are explicit', () => {
const Component = setup();
flat(Component(props))
.find((node) => node.type === 'select')
.props.onChange({ target: { value: 'resource' } });
const loading = Component({
...props,
records: [],
points: null,
areas: [],
});
assert.equal(
flat(loading).find((node) => node.type === Map).props.points,
null,
);
assert.equal(
flat(loading).find((node) => node.type === 'select').props.disabled,
true,
);
assert.doesNotMatch(renderToStaticMarkup(loading), /Resource fixture/);
const html = renderToStaticMarkup(
Component({
...props,
points: [],
records: [],
areas: [],
truncated: true,
}),
);
assert.match(html, /Sem evidência pública local/);
assert.match(html, /A resposta está limitada/);
assert.match(html, /não significa ausência do recurso/);
});
test('theme selection survives reload in the tab and unavailable or invalid storage does not break the map', () => {
const previous = globalThis.window;
let saved = 'resource';
globalThis.window = {
sessionStorage: {
getItem: () => saved,
setItem: (key, value) => {
assert.equal(key, 'investmoz-map-theme-v1');
saved = value;
},
},
};
try {
const Component = setup();
let select = flat(Component(props)).find(
(node) => node.type === 'select',
);
assert.equal(select.props.value, 'resource');
select.props.onChange({ target: { value: 'infrastructure' } });
assert.equal(saved, 'infrastructure');
assert.equal(
flat(setup()(props)).find((node) => node.type === 'select').props
.value,
'infrastructure',
);
saved = 'invalid';
assert.equal(
flat(setup()(props)).find((node) => node.type === 'select').props
.value,
'all',
);
globalThis.window = {
sessionStorage: {
getItem: () => {
throw new Error('Storage unavailable');
},
setItem: () => {
throw new Error('Storage unavailable');
},
},
};
const WithoutStorage = setup();
select = flat(WithoutStorage(props)).find(
(node) => node.type === 'select',
);
assert.equal(select.props.value, 'all');
select.props.onChange({ target: { value: 'resource' } });
assert.equal(
flat(WithoutStorage(props)).find((node) => node.type === 'select')
.props.value,
'resource',
);
} finally {
if (previous === undefined) delete globalThis.window;
else globalThis.window = previous;
}
});