Consolida rastreabilidade territorial e plano autenticado V2
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# Regras do acervo documental InvestMoz
|
||||
|
||||
## Aceitação das fontes oficiais — decisão do responsável, 08/10/2026
|
||||
|
||||
- Dados e documentos com origem oficial/governamental identificada (incluindo BR, decretos e leis) são aceites pelo responsável como fontes fidedignas e válidas. Não exigir segunda ou terceira validação da autenticidade/proveniência oficial, nem certificação por outra fonte.
|
||||
- Registar e recuperar origem, exemplar, versão e citação é rastreabilidade; a ausência desses campos num cadastro não revoga a aceitação nem torna o dado inválido. Preservar as validações e decisões já documentadas.
|
||||
- Esta decisão tem precedência sobre pendências genéricas de revalidação de proveniência oficial em relatórios históricos. Não estender a classificação oficial a documentos de promotores ou de programação sem origem oficial identificada.
|
||||
- A aceitação da fonte não substitui a identificação do âmbito, período aplicável, alterações/revogações expressas no próprio acervo oficial ou dos parâmetros necessários a uma regra/cálculo. Conferir esses elementos no documento oficial é aplicação da fonte aceite, não uma segunda validação da sua credibilidade. Não ativar cálculos por importação/publicação.
|
||||
|
||||
- O responsável define o acervo e a finalidade. Inventariar todos os ficheiros e anexos acessíveis antes de seleccionar documentos para consulta; não omitir por não serem usados por uma funcionalidade.
|
||||
- Manter três estados independentes: disponibilidade para consulta, situação jurídica/documental e utilização numa regra ou cálculo. Importação/publicação nunca activa um cálculo fiscal.
|
||||
- Não usar associação a módulo, requisito ou artigo como pré-condição para cadastrar ou consultar um documento autorizado. Relações sem citação verificada ficam pendentes e visivelmente identificadas.
|
||||
|
||||
@@ -3,21 +3,37 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\InvestmentRelationshipRepair;
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class InvestmentRelationshipRepairController extends Controller
|
||||
{
|
||||
public function preview(InvestmentRelationshipRepair $repair): Response
|
||||
{
|
||||
return Inertia::render('management/relationship-repair', $repair->preview());
|
||||
return Inertia::render('management/relationship-repair', [...$repair->preview(), 'traceability' => app(InvestmentTerritorialTraceability::class)->preview()]);
|
||||
}
|
||||
|
||||
public function apply(Request $request, InvestmentRelationshipRepair $repair): RedirectResponse|JsonResponse
|
||||
{
|
||||
if ($request->input('operation') === 'traceability') {
|
||||
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'reverse' => ['required', 'boolean']]);
|
||||
try {
|
||||
$result = app(InvestmentTerritorialTraceability::class)->apply($data['hash'], $request->user()->id, $data['reverse']);
|
||||
} catch (HttpException $exception) {
|
||||
if ($request->expectsJson()) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return back()->withErrors(['operation' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return $request->expectsJson() ? response()->json($result) : back()->with('success', $data['reverse'] ? 'Rastreabilidade revertida, histórico preservado.' : 'Rastreabilidade aplicada; validações e dados territoriais preservados.');
|
||||
}
|
||||
$data = $request->validate(['hash' => ['required', 'string', 'size:64'], 'row_id' => ['nullable', 'string', 'size:64'],
|
||||
'validation_hash' => ['required_with:row_id', 'nullable', 'string', 'size:64']]);
|
||||
$result = $repair->apply($data['hash'], $request->user()->id, $data['row_id'] ?? null, $data['validation_hash'] ?? null);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\InvestmentZone;
|
||||
use App\Models\Opportunity;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use App\Services\InvestmentZoneAccess;
|
||||
use App\Services\InvestmentZones;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -73,6 +74,7 @@ class InvestmentZoneController extends Controller
|
||||
'operators' => $management ? DB::table('organizations')->whereNull('deleted_at')->orderBy('name')->limit(200)->get(['id', 'name']) : [],
|
||||
'links' => $management ? DB::table('investment_zone_links')->where('zone_id', $id)->orderByDesc('created_at')->limit(100)->get() : [],
|
||||
'locations' => $locations,
|
||||
'territorialSources' => app(InvestmentTerritorialTraceability::class)->sources('investment_zones', $id),
|
||||
'attachments' => $attachments,
|
||||
'revisions' => $management ? DB::table('investment_zone_revisions')->where('zone_id', $id)->orderByDesc('version_no')->limit(20)->get(['version_no', 'action', 'sha256', 'created_at']) : []];
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class InvestmentCatalogue
|
||||
$filters = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
|
||||
'opportunity_id' => ['nullable', 'integer', 'min:1'],
|
||||
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid'],
|
||||
'include_national' => ['nullable', 'boolean'],
|
||||
'include_national' => ['nullable', 'boolean'], 'territory_basis' => ['nullable', 'in:all,direct,context'],
|
||||
'province' => ['nullable', 'string', 'max:100'], 'district' => ['nullable', 'string', 'max:100'],
|
||||
'cluster_id' => ['nullable', 'uuid'], 'corridor_id' => ['nullable', 'uuid'], 'project_id' => ['nullable', 'uuid'],
|
||||
'geo_location_id' => ['nullable', 'uuid'], 'education' => ['nullable', 'boolean'],
|
||||
@@ -44,6 +44,13 @@ class InvestmentCatalogue
|
||||
public function query(array $filters): Builder
|
||||
{
|
||||
$query = Opportunity::published()->with('sector:id,name,slug');
|
||||
if (($filters['territory_basis'] ?? 'all') === 'direct') {
|
||||
$query->where(fn ($q) => $q->whereNotNull('opportunities.primary_territory_id')->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id')));
|
||||
} elseif (($filters['territory_basis'] ?? 'all') === 'context') {
|
||||
$query->whereNull('opportunities.primary_territory_id')->whereNotExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations')->whereColumn('opportunity_id', 'opportunities.id'));
|
||||
$query->where(fn ($q) => $q->whereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_projects')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('project_id', app(InvestmentRelations::class)->projectIds()))
|
||||
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_clusters')->whereColumn('opportunity_id', 'opportunities.id')->whereIn('cluster_id', $this->publicStructureIds('clusters', 'cluster_id'))));
|
||||
}
|
||||
if (! empty($filters['opportunity_id'])) {
|
||||
$query->whereKey($filters['opportunity_id']);
|
||||
}
|
||||
@@ -51,11 +58,13 @@ class InvestmentCatalogue
|
||||
$includeNational = filter_var($filters['include_national'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if ($path !== []) {
|
||||
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
|
||||
$query->where(function ($territory) use ($ids, $includeNational): void {
|
||||
$query->where(function ($territory) use ($ids, $includeNational, $filters): void {
|
||||
$territory->whereIn('opportunities.primary_territory_id', $ids)
|
||||
->orWhereExists(fn ($locations) => $locations->selectRaw('1')->from('opportunity_locations as territory_link')
|
||||
->whereColumn('territory_link.opportunity_id', 'opportunities.id')->whereIn('territory_link.geo_location_id', $ids));
|
||||
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
|
||||
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
|
||||
app(InvestmentRelations::class)->opportunityTerritories($territory, $ids);
|
||||
}
|
||||
if ($includeNational) {
|
||||
$territory->orWhere('opportunities.geography_scope', 'national');
|
||||
}
|
||||
@@ -86,6 +95,10 @@ class InvestmentCatalogue
|
||||
$term = '%'.$filters['q'].'%';
|
||||
$query->where(fn ($q) => $q->where('title', 'ilike', $term)->orWhere('summary', 'ilike', $term)->orWhere('reference', 'ilike', $term)
|
||||
->orWhere('province', 'ilike', $term)->orWhereHas('sector', fn ($s) => $s->where('name', 'ilike', $term))
|
||||
->orWhereExists(fn ($g) => $g->selectRaw('1')->from('geo_locations as search_location')->whereNull('search_location.deleted_at')->where('search_location.data_classification', 'public')
|
||||
->where('search_location.name', 'ilike', $term)->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
|
||||
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
|
||||
->orWhereExists(fn ($l) => $l->selectRaw('1')->from('opportunity_locations as search_link')->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))))
|
||||
->orWhereExists(fn ($d) => $d->selectRaw('1')->from('investment_dossiers as searchable_dossier')
|
||||
->whereNull('searchable_dossier.deleted_at')->where('searchable_dossier.publication_status', 'published')
|
||||
->where(fn ($text) => $text->where('searchable_dossier.title', 'ilike', $term)->orWhereRaw('searchable_dossier.content::text ILIKE ?', [$term]))
|
||||
@@ -107,13 +120,15 @@ class InvestmentCatalogue
|
||||
? app(InvestmentGeography::class)->descendants((string) $filters[$foreign])
|
||||
: null;
|
||||
if ($relation === 'locations') {
|
||||
$query->where(function ($matches) use ($locationIds) {
|
||||
$query->where(function ($matches) use ($locationIds, $filters) {
|
||||
$matches->whereIn('opportunities.primary_territory_id', $locationIds ?? [])
|
||||
->orWhereExists(fn ($sub) => $sub->selectRaw('1')->from('opportunity_locations as link')
|
||||
->join('geo_locations as target', 'target.id', '=', 'link.geo_location_id')
|
||||
->whereColumn('link.opportunity_id', 'opportunities.id')->whereIn('link.geo_location_id', $locationIds ?? [])
|
||||
->whereNull('target.deleted_at')->where('target.data_classification', 'public'));
|
||||
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
|
||||
if (($filters['territory_basis'] ?? 'all') !== 'direct') {
|
||||
app(InvestmentRelations::class)->opportunityTerritories($matches, $locationIds ?? []);
|
||||
}
|
||||
});
|
||||
|
||||
continue;
|
||||
@@ -229,6 +244,8 @@ class InvestmentCatalogue
|
||||
->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('m.opportunity_id', $id)
|
||||
->get(['z.id', 'z.name', 'z.category', 'zl.association_type', 'zl.source_reference', 'zl.valid_from', 'zl.valid_until']);
|
||||
$result['primary_territory_path'] = app(InvestmentGeography::class)->path(DB::table('opportunities')->where('id', $id)->value('primary_territory_id'));
|
||||
$result['opportunity_locations'] = app(InvestmentRelations::class)->opportunityLocations($id, $management);
|
||||
$result['source_evidence'] = app(InvestmentTerritorialTraceability::class)->sources('opportunities', $id);
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -242,8 +259,11 @@ class InvestmentCatalogue
|
||||
$records = $this->query($filters)->limit(1000)->get();
|
||||
$total = $this->query($filters)->reorder()->count();
|
||||
$ids = $records->pluck('id');
|
||||
$points = DB::table('opportunities as o')->join('geo_locations as g', function ($join) {
|
||||
$spatial = DB::table('opportunities as o')->join('geo_locations as g', function ($join) use ($filters) {
|
||||
$join->on('g.id', '=', 'o.primary_territory_id')->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_locations as l')->whereColumn('l.opportunity_id', 'o.id')->whereColumn('l.geo_location_id', 'g.id'));
|
||||
if (($filters['territory_basis'] ?? 'all') === 'direct') {
|
||||
return;
|
||||
}
|
||||
$join->orWhereExists(fn ($q) => $q->selectRaw('1')->from('opportunity_projects as map_project')
|
||||
->join('projects as p', 'p.id', '=', 'map_project.project_id')->whereColumn('map_project.opportunity_id', 'o.id')->whereNull('p.deleted_at')
|
||||
->whereNotIn('p.data_classification', ['confidential', 'restricted'])->whereIn('p.id', app(InvestmentRelations::class)->projectIds())
|
||||
@@ -253,21 +273,31 @@ class InvestmentCatalogue
|
||||
->whereNull('map_location.deleted_at')->where('map_location.data_classification', 'public')->where('map_location.role', '!=', 'impact'))));
|
||||
})->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id')
|
||||
->whereIn('o.id', $ids)->where('g.data_classification', 'public')->where('shape.data_classification', 'public')
|
||||
->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')
|
||||
->whereNull('g.deleted_at')->whereNull('shape.deleted_at')
|
||||
->whereRaw("NOT EXISTS (WITH RECURSIVE parents AS (
|
||||
SELECT id,parent_id,deleted_at,data_classification,ARRAY[id] AS visited,false AS cycle FROM geo_locations WHERE id=g.id
|
||||
UNION ALL SELECT p.id,p.parent_id,p.deleted_at,p.data_classification,a.visited||p.id,p.id=ANY(a.visited)
|
||||
FROM geo_locations p JOIN parents a ON p.id=a.parent_id WHERE NOT a.cycle
|
||||
) SELECT 1 FROM parents WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)")
|
||||
->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326')
|
||||
->whereRaw('ST_IsValid(shape.geom) AND NOT ST_IsEmpty(shape.geom) AND ST_SRID(shape.geom)=4326');
|
||||
$points = (clone $spatial)->where('g.location_type', 'site')->where('shape.geometry_type', 'point')
|
||||
->whereRaw('ST_X(shape.geom) BETWEEN -180 AND 180 AND ST_Y(shape.geom) BETWEEN -90 AND 90')
|
||||
->orderBy('o.id')->orderByRaw("CASE WHEN g.source_system='investment-site-editor' THEN 0 ELSE 1 END")->orderBy('g.id')->orderBy('shape.id')
|
||||
->get(['o.id', 'g.name', 'g.id as location_id', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
|
||||
->get(['o.id', 'g.name', 'g.id as location_id', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
|
||||
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'opportunity_site' ELSE 'linked_project_site' END as location_basis")])
|
||||
->groupBy('id')->map(function ($candidates) {
|
||||
$direct = $candidates->where('location_basis', 'opportunity_site');
|
||||
$selected = $direct->isNotEmpty() ? $direct : $candidates;
|
||||
// Several sites are legitimate; only conflicting coordinates for the same site are withheld.
|
||||
$sites = [];
|
||||
foreach ($candidates->groupBy('location_id') as $site) {
|
||||
if ($site->unique('geometry')->count() === 1) {
|
||||
foreach ($site as $candidate) {
|
||||
$sites[] = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $selected->unique('location_id')->count() === 1 && $selected->unique('geometry')->count() === 1 ? $selected->first() : null;
|
||||
return $sites;
|
||||
})->filter();
|
||||
$relations = [];
|
||||
foreach (['clusters', 'corridors', 'projects'] as $relation) {
|
||||
@@ -278,22 +308,35 @@ class InvestmentCatalogue
|
||||
->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id');
|
||||
}
|
||||
$features = $missing = [];
|
||||
$areas = (clone $spatial)->whereIn('shape.geometry_type', ['polygon', 'multipolygon'])->orderBy('shape.id')
|
||||
->get(['o.id', 'o.title', 'o.slug', 'g.name', 'shape.source_system', 'shape.properties', DB::raw('ST_AsGeoJSON(shape.geom) as geometry'),
|
||||
DB::raw("CASE WHEN o.primary_territory_id=g.id OR EXISTS (SELECT 1 FROM opportunity_locations direct WHERE direct.opportunity_id=o.id AND direct.geo_location_id=g.id) THEN 'território declarado da oportunidade' ELSE 'contexto do projeto associado; implantação da oportunidade por confirmar' END AS basis")])
|
||||
->map(fn ($area) => ['geometry' => json_decode($area->geometry, true), 'title' => $area->title.' — '.$area->name.' ('.$area->basis.')',
|
||||
'source_reference' => $area->source_system, 'source_date' => json_decode($area->properties ?? '{}', true)['source_date'] ?? null,
|
||||
'url' => '/oportunidades/'.$area->slug])->all();
|
||||
$located = 0;
|
||||
foreach ($records as $record) {
|
||||
$properties = ['id' => $record->id, 'title' => $record->title, 'category' => $record->sector?->name,
|
||||
'location' => trim($record->province.' '.$record->district), 'url' => '/oportunidades/'.$record->slug];
|
||||
foreach ($relations as $relation => $groups) {
|
||||
$properties[$relation] = ($groups->get($record->id) ?? collect())->pluck('name')->unique()->values()->all();
|
||||
}
|
||||
if ($point = $points->get($record->id)) {
|
||||
$features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties,
|
||||
'location_basis' => $point->location_basis, 'location' => trim($properties['location'].' — '.$point->name).($point->location_basis === 'linked_project_site' ? ' (local documentado do projecto associado; implantação da oportunidade por confirmar)' : '')]];
|
||||
$sites = collect($points->get($record->id) ?? []);
|
||||
if ($sites->isNotEmpty()) {
|
||||
$located++;
|
||||
foreach ($sites as $point) {
|
||||
$features[] = ['type' => 'Feature', 'geometry' => json_decode($point->geometry, true), 'properties' => [...$properties,
|
||||
'location_id' => $point->location_id, 'source_reference' => $point->source_system,
|
||||
'source_date' => json_decode($point->properties ?? '{}', true)['source_date'] ?? null,
|
||||
'location_basis' => $point->location_basis, 'location' => $point->name.($point->location_basis === 'linked_project_site' ? ' (local do projecto associado; contexto territorial)' : ' (implantação cadastrada da oportunidade)')]];
|
||||
}
|
||||
} else {
|
||||
$missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(),
|
||||
'georeferenced_count' => count($features), 'unlocated' => $missing, 'features' => $features,
|
||||
'complete' => $total === count($features)];
|
||||
'georeferenced_count' => $located, 'marker_count' => count($features), 'unlocated' => $missing, 'features' => $features,
|
||||
'areas' => $areas, 'complete' => $total === $located];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,21 @@ use Illuminate\Support\Facades\DB;
|
||||
/** Resolve documented relationships without generating geography or spreading opportunity memberships. */
|
||||
class InvestmentRelations
|
||||
{
|
||||
/** Directly declared opportunity territories; a link does not certify implantation.
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function opportunityLocations(int $id, bool $management = false): array
|
||||
{
|
||||
$record = DB::table('opportunities')->where('id', $id)->first();
|
||||
$items = DB::table('opportunity_locations')->where('opportunity_id', $id)->pluck('geo_location_id')
|
||||
->map(fn ($location) => ['id' => $location, 'role' => 'opportunity', 'source_reference' => null, 'source_date' => null])->values()->all();
|
||||
if ($record?->primary_territory_id && ! collect($items)->contains('id', $record->primary_territory_id)) {
|
||||
$items[] = ['id' => $record->primary_territory_id, 'role' => 'opportunity', 'source_reference' => null, 'source_date' => null];
|
||||
}
|
||||
|
||||
return $this->locations(array_values($items), $management);
|
||||
}
|
||||
|
||||
public function projectIds(): Builder
|
||||
{
|
||||
return InvestmentPortfolioController::visible()->whereNotNull('project_id')->select('project_id')->toBase();
|
||||
@@ -82,10 +97,18 @@ class InvestmentRelations
|
||||
$shapes = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))
|
||||
->where('geometry_type', 'point')->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
|
||||
->orderBy('id')->get(['id', 'source_system', DB::raw('ST_AsGeoJSON(geom) as geometry')]);
|
||||
->whereRaw('ST_X(geom) BETWEEN -180 AND 180 AND ST_Y(geom) BETWEEN -90 AND 90')
|
||||
->orderBy('id')->get(['id', 'source_system', 'properties', DB::raw('ST_AsGeoJSON(geom) as geometry')]);
|
||||
$coordinates = $shapes->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system,
|
||||
'source_date' => json_decode($shape->properties ?? '{}', true)['source_date'] ?? null,
|
||||
'geometry' => json_decode($shape->geometry, true)])->all();
|
||||
$result[] = [...$item, 'name' => $site->name, 'location_type' => $site->location_type, 'path' => $path, 'coordinates' => $coordinates];
|
||||
$boundaries = DB::table('geo_geometries')->where('geo_location_id', $item['id'])->whereNull('deleted_at')
|
||||
->when(! $management, fn ($q) => $q->where('data_classification', 'public'))
|
||||
->whereIn('geometry_type', ['polygon', 'multipolygon'])->whereRaw('ST_IsValid(geom) AND NOT ST_IsEmpty(geom) AND ST_SRID(geom)=4326')
|
||||
->get(['id', 'source_system', 'properties', DB::raw('ST_AsGeoJSON(geom) as geometry')])
|
||||
->map(fn ($shape) => ['geometry_id' => $shape->id, 'source_reference' => $shape->source_system,
|
||||
'source_date' => json_decode($shape->properties ?? '{}', true)['source_date'] ?? null, 'geometry' => json_decode($shape->geometry, true)])->all();
|
||||
$result[] = [...$item, 'name' => $site->name, 'location_type' => $site->location_type, 'path' => $path, 'coordinates' => $coordinates, 'boundaries' => $boundaries];
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -94,7 +117,8 @@ class InvestmentRelations
|
||||
/** @return array<string,mixed> */
|
||||
public function dossier(InvestmentDossier $dossier, bool $management = false): array
|
||||
{
|
||||
$result = ['locations' => [], 'members' => [], 'clusters' => [], 'chainLinks' => [], 'chains' => []];
|
||||
$result = ['locations' => [], 'members' => [], 'clusters' => [], 'chainLinks' => [], 'chains' => [],
|
||||
'source_evidence' => app(InvestmentTerritorialTraceability::class)->sources($dossier->project_id ? 'projects' : 'economic_clusters', $dossier->project_id ?? $dossier->cluster_id ?? '')];
|
||||
if ($dossier->project_id) {
|
||||
$result['locations'] = $this->projectLocations($dossier->project_id, $management);
|
||||
$memberships = $this->memberships()->where('cp.project_id', $dossier->project_id)->get(['cp.cluster_id', 'cp.role', 'cp.validation_status', 'cp.source_reference']);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Models\Opportunity;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/** Attach evidence to existing relationships; never grant legal approval or infer new geography. */
|
||||
class InvestmentTerritorialTraceability
|
||||
{
|
||||
public function directory(): string
|
||||
{
|
||||
return config('investment.traceability_directory', storage_path('app/private/investmoz-v2'));
|
||||
}
|
||||
|
||||
public function fingerprint(): string
|
||||
{
|
||||
return hash('sha256', implode('', array_map(fn ($file) => hash_file('sha256', base_path($file)), [
|
||||
'app/Services/InvestmentTerritorialTraceability.php', 'app/Http/Controllers/InvestmentRelationshipRepairController.php',
|
||||
'resources/js/components/territorial-traceability-plan.tsx', 'tools/investmoz-v2-prepare-territorial-plan.cjs',
|
||||
])));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function preview(): ?array
|
||||
{
|
||||
$file = $this->directory().'/territory-activation-plan.json';
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$bytes = file_get_contents($file);
|
||||
abort_if($bytes === false, 503, 'Plano não pode ser lido.');
|
||||
$plan = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
abort_unless(is_array($plan) && ($plan['schema'] ?? null) === 1 && is_array($plan['rows'] ?? null), 422, 'Plano inválido.');
|
||||
$hash = hash('sha256', $bytes);
|
||||
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->first();
|
||||
|
||||
return ['hash' => $hash, 'plan' => $plan, 'code_matches' => ($plan['code_hash'] ?? '') === $this->fingerprint(),
|
||||
'applied' => $last?->action === 'traceability_applied', 'backup_ready' => $this->backupReady($plan['target'], $hash)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $target */
|
||||
private function backupReady(array $target, string $hash): bool
|
||||
{
|
||||
$file = $this->directory().'/territory-backup-proof.json';
|
||||
if (! is_file($file)) {
|
||||
return false;
|
||||
}
|
||||
$proof = json_decode(file_get_contents($file) ?: '{}', true);
|
||||
|
||||
return ($proof['restore_verified'] ?? false) === true && ($proof['reversal_verified'] ?? false) === true
|
||||
&& ($proof['plan_hash'] ?? '') === $hash && ($proof['code_hash'] ?? '') === $this->fingerprint()
|
||||
&& ($proof['target'] ?? []) === $target && strtotime($proof['verified_at'] ?? '') >= time() - 86400
|
||||
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $guard */
|
||||
private function checkGuard(array $guard): void
|
||||
{
|
||||
abort_unless(in_array($guard['table'] ?? '', ['projects', 'economic_clusters', 'investment_zones', 'opportunities', 'investment_dossiers', 'cluster_projects', 'project_locations', 'investment_zone_locations', 'investment_legal_sources'], true), 422, 'Tabela fora do plano permitido.');
|
||||
$row = DB::table($guard['table'])->where('id', $guard['id'])->lockForUpdate()->first();
|
||||
abort_unless($row !== null, 409, 'Registo do plano já não existe.');
|
||||
foreach ($guard['expected'] as $field => $value) {
|
||||
abort_unless(property_exists($row, $field) && ($row->$field === null ? $value === null : $value !== null && (string) $row->$field === (string) $value), 409, 'Registo alterado desde a pré-visualização: '.$guard['table'].'.'.$field);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function apply(string $hash, int $actor, bool $reverse = false): array
|
||||
{
|
||||
$preview = $this->preview();
|
||||
abort_unless($preview && hash_equals($preview['hash'], $hash) && $preview['code_matches'], 409, 'Plano ou código alterado. Reveja a pré-visualização.');
|
||||
$plan = $preview['plan'];
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
abort_unless($identity->database === $plan['target']['database'] && (int) $identity->port === (int) $plan['target']['port'], 409, 'Destino diferente do plano.');
|
||||
if (app()->environment('testing')) {
|
||||
abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base isolada.');
|
||||
}
|
||||
abort_unless($preview['backup_ready'], 422, 'Backup/restauro/reversão verificados e atualizados são necessários.');
|
||||
|
||||
return DB::transaction(function () use ($plan, $hash, $actor, $reverse) {
|
||||
DB::select('SELECT pg_advisory_xact_lock(81026, 160000)');
|
||||
$last = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->where('after_data->plan_hash', $hash)->orderByDesc('occurred_at')->orderByDesc('id')->lockForUpdate()->first();
|
||||
if ((! $reverse && $last?->action === 'traceability_applied') || ($reverse && $last?->action === 'traceability_reversed')) {
|
||||
return ['replayed' => true, 'changed' => 0];
|
||||
}
|
||||
abort_if(! $reverse && $last?->action === 'traceability_reversed', 409, 'Plano revertido; prepare um novo plano com o estado atual.');
|
||||
$before = [];
|
||||
$applied = ['updates' => []];
|
||||
if ($reverse) {
|
||||
abort_unless($last?->action === 'traceability_applied', 409, 'Não há aplicação para reverter.');
|
||||
$applied = json_decode($last->after_data, true);
|
||||
foreach ($applied['updates'] as $update) {
|
||||
$this->checkGuard(['table' => 'cluster_projects', 'id' => $update['id'], 'expected' => $update['after']]);
|
||||
$values = $update['before'];
|
||||
$values['version_no'] = $update['after']['version_no'] + 1;
|
||||
DB::table('cluster_projects')->where('id', $update['id'])->update($values);
|
||||
}
|
||||
} else {
|
||||
foreach ($plan['guards'] as $guard) {
|
||||
$this->checkGuard($guard);
|
||||
}
|
||||
foreach ($plan['updates'] as $update) {
|
||||
abort_unless($update['table'] === 'cluster_projects' && array_diff(array_keys($update['values']), ['source_reference', 'evidence_reference']) === [], 422, 'Alteração fora do âmbito da rastreabilidade.');
|
||||
$row = DB::table('cluster_projects')->where('id', $update['id'])->first();
|
||||
$after = [...$update['values'], 'version_no' => $row->version_no + 1];
|
||||
$before[] = ['id' => $row->id, 'before' => ['source_reference' => $row->source_reference, 'evidence_reference' => $row->evidence_reference, 'version_no' => $row->version_no], 'after' => $after];
|
||||
DB::table('cluster_projects')->where('id', $row->id)->update($after);
|
||||
}
|
||||
}
|
||||
DB::table('audit_events')->insert(['source_system' => 'territory-traceability-v2', 'entity_type' => 'territorial_sources', 'actor_user_id' => $actor,
|
||||
'action' => $reverse ? 'traceability_reversed' : 'traceability_applied', 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'),
|
||||
'after_data' => json_encode(['plan_hash' => $hash, 'rows' => $plan['rows'], 'updates' => $before, 'no_new_geography' => true, 'official_policy' => 'accepted_without_revalidation'], JSON_THROW_ON_ERROR)]);
|
||||
|
||||
return ['replayed' => false, 'changed' => $reverse ? count($applied['updates']) : count($before), 'associations' => count($plan['rows'])];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function sources(string $kind, string|int $id): array
|
||||
{
|
||||
$visible = match ($kind) {
|
||||
'projects' => InvestmentPortfolioController::visible()->where('project_id', $id)->exists(),
|
||||
'economic_clusters' => InvestmentPortfolioController::visible()->where('cluster_id', $id)->exists(),
|
||||
'investment_zones' => app(InvestmentZones::class)->visible()->whereKey($id)->exists(),
|
||||
'opportunities' => Opportunity::published()->whereKey($id)->exists(),
|
||||
default => false,
|
||||
};
|
||||
if (! $visible) {
|
||||
return [];
|
||||
}
|
||||
$events = DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->orderBy('occurred_at')->orderBy('id')->get(['action', 'after_data']);
|
||||
$plans = [];
|
||||
foreach ($events as $event) {
|
||||
$data = json_decode($event->after_data, true);
|
||||
$plans[$data['plan_hash']] = $event->action === 'traceability_applied' ? $data['rows'] : [];
|
||||
}
|
||||
$sources = [];
|
||||
foreach ($plans as $rows) {
|
||||
foreach ($rows as $row) {
|
||||
if ($row['kind'] === $kind && (string) $row['record_id'] === (string) $id && ($row['public'] ?? false)) {
|
||||
if (! empty($row['dossier_id']) && ! InvestmentPortfolioController::visible()->whereKey($row['dossier_id'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
if (! empty($row['public_location_id']) && ! app(InvestmentRelations::class)->locations([['id' => $row['public_location_id'], 'role' => $row['role']]])) {
|
||||
continue;
|
||||
}
|
||||
if (! empty($row['association_id']) && ! app(InvestmentRelations::class)->memberships()->where('cp.id', $row['association_id'])->exists()) {
|
||||
continue;
|
||||
}
|
||||
$sources[] = array_intersect_key($row, array_flip(['association', 'role', 'source_reference', 'source_quote', 'supports', 'traceability', 'official_source_accepted', 'existing_validation']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# V2 — rastreabilidade territorial e aplicação autenticada
|
||||
|
||||
Entrega preparada em 9 de outubro de 2026 (Africa/Johannesburg). Complementa [a qualidade territorial por registo](qualidade-territorial.md) e [a reconciliação documental dos 46 registos](reconciliacao-fontes-territoriais.md).
|
||||
|
||||
## Aceitação e âmbito
|
||||
|
||||
A regra permanente de `AGENTS.md` mantém a aceitação das fontes oficiais/governamentais identificadas. Não foi repetida a verificação da proveniência. As 11 referências oficiais/legais, os 14 estados cadastrais `validated` e a aceitação existente do pacote INE foram reutilizados. Recuperar uma referência não desvalida os dados; os seis estados legais `pending` preexistentes não foram alterados por este incremento.
|
||||
|
||||
O plano abrange 46 registos: seis oportunidades, 16 projetos, oito clusters e 16 zonas. Contém **112 entradas de rastreabilidade**: proveniência do registo, implantação cadastrada, locais dos projetos, impacto, associações territoriais das zonas e membros dos clusters, preservando os papéis originais. Cada entrada identifica fonte, informação fundamentada, estado existente, pendência específica e efeito na interface. Citações já existentes nas 28 associações de projetos foram reutilizadas, sem nova validação.
|
||||
|
||||
Foram recuperadas citações explícitas para as **13 associações cluster–projeto existentes**. A proposta preenche apenas `source_reference` e `evidence_reference` em `cluster_projects`, incrementando a versão. Não muda `validation_status`, `association_type`, autoria original ou classificação de divulgação. O dossier de programação citado documenta essa relação existente; não recebe a classificação de documento oficial por esta operação.
|
||||
|
||||
As seis oportunidades têm território principal, mas não têm correspondência explícita a âncoras/clusters nas referências já reconciliadas. O plano não cria essas ligações por setor ou proximidade. Há **41 entradas de pendência**, incluindo essas seis correspondências e 35 pontes de rastreabilidade; não são 41 invalidações nem pedidos de revalidação da origem oficial. A correspondência técnica entre o PDF INE aceite e o workbook administrativo não foi presumida.
|
||||
|
||||
## Pré-visualização e efeitos
|
||||
|
||||
Página existente: **http://127.0.0.1:8000/management/relationship-repair**, com autenticação e a permissão de gestão já utilizada pelo projeto. O bloco inicial **“V2 — aplicar rastreabilidade territorial”** apresenta o destino `investemoz:5432`, os campos atuais e propostos das 13 alterações, a fonte/citação e as 112 entradas por associação. O plano anterior da página permanece preservado e separado.
|
||||
|
||||
A ação desta entrega é exclusivamente **“Aplicar rastreabilidade territorial V2”**. A operação é transacional, verifica o hash do plano/código, o destino, o backup e o estado atual de cada registo. Conflitos apresentam um erro no próprio bloco e não aplicam parte do plano. O botão fica desativado durante a operação. Pedidos repetidos do mesmo plano aplicado não criam duplicados.
|
||||
|
||||
O evento de auditoria guarda a projeção das fontes, o ator autenticado e os valores antes/depois. As fichas autorizadas mostram o fundamento por associação; as permissões atuais dos dossiers, locais e membros continuam a ser verificadas. Pesquisa, filtros e mapa reutilizam as relações existentes; preencher referências não altera implantação, contexto, impacto, abrangência, geometria ou disponibilidade. Registos sem coordenadas continuam consultáveis.
|
||||
|
||||
## Backup, compatibilidade e reversão
|
||||
|
||||
Backup atualizado restaurado em **`desk_restore_20261008_235546_test:55441`**. Foram comparadas **288 tabelas por contagem e hash dos dados**, e restaurados/verificados quatro ficheiros documentais mais a configuração privada. Isto comprova restauro; não declara scan antimalware desses documentos.
|
||||
|
||||
Na cópia restaurada, o mesmo plano aplicou 13 alterações, repetiu a operação sem duplicados e reverteu as 13. Valores de referência e estados de validação foram restituídos, com versões monotónicas e histórico de auditoria preservado. Onze tabelas territoriais/documentais relacionadas mantiveram os mesmos hashes; não houve nova geografia nem novas ligações de oportunidades.
|
||||
|
||||
A prova está vinculada ao plano e ao código, com validade de 24 horas: **até 10/10/2026 às 01:55:55, Africa/Johannesburg**. Após expiração ou mudança do código/plano, o botão exige nova preparação. Não há migrations nesta aplicação.
|
||||
|
||||
Após aplicação, a mesma página disponibiliza **“Reverter este incremento de rastreabilidade”**. A reversão restitui apenas os campos deste incremento, preservando eventos e versões. Se os campos/versões já tiverem sido alterados depois da aplicação, recusa sobrescrever a alteração posterior. Recuperação integral do backup permanece um procedimento operacional separado; nunca executar restauro de QA contra a base ativa.
|
||||
|
||||
## Evidência e estado
|
||||
|
||||
- 56 testes PHP passaram, com 884 asserções: relações, território, zonas, carteira, unificação e o novo fluxo autenticado. Os novos testes cobrem permissões, idempotência, reversão, consulta autorizada, conflito e prova expirada/alterada.
|
||||
- Quatro testes de reconciliação verificam os estados independentes e a preservação da aceitação. São testes de comportamento com fixtures; não reabrem a proveniência dos documentos.
|
||||
- TypeScript, lint/formatação, PHPStan dos serviços/controladores afetados e compilação passaram.
|
||||
- Navegador QA: pré-visualização autenticada, clique no botão V2, HTTP 302, sucesso visível, recuperação após recarregar, fonte por associação na ficha e reversão; sem erros de JavaScript. Somente login e duas ações de gestão na cópia isolada.
|
||||
- Dez casos territoriais de navegador passaram em computador/móvel, sem escritas: localização própria, contexto associado, vários locais, informação insuficiente e localização sem coordenadas.
|
||||
- Leitura ativa em `READ ONLY` às 00:00:12 UTC de 9 de outubro: domínio territorial inalterado e **zero eventos de aplicação deste incremento**. O hash comparado consta da prova privada; sucesso em QA não é aplicação ativa.
|
||||
|
||||
Provas privadas em `storage/app/private/investmoz-v2`: `territory-activation-plan.json`, `territory-backup-proof.json`, `territory-reversal-evidence.json`, `territory-activation-browser.json` e `territory-activation-active-preservation.json`. Backup, configuração e matrizes privadas não entram no commit nem são publicados.
|
||||
|
||||
**Preparado:** código, pré-visualização e aplicação autenticada. **Validado em QA:** aplicação, reversão, permissões e apresentação. **Aplicado na base ativa:** nenhuma alteração de dados deste incremento; ação autenticada do responsável permanece por executar.
|
||||
|
||||
Continuam separadas: questões jurídicas específicas de âmbito/versão/aplicabilidade ainda não resolvidas; diferença de cobertura do inventário; ClamD real; primeiro pedido real no Balcão. A aceitação da proveniência oficial não foi reaberta e nenhuma dessas pendências foi concluída pelos testes territoriais.
|
||||
@@ -124,3 +124,42 @@ V2-11/V2-15: DOCX lido normalmente; novo hash `d0c06a759693e7b74c6daec14539e247e
|
||||
A próxima frente deve reutilizar as entidades e relações atuais, identificar vínculos ausentes ou contraditórios e distinguir localização indicada de disponibilidade/capacidade comprovada. Correções e associações devem ser demonstradas em QA; qualquer mudança de dados ativos continua dependente de backup/restauro e ação autenticada prevista no projeto.
|
||||
|
||||
Verificação deste incremento: 19 testes PHP/514 asserções de biblioteca e compatibilidade, 28 testes de regressão do simulador, TypeScript, formatação/lint e percurso de navegador nas três páginas em computador/móvel. A releitura repetiu os controlos afetados das 14 etapas e referências. **Nenhuma escrita na base ativa.** ClamD real, validação das fontes institucionais e primeiro pedido real no Balcão continuam três pendências separadas; estes testes não as concluem.
|
||||
|
||||
## Entrega territorial — relações e apresentação em QA
|
||||
|
||||
V2-08/V2-09: [resultados por registo, fontes, motivos e propostas](qualidade-territorial.md). Leitura `READ ONLY` da base ativa: seis oportunidades, 16 projetos, oito clusters e 16 zonas; 1 921 nós percorridos sem inconsistência estrutural detetada e zero geometrias cadastradas. Isto não certifica geografia oficial. As seis oportunidades têm território principal, sem vínculos cadastrados a projetos/clusters; não foram criadas associações por proximidade.
|
||||
|
||||
| Âmbito | Resultado | Aplicado à base ativa |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Código — fichas e pesquisa | Implantação declarada distinta de locais/impacto de projetos e abrangência de clusters; fontes/datas ausentes explícitas; filtros de implantação/contexto e pesquisa territorial | Código local, demonstrado em QA; nenhuma alteração de dados/esquema |
|
||||
| Código — mapa e navegação | Vários locais preservados com seleção independente; polígonos cadastrados com proveniência; dossier/oportunidade nos dois sentidos e território/pesquisa | Sem novos pontos ou polígonos ativos |
|
||||
| Propostas de dados | 46 registos discriminados, mantendo relações e papéis existentes; lacunas de fonte/data e vínculos ausentes para revisão documental | Não aplicadas; backup/restauro e ação autenticada continuam necessários |
|
||||
| QA | 53 testes PHP/859 asserções; repetição dos 12 afetados/186 asserções; 10 casos de navegador em computador/móvel, TypeScript, PHPStan dos serviços, lint e build | Exclusivamente `investmoz_v2_20261008_test:55441`; sem escritas de navegador |
|
||||
| Pendências documentais | Evidência da implantação, fontes/datas das relações, limites oficiais, disponibilidade/capacidade e vínculos oportunidade/projeto/cluster | Nenhuma confirmação institucional inferida |
|
||||
|
||||
Continuam separadas: **diferença de cobertura do inventário**, **validação jurídica e das fontes institucionais**, **ClamD real** e **primeiro pedido real no Balcão**. O sucesso destes testes não conclui nenhuma delas.
|
||||
|
||||
Verificação final: a repetição do navegador detetou uma animação do mapa após remoção (`_leaflet_pos`); ciclo de vida e limpeza de pontos/áreas corrigidos, dez casos finais passaram sem erros. Duas leituras `READ ONLY` confirmaram o mesmo hash para dados/relações do domínio territorial auditado antes e depois da QA (`territory-active-preservation.json`). Nenhuma proposta de dados foi aplicada.
|
||||
|
||||
## Retificação da classificação das fontes territoriais
|
||||
|
||||
**Decisão expressa do responsável — 08/10/2026:** dados e documentos de origem oficial/governamental identificada, incluindo BR, decretos e leis, são aceites como fontes fidedignas e válidas, sem segunda ou terceira validação da sua autenticidade/proveniência e sem confirmação por outra fonte. Regra permanente registada em `AGENTS.md`. Pendências históricas genéricas de revalidação dessa proveniência ficam substituídas por esta decisão; lacunas de ligação ao exemplar são rastreabilidade, não invalidade. Âmbito, versão/período aplicável e parâmetros de cálculo continuam a ser extraídos da própria fonte oficial aceite, sem transformar consulta/publicação em ativação de regras.
|
||||
|
||||
Critério confirmado pelo responsável: **documentos oficiais são a única fonte de validação oficial**. A origem oficial declarada dos dados em uso é preservada; ausência de fonte/data numa coluna não invalida os dados nem exige repetir uma validação já documentada. [Reconciliação dos 46 registos](reconciliacao-fontes-territoriais.md): 11 referências oficiais/legais identificadas, 14 estados `validated` de zonas preservados, seis estados legais `pending` preexistentes apresentados separadamente e aceitação do pacote INE mantida, com hash do exemplar conferido. A correspondência técnica INE/workbook não foi inventada.
|
||||
|
||||
Propostas e [relatório territorial](qualidade-territorial.md) corrigidos para distinguir validação existente, rastreabilidade por recuperar e questões jurídicas efetivamente pendentes. Programação e promotores não substituem documentos oficiais. Quatro testes de reconciliação com fixtures sintéticos passaram, verificando preservação de validações, ausência de revogação por campo vazio, separação dos estados legais e recusa de transformar promotor em fonte oficial. Nenhuma escrita ativa ou ativação de cálculo; as quatro pendências anteriores mantêm âmbitos separados.
|
||||
|
||||
## Incremento territorial — 9 de outubro de 2026
|
||||
|
||||
[Plano autenticado, restauro e reversão](ativacao-rastreabilidade-territorial.md). A aceitação das fontes oficiais identificadas foi reutilizada, sem nova verificação de proveniência.
|
||||
|
||||
| Requisito | Preparado / validado em QA | Aplicado na base ativa |
|
||||
| --- | --- | --- |
|
||||
| V2-08 — rastreabilidade | 46 registos reconciliados; 112 entradas por associação; 11 referências oficiais/legais, 14 fichas validadas e pacote INE preservados | Nenhuma nova gravação; faltas de ligação documental não revogam aceitação |
|
||||
| V2-08 — relações | 13 citações cluster–projeto recuperadas; proposta de preencher referências existentes; 28 vínculos territoriais de projetos reutilizados | Proposta não aplicada; sem novas relações inferidas |
|
||||
| V2-08/V2-09 — fichas, filtros e mapa | Papéis e geometria preservados; fundamento por associação nas fichas autorizadas; navegação e filtros da entrega anterior reutilizados | Código local; sem novos pontos, limites ou capacidade atribuída |
|
||||
| V2-13 — aplicação | Página `/management/relationship-repair`, botão específico V2, conflitos visíveis, idempotência e reversão auditada | Ação autenticada ainda por executar |
|
||||
| V2-13 — backup/restauro | 288 tabelas comparadas e cinco ficheiros restaurados; aplicação/reversão ensaiadas em `desk_restore_20261008_235546_test:55441` | Leitura/backup apenas; prova vinculada ao plano/código, validade 24 horas |
|
||||
| QA | 56 testes PHP/884 asserções, quatro testes de reconciliação, TypeScript, lint, PHPStan, build e percurso autenticado no navegador | Zero eventos deste incremento na leitura ativa final; dados territoriais inalterados |
|
||||
|
||||
Restam pontes de rastreabilidade e seis correspondências oportunidade–âncora/cluster sem fundamento explícito recuperado; não se retirou a aceitação existente. Questões jurídicas específicas, diferença de cobertura do inventário, ClamD real e primeiro pedido real permanecem separadas.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Qualidade territorial e relações — entrega V2
|
||||
|
||||
## Retificação — validação oficial e rastreabilidade
|
||||
|
||||
**Só os documentos oficiais fundamentam validação oficial**, conforme o critério do responsável. A declaração de que os dados em uso foram extraídos desses documentos é preservada. A revisão anterior confundiu, em várias propostas, ausência de campos de fonte/data com necessidade de voltar a validar o dado. Essas propostas foram corrigidas: recuperar a ponte documental não desvalida nem revoga o dado.
|
||||
|
||||
A [matriz por registo](reconciliacao-fontes-territoriais.md) recupera 11 referências oficiais/legais identificadas, preserva as 14 fichas de zonas com estado `validated` e distingue os seis estados `pending` que **já existiam nas fontes legais**. Também conserva a aceitação explícita do exemplar INE, com hash conferido. Nenhuma nova validação, revogação, alteração ativa ou ativação de cálculo foi efetuada. Referências de programação/promotores continuam identificadas como tais, sem substituir o documento oficial.
|
||||
|
||||
Os restantes registos têm citação e/ou ligação incompleta ao exemplar oficial no cadastro revisto. O resultado é **rastreabilidade por recuperar**, não «dado inválido» nem «fonte oficial inexistente». A matriz separa a validação do cadastro, a validação da fonte legal, a proveniência documental e o âmbito da evidência.
|
||||
|
||||
Leitura protegida em 2026-10-08T17:11:53+00:00, base ativa investemoz:5432, transação READ ONLY terminada por ROLLBACK. Nenhum dado ativo alterado. Evidência completa privada: `territory-audit.json`.
|
||||
|
||||
## Resultados por registo e propostas de dados
|
||||
|
||||
Cada linha identifica o estado cadastrado, a fonte consultada, motivo e impacto da proposta. A leitura de um cadastro ou dossier não valida a fonte institucional. Sem associação explícita não se cria uma relação por nome semelhante, cluster ou proximidade. Não foram consultados sites institucionais nesta auditoria.
|
||||
|
||||
| Registo | Relações/localizações cadastradas | Fonte consultada | Proposta, motivo e impacto |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| INV-MZ-AGR-001 | principal cadastrada: Moçambique → Nampula; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| INV-MZ-ENE-002 | principal cadastrada: Moçambique → Zambezia; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| INV-MZ-TUR-003 | principal cadastrada: Moçambique → Inhambane; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| INV-MZ-INF-004 | principal cadastrada: Moçambique → Sofala; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| INV-MZ-BLUE-005 | principal cadastrada: Moçambique → Zambezia; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| INV-MZ-FIN-006 | principal cadastrada: Moçambique → Maputo Cidade; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | Cadastro da oportunidade; fonte da relação e data ausentes | Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A03 | secondary: Moçambique → Cabo Delgado; secondary: Moçambique → Nampula; secondary: Moçambique → Niassa; secondary: Moçambique → Tete; impact: Moçambique → Zambezia; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A08 | primary: Moçambique → Zambezia; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A10 | primary: Moçambique → Tete; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A04 | primary: Moçambique → Nampula; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A07 | secondary: Moçambique → Tete; secondary: Moçambique → Manica; primary: Moçambique → Sofala; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A06 | secondary: Moçambique → Tete; secondary: Moçambique → Zambezia; secondary: Moçambique → Manica; secondary: Moçambique → Sofala; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A02 | Sem localização estruturada; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A12 | primary: Moçambique → Zambezia; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| EXAMPLE-PROJECT-001 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | example | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A14 | primary: Moçambique → Gaza; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A01 | Sem localização estruturada; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A13 | primary: Moçambique → Inhambane; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A15 | impact: Moçambique → Maputo Província → MOAMBA; primary: Moçambique → Maputo Província; impact: Moçambique → Maputo Província → BOANE; impact: Moçambique → Maputo Província → NAMAACHA; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A11 | secondary: Moçambique → Tete; secondary: Moçambique → Zambezia; secondary: Moçambique → Zambezia → NICOADALA; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A05 | secondary: Moçambique → Cabo Delgado; secondary: Moçambique → Nampula; projetos: 0; clusters: 1; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| A09 | primary: Moçambique → Zambezia; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-05 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 1; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-06 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-01 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 3; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-04 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 2; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-08 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-03 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 3; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-07 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| CLU-02 | Sem localização estruturada; projetos: 0; clusters: 0; membros: 4; ligações de zona: 0 | upgrade-v4; upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto; Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-TOPUITO-PARK | administrative: Moçambique → Nampula; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://mozparks.co.mz/construction-begins-on-sme-village-at-topuito-industrial-park/ — Construction begins on SME Village at Topuito Industrial Park | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-CDP-ANCUABE | administrative: Moçambique → Cabo Delgado → ANCUABE; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ — MozParks — Cabo Delgado parks initiative | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-BELULUANE-PARK | administrative: Moçambique → Maputo Província → BOANE → MATOLA RIO; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-TOPUITO-ZFI | administrative: Moçambique → Nampula; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349 — Decreto n.º 57/2023 — criação da ZFI de Topuito | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-MANGA-MUNGASSA-ZEE | administrative: Moçambique → Sofala → CIDADE DA BEIRA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/ — Decreto n.º 22/2012, de 6 de Junho | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-CDP-PALMA | administrative: Moçambique → Cabo Delgado → PALMA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ — MozParks — Cabo Delgado parks initiative | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-MOCUBA-ZFI | administrative: Moçambique → Zambezia → MOCUBA → CIDADE DE MOCUBA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/zif/mocuba-free-industrial-zone/ — Decreto n.º 29/2014, de 6 de Junho | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-CRUSSE-JAMALI-ZEE | legal_scope: Moçambique → Nampula → MOSSURIL → MATIBANE; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-70-de-300813-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-571 — Decreto n.º 47/2013, de 30 de Agosto | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-MOCUBA-ZEE | legal_scope: Moçambique → Zambezia → MOCUBA; legal_scope: Moçambique → Zambezia → LUGELA → MUNHAMADE; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-46-de-060614-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1259 — Decreto n.º 28/2014, de 6 de Junho | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-CDP-MONTEPUEZ | administrative: Moçambique → Cabo Delgado → MONTEPUEZ; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ — MozParks — Cabo Delgado parks initiative | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-BELULUANE-ZFI | administrative: Moçambique → Maputo Província → BOANE → MATOLA RIO; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/beluluane-industrial-park-free-zone/ — Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-MUNHAVA-PARK | administrative: Moçambique → Sofala → CIDADE DA BEIRA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://sdubeira.co.mz/project/parque-industrial-logistico/ — Parque Industrial e Logístico — SDUBeira | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-REVUBUE-ZFI | legal_scope: Moçambique → Tete → MOATIZE; legal_scope: Moçambique → Tete → CHIUTA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/revubue-free-industrial-zone/ — Revúbuè Free Industrial Zone — APIEX | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-NACALA-ZEE | legal_scope: Moçambique → Nampula → NACALA-VELHA; legal_scope: Moçambique → Nampula → NACALA-PORTO; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/nacala-special-economic-zone/ — Decreto n.º 76/2007, de 18 de Dezembro | Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide; Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-MINHEWENE-ZFI | administrative: Moçambique → Nampula; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://apiex.gov.mz/zona-franca-industrial-de-minhewene/ — Zona Franca Industrial de Minhewene — APIEX | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
| MOZ-CDP-BALAMA | administrative: Moçambique → Cabo Delgado → BALAMA; projetos: 0; clusters: 0; membros: 0; ligações de zona: 0 | https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ — MozParks — Cabo Delgado parks initiative | Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade; Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável |
|
||||
|
||||
## Hierarquia e cartografia
|
||||
|
||||
1921 nós administrativos/locais percorridos; 0 caminhos com inconsistência estrutural identificada por esta verificação. Ausência de inconsistência cadastral não certifica fronteiras oficiais. 0 geometrias ativas cadastradas: nenhum ponto, polígono ou centroide gerado.
|
||||
|
||||
## Correções de código e QA
|
||||
|
||||
- Ficha apresenta implantação declarada separada dos locais/impacto dos projetos e abrangência dos clusters; fonte/data ausentes explícitas.
|
||||
- Filtros distinguem implantação declarada de apenas contexto associado; localizações sem coordenadas continuam pesquisáveis.
|
||||
- Mapa mantém vários locais do mesmo investimento e seleção independente; áreas cadastradas são apresentadas com proveniência, sem homologar limites.
|
||||
- Corrigida a transição do mapa após seleção/navegação: animações e deslocação automática de popups deixam de correr após remoção; callbacks de redimensionamento respeitam o ciclo de vida. Ao mudar filtros, pontos e áreas anteriores são limpos durante a nova consulta. O ensaio final repetiu os dez casos sem o erro `_leaflet_pos` observado na repetição intermédia.
|
||||
- Associação existente continua a permitir navegação oportunidade → dossier → oportunidade e território → pesquisa. Permissões e relações revogadas preservadas.
|
||||
|
||||
QA isolada em `investmoz_v2_20261008_test:55441`: **53 testes PHP, 859 asserções** (relações, território, zonas, carteira e unificação). Após o ajuste de tipagem do agrupamento, os 12 testes afetados passaram novamente, com 186 asserções. PHPStan dos dois serviços sem erros; TypeScript, formatação/lint e compilação concluídos. A compilação conserva os avisos de dependência opcional `fontaine` e tamanho de chunks, sem falha de geração.
|
||||
|
||||
Navegador: **10 casos**, cinco cenários em 1440/390 px — localização própria, apenas contexto associado, duas localizações, informação insuficiente e localização documental sem coordenadas. Verificados os destinos e o retorno pelo dossier, filtro de contexto, seleção independente dos marcadores, polígonos com fonte/data e consulta sem coordenadas. Sem erros JavaScript e sem pedidos de escrita; tráfego externo bloqueado. Fontes e geometrias destes fixtures são sintéticas, exclusivamente QA, e não comprovam dados reais.
|
||||
|
||||
Evidência privada: `territory-php-tests.txt`, `territory-php-affected.txt`, `territory-browser.json` e capturas `territory-browser-1440.png`/`territory-browser-390.png`. Auditoria e propostas completas por ID: `territory-audit.json` e `territory-proposals.json`. Os ficheiros privados não devem ser publicados.
|
||||
|
||||
Preservação ativa conferida por duas leituras `READ ONLY`, às 17:11:53 e 17:25:31 UTC: os registos, relações, nós, geometrias e dossiers do domínio auditado têm o mesmo hash de comparação `0a83af33d02ca15489a4e8db8a1d741dd39c3cd9c7660df95e33ee64432dd702`. Prova privada `territory-active-preservation.json`; primeira leitura conservada em `territory-audit-before-qa.json`. Esta comparação não é um backup/restauro nem autoriza futuras alterações ativas.
|
||||
|
||||
**Limitações de rastreabilidade e âmbito:** nenhuma geometria foi encontrada no cadastro; isto não prova ausência de mapas nos documentos oficiais. O pivot de localizações da oportunidade não conserva fonte/data: falta recuperar a ligação ao documento utilizado, sem invalidar a localização. As seis oportunidades não têm vínculos cadastrados com projetos/clusters; a falta de vínculo técnico não prova ausência de relação documental. Os vínculos de zonas existentes são reutilizados quando presentes e autorizados; não foram criados novos vínculos. Cada validação mantém o seu âmbito: um documento sobre localização não confirma automaticamente outros atributos. Propostas desta tabela não são alterações aplicadas nem revogação de validações.
|
||||
|
||||
## Pendências independentes
|
||||
|
||||
Diferença de cobertura do inventário documental, questões jurídicas específicas de âmbito/versão/aplicabilidade ainda não resolvidas, ClamD real e primeiro pedido real no Balcão permanecem separados. A aceitação das fontes oficiais identificadas não é reaberta. Nenhum teste territorial conclui essas pendências. Aplicação futura de dados exige backup/restauro confirmado e ação autenticada existente.
|
||||
|
||||
## Continuação — rastreabilidade e ativação, 9 de outubro
|
||||
|
||||
[Plano concreto de aplicação autenticada](ativacao-rastreabilidade-territorial.md): 112 entradas de fonte/fundamento para os mesmos 46 registos; 13 referências de relações cluster–projeto propostas, sem alterar validações ou criar ligações por proximidade. Os 28 vínculos territoriais existentes, as 11 referências oficiais/legais, as 14 fichas validadas e o pacote INE aceite foram reutilizados. Pendências de rastreabilidade preservam a aceitação existente.
|
||||
|
||||
Backup/restauro de 288 tabelas e cinco ficheiros verificado; aplicação e reversão testadas na cópia isolada. 56 testes PHP/884 asserções passaram e o navegador comprovou o clique V2, resposta HTTP, recuperação, fonte por associação na ficha e reversão. Nenhuma proposta deste incremento foi aplicada à base ativa. A pré-visualização autenticada mostra campos atuais/propostos, fonte, motivo e efeito antes da ação do responsável.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Reconciliação das fontes e validações territoriais
|
||||
|
||||
## Decisão permanente sobre origem oficial
|
||||
|
||||
Em 08/10/2026, o responsável determinou que dados e documentos com origem oficial/governamental identificada — BR, decretos, leis e outros documentos oficiais — são aceites como fidedignos e válidos, sem segunda ou terceira validação da autenticidade/proveniência ou confirmação por outra fonte. Esta decisão substitui a exigência genérica de revalidar fontes oficiais nos relatórios anteriores. Os estados técnicos históricos abaixo são conservados como leitura do cadastro, não como obrigação de repetir essa validação.
|
||||
|
||||
Completar a referência ao exemplar, versão e citação é recuperar rastreabilidade. Identificar âmbito, período de aplicação ou alterações/revogações na própria documentação oficial é aplicar a fonte aceite; não questionar a sua credibilidade. Consulta autorizada, aplicação jurídica ao caso e utilização em cálculo mantêm funções distintas.
|
||||
|
||||
Critério do responsável: documentos oficiais são a única fonte de validação oficial. Programação, promotores, testes e leitura do cadastro não substituem esses documentos. Ausência de um campo de fonte/data é uma lacuna de rastreabilidade, não uma decisão de invalidade nem revogação.
|
||||
|
||||
Esta revisão conserva os estados existentes. Uma referência oficial identificada não significa que todas as afirmações da ficha estejam cobertas: o âmbito da citação deve ser conservado. Não foram concedidas novas validações nem ativados cálculos. A declaração do responsável sobre a origem oficial dos dados em uso é registada; as pontes técnicas ainda incompletas são identificadas abaixo, sem contradizer essa declaração.
|
||||
|
||||
## Evidência oficial e aceitação já existentes
|
||||
|
||||
INE — Codificador da Divisão Político-Administrativa: exemplar privado com SHA-256 `5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2`, coincidente com a aceitação de 2026-09-28. Decisão preservada: `declared_validated_by_owner` e `declared_authenticated_by_owner`, por instrução explícita do responsável. A aceitação não é reaberta. O próprio registo distingue-a da correspondência técnica: `unresolved_column_alignment`; zero territórios importados por essa decisão. Não se atribui automaticamente esse PDF aos 1 921 nós oriundos do workbook.
|
||||
|
||||
As seis fontes de diplomas existentes conservam `validation_status=pending` tal como já estava na base; esse estado não foi imposto por esta entrega. As 14 fichas de zonas com `validated` conservam esse estado, distinguindo validação do cadastro de validação jurídica do diploma associado. Referências de promotores não passam a fonte oficial por a ficha estar validada.
|
||||
|
||||
## Matriz por registo
|
||||
|
||||
| Registo | Validação já registada, preservada | Fonte/citação recuperada | Lacuna específica de rastreabilidade |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| INV-MZ-AGR-001 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| INV-MZ-ENE-002 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| INV-MZ-TUR-003 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| INV-MZ-INF-004 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| INV-MZ-BLUE-005 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| INV-MZ-FIN-006 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| A03 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.; dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.; dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.; dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.; dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor de Nacala: Nampula, Cabo Delgado, Niassa, Tete e Norte da Zambézia, incluindo Nacala, Nampula, Cuamba, Palma e áreas ligadas ao corredor.; dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550; auditoria não identificada; Dossier 39f5973c-f4cd-49d0-aeea-1c0c71783550, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A08 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Mocuba e Munhamade, província da Zambézia, com ligações a Quelimane, Nacala, Caia e Milange.; dossier 81d775e5-5dff-4fa2-98b1-72affb0a1082; auditoria não identificada; Dossier 81d775e5-5dff-4fa2-98b1-72affb0a1082, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A10 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Rio Zambeze, província de Tete, a jusante da Hidroeléctrica de Cahora Bassa; área exacta sujeita à documentação oficial do projecto.; dossier cad01a02-6072-4a11-823a-1ae379bc65da; auditoria não identificada; Dossier cad01a02-6072-4a11-823a-1ae379bc65da, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A04 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Crusse Jamali, zona de Nacala, província de Nampula.; dossier 0dccdd26-9796-4ef7-947b-5a2970ace379; auditoria não identificada; Dossier 0dccdd26-9796-4ef7-947b-5a2970ace379, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A07 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Região Centro, com incidência principal em Sofala e articulação produtiva com Manica e Tete, junto ao Corredor da Beira.; dossier 22323abb-91d9-43ed-98f3-1194a5250c9e; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Região Centro, com incidência principal em Sofala e articulação produtiva com Manica e Tete, junto ao Corredor da Beira.; dossier 22323abb-91d9-43ed-98f3-1194a5250c9e; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Região Centro, com incidência principal em Sofala e articulação produtiva com Manica e Tete, junto ao Corredor da Beira.; dossier 22323abb-91d9-43ed-98f3-1194a5250c9e; auditoria não identificada; Dossier 22323abb-91d9-43ed-98f3-1194a5250c9e, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A06 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Províncias de Tete, Manica, Zambézia e Sofala, articuladas pelos corredores da Beira e de Nacala e pelas bacias de irrigação identificadas.; dossier 15474e6a-90a3-4db4-b456-eb1c1c66227c; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Províncias de Tete, Manica, Zambézia e Sofala, articuladas pelos corredores da Beira e de Nacala e pelas bacias de irrigação identificadas.; dossier 15474e6a-90a3-4db4-b456-eb1c1c66227c; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Províncias de Tete, Manica, Zambézia e Sofala, articuladas pelos corredores da Beira e de Nacala e pelas bacias de irrigação identificadas.; dossier 15474e6a-90a3-4db4-b456-eb1c1c66227c; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Províncias de Tete, Manica, Zambézia e Sofala, articuladas pelos corredores da Beira e de Nacala e pelas bacias de irrigação identificadas.; dossier 15474e6a-90a3-4db4-b456-eb1c1c66227c; auditoria não identificada; Dossier 15474e6a-90a3-4db4-b456-eb1c1c66227c, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A02 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 1125f069-61e2-4071-99f3-dd103caf3156, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A12 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Província da Zambézia, com incidência em zonas rurais, no rio Licungo, na Circular de Mocuba e em comunidades e ecossistemas costeiros.; dossier a023d6b2-1fa2-4fe7-9709-f93ae1f10e99; auditoria não identificada; Dossier a023d6b2-1fa2-4fe7-9709-f93ae1f10e99, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| EXAMPLE-PROJECT-001 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Sem ponte documental no cadastro revisto | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| A14 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Vale do Limpopo, província de Gaza: Chókwè, Xai-Xai, Guijá, Chongoene, Chibuto, Limpopo e Bilene.; dossier 6d973bb4-ab8a-49bc-92ed-286ed5b96fc4; auditoria não identificada; Dossier 6d973bb4-ab8a-49bc-92ed-286ed5b96fc4, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A01 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 219e1dbb-939c-4ef0-92b7-7f78f8561fa0, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A13 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor turístico Vilanculos–Inhassoro–Bazaruto, província de Inhambane, incluindo áreas costeiras e ilhas associadas.; dossier bb15487f-1617-49e6-86b7-d617812b658b; auditoria não identificada; Dossier bb15487f-1617-49e6-86b7-d617812b658b, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A15 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Bacia Hidrográfica do Rio Maputo, província de Maputo, com incidência nos distritos de Boane, Moamba e Namaacha e ligação às bacias do Maputo e Incomáti.; dossier 1468b8d2-1b08-4c84-8000-ece7662edbf6; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Bacia Hidrográfica do Rio Maputo, província de Maputo, com incidência nos distritos de Boane, Moamba e Namaacha e ligação às bacias do Maputo e Incomáti.; dossier 1468b8d2-1b08-4c84-8000-ece7662edbf6; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Bacia Hidrográfica do Rio Maputo, província de Maputo, com incidência nos distritos de Boane, Moamba e Namaacha e ligação às bacias do Maputo e Incomáti.; dossier 1468b8d2-1b08-4c84-8000-ece7662edbf6; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Bacia Hidrográfica do Rio Maputo, província de Maputo, com incidência nos distritos de Boane, Moamba e Namaacha e ligação às bacias do Maputo e Incomáti.; dossier 1468b8d2-1b08-4c84-8000-ece7662edbf6; auditoria não identificada; Dossier 1468b8d2-1b08-4c84-8000-ece7662edbf6, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A11 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor entre Moatize, na província de Tete, e Macuse, no distrito de Nicoadala, província da Zambézia, incluindo Mutarara, Mopeia e Quelimane.; dossier 72c13e83-e8dd-465d-99a9-eb0ccaa4fd0b; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor entre Moatize, na província de Tete, e Macuse, no distrito de Nicoadala, província da Zambézia, incluindo Mutarara, Mopeia e Quelimane.; dossier 72c13e83-e8dd-465d-99a9-eb0ccaa4fd0b; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Corredor entre Moatize, na província de Tete, e Macuse, no distrito de Nicoadala, província da Zambézia, incluindo Mutarara, Mopeia e Quelimane.; dossier 72c13e83-e8dd-465d-99a9-eb0ccaa4fd0b; auditoria não identificada; Dossier 72c13e83-e8dd-465d-99a9-eb0ccaa4fd0b, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A05 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Eixo Namialo–Metoro, entre as províncias de Nampula e Cabo Delgado, com ligações a Pemba e Palma.; dossier 361789ad-1e14-4b64-8f1f-fad053886d5a; auditoria não identificada; upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Eixo Namialo–Metoro, entre as províncias de Nampula e Cabo Delgado, com ligações a Pemba e Palma.; dossier 361789ad-1e14-4b64-8f1f-fad053886d5a; auditoria não identificada; Dossier 361789ad-1e14-4b64-8f1f-fad053886d5a, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| A09 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6; citação: Cidade de Mocuba, província da Zambézia.; dossier 67263220-6849-468f-82f4-d5e1f5617190; auditoria não identificada; Dossier 67263220-6849-468f-82f4-d5e1f5617190, versão 1: upgrade do portal de investimentos v4 programacao.docx — 6.1 / 6.6 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-05 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier b9b1dc01-dca1-42d8-a9aa-40148dccfaf7, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-06 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 0f29da56-b74a-4a69-afaa-b9a0d349580c, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-01 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier ca6a1427-0e2b-406e-94bd-da23630d5ec4, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-04 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 5f0ff662-dc74-41de-adcd-de784a595321, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-08 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier ed2ea4e4-fca7-4cc8-bb8c-f99a1dfda73e, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-03 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier ec9494a2-22cc-423e-8a9e-efa1e05ac2b4, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-07 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 523d2aa1-4c7d-4a84-97fc-220f4e845fd7, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| CLU-02 | Cadastro: campo de validação não existente/não registado nesta entidade; fonte legal: sem fonte legal ligada | Dossier 3cd11616-9099-477c-bf3e-09cddca74053, versão 1: upgrade do portal de investimentos v4 programacao.docx — 4.5.2 | Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada. |
|
||||
| MOZ-TOPUITO-PARK | Cadastro: validated; fonte legal: sem fonte legal ligada | Referência promoter, não oficial: Construction begins on SME Village at Topuito Industrial Park; https://mozparks.co.mz/construction-begins-on-sme-village-at-topuito-industrial-park/ | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| MOZ-CDP-ANCUABE | Cadastro: pending; fonte legal: sem fonte legal ligada | Referência promoter, não oficial: MozParks — Cabo Delgado parks initiative; https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| MOZ-BELULUANE-PARK | Cadastro: validated; fonte legal: sem fonte legal ligada | Beluluane Industrial Park – Free Zone; https://apiex.gov.mz/beluluane-industrial-park-free-zone/; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-TOPUITO-ZFI | Cadastro: validated; fonte legal: sem fonte legal ligada | Decreto n.º 57/2023 — criação da ZFI de Topuito; https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-190-de-031023-boletim-da-rep%C3%BAblica-i-s%C3%A9rie-p%C3%A1g-2349; âmbito: Legal creation and reported legal area; primary review remains required. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-MANGA-MUNGASSA-ZEE | Cadastro: validated; fonte legal: pending | Decreto n.º 22/2012, de 6 de Junho; Manga-Mungassa Special Economic Zone – APIEX; Act of creation; article reference pending review; https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/; versão 1; Manga-Mungassa Special Economic Zone – APIEX; https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-CDP-PALMA | Cadastro: validated; fonte legal: sem fonte legal ligada | Referência promoter, não oficial: MozParks — Cabo Delgado parks initiative; https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| MOZ-MOCUBA-ZFI | Cadastro: validated; fonte legal: pending | Decreto n.º 29/2014, de 6 de Junho; Mocuba IFZ – APIEX; Act of creation; article reference pending review; https://apiex.gov.mz/zif/mocuba-free-industrial-zone/; versão 1; Mocuba IFZ – APIEX; https://apiex.gov.mz/zif/mocuba-free-industrial-zone/; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-CRUSSE-JAMALI-ZEE | Cadastro: validated; fonte legal: pending | Decreto n.º 47/2013, de 30 de Agosto; Conselho de Ministros — BR n.º 70 de 30.08.13; Act of creation; article reference pending review; https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-70-de-300813-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-571; versão 1; Conselho de Ministros — BR n.º 70 de 30.08.13; https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-70-de-300813-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-571; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-MOCUBA-ZEE | Cadastro: validated; fonte legal: pending | Decreto n.º 28/2014, de 6 de Junho; BR n.º 46 de 06.06.14 — Decreto n.º 28/2014; Act of creation; article reference pending review; https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-46-de-060614-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1259; versão 1; BR n.º 46 de 06.06.14 — Decreto n.º 28/2014; https://www.inm.gov.mz/pt-br/content/conselho-de-ministros-br-n%C2%BA-46-de-060614-boletim-da-rep%C3%BAblica-i-serie-p%C3%A1g-1259; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-CDP-MONTEPUEZ | Cadastro: validated; fonte legal: sem fonte legal ligada | Referência promoter, não oficial: MozParks — Cabo Delgado parks initiative; https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
| MOZ-BELULUANE-ZFI | Cadastro: validated; fonte legal: pending | Resolução Interna do Conselho de Ministros n.º 15/99, de 12 de Outubro; Beluluane Industrial Park – Free Zone; Act of creation; article and current legal effect pending review; https://apiex.gov.mz/beluluane-industrial-park-free-zone/; versão 1; Beluluane Industrial Park – Free Zone; https://apiex.gov.mz/beluluane-industrial-park-free-zone/; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-MUNHAVA-PARK | Cadastro: pending; fonte legal: pending | Decreto n.º 22/2012, de 6 de Junho; Manga-Mungassa Special Economic Zone – APIEX; Act of creation; article reference pending review; https://apiex.gov.mz/zee/manga-mungassa-special-economic-zone/; versão 1; Referência promoter, não oficial: Parque Industrial e Logístico — SDUBeira; https://sdubeira.co.mz/project/parque-industrial-logistico/ | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-REVUBUE-ZFI | Cadastro: validated; fonte legal: sem fonte legal ligada | Revúbuè Free Industrial Zone — APIEX; https://apiex.gov.mz/revubue-free-industrial-zone/; âmbito: APIEX-reported identity, district scope and area; primary legal text pending. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-NACALA-ZEE | Cadastro: validated; fonte legal: pending | Decreto n.º 76/2007, de 18 de Dezembro; Nacala Special Economic Zone – APIEX; Act of creation; article reference pending review; https://apiex.gov.mz/nacala-special-economic-zone/; versão 1; Nacala Special Economic Zone – APIEX; https://apiex.gov.mz/nacala-special-economic-zone/; âmbito: Referência institucional/promocional; não comprova regime jurídico, incentivos ou disponibilidade. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-MINHEWENE-ZFI | Cadastro: validated; fonte legal: sem fonte legal ligada | Zona Franca Industrial de Minhewene — APIEX; https://apiex.gov.mz/zona-franca-industrial-de-minhewene/; âmbito: APIEX-reported identity, province and area; primary legal text and site precision pending. | Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte. |
|
||||
| MOZ-CDP-BALAMA | Cadastro: validated; fonte legal: sem fonte legal ligada | Referência promoter, não oficial: MozParks — Cabo Delgado parks initiative; https://mozparks.co.mz/mozparks-launches-cabo-delgado-parks/ | Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial. |
|
||||
|
||||
## Estados independentes e limites
|
||||
|
||||
Consulta autorizada permanece independente da completude da referência. Validação oficial deriva apenas da evidência documental oficial no âmbito aceite; o estado cadastral é preservado e apresentado separadamente. Utilização em regras/cálculos permanece inalterada. Não se transforma a data de criação, data de aplicação ou início de vigência em data da fonte.
|
||||
|
||||
O rótulo histórico de programação v4 foi preservado; a revisão anterior encontrou programação v5 corroborante, sem o exemplar v4. Esta diferença de rótulo é uma questão de proveniência, não uma revogação das 28 relações aplicadas. Programação não substitui o documento oficial na validação oficial.
|
||||
|
||||
Nenhuma base ativa foi escrita. A matriz integral privada está em `territory-source-reconciliation.json`. Diferença de cobertura do inventário, questões jurídicas realmente não resolvidas, ClamD real e primeiro pedido real continuam separados.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Map as LeafletMap, Marker } from 'leaflet';
|
||||
import type { GeoJsonObject } from 'geojson';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export type InvestmentMapFeature = {
|
||||
@@ -13,13 +14,25 @@ export type InvestmentMapFeature = {
|
||||
clusters?: string[];
|
||||
corridors?: string[];
|
||||
projects?: string[];
|
||||
source_reference?: string | null;
|
||||
source_date?: string | null;
|
||||
};
|
||||
};
|
||||
export type InvestmentMapArea = {
|
||||
geometry: GeoJsonObject;
|
||||
title: string;
|
||||
source_reference?: string | null;
|
||||
source_date?: string | null;
|
||||
url: string;
|
||||
};
|
||||
const noAreas: InvestmentMapArea[] = [];
|
||||
|
||||
export default function InteractiveInvestmentMap({
|
||||
points,
|
||||
areas = noAreas,
|
||||
}: {
|
||||
points: InvestmentMapFeature[] | null;
|
||||
areas?: InvestmentMapArea[];
|
||||
}) {
|
||||
const element = useRef<HTMLDivElement>(null);
|
||||
const map = useRef<LeafletMap | null>(null);
|
||||
@@ -35,6 +48,9 @@ export default function InteractiveInvestmentMap({
|
||||
const instance = L.map(element.current, {
|
||||
scrollWheelZoom: false,
|
||||
keyboard: true,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
}).setView([-18.5, 35], 5);
|
||||
map.current = instance;
|
||||
L.tileLayer(
|
||||
@@ -55,6 +71,19 @@ export default function InteractiveInvestmentMap({
|
||||
.addTo(instance);
|
||||
L.control.scale({ imperial: false }).addTo(instance);
|
||||
const bounds: [number, number][] = [];
|
||||
for (const area of areas) {
|
||||
const label = document.createElement('p');
|
||||
label.textContent = `${area.title} · Polígono cadastrado, limites oficiais por verificar · Fonte: ${area.source_reference ?? 'Não indicada'} · Data: ${area.source_date ?? 'Não documentada'}`;
|
||||
const layer = L.geoJSON(area.geometry)
|
||||
.bindPopup(label)
|
||||
.addTo(instance);
|
||||
const extent = layer.getBounds();
|
||||
if (extent.isValid())
|
||||
bounds.push(
|
||||
[extent.getSouth(), extent.getWest()],
|
||||
[extent.getNorth(), extent.getEast()],
|
||||
);
|
||||
}
|
||||
for (const [index, point] of (points ?? []).entries()) {
|
||||
const [longitude, latitude] = point.geometry.coordinates;
|
||||
if (
|
||||
@@ -72,6 +101,7 @@ export default function InteractiveInvestmentMap({
|
||||
for (const text of [
|
||||
p.category,
|
||||
p.location,
|
||||
`Fonte: ${p.source_reference ?? 'Não indicada'} · Data: ${p.source_date ?? 'Não documentada'}`,
|
||||
`Corredor: ${p.corridors?.join(', ') || 'Não associado'}`,
|
||||
`Cluster: ${p.clusters?.join(', ') || 'Não associado'}`,
|
||||
`Projecto âncora: ${p.projects?.join(', ') || 'Não associado'}`,
|
||||
@@ -98,18 +128,22 @@ export default function InteractiveInvestmentMap({
|
||||
alt: p.title,
|
||||
keyboard: true,
|
||||
})
|
||||
.bindPopup(popup)
|
||||
.on('click', () => setSelected(p.id))
|
||||
.bindPopup(popup, { autoPan: false })
|
||||
.on('click', () => setSelected(index))
|
||||
.addTo(instance);
|
||||
markers.current.set(p.id, marker);
|
||||
markers.current.set(index, marker);
|
||||
bounds.push([latitude, longitude]);
|
||||
}
|
||||
if (bounds.length)
|
||||
instance.fitBounds(bounds, {
|
||||
padding: [40, 40],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
});
|
||||
observer = new ResizeObserver(() => instance.invalidateSize());
|
||||
observer = new ResizeObserver(() => {
|
||||
if (!cancelled)
|
||||
instance.invalidateSize({ animate: false, pan: false });
|
||||
});
|
||||
observer.observe(element.current);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -122,7 +156,7 @@ export default function InteractiveInvestmentMap({
|
||||
map.current = null;
|
||||
markers.current.clear();
|
||||
};
|
||||
}, [points]);
|
||||
}, [points, areas]);
|
||||
return (
|
||||
<section
|
||||
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
|
||||
@@ -143,15 +177,15 @@ export default function InteractiveInvestmentMap({
|
||||
<p role="status">A carregar localizações…</p>
|
||||
) : (
|
||||
<p role="status">
|
||||
{points.length} investimentos com coordenadas disponíveis.
|
||||
{points.length} locais com coordenadas disponíveis.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-2 md:grid-cols-2">
|
||||
{(points ?? []).map((p) => (
|
||||
{(points ?? []).map((p, index) => (
|
||||
<li
|
||||
key={p.properties.id}
|
||||
key={index}
|
||||
className={
|
||||
selected === p.properties.id
|
||||
selected === index
|
||||
? 'rounded border-2 border-emerald-700 p-3'
|
||||
: 'rounded border p-3'
|
||||
}
|
||||
@@ -159,18 +193,17 @@ export default function InteractiveInvestmentMap({
|
||||
<button
|
||||
type="button"
|
||||
className="text-left font-bold underline"
|
||||
aria-pressed={selected === p.properties.id}
|
||||
aria-pressed={selected === index}
|
||||
onClick={() => {
|
||||
const marker = markers.current.get(
|
||||
p.properties.id,
|
||||
);
|
||||
const marker = markers.current.get(index);
|
||||
if (marker) {
|
||||
map.current?.setView(
|
||||
marker.getLatLng(),
|
||||
14,
|
||||
{ animate: false },
|
||||
);
|
||||
marker.openPopup();
|
||||
setSelected(p.properties.id);
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -179,12 +212,28 @@ export default function InteractiveInvestmentMap({
|
||||
<p>
|
||||
{p.properties.category} · {p.properties.location}
|
||||
</p>
|
||||
<p>
|
||||
Fonte:{' '}
|
||||
{p.properties.source_reference ?? 'Não indicada'} ·
|
||||
Data:{' '}
|
||||
{p.properties.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
<a className="underline" href={p.properties.url}>
|
||||
Abrir ficha
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{areas.map((area, index) => (
|
||||
<p key={index}>
|
||||
<a className="underline" href={area.url}>
|
||||
{area.title}
|
||||
</a>{' '}
|
||||
· Polígono cadastrado, limites oficiais por verificar ·
|
||||
Fonte: {area.source_reference ?? 'Não indicada'} · Data:{' '}
|
||||
{area.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import InteractiveInvestmentMap, {
|
||||
type InvestmentMapFeature,
|
||||
type InvestmentMapArea,
|
||||
} from './interactive-investment-map';
|
||||
|
||||
export default function InvestmentMapPanel({
|
||||
@@ -14,10 +15,12 @@ export default function InvestmentMapPanel({
|
||||
{ id: number; title: string; url: string }[]
|
||||
>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams(JSON.parse(key));
|
||||
setPoints(null);
|
||||
setAreas([]);
|
||||
fetch(`/api/v1/investment/map?${params}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -29,6 +32,7 @@ export default function InvestmentMapPanel({
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPoints(data.features);
|
||||
setAreas(data.areas ?? []);
|
||||
setMissing(data.unlocated ?? []);
|
||||
setError(
|
||||
data.truncated
|
||||
@@ -44,12 +48,13 @@ export default function InvestmentMapPanel({
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<InteractiveInvestmentMap points={points} />
|
||||
<InteractiveInvestmentMap points={points} areas={areas} />
|
||||
{missing.length > 0 && (
|
||||
<div>
|
||||
<p>
|
||||
{missing.length} investimentos aguardam coordenadas do
|
||||
local:
|
||||
{missing.length} investimentos sem ponto público
|
||||
utilizável. Consulte as fichas para localizações
|
||||
documentadas sem coordenadas:
|
||||
</p>
|
||||
<ul>
|
||||
{missing.map((record) => (
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import TerritorialSources, {
|
||||
type TerritorialSourceEvidence,
|
||||
} from './territorial-source-evidence';
|
||||
import type { GeoJsonObject } from 'geojson';
|
||||
import InteractiveInvestmentMap from './interactive-investment-map';
|
||||
|
||||
export type DocumentedLocation = {
|
||||
@@ -6,15 +10,24 @@ export type DocumentedLocation = {
|
||||
name: string;
|
||||
role: string;
|
||||
source_reference?: string;
|
||||
source_date?: string | null;
|
||||
boundaries?: {
|
||||
geometry_id: string;
|
||||
source_reference: string | null;
|
||||
source_date: string | null;
|
||||
geometry: GeoJsonObject;
|
||||
}[];
|
||||
location_type: string;
|
||||
path: { id: string; name: string; location_type: string }[];
|
||||
coordinates: {
|
||||
geometry_id: string;
|
||||
source_reference: string;
|
||||
source_reference: string | null;
|
||||
source_date?: string | null;
|
||||
geometry: { coordinates: [number, number] };
|
||||
}[];
|
||||
};
|
||||
export type StructureGeography = {
|
||||
source_evidence?: TerritorialSourceEvidence[];
|
||||
locations: DocumentedLocation[];
|
||||
members: {
|
||||
id: string;
|
||||
@@ -44,6 +57,7 @@ export type StructureGeography = {
|
||||
};
|
||||
|
||||
const roles: Record<string, string> = {
|
||||
opportunity: 'Implantação cadastrada da oportunidade',
|
||||
primary: 'Localização principal',
|
||||
secondary: 'Localização secundária',
|
||||
impact: 'Área de impacto',
|
||||
@@ -85,11 +99,13 @@ export function LocationList({
|
||||
<p key={point.geometry_id}>
|
||||
Coordenadas documentadas (
|
||||
{location.location_type === 'site'
|
||||
? 'local do projecto'
|
||||
? 'local cadastrado; não confirma a implantação da oportunidade'
|
||||
: 'referência territorial; não representa a implantação'}
|
||||
): {point.geometry.coordinates[1]},{' '}
|
||||
{point.geometry.coordinates[0]} · Fonte:{' '}
|
||||
{point.source_reference}
|
||||
{point.source_reference ?? 'Não indicada'}
|
||||
{' · Data da fonte: '}
|
||||
{point.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
))}
|
||||
{!location.coordinates.length && (
|
||||
@@ -98,7 +114,20 @@ export function LocationList({
|
||||
<p className="text-sm">
|
||||
Fonte da relação:{' '}
|
||||
{location.source_reference ?? 'Não indicada'}
|
||||
{' · Data da fonte: '}
|
||||
{location.source_date ?? 'Não documentada'}
|
||||
</p>
|
||||
{location.boundaries?.map((boundary) => (
|
||||
<p key={boundary.geometry_id}>
|
||||
Polígono cadastrado · Fonte:{' '}
|
||||
{boundary.source_reference ?? 'Não indicada'} ·
|
||||
Data: {boundary.source_date ?? 'Não documentada'}. O
|
||||
cadastro não certifica limites oficiais.
|
||||
</p>
|
||||
))}
|
||||
{!location.boundaries?.length && (
|
||||
<p>Polígono não documentado.</p>
|
||||
)}
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/oportunidades?geo_location_id=${location.id}`}
|
||||
@@ -165,6 +194,7 @@ export default function StructureGeographyPanel({
|
||||
: 'Localização documentada do projecto'}
|
||||
</h2>
|
||||
<LocationList locations={geography.locations} />
|
||||
<TerritorialSources sources={geography.source_evidence} />
|
||||
{!geography.locations.length && (
|
||||
<p>
|
||||
{cluster
|
||||
@@ -178,7 +208,27 @@ export default function StructureGeographyPanel({
|
||||
um ponto único atribuído ao cluster.
|
||||
</p>
|
||||
)}
|
||||
<InteractiveInvestmentMap points={points} />
|
||||
<InteractiveInvestmentMap
|
||||
points={points}
|
||||
areas={[
|
||||
...items.flatMap((item) =>
|
||||
(item.location.boundaries ?? []).map((area) => ({
|
||||
...area,
|
||||
title: `${roles[item.location.role] ?? item.location.role} · ${item.title}`,
|
||||
url: item.url,
|
||||
})),
|
||||
),
|
||||
...(cluster
|
||||
? geography.locations.flatMap((location) =>
|
||||
(location.boundaries ?? []).map((area) => ({
|
||||
...area,
|
||||
title: `Abrangência do cluster · ${location.name}`,
|
||||
url: `/carteira/${dossierId}`,
|
||||
})),
|
||||
)
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{!points.length && (
|
||||
<p>
|
||||
Sem coordenadas documentadas dos locais. Nenhum ponto foi
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export type TerritorialSourceEvidence = {
|
||||
association: string;
|
||||
role: string;
|
||||
source_reference: string | null;
|
||||
source_quote: string | null;
|
||||
supports: string;
|
||||
traceability: string;
|
||||
official_source_accepted: boolean;
|
||||
existing_validation: string | null;
|
||||
};
|
||||
export default function TerritorialSources({
|
||||
sources,
|
||||
}: {
|
||||
sources?: TerritorialSourceEvidence[];
|
||||
}) {
|
||||
if (!sources?.length) return null;
|
||||
return (
|
||||
<section className="space-y-3 rounded border p-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
Fundamento documental das associações
|
||||
</h2>
|
||||
<p>
|
||||
Fontes oficiais identificadas são aceites; recuperar uma
|
||||
referência não retira a aceitação existente. O fundamento
|
||||
aplica-se à informação indicada, sem deduzir implantação,
|
||||
disponibilidade ou capacidade.
|
||||
</p>
|
||||
{sources.map((source, i) => (
|
||||
<article className="border-t pt-3" key={i}>
|
||||
<h3 className="font-semibold">
|
||||
{source.role} — {source.association}
|
||||
</h3>
|
||||
<p>
|
||||
Fonte:{' '}
|
||||
{source.source_reference ??
|
||||
'ligação ao exemplar por recuperar'}
|
||||
</p>
|
||||
<p>{source.source_quote ?? source.supports}</p>
|
||||
<p>
|
||||
{source.official_source_accepted
|
||||
? 'Fonte oficial identificada e aceite.'
|
||||
: 'Aceitação existente preservada; ponte ao exemplar oficial por recuperar. Esta referência não recebe outra classificação de proveniência.'}{' '}
|
||||
Rastreabilidade: {source.traceability}. Estado
|
||||
registado:{' '}
|
||||
{source.existing_validation ?? 'sem campo específico'};
|
||||
preservado.
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useForm } from '@inertiajs/react';
|
||||
|
||||
export type TraceabilityPlan = {
|
||||
hash: string;
|
||||
code_matches: boolean;
|
||||
backup_ready: boolean;
|
||||
applied: boolean;
|
||||
plan: {
|
||||
target: { database: string; port: number };
|
||||
rows: {
|
||||
key: string;
|
||||
reference: string;
|
||||
association: string;
|
||||
role: string;
|
||||
source_reference: string | null;
|
||||
source_quote: string | null;
|
||||
supports: string;
|
||||
traceability: string;
|
||||
existing_validation: string | null;
|
||||
effect: string;
|
||||
}[];
|
||||
updates: {
|
||||
id: string;
|
||||
reference: string;
|
||||
current: {
|
||||
source_reference: string | null;
|
||||
evidence_reference: string | null;
|
||||
};
|
||||
values: { source_reference: string; evidence_reference: string };
|
||||
source_quote: string;
|
||||
effect: string;
|
||||
}[];
|
||||
pending: { reference: string; reason: string }[];
|
||||
};
|
||||
};
|
||||
export default function TerritorialTraceabilityPlan({
|
||||
preview,
|
||||
}: {
|
||||
preview: TraceabilityPlan;
|
||||
}) {
|
||||
const form = useForm({
|
||||
hash: preview.hash,
|
||||
operation: 'traceability',
|
||||
reverse: false,
|
||||
});
|
||||
return (
|
||||
<section className="space-y-4 rounded border p-5">
|
||||
<h2 className="text-xl font-bold">
|
||||
V2 — aplicar rastreabilidade territorial
|
||||
</h2>
|
||||
<p>
|
||||
Destino: {preview.plan.target.database}:
|
||||
{preview.plan.target.port}. Fontes oficiais identificadas são
|
||||
aceites; não se repete validação da proveniência. Estados
|
||||
existentes são preservados.
|
||||
</p>
|
||||
<p>
|
||||
{preview.plan.rows.length} associações/fontes;{' '}
|
||||
{preview.plan.updates.length} atualizações de referências.
|
||||
Nenhuma nova implantação ou relação por proximidade.
|
||||
</p>
|
||||
<p role="status">
|
||||
Backup/restauro/reversão:{' '}
|
||||
{preview.backup_ready
|
||||
? 'verificados (validade 24 horas)'
|
||||
: 'pendentes ou expirados'}{' '}
|
||||
· Código:{' '}
|
||||
{preview.code_matches
|
||||
? 'corresponde ao plano'
|
||||
: 'plano deve ser atualizado'}{' '}
|
||||
· Aplicação: {preview.applied ? 'aplicada' : 'não aplicada'}
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform(() => ({
|
||||
hash: preview.hash,
|
||||
operation: 'traceability',
|
||||
reverse: preview.applied,
|
||||
}));
|
||||
form.post('/management/relationship-repair', {
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={
|
||||
form.processing ||
|
||||
!preview.backup_ready ||
|
||||
!preview.code_matches
|
||||
}
|
||||
className="rounded bg-emerald-800 px-4 py-3 text-white disabled:opacity-50"
|
||||
>
|
||||
{preview.applied
|
||||
? 'Reverter este incremento de rastreabilidade'
|
||||
: 'Aplicar rastreabilidade territorial V2'}
|
||||
</button>
|
||||
{Object.values(form.errors).map((error) => (
|
||||
<p role="alert" key={error}>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
<h3 className="font-bold">Alterações aos campos existentes</h3>
|
||||
{preview.plan.updates.map((update) => (
|
||||
<article className="border-t py-3" key={update.id}>
|
||||
<h4 className="font-bold">{update.reference}</h4>
|
||||
<p>
|
||||
Referência atual:{' '}
|
||||
{update.current.source_reference ?? 'não preenchida'};
|
||||
evidência atual:{' '}
|
||||
{update.current.evidence_reference ?? 'não preenchida'}.
|
||||
</p>
|
||||
<p>
|
||||
Proposta: {update.values.source_reference}; dossier{' '}
|
||||
{update.values.evidence_reference}.
|
||||
</p>
|
||||
<p>Fundamento: {update.source_quote}</p>
|
||||
<p>{update.effect}</p>
|
||||
</article>
|
||||
))}
|
||||
<h3 className="font-bold">Fundamento por associação</h3>
|
||||
{preview.plan.rows.map((row) => (
|
||||
<article className="border-t py-3" key={row.key}>
|
||||
<h3 className="font-bold">
|
||||
{row.reference} — {row.association} ({row.role})
|
||||
</h3>
|
||||
<p>
|
||||
Atual:{' '}
|
||||
{row.existing_validation ??
|
||||
'sem campo de validação nesta associação'}
|
||||
; estado preservado.
|
||||
</p>
|
||||
<p>
|
||||
Proposta: ligar a referência e o fundamento. Fonte:{' '}
|
||||
{row.source_reference ?? 'por recuperar no acervo'}
|
||||
</p>
|
||||
<p>Citação/âmbito: {row.source_quote ?? row.supports}</p>
|
||||
<p>
|
||||
Rastreabilidade: {row.traceability} · Efeito:{' '}
|
||||
{row.effect}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<h3 className="font-bold">
|
||||
Pendências de rastreabilidade (não invalidam dados)
|
||||
</h3>
|
||||
{preview.plan.pending.map((row, i) => (
|
||||
<p key={i}>
|
||||
{row.reference}: {row.reason}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Map from '@/components/interactive-investment-map';
|
||||
import type { InvestmentMapArea } from '@/components/interactive-investment-map';
|
||||
import {
|
||||
TerritoryLevel,
|
||||
type TerritoryOption,
|
||||
@@ -57,6 +58,7 @@ export default function Catalogue({
|
||||
q: String(filters.q ?? ''),
|
||||
dimension: String(filters.dimension ?? ''),
|
||||
view: String(filters.view ?? 'grid'),
|
||||
territory_basis: String(filters.territory_basis ?? 'all'),
|
||||
sector: String(filters.sector ?? ''),
|
||||
province: String(filters.province ?? ''),
|
||||
district: String(filters.district ?? ''),
|
||||
@@ -91,6 +93,7 @@ export default function Catalogue({
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
|
||||
const [points, setPoints] = useState<Feature[] | null>(null);
|
||||
const [areas, setAreas] = useState<InvestmentMapArea[]>([]);
|
||||
const [unlocated, setUnlocated] = useState<
|
||||
{ id: number; title: string; location: string; url: string }[]
|
||||
>([]);
|
||||
@@ -147,6 +150,7 @@ export default function Catalogue({
|
||||
);
|
||||
}
|
||||
setPoints(null);
|
||||
setAreas([]);
|
||||
setUnlocated([]);
|
||||
setTruncated(false);
|
||||
setError('');
|
||||
@@ -154,6 +158,7 @@ export default function Catalogue({
|
||||
.then((data) => {
|
||||
if (active) {
|
||||
setPoints(data.features);
|
||||
setAreas(data.areas ?? []);
|
||||
setUnlocated(data.unlocated ?? []);
|
||||
setTruncated(Boolean(data.truncated));
|
||||
}
|
||||
@@ -551,6 +556,26 @@ export default function Catalogue({
|
||||
/>
|
||||
</Field>
|
||||
</section>
|
||||
<Field label="Base da relação territorial">
|
||||
<select
|
||||
className={input}
|
||||
value={form.data.territory_basis}
|
||||
onChange={(e) =>
|
||||
form.setData('territory_basis', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="all">
|
||||
Implantação e contexto associado
|
||||
</option>
|
||||
<option value="direct">
|
||||
Implantação declarada da oportunidade
|
||||
</option>
|
||||
<option value="context">
|
||||
Apenas contexto associado; implantação por
|
||||
documentar
|
||||
</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -1060,7 +1085,7 @@ export default function Catalogue({
|
||||
</Link>
|
||||
</div>
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{!error && <Map points={points} />}
|
||||
{!error && <Map points={points} areas={areas} />}
|
||||
{truncated && (
|
||||
<p role="alert">
|
||||
O mapa atingiu o limite de 1.000 registos; refine os
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import InvestmentMapPanel from '@/components/investment-map-panel';
|
||||
import StructureGeographyPanel from '@/components/structure-geography';
|
||||
import StructureGeographyPanel, {
|
||||
LocationList,
|
||||
} from '@/components/structure-geography';
|
||||
import PartnershipSourceLinks from '@/components/partnership-source-links';
|
||||
import TerritorialSources from '@/components/territorial-source-evidence';
|
||||
import { Frame, card, button, money, groupLabels } from './shared';
|
||||
import type { Opportunity, Context } from './shared';
|
||||
export default function Detail({
|
||||
@@ -48,11 +51,31 @@ export default function Detail({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xl">{r.summary}</p>
|
||||
<TerritorialSources sources={c.source_evidence} />
|
||||
<section className={card + ' space-y-3'}>
|
||||
<h2 className="text-xl font-bold">
|
||||
Implantação declarada da oportunidade
|
||||
</h2>
|
||||
<p>
|
||||
As relações cadastradas não confirmam disponibilidade ou
|
||||
capacidade. Locais dos projetos associados, áreas de impacto
|
||||
e abrangência dos clusters são apresentados separadamente.
|
||||
</p>
|
||||
<LocationList locations={c.opportunity_locations ?? []} />
|
||||
{!c.opportunity_locations?.length && (
|
||||
<p>
|
||||
Implantação estruturada não documentada. O contexto
|
||||
associado abaixo não confirma onde a oportunidade será
|
||||
implantada.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<InvestmentMapPanel filters={{ opportunity_id: r.id }} />
|
||||
{c.primary_territory_path &&
|
||||
c.primary_territory_path.length > 0 && (
|
||||
<p>
|
||||
Localização:{' '}
|
||||
Território principal cadastrado (implantação por
|
||||
confirmar):{' '}
|
||||
{c.primary_territory_path
|
||||
.map((node) => node.name)
|
||||
.join(' → ')}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PortalMenu } from '@/layouts/portal-navigation';
|
||||
import type { StructureGeography } from '@/components/structure-geography';
|
||||
import type { TerritorialSourceEvidence } from '@/components/territorial-source-evidence';
|
||||
import type {
|
||||
StructureGeography,
|
||||
DocumentedLocation,
|
||||
} from '@/components/structure-geography';
|
||||
|
||||
export type Option = {
|
||||
id: number;
|
||||
@@ -47,6 +51,8 @@ export type Opportunity = {
|
||||
last_validated_at?: string | null;
|
||||
};
|
||||
export type Context = {
|
||||
source_evidence?: TerritorialSourceEvidence[];
|
||||
opportunity_locations?: DocumentedLocation[];
|
||||
primary_territory_path?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Frame, Field, input, button, card, api } from './shared';
|
||||
import PartnershipSourceLinks from '@/components/partnership-source-links';
|
||||
import TerritorialSources, {
|
||||
type TerritorialSourceEvidence,
|
||||
} from '@/components/territorial-source-evidence';
|
||||
import ZoneRelations from './zone-relations';
|
||||
import ZoneMap from './zone-map';
|
||||
import ZoneAttachments from './zone-attachments';
|
||||
@@ -124,6 +127,7 @@ type Props = {
|
||||
validation_status: string;
|
||||
}[];
|
||||
record: Zone;
|
||||
territorialSources?: TerritorialSourceEvidence[];
|
||||
management: boolean;
|
||||
zoneExtensionsAvailable?: boolean;
|
||||
source: Source | null;
|
||||
@@ -186,6 +190,7 @@ function Editor({
|
||||
relations,
|
||||
geometries,
|
||||
record: z,
|
||||
territorialSources,
|
||||
management,
|
||||
zoneExtensionsAvailable = false,
|
||||
source,
|
||||
@@ -268,6 +273,7 @@ function Editor({
|
||||
}
|
||||
return (
|
||||
<Frame title={z.name}>
|
||||
<TerritorialSources sources={territorialSources} />
|
||||
{!management && <PartnershipSourceLinks type="zone" id={z.id} />}
|
||||
<p>
|
||||
{z.code} · {z.category.toUpperCase()} · {z.validation_status} ·{' '}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { Management } from './shared';
|
||||
import TerritorialTraceabilityPlan, {
|
||||
type TraceabilityPlan,
|
||||
} from '@/components/territorial-traceability-plan';
|
||||
|
||||
type Plan = {
|
||||
target: { database: string; port: number };
|
||||
@@ -149,16 +152,21 @@ export default function RelationshipRepair({
|
||||
territorial,
|
||||
history,
|
||||
unproposed,
|
||||
traceability,
|
||||
}: {
|
||||
hash: string;
|
||||
plan: Plan;
|
||||
territorial: Territory[];
|
||||
history: History[];
|
||||
unproposed: Unproposed[];
|
||||
traceability?: TraceabilityPlan | null;
|
||||
}) {
|
||||
const form = useForm({ hash });
|
||||
return (
|
||||
<Management title="Associações documentadas de projectos âncora e clusters">
|
||||
{traceability && (
|
||||
<TerritorialTraceabilityPlan preview={traceability} />
|
||||
)}
|
||||
<p>
|
||||
Pré-visualização para {plan.target.database}:{plan.target.port}.
|
||||
A execução verifica o backup e o restauro, a versão dos dossiers
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentRelations;
|
||||
use App\Services\InvestmentRelationshipRepair;
|
||||
use App\Services\InvestmentTerritorialValidation;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -104,7 +105,7 @@ class InvestmentRelationshipTest extends TestCase
|
||||
$this->get('/carteira/'.$f['dossier']->id)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_ambiguous_multiple_sites_do_not_choose_an_arbitrary_opportunity_marker(): void
|
||||
public function test_multiple_documented_sites_are_shown_separately_without_choosing_one(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$id = (string) Str::uuid();
|
||||
@@ -112,11 +113,59 @@ class InvestmentRelationshipTest extends TestCase
|
||||
DB::table('project_locations')->insert(['id' => (string) Str::uuid(), 'project_id' => $f['project']->id, 'geo_location_id' => $id, 'role' => 'secondary', 'data_classification' => 'public']);
|
||||
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id, 'geometry_type' => 'point', 'data_classification' => 'public', 'geom' => DB::raw('ST_SetSRID(ST_MakePoint(32.6,-25.8),4326)')]);
|
||||
$map = app(InvestmentCatalogue::class)->map([]);
|
||||
$this->assertSame(0, $map['georeferenced_count']);
|
||||
$this->assertCount(1, $map['unlocated']);
|
||||
$this->assertSame(1, $map['georeferenced_count']);
|
||||
$this->assertCount(0, $map['unlocated']);
|
||||
$this->assertCount(2, $map['features']);
|
||||
$this->assertSame(2, $map['marker_count']);
|
||||
$this->assertCount(2, app(InvestmentRelations::class)->projectLocations($f['project']->id));
|
||||
}
|
||||
|
||||
public function test_direct_context_impact_and_missing_geography_remain_distinct(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$catalogue = app(InvestmentCatalogue::class);
|
||||
$this->assertEmpty($catalogue->context($f['opportunity']->id)['opportunity_locations']);
|
||||
$this->assertSame([$f['opportunity']->id], $catalogue->query(['territory_basis' => 'context'])->pluck('id')->all());
|
||||
$this->assertSame([], $catalogue->query(['territory_basis' => 'direct'])->pluck('id')->all());
|
||||
DB::table('opportunity_locations')->insert(['opportunity_id' => $f['opportunity']->id, 'geo_location_id' => $f['ids']['district']]);
|
||||
$location = $catalogue->context($f['opportunity']->id)['opportunity_locations'][0];
|
||||
$this->assertSame('opportunity', $location['role']);
|
||||
$this->assertSame([$f['opportunity']->id], $catalogue->query(['q' => 'QA district'])->pluck('id')->all());
|
||||
$this->assertNull($location['source_reference']);
|
||||
$this->assertEmpty($location['coordinates']);
|
||||
$this->assertSame([], $catalogue->query(['territory_basis' => 'context'])->pluck('id')->all());
|
||||
$this->assertSame([$f['opportunity']->id], $catalogue->query(['territory_basis' => 'direct', 'geo_location_id' => $f['ids']['district']])->pluck('id')->all());
|
||||
$this->assertSame([], $catalogue->query(['territory_basis' => 'direct', 'geo_location_id' => $f['ids']['site']])->pluck('id')->all());
|
||||
DB::table('project_locations')->update(['role' => 'impact']);
|
||||
$this->assertSame('impact', $catalogue->context($f['opportunity']->id)['projects'][0]->geography['locations'][0]['role']);
|
||||
$this->assertSame(0, $catalogue->map([])['georeferenced_count']);
|
||||
DB::table('opportunity_locations')->delete();
|
||||
DB::table('opportunity_projects')->delete();
|
||||
$this->assertEmpty($catalogue->context($f['opportunity']->id)['opportunity_locations']);
|
||||
$this->assertSame(1, $catalogue->query([])->count());
|
||||
$this->assertCount(1, $catalogue->map([])['unlocated']);
|
||||
}
|
||||
|
||||
public function test_invalid_coordinates_are_rejected_and_polygon_provenance_is_preserved(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
DB::table('opportunity_locations')->insert(['opportunity_id' => $f['opportunity']->id, 'geo_location_id' => $f['ids']['site']]);
|
||||
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $f['ids']['site'], 'geometry_type' => 'polygon', 'data_classification' => 'public',
|
||||
'geom' => DB::raw("ST_GeomFromText('POLYGON((32 -26,33 -26,33 -25,32 -25,32 -26))',4326)"), 'source_system' => 'QA boundary', 'properties' => json_encode(['source_date' => '2026-10-08'])]);
|
||||
$context = app(InvestmentCatalogue::class)->context($f['opportunity']->id);
|
||||
$this->assertSame('2026-10-08', $context['opportunity_locations'][0]['boundaries'][0]['source_date']);
|
||||
$this->assertSame('opportunity_site', app(InvestmentCatalogue::class)->map([])['features'][0]['properties']['location_basis']);
|
||||
$this->assertCount(1, app(InvestmentCatalogue::class)->map([])['areas']);
|
||||
try {
|
||||
DB::transaction(fn () => DB::table('geo_geometries')->where('geo_location_id', $f['ids']['site'])->where('geometry_type', 'point')->update(['geom' => DB::raw('ST_SetSRID(ST_MakePoint(32.7,95),4326)')]));
|
||||
$this->fail('Invalid coordinates must be rejected by the existing database guard.');
|
||||
} catch (QueryException $error) {
|
||||
$this->assertSame('23514', $error->getCode());
|
||||
}
|
||||
$this->assertSame(1, app(InvestmentCatalogue::class)->map([])['georeferenced_count']);
|
||||
$this->assertCount(1, app(InvestmentCatalogue::class)->context($f['opportunity']->id)['opportunity_locations'][0]['coordinates']);
|
||||
}
|
||||
|
||||
public function test_repair_requires_authenticated_management_is_transactional_and_replays_without_duplicates(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\EconomicCluster;
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class InvestmentTerritorialTraceabilityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $directory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->directory = storage_path('framework/testing/territorial-'.Str::uuid());
|
||||
mkdir($this->directory, 0700, true);
|
||||
config(['investment.traceability_directory' => $this->directory]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->directory.'/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
rmdir($this->directory);
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function fixture(): array
|
||||
{
|
||||
$project = Project::factory()->create();
|
||||
$cluster = EconomicCluster::create(['code' => 'QA-TRACE', 'name' => 'Synthetic QA cluster', 'sector' => 'QA', 'status' => 'planned']);
|
||||
$dossier = InvestmentDossier::create(['cluster_id' => $cluster->id, 'title' => 'QA synthetic source', 'source' => 'Synthetic fixture only', 'content' => [], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'QA synthetic disclosure']);
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('cluster_projects')->insert(['id' => $id, 'cluster_id' => $cluster->id, 'project_id' => $project->id, 'role' => 'anchor', 'source_system' => 'QA']);
|
||||
$row = DB::table('cluster_projects')->where('id', $id)->first();
|
||||
$plan = ['schema' => 1, 'target' => ['database' => config('database.connections.pgsql.database'), 'port' => 55441], 'code_hash' => app(InvestmentTerritorialTraceability::class)->fingerprint(),
|
||||
'rows' => [['kind' => 'economic_clusters', 'record_id' => $cluster->id, 'association' => $project->id, 'role' => 'context', 'public' => true, 'dossier_id' => $dossier->id, 'source_reference' => 'QA synthetic source', 'source_quote' => 'QA anchor', 'supports' => 'Existing membership only', 'traceability' => 'quote recovered', 'official_source_accepted' => false, 'existing_validation' => $row->validation_status]],
|
||||
'guards' => [['table' => 'cluster_projects', 'id' => $id, 'expected' => ['version_no' => $row->version_no, 'validation_status' => $row->validation_status, 'source_reference' => null]]],
|
||||
'updates' => [['table' => 'cluster_projects', 'id' => $id, 'values' => ['source_reference' => 'QA synthetic source', 'evidence_reference' => $dossier->id]]]];
|
||||
$bytes = json_encode($plan, JSON_THROW_ON_ERROR);
|
||||
file_put_contents($this->directory.'/territory-activation-plan.json', $bytes);
|
||||
file_put_contents($this->directory.'/qa-backup-fixture', 'QA fixture, not active backup evidence');
|
||||
$hash = hash('sha256', $bytes);
|
||||
$proof = ['restore_verified' => true, 'reversal_verified' => true, 'target' => $plan['target'], 'plan_hash' => $hash, 'code_hash' => $plan['code_hash'], 'verified_at' => gmdate('c'), 'dump_path' => $this->directory.'/qa-backup-fixture', 'sha256' => hash_file('sha256', $this->directory.'/qa-backup-fixture')];
|
||||
file_put_contents($this->directory.'/territory-backup-proof.json', json_encode($proof, JSON_THROW_ON_ERROR));
|
||||
$manager = User::factory()->create(['email' => 'territory-manager@example.invalid']);
|
||||
config(['portal.managers' => [$manager->email]]);
|
||||
|
||||
return compact('hash', 'manager', 'id', 'cluster', 'dossier', 'row');
|
||||
}
|
||||
|
||||
public function test_authenticated_application_is_idempotent_and_reversible_without_revalidating_sources(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$payload = ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false];
|
||||
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', $payload)->assertOk()->assertJsonPath('changed', 1);
|
||||
$this->postJson('/management/relationship-repair', $payload)->assertOk()->assertJsonPath('replayed', true)->assertJsonPath('changed', 0);
|
||||
$service = app(InvestmentTerritorialTraceability::class);
|
||||
$this->assertCount(1, $service->sources('economic_clusters', $f['cluster']->id));
|
||||
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'validation_status' => $f['row']->validation_status, 'source_reference' => 'QA synthetic source']);
|
||||
$f['dossier']->update(['publication_status' => 'draft']);
|
||||
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
|
||||
$this->postJson('/management/relationship-repair', [...$payload, 'reverse' => true])->assertOk()->assertJsonPath('changed', 1);
|
||||
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'version_no' => $f['row']->version_no + 2, 'source_reference' => null, 'evidence_reference' => null, 'validation_status' => $f['row']->validation_status]);
|
||||
$this->assertSame([], $service->sources('economic_clusters', $f['cluster']->id));
|
||||
$this->assertSame(2, DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->count());
|
||||
$this->postJson('/management/relationship-repair', $payload)->assertConflict();
|
||||
}
|
||||
|
||||
public function test_permissions_and_conflicting_snapshot_prevent_writes_and_show_field_error(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$payload = ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false];
|
||||
$this->actingAs(User::factory()->create())->postJson('/management/relationship-repair', $payload)->assertForbidden();
|
||||
DB::table('cluster_projects')->where('id', $f['id'])->increment('version_no');
|
||||
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', $payload)->assertConflict();
|
||||
$this->from('/management/relationship-repair')->post('/management/relationship-repair', $payload)->assertRedirect('/management/relationship-repair')->assertSessionHasErrors('operation');
|
||||
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'source_reference' => null]);
|
||||
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'territory-traceability-v2')->count());
|
||||
}
|
||||
|
||||
public function test_expired_backup_and_modified_code_proof_require_updated_plan(): void
|
||||
{
|
||||
$f = $this->fixture();
|
||||
$proofFile = $this->directory.'/territory-backup-proof.json';
|
||||
$proof = json_decode(file_get_contents($proofFile), true);
|
||||
$proof['verified_at'] = gmdate('c', time() - 86401);
|
||||
file_put_contents($proofFile, json_encode($proof));
|
||||
$this->actingAs($f['manager'])->postJson('/management/relationship-repair', ['operation' => 'traceability', 'hash' => $f['hash'], 'reverse' => false])->assertUnprocessable();
|
||||
$proof['verified_at'] = gmdate('c');
|
||||
$proof['code_hash'] = 'wrong';
|
||||
file_put_contents($proofFile, json_encode($proof));
|
||||
$this->assertFalse(app(InvestmentTerritorialTraceability::class)->preview()['backup_ready']);
|
||||
$this->assertDatabaseHas('cluster_projects', ['id' => $f['id'], 'source_reference' => null]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const dir = path.join(root, 'storage/app/private/investmoz-v2');
|
||||
const audit = JSON.parse(
|
||||
fs.readFileSync(path.join(dir, 'territory-audit.json'), 'utf8'),
|
||||
);
|
||||
const reconciliation = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(dir, 'territory-source-reconciliation.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
if (
|
||||
audit.identity.read_only !== 'on' ||
|
||||
reconciliation.captured_at !== audit.captured_at
|
||||
)
|
||||
throw new Error('Matching read-only source reconciliation required');
|
||||
const plan = {
|
||||
schema: 1,
|
||||
prepared_at: new Date().toISOString(),
|
||||
target: {
|
||||
database: audit.identity.database,
|
||||
port: Number(audit.identity.port),
|
||||
},
|
||||
code_hash: '',
|
||||
rows: [],
|
||||
updates: [],
|
||||
guards: [],
|
||||
pending: [],
|
||||
new_relationships: [],
|
||||
};
|
||||
const guardIds = new Set();
|
||||
const guard = (table, record, fields) => {
|
||||
const key = table + ':' + record.id;
|
||||
if (guardIds.has(key)) return;
|
||||
guardIds.add(key);
|
||||
plan.guards.push({
|
||||
table,
|
||||
id: record.id,
|
||||
expected: Object.fromEntries(
|
||||
fields
|
||||
.filter((field) => field in record)
|
||||
.map((field) => [field, record[field]]),
|
||||
),
|
||||
});
|
||||
};
|
||||
const publicDossiers = audit.dossiers.filter(
|
||||
(d) => d.publication_status === 'published',
|
||||
);
|
||||
for (const entity of audit.records) {
|
||||
const record = entity.record;
|
||||
const reference = record.code || record.reference || String(record.id);
|
||||
const source = reconciliation.records.find(
|
||||
(r) => r.kind === entity.kind && r.id === entity.id,
|
||||
);
|
||||
guard(entity.kind, record, [
|
||||
'version_no',
|
||||
'territory_version',
|
||||
'metadata',
|
||||
'source_evidence',
|
||||
'validation_status',
|
||||
'publication_status',
|
||||
'status',
|
||||
'primary_territory_id',
|
||||
'deleted_at',
|
||||
]);
|
||||
const dossiers = audit.dossiers.filter(
|
||||
(d) => d.project_id === record.id || d.cluster_id === record.id,
|
||||
);
|
||||
for (const dossier of dossiers)
|
||||
guard('investment_dossiers', dossier, [
|
||||
'version_no',
|
||||
'source',
|
||||
'content',
|
||||
'publication_status',
|
||||
]);
|
||||
const visible =
|
||||
entity.kind === 'opportunities'
|
||||
? record.status === 'published'
|
||||
: entity.kind === 'investment_zones'
|
||||
? record.publication_status === 'published'
|
||||
: dossiers.some((d) => d.publication_status === 'published');
|
||||
const metadata = JSON.parse(record.metadata || '{}');
|
||||
const legal = source.legal_source;
|
||||
if (legal) guard('investment_legal_sources', legal, Object.keys(legal));
|
||||
const evidence =
|
||||
source.official_references[0] || source.non_official_references[0];
|
||||
const row = (association, role, props = {}) => {
|
||||
plan.rows.push({
|
||||
key: `${entity.kind}:${entity.id}:${association}:${role}`,
|
||||
kind: entity.kind,
|
||||
record_id: entity.id,
|
||||
reference,
|
||||
association,
|
||||
role,
|
||||
public: visible,
|
||||
source_reference: legal
|
||||
? `${legal.diploma} — ${legal.url}`
|
||||
: evidence
|
||||
? `${evidence.title} — ${evidence.url}`
|
||||
: dossiers[0]?.source || null,
|
||||
source_quote: null,
|
||||
supports:
|
||||
evidence?.supports ||
|
||||
'Origem oficial aceite pelo responsável; ligação ao exemplar a recuperar onde ausente.',
|
||||
existing_validation: record.validation_status ?? null,
|
||||
official_source_accepted: Boolean(
|
||||
legal || source.official_references.length,
|
||||
),
|
||||
traceability: source.traceability,
|
||||
effect: 'Ficha mostra fundamento e pendência específica; identidade, geometria, filtros e validações preservados.',
|
||||
...props,
|
||||
});
|
||||
};
|
||||
row('registo', 'proveniência');
|
||||
const locations = entity.relations.own || [];
|
||||
for (const location of locations) {
|
||||
if (location.id)
|
||||
guard(
|
||||
entity.kind === 'projects'
|
||||
? 'project_locations'
|
||||
: 'investment_zone_locations',
|
||||
location,
|
||||
Object.keys(location),
|
||||
);
|
||||
const saved =
|
||||
metadata.documented_location_sources?.[
|
||||
location.geo_location_id + ':' + location.role
|
||||
];
|
||||
row(
|
||||
location.geo_location_id,
|
||||
location.role || location.scope_type || 'implantação',
|
||||
{
|
||||
public_location_id: location.geo_location_id,
|
||||
source_reference:
|
||||
saved?.source_reference ||
|
||||
location.source_reference ||
|
||||
legal?.url ||
|
||||
evidence?.url ||
|
||||
null,
|
||||
source_quote: saved?.source_quote || null,
|
||||
supports:
|
||||
saved?.reason ||
|
||||
evidence?.supports ||
|
||||
'Ligação ao documento utilizado por recuperar; dado preservado.',
|
||||
dossier_id: saved?.dossier_id || null,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
record.primary_territory_id &&
|
||||
!locations.some(
|
||||
(l) => l.geo_location_id === record.primary_territory_id,
|
||||
)
|
||||
)
|
||||
row(
|
||||
record.primary_territory_id,
|
||||
entity.kind === 'opportunities'
|
||||
? 'implantação cadastrada'
|
||||
: 'território principal',
|
||||
{
|
||||
public_location_id: record.primary_territory_id,
|
||||
supports:
|
||||
'Associação já existente; nomes administrativos provenientes do workbook cartografia-moz. Não se atribui automaticamente o PDF INE a este vínculo.',
|
||||
},
|
||||
);
|
||||
for (const coverage of metadata.documented_coverage || [])
|
||||
row(coverage.id, 'abrangência', {
|
||||
source_reference: coverage.source_reference || null,
|
||||
source_quote: coverage.source_quote || null,
|
||||
public_location_id: coverage.id,
|
||||
public: visible && coverage.data_classification === 'public',
|
||||
});
|
||||
if (entity.kind === 'economic_clusters') {
|
||||
const dossier = publicDossiers.find((d) => d.cluster_id === record.id);
|
||||
const content = JSON.parse(dossier?.content || '{}');
|
||||
for (const member of entity.relations.members || []) {
|
||||
const project = audit.records.find(
|
||||
(r) => r.kind === 'projects' && r.id === member.project_id,
|
||||
);
|
||||
const quote = content.anchors || '';
|
||||
const explicit =
|
||||
project &&
|
||||
new RegExp(
|
||||
'(^|[^A-Za-z0-9])' +
|
||||
project.record.code +
|
||||
'([^A-Za-z0-9]|$)',
|
||||
).test(quote);
|
||||
guard('cluster_projects', member, Object.keys(member));
|
||||
row(member.project_id, 'contexto — membro ' + member.role, {
|
||||
association_id: member.id,
|
||||
dossier_id: dossier?.id || null,
|
||||
source_reference:
|
||||
member.source_reference ||
|
||||
(explicit ? dossier.source : null),
|
||||
source_quote: explicit ? quote : null,
|
||||
existing_validation: member.validation_status,
|
||||
official_source_accepted: false,
|
||||
traceability: explicit
|
||||
? 'citação recuperada; ponte ao exemplar oficial por recuperar'
|
||||
: 'rastreabilidade por recuperar',
|
||||
effect: 'Citação da relação cluster–projeto passa a ser visível; não muda validação nem cria implantação da oportunidade.',
|
||||
});
|
||||
if (
|
||||
explicit &&
|
||||
!member.source_reference &&
|
||||
!member.evidence_reference
|
||||
)
|
||||
plan.updates.push({
|
||||
table: 'cluster_projects',
|
||||
id: member.id,
|
||||
reference: `${reference} → ${project.record.code}`,
|
||||
current: {
|
||||
source_reference: null,
|
||||
evidence_reference: null,
|
||||
},
|
||||
values: {
|
||||
source_reference: dossier.source,
|
||||
evidence_reference: dossier.id,
|
||||
},
|
||||
source_quote: quote,
|
||||
effect: 'Relação existente reutilizada; apenas referências preenchidas.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (source.traceability !== 'official_reference_identified')
|
||||
plan.pending.push({
|
||||
reference,
|
||||
reason: 'Ponte ao exemplar oficial não identificada no cadastro revisto; recuperar sem desvalidar.',
|
||||
});
|
||||
if (
|
||||
entity.kind === 'opportunities' &&
|
||||
!(entity.relations.projects || []).length &&
|
||||
!(entity.relations.clusters || []).length
|
||||
)
|
||||
plan.pending.push({
|
||||
reference,
|
||||
reason: 'Sem correspondência documental explícita a uma âncora/cluster nas fontes já reconciliadas; não criar ligação por setor ou proximidade.',
|
||||
});
|
||||
}
|
||||
const files = [
|
||||
'app/Services/InvestmentTerritorialTraceability.php',
|
||||
'app/Http/Controllers/InvestmentRelationshipRepairController.php',
|
||||
'resources/js/components/territorial-traceability-plan.tsx',
|
||||
'tools/investmoz-v2-prepare-territorial-plan.cjs',
|
||||
];
|
||||
plan.code_hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(
|
||||
files
|
||||
.map((file) =>
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(root, file)))
|
||||
.digest('hex'),
|
||||
)
|
||||
.join(''),
|
||||
)
|
||||
.digest('hex');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'territory-activation-plan.json'),
|
||||
JSON.stringify(plan, null, 2),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
records: audit.records.length,
|
||||
associations: plan.rows.length,
|
||||
updates: plan.updates.length,
|
||||
pending: plan.pending.length,
|
||||
new_relationships: 0,
|
||||
target: plan.target,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function reconcile(item, audit) {
|
||||
const row = item.record;
|
||||
const metadata = JSON.parse(row.metadata || '{}');
|
||||
const evidence = JSON.parse(row.source_evidence || '[]');
|
||||
const legal =
|
||||
(audit.legal_sources || []).find(
|
||||
(source) => source.id === row.legal_source_id,
|
||||
) || null;
|
||||
const dossiers = audit.dossiers.filter(
|
||||
(d) => d.project_id === row.id || d.cluster_id === row.id,
|
||||
);
|
||||
const officialReferences = evidence.filter((source) =>
|
||||
['government', 'institutional'].includes(source.source_type),
|
||||
);
|
||||
const documentaryRelations = Object.entries(
|
||||
metadata.documented_location_sources || {},
|
||||
).map(([key, source]) => ({ key, ...source }));
|
||||
return {
|
||||
kind: item.kind,
|
||||
id: row.id,
|
||||
reference: row.code || row.reference || String(row.id),
|
||||
existing_record_validation: row.validation_status ?? null,
|
||||
existing_legal_validation: legal?.validation_status ?? null,
|
||||
legal_source: legal,
|
||||
official_references: officialReferences,
|
||||
non_official_references: evidence.filter(
|
||||
(source) =>
|
||||
!['government', 'institutional'].includes(source.source_type),
|
||||
),
|
||||
documentary_relations: documentaryRelations,
|
||||
dossiers: dossiers.map((d) => ({
|
||||
id: d.id,
|
||||
source: d.source,
|
||||
version: d.version_no,
|
||||
})),
|
||||
traceability:
|
||||
legal || officialReferences.length
|
||||
? 'official_reference_identified'
|
||||
: documentaryRelations.length || dossiers.length
|
||||
? 'documentary_bridge_to_official_source_to_recover'
|
||||
: 'official_source_link_not_identified_in_records_reviewed',
|
||||
official_validation_newly_granted: false,
|
||||
validation_revoked: false,
|
||||
calculation_activated: false,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { reconcile };
|
||||
if (require.main === module) {
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const privateDir = path.join(root, 'storage/app/private/investmoz-v2');
|
||||
const audit = JSON.parse(
|
||||
fs.readFileSync(path.join(privateDir, 'territory-audit.json'), 'utf8'),
|
||||
);
|
||||
if (audit.identity.read_only !== 'on')
|
||||
throw new Error('Read-only audit required');
|
||||
const records = audit.records.map((item) => reconcile(item, audit));
|
||||
const acceptancePath = path.join(
|
||||
root,
|
||||
'storage/app/private/geo-ine-review-20260928-v2',
|
||||
);
|
||||
const acceptanceFile = fs
|
||||
.readdirSync(acceptancePath)
|
||||
.find(
|
||||
(name) =>
|
||||
name.startsWith('owner-acceptance-') && name.endsWith('.json'),
|
||||
);
|
||||
const acceptance = JSON.parse(
|
||||
fs.readFileSync(path.join(acceptancePath, acceptanceFile), 'utf8'),
|
||||
);
|
||||
const crypto = require('node:crypto');
|
||||
const archiveHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(path.join(acceptancePath, 'original.pdf')))
|
||||
.digest('hex');
|
||||
if (archiveHash !== acceptance.source_checksums['original.pdf'])
|
||||
throw new Error(
|
||||
'Accepted INE archive hash mismatch; do not reuse acceptance',
|
||||
);
|
||||
const clean = (s) =>
|
||||
String(s ?? '')
|
||||
.replaceAll('|', '\\|')
|
||||
.replaceAll('\n', ' ');
|
||||
const lines = [
|
||||
'# Reconciliação das fontes e validações territoriais',
|
||||
'',
|
||||
'Critério do responsável: documentos oficiais são a única fonte de validação oficial. Programação, promotores, testes e leitura do cadastro não substituem esses documentos. Ausência de um campo de fonte/data é uma lacuna de rastreabilidade, não uma decisão de invalidade nem revogação.',
|
||||
'',
|
||||
'Esta revisão conserva os estados existentes. Uma referência oficial identificada não significa que todas as afirmações da ficha estejam cobertas: o âmbito da citação deve ser conservado. Não foram concedidas novas validações nem ativados cálculos. A declaração do responsável sobre a origem oficial dos dados em uso é registada; as pontes técnicas ainda incompletas são identificadas abaixo, sem contradizer essa declaração.',
|
||||
'',
|
||||
'## Evidência oficial e aceitação já existentes',
|
||||
'',
|
||||
`INE — Codificador da Divisão Político-Administrativa: exemplar privado com SHA-256 \`${archiveHash}\`, coincidente com a aceitação de ${acceptance.decision_date}. Decisão preservada: \`${acceptance.validation}\` e \`${acceptance.authenticity}\`, por instrução explícita do responsável. A aceitação não é reaberta. O próprio registo distingue-a da correspondência técnica: \`${acceptance.technical_mapping_status}\`; zero territórios importados por essa decisão. Não se atribui automaticamente esse PDF aos 1 921 nós oriundos do workbook.`,
|
||||
'',
|
||||
'As seis fontes de diplomas existentes conservam `validation_status=pending` tal como já estava na base; esse estado não foi imposto por esta entrega. As 14 fichas de zonas com `validated` conservam esse estado, distinguindo validação do cadastro de validação jurídica do diploma associado. Referências de promotores não passam a fonte oficial por a ficha estar validada.',
|
||||
'',
|
||||
'## Matriz por registo',
|
||||
'',
|
||||
'| Registo | Validação já registada, preservada | Fonte/citação recuperada | Lacuna específica de rastreabilidade |',
|
||||
'| --- | --- | --- | --- |',
|
||||
];
|
||||
for (const r of records) {
|
||||
const sources = [
|
||||
...(r.legal_source
|
||||
? [
|
||||
`${r.legal_source.diploma}; ${r.legal_source.title}; ${r.legal_source.article}; ${r.legal_source.url}; versão ${r.legal_source.version_no}`,
|
||||
]
|
||||
: []),
|
||||
...r.official_references.map(
|
||||
(s) => `${s.title}; ${s.url}; âmbito: ${s.supports}`,
|
||||
),
|
||||
...r.non_official_references.map(
|
||||
(s) =>
|
||||
`Referência ${s.source_type}, não oficial: ${s.title}; ${s.url}`,
|
||||
),
|
||||
...r.documentary_relations.map(
|
||||
(s) =>
|
||||
`${s.source_reference}; citação: ${s.source_quote}; dossier ${s.dossier_id}; auditoria ${s.applied_at || 'não identificada'}`,
|
||||
),
|
||||
...r.dossiers.map(
|
||||
(d) => `Dossier ${d.id}, versão ${d.version}: ${d.source}`,
|
||||
),
|
||||
];
|
||||
const state = `Cadastro: ${r.existing_record_validation ?? 'campo de validação não existente/não registado nesta entidade'}; fonte legal: ${r.existing_legal_validation ?? 'sem fonte legal ligada'}`;
|
||||
const gap =
|
||||
r.traceability === 'official_reference_identified'
|
||||
? 'Referência oficial recuperada; ligar exemplar/citação ao atributo quando essa ponte não consta do cadastro. Não repetir validação já evidenciada; não alargar o âmbito da fonte.'
|
||||
: r.traceability ===
|
||||
'documentary_bridge_to_official_source_to_recover'
|
||||
? 'Citação e histórico documentais recuperados; recuperar a ponte ao documento oficial utilizado. Não desvalidar a relação aplicada.'
|
||||
: 'Ligação ao exemplar oficial não identificada nos registos revistos; recuperar proveniência da importação. Ausência de ligação não prova ausência de fonte oficial.';
|
||||
lines.push(
|
||||
`| ${clean(r.reference)} | ${clean(state)} | ${clean(sources.join('; ') || 'Sem ponte documental no cadastro revisto')} | ${clean(gap)} |`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
'## Estados independentes e limites',
|
||||
'',
|
||||
'Consulta autorizada permanece independente da completude da referência. Validação oficial deriva apenas da evidência documental oficial no âmbito aceite; o estado cadastral é preservado e apresentado separadamente. Utilização em regras/cálculos permanece inalterada. Não se transforma a data de criação, data de aplicação ou início de vigência em data da fonte.',
|
||||
'',
|
||||
'O rótulo histórico de programação v4 foi preservado; a revisão anterior encontrou programação v5 corroborante, sem o exemplar v4. Esta diferença de rótulo é uma questão de proveniência, não uma revogação das 28 relações aplicadas. Programação não substitui o documento oficial na validação oficial.',
|
||||
'',
|
||||
'Nenhuma base ativa foi escrita. A matriz integral privada está em `territory-source-reconciliation.json`. Diferença de cobertura do inventário, questões jurídicas realmente não resolvidas, ClamD real e primeiro pedido real continuam separados.',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(privateDir, 'territory-source-reconciliation.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
captured_at: audit.captured_at,
|
||||
policy: 'official_documents_only_for_official_validation',
|
||||
owner_origin_declaration_preserved: true,
|
||||
ine_acceptance: acceptance,
|
||||
records,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'docs/investmoz/v2/reconciliacao-fontes-territoriais.md',
|
||||
),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
records: records.length,
|
||||
official_references_identified: records.filter(
|
||||
(r) => r.traceability === 'official_reference_identified',
|
||||
).length,
|
||||
record_validations_preserved: records.filter(
|
||||
(r) => r.existing_record_validation === 'validated',
|
||||
).length,
|
||||
accepted_ine_hash_matches: true,
|
||||
active_writes: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { reconcile } = require('./investmoz-v2-source-reconciliation.cjs');
|
||||
test('validated official source and record survive missing citation fields', () => {
|
||||
const result = reconcile(
|
||||
{
|
||||
kind: 'investment_zones',
|
||||
record: {
|
||||
id: 'z',
|
||||
validation_status: 'validated',
|
||||
legal_source_id: 'l',
|
||||
},
|
||||
},
|
||||
{
|
||||
dossiers: [],
|
||||
legal_sources: [
|
||||
{
|
||||
id: 'l',
|
||||
validation_status: 'validated',
|
||||
diploma: 'QA official fixture',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.equal(result.existing_record_validation, 'validated');
|
||||
assert.equal(result.existing_legal_validation, 'validated');
|
||||
assert.equal(result.validation_revoked, false);
|
||||
assert.equal(result.traceability, 'official_reference_identified');
|
||||
});
|
||||
test('missing source field is a traceability gap, not rejection', () => {
|
||||
const result = reconcile(
|
||||
{
|
||||
kind: 'projects',
|
||||
record: {
|
||||
id: 'p',
|
||||
metadata: JSON.stringify({
|
||||
documented_location_sources: {
|
||||
'x:primary': {
|
||||
source_quote: 'QA quotation',
|
||||
source_reference: 'QA dossier',
|
||||
applied_at: '2026-10-07',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ dossiers: [], legal_sources: [] },
|
||||
);
|
||||
assert.equal(
|
||||
result.traceability,
|
||||
'documentary_bridge_to_official_source_to_recover',
|
||||
);
|
||||
assert.equal(result.documentary_relations[0].source_quote, 'QA quotation');
|
||||
assert.equal(result.validation_revoked, false);
|
||||
assert.equal(result.calculation_activated, false);
|
||||
});
|
||||
test('promoter cannot become official through validated record', () => {
|
||||
const result = reconcile(
|
||||
{
|
||||
kind: 'investment_zones',
|
||||
record: {
|
||||
id: 'z',
|
||||
validation_status: 'validated',
|
||||
source_evidence: JSON.stringify([
|
||||
{ source_type: 'promoter', title: 'QA promoter' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
{ dossiers: [], legal_sources: [] },
|
||||
);
|
||||
assert.equal(result.existing_record_validation, 'validated');
|
||||
assert.equal(result.official_references.length, 0);
|
||||
assert.equal(result.official_validation_newly_granted, false);
|
||||
});
|
||||
test('existing legal pending is preserved without invalidating consultation', () => {
|
||||
const result = reconcile(
|
||||
{
|
||||
kind: 'investment_zones',
|
||||
record: {
|
||||
id: 'z',
|
||||
validation_status: 'validated',
|
||||
legal_source_id: 'l',
|
||||
},
|
||||
},
|
||||
{
|
||||
dossiers: [],
|
||||
legal_sources: [{ id: 'l', validation_status: 'pending' }],
|
||||
},
|
||||
);
|
||||
assert.equal(result.existing_record_validation, 'validated');
|
||||
assert.equal(result.existing_legal_validation, 'pending');
|
||||
assert.equal(result.validation_revoked, false);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Bootstrap\LoadConfiguration;
|
||||
use Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables;
|
||||
|
||||
// Read-only audit: no Laravel kernel, migrations, seeders or data updates.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
(new LoadEnvironmentVariables)->bootstrap($app);
|
||||
(new LoadConfiguration)->bootstrap($app);
|
||||
$c = $app->make('config')->get('database.connections.'.$app->make('config')->get('database.default'));
|
||||
if (($c['driver'] ?? '') !== 'pgsql' || ! in_array($c['url'] ?? null, [null, ''], true)) {
|
||||
throw new RuntimeException('Unsupported target');
|
||||
}
|
||||
$pdo = new PDO('pgsql:host='.$c['host'].';port='.$c['port'].';dbname='.$c['database'], $c['username'], $c['password'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$pdo->exec('BEGIN TRANSACTION READ ONLY');
|
||||
$proof = ['captured_at' => gmdate('c'), 'identity' => $pdo->query("SELECT current_database() AS database, inet_server_port() AS port, current_setting('transaction_read_only') AS read_only")->fetch(PDO::FETCH_ASSOC), 'records' => []];
|
||||
foreach (['opportunities', 'projects', 'economic_clusters', 'investment_zones'] as $table) {
|
||||
$rows = $pdo->query('SELECT * FROM '.$table.' WHERE deleted_at IS NULL ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$relations = [];
|
||||
$queries = match ($table) {
|
||||
'opportunities' => ['own' => 'SELECT geo_location_id FROM opportunity_locations WHERE opportunity_id=?', 'projects' => 'SELECT project_id FROM opportunity_projects WHERE opportunity_id=?', 'clusters' => 'SELECT cluster_id FROM opportunity_clusters WHERE opportunity_id=?'],
|
||||
'projects' => ['own' => 'SELECT * FROM project_locations WHERE project_id=? AND deleted_at IS NULL', 'clusters' => 'SELECT * FROM cluster_projects WHERE project_id=? AND deleted_at IS NULL'],
|
||||
'economic_clusters' => ['members' => 'SELECT * FROM cluster_projects WHERE cluster_id=? AND deleted_at IS NULL'],
|
||||
'investment_zones' => ['own' => 'SELECT * FROM investment_zone_locations WHERE zone_id=?', 'links' => 'SELECT * FROM investment_zone_links WHERE zone_id=?'],
|
||||
};
|
||||
foreach ($queries as $kind => $sql) {
|
||||
$statement = $pdo->prepare($sql);
|
||||
$statement->execute([$row['id']]);
|
||||
$relations[$kind] = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
$issues = [];
|
||||
$own = count($relations['own'] ?? []) + (empty($row['primary_territory_id']) ? 0 : 1);
|
||||
if ($table === 'opportunities') {
|
||||
if (! $own) {
|
||||
$issues[] = ($relations['projects'] || $relations['clusters']) ? 'context_only_not_implantation' : 'insufficient_territorial_information';
|
||||
}
|
||||
if ($own > 1) {
|
||||
$issues[] = 'multiple_links_review_distinct_ids';
|
||||
}
|
||||
$issues[] = 'direct_link_source_and_date_not_stored_in_pivot';
|
||||
}
|
||||
$proof['records'][] = ['kind' => $table, 'id' => $row['id'], 'record' => $row, 'relations' => $relations, 'findings' => $issues];
|
||||
}
|
||||
}
|
||||
$proof['geography'] = $pdo->query('SELECT g.id,g.name,g.location_type,g.parent_id,g.source_system,g.data_classification,
|
||||
p.location_type AS parent_type,p.deleted_at AS parent_deleted_at,
|
||||
(SELECT count(*) FROM geo_geometries s WHERE s.geo_location_id=g.id AND s.deleted_at IS NULL) AS geometry_count
|
||||
FROM geo_locations g LEFT JOIN geo_locations p ON p.id=g.parent_id WHERE g.deleted_at IS NULL ORDER BY g.id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['geometries'] = $pdo->query('SELECT id,geo_location_id,geometry_type,source_system,properties,data_classification,ST_IsValid(geom) AS valid,ST_IsEmpty(geom) AS empty,ST_SRID(geom) AS srid,ST_AsGeoJSON(geom) AS geometry FROM geo_geometries WHERE deleted_at IS NULL ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['dossiers'] = $pdo->query('SELECT id,project_id,cluster_id,title,source,content,version_no,publication_status FROM investment_dossiers WHERE deleted_at IS NULL ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['legal_sources'] = $pdo->query('SELECT id,title,url,diploma,article,competent_entity,validation_status,validated_at,version_no,valid_from,valid_until FROM investment_legal_sources ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['geo_sources'] = $pdo->query('SELECT id,name,responsible_entity,reference,checksum,source_version,status,approval_reference FROM geo_sources ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['geo_profiles'] = $pdo->query('SELECT geo_location_id,source_id,status,valid_from,valid_to FROM geo_territory_profiles ORDER BY geo_location_id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$proof['traceability_events'] = $pdo->query("SELECT id,action,occurred_at FROM audit_events WHERE source_system='territory-traceability-v2' ORDER BY occurred_at,id")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$pdo->exec('ROLLBACK');
|
||||
file_put_contents(dirname(__DIR__).'/storage/app/private/investmoz-v2/territory-audit.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['identity' => $proof['identity'], 'records' => count($proof['records']), 'territories' => count($proof['geography']), 'geometries' => count($proof['geometries'])], JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,239 @@
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const privateDir = path.resolve(
|
||||
__dirname,
|
||||
'../storage/app/private/investmoz-v2',
|
||||
);
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(privateDir, 'territory-qa-fixtures.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.equal(fixture.database, 'investmoz_v2_20261008_test');
|
||||
assert.equal(fixture.port, 55441);
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
const checks = [];
|
||||
try {
|
||||
for (const width of [1440, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 850 },
|
||||
});
|
||||
await context.route('**/*', (route) =>
|
||||
route.request().url().startsWith('http://127.0.0.1:18092')
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const writes = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
page.on('request', (r) => {
|
||||
if (!['GET', 'HEAD'].includes(r.method()))
|
||||
writes.push(r.method() + ' ' + r.url());
|
||||
});
|
||||
for (const [kind, record] of Object.entries(fixture.cases)) {
|
||||
const response = await page.goto(
|
||||
'http://127.0.0.1:18092/oportunidades/' + record.slug,
|
||||
);
|
||||
assert.equal(response.status(), 200);
|
||||
assert.match(
|
||||
response.headers()['x-investmoz-qa-target'] || '',
|
||||
/investmoz_v2_20261008_test/,
|
||||
);
|
||||
try {
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Implantação declarada da oportunidade',
|
||||
})
|
||||
.waitFor({ timeout: 10000 });
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
privateDir,
|
||||
'territory-browser-diagnostic.json',
|
||||
),
|
||||
JSON.stringify(
|
||||
{
|
||||
url: page.url(),
|
||||
errors,
|
||||
body: await page.locator('body').innerText(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const map = await page.request.get(
|
||||
'http://127.0.0.1:18092/api/v1/investment/map?opportunity_id=' +
|
||||
record.id,
|
||||
);
|
||||
const data = await map.json();
|
||||
const expected =
|
||||
kind === 'multiple'
|
||||
? 2
|
||||
: ['direct', 'context'].includes(kind)
|
||||
? 1
|
||||
: 0;
|
||||
assert.equal(data.features.length, expected);
|
||||
const section = page
|
||||
.locator('section')
|
||||
.filter({
|
||||
has: page.getByRole('heading', {
|
||||
name: 'Implantação declarada da oportunidade',
|
||||
exact: true,
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
if (['context', 'insufficient'].includes(kind))
|
||||
assert.match(await section.innerText(), /não documentada/);
|
||||
if (kind === 'context') {
|
||||
assert.equal(
|
||||
data.features[0].properties.location_basis,
|
||||
'linked_project_site',
|
||||
);
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: 'Consultar dossier: Territory QA dossier',
|
||||
})
|
||||
.click();
|
||||
await page.waitForURL('**/carteira/' + fixture.dossier);
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: record.title || 'Territory QA context',
|
||||
exact: true,
|
||||
})
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
'**/oportunidades/' + record.slug + '**',
|
||||
);
|
||||
}
|
||||
if (kind === 'multiple') {
|
||||
const buttons = page.getByRole('button', {
|
||||
name: 'Territory QA multiple',
|
||||
exact: true,
|
||||
});
|
||||
await buttons.first().waitFor();
|
||||
assert.equal(await buttons.count(), 2);
|
||||
await buttons.nth(0).click();
|
||||
await buttons.nth(1).click();
|
||||
assert.equal(
|
||||
await buttons.nth(1).getAttribute('aria-pressed'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await buttons.nth(0).getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
}
|
||||
if (kind === 'direct') {
|
||||
assert.equal(data.areas.length, 1);
|
||||
await page
|
||||
.getByText(
|
||||
/Polígono cadastrado, limites oficiais por verificar/,
|
||||
)
|
||||
.first()
|
||||
.waitFor();
|
||||
}
|
||||
if (kind === 'without-point') {
|
||||
await section
|
||||
.getByText('Coordenadas não documentadas.')
|
||||
.waitFor();
|
||||
await section
|
||||
.getByRole('link', {
|
||||
name: 'Pesquisar oportunidades neste território',
|
||||
})
|
||||
.click();
|
||||
await page.waitForURL(
|
||||
'**/oportunidades?geo_location_id=' + fixture.district,
|
||||
);
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: 'Territory QA without-point',
|
||||
exact: true,
|
||||
})
|
||||
.first()
|
||||
.waitFor();
|
||||
}
|
||||
checks.push({
|
||||
width,
|
||||
kind,
|
||||
markers: data.features.length,
|
||||
areas: data.areas.length,
|
||||
passed: true,
|
||||
});
|
||||
}
|
||||
await page.goto('http://127.0.0.1:18092/oportunidades');
|
||||
await page
|
||||
.getByLabel('Base da relação territorial')
|
||||
.selectOption('context');
|
||||
await page
|
||||
.getByRole('button', { name: 'Aplicar filtros', exact: true })
|
||||
.click();
|
||||
await page.waitForURL('**territory_basis=context**');
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: 'Territory QA context',
|
||||
exact: true,
|
||||
})
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('link', {
|
||||
name: 'Territory QA direct',
|
||||
exact: true,
|
||||
})
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page
|
||||
.getByRole('status')
|
||||
.filter({ hasText: 'locais com coordenadas disponíveis.' })
|
||||
.first()
|
||||
.waitFor();
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(writes, []);
|
||||
await page.screenshot({
|
||||
path: path.join(
|
||||
privateDir,
|
||||
'territory-browser-' + width + '.png',
|
||||
),
|
||||
fullPage: true,
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(privateDir, 'territory-browser.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
database: fixture.database,
|
||||
port: fixture.port,
|
||||
external_requests_blocked: true,
|
||||
checks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({ passed: true, checks: checks.length, writes: 0 }),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InvestmentDossier;
|
||||
use App\Models\Opportunity;
|
||||
use App\Models\Project;
|
||||
use App\Models\Sector;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => 'investmoz_v2_20261008_test', 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => ''] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
$app = require dirname(__DIR__).'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$connection = DB::connection();
|
||||
if ($connection->getDatabaseName() !== 'investmoz_v2_20261008_test' || (string) $connection->getConfig('port') !== '55441' || $connection->getConfig('host') !== '127.0.0.1' || ! in_array($connection->getConfig('url'), [null, ''], true)) {
|
||||
throw new RuntimeException('QA configuration required');
|
||||
}
|
||||
$identity = DB::selectOne('SELECT current_database() AS name,inet_server_port() AS port');
|
||||
if ($identity->name !== 'investmoz_v2_20261008_test' || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('QA identity required');
|
||||
}
|
||||
$manifest = DB::transaction(function () {
|
||||
$nodes = [];
|
||||
$parent = null;
|
||||
foreach (['country', 'province', 'district', 'admin_post', 'locality', 'site'] as $type) {
|
||||
$id = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Territory QA '.$type, 'location_type' => $type, 'parent_id' => $parent, 'data_classification' => 'public',
|
||||
'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
|
||||
$nodes[$type] = $id;
|
||||
$parent = $id;
|
||||
}
|
||||
$second = (string) Str::uuid();
|
||||
DB::table('geo_locations')->insert(['id' => $second, 'code' => $second, 'name' => 'Territory QA second site', 'location_type' => 'site', 'parent_id' => $nodes['locality'], 'data_classification' => 'public']);
|
||||
foreach ([$nodes['site'], $second] as $index => $id) {
|
||||
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $id, 'geometry_type' => 'point', 'data_classification' => 'public', 'source_system' => 'Synthetic QA coordinates; no real source',
|
||||
'geom' => DB::raw('ST_SetSRID(ST_MakePoint('.(32.5 + $index / 10).',-25.9),4326)'), 'properties' => json_encode(['source_date' => '2026-10-08'])]);
|
||||
}
|
||||
DB::table('geo_geometries')->insert(['id' => (string) Str::uuid(), 'geo_location_id' => $nodes['site'], 'geometry_type' => 'polygon', 'data_classification' => 'public', 'source_system' => 'Synthetic QA boundary; not official',
|
||||
'geom' => DB::raw("ST_GeomFromText('POLYGON((32 -26,33 -26,33 -25,32 -25,32 -26))',4326)"), 'properties' => json_encode(['source_date' => '2026-10-08'])]);
|
||||
$project = Project::factory()->create(['name' => 'Territory QA project', 'status' => 'draft', 'data_classification' => 'internal']);
|
||||
$dossier = InvestmentDossier::create(['project_id' => $project->id, 'title' => 'Territory QA dossier', 'source' => 'Synthetic QA source', 'content' => ['location' => 'Synthetic QA site'], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic isolated QA disclosure']);
|
||||
DB::table('project_locations')->insert(['id' => (string) Str::uuid(), 'project_id' => $project->id, 'geo_location_id' => $nodes['site'], 'role' => 'primary', 'data_classification' => 'public', 'source_system' => 'Synthetic QA source']);
|
||||
DB::table('project_locations')->insert(['id' => (string) Str::uuid(), 'project_id' => $project->id, 'geo_location_id' => $nodes['district'], 'role' => 'impact', 'data_classification' => 'public', 'source_system' => 'Synthetic QA impact']);
|
||||
$sector = Sector::firstOrCreate(['slug' => 'territory-qa'], ['name' => 'Territory QA', 'is_active' => true]);
|
||||
$cases = [];
|
||||
foreach (['direct', 'context', 'multiple', 'insufficient', 'without-point'] as $kind) {
|
||||
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'TERR-QA-'.Str::uuid(), 'title' => 'Territory QA '.$kind, 'slug' => 'territory-qa-'.$kind.'-'.Str::uuid(), 'status' => 'published', 'published_at' => now()->subMinute()]);
|
||||
if ($kind === 'context') {
|
||||
DB::table('opportunity_projects')->insert(['opportunity_id' => $opportunity->id, 'project_id' => $project->id]);
|
||||
}
|
||||
foreach (match ($kind) {
|
||||
'direct' => [$nodes['site']], 'multiple' => [$nodes['site'], $second], 'without-point' => [$nodes['district']], default => []
|
||||
} as $id) {
|
||||
DB::table('opportunity_locations')->insert(['opportunity_id' => $opportunity->id, 'geo_location_id' => $id]);
|
||||
}
|
||||
$cases[$kind] = ['id' => $opportunity->id, 'slug' => $opportunity->slug];
|
||||
}
|
||||
|
||||
return ['database' => 'investmoz_v2_20261008_test', 'port' => 55441, 'cases' => $cases, 'dossier' => $dossier->id, 'district' => $nodes['district']];
|
||||
});
|
||||
file_put_contents(dirname(__DIR__).'/storage/app/private/investmoz-v2/territory-qa-fixtures.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($manifest, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,216 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { reconcile } = require('./investmoz-v2-source-reconciliation.cjs');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const audit = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'storage/app/private/investmoz-v2/territory-audit.json',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
if (audit.identity.read_only !== 'on')
|
||||
throw new Error('Read-only evidence required');
|
||||
const geo = new Map(audit.geography.map((g) => [g.id, g]));
|
||||
const clean = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('|', '\\|')
|
||||
.replaceAll('\n', ' ');
|
||||
const lineage = (id) => {
|
||||
const visited = new Set();
|
||||
const nodes = [];
|
||||
const issues = [];
|
||||
while (id) {
|
||||
if (visited.has(id)) {
|
||||
issues.push('ciclo');
|
||||
break;
|
||||
}
|
||||
visited.add(id);
|
||||
const g = geo.get(id);
|
||||
if (!g) {
|
||||
issues.push('nó/antepassado ausente');
|
||||
break;
|
||||
}
|
||||
nodes.unshift(g);
|
||||
id = g.parent_id;
|
||||
}
|
||||
const ranks = [
|
||||
'country',
|
||||
'province',
|
||||
'district',
|
||||
'admin_post',
|
||||
'locality',
|
||||
'site',
|
||||
];
|
||||
for (let i = 1; i < nodes.length; i++) {
|
||||
if (
|
||||
nodes[i].location_type !== 'site' &&
|
||||
ranks.indexOf(nodes[i].location_type) !==
|
||||
ranks.indexOf(nodes[i - 1].location_type) + 1
|
||||
)
|
||||
issues.push('hierarquia incompatível');
|
||||
}
|
||||
return { names: nodes.map((g) => g.name).join(' → '), issues };
|
||||
};
|
||||
const lines = [
|
||||
'# Qualidade territorial e relações — entrega V2',
|
||||
'',
|
||||
`Leitura protegida em ${audit.captured_at}, base ativa ${audit.identity.database}:${audit.identity.port}, transação READ ONLY terminada por ROLLBACK. Nenhum dado ativo alterado. Evidência completa privada: \`territory-audit.json\`.`,
|
||||
'',
|
||||
'## Resultados por registo e propostas de dados',
|
||||
'',
|
||||
'Cada linha identifica o estado cadastrado, a fonte consultada, motivo e impacto da proposta. Documentos oficiais são a única fonte de validação oficial. Ausência de campos de fonte/data é uma lacuna de rastreabilidade, não invalidade ou revogação. Estados e aceitações existentes são preservados na [matriz de reconciliação](reconciliacao-fontes-territoriais.md). Sem associação explícita não se cria uma relação por nome semelhante, cluster ou proximidade.',
|
||||
'',
|
||||
'| Registo | Relações/localizações cadastradas | Fonte consultada | Proposta, motivo e impacto |',
|
||||
'| --- | --- | --- | --- |',
|
||||
];
|
||||
const result = [];
|
||||
for (const item of audit.records) {
|
||||
const r = item.record;
|
||||
const reconciled = reconcile(item, audit);
|
||||
const metadata = JSON.parse(r.metadata || '{}');
|
||||
const own = (item.relations.own || []).map((l) => ({
|
||||
id: l.geo_location_id,
|
||||
role: l.role || l.scope_type || 'declarada',
|
||||
source:
|
||||
metadata.documented_location_sources?.[
|
||||
l.geo_location_id + ':' + l.role
|
||||
]?.source_reference ||
|
||||
l.source_reference ||
|
||||
l.source_system ||
|
||||
null,
|
||||
}));
|
||||
if (
|
||||
r.primary_territory_id &&
|
||||
!own.some((l) => l.id === r.primary_territory_id)
|
||||
)
|
||||
own.unshift({
|
||||
id: r.primary_territory_id,
|
||||
role: 'principal cadastrada',
|
||||
source: r.source_system || null,
|
||||
});
|
||||
for (const c of metadata.documented_coverage || [])
|
||||
own.push({
|
||||
id: c.id,
|
||||
role: 'abrangência',
|
||||
source: c.source_reference || null,
|
||||
});
|
||||
const locations = own.map((l) => ({ ...l, ...lineage(l.id) }));
|
||||
const dossiers = audit.dossiers.filter(
|
||||
(d) => d.project_id === r.id || d.cluster_id === r.id,
|
||||
);
|
||||
const source =
|
||||
[
|
||||
...new Set(
|
||||
[
|
||||
r.source_system,
|
||||
...dossiers.map((d) => d.source),
|
||||
...locations.map((l) => l.source),
|
||||
].filter(Boolean),
|
||||
),
|
||||
].join('; ') ||
|
||||
'Cadastro da oportunidade; fonte da relação e data ausentes';
|
||||
const proposed = [];
|
||||
if (item.kind === 'opportunities')
|
||||
proposed.push(
|
||||
'Recuperar a ligação ao documento oficial utilizado e a data/citação no cadastro, sem desvalidar o dado; associações a projetos/clusters ausentes: recuperar a relação documentada, sem criar vínculos automaticamente',
|
||||
);
|
||||
if (!own.length)
|
||||
proposed.push(
|
||||
'Sem localização estruturada: preservar descrição e obter identificação documental; não estimar ponto',
|
||||
);
|
||||
if (locations.some((l) => l.issues.length))
|
||||
proposed.push(
|
||||
'Rever hierarquia indicada na evidência; não mudar parent_id automaticamente',
|
||||
);
|
||||
if (own.length > 1)
|
||||
proposed.push(
|
||||
'Preservar locais/papéis distintos; não selecionar um único local nem calcular centroide',
|
||||
);
|
||||
if (item.kind === 'economic_clusters')
|
||||
proposed.push(
|
||||
'Abrangência do cluster distinta dos locais dos membros; não deduzir perímetro',
|
||||
);
|
||||
if (item.kind === 'investment_zones')
|
||||
proposed.push(
|
||||
'Preservar a validação registada e ligar fonte/citação ao atributo no respetivo âmbito; localização/regime não prova outros atributos como disponibilidade ou capacidade',
|
||||
);
|
||||
if (!proposed.length)
|
||||
proposed.push(
|
||||
'Preservar relação documental e validações existentes; completar a rastreabilidade da evidência/data quando ausente no cadastro',
|
||||
);
|
||||
proposed.push(
|
||||
'Coordenadas/polígonos não cadastrados nesta leitura; ficha continua consultável',
|
||||
);
|
||||
const label = r.code || r.reference || String(r.id);
|
||||
const relations =
|
||||
locations
|
||||
.map(
|
||||
(l) =>
|
||||
`${l.role}: ${l.names || l.id}${l.issues.length ? ' [' + l.issues.join(', ') + ']' : ''}`,
|
||||
)
|
||||
.join('; ') || 'Sem localização estruturada';
|
||||
const counts = `projetos: ${(item.relations.projects || []).length}; clusters: ${(item.relations.clusters || []).length}; membros: ${(item.relations.members || []).length}; ligações de zona: ${(item.relations.links || []).length}`;
|
||||
lines.push(
|
||||
`| ${clean(label)} | ${clean(relations + '; ' + counts)} | ${clean(source + '; cadastro: ' + (reconciled.existing_record_validation ?? 'sem campo/estado registado') + '; fonte legal: ' + (reconciled.existing_legal_validation ?? 'sem ligação no cadastro'))} | ${clean(proposed.join('; '))} |`,
|
||||
);
|
||||
result.push({
|
||||
kind: item.kind,
|
||||
id: r.id,
|
||||
reference: label,
|
||||
locations,
|
||||
proposals: proposed,
|
||||
source_reconciliation: reconciled,
|
||||
});
|
||||
}
|
||||
const hierarchy = audit.geography
|
||||
.map((g) => ({ id: g.id, ...lineage(g.id) }))
|
||||
.filter((g) => g.issues.length);
|
||||
lines.push(
|
||||
'',
|
||||
'## Hierarquia e cartografia',
|
||||
'',
|
||||
`${audit.geography.length} nós administrativos/locais percorridos; ${hierarchy.length} caminhos com inconsistência estrutural identificada por esta verificação. Ausência de inconsistência cadastral não certifica fronteiras oficiais. ${audit.geometries.length} geometrias ativas cadastradas: nenhum ponto, polígono ou centroide gerado.`,
|
||||
'',
|
||||
'## Correções de código e QA',
|
||||
'',
|
||||
'- Ficha apresenta implantação declarada separada dos locais/impacto dos projetos e abrangência dos clusters; fonte/data ausentes explícitas.',
|
||||
'- Filtros distinguem implantação declarada de apenas contexto associado; localizações sem coordenadas continuam pesquisáveis.',
|
||||
'- Mapa mantém vários locais do mesmo investimento e seleção independente; áreas cadastradas são apresentadas com proveniência, sem homologar limites.',
|
||||
'- Associação existente continua a permitir navegação oportunidade → dossier → oportunidade e território → pesquisa. Permissões e relações revogadas preservadas.',
|
||||
'',
|
||||
'Ver resultados de QA no backlog e na matriz de reconciliação. Propostas desta tabela não são alterações aplicadas nem plano de escrita aprovado; não revogam validações documentais existentes.',
|
||||
'',
|
||||
'## Pendências independentes',
|
||||
'',
|
||||
'Diferença de cobertura do inventário documental, validação jurídica/documental das fontes, ClamD real e primeiro pedido real no Balcão permanecem separados. Nenhum teste territorial conclui essas pendências. Aplicação futura de dados exige backup/restauro confirmado e ação autenticada existente.',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'docs/investmoz/v2/qualidade-territorial.md'),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
root,
|
||||
'storage/app/private/investmoz-v2/territory-proposals.json',
|
||||
),
|
||||
JSON.stringify(
|
||||
{
|
||||
captured_at: audit.captured_at,
|
||||
applied: false,
|
||||
records: result,
|
||||
hierarchy,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
records: result.length,
|
||||
hierarchy_issues: hierarchy.length,
|
||||
geometry_count: audit.geometries.length,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
// Reuse the existing read-only active backup and isolated restore procedure.
|
||||
require __DIR__.'/desk-activation-backup.php';
|
||||
$proof = json_decode(file_get_contents(dirname(__DIR__).'/storage/app/private/virtual-desk/backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$proof['reversal_verified'] = false;
|
||||
file_put_contents(dirname(__DIR__).'/storage/app/private/investmoz-v2/territory-backup-proof.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Services\InvestmentTerritorialTraceability;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// Only the restored, isolated backup can be mutated by this compatibility rehearsal.
|
||||
$root = dirname(__DIR__);
|
||||
$directory = $root.'/storage/app/private/investmoz-v2';
|
||||
$proofFile = $directory.'/territory-backup-proof.json';
|
||||
$proof = json_decode(file_get_contents($proofFile), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441 || ! $proof['restore_verified']) {
|
||||
throw new RuntimeException('Verified isolated restoration required.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$connection = config('database.connections.pgsql');
|
||||
if (config('database.default') !== 'pgsql' || $connection['host'] !== '127.0.0.1' || (int) $connection['port'] !== 55441 || $connection['database'] !== $target['database'] || ! empty($connection['url'])) {
|
||||
throw new RuntimeException('Refusing non-QA configuration.');
|
||||
}
|
||||
$identity = DB::selectOne('SELECT current_database() AS database, inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Refusing non-QA connection.');
|
||||
}
|
||||
$qaDirectory = $directory.'/qa-traceability-reversal';
|
||||
if (! is_dir($qaDirectory)) {
|
||||
mkdir($qaDirectory, 0700, true);
|
||||
}
|
||||
$activeBytes = file_get_contents($directory.'/territory-activation-plan.json');
|
||||
$activePlan = json_decode($activeBytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
$plan = $activePlan;
|
||||
$plan['target'] = $target;
|
||||
$qaBytes = json_encode($plan, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
$qaHash = hash('sha256', $qaBytes);
|
||||
file_put_contents($qaDirectory.'/territory-activation-plan.json', $qaBytes);
|
||||
// This fixture authorizes the QA rehearsal only; it is not evidence of a completed reversal.
|
||||
$qaProof = [...$proof, 'target' => $target, 'reversal_verified' => true, 'plan_hash' => $qaHash, 'code_hash' => $plan['code_hash'], 'fixture_only' => true];
|
||||
file_put_contents($qaDirectory.'/territory-backup-proof.json', json_encode($qaProof, JSON_THROW_ON_ERROR));
|
||||
config(['investment.traceability_directory' => $qaDirectory]);
|
||||
$service = app(InvestmentTerritorialTraceability::class);
|
||||
$snapshot = fn () => DB::table('cluster_projects')->orderBy('id')->get(['id', 'source_reference', 'evidence_reference', 'validation_status', 'association_type', 'version_no'])->map(fn ($row) => (array) $row)->all();
|
||||
$before = $snapshot();
|
||||
$unchangedTables = ['projects', 'opportunities', 'economic_clusters', 'investment_zones', 'investment_legal_sources', 'investment_dossiers', 'project_locations', 'opportunity_projects', 'opportunity_clusters', 'geo_locations', 'geo_geometries'];
|
||||
$hashes = fn () => array_combine($unchangedTables, array_map(fn ($table) => hash('sha256', json_encode(DB::table($table)->orderByRaw('to_jsonb('.$table.')::text')->get(), JSON_THROW_ON_ERROR)), $unchangedTables));
|
||||
$beforeHashes = $hashes();
|
||||
$actor = DB::table('users')->insertGetId(['name' => 'Territory reversal QA', 'email' => 'territory-reversal-'.bin2hex(random_bytes(5)).'@example.invalid', 'password' => password_hash('QA-only', PASSWORD_BCRYPT), 'created_at' => now(), 'updated_at' => now()]);
|
||||
$applied = $service->apply($qaHash, $actor);
|
||||
$replayed = $service->apply($qaHash, $actor);
|
||||
if ($applied['changed'] !== count($plan['updates']) || ! $replayed['replayed'] || $replayed['changed'] !== 0) {
|
||||
throw new RuntimeException('Application/idempotence failed.');
|
||||
}
|
||||
$sources = $service->sources('economic_clusters', $plan['rows'][array_search('economic_clusters', array_column($plan['rows'], 'kind'))]['record_id']);
|
||||
$reversed = $service->apply($qaHash, $actor, true);
|
||||
$after = $snapshot();
|
||||
foreach ($after as $index => $row) {
|
||||
$expected = $before[$index];
|
||||
$changed = in_array($row['id'], array_column($plan['updates'], 'id'), true);
|
||||
$expected['version_no'] += $changed ? 2 : 0;
|
||||
if ($row !== $expected) {
|
||||
throw new RuntimeException('Reversal did not preserve values, validation or monotonic version.');
|
||||
}
|
||||
}
|
||||
if ($hashes() !== $beforeHashes || $reversed['changed'] !== count($plan['updates']) || ! $sources) {
|
||||
throw new RuntimeException('Unrelated data changed or sources not presented.');
|
||||
}
|
||||
$evidence = ['verified_at' => gmdate('c'), 'target' => $target, 'plan_hash' => hash('sha256', $activeBytes), 'code_hash' => $plan['code_hash'], 'applied' => $applied, 'replayed' => $replayed, 'reversed' => $reversed, 'source_projection_count' => count($sources), 'unchanged_tables' => $unchangedTables, 'validation_states_preserved' => true, 'versions_monotonic' => true, 'active_writes' => 0];
|
||||
file_put_contents($directory.'/territory-reversal-evidence.json', json_encode($evidence, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
$proof['reversal_verified'] = true;
|
||||
$proof['plan_hash'] = $evidence['plan_hash'];
|
||||
$proof['code_hash'] = $evidence['code_hash'];
|
||||
$proof['reversal_evidence'] = 'territory-reversal-evidence.json';
|
||||
file_put_contents($proofFile, json_encode($proof, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
echo json_encode($evidence, JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,180 @@
|
||||
const {
|
||||
chromium,
|
||||
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const directory = path.resolve(
|
||||
__dirname,
|
||||
'../storage/app/private/investmoz-v2',
|
||||
);
|
||||
const plan = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
directory,
|
||||
'qa-traceability-browser/territory-activation-plan.json',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert.match(plan.target.database, /^desk_restore_\d{8}_\d{6}_test$/);
|
||||
assert.equal(plan.target.port, 55441);
|
||||
const base = 'http://127.0.0.1:18093';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 950 },
|
||||
});
|
||||
await context.route('**/*', (route) =>
|
||||
route.request().url().startsWith(base)
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const writes = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST')
|
||||
writes.push(new URL(request.url()).pathname);
|
||||
});
|
||||
const response = await page.goto(base + '/login');
|
||||
assert.equal(
|
||||
response.headers()['x-investmoz-qa-target'],
|
||||
plan.target.database + ':55441',
|
||||
);
|
||||
await page
|
||||
.locator('input[name=email]')
|
||||
.fill('territory-browser@example.invalid');
|
||||
await page.locator('input[name=password]').fill('Territory-QA-2026!');
|
||||
await page.getByRole('button', { name: 'Log in', exact: true }).click();
|
||||
await page.waitForURL((url) => url.pathname !== '/login');
|
||||
await page.goto(base + '/management/relationship-repair');
|
||||
const apply = page.getByRole('button', {
|
||||
name: 'Aplicar rastreabilidade territorial V2',
|
||||
exact: true,
|
||||
});
|
||||
await apply.waitFor();
|
||||
assert.equal(await apply.isEnabled(), true);
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/112 associações\/fontes/,
|
||||
);
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/13 atualizações de referências/,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(directory, 'territory-activation-preview.png'),
|
||||
});
|
||||
const post = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().endsWith('/management/relationship-repair'),
|
||||
);
|
||||
await apply.click();
|
||||
assert.equal((await post).status(), 302);
|
||||
const revert = page.getByRole('button', {
|
||||
name: 'Reverter este incremento de rastreabilidade',
|
||||
exact: true,
|
||||
});
|
||||
try {
|
||||
await revert.waitFor({ timeout: 8000 });
|
||||
} catch (error) {
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'territory-browser-failure.txt'),
|
||||
await page.locator('body').innerText(),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/Rastreabilidade aplicada; validações e dados territoriais preservados/,
|
||||
);
|
||||
await page.reload();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reverter este incremento de rastreabilidade',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
const row = plan.rows.find(
|
||||
(row) =>
|
||||
row.kind === 'economic_clusters' &&
|
||||
row.dossier_id &&
|
||||
row.association_id,
|
||||
);
|
||||
await page.goto(base + '/carteira/' + row.dossier_id);
|
||||
await page
|
||||
.getByRole('heading', {
|
||||
name: 'Fundamento documental das associações',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/citação recuperada; ponte ao exemplar oficial por recuperar/,
|
||||
);
|
||||
await page.goto(base + '/management/relationship-repair');
|
||||
const reversal = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().endsWith('/management/relationship-repair'),
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reverter este incremento de rastreabilidade',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.equal((await reversal).status(), 302);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Aplicar rastreabilidade territorial V2',
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.match(
|
||||
await page.locator('body').innerText(),
|
||||
/Rastreabilidade revertida, histórico preservado/,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(
|
||||
writes.filter((path) => path !== '/login'),
|
||||
[
|
||||
'/management/relationship-repair',
|
||||
'/management/relationship-repair',
|
||||
],
|
||||
);
|
||||
const evidence = {
|
||||
recorded_at: new Date().toISOString(),
|
||||
target: plan.target,
|
||||
checks: [
|
||||
'authenticated preview',
|
||||
'apply exact V2 button',
|
||||
'HTTP 302 and visible success',
|
||||
'recovery after reload',
|
||||
'source per association on public dossier',
|
||||
'revert exact increment',
|
||||
'no console errors',
|
||||
],
|
||||
writes,
|
||||
active_writes: 0,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(directory, 'territory-activation-browser.json'),
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
console.log(JSON.stringify(evidence));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$directory = $root.'/storage/app/private/investmoz-v2';
|
||||
$proof = json_decode(file_get_contents($directory.'/territory-backup-proof.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$target = $proof['restored_target'];
|
||||
if (! preg_match('/^desk_restore_\d{8}_\d{6}_test$/', $target['database']) || (int) $target['port'] !== 55441) {
|
||||
throw new RuntimeException('Isolated restored QA target required.');
|
||||
}
|
||||
foreach (['APP_ENV' => 'testing', 'APP_DEBUG' => 'true', 'APP_URL' => 'http://127.0.0.1:18093', 'APP_KEY' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', 'DB_CONNECTION' => 'pgsql', 'DB_HOST' => '127.0.0.1', 'DB_PORT' => '55441', 'DB_DATABASE' => $target['database'], 'DB_USERNAME' => 'postgres', 'DB_PASSWORD' => '', 'DB_URL' => '', 'SESSION_DRIVER' => 'file', 'SESSION_COOKIE' => 'territory_traceability_qa', 'CACHE_STORE' => 'array', 'QUEUE_CONNECTION' => 'sync'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
require $root.'/vendor/autoload.php';
|
||||
$app = require $root.'/bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$c = config('database.connections.pgsql');
|
||||
if (config('database.default') !== 'pgsql' || $c['host'] !== '127.0.0.1' || (int) $c['port'] !== 55441 || $c['database'] !== $target['database'] || ! empty($c['url'])) {
|
||||
throw new RuntimeException('Refusing non-QA configuration.');
|
||||
}
|
||||
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
|
||||
if ($identity->database !== $target['database'] || (int) $identity->port !== 55441) {
|
||||
throw new RuntimeException('Refusing non-QA connection.');
|
||||
}
|
||||
$qa = $directory.'/qa-traceability-browser';
|
||||
if (! is_dir($qa.'/sessions')) {
|
||||
mkdir($qa.'/sessions', 0700, true);
|
||||
}
|
||||
config(['session.files' => $qa.'/sessions', 'portal.managers' => ['territory-browser@example.invalid'], 'investment.traceability_directory' => $qa, 'investment.repair_directory' => $qa, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]);
|
||||
Vite::useHotFile($root.'/storage/framework/territory-qa-absent-hot');
|
||||
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === 'seed') {
|
||||
$user = User::updateOrCreate(['email' => 'territory-browser@example.invalid'], ['name' => 'Territory browser QA', 'password' => Hash::make('Territory-QA-2026!'), 'email_verified_at' => now()]);
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$plan = json_decode(file_get_contents($directory.'/territory-activation-plan.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$plan['target'] = $target;
|
||||
$plan['prepared_at'] = gmdate('c');
|
||||
foreach ($plan['guards'] as &$guard) {
|
||||
if ($guard['table'] === 'cluster_projects') {
|
||||
$row = DB::table('cluster_projects')->where('id', $guard['id'])->first();
|
||||
foreach (array_keys($guard['expected']) as $field) {
|
||||
$guard['expected'][$field] = $row->$field;
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($guard);
|
||||
$bytes = json_encode($plan, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
|
||||
file_put_contents($qa.'/territory-activation-plan.json', $bytes);
|
||||
file_put_contents($qa.'/territory-backup-proof.json', json_encode([...$proof, 'target' => $target, 'plan_hash' => hash('sha256', $bytes), 'fixture_only' => true], JSON_THROW_ON_ERROR));
|
||||
file_put_contents($qa.'/preview.json', json_encode(['target' => $target, 'locations' => [], 'memberships' => [], 'pending' => [], 'prepared_opportunities' => []], JSON_THROW_ON_ERROR));
|
||||
echo json_encode(['target' => $target, 'seeded_qa_user' => $user->id, 'active_writes' => 0]).PHP_EOL;
|
||||
exit;
|
||||
}
|
||||
$path = rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/');
|
||||
$asset = realpath($root.'/public'.$path);
|
||||
$public = realpath($root.'/public');
|
||||
if ($path !== '/' && $asset && $public && str_starts_with($asset, $public.DIRECTORY_SEPARATOR) && is_file($asset)) {
|
||||
return false;
|
||||
}
|
||||
header('X-Investmoz-QA-Target: '.$identity->database.':'.$identity->port);
|
||||
$app->handleRequest(Request::capture());
|
||||
Reference in New Issue
Block a user