feat: show immediate administrative subdivisions and map coverage gaps
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -8,15 +8,20 @@ use Illuminate\Support\Facades\DB;
|
||||
final class TerritoryGeometry
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<InteractiveInvestmentMap
|
||||
points={points}
|
||||
@@ -54,14 +58,15 @@ export default function CatalogueInvestmentMap({
|
||||
Sem limite administrativo acessível para o
|
||||
território seleccionado.
|
||||
{context.areas.length > 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.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{context?.truncated && (
|
||||
<p role="alert">
|
||||
Cobertura administrativa incompleta: são
|
||||
apresentados até 100 limites.
|
||||
consultadas até 100 geometrias e 500
|
||||
subdivisões.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -62,21 +62,32 @@ export function TerritoryGeometryDetails({
|
||||
{!context.selected_geometry_available &&
|
||||
context.areas.length > 0 && (
|
||||
<p role="note">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{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.
|
||||
</p>
|
||||
{context.child_coverage?.location_type && (
|
||||
<p role="status">
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{context.truncated && (
|
||||
<p role="alert">
|
||||
Exibidos os primeiros 100 limites; cobertura
|
||||
incompleta.
|
||||
Consulta limitada a 100 geometrias e 500
|
||||
subdivisões; cobertura incompleta.
|
||||
</p>
|
||||
)}
|
||||
{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'}
|
||||
|
||||
@@ -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: '' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\TerritoryGeometry;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TerritoryGeometrySubdivisionTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$target = DB::selectOne('SELECT current_database() AS name, inet_server_port() AS port');
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user