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/);
+});