From bbc05f1df65333ae5b78db23f4dc75af02a82e89 Mon Sep 17 00:00:00 2001 From: nala Date: Wed, 4 Nov 2026 10:58:28 +0200 Subject: [PATCH] feat: show immediate administrative subdivisions and map coverage gaps --- .../InvestmentTerritoryController.php | 4 +- app/Services/TerritoryGeometry.php | 24 +++- .../consolidated-review-matrix-2026-10-03.md | 15 +++ .../map-subdivision-local-20261104.json | 112 +++++++++++++++ .../unification/traceability-and-manual.md | 15 +++ docs/investmoz/v2/auditoria-portal/backlog.md | 15 +++ .../components/catalogue-investment-map.tsx | 11 +- .../components/interactive-investment-map.tsx | 3 +- .../components/territory-geometry-preview.tsx | 27 +++- .../js/components/use-territory-geometry.ts | 14 +- .../TerritoryGeometrySubdivisionTest.php | 127 ++++++++++++++++++ tests/frontend/catalogue-map-layers.test.mjs | 58 +++++++- 12 files changed, 399 insertions(+), 26 deletions(-) create mode 100644 docs/investmoz/unification/map-subdivision-local-20261104.json create mode 100644 tests/Feature/TerritoryGeometrySubdivisionTest.php diff --git a/app/Http/Controllers/InvestmentTerritoryController.php b/app/Http/Controllers/InvestmentTerritoryController.php index e8e85e49..9323e096 100644 --- a/app/Http/Controllers/InvestmentTerritoryController.php +++ b/app/Http/Controllers/InvestmentTerritoryController.php @@ -16,7 +16,7 @@ class InvestmentTerritoryController extends Controller { public function geometries(Request $request): JsonResponse { - $data = $request->validate(['territory_id' => ['required', 'uuid']]); + $data = $request->validate(['territory_id' => ['required', 'uuid'], 'include_children' => ['sometimes', 'boolean']]); $profile = $request->is('minhas-oportunidades/*'); $management = $request->is('management/*') || $profile; $actor = $request->user(); @@ -31,7 +31,7 @@ class InvestmentTerritoryController extends Controller } return response()->json([ - ...app(TerritoryGeometry::class)->context($data['territory_id'], $internal), + ...app(TerritoryGeometry::class)->context($data['territory_id'], $internal, $request->boolean('include_children')), 'geometry_scope' => $internal ? 'authorized_internal' : 'public', ])->header('Cache-Control', 'private, no-store'); } diff --git a/app/Services/TerritoryGeometry.php b/app/Services/TerritoryGeometry.php index dcd701e6..eaf5b171 100644 --- a/app/Services/TerritoryGeometry.php +++ b/app/Services/TerritoryGeometry.php @@ -8,15 +8,20 @@ use Illuminate\Support\Facades\DB; final class TerritoryGeometry { /** @return array */ - public function context(string $id, bool $management = false): array + public function context(string $id, bool $management = false, bool $includeChildren = false): array { $geography = app(InvestmentGeography::class); - abort_unless($geography->references($management)->where('id', $id)->exists(), 404); + $selected = $geography->references($management)->where('id', $id)->first(['location_type']); + abort_unless($selected !== null, 404); $path = $geography->path($id, $management); - $ids = array_map(fn ($node) => $node->id, $path); + $ids = array_values(array_unique([$id, ...array_map(fn ($node) => $node->id, $path)])); + $childType = ['country' => 'province', 'province' => 'district', 'district' => 'admin_post', 'admin_post' => 'locality'][$selected->location_type] ?? null; + $children = $geography->references($management)->where('parent_id', $id)->where('location_type', $childType ?? 'none'); + $childCount = $includeChildren && $childType ? (clone $children)->count() : 0; + $childIds = $includeChildren && $childType ? $children->orderBy('name')->orderBy('id')->limit(500)->pluck('id')->all() : []; $approved = app(ApprovedAdministrativeBoundaries::class); $rows = DB::table('geo_geometries as g')->join('geo_locations as l', 'l.id', '=', 'g.geo_location_id') - ->whereIn('g.geo_location_id', $ids)->whereNull('g.deleted_at')->whereNull('l.deleted_at') + ->whereIn('g.geo_location_id', [...$ids, ...$childIds])->whereNull('g.deleted_at')->whereNull('l.deleted_at') ->where(function ($query) use ($management, $approved) { $query->where('g.data_classification', 'public'); $query->orWhere(function ($internal) use ($management, $approved) { @@ -29,20 +34,22 @@ final class TerritoryGeometry ->whereRaw("g.properties->>'purpose' = 'administrative_boundary'") ->whereIn('g.geometry_type', ['polygon', 'multipolygon']) ->whereRaw('ST_IsValid(g.geom) AND NOT ST_IsEmpty(g.geom) AND ST_SRID(g.geom)=4326') + ->orderByRaw('CASE WHEN g.geo_location_id = ? THEN 0 WHEN g.geo_location_id IN ('.implode(',', array_fill(0, count($ids), '?')).') THEN 1 ELSE 2 END', [$id, ...$ids]) ->orderBy('g.geo_location_id')->orderBy('g.id')->limit(101) ->get(['g.id', 'g.geo_location_id', 'l.name', 'l.location_type', 'g.properties', 'g.data_classification', DB::raw('ST_AsGeoJSON(ST_SimplifyPreserveTopology(g.geom,0.0001),6) as geometry'), DB::raw("encode(sha256(ST_AsEWKB(g.geom)),'hex') as geometry_sha256"), DB::raw("encode(sha256(convert_to(g.properties::text,'UTF8')),'hex') as properties_sha256")]); + $queryTruncated = $rows->count() > 100; if (! $management) { $rows = $rows->filter(fn ($row) => $row->data_classification === 'public' || $approved->allows($row->id, $row->geo_location_id, $row->geometry_sha256, $row->properties_sha256))->values(); } - $areas = $rows->take(100)->map(function ($row) use ($id, $management) { + $areas = $rows->take(100)->map(function ($row) use ($id, $management, $childIds) { $source = json_decode($row->properties, true, 512, JSON_THROW_ON_ERROR); return ['id' => $row->id, 'territory_id' => $row->geo_location_id, 'location_type' => $row->location_type, - 'selected_territory' => $row->geo_location_id === $id, 'geometry' => json_decode($row->geometry, true, 512, JSON_THROW_ON_ERROR), + 'selected_territory' => $row->geo_location_id === $id, 'child_territory' => in_array($row->geo_location_id, $childIds, true), 'geometry' => json_decode($row->geometry, true, 512, JSON_THROW_ON_ERROR), 'title' => 'Limite administrativo: '.$row->name, 'purpose' => 'administrative_boundary', 'source_reference' => $source['authority'] ?? null, 'source_date' => $source['source_version'] ?? null, 'source_url' => $source['source_url'] ?? null, 'source_sha256' => $source['source_sha256'] ?? null, @@ -51,6 +58,9 @@ final class TerritoryGeometry return ['territory_id' => $id, 'territory_registered' => true, 'selected_geometry_available' => $rows->contains('geo_location_id', $id), 'context_geometry_count' => count($areas), - 'areas' => $areas, 'truncated' => $rows->count() > 100, 'investment_site' => false]; + 'areas' => $areas, 'truncated' => $queryTruncated || $childCount > 500, 'investment_site' => false, + 'child_coverage' => $includeChildren ? ['location_type' => $childType, 'registered_count' => $childCount, + 'represented_count' => count(array_unique(array_column(array_filter($areas, fn ($area) => $area['child_territory']), 'territory_id'))), + 'reference_limit' => 500, 'geometry_limit' => 100] : null]; } } diff --git a/docs/investmoz/consolidated-review-matrix-2026-10-03.md b/docs/investmoz/consolidated-review-matrix-2026-10-03.md index 400d72ca..7c406d51 100644 --- a/docs/investmoz/consolidated-review-matrix-2026-10-03.md +++ b/docs/investmoz/consolidated-review-matrix-2026-10-03.md @@ -2213,3 +2213,18 @@ Filtro apenas por Maputo Província, com sector Agronegócio: cinco oportunidade Detectada e corrigida divergência no CSV: coluna Província vazia apesar da localização canónica/contexto de corredor disponível. Exportação reutiliza a apresentação territorial existente, conserva as oito primeiras colunas e o escape contra fórmulas, acrescentando Localização e âmbito. Contexto do corredor indica expressamente que distrito/posto não foram indicados; não grava nem inventa localização. CSV descarregado pela interface após correcção: nove linhas, todas com província e âmbito coerentes. Delta aprovado: 17 testes de base de dados, 609 asserções; PHPStan sem erros e Pint aprovado. Provas anteriores de 1.325 testes/28.377 asserções preservadas, sem repetição integral. Recibo com hashes e capturas: `docs/investmoz/unification/main-local-visual-qa-20261104.json`; artefactos em `outputs/main-visual-qa-20261104/`. Apenas main local e QA; sem push, escrita activa ou alteração de permissões activas. Conferência dos filtros no portal de destino e pendências gerais ACT-02/V2-07/V2-10 continuam abertas. + + +### 04/11/2026 — ACT-20261029-02: limites do nível imediatamente inferior e lacunas de cobertura + +Continuação sequencial na main local sobre `6dde36b5`, reutilizando V2-07/V2-10, serviço de geometria, permissões e mapa único. Inventário prévio confirmou camadas/legenda/fontes já existentes e contexto limitado à selecção/antepassados; nenhuma perda confirmada. Os 12 artefactos do recibo visual anterior foram novamente conferidos por SHA256, sem alteração. Provas aceites de 1.325 testes e demais incrementos preservadas, sem repetição integral. + +O catálogo solicita `include_children=1` no endpoint administrativo existente. A opção acrescenta apenas referências do nível imediatamente inferior: distritos de uma província, postos de um distrito e localidades de um posto. Sem a opção conserva o âmbito anterior. Geometrias continuam sujeitas à classificação, acesso efectivo e snapshots/hashes de divulgação existentes; não há publicação, importação ou nova autorização. O território seleccionado tem prioridade, seguido dos antepassados e subdivisões. Até 100 geometrias e 500 referências inferiores, com truncagem explícita. + +Resposta acrescenta `child_territory` e `child_coverage` com nível, referências acessíveis e territórios representados, distinguindo contagem territorial de polígonos. Interface identifica subdivisão/contexto/território seleccionado, conserva fonte/edição e explicita que falta de limite na consulta não significa inexistência do território, nem terreno disponível. Nenhuma oportunidade é reposicionada e nenhuma coordenada é presumida. + +Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis de acesso existente), nove testes frontend, PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de outra província/netos, limites públicos/internos, referência eliminada, ausência de geometria, priorização e truncagem de geometrias/referências, validação do parâmetro e descarte de resposta anterior ao mudar a opção. Primeira tentativa da fixture falhou por faltar país-pai; corrigida a fixture, mantendo a integridade territorial e os logs iniciais. + +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. diff --git a/docs/investmoz/unification/map-subdivision-local-20261104.json b/docs/investmoz/unification/map-subdivision-local-20261104.json new file mode 100644 index 00000000..31b91b5e --- /dev/null +++ b/docs/investmoz/unification/map-subdivision-local-20261104.json @@ -0,0 +1,112 @@ +{ + "date": "2026-11-04", + "base_commit": "6dde36b5", + "requirement": "ACT-20261029-02; V2-07/V2-10", + "scope": "Incremento local de cobertura administrativa por nível inferior; actividade global parcial", + "implemented": [ + "Consulta opcional include_children=1 no endpoint existente; catálogo activa a opção.", + "Apenas filhos do nível imediatamente inferior e contexto anterior; não inclui outros territórios ou netos.", + "Geometria seleccionada prioritária; até 100 geometrias e 500 referências inferiores; truncagem explícita.", + "Contagens de referências acessíveis e territórios representados distintas; ausência na resposta não significa inexistência.", + "Fonte/edição preservadas; subdivisão identificada sem atribuição de local de investimento.", + "Leitura pública, autorização interna, snapshots aprovados e hashes de divulgação existentes preservados." + ], + "contract": { + "route": "GET /api/v1/investment/territory-geometries", + "optional_parameter": "include_children=1", + "default": "sem filhos; mantém âmbito anterior", + "new_area_field": "child_territory", + "new_context_field": "child_coverage; null sem opção", + "registered_count": "referências activas acessíveis do nível imediatamente inferior", + "represented_count": "territórios inferiores distintos com geometria autorizada nesta resposta; não número de polígonos" + }, + "checks": { + "database_tests": 11, + "database_assertions": 125, + "new_database_tests": 5, + "access_regression_tests": 6, + "frontend_tests": 9, + "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", + "province": "Maputo Província", + "registered_children": 8, + "represented_children": 0, + "opportunities_preserved": 9, + "new_geometry_or_coordinates": false, + "status": "Resumo de ausência de cobertura conferido no Edge; apresentação de polígonos inferiores exercitada em testes sintéticos, não demonstrada visualmente neste snapshot sem limites públicos." + }, + "previous_proofs": { + "visual_receipt": "docs/investmoz/unification/main-local-visual-qa-20261104.json", + "all_12_artifact_hashes_verified_unchanged": true, + "database_receipt": "docs/investmoz/unification/main-local-database-qa-20261104.json", + "accepted_tests_not_reexecuted": 1325 + }, + "initial_attempt": "Fixture nova sem país-pai recusada pela integridade territorial; fixture corrigida sem relaxar o trigger. Tentativa inicial preservada em map-subdivision-delta-initial.*.", + "pending": [ + "Camadas factuais de recursos, infra-estruturas, mão-de-obra e potencial económico permanecem por completar com dados compatíveis.", + "Cobertura administrativa depende das geometrias autorizadas existentes; não houve importação/publicação.", + "Conferência no portal remoto e aceitação da equipa mantêm-se pendentes.", + "ACT-02 e requisitos transversais não encerrados." + ], + "safety": { + "branch": "main local", + "push": false, + "active_db_changed": false, + "migration": false, + "permissions_changed": false, + "other_services_restarted": false + }, + "proofs": { + "outputs/map-subdivision-20261104/coverage-maputo.jpg": { + "sha256": "cddce8a1e2c4aa789aa1d4412c020940a6a1c7de9fe18467576f1449d3a7b476", + "bytes": 148244 + }, + "outputs/map-subdivision-20261104/coverage-maputo.dom.txt": { + "sha256": "81285b9ffd6c4db0d43f9d8126d6c91bdc86821b5ac9f64a20696cb9ceaa9135", + "bytes": 23142 + }, + "outputs/main-db-qa-20261104/map-subdivision-delta.log": { + "sha256": "affeadfee17b9402e5ecc75526a00b02d5109597d7a5e008dc99555ce2e031b8", + "bytes": 94 + }, + "outputs/main-db-qa-20261104/map-subdivision-delta.junit.xml": { + "sha256": "13bda85376995be9c018ca63f1f2420ba86d146be4ec024b8531e7e5ad5f4589", + "bytes": 2455 + }, + "outputs/main-db-qa-20261104/map-access-regression.log": { + "sha256": "0745715415ee74ca4fc27f1aec6ddd1dba32bbf67c54df59850ae2ecf2be1305", + "bytes": 95 + }, + "outputs/main-db-qa-20261104/map-access-regression.junit.xml": { + "sha256": "287e24afb3fc545ecff3d3ab63345ac700619d71c8819a3ec028a445d394136c", + "bytes": 2870 + }, + "outputs/main-db-qa-20261104/map-subdivision-frontend.log": { + "sha256": "fe3eb8a09e68e841ce44006477c3ad84f80ba5aa7a2f36b9e22c342bb5c7071e", + "bytes": 1335 + }, + "outputs/main-db-qa-20261104/map-subdivision-phpstan.log": { + "sha256": "1997495ca8f04c2ec09798d55607d1a2f85e3338b772f258666465bf658032d2", + "bytes": 49 + }, + "outputs/main-db-qa-20261104/map-subdivision-types.log": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + "outputs/main-db-qa-20261104/map-subdivision-lint.log": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "bytes": 0 + }, + "outputs/main-db-qa-20261104/map-subdivision-build.log": { + "sha256": "81990a37221e58bb43b7718b22d824922c5db2e1ffd1c979d2d60002cf0f38e4", + "bytes": 31606 + } + } +} diff --git a/docs/investmoz/unification/traceability-and-manual.md b/docs/investmoz/unification/traceability-and-manual.md index 5af67f4f..b3953795 100644 --- a/docs/investmoz/unification/traceability-and-manual.md +++ b/docs/investmoz/unification/traceability-and-manual.md @@ -9109,3 +9109,18 @@ Filtro apenas por Maputo Província, com sector Agronegócio: cinco oportunidade Detectada e corrigida divergência no CSV: coluna Província vazia apesar da localização canónica/contexto de corredor disponível. Exportação reutiliza a apresentação territorial existente, conserva as oito primeiras colunas e o escape contra fórmulas, acrescentando Localização e âmbito. Contexto do corredor indica expressamente que distrito/posto não foram indicados; não grava nem inventa localização. CSV descarregado pela interface após correcção: nove linhas, todas com província e âmbito coerentes. Delta aprovado: 17 testes de base de dados, 609 asserções; PHPStan sem erros e Pint aprovado. Provas anteriores de 1.325 testes/28.377 asserções preservadas, sem repetição integral. Recibo com hashes e capturas: `docs/investmoz/unification/main-local-visual-qa-20261104.json`; artefactos em `outputs/main-visual-qa-20261104/`. Apenas main local e QA; sem push, escrita activa ou alteração de permissões activas. Conferência dos filtros no portal de destino e pendências gerais ACT-02/V2-07/V2-10 continuam abertas. + + +### 04/11/2026 — ACT-20261029-02: limites do nível imediatamente inferior e lacunas de cobertura + +Continuação sequencial na main local sobre `6dde36b5`, reutilizando V2-07/V2-10, serviço de geometria, permissões e mapa único. Inventário prévio confirmou camadas/legenda/fontes já existentes e contexto limitado à selecção/antepassados; nenhuma perda confirmada. Os 12 artefactos do recibo visual anterior foram novamente conferidos por SHA256, sem alteração. Provas aceites de 1.325 testes e demais incrementos preservadas, sem repetição integral. + +O catálogo solicita `include_children=1` no endpoint administrativo existente. A opção acrescenta apenas referências do nível imediatamente inferior: distritos de uma província, postos de um distrito e localidades de um posto. Sem a opção conserva o âmbito anterior. Geometrias continuam sujeitas à classificação, acesso efectivo e snapshots/hashes de divulgação existentes; não há publicação, importação ou nova autorização. O território seleccionado tem prioridade, seguido dos antepassados e subdivisões. Até 100 geometrias e 500 referências inferiores, com truncagem explícita. + +Resposta acrescenta `child_territory` e `child_coverage` com nível, referências acessíveis e territórios representados, distinguindo contagem territorial de polígonos. Interface identifica subdivisão/contexto/território seleccionado, conserva fonte/edição e explicita que falta de limite na consulta não significa inexistência do território, nem terreno disponível. Nenhuma oportunidade é reposicionada e nenhuma coordenada é presumida. + +Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis de acesso existente), nove testes frontend, PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de outra província/netos, limites públicos/internos, referência eliminada, ausência de geometria, priorização e truncagem de geometrias/referências, validação do parâmetro e descarte de resposta anterior ao mudar a opção. Primeira tentativa da fixture falhou por faltar país-pai; corrigida a fixture, mantendo a integridade territorial e os logs iniciais. + +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. diff --git a/docs/investmoz/v2/auditoria-portal/backlog.md b/docs/investmoz/v2/auditoria-portal/backlog.md index df832e8e..ebd8a6ed 100644 --- a/docs/investmoz/v2/auditoria-portal/backlog.md +++ b/docs/investmoz/v2/auditoria-portal/backlog.md @@ -3417,3 +3417,18 @@ Filtro apenas por Maputo Província, com sector Agronegócio: cinco oportunidade Detectada e corrigida divergência no CSV: coluna Província vazia apesar da localização canónica/contexto de corredor disponível. Exportação reutiliza a apresentação territorial existente, conserva as oito primeiras colunas e o escape contra fórmulas, acrescentando Localização e âmbito. Contexto do corredor indica expressamente que distrito/posto não foram indicados; não grava nem inventa localização. CSV descarregado pela interface após correcção: nove linhas, todas com província e âmbito coerentes. Delta aprovado: 17 testes de base de dados, 609 asserções; PHPStan sem erros e Pint aprovado. Provas anteriores de 1.325 testes/28.377 asserções preservadas, sem repetição integral. Recibo com hashes e capturas: `docs/investmoz/unification/main-local-visual-qa-20261104.json`; artefactos em `outputs/main-visual-qa-20261104/`. Apenas main local e QA; sem push, escrita activa ou alteração de permissões activas. Conferência dos filtros no portal de destino e pendências gerais ACT-02/V2-07/V2-10 continuam abertas. + + +### 04/11/2026 — ACT-20261029-02: limites do nível imediatamente inferior e lacunas de cobertura + +Continuação sequencial na main local sobre `6dde36b5`, reutilizando V2-07/V2-10, serviço de geometria, permissões e mapa único. Inventário prévio confirmou camadas/legenda/fontes já existentes e contexto limitado à selecção/antepassados; nenhuma perda confirmada. Os 12 artefactos do recibo visual anterior foram novamente conferidos por SHA256, sem alteração. Provas aceites de 1.325 testes e demais incrementos preservadas, sem repetição integral. + +O catálogo solicita `include_children=1` no endpoint administrativo existente. A opção acrescenta apenas referências do nível imediatamente inferior: distritos de uma província, postos de um distrito e localidades de um posto. Sem a opção conserva o âmbito anterior. Geometrias continuam sujeitas à classificação, acesso efectivo e snapshots/hashes de divulgação existentes; não há publicação, importação ou nova autorização. O território seleccionado tem prioridade, seguido dos antepassados e subdivisões. Até 100 geometrias e 500 referências inferiores, com truncagem explícita. + +Resposta acrescenta `child_territory` e `child_coverage` com nível, referências acessíveis e territórios representados, distinguindo contagem territorial de polígonos. Interface identifica subdivisão/contexto/território seleccionado, conserva fonte/edição e explicita que falta de limite na consulta não significa inexistência do território, nem terreno disponível. Nenhuma oportunidade é reposicionada e nenhuma coordenada é presumida. + +Delta aprovado: 11 testes de base de dados/125 asserções (cinco novos e seis de acesso existente), nove testes frontend, PHPStan sem erros, tipos, Pint, lint e build QA aprovados. Inclui exclusão de outra província/netos, limites públicos/internos, referência eliminada, ausência de geometria, priorização e truncagem de geometrias/referências, validação do parâmetro e descarte de resposta anterior ao mudar a opção. Primeira tentativa da fixture falhou por faltar país-pai; corrigida a fixture, mantendo a integridade territorial e os logs iniciais. + +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. diff --git a/resources/js/components/catalogue-investment-map.tsx b/resources/js/components/catalogue-investment-map.tsx index 627655c1..753e0b34 100644 --- a/resources/js/components/catalogue-investment-map.tsx +++ b/resources/js/components/catalogue-investment-map.tsx @@ -18,7 +18,11 @@ export default function CatalogueInvestmentMap({ territoryId?: string; mapKind?: InvestmentMapKind; }) { - const { context, error } = useTerritoryGeometry(territoryId); + const { context, error } = useTerritoryGeometry( + territoryId, + undefined, + true, + ); return ( 0 - ? ' Os limites de níveis superiores são apenas contexto e não representam a selecção.' + ? ' Os limites disponíveis são contexto administrativo e não substituem o limite da selecção.' : ''}

)} {context?.truncated && (

Cobertura administrativa incompleta: são - apresentados até 100 limites. + consultadas até 100 geometrias e 500 + subdivisões.

)} diff --git a/resources/js/components/interactive-investment-map.tsx b/resources/js/components/interactive-investment-map.tsx index 2fc96a26..bbba0cec 100644 --- a/resources/js/components/interactive-investment-map.tsx +++ b/resources/js/components/interactive-investment-map.tsx @@ -25,6 +25,7 @@ export type InvestmentMapFeature = { }; export type InvestmentMapArea = { selected_territory?: boolean; + child_territory?: boolean; location_type?: string; purpose?: string; geometry: GeoJsonObject; @@ -67,7 +68,7 @@ export default function InteractiveInvestmentMap({ }[mapKind]; const areaDescription = (area: InvestmentMapArea) => area.purpose === 'administrative_boundary' - ? 'Limite administrativo da edição indicada; não representa um local de investimento' + ? `${area.selected_territory ? 'Território seleccionado' : area.child_territory ? 'Subdivisão do território seleccionado' : 'Contexto de nível superior'} · Limite administrativo da edição indicada; não representa um local de investimento` : area.purpose === 'declared_parcel' ? 'Perímetro declarado pelo Promotor; não é limite administrativo nem comprova disponibilidade legal' : mapKind === 'documented_locations' diff --git a/resources/js/components/territory-geometry-preview.tsx b/resources/js/components/territory-geometry-preview.tsx index 5690a369..7734f59a 100644 --- a/resources/js/components/territory-geometry-preview.tsx +++ b/resources/js/components/territory-geometry-preview.tsx @@ -62,21 +62,32 @@ export function TerritoryGeometryDetails({ {!context.selected_geometry_available && context.areas.length > 0 && (

- O mapa mostra apenas limites dos níveis - superiores como contexto. Estes limites não - representam o território seleccionado. + O mapa mostra outros limites da hierarquia como + contexto. Estes limites não representam o + território seleccionado.

)}

{context.context_geometry_count} limites disponíveis na - hierarquia selecionada. São contexto administrativo, não + hierarquia consultada. São contexto administrativo, não perímetro nem disponibilidade de investimento. A edição e a atualidade jurídica são informações distintas.

+ {context.child_coverage?.location_type && ( +

+ Subdivisões do nível imediatamente inferior com + limite nesta resposta:{' '} + {context.child_coverage.represented_count} de{' '} + {context.child_coverage.registered_count}{' '} + referências acessíveis. As restantes não têm limite + apresentado nesta consulta; isso não significa + inexistência do território. +

+ )} {context.truncated && (

- Exibidos os primeiros 100 limites; cobertura - incompleta. + Consulta limitada a 100 geometrias e 500 + subdivisões; cobertura incompleta.

)} {renderMap && context.areas.length > 0 && ( @@ -93,7 +104,9 @@ export function TerritoryGeometryDetails({ {area.title} ·{' '} {area.selected_territory ? 'Território seleccionado' - : 'Contexto de nível superior'}{' '} + : area.child_territory + ? 'Subdivisão do território seleccionado' + : 'Contexto de nível superior'}{' '} · Fonte:{' '} {area.source_reference ?? 'Não indicada'} · Edição: {area.source_date ?? 'Não indicada'} diff --git a/resources/js/components/use-territory-geometry.ts b/resources/js/components/use-territory-geometry.ts index fa9d65d8..e9b08595 100644 --- a/resources/js/components/use-territory-geometry.ts +++ b/resources/js/components/use-territory-geometry.ts @@ -6,13 +6,21 @@ export type TerritoryGeometryContext = { context_geometry_count: number; areas: InvestmentMapArea[]; truncated: boolean; + child_coverage?: { + location_type: string | null; + registered_count: number; + represented_count: number; + reference_limit: number; + geometry_limit: number; + } | null; }; export function useTerritoryGeometry( territoryId?: string, endpoint = '/api/v1/investment/territory-geometries', + includeChildren = false, ) { - const key = JSON.stringify([territoryId, endpoint]); + const key = JSON.stringify([territoryId, endpoint, includeChildren]); const [result, setResult] = useState<{ key: string; context: TerritoryGeometryContext | null; @@ -23,7 +31,7 @@ export function useTerritoryGeometry( setResult({ key, context: null, error: '' }); if (!territoryId) return () => controller.abort(); void fetch( - `${endpoint}?${new URLSearchParams({ territory_id: territoryId })}`, + `${endpoint}?${new URLSearchParams({ territory_id: territoryId, ...(includeChildren ? { include_children: '1' } : {}) })}`, { signal: controller.signal, headers: { Accept: 'application/json' }, @@ -52,6 +60,6 @@ export function useTerritoryGeometry( }); }); return () => controller.abort(); - }, [key, territoryId, endpoint]); + }, [key, territoryId, endpoint, includeChildren]); return result.key === key ? result : { key, context: null, error: '' }; } diff --git a/tests/Feature/TerritoryGeometrySubdivisionTest.php b/tests/Feature/TerritoryGeometrySubdivisionTest.php new file mode 100644 index 00000000..d3bdeec8 --- /dev/null +++ b/tests/Feature/TerritoryGeometrySubdivisionTest.php @@ -0,0 +1,127 @@ +assertStringEndsWith('_test', $target->name); + $this->assertSame(55441, (int) $target->port); + $this->assertSame('127.0.0.1', config('database.connections.pgsql.host')); + $this->withoutMiddleware(ThrottleRequests::class); + } + + private function location(string $type, ?string $parent = null, string $classification = 'public'): string + { + if ($type === 'province' && $parent === null) { + $parent = $this->location('country'); + } + $id = (string) Str::uuid(); + DB::table('geo_locations')->insert(['id' => $id, 'name' => 'Synthetic '.$type.' '.$id, 'code' => $id, + 'location_type' => $type, 'parent_id' => $parent, 'data_classification' => $classification, + 'source_system' => 'cartografia-workbook', + 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]); + + return $id; + } + + private function geometry(string $territory, string $classification = 'public'): string + { + $id = (string) Str::uuid(); + DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,?)", + [$id, $territory, json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic QA only', 'source_version' => '2026-11-04']), $classification]); + + return $id; + } + + public function test_optional_children_preserve_default_and_sources_without_grandchildren_or_other_provinces(): void + { + $province = $this->location('province'); + $district = $this->location('district', $province); + $post = $this->location('admin_post', $district); + $this->geometry($province); + $this->geometry($district); + $this->geometry($post); + $this->geometry($this->location('district', $this->location('province'))); + $url = '/api/v1/investment/territory-geometries?territory_id='.$province; + $this->getJson($url)->assertOk()->assertJsonCount(1, 'areas')->assertJsonPath('child_coverage', null); + $result = $this->getJson($url.'&include_children=1')->assertOk()->assertJsonCount(2, 'areas') + ->assertJsonPath('areas.0.territory_id', $province)->assertJsonPath('areas.0.selected_territory', true) + ->assertJsonPath('areas.1.territory_id', $district)->assertJsonPath('areas.1.child_territory', true) + ->assertJsonPath('areas.1.source_reference', 'Synthetic QA only')->assertJsonPath('areas.1.source_date', '2026-11-04') + ->assertJsonPath('child_coverage.registered_count', 1)->assertJsonPath('child_coverage.represented_count', 1) + ->assertJsonPath('investment_site', false)->assertJsonPath('truncated', false)->json(); + $this->assertFalse($result['areas'][1]['selected_territory']); + $this->getJson('/api/v1/investment/territory-geometries?territory_id='.$district.'&include_children=1') + ->assertOk()->assertJsonCount(3, 'areas')->assertJsonPath('child_coverage.location_type', 'admin_post'); + } + + public function test_coverage_counts_only_accessible_references_and_never_publishes_private_boundaries(): void + { + $province = $this->location('province'); + $public = $this->location('district', $province); + $missing = $this->location('district', $province); + $hidden = $this->location('district', $province, 'internal'); + $privateGeometry = $this->geometry($public, 'internal'); + $this->geometry($hidden); + $before = DB::table('geo_geometries')->where('id', $privateGeometry)->value('data_classification'); + $this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1') + ->assertOk()->assertJsonCount(0, 'areas')->assertJsonPath('selected_geometry_available', false) + ->assertJsonPath('child_coverage.registered_count', 2)->assertJsonPath('child_coverage.represented_count', 0); + $internal = app(TerritoryGeometry::class)->context($province, true, true); + $this->assertSame(3, $internal['child_coverage']['registered_count']); + $this->assertSame(2, $internal['child_coverage']['represented_count']); + $this->assertSame($before, DB::table('geo_geometries')->where('id', $privateGeometry)->value('data_classification')); + DB::table('geo_locations')->where('id', $missing)->update(['deleted_at' => now()]); + $this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1') + ->assertOk()->assertJsonPath('child_coverage.registered_count', 1); + } + + public function test_limits_are_explicit_and_selected_boundary_is_retained_ahead_of_children(): void + { + $province = $this->location('province'); + $selected = $this->geometry($province); + for ($i = 0; $i < 101; $i++) { + $this->geometry($this->location('district', $province)); + } + $this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1') + ->assertOk()->assertJsonCount(100, 'areas')->assertJsonPath('areas.0.id', $selected) + ->assertJsonPath('selected_geometry_available', true)->assertJsonPath('truncated', true) + ->assertJsonPath('child_coverage.registered_count', 101)->assertJsonPath('child_coverage.represented_count', 99); + } + + public function test_reference_limit_reports_unrepresented_subdivisions_without_claiming_they_do_not_exist(): void + { + $province = $this->location('province'); + for ($i = 0; $i < 501; $i++) { + $this->location('district', $province); + } + $this->getJson('/api/v1/investment/territory-geometries?territory_id='.$province.'&include_children=1') + ->assertOk()->assertJsonCount(0, 'areas')->assertJsonPath('truncated', true) + ->assertJsonPath('child_coverage.registered_count', 501)->assertJsonPath('child_coverage.represented_count', 0); + } + + public function test_invalid_child_option_is_rejected_and_locality_has_no_lower_level(): void + { + $province = $this->location('province'); + $district = $this->location('district', $province); + $post = $this->location('admin_post', $district); + $locality = $this->location('locality', $post); + $url = '/api/v1/investment/territory-geometries?territory_id='.$locality; + $this->getJson($url.'&include_children=all')->assertUnprocessable(); + $this->getJson($url.'&include_children=1')->assertOk()->assertJsonPath('child_coverage.location_type', null) + ->assertJsonPath('child_coverage.registered_count', 0)->assertJsonPath('truncated', false); + } +} diff --git a/tests/frontend/catalogue-map-layers.test.mjs b/tests/frontend/catalogue-map-layers.test.mjs index 554131e5..7b8015d4 100644 --- a/tests/frontend/catalogue-map-layers.test.mjs +++ b/tests/frontend/catalogue-map-layers.test.mjs @@ -81,7 +81,9 @@ await test('catalogue composes one map with the same opportunity data and separa './interactive-investment-map': { default: Map }, './territory-geometry-preview': { TerritoryGeometryDetails: Info }, './use-territory-geometry': { - useTerritoryGeometry: (id) => { + useTerritoryGeometry: (id, endpoint, children) => { + assert.equal(endpoint, undefined); + assert.equal(children, true); requests.push(id); return { context: { areas: [boundary] }, error: '' }; }, @@ -125,9 +127,9 @@ await test('territorial request changes discard old boundaries immediately and i const { useTerritoryGeometry } = load('use-territory-geometry.ts', { react: hooks, }); - const render = (id) => { + const render = (id, children = false) => { cursor = 0; - return useTerritoryGeometry(id); + return useTerritoryGeometry(id, undefined, children); }; render('a'); const cleanup = effect(); @@ -150,6 +152,22 @@ await test('territorial request changes discard old boundaries immediately and i assert.equal(render(undefined).context, null); assert.equal(pending[0].options.signal.aborted, true); assert.equal(pending.length, 2); + assert.equal(render('b', true).context, null); + effect(); + assert.match(pending[2].url, /include_children=1/); + pending[2].resolve({ + ok: true, + json: async () => ({ + areas: [boundary], + child_coverage: { registered_count: 2 }, + }), + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + render('b', true).context.child_coverage.registered_count, + 2, + ); + assert.equal(render('b', false).context, null); } finally { globalThis.fetch = previousFetch; } @@ -452,3 +470,37 @@ await test('partnership catalogue has one contextual map and keeps the applied t assert.equal(maps()[0].props.territoryId, 'a'); assert.equal(maps()[0].props.points, props.mapPoints); }); + +await test('administrative coverage identifies lower-level subdivisions and distinguishes absent geometry from absent territory', () => { + const Details = load('territory-geometry-preview.tsx', { + './interactive-investment-map': { default: () => null }, + './use-territory-geometry': {}, + }).TerritoryGeometryDetails; + const html = renderToStaticMarkup( + React.createElement(Details, { + error: '', + context: { + selected_geometry_available: false, + context_geometry_count: 1, + truncated: true, + child_coverage: { + location_type: 'district', + registered_count: 3, + represented_count: 1, + }, + areas: [ + { + ...boundary, + selected_territory: false, + child_territory: true, + }, + ], + }, + }), + ); + assert.match(html, /1 de 3 referências acessíveis/); + assert.match(html, /Subdivisão do território seleccionado/); + assert.match(html, /não significa inexistência do território/); + assert.match(html, /100 geometrias e 500 subdivisões/); + assert.doesNotMatch(html, /apenas limites dos níveis superiores/); +});