varias alteracoes

This commit is contained in:
2026-09-29 08:41:57 +02:00
parent b77714b156
commit 2e0217b486
51 changed files with 2605 additions and 270 deletions
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Services\TerritorialCsvAudit;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Throwable;
class AuditTerritorialCsv extends Command
{
protected $signature = 'geo:audit-csv {path : CSV UTF-8 do contrato INE}';
protected $description = 'Arquivar e validar um CSV territorial sem alterar o cadastro';
public function handle(TerritorialCsvAudit $audit): int
{
$path = (string) $this->argument('path');
if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) {
$this->error('CSV inacessível ou superior a 20 MiB.');
return self::FAILURE;
}
$directory = storage_path('app/private/geo-csv-audits/'.Str::uuid());
File::ensureDirectoryExists($directory, 0700);
try {
if (! copy($path, $directory.'/original.csv')) {
throw new \RuntimeException('Não foi possível arquivar o original.');
}
$codes = DB::table('geo_locations as p')->join('geo_locations as c', 'p.parent_id', '=', 'c.id')
->where('p.location_type', 'province')->where('c.location_type', 'country')->where('c.code', '508')
->whereNull('p.deleted_at')->whereNull('c.deleted_at')->pluck('p.code')->map(fn ($code) => (string) $code)->all();
$report = $audit->inspect($directory.'/original.csv', array_values($codes));
} catch (Throwable $exception) {
$report = ['status' => 'rejected', 'error' => $exception->getMessage()];
}
File::put($directory.'/report.json', json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$this->info('Relatório privado: '.$directory.'/report.json');
$this->info('Nenhum território foi criado ou aprovado.');
return $report['status'] === 'rejected' ? self::FAILURE : self::SUCCESS;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InvestmentCoordinatesController extends Controller
{
public function show(int $id): JsonResponse
{
Opportunity::findOrFail($id);
$point = DB::table('geo_locations as g')->join('geo_geometries as s', 's.geo_location_id', '=', 'g.id')
->where('g.source_system', 'investment-site-editor')->where('g.external_id', (string) $id)->whereNull('g.deleted_at')->whereNull('s.deleted_at')
->first(['s.version_no', 's.properties', DB::raw('ST_X(s.geom) as longitude'), DB::raw('ST_Y(s.geom) as latitude')]);
return response()->json(['version' => $point->version_no ?? 0, 'latitude' => $point?->latitude, 'longitude' => $point?->longitude,
'source_reference' => $point ? (json_decode($point->properties, true)['source_reference'] ?? '') : '']);
}
public function save(Request $request, int $id): JsonResponse
{
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'], 'source_reference' => ['required', 'string', 'min:10', 'max:2000'],
'confirmed_site' => ['accepted']]);
$version = DB::transaction(function () use ($data, $id, $request) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$opportunity = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$location = DB::table('geo_locations')->where('source_system', 'investment-site-editor')->where('external_id', (string) $id)->first();
abort_if($location && $location->deleted_at, 409, 'Local arquivado; requer reconciliação.');
abort_if($location && $location->data_classification !== 'public', 409, 'Local retirado de divulgação; requer revisão da classificação.');
$geometry = $location ? DB::table('geo_geometries')->where('geo_location_id', $location->id)->where('geometry_type', 'point')->first() : null;
abort_if($geometry && $geometry->deleted_at, 409, 'Geometria arquivada; requer reconciliação.');
abort_if($geometry && $geometry->data_classification !== 'public', 409, 'Geometria retirada de divulgação; requer revisão da classificação.');
abort_unless((int) ($geometry->version_no ?? 0) === $data['version'], 409, 'Coordenadas alteradas. Recarregue.');
$locationId = $location->id ?? (string) Str::uuid();
if (! $location) {
DB::table('geo_locations')->insert(['id' => $locationId, 'source_system' => 'investment-site-editor', 'external_id' => (string) $id,
'name' => $opportunity->title, 'location_type' => 'site', 'code' => 'SITE-OPP-'.$id, 'data_classification' => 'public', 'parent_id' => $opportunity->primary_territory_id]);
}
$version = $data['version'] + 1;
$geometryId = $geometry->id ?? (string) Str::uuid();
DB::statement("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,data_classification,version_no,properties)
VALUES(?,?,'point',ST_SetSRID(ST_MakePoint(?,?),4326),'public',?,?::jsonb)
ON CONFLICT(id) DO UPDATE SET geom=EXCLUDED.geom,version_no=EXCLUDED.version_no,properties=EXCLUDED.properties,updated_at=now()",
[$geometryId, $locationId, $data['longitude'], $data['latitude'], $version, json_encode(['source_reference' => $data['source_reference'], 'accuracy' => 'site_confirmed_by_editor'], JSON_THROW_ON_ERROR)]);
DB::table('opportunity_locations')->updateOrInsert(['opportunity_id' => $id, 'geo_location_id' => $locationId], []);
DB::table('audit_events')->insert(['source_system' => 'investment-coordinates', 'entity_type' => 'geo_geometries', 'entity_id' => $geometryId,
'actor_user_id' => $request->user()->id, 'action' => 'site_coordinates_saved',
'before_data' => $geometry ? json_encode(['version' => $geometry->version_no, 'geometry_hex' => $geometry->geom], JSON_THROW_ON_ERROR) : null,
'after_data' => json_encode(['opportunity_id' => $id, 'version' => $version, 'latitude' => $data['latitude'], 'longitude' => $data['longitude'], 'source_sha256' => hash('sha256', $data['source_reference'])], JSON_THROW_ON_ERROR)]);
return $version;
});
return response()->json(['version' => $version]);
}
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\InvestmentDossier;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZones;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
@@ -24,6 +25,7 @@ class InvestmentDiscoveryController extends Controller
}
return Inertia::render('investment/catalogue', ['filters' => $filters, 'references' => $catalogue->references(),
'territorySelection' => app(InvestmentGeography::class)->selection($filters),
'results' => $catalogue->query($filters)->paginate(12)->withQueryString(),
'favourites' => $request->user() ? DB::table('opportunity_favourites')->where('user_id', $request->user()->id)->pluck('opportunity_id') : [],
'territorial' => $this->territorial($filters)]);
@@ -36,6 +38,15 @@ class InvestmentDiscoveryController extends Controller
private function territorial(array $filters): Collection
{
$query = InvestmentPortfolioController::visible();
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->where(function ($query) use ($ids) {
foreach (['project_id' => 'projects', 'cluster_id' => 'economic_clusters', 'corridor_id' => 'development_corridors'] as $foreign => $table) {
$query->orWhereIn($foreign, DB::table($table)->whereNull('deleted_at')->whereIn('primary_territory_id', $ids)->select('id'));
}
});
}
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
return $query->whereRaw('false')->get();
}
@@ -7,6 +7,7 @@ use App\Models\EconomicCluster;
use App\Models\InvestmentGap;
use App\Models\Project;
use App\Models\ValueChain;
use App\Services\InvestmentGeography;
use App\Services\PortalAudit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -74,7 +75,19 @@ class InvestmentStructureController extends Controller
} else {
$rules['sector'] = ['required', 'string', 'max:200'];
}
PortalAudit::save($record, $request->validate($rules), $request->user()->id);
if (! $id) {
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
}
$data = $request->validate($rules);
if (! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
}
PortalAudit::save($record, $data, $request->user()->id);
return redirect('/management/'.$structure.'/'.$record->getKey().'/edit')->with('success', 'Registo guardado.');
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class InvestmentTerritoryController extends Controller
{
private const TABLES = ['opportunities' => 'opportunities', 'projects' => 'projects', 'clusters' => 'economic_clusters', 'corridors' => 'development_corridors', 'zones' => 'investment_zones'];
public function subjects(Request $request): JsonResponse
{
$data = $request->validate(['kind' => ['required', 'in:'.implode(',', array_keys(self::TABLES))], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$column = $data['kind'] === 'opportunities' ? 'title' : 'name';
$query = DB::table(self::TABLES[$data['kind']])->whereNull('deleted_at');
if (! empty($data['q'])) {
$query->whereRaw($column.' ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy($column)->orderBy('id')->paginate(50, ['id', $column.' as name']));
}
public function options(Request $request): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type'])
->whereIn('data_classification', ['public', 'internal']);
if ($data['type'] !== 'province') {
$query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
}
public function show(string $kind, string $id): JsonResponse
{
$table = self::TABLES[$kind] ?? abort(404);
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
abort_unless($record !== null, 404);
$path = [];
$current = $record->primary_territory_id;
for ($i = 0; $current && $i < 5; $i++) {
$location = DB::table('geo_locations')->where('id', $current)->first(['id', 'name', 'parent_id', 'location_type', 'deleted_at']);
if (! $location) {
break;
}
$path[] = $location;
$current = $location->parent_id;
}
return response()->json(['version' => $record->territory_version, 'path' => $path]);
}
public function save(Request $request, string $kind, string $id): JsonResponse
{
$table = self::TABLES[$kind] ?? abort(404);
if ($kind === 'zones') {
abort_unless($request->user()->two_factor_confirmed_at && $request->user()->two_factor_secret, 403);
}
$data = $request->validate(['version' => ['required', 'integer', 'min:0'], 'path' => ['present', 'array', 'max:4'], 'path.*' => ['required', 'uuid', 'distinct']]);
$version = DB::transaction(function () use ($request, $table, $id, $data) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$record = DB::table($table)->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_unless($record !== null, 404);
abort_unless((int) $record->territory_version === $data['version'], 409, 'Localização alterada. Recarregue a ficha.');
$parent = null;
$names = [];
foreach (array_values($data['path']) as $index => $locationId) {
$location = DB::table('geo_locations')->where('id', $locationId)->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->first();
if (! $location || $location->location_type !== ['province', 'district', 'admin_post', 'locality'][$index] || ($index > 0 && $location->parent_id !== $parent)) {
throw ValidationException::withMessages(['path' => 'Seleccione uma hierarquia territorial coerente e activa.']);
}
$parent = $locationId;
$names[] = $location->name;
}
$version = $data['version'] + 1;
$changes = ['primary_territory_id' => $parent, 'territory_version' => $version, 'updated_at' => now()];
if (in_array($table, ['opportunities', 'investment_zones'], true)) {
$changes['province'] = $names[0] ?? null;
$changes['district'] = $names[1] ?? null;
}
if ($table === 'investment_zones') {
app(InvestmentZones::class)->save($id, [...$changes, 'version_no' => $record->version_no], $request->user()->id);
} else {
DB::table($table)->where('id', $id)->update($changes);
}
DB::table('audit_events')->insert(['source_system' => 'investment-territory', 'entity_type' => $table,
'actor_user_id' => $request->user()->id, 'action' => 'primary_territory_saved',
'after_data' => json_encode(['record_id' => $id, 'territory_id' => $parent, 'version' => $version], JSON_THROW_ON_ERROR)]);
return $version;
});
return response()->json(['version' => $version]);
}
}
@@ -6,6 +6,7 @@ use App\Models\InvestmentDossier;
use App\Models\InvestmentZone;
use App\Models\Opportunity;
use App\Services\InvestmentCatalogue;
use App\Services\InvestmentGeography;
use App\Services\InvestmentZoneAccess;
use App\Services\InvestmentZones;
use Illuminate\Http\JsonResponse;
@@ -49,13 +50,18 @@ class InvestmentZoneController extends Controller
public function index(Request $request, InvestmentZones $zones): Response|JsonResponse
{
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
$filters = $request->validate(['category' => ['nullable', 'in:zee,zfi,industrial_park'], 'q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1'],
'province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$management = $request->is('management/*', 'api/v2/investment/admin/*');
$query = $management ? InvestmentZone::query() : $zones->visible();
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$query->whereIn('primary_territory_id', app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id));
}
$rows = $query->when($filters['category'] ?? null, fn ($q, $v) => $q->where('category', $v))
->when($filters['q'] ?? null, fn ($q, $v) => $q->where(fn ($q) => $q->where('name', 'ilike', '%'.$v.'%')->orWhere('code', 'ilike', '%'.$v.'%')))
->orderBy('name')->orderBy('id')->paginate(12, $management ? ['*'] : ['id', 'code', 'name', 'name_en', 'category', 'province', 'district', 'description', 'description_en', 'validation_status', 'updated_at'])->withQueryString();
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management];
$props = ['records' => $rows, 'filters' => $filters, 'management' => $management, 'territorySelection' => $path];
return $request->is('api/*') ? response()->json($props) : Inertia::render('investment/zones', $props);
}
@@ -70,6 +76,15 @@ class InvestmentZoneController extends Controller
'operator_id' => ['nullable', 'uuid', Rule::exists('organizations', 'id')->whereNull('deleted_at')],
'legal_source_id' => ['nullable', 'uuid', 'exists:investment_legal_sources,id']]);
if (! $id && $request->has('province_id')) {
$geo = $request->validate(['province_id' => ['nullable', 'uuid'], 'district_id' => ['nullable', 'uuid'], 'admin_post_id' => ['nullable', 'uuid'], 'locality_id' => ['nullable', 'uuid']]);
$path = app(InvestmentGeography::class)->selection($geo);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
}
return response()->json($zones->save($id, $data, $request->user()->id), $id ? 200 : 201);
}
@@ -6,6 +6,7 @@ use App\Models\InterestRequest;
use App\Models\InvestmentSubmission;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentGeography;
use App\Services\InvestmentWorkspace;
use App\Services\PortalAudit;
use Illuminate\Http\JsonResponse;
@@ -177,7 +178,20 @@ class PortalManagementController extends Controller
'status' => ['required', Rule::in(['draft', 'published', 'archived'])],
'is_featured' => ['required', 'boolean'],
];
if ($resource === 'opportunities' && ! $id) {
foreach (InvestmentGeography::FIELDS as $field => $type) {
$rules[$field] = ['nullable', 'uuid'];
}
}
$data = $request->validate($rules);
if ($resource === 'opportunities' && ! $id && $request->has('province_id')) {
$path = app(InvestmentGeography::class)->selection($data);
$data = array_diff_key($data, InvestmentGeography::FIELDS);
$data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id;
$data['territory_version'] = $path === [] ? 0 : 1;
$data['province'] = $path[0]->name ?? null;
$data['district'] = $path[1]->name ?? null;
}
if ($resource === 'opportunities' && $data['status'] === 'published' && $record->exists
&& Schema::hasTable('education_investment_proposals')
&& DB::table('education_investment_proposals')->where('opportunity_id', $record->id)->exists()) {
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Services\InvestmentGeography;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PublicGeographyController extends Controller
{
public function options(Request $request, InvestmentGeography $geography): JsonResponse
{
$data = $request->validate(['type' => ['required', 'in:province,district,admin_post,locality'], 'parent_id' => ['nullable', 'uuid'], 'q' => ['nullable', 'string', 'max:100'], 'page' => ['nullable', 'integer', 'min:1']]);
$query = $geography->publicReferences()->where('location_type', $data['type']);
if ($data['type'] !== 'province') {
$parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']];
$parent = $geography->publicReferences()->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->where('location_type', $parentType)->value('id');
$query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000');
}
if (! empty($data['q'])) {
$query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']);
}
return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type']));
}
}
+2
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/** @property string|null $primary_territory_id */
class Opportunity extends Model
{
use SoftDeletes;
@@ -18,6 +19,7 @@ class Opportunity extends Model
'estimated_investment_currency', 'financing_modality',
'expected_impact', 'status', 'is_featured', 'published_at',
'last_validated_at',
'primary_territory_id', 'territory_version',
];
protected $casts = [
+54 -8
View File
@@ -16,7 +16,9 @@ class InvestmentCatalogue
/** @return array<string,mixed> */
public function filters(Request $request): array
{
return $request->validate(['q' => ['nullable', 'string', 'max:120'], 'sector' => ['nullable', 'string', 'max:80'],
$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'],
'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'],
@@ -28,6 +30,9 @@ class InvestmentCatalogue
'currency' => ['nullable', 'required_with:min_amount,max_amount', 'in:MZN,USD,EUR,ZAR'],
'before' => ['nullable', 'date_format:Y-m-d'], 'sort' => ['nullable', 'in:recent,title,amount_asc,amount_desc'],
'per_page' => ['nullable', 'integer', 'between:1,50'], 'page' => ['nullable', 'integer', 'min:1']]);
app(InvestmentGeography::class)->selection($filters);
return $filters;
}
/**
@@ -37,6 +42,14 @@ class InvestmentCatalogue
public function query(array $filters): Builder
{
$query = Opportunity::published()->with('sector:id,name,slug');
if (! empty($filters['opportunity_id'])) {
$query->whereKey($filters['opportunity_id']);
}
$path = app(InvestmentGeography::class)->selection($filters);
if ($path !== []) {
$ids = app(InvestmentGeography::class)->descendants($path[array_key_last($path)]->id);
$query->whereIn('primary_territory_id', $ids);
}
if (in_array($filters['dimension'] ?? null, ['zee', 'zfi'], true)) {
$query->whereIn('opportunities.id', app(InvestmentZones::class)->links()->join('investment_item_opportunities as mapping', 'mapping.item_id', '=', 'zl.item_id')
->join('investment_zones as z', 'z.id', '=', 'zl.zone_id')->where('z.category', $filters['dimension'])->select('mapping.opportunity_id'));
@@ -147,6 +160,7 @@ class InvestmentCatalogue
$result['zones'] = app(InvestmentZones::class)->links()->join('investment_item_opportunities as m', 'm.item_id', '=', 'zl.item_id')
->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'));
return $result;
}
@@ -157,14 +171,46 @@ class InvestmentCatalogue
*/
public function map(array $filters): array
{
$ids = $this->query($filters)->reorder()->select('opportunities.id');
$points = DB::table('opportunity_locations as l')->join('geo_locations as g', 'g.id', '=', 'l.geo_location_id')
->join('geo_geometries as shape', 'shape.geo_location_id', '=', 'g.id')->join('opportunities as o', 'o.id', '=', 'l.opportunity_id')
$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) {
$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'));
})->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')
->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')->limit(1000)
->get(['o.id', 'o.title', 'o.slug', 'g.name', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')]);
->where('g.location_type', 'site')->whereNull('g.deleted_at')->whereNull('shape.deleted_at')->where('shape.geometry_type', 'point')
->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')
->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', DB::raw('ST_AsGeoJSON(shape.geom) as geometry')])->unique('id')->keyBy('id');
$relations = [];
foreach (['clusters', 'corridors', 'projects'] as $relation) {
[$foreign, $table] = self::RELATIONS[$relation];
$relations[$relation] = DB::table('opportunity_'.$relation.' as l')->join($table.' as t', 't.id', '=', 'l.'.$foreign)
->whereIn('l.opportunity_id', $ids)->whereNull('t.deleted_at')
->whereIn('t.id', InvestmentPortfolioController::visible()->whereNotNull($foreign)->select($foreign))
->get(['l.opportunity_id', 't.name'])->groupBy('opportunity_id');
}
$features = $missing = [];
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' => trim($properties['location'].' — '.$point->name)]];
} else {
$missing[] = [...$properties, 'reason' => 'missing_public_site_coordinates'];
}
}
return ['type' => 'FeatureCollection', 'limit' => 1000, 'features' => $points->map(fn ($p) => ['type' => 'Feature',
'geometry' => json_decode($p->geometry, true), 'properties' => ['id' => $p->id, 'title' => $p->title, 'location' => $p->name, 'url' => '/oportunidades/'.$p->slug]])->all()];
return ['type' => 'FeatureCollection', 'limit' => 1000, 'matched_count' => $total, 'truncated' => $total > $records->count(),
'georeferenced_count' => count($features), 'unlocated' => $missing, 'features' => $features,
'complete' => $total === count($features)];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Services;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class InvestmentGeography
{
public const FIELDS = ['province_id' => 'province', 'district_id' => 'district', 'admin_post_id' => 'admin_post', 'locality_id' => 'locality'];
public function publicReferences(): Builder
{
return DB::table('geo_locations')->whereNull('deleted_at')->where('data_classification', 'public')
->whereRaw("NOT EXISTS (WITH RECURSIVE ancestors AS (
SELECT id,parent_id,deleted_at,data_classification,ARRAY[id] AS visited,false AS cycle FROM geo_locations a WHERE a.id=geo_locations.id
UNION ALL SELECT a.id,a.parent_id,a.deleted_at,a.data_classification,p.visited||a.id,a.id=ANY(p.visited)
FROM geo_locations a JOIN ancestors p ON a.id=p.parent_id WHERE NOT p.cycle
) SELECT 1 FROM ancestors WHERE deleted_at IS NOT NULL OR data_classification<>'public' OR cycle)")
->where(function ($query) {
$query->where(function ($workbook) {
$workbook->where('source_system', 'cartografia-workbook')
->where('external_id', 'like', '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:%');
})->orWhereIn('id', app(TerritorialRegistry::class)->published()->select('g.id'));
});
}
/** @param array<string,mixed> $input
* @return list<\stdClass>
*/
public function selection(array $input): array
{
$path = [];
$parent = null;
$gap = false;
foreach (self::FIELDS as $field => $type) {
if (empty($input[$field])) {
$gap = true;
continue;
}
$node = $this->publicReferences()->where('id', $input[$field])->where('location_type', $type)->first(['id', 'name', 'parent_id', 'location_type']);
if ($gap || ! $node || ($parent && $node->parent_id !== $parent)) {
throw ValidationException::withMessages([$field => 'Seleccione a localização dentro da hierarquia indicada.']);
}
$path[] = $node;
$parent = $node->id;
}
return $path;
}
/** @return list<string> */
public function descendants(string $id): array
{
$rows = DB::select('WITH RECURSIVE tree AS (
SELECT id, ARRAY[id] AS visited FROM geo_locations WHERE id=? AND deleted_at IS NULL
UNION ALL SELECT g.id, tree.visited||g.id FROM geo_locations g JOIN tree ON g.parent_id=tree.id
WHERE g.deleted_at IS NULL AND NOT g.id=ANY(tree.visited) AND cardinality(tree.visited)<5
) SELECT id FROM tree', [$id]);
return array_values($this->publicReferences()->whereIn('id', array_map(fn ($row) => $row->id, $rows))->pluck('id')->map(fn ($id) => (string) $id)->all());
}
/** @return list<\stdClass> */
public function path(?string $id): array
{
$path = [];
for ($i = 0; $id && $i < 5; $i++) {
$node = $this->publicReferences()->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type']);
if (! $node) {
return [];
}
if ($node->location_type !== 'country') {
array_unshift($path, $node);
}
$id = $node->parent_id;
}
return $path;
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ class PortalAudit
$record->setRawAttributes($locked->getAttributes(), true);
}
// Exclude free text and personal data from the immutable event payload.
$fields = ['version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level',
$fields = ['primary_territory_id', 'territory_version', 'version_no', 'status', 'is_active', 'is_featured', 'sort_order', 'sector_id', 'maturity_level',
'estimated_investment_amount', 'estimated_investment_currency', 'owner_user_id', 'assignee_user_id', 'due_at',
'cluster_id', 'project_id', 'corridor_id', 'dossier_id', 'interest_request_id', 'publication_status', 'editor_id', 'reviewer_id', 'role', 'stage', 'value_chain_id', 'from_value_chain_id', 'to_value_chain_id', 'link_type', 'deleted_at'];
$before = array_intersect_key($record->getAttributes(), array_flip($fields));
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use RuntimeException;
/** Offline structural validation. This never approves a source or writes territories. */
final class TerritorialCsvAudit
{
public const HEADERS = ['province_code', 'province_name', 'district_code', 'district_name', 'admin_post_code', 'admin_post_name', 'locality_code', 'locality_name', 'locality_type', 'latitude', 'longitude'];
/** @param list<string> $provinceCodes
* @return array<string,mixed>
*/
public function inspect(string $path, array $provinceCodes): array
{
if (! is_file($path) || ! is_readable($path) || filesize($path) > 20 * 1024 * 1024) {
throw new RuntimeException('CSV inacessível ou superior a 20 MiB.');
}
$bytes = file_get_contents($path);
if ($bytes === false || ! mb_check_encoding($bytes, 'UTF-8') || str_contains($bytes, "\0")) {
throw new RuntimeException('O CSV deve ser UTF-8 sem bytes nulos.');
}
// Parse the exact bytes hashed, avoiding a second read of a changing input.
$stream = fopen('php://temp', 'w+');
if ($stream === false) {
throw new RuntimeException('Não foi possível preparar a leitura.');
}
fwrite($stream, preg_replace('/^\xEF\xBB\xBF/', '', $bytes) ?? $bytes);
rewind($stream);
try {
if (fgetcsv($stream, null, ';', '"', '') !== self::HEADERS) {
throw new RuntimeException('Cabeçalho inválido: usar o contrato INE com separador ponto e vírgula.');
}
$errors = $seen = $names = $totals = [];
$duplicates = $count = 0;
while (($values = fgetcsv($stream, null, ';', '"', '')) !== false) {
$count++;
if ($count > 50000) {
throw new RuntimeException('O CSV excede 50.000 registos.');
}
$issues = [];
if (count($values) !== count(self::HEADERS)) {
$errors[] = ['record' => $count + 1, 'issues' => ['column_count']];
continue;
}
$row = array_combine(self::HEADERS, array_map(fn ($v) => trim((string) $v), $values));
foreach ($row as $field => $value) {
if (mb_strlen($value) > 255 || preg_match('/[\x00-\x1F\x7F]/', $value)) {
$issues[] = 'invalid_field:'.$field;
}
}
if (! preg_match('/^[0-9]{2}$/D', $row['province_code']) || ! in_array($row['province_code'], $provinceCodes, true)) {
$issues[] = 'unknown_province';
}
foreach (['province', 'district', 'admin_post', 'locality'] as $kind) {
$code = $row[$kind.'_code'];
$name = $row[$kind.'_name'];
if (($kind === 'province' || $kind === 'district' || $code !== '') && $name === '') {
$issues[] = 'missing_name:'.$kind;
}
if (($kind === 'province' || $kind === 'district' || $name !== '') && $code === '') {
$issues[] = 'missing_code:'.$kind;
}
if ($code === '') {
continue;
}
if (! preg_match('/^[0-9]+$/D', $code)) {
$issues[] = 'invalid_code:'.$kind;
}
$parent = match ($kind) {
'province' => 'MZ', 'district' => $row['province_code'],
'admin_post' => $row['district_code'], default => $row['admin_post_code'],
};
$identity = $kind.':'.$code;
$definition = [$parent, $name];
if ($kind === 'locality') {
$definition = [...$definition, $row['locality_type'], $row['latitude'], $row['longitude']];
}
if (isset($seen[$identity]) && $seen[$identity] !== $definition) {
$issues[] = 'conflicting_code:'.$kind;
}
$seen[$identity] ??= $definition;
$nameKey = $kind.':'.$parent.':'.mb_strtolower($name);
if (isset($names[$nameKey]) && $names[$nameKey] !== $code) {
$issues[] = 'ambiguous_name:'.$kind;
}
$names[$nameKey] ??= $code;
$totals[$row['province_code']][$kind][$code] = true;
}
if ($row['locality_code'] !== '' && $row['admin_post_code'] === '') {
$issues[] = 'locality_requires_reviewed_parent_exception';
}
if (($row['latitude'] === '') !== ($row['longitude'] === '')) {
$issues[] = 'incomplete_coordinates';
}
foreach (['latitude' => 90, 'longitude' => 180] as $field => $bound) {
if ($row[$field] !== '' && (! preg_match('/^-?[0-9]+(?:\.[0-9]+)?$/D', $row[$field]) || abs((float) $row[$field]) > $bound)) {
$issues[] = 'invalid_coordinate:'.$field;
}
}
if ($row['locality_code'] === '' && ($row['latitude'] !== '' || $row['longitude'] !== '' || $row['locality_type'] !== '')) {
$issues[] = 'locality_metadata_without_locality';
}
$fingerprint = hash('sha256', json_encode($row, JSON_THROW_ON_ERROR));
if (isset($seen['row:'.$fingerprint])) {
$duplicates++;
}
$seen['row:'.$fingerprint] = [];
if ($issues !== []) {
$errors[] = ['record' => $count + 1, 'issues' => array_values(array_unique($issues))];
}
}
if ($count === 0) {
$errors[] = ['record' => 1, 'issues' => ['empty_dataset']];
}
ksort($totals);
$counts = [];
foreach ($totals as $province => $kinds) {
$counts[$province] = array_map('count', $kinds);
}
return ['schema_version' => 1, 'sha256' => hash('sha256', $bytes), 'encoding' => 'UTF-8',
'delimiter' => ';', 'coordinate_crs' => 'EPSG:4326', 'records' => $count,
'duplicate_records' => $duplicates, 'rejected_records' => count($errors),
'status' => $errors === [] ? 'structurally_valid_requires_review' : 'rejected',
'candidate_counts_by_province' => $counts, 'errors' => $errors];
} finally {
fclose($stream);
}
}
}
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
foreach (['opportunities', 'projects', 'economic_clusters', 'development_corridors', 'investment_zones'] as $table) {
Schema::table($table, function (Blueprint $t) {
$t->foreignUuid('primary_territory_id')->nullable()->constrained('geo_locations')->restrictOnDelete();
$t->unsignedInteger('territory_version')->default(0);
});
}
}
public function down(): void
{
$tables = ['opportunities', 'projects', 'economic_clusters', 'development_corridors', 'investment_zones'];
foreach ($tables as $table) {
if (Schema::hasColumn($table, 'primary_territory_id') && DB::table($table)->whereNotNull('primary_territory_id')->exists()) {
throw new RuntimeException('Preserve territorial assignments before rollback.');
}
}
foreach ($tables as $table) {
if (Schema::hasColumn($table, 'primary_territory_id')) {
Schema::table($table, function (Blueprint $t) {
$t->dropConstrainedForeignId('primary_territory_id');
$t->dropColumn('territory_version');
});
}
}
}
};
+42
View File
@@ -0,0 +1,42 @@
# Revisão das entregas e omissões — 29/09/2026
As instruções de implementação e avanço entre fases estão autorizadas. Não é necessária nova autorização genérica. Este inventário distingue funcionalidades entregues de trabalho por terminar; não substitui a verificação integral de cada requisito dos anexos.
## Falhas confirmadas e corrigidas nesta revisão
1. O Excel foi inicialmente interpretado de forma errada: B:G são códigos, H é a designação do nível mais profundo da linha. Os dropdowns receberam códigos como nomes. Corrigida a transformação, preservando UUID; 1.920 nomes reparados, 69 referências sem definição inequívoca retiradas das opções e preservadas para auditoria. A contagem anterior de 1.990 não provava qualidade ou completude.
2. Os dropdowns só estavam na gestão. Acrescentados ao catálogo público, ao filtro de zonas e à criação de oportunidades, clusters/projectos e zonas. Removidos os inputs livres de província/distrito das fichas de edição de oportunidades e zonas; alterações de localização usam os identificadores.
3. As associações guardadas não eram usadas pela pesquisa pública. A pesquisa passa a usar o território seleccionado e os seus descendentes, com validação da hierarquia. A pesquisa da carteira considera a localização dos projectos/clusters/corredores ligados aos dossiers publicados.
4. As seis oportunidades existentes estavam sem relação territorial. Foram associadas às províncias por correspondência única de nome, normalizando apenas caixa, espaços exteriores e acentuação. Não se inferiram distritos a partir de descrições.
5. O envio do filtro pelo navegador falhava: `education=false` em texto não passava a validação booleana e `filters=[]` fazia a leitura de `.sort` devolver o método nativo JavaScript. Corrigidos serialização 0/1 e valor inicial da ordenação; teste real de selecção, submissão e recarregamento passou.
6. Alterar a localização de uma zona podia deixar a versão documental intacta. A alteração passa pelo serviço da zona, cria snapshot e devolve o registo a rascunho.
## Dados e disponibilidade actual
Referências com nomes efectivamente extraídos: **11 províncias, 148 distritos, 437 postos administrativos e 1.324 localidades**, além do país. Estes totais referem-se ao Excel recebido; não são declaração de cobertura administrativa actual do país. Dez linhas foram sinalizadas por conflitos/lacunas; 18.477 linhas de bairros/aldeias não são convertidas em localidades. As 69 referências anteriores sem definição inequívoca ficaram arquivadas, sem quebra de FK.
Disponível localmente em `/oportunidades`, `/zonas`, nas páginas de criação/edição aplicáveis e em `/management/territory-associations`. Filtro público expõe apenas ID/nome/tipo/pai das referências divulgadas, não os ficheiros originais. Publicação do vocabulário alfanumérico por instrução do responsável não equivale a publicação de limites geográficos homologados. Os dados confidenciais/restritos e ascendentes retirados não entram nas opções públicas.
Backup/restauro verificado antes desta correcção: `storage/app/private/zones-backup-20260929-005946/`, 113 tabelas. Relatórios e candidatos anteriores permanecem privados. A reparação não introduziu nova migration nem apagou referências fisicamente.
## Trabalho que continua por terminar
| Área | Lacuna confirmada nos relatórios/código | Trabalho necessário |
| --- | --- | --- |
| GIS completo | Limites geográficos versionados, transformação/validação de geometrias, endpoints resolve/near/bbox e cobertura espacial não entregues integralmente | Implementar serviços e contratos; carregar geometrias apenas quando fornecidas, sem inventar coordenadas |
| Integração territorial transversal | A nova associação é principal; os consumidores educativos, organizações e restantes formulários não usam ainda todos o mesmo selector; relações múltiplas legadas continuam separadas | Consolidar consumidores e testar cada jornada, incluindo históricos e remoção de referências |
| Importação territorial | CSV auditado, mas não existe ainda o workflow integral de batches/rejeições/aprovação/carga; PDF histórico tem colunas ambíguas | Terminar pipeline, excepções documentadas e reconciliação dos códigos; não tratar aprovação como reparação automática de dados contraditórios |
| IA | Base lexical/determinística; não há modelo generativo, embeddings, reranker ou benchmark generativo | Implementar RAG e avaliação com corpus autorizado; documentar recursos e fornecedor efectivamente usados |
| Mercado de Negócios | Preparação/submissão privada e objectos tipados; publicação, revisão completa, sala documental segura e integrações não concluídas | Implementar os estados/jornadas restantes, acesso documental, scanner, retenção e testes |
| ZEE/ZFI | Núcleo e filtros existentes; cadastro local de zonas continua vazio; modelo integral de actividades, benefícios, obrigações, lotes e indicadores não entregue | Completar domínio e importar conteúdos disponíveis com origem; não substituir dados ausentes por zonas fictícias |
| Educação | Há implementação parcial; metodologia, consumidores, integração e piloto não estão integralmente entregues | Confrontar decisões posteriores com o relatório histórico, executar lacunas técnicas e testar o fluxo completo |
| CRM/integrações | A integração externa real com SuiteCRM e a consolidação da outra máquina não foram executadas | Validar ligação quando disponível; a consolidação da outra máquina foi expressamente adiada pelo utilizador |
| Operação/QA | Testes automáticos e alguns percursos de navegador não cobrem todos os prompts; carga, acessibilidade assistiva e produção não demonstradas | Matriz de aceitação por jornada, testes com dados reais, ensaios operacionais e disponibilização no destino configurado |
Estas lacunas não são todas bloqueios de autorização. A maior parte corresponde a implementação que ficou por fazer. Dependências de dados, de ligações externas e de recursos de execução devem ser identificadas separadamente; não servem para suspender trabalho independente já autorizado.
## Evidência desta correcção
`tools/territory-public-browser.mjs` verifica o portal local sem escrever dados: 11 províncias com nomes, 17 opções de distrito para Zambezia, duas oportunidades correspondentes, filtro no URL preservado após recarregar, larguras 390/1.440 px sem overflow e zero erros JavaScript. A suite PHP cobre autorização, referências privadas, hierarquia, criação, associação, conflitos de versão e pesquisa. Os totais finais são registados no relatório GIS.
Não declarar os prompts integralmente concluídos enquanto a matriz acima contiver lacunas. A disponibilidade de uma tabela, de um endpoint ou de um teste isolado não equivale à entrega da jornada pedida pelo utilizador.
+10
View File
@@ -1,5 +1,15 @@
# Cartografia GIS — execução incremental
**Mapa interactivo:** [implementação, testes e cobertura real](interactive-map.md). Catálogo e fichas usam marcadores, zoom e filtros reactivos. A base contém seis oportunidades públicas e zero coordenadas de local; o critério de georreferenciação integral ainda não está cumprido.
**Correcção de 29/09/2026:** a extracção anterior confundiu códigos com nomes. Reparados os nomes a partir de «Designação» e arquivadas 69 referências sem definição inequívoca. Dropdowns públicos e filtros ligados às associações agora disponíveis em `/oportunidades` e `/zonas`. Contagens válidas: 11 províncias, 148 distritos, 437 postos, 1.324 localidades. Ver [omissões, correcções e trabalho restante](../delivery-gaps.md).
**29/09/2026 — dropdowns aplicados:** [selecção em cascata e associação aos investimentos](investment-dropdowns.md). Disponível em `/management/territory-associations` e nas fichas de gestão. Excel carregado: 11 províncias, 150 distritos, 451 postos e 1.377 localidades; relações guardadas por UUID.
Aceitação do responsável aplicada ao pacote INE: declaração de dados validados e autenticados registada na auditoria local e vinculada aos checksums. Ver [decisão e limites técnicos](ine-import-status.md#aceitação-expressa-do-responsável--aplicada). A carga de territórios continua dependente da resolução das colunas desalinhadas.
Novo anexo: [preparação da importação INE, evidências e fases pendentes](ine-import-status.md). Auditoria CSV e extracção para revisão entregues; a carga territorial integral continua por implementar.
Actualização: recebido o novo documento do modelo territorial, com autorização integral. Foi implementado o [núcleo do registo territorial e APIs editoriais](territorial-mvp.md), sem duplicar a identidade de geo_locations. O histórico abaixo descreve a auditoria anterior; não representa a cobertura integral do novo MVP.
## Inspecção do Excel indicado pelo utilizador
+68
View File
@@ -0,0 +1,68 @@
# Preparação da importação INE — 28/09/2026
Execução parcial do novo anexo; a autorização para todas as fases está registada. Este incremento entrega validação offline, não o importador transaccional completo.
## Aceitação expressa do responsável — aplicada
Em 28/09/2026, o utilizador instruiu: «aplicar os resultados e aceite os dados como validados e autenticados». A decisão foi aplicada ao pacote INE como **aceite, validado e autenticado por declaração do responsável**, vinculada aos checksums do PDF e da extracção. Não é uma alegação de autenticação independente pelo sistema.
A decisão foi gravada na base local em `audit_events` (`source_system=territorial-owner-acceptance`, `action=owner_accepted`) e em recibo privado `geo-ine-review-20260928-v2/owner-acceptance-242d74394061d175e70e56161a48f66f02cce8d5989fc61098599d082da38f60.json`. O script `tools/record-territorial-owner-acceptance.php` verifica os checksums antes da escrita; a segunda execução confirmou idempotência, sem duplicar o evento. Não foi atribuída a declaração a uma conta da aplicação.
A aprovação do responsável deixou de estar pendente para este pacote. O relatório original da extracção é preservado como histórico; a decisão posterior está no recibo. Permanecem pendentes as correspondências concretas entre colunas: aprovação não fornece os pares posto/distrito ausentes da extracção. Por isso, esta operação registou a aceitação mas não inseriu territórios nem alterou permissões ou publicou dados. O importador deve conservar separadamente a aceitação documental e a validação estrutural.
## Fonte e evidência
Foi preservado o [PDF do INE](https://www.ine.gov.mz/documents/20119/44337/Codificador%20da%20Divisao%20Politico%20administrativa.pdf/14aed3d0-9296-c038-dbac-041a5cf40f1d), cujo rodapé indica **07-06-2012**. A disponibilidade no site não comprova vigência actual nem licença de reutilização.
- Original privado: `storage/app/private/geo-ine-source-20260928/original.pdf`, 67.067 bytes.
- SHA-256: `5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2`.
- Pacote concluído: `storage/app/private/geo-ine-review-20260928-v2/`.
- 666 linhas extraídas para revisão; **zero territórios aceites automaticamente**.
As colunas extraídas têm alturas diferentes e podem associar postos a distritos errados. Não se inferiram relações. Contagens de códigos por prefixo no relatório são ocorrências, não totais territoriais confirmados; a comparação visual com a fonte está pendente. O directório sem sufixo `-v2` conserva apenas o original de uma tentativa interrompida pela convenção de exit code do Xpdf.
## Componentes e execução
`tools/territorial-pdf-review.py` preserva o original, executa `pdftotext`, regista versão e checksums e gera `review.csv` e `report.json`. Recusa sobrescrever pacotes anteriores. Não gera associações a partir das colunas desalinhadas.
```powershell
python tools/territorial-pdf-review.py original.pdf storage/app/private/ine-review `
--pdftotext C:/laragon/bin/git/mingw64/bin/pdftotext.exe `
--source-url 'URL da fonte' --source-version 'Versão documental'
```
Após revisão, o CSV deve ser UTF-8, separado por `;`, com cabeçalho exacto:
```csv
province_code;province_name;district_code;district_name;admin_post_code;admin_post_name;locality_code;locality_name;locality_type;latitude;longitude
```
```sh
php artisan geo:audit-csv caminho/territorios.csv
```
O comando arquiva original e relatório num directório UUID privado. Consulta as províncias existentes sob país de código `508`; o cadastro local vazio implica rejeição dos códigos até existir o seed. Não altera a base.
`TerritorialCsvAudit` valida UTF-8, limites de 20 MiB/50.000 registos, cabeçalho, campos obrigatórios, códigos numéricos preservados como texto, conflitos de código/nome/pai, nomes ambíguos e pares de coordenadas EPSG:4326. Localidade sem posto é rejeitada enquanto não existir o workflow de excepção. Não cria localidades a partir de postos. Duplicados idênticos são contados; qualquer erro rejeita o conjunto. Validade estrutural não equivale a aprovação documental.
Relatórios indicam ordinal do registo CSV e códigos de erro, sem repetir o conteúdo das linhas. O original privado conserva a evidência. Registos multilinha não têm ordinal igual à linha física.
## Aceitação e próximas fases
Validação executada: suite PHP completa com **168 testes e 1.707 verificações aprovadas** (32,524 s), incluindo seis novos testes unitários de CSV; PHPStan sem erros e Pint aprovado nos ficheiros PHP alterados. A extracção do PDF real terminou com 666 linhas de revisão. Não houve alteração de frontend nem alegação de testes visuais nesta fase.
| Critério | Situação e motivo | Impacto / próxima acção |
| --- | --- | --- |
| Original, checksum e revisão reproduzível | Executado | Conservar pacote privado |
| Auditoria estrutural CSV | Executada | Não substitui carga ou aprovação |
| Seed MZ, 11 províncias e capitais em staging | Por implementar | Criar seed idempotente com proveniência, sem localidades fictícias |
| Batch persistente e carga atómica | Por implementar | Preservar rejeições e impedir alteração silenciosa de dados aprovados |
| CSV final extraído do PDF | Colunas ambíguas | Reconciliar visualmente e validar antes de converter |
| Vigência, licença e totais oficiais | Não comprovados | Obter evidência documental antes da publicação |
| Selectores em cascata e consumidores | Por implementar | Criar componente acessível e validar UUID/hierarquia no servidor |
| Atribuições FK, versões de limites e serviços espaciais | Por implementar | Completar schema e serviços sem duplicar cadastro |
| OIDC, OpenAPI integral, p95 e carga | Por implementar | Completar integração e ensaios; sessão local continua com limites documentados |
| UI editorial, acessibilidade e UAT | Por implementar | Entregar e testar jornadas no navegador |
| Go-live integral | Não concluído | Resolver itens anteriores e verificar critérios do anexo |
Nenhuma migration ou novo endpoint neste incremento; nenhum território importado, publicado ou alterado. Reversão: retirar os componentes acrescentados, preservando os pacotes de auditoria; não há rollback de dados. O núcleo territorial anterior está documentado em [territorial-mvp.md](territorial-mvp.md). Os itens pendentes são trabalho restante, não novos pedidos de autorização.
+33
View File
@@ -0,0 +1,33 @@
# Cartografia interactiva — execução e cobertura
29/09/2026. **Critério integral do prompt ainda não atingido.** Há mapa interactivo implementado, mas nenhuma das seis oportunidades públicas tem coordenadas de local na base. O inventário não contém geometrias de investimento ou de zona. O Excel fornece códigos/designações administrativos, não posições de projectos. O documento da carteira inclui programas nacionais e traçados dependentes de estudos; não permite deduzir uma posição exacta de cada investimento.
## Implementado
- `InteractiveInvestmentMap`: Leaflet 1.9.4 com arrastamento, zoom, teclado, toque, escala, marcadores individuais e popups construídos por DOM/textContent; não interpola nomes em HTML. Popup inclui nome, sector, localização, corredores, clusters, projectos âncora e ligação à ficha. A lista acessível selecciona e centra o marcador correspondente.
- Substituído o SVG estático do catálogo. O mapa fica visível com as vistas de resultados; alterações de pesquisa/filtros desencadeiam navegação Inertia com debounce de 350 ms, sem recarregamento integral. Pedidos anteriores são cancelados; carregamento não mantém a lista de ausências de uma consulta anterior.
- Componente comum integrado no catálogo, ficha pública de oportunidade, dossiers de clusters/corredores/projectos e fichas de ZEE/ZFI/parques. Consulta a mesma API e as mesmas regras de publicação. A vista geométrica de limites das zonas continua separada; não foi transformada em georreferenciação de oportunidades.
- `/api/v1/investment/map` reutiliza filtros do catálogo, devolve metadados relacionais públicos e reconcilia `matched_count`, `georeferenced_count`, `unlocated`, `truncated` e `complete`. Não confunde centróide provincial com local de investimento. Só geometrias pontuais de locais (`site`) públicos, válidos, WGS84, activos e com ascendentes públicos são elegíveis.
- Um marcador por oportunidade; quando existem vários locais elegíveis, a edição explícita da posição tem precedência sobre os locais legados, seguindo-se ordem estável de UUID. As geometrias legadas não são apagadas. Não se deslocam coordenadas para separar marcadores coincidentes; cada um continua acessível na lista.
- Formulário **Posição do investimento no mapa** na edição da oportunidade: latitude, longitude, referência da fonte e confirmação expressa de correspondência ao local/divulgação. GET/POST `/management/opportunities/{id}/coordinates`, gestor autenticado/verificado, CSRF, limites de coordenadas, controlo de versão, transacção, FK e auditoria. Pontos retirados de divulgação não são reactivados por esta operação. A confirmação editorial não é validação geodésica independente.
## Mapa base
[Documentação Leaflet](https://leafletjs.com/reference.html) e [política do serviço de tiles OSM](https://operations.osmfoundation.org/policies/tiles/). URL HTTPS, atribuição visível e cache normal do navegador; sem prefetch/download offline. `VITE_MAP_TILE_URL` permite substituir o endereço no build; se substituir o fornecedor, ajustar também a atribuição/licença no componente. Falha do mapa base apresenta aviso e preserva marcadores/lista. Testes automatizados bloqueiam pedidos de tiles, evitando descarregamentos automáticos do serviço comunitário.
## Verificação executada
- Suite completa: **172 testes / 1.766 verificações**, aprovada em 45,176 s; PHPStan e Pint aprovados.
- TypeScript e lint dos componentes verificados; build de 2.370 módulos aprovado.
- `tools/investment-map-browser.mjs`, exclusivamente QA com um investimento sintético: marcador, popup, ligação, controlo de zoom, pesquisa automática, remoção do marcador após filtro sem correspondências e ausência de recarregamento integral. 390/1.440 px sem overflow, zero excepções JavaScript. Tráfego de tiles bloqueado no teste; não é evidência de disponibilidade externa.
- Nenhuma coordenada sintética foi inserida na base real. Não houve migration neste incremento.
## Reconciliação e impedimentos concretos
[map-coverage.json](map-coverage.json), gerado por `php tools/investment-map-coverage.php`, enumera as seis oportunidades públicas: **0 georreferenciadas, 6 sem coordenadas**. Não estão silenciosamente excluídas: a interface identifica-as, mas a representação cartográfica obrigatória de cada uma permanece por cumprir.
Para terminar, faltam as coordenadas, moradas exactas ou plantas que permitam identificar os locais, ou a definição documental do que representa um programa nacional sem local único. Foi pedida a localização dessas fontes; não é um pedido de nova autorização de implementação. Não usar sedes, capitais ou centros provinciais como se fossem o local do investimento.
Também permanecem por concluir: georreferenciação de todos os registos aplicáveis, integração dos restantes consumidores da suite, mapa privado de todos os rascunhos e paginação cartográfica além de 1.000 oportunidades (a API assinala truncagem, sem alegar cobertura). Limites jurídicos/geometrias de zonas e projectos lineares não se tornam pontos por este incremento. A aprovação integral só pode ser declarada depois de reconciliar esses itens e obter `complete=true` com evidência de precisão.
Reversão: retirar os componentes e rotas deste incremento, conservando geometrias e auditoria; não executar rollback destrutivo das tabelas geográficas partilhadas. Ficheiros legados, referências administrativas e associações existentes foram preservados.
@@ -0,0 +1,42 @@
# Dropdowns e associação territorial — aplicados em 29/09/2026
**Actualização que substitui as contagens e limites da primeira entrega abaixo:** corrigido o uso de códigos como nomes na extracção do Excel. Actualmente há 11 províncias, 148 distritos, 437 postos e 1.324 localidades com designações explícitas. Os filtros públicos e os formulários de criação foram integrados; as seis oportunidades existentes têm associação provincial. Histórico e lacunas restantes em [delivery-gaps.md](../delivery-gaps.md). As secções abaixo conservam a descrição da primeira entrega, anterior a esta correcção.
## Onde utilizar
Aceder como gestor a **Gestão → Localizações dos investimentos**, endereço `/management/territory-associations`. Escolher tipo e investimento; seleccionar Província → Distrito → Posto administrativo → Localidade e clicar **Guardar localização**. Pode guardar só até ao nível conhecido, ou limpar a província e guardar para remover a associação.
O mesmo painel está nas fichas de edição de oportunidades, clusters e projectos, e na gestão de zonas (ZEE, ZFI e parques industriais). A página central também permite associar corredores. Numa ficha nova, guardar primeiro o registo para obter o identificador. A associação territorial tem botão e versão próprios, separados do formulário principal.
Campos textuais legados de província/distrito permanecem por compatibilidade; não são a relação geográfica guardada pelo novo painel. Os filtros do catálogo público ainda usam o contrato anterior. Este incremento entrega a associação na gestão, não a migração de todos os filtros públicos.
## Dados efectivamente carregados
Fonte: `C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx`, SHA-256 `964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff`, indicada pelo utilizador e utilizada ao abrigo das suas instruções de aplicação dos dados. Extraídas apenas as colunas explícitas Provincia/Distrito/Posto_Administrativo/Localidade. O PDF INE com colunas desalinhadas não foi usado para construir estas relações.
Carregadas **1.990 entradas** em `geo_locations`: 1 país, 11 províncias, 150 distritos, 451 postos administrativos e 1.377 localidades. Onze linhas apresentam lacunas: níveis descendentes de uma lacuna não foram inferidos; os níveis anteriores completos puderam ser aproveitados. Identificadores determinísticos deduplicam nome/tipo/pai, preservando localidades homónimas em pais diferentes. Os códigos `XLSX-…` são internos, não códigos oficiais INE. Não foram geradas coordenadas.
Origem `cartografia-workbook`, classificação interna. Estas referências são consultáveis por gestores; não se apresentam como territórios publicados pelo workflow institucional. Nenhum investimento real recebeu uma localização inferida automaticamente. Os campos de associação estão disponíveis para seleccionar e guardar o local conhecido.
Artefactos privados: `storage/app/private/territory-workbook-candidates.json` contém candidatos e linhas sinalizadas. Preparação reproduzível: `tools/prepare-territory-workbook.py`; carga transaccional: `tools/import-territory-workbook.php`. Segunda execução criou zero entradas, confirmando idempotência. Os scripts verificam o checksum da fonte e não substituem registos divergentes.
## Persistência e endpoints
Migration `2026_09_28_160000_investment_primary_territory` aplicada: `primary_territory_id` com FK restritiva para `geo_locations` e `territory_version` em opportunities, projects, economic_clusters, development_corridors e investment_zones. A localização principal mais específica é guardada; os ascendentes são obtidos pelas relações parent_id. As relações múltiplas legadas são preservadas.
- GET `/management/territory-associations`: página de gestão.
- GET `/management/territory-subjects?kind=clusters&q=…`: investimentos paginados, 50/página.
- GET `/management/territory-options?type=district&parent_id=…&q=…`: opções paginadas, 100/página.
- GET `/management/territories/{kind}/{id}`: versão e caminho actual.
- POST `/management/territories/{kind}/{id}`: `{ "version": 0, "path": ["UUID-província", "UUID-distrito"] }`.
Tipos: opportunities, projects, clusters, corridors, zones. Sessão autenticada, email verificado, permissão manage-portal e CSRF nas escritas. Escrita em zonas mantém requisito MFA. Servidor rejeita níveis fora de ordem, pais incompatíveis, referências apagadas e versões desactualizadas; mantém FK e auditoria da gravação. Opções não expõem classificações confidenciais/restritas. Pesquisa tem debounce e cancelamento de pedidos anteriores. Selecção arquivada preexistente é mostrada sem substituição silenciosa.
## Verificação e recuperação
- Backup/restauro verificado antes da aplicação: `storage/app/private/zones-backup-20260929-003609/`, 113 tabelas reconciliadas.
- Suite completa: **170 testes / 1.722 verificações** aprovadas antes das últimas verificações da página central; testes focados depois: **2 testes / 18 verificações** aprovadas.
- PHPStan, Pint, TypeScript, lint dos componentes novos e build aprovados.
- Navegador QA com dados sintéticos: cascata completa, gravação e reset dos descendentes aprovados; 390/1.440 px sem overflow, zero erros JavaScript. Script `tools/territory-browser.mjs`, conta sintética e papel de gestor configurados exclusivamente no servidor QA durante a execução.
Rollback da migration recusa associações existentes. Com dados, preservar o backup e corrigir progressivamente. Não apagar `geo_locations` para desfazer a interface: são referências partilhadas. Não houve publicação automática, georreferenciação ou mudança de papéis de utilizadores da aplicação local.
+77
View File
@@ -0,0 +1,77 @@
{
"generated_at": "2026-09-29T06:19:06+00:00",
"published_opportunities": 6,
"georeferenced": 0,
"complete": false,
"truncated": false,
"unlocated": [
{
"id": 6,
"title": "Plataforma de crédito para PME agrícolas",
"category": "Mercado Financeiro",
"location": "Maputo Cidade",
"url": "\/oportunidades\/inv-mz-fin-006-plataforma-de-credito-para-pme-agricolas",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
},
{
"id": 5,
"title": "Processamento de pescado e cadeia fria",
"category": "Economia Azul",
"location": "Zambézia",
"url": "\/oportunidades\/inv-mz-blue-005-processamento-de-pescado-e-cadeia-fria",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
},
{
"id": 4,
"title": "Centro logístico provincial",
"category": "Infraestruturas",
"location": "Sofala",
"url": "\/oportunidades\/inv-mz-inf-004-centro-logistico-provincial",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
},
{
"id": 3,
"title": "Eco-lodge costeiro com integração comunitária",
"category": "Turismo",
"location": "Inhambane",
"url": "\/oportunidades\/inv-mz-tur-003-eco-lodge-costeiro-com-integracao-comunitaria",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
},
{
"id": 2,
"title": "Mini-rede solar para polo produtivo",
"category": "Energia e Mineração",
"location": "Zambézia",
"url": "\/oportunidades\/inv-mz-ene-002-mini-rede-solar-para-polo-produtivo",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
},
{
"id": 1,
"title": "Unidade de processamento agrícola em Nampula",
"category": "Agronegócio",
"location": "Nampula",
"url": "\/oportunidades\/inv-mz-agr-001-unidade-de-processamento-agricola-em-nampula",
"clusters": [],
"corridors": [],
"projects": [],
"reason": "missing_public_site_coordinates"
}
],
"geo_geometries": 0,
"zone_geometries": 0
}
+25
View File
@@ -30,6 +30,7 @@
"concurrently": "^10.0.3",
"input-otp": "^1.4.2",
"laravel-vite-plugin": "^3.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.475.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -43,6 +44,7 @@
"devDependencies": {
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@rolldown/plugin-babel": "^0.2.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
"vite-plus": "0.3.0"
@@ -2945,6 +2947,13 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
@@ -2953,6 +2962,16 @@
"license": "MIT",
"peer": true
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -4811,6 +4830,12 @@
}
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+2
View File
@@ -36,6 +36,7 @@
"concurrently": "^10.0.3",
"input-otp": "^1.4.2",
"laravel-vite-plugin": "^3.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.475.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -49,6 +50,7 @@
"devDependencies": {
"@laravel/vite-plugin-wayfinder": "^0.1.3",
"@rolldown/plugin-babel": "^0.2.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^22.13.5",
"babel-plugin-react-compiler": "^1.0.0",
"vite-plus": "0.3.0"
@@ -0,0 +1,179 @@
import { useEffect, useRef, useState } from "react";
import type { Map as LeafletMap, Marker } from "leaflet";
import "leaflet/dist/leaflet.css";
export type InvestmentMapFeature = {
geometry: { coordinates: [number, number] };
properties: {
id: number;
title: string;
location: string;
url: string;
category?: string;
clusters?: string[];
corridors?: string[];
projects?: string[];
};
};
export default function InteractiveInvestmentMap({
points,
}: {
points: InvestmentMapFeature[] | null;
}) {
const element = useRef<HTMLDivElement>(null);
const map = useRef<LeafletMap | null>(null);
const markers = useRef(new Map<number, Marker>());
const [selected, setSelected] = useState<number | null>(null);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
let observer: ResizeObserver | undefined;
void import("leaflet")
.then((L) => {
if (cancelled || !element.current) return;
const instance = L.map(element.current, {
scrollWheelZoom: false,
keyboard: true,
}).setView([-18.5, 35], 5);
map.current = instance;
L.tileLayer(
import.meta.env.VITE_MAP_TILE_URL ||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
{
maxZoom: 19,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
)
.on("tileerror", () => {
if (!cancelled)
setError(
"Mapa base indisponível. Os marcadores e a lista continuam acessíveis.",
);
})
.addTo(instance);
L.control.scale({ imperial: false }).addTo(instance);
const bounds: [number, number][] = [];
for (const [index, point] of (points ?? []).entries()) {
const [longitude, latitude] = point.geometry.coordinates;
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
continue;
const { properties: p } = point;
const popup = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = p.title;
popup.append(heading);
for (const text of [
p.category,
p.location,
`Corredor: ${p.corridors?.join(", ") || "Não associado"}`,
`Cluster: ${p.clusters?.join(", ") || "Não associado"}`,
`Projecto âncora: ${p.projects?.join(", ") || "Não associado"}`,
]) {
const line = document.createElement("p");
line.textContent = text ?? "";
popup.append(line);
}
if (p.url.startsWith("/") && !p.url.startsWith("//")) {
const link = document.createElement("a");
link.href = p.url;
link.textContent = "Abrir ficha detalhada";
popup.append(link);
}
const icon = L.divIcon({
className: "",
html: `<span style="display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:#065f46;color:white;border:2px solid white;font-weight:bold">${index + 1}</span>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
const marker = L.marker([latitude, longitude], {
icon,
title: p.title,
alt: p.title,
keyboard: true,
})
.bindPopup(popup)
.on("click", () => setSelected(p.id))
.addTo(instance);
markers.current.set(p.id, marker);
bounds.push([latitude, longitude]);
}
if (bounds.length) instance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
observer = new ResizeObserver(() => instance.invalidateSize());
observer.observe(element.current);
})
.catch(() => {
if (!cancelled) setError("Não foi possível iniciar o mapa.");
});
return () => {
cancelled = true;
observer?.disconnect();
map.current?.remove();
map.current = null;
markers.current.clear();
};
}, [points]);
return (
<section
className="space-y-3 rounded-xl border bg-white p-4 text-slate-900"
aria-label="Mapa interactivo dos investimentos"
>
<h2 className="text-xl font-bold">Mapa dos investimentos</h2>
<p>
Arraste para navegar; use +/− para ampliar. Pode seleccionar um investimento na
lista para o localizar.
</p>
{error && <p role="status">{error}</p>}
<div
ref={element}
className="relative z-0 h-[460px] w-full rounded"
aria-label="Mapa; navegação por setas e teclas mais e menos"
/>
{points === null ? (
<p role="status">A carregar localizações…</p>
) : (
<p role="status">{points.length} investimentos com coordenadas disponíveis.</p>
)}
<ul className="grid gap-2 md:grid-cols-2">
{(points ?? []).map((p) => (
<li
key={p.properties.id}
className={
selected === p.properties.id
? "rounded border-2 border-emerald-700 p-3"
: "rounded border p-3"
}
>
<button
type="button"
className="text-left font-bold underline"
aria-pressed={selected === p.properties.id}
onClick={() => {
const marker = markers.current.get(p.properties.id);
if (marker) {
map.current?.setView(marker.getLatLng(), 14);
marker.openPopup();
setSelected(p.properties.id);
}
}}
>
{p.properties.title}
</button>
<p>
{p.properties.category} · {p.properties.location}
</p>
<a className="underline" href={p.properties.url}>
Abrir ficha
</a>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { api } from "@/pages/investment/shared";
export default function InvestmentCoordinates({ id }: { id: number | string }) {
const [data, setData] = useState({
version: 0,
latitude: "",
longitude: "",
source_reference: "",
});
const [ready, setReady] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
let active = true;
api(`/management/opportunities/${id}/coordinates`)
.then((result) => {
if (active) {
setData({
...result,
latitude: String(result.latitude ?? ""),
longitude: String(result.longitude ?? ""),
});
setReady(true);
}
})
.catch((e) => {
if (active) setMessage(e.message);
});
return () => {
active = false;
};
}, [id]);
return (
<form
className="my-6 space-y-3 rounded border p-4"
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
try {
const result = await api(`/management/opportunities/${id}/coordinates`, {
...data,
confirmed_site: confirmed,
});
setData((previous) => ({ ...previous, version: result.version }));
setMessage(
"Coordenadas guardadas. O marcador acompanha a publicação da oportunidade.",
);
} catch (e) {
setMessage(e instanceof Error ? e.message : "Falha ao guardar.");
} finally {
setBusy(false);
}
}}
>
<h2 className="text-lg font-bold">Posição do investimento no mapa</h2>
<p>Registe a posição do local em WGS84 e a referência que a confirma.</p>
<div className="grid gap-3 md:grid-cols-2">
{(["latitude", "longitude"] as const).map((field) => (
<label key={field}>
{field === "latitude" ? "Latitude" : "Longitude"}
<input
className="w-full rounded border p-2"
type="number"
step="any"
min={field === "latitude" ? -90 : -180}
max={field === "latitude" ? 90 : 180}
required
value={data[field]}
onChange={(e) => setData({ ...data, [field]: e.target.value })}
/>
</label>
))}
</div>
<label className="block">
Fonte das coordenadas
<input
className="w-full rounded border p-2"
required
minLength={10}
maxLength={2000}
value={data.source_reference}
onChange={(e) => setData({ ...data, source_reference: e.target.value })}
/>
</label>
<label className="flex gap-2">
<input
type="checkbox"
required
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
/>
Confirmo que esta posição corresponde ao local do investimento e pode ser divulgada
quando a ficha estiver publicada.
</label>
<button
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
disabled={!ready || busy}
>
Guardar coordenadas
</button>
{message && <p role="status">{message}</p>}
</form>
);
}
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import InteractiveInvestmentMap, { type InvestmentMapFeature } from "./interactive-investment-map";
export default function InvestmentMapPanel({
filters,
}: {
filters: Record<string, string | number>;
}) {
const key = JSON.stringify(filters);
const [points, setPoints] = useState<InvestmentMapFeature[] | null>(null);
const [missing, setMissing] = useState<{ id: number; title: string; url: string }[]>([]);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
const params = new URLSearchParams(JSON.parse(key));
setPoints(null);
fetch(`/api/v1/investment/map?${params}`, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then(async (response) => {
if (!response.ok) throw new Error("Mapa indisponível.");
return response.json();
})
.then((data) => {
if (controller.signal.aborted) return;
setPoints(data.features);
setMissing(data.unlocated ?? []);
setError(data.truncated ? "Cobertura limitada: refine os filtros." : "");
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}, [key]);
return (
<section className="space-y-3">
{error && <p role="alert">{error}</p>}
<InteractiveInvestmentMap points={points} />
{missing.length > 0 && (
<div>
<p>{missing.length} investimentos aguardam coordenadas do local:</p>
<ul>
{missing.map((record) => (
<li key={record.id}>
<a href={record.url} className="underline">
{record.title}
</a>
</li>
))}
</ul>
</div>
)}
</section>
);
}
@@ -0,0 +1,230 @@
import { useEffect, useId, useState } from "react";
import { api } from "@/pages/investment/shared";
export type TerritoryOption = {
id: string;
name: string;
parent_id: string | null;
location_type: string;
deleted_at?: string | null;
};
type Option = TerritoryOption;
const levels = ["province", "district", "admin_post", "locality"];
const labels = ["Província", "Distrito", "Posto administrativo", "Localidade"];
const control = "w-full rounded border bg-background p-2 text-foreground";
export function TerritoryFields({
initial = [],
onChange,
}: {
initial?: TerritoryOption[];
onChange?: (path: TerritoryOption[]) => void;
}) {
const [path, setPath] = useState(initial);
return (
<div className="grid gap-4 md:col-span-full md:grid-cols-2">
{(["province_id", "district_id", "admin_post_id", "locality_id"] as const).map(
(field, index) => (
<div key={`${field}-${path[index - 1]?.id ?? ""}`}>
<input type="hidden" name={field} value={path[index]?.id ?? ""} />
<TerritoryLevel
index={index}
parent={path[index - 1]?.id ?? ""}
selected={path[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) => {
const next = [...path.slice(0, index), ...(value ? [value] : [])];
setPath(next);
onChange?.(next);
}}
/>
</div>
),
)}
</div>
);
}
export function TerritoryLevel({
index,
parent,
selected,
onChange,
endpoint = "/management/territory-options",
}: {
index: number;
parent: string;
selected?: Option;
onChange: (value?: Option) => void;
endpoint?: string;
}) {
const id = useId();
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [options, setOptions] = useState<Option[]>([]);
const [more, setMore] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const enabled = index === 0 || Boolean(parent);
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({
type: levels[index],
parent_id: parent,
q,
page: String(page),
});
const response = await fetch(`${endpoint}?${params}`, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error("Não foi possível carregar as localizações.");
const data = await response.json();
setOptions(data.data);
setMore(Boolean(data.next_page_url));
} catch (e) {
if (!controller.signal.aborted)
setError(e instanceof Error ? e.message : "Erro de ligação.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [index, parent, q, page, enabled, endpoint]);
return (
<div className="space-y-2">
<label htmlFor={id} className="font-medium">
{labels[index]}
</label>
<input
className={control}
aria-label={`Pesquisar ${labels[index]}`}
disabled={!enabled}
value={q}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
placeholder="Pesquisar por nome"
/>
<select
id={id}
className={control}
disabled={!enabled || loading}
value={selected?.id ?? ""}
onChange={(e) => onChange(options.find((o) => o.id === e.target.value))}
>
<option value="">{enabled ? "Seleccione" : "Seleccione o nível anterior"}</option>
{selected && !options.some((o) => o.id === selected.id) && (
<option value={selected.id}>
{selected.name}
{selected.deleted_at ? " (arquivada)" : " (selecção actual)"}
</option>
)}
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
{loading && <p role="status">A carregar…</p>}
{error && <p role="alert">{error}</p>}
{enabled && !loading && !error && options.length === 0 && (
<p className="text-sm">Sem opções para esta pesquisa.</p>
)}
<div className="flex gap-3 text-sm">
{page > 1 && (
<button type="button" onClick={() => setPage(page - 1)}>
Anterior
</button>
)}
{more && (
<button type="button" onClick={() => setPage(page + 1)}>
Mais opções
</button>
)}
</div>
</div>
);
}
export default function InvestmentTerritory({ kind, id }: { kind: string; id: string | number }) {
const [path, setPath] = useState<Option[]>([]);
const [version, setVersion] = useState<number | null>(null);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let active = true;
api(`/management/territories/${kind}/${id}`)
.then((data) => {
if (!active) return;
setPath(
levels.flatMap((level) =>
data.path.filter((o: Option) => o.location_type === level),
),
);
setVersion(data.version);
})
.catch((e) => {
if (active) setMessage(e.message);
});
return () => {
active = false;
};
}, [kind, id]);
return (
<section
className="my-6 space-y-4 rounded border p-4"
aria-label="Localização do investimento"
>
<h2 className="text-lg font-semibold">Localização do investimento</h2>
<p>Seleccione até ao nível conhecido. Alterar um nível limpa as selecções seguintes.</p>
<div className="grid gap-4 md:grid-cols-2">
{levels.map((level, index) => (
<TerritoryLevel
key={`${level}-${path[index - 1]?.id ?? ""}`}
index={index}
parent={path[index - 1]?.id ?? ""}
selected={path[index]}
onChange={(value) => {
setPath([...path.slice(0, index), ...(value ? [value] : [])]);
setMessage("");
}}
/>
))}
</div>
<button
type="button"
className="rounded bg-primary px-4 py-2 text-primary-foreground disabled:opacity-50"
disabled={version === null || busy}
onClick={async () => {
setBusy(true);
setMessage("");
try {
const data = await api(`/management/territories/${kind}/${id}`, {
version,
path: path.map((o) => o.id),
});
setVersion(data.version);
setMessage("Localização associada e guardada.");
} catch (e) {
setMessage(e instanceof Error ? e.message : "Erro ao guardar.");
} finally {
setBusy(false);
}
}}
>
Guardar localização
</button>
{message && <p role="status">{message}</p>}
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
/** Estados de preparação usados pelo cadastro de oportunidades (níveis 1–6). */
export const MATURITY_LEVELS = [
"Ideia identificada",
"Pré-estudo disponível",
"Estudo de viabilidade em curso",
"Projecto estruturado",
"Pronto para investimento",
"Em negociação ou implementação",
] as const;
+1 -8
View File
@@ -1,6 +1,7 @@
import { createContext, useContext, useRef, useState } from "react";
import { router } from "@inertiajs/react";
import { PortalMenu } from "@/layouts/portal-navigation";
import { MATURITY_LEVELS } from "@/lib/investment-maturity";
import type { Dispatch, FormEvent, ReactNode, SetStateAction } from "react";
import type { LucideIcon } from "lucide-react";
import { usePortalQuery } from "@/hooks/use-portal-query";
@@ -94,14 +95,6 @@ const PROVINCES = [
"Niassa",
];
const MATURITY_LEVELS = [
"Ideia identificada",
"Pré-estudo disponível",
"Estudo de viabilidade em curso",
"Projecto estruturado",
"Pronto para investimento",
"Em negociação ou implementação",
];
/* ------------------------------------------------------------------ */
/* Componentes */
+131 -109
View File
@@ -1,5 +1,8 @@
import { Link, router, useForm, usePage } from "@inertiajs/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Map from "@/components/interactive-investment-map";
import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory";
import { MATURITY_LEVELS } from "@/lib/investment-maturity";
import { Frame, Field, input, button, card, money, api, groupLabels } from "./shared";
import type { Opportunity, References } from "./shared";
@@ -13,6 +16,7 @@ export default function Catalogue({
results,
favourites,
territorial,
territorySelection,
}: {
filters: Record<string, string | string[] | boolean>;
references: References;
@@ -23,6 +27,7 @@ export default function Catalogue({
next_page_url: string | null;
};
favourites: number[];
territorySelection: TerritoryOption[];
territorial: {
id: string;
title: string;
@@ -40,6 +45,10 @@ export default function Catalogue({
sector: String(filters.sector ?? ""),
province: String(filters.province ?? ""),
district: String(filters.district ?? ""),
province_id: String(filters.province_id ?? ""),
district_id: String(filters.district_id ?? ""),
admin_post_id: String(filters.admin_post_id ?? ""),
locality_id: String(filters.locality_id ?? ""),
cluster_id: String(filters.cluster_id ?? ""),
zee_id: String(filters.zee_id ?? ""),
zfi_id: String(filters.zfi_id ?? ""),
@@ -53,19 +62,49 @@ export default function Catalogue({
currency: String(filters.currency ?? ""),
maturity_level: String(filters.maturity_level ?? ""),
before: String(filters.before ?? ""),
sort: String(filters.sort ?? "recent"),
sort: typeof filters.sort === "string" ? filters.sort : "recent",
facets: (Array.isArray(filters.facets) ? filters.facets : []).map(Number),
});
const view = String(filters.view ?? "grid");
const [territoryPath, setTerritoryPath] = useState(territorySelection);
const [saved, setSaved] = useState(favourites);
const [selected, setSelected] = useState<number[]>([]);
const [comparison, setComparison] = useState<{ record: Opportunity }[]>([]);
const [points, setPoints] = useState<Feature[] | null>(null);
const [unlocated, setUnlocated] = useState<
{ id: number; title: string; location: string; url: string }[]
>([]);
const [truncated, setTruncated] = useState(false);
const [error, setError] = useState("");
const [suggestions, setSuggestions] = useState<{ title: string; slug: string }[]>([]);
const filterKey = JSON.stringify(filters);
const draftFilterKey = JSON.stringify(form.data);
const lastRequest = useRef(draftFilterKey);
useEffect(() => {
if (draftFilterKey === lastRequest.current) return;
let cancel: (() => void) | undefined;
const timer = setTimeout(() => {
const data = JSON.parse(draftFilterKey);
lastRequest.current = draftFilterKey;
router.get(
"/oportunidades",
{ ...data, education: data.education ? 1 : 0 },
{
preserveState: true,
preserveScroll: true,
replace: true,
onCancelToken: (token) => {
cancel = () => token.cancel();
},
},
);
}, 350);
return () => {
clearTimeout(timer);
cancel?.();
};
}, [draftFilterKey]);
useEffect(() => {
if (view !== "map" && view !== "combined") return;
let active = true;
const values = JSON.parse(filterKey) as Record<string, string | string[] | boolean>;
const params = new URLSearchParams();
@@ -75,10 +114,16 @@ export default function Catalogue({
params.set(key, String(value === true ? 1 : value === false ? 0 : value));
}
setPoints(null);
setUnlocated([]);
setTruncated(false);
setError("");
api(`/api/v1/investment/map?${params}`)
.then((data) => {
if (active) setPoints(data.features);
if (active) {
setPoints(data.features);
setUnlocated(data.unlocated ?? []);
setTruncated(Boolean(data.truncated));
}
})
.catch(() => {
if (active)
@@ -166,6 +211,7 @@ export default function Catalogue({
setPoints(null);
setComparison([]);
setSelected([]);
form.transform((data) => ({ ...data, education: data.education ? 1 : 0 }));
form.get("/oportunidades", { preserveState: true });
}}
>
@@ -218,39 +264,30 @@ export default function Catalogue({
</select>
</Field>
))}
{(["clusters", "corridors", "projects", "locations"] as const).map(
(group, i) => {
const key = (
[
"cluster_id",
"corridor_id",
"project_id",
"geo_location_id",
] as const
)[i];
return (
<Field
key={group}
label={
["Cluster", "Corredor", "Projecto âncora", "Localização"][i]
}
{(["clusters", "corridors", "projects"] as const).map((group, i) => {
const key = (
["cluster_id", "corridor_id", "project_id", "geo_location_id"] as const
)[i];
return (
<Field
key={group}
label={["Cluster", "Corredor", "Projecto âncora", "Localização"][i]}
>
<select
className={input}
value={form.data[key]}
onChange={(e) => form.setData(key, e.target.value)}
>
<select
className={input}
value={form.data[key]}
onChange={(e) => form.setData(key, e.target.value)}
>
<option value="">Todos</option>
{references[group].map((r) => (
<option value={r.id} key={r.id}>
{r.name}
</option>
))}
</select>
</Field>
);
},
)}
<option value="">Todos</option>
{references[group].map((r) => (
<option value={r.id} key={r.id}>
{r.name}
</option>
))}
</select>
</Field>
);
})}
<Field label="Sector">
<select
className={input}
@@ -265,16 +302,34 @@ export default function Catalogue({
))}
</select>
</Field>
{(["province", "district"] as const).map((key) => (
<Field key={key} label={key === "province" ? "Província" : "Distrito"}>
<input
className={input}
value={form.data[key]}
onChange={(e) => form.setData(key, e.target.value)}
maxLength={100}
{(["province_id", "district_id", "admin_post_id", "locality_id"] as const).map(
(key, index) => (
<TerritoryLevel
key={`${key}-${territoryPath[index - 1]?.id ?? ""}`}
index={index}
parent={territoryPath[index - 1]?.id ?? ""}
selected={territoryPath[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) => {
const path = [
...territoryPath.slice(0, index),
...(value ? [value] : []),
];
setTerritoryPath(path);
form.setData((data) => ({
...data,
province: "",
district: "",
geo_location_id: "",
province_id: path[0]?.id ?? "",
district_id: path[1]?.id ?? "",
admin_post_id: path[2]?.id ?? "",
locality_id: path[3]?.id ?? "",
}));
}}
/>
</Field>
))}
),
)}
<Field label="Estado de preparação">
<select
className={input}
@@ -282,9 +337,9 @@ export default function Catalogue({
onChange={(e) => form.setData("maturity_level", e.target.value)}
>
<option value="">Todos os níveis</option>
{[1, 2, 3, 4, 5, 6].map((n) => (
<option key={n} value={n}>
Maturidade {n}
{MATURITY_LEVELS.map((label, index) => (
<option key={index + 1} value={index + 1}>
{label}
</option>
))}
</select>
@@ -434,7 +489,34 @@ export default function Catalogue({
</Link>
</div>
{error && <p role="alert">{error}</p>}
{(view === "map" || view === "combined") && !error && <Map points={points} />}
{!error && <Map points={points} />}
{truncated && (
<p role="alert">
O mapa atingiu o limite de 1.000 registos; refine os filtros. A cobertura não
está completa.
</p>
)}
{unlocated.length > 0 && (
<section className={card} aria-label="Investimentos a georreferenciar">
<h2 className="text-xl font-bold">
{unlocated.length} investimentos sem coordenadas de local confirmadas
</h2>
<p>
Continuam nos resultados. Seleccionar uma província não determina a posição
exacta do investimento.
</p>
<ul>
{unlocated.map((record) => (
<li key={record.id}>
<Link href={record.url} className="underline">
{record.title}
</Link>{" "}
— {record.location || "Localização por completar"}
</li>
))}
</ul>
</section>
)}
{(view !== "map" || error || points?.length === 0) && (
<div
className={
@@ -583,63 +665,3 @@ export default function Catalogue({
</Frame>
);
}
function Map({ points }: { points: Feature[] | null }) {
if (points === null) return <p role="status">A carregar localizações…</p>;
if (!points.length)
return (
<p className={card}>
Sem coordenadas públicas associadas aos resultados. Não são atribuídas localizações
aproximadas.
</p>
);
const xs = points.map((p) => p.geometry.coordinates[0]),
ys = points.map((p) => p.geometry.coordinates[1]);
const minX = Math.min(...xs) - 0.1,
maxX = Math.max(...xs) + 0.1,
minY = Math.min(...ys) - 0.1,
maxY = Math.max(...ys) + 0.1;
return (
<section className={card}>
<h2 className="text-xl font-bold">Localizações georreferenciadas</h2>
<p>
Coordenadas WGS84 publicadas, sem mapa base externo. Até 1.000 pontos; a lista
acessível apresenta os mesmos dados.
</p>
<svg
viewBox="0 0 900 400"
role="img"
aria-label="Distribuição das localizações por longitude e latitude"
className="my-4 w-full rounded bg-sky-50"
>
<path d="M30 10 V370 H880" fill="none" stroke="#475569" />
{points.map((p, i) => (
<a
key={`${p.properties.id}-${i}`}
href={p.properties.url}
aria-label={`${p.properties.title}, ${p.properties.location}`}
>
<circle
cx={45 + ((p.geometry.coordinates[0] - minX) / (maxX - minX)) * 810}
cy={350 - ((p.geometry.coordinates[1] - minY) / (maxY - minY)) * 310}
r="8"
fill="#065f46"
>
<title>{p.properties.title}</title>
</circle>
</a>
))}
</svg>
<ul className="space-y-2">
{points.map((p, i) => (
<li key={i}>
<Link className="underline" href={p.properties.url}>
{p.properties.title}
</Link>{" "}
— {p.properties.location}; longitude {p.geometry.coordinates[0]}, latitude{" "}
{p.geometry.coordinates[1]}
</li>
))}
</ul>
</section>
);
}
+5
View File
@@ -1,5 +1,6 @@
import { Link, router, usePage } from "@inertiajs/react";
import { useState } from "react";
import InvestmentMapPanel from "@/components/investment-map-panel";
import { Frame, card, button, money, groupLabels } from "./shared";
import type { Opportunity, Context } from "./shared";
export default function Detail({
@@ -19,6 +20,10 @@ export default function Detail({
{r.reference} · {r.sector?.name} · {r.province} {r.district}
</p>
<p className="text-xl">{r.summary}</p>
<InvestmentMapPanel filters={{ opportunity_id: r.id }} />
{c.primary_territory_path && c.primary_territory_path.length > 0 && (
<p>Localização: {c.primary_territory_path.map((node) => node.name).join(" → ")}</p>
)}
<div className="flex flex-wrap gap-4">
{auth.user ? (
<button
+1
View File
@@ -40,6 +40,7 @@ export type Opportunity = {
status: string;
};
export type Context = {
primary_territory_path?: { id: string; name: string; location_type: string }[];
zones?: {
id: string;
name: string;
+14 -4
View File
@@ -1,4 +1,6 @@
import { Link, router, useForm } from "@inertiajs/react";
import InvestmentTerritory from "@/components/investment-territory";
import InvestmentMapPanel from "@/components/investment-map-panel";
import { useState } from "react";
import { Frame, Field, input, button, card, api } from "./shared";
import ZoneRelations from "./zone-relations";
@@ -91,8 +93,6 @@ function Editor({
name_en: z.name_en ?? "",
description: z.description ?? "",
description_en: z.description_en ?? "",
province: z.province ?? "",
district: z.district ?? "",
operator_id: z.operator_id ?? "",
legal_source_id: z.legal_source_id ?? "",
});
@@ -153,8 +153,6 @@ function Editor({
["code", "Código"],
["name", "Nome PT"],
["name_en", "Nome EN"],
["province", "Província"],
["district", "Distrito"],
] as const
).map(([key, label]) => (
<Field key={key} label={label}>
@@ -484,6 +482,18 @@ function Editor({
</section>
</>
)}
{management && <InvestmentTerritory kind="zones" id={z.id} />}
{!management && ["zee", "zfi", "industrial_park"].includes(z.category) && (
<InvestmentMapPanel
filters={{
[z.category === "zee"
? "zee_id"
: z.category === "zfi"
? "zfi_id"
: "park_id"]: z.id,
}}
/>
)}
</Frame>
);
}
+5
View File
@@ -1,5 +1,6 @@
import { Link, router } from "@inertiajs/react";
import { useState } from "react";
import { TerritoryFields, type TerritoryOption } from "@/components/investment-territory";
import { Frame, Field, input, button, card, api } from "./shared";
type Zone = {
@@ -17,6 +18,7 @@ export default function Zones({
records,
filters,
management,
territorySelection = [],
}: {
records: {
data: Zone[];
@@ -26,6 +28,7 @@ export default function Zones({
};
filters: { q?: string; category?: string };
management: boolean;
territorySelection?: TerritoryOption[];
}) {
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@@ -56,6 +59,7 @@ export default function Zones({
<option value="industrial_park">Parque industrial</option>
</select>
</Field>
<TerritoryFields initial={territorySelection} />
<button className={button}>Pesquisar</button>
</form>
<p role="status">{records.total} registos disponíveis</p>
@@ -115,6 +119,7 @@ export default function Zones({
}}
>
<h2 className="text-xl font-bold">Criar rascunho</h2>
<TerritoryFields />
<Field label="Código">
<input name="code" className={input} required maxLength={80} />
</Field>
+41 -2
View File
@@ -1,4 +1,8 @@
import { Link, useForm } from "@inertiajs/react";
import InvestmentTerritory from "@/components/investment-territory";
import InvestmentCoordinates from "@/components/investment-coordinates";
import { TerritoryLevel, type TerritoryOption } from "@/components/investment-territory";
import { useState } from "react";
import { Management, Field, button, control, states } from "./shared";
type Values = Record<string, string | boolean>;
@@ -9,8 +13,6 @@ const opportunityFields = {
slug: "Slug (endereço)",
summary: "Resumo",
description: "Descrição",
province: "Província",
district: "Distrito",
promoter_name: "Promotor",
promoter_type: "Tipo de promotor",
estimated_investment_amount: "Investimento estimado",
@@ -66,6 +68,7 @@ export default function Editor({
initial.is_featured = Boolean(record?.is_featured ?? false);
}
const form = useForm<Values>(initial);
const [creationPath, setCreationPath] = useState<TerritoryOption[]>([]);
return (
<Management
title={`${record ? "Editar" : "Criar"} ${isSector ? "sector" : "oportunidade"}`}
@@ -84,6 +87,40 @@ export default function Editor({
}}
>
<fieldset disabled={form.processing} className="space-y-5">
{!isSector && !record && (
<div className="grid gap-4 md:grid-cols-2">
{(
[
"province_id",
"district_id",
"admin_post_id",
"locality_id",
] as const
).map((key, index) => (
<TerritoryLevel
key={`${key}-${creationPath[index - 1]?.id ?? ""}`}
index={index}
parent={creationPath[index - 1]?.id ?? ""}
selected={creationPath[index]}
endpoint="/api/v1/investment/territory-options"
onChange={(value) => {
const path = [
...creationPath.slice(0, index),
...(value ? [value] : []),
];
setCreationPath(path);
form.setData((data) => ({
...data,
province_id: path[0]?.id ?? "",
district_id: path[1]?.id ?? "",
admin_post_id: path[2]?.id ?? "",
locality_id: path[3]?.id ?? "",
}));
}}
/>
))}
</div>
)}
{Object.entries(fields).map(([key, label]) => (
<Field key={key} label={label} error={form.errors[key]}>
{[
@@ -177,6 +214,8 @@ export default function Editor({
</div>
</fieldset>
</form>
{!isSector && record && <InvestmentTerritory kind="opportunities" id={record.id} />}
{!isSector && record && <InvestmentCoordinates id={record.id} />}
</Management>
);
}
+3
View File
@@ -41,6 +41,9 @@ export function Management({ title, children }: { title: string; children: React
<Link href="/management/crm/investors">Investidores</Link>
<Link href="/management/projects">Projectos</Link>
<Link href="/management/clusters">Clusters</Link>
<Link href="/management/territory-associations">
Localizações dos investimentos
</Link>
<Link href="/management/investment/zones">ZEE, ZFI e parques</Link>
{educationEnabled && (
<Link href="/management/education">Competências e formação</Link>
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import InvestmentTerritory from "@/components/investment-territory";
import { Management, Field, control } from "./shared";
export default function TerritoryAssociations() {
const [kind, setKind] = useState("projects");
const [id, setId] = useState("");
const [q, setQ] = useState("");
const [page, setPage] = useState(1);
const [rows, setRows] = useState<{ id: string; name: string }[]>([]);
const [more, setMore] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const params = new URLSearchParams({ kind, q, page: String(page) });
const response = await fetch(`/management/territory-subjects?${params}`, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
if (!response.ok) throw new Error("Não foi possível carregar os investimentos.");
const data = await response.json();
setRows(data.data);
setMore(Boolean(data.next_page_url));
setError("");
} catch (e) {
if (!controller.signal.aborted)
setError(e instanceof Error ? e.message : "Erro de ligação.");
}
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [kind, q, page]);
return (
<Management title="Localizações dos investimentos">
<Field label="Tipo de investimento">
<select
className={control}
value={kind}
onChange={(e) => {
setKind(e.target.value);
setId("");
setRows([]);
setQ("");
setPage(1);
}}
>
<option value="projects">Projectos / projectos âncora</option>
<option value="clusters">Clusters</option>
<option value="corridors">Corredores</option>
<option value="zones">ZEE / ZFI / parques industriais</option>
<option value="opportunities">Oportunidades</option>
</select>
</Field>
<Field label="Pesquisar investimento">
<input
className={control}
value={q}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
setId("");
}}
/>
</Field>
<Field label="Investimento">
<select className={control} value={id} onChange={(e) => setId(e.target.value)}>
<option value="">Seleccione</option>
{rows.map((row) => (
<option value={row.id} key={row.id}>
{row.name}
</option>
))}
</select>
</Field>
{error && <p role="alert">{error}</p>}
{!error && rows.length === 0 && <p>Sem registos para esta pesquisa.</p>}
<div className="flex gap-4">
{page > 1 && (
<button
type="button"
onClick={() => {
setId("");
setPage(page - 1);
}}
>
Anterior
</button>
)}
{more && (
<button
type="button"
onClick={() => {
setId("");
setPage(page + 1);
}}
>
Mais investimentos
</button>
)}
</div>
{id && <InvestmentTerritory key={`${kind}-${id}`} kind={kind} id={id} />}
</Management>
);
}
+12
View File
@@ -1,4 +1,5 @@
import { Link, useForm, router } from "@inertiajs/react";
import InvestmentMapPanel from "@/components/investment-map-panel";
import { Management } from "../management/shared";
import { Layout, Field, control, button, labels, kind } from "./shared";
import type { Dossier } from "./shared";
@@ -47,6 +48,17 @@ function Editor({
{kind(record)} · {record.content.code}
</p>
<p className="text-sm">Fonte: {record.source}</p>
{!management && (record.cluster_id || record.corridor_id || record.project_id) && (
<InvestmentMapPanel
filters={
record.cluster_id
? { cluster_id: record.cluster_id }
: record.corridor_id
? { corridor_id: record.corridor_id }
: { project_id: record.project_id! }
}
/>
)}
{zones.length > 0 && (
<section>
<h2 className="text-xl font-bold">ZEE, ZFI e parques relacionados</h2>
+84 -135
View File
@@ -1,15 +1,16 @@
import { Link, useForm } from '@inertiajs/react';
import { Management, Field, button, control } from '../management/shared';
import { titles, statuses, stages, roles } from './types';
import type { Structure, StructureRecord, Chain } from './types';
import ValueNetwork from './value-network';
import type { ChainLink, Gap } from './value-network';
import { Link, useForm } from "@inertiajs/react";
import InvestmentTerritory, { TerritoryFields } from "@/components/investment-territory";
import { Management, Field, button, control } from "../management/shared";
import { titles, statuses, stages, roles } from "./types";
import type { Structure, StructureRecord, Chain } from "./types";
import ValueNetwork from "./value-network";
import type { ChainLink, Gap } from "./value-network";
function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
const form = useForm({
code: chain?.code ?? '',
name: chain?.name ?? '',
stage: chain?.stage ?? 'input',
code: chain?.code ?? "",
name: chain?.name ?? "",
stage: chain?.stage ?? "input",
});
return (
<form
@@ -17,8 +18,7 @@ function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
onSubmit={(e) => {
e.preventDefault();
const url = `/management/clusters/${clusterId}/chains`;
if (chain)
form.put(`${url}/${chain.id}`, { preserveScroll: true });
if (chain) form.put(`${url}/${chain.id}`, { preserveScroll: true });
else
form.post(url, {
preserveScroll: true,
@@ -26,28 +26,26 @@ function ChainForm({ clusterId, chain }: { clusterId: string; chain?: Chain }) {
});
}}
>
<h3 className="font-semibold">
{chain ? chain.name : 'Nova etapa'}
</h3>
<h3 className="font-semibold">{chain ? chain.name : "Nova etapa"}</h3>
<Field label="Código da etapa" error={form.errors.code}>
<input
className={control}
value={form.data.code}
onChange={(e) => form.setData('code', e.target.value)}
onChange={(e) => form.setData("code", e.target.value)}
/>
</Field>
<Field label="Nome da etapa" error={form.errors.name}>
<input
className={control}
value={form.data.name}
onChange={(e) => form.setData('name', e.target.value)}
onChange={(e) => form.setData("name", e.target.value)}
/>
</Field>
<Field label="Fase da cadeia" error={form.errors.stage}>
<select
className={control}
value={form.data.stage}
onChange={(e) => form.setData('stage', e.target.value)}
onChange={(e) => form.setData("stage", e.target.value)}
>
{Object.entries(stages).map(([value, label]) => (
<option key={value} value={value}>
@@ -85,36 +83,47 @@ export default function Editor({
role: string;
}[];
}) {
const isProject = structure === 'projects';
const isProject = structure === "projects";
const form = useForm({
code: record?.code ?? '',
name: record?.name ?? '',
status: record?.status ?? (isProject ? 'draft' : 'planned'),
sector: record?.sector ?? '',
description: record?.description ?? '',
budget_amount: record?.budget_amount ?? '',
budget_currency: record?.budget_currency ?? '',
start_date: record?.start_date?.slice(0, 10) ?? '',
end_date: record?.end_date?.slice(0, 10) ?? '',
code: record?.code ?? "",
name: record?.name ?? "",
status: record?.status ?? (isProject ? "draft" : "planned"),
sector: record?.sector ?? "",
description: record?.description ?? "",
budget_amount: record?.budget_amount ?? "",
budget_currency: record?.budget_currency ?? "",
start_date: record?.start_date?.slice(0, 10) ?? "",
end_date: record?.end_date?.slice(0, 10) ?? "",
province_id: "",
district_id: "",
admin_post_id: "",
locality_id: "",
});
const association = useForm({ project_id: '', role: 'complementary' });
const association = useForm({ project_id: "", role: "complementary" });
return (
<Management
title={`${record ? 'Editar' : 'Criar'} ${isProject ? 'projecto' : 'cluster'}`}
>
<Management title={`${record ? "Editar" : "Criar"} ${isProject ? "projecto" : "cluster"}`}>
<Link className="underline" href={`/management/${structure}`}>
Voltar a {titles[structure].toLowerCase()}
</Link>
<p>
Registo interno de gestão. O estado activo não publica este
conteúdo no portal.
</p>
<p>Registo interno de gestão. O estado activo não publica este conteúdo no portal.</p>
{!record && (
<TerritoryFields
onChange={(path) =>
form.setData((data) => ({
...data,
province_id: path[0]?.id ?? "",
district_id: path[1]?.id ?? "",
admin_post_id: path[2]?.id ?? "",
locality_id: path[3]?.id ?? "",
}))
}
/>
)}
<form
className="max-w-3xl space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (record)
form.put(`/management/${structure}/${record.id}`);
if (record) form.put(`/management/${structure}/${record.id}`);
else form.post(`/management/${structure}`);
}}
>
@@ -122,29 +131,27 @@ export default function Editor({
<input
className={control}
value={form.data.code}
onChange={(e) => form.setData('code', e.target.value)}
onChange={(e) => form.setData("code", e.target.value)}
/>
</Field>
<Field label="Nome" error={form.errors.name}>
<input
className={control}
value={form.data.name}
onChange={(e) => form.setData('name', e.target.value)}
onChange={(e) => form.setData("name", e.target.value)}
/>
</Field>
<Field label="Estado" error={form.errors.status}>
<select
className={control}
value={form.data.status}
onChange={(e) => form.setData('status', e.target.value)}
onChange={(e) => form.setData("status", e.target.value)}
>
{Object.entries(statuses[structure]).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
{Object.entries(statuses[structure]).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</Field>
{!isProject && (
@@ -152,25 +159,18 @@ export default function Editor({
<input
className={control}
value={form.data.sector}
onChange={(e) =>
form.setData('sector', e.target.value)
}
onChange={(e) => form.setData("sector", e.target.value)}
/>
</Field>
)}
{isProject && (
<>
<Field
label="Descrição"
error={form.errors.description}
>
<Field label="Descrição" error={form.errors.description}>
<textarea
className={control}
rows={5}
value={form.data.description}
onChange={(e) =>
form.setData('description', e.target.value)
}
onChange={(e) => form.setData("description", e.target.value)}
/>
</Field>
<Field
@@ -181,12 +181,7 @@ export default function Editor({
inputMode="decimal"
className={control}
value={form.data.budget_amount}
onChange={(e) =>
form.setData(
'budget_amount',
e.target.value,
)
}
onChange={(e) => form.setData("budget_amount", e.target.value)}
/>
</Field>
<Field
@@ -198,37 +193,24 @@ export default function Editor({
className={control}
value={form.data.budget_currency}
onChange={(e) =>
form.setData(
'budget_currency',
e.target.value.toUpperCase(),
)
form.setData("budget_currency", e.target.value.toUpperCase())
}
/>
</Field>
<Field
label="Data de início"
error={form.errors.start_date}
>
<Field label="Data de início" error={form.errors.start_date}>
<input
type="date"
className={control}
value={form.data.start_date}
onChange={(e) =>
form.setData('start_date', e.target.value)
}
onChange={(e) => form.setData("start_date", e.target.value)}
/>
</Field>
<Field
label="Data de conclusão"
error={form.errors.end_date}
>
<Field label="Data de conclusão" error={form.errors.end_date}>
<input
type="date"
className={control}
value={form.data.end_date}
onChange={(e) =>
form.setData('end_date', e.target.value)
}
onChange={(e) => form.setData("end_date", e.target.value)}
/>
</Field>
</>
@@ -240,9 +222,7 @@ export default function Editor({
{!isProject && record && (
<>
<section className="max-w-3xl space-y-4">
<h2 className="text-xl font-semibold">
Projectos associados
</h2>
<h2 className="text-xl font-semibold">Projectos associados</h2>
{memberships.map((member) => (
<p key={member.id}>
<Link
@@ -250,93 +230,61 @@ export default function Editor({
href={`/management/projects/${member.project_id}/edit`}
>
{member.name}
</Link>{' '}
</Link>{" "}
· {roles[member.role]}
</p>
))}
{!memberships.length && (
<p>Ainda não existem projectos associados.</p>
)}
{!memberships.length && <p>Ainda não existem projectos associados.</p>}
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
association.post(
`/management/clusters/${record.id}/projects`,
{ preserveScroll: true },
);
association.post(`/management/clusters/${record.id}/projects`, {
preserveScroll: true,
});
}}
>
<Field
label="Projecto"
error={association.errors.project_id}
>
<Field label="Projecto" error={association.errors.project_id}>
<select
className={control}
value={association.data.project_id}
onChange={(e) =>
association.setData(
'project_id',
e.target.value,
)
association.setData("project_id", e.target.value)
}
>
<option value="">Seleccione</option>
{projects.map((project) => (
<option
key={project.id}
value={project.id}
>
<option key={project.id} value={project.id}>
{project.code} — {project.name}
</option>
))}
</select>
</Field>
<Field
label="Papel no cluster"
error={association.errors.role}
>
<Field label="Papel no cluster" error={association.errors.role}>
<select
className={control}
value={association.data.role}
onChange={(e) =>
association.setData(
'role',
e.target.value,
)
}
onChange={(e) => association.setData("role", e.target.value)}
>
{Object.entries(roles).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
{Object.entries(roles).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</Field>
<button
className={button}
disabled={association.processing}
>
<button className={button} disabled={association.processing}>
Guardar associação
</button>
<p className="text-sm">
Seleccionar um projecto já associado permite
actualizar o seu papel.
Seleccionar um projecto já associado permite actualizar o seu papel.
</p>
</form>
</section>
<section className="max-w-3xl space-y-4">
<h2 className="text-xl font-semibold">
Etapas da cadeia de valor
</h2>
<h2 className="text-xl font-semibold">Etapas da cadeia de valor</h2>
{chains.map((chain) => (
<ChainForm
key={chain.id}
clusterId={record.id}
chain={chain}
/>
<ChainForm key={chain.id} clusterId={record.id} chain={chain} />
))}
<ChainForm clusterId={record.id} />
</section>
@@ -348,6 +296,7 @@ export default function Editor({
/>
</>
)}
{record && <InvestmentTerritory kind={structure} id={record.id} />}
</Management>
);
}
+12
View File
@@ -11,12 +11,15 @@ use App\Http\Controllers\CrmController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EducationController;
use App\Http\Controllers\EducationInvestmentController;
use App\Http\Controllers\InvestmentCoordinatesController;
use App\Http\Controllers\InvestmentDiscoveryController;
use App\Http\Controllers\InvestmentPortfolioController;
use App\Http\Controllers\InvestmentStructureController;
use App\Http\Controllers\InvestmentTerritoryController;
use App\Http\Controllers\InvestorWorkspaceController;
use App\Http\Controllers\PortalManagementController;
use App\Http\Controllers\PortalServicesController;
use App\Http\Controllers\PublicGeographyController;
use App\Http\Controllers\PublicPortalController;
use App\Http\Middleware\AiEnabled;
use App\Http\Middleware\EducationEnabled;
@@ -80,7 +83,16 @@ Route::middleware(['auth', 'verified', 'can:manage-portal', AiEnabled::class])->
}
});
Route::get('/api/v1/investment/territory-options', [PublicGeographyController::class, 'options'])->middleware('throttle:api');
Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management')->name('management.')->group(function () {
Route::get('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'show'])->whereNumber('id');
Route::post('opportunities/{id}/coordinates', [InvestmentCoordinatesController::class, 'save'])->whereNumber('id');
Route::get('territory-options', [InvestmentTerritoryController::class, 'options'])->middleware('throttle:api');
Route::get('territory-subjects', [InvestmentTerritoryController::class, 'subjects'])->middleware('throttle:api');
Route::get('territory-associations', fn () => Inertia::render('management/territory-associations'));
Route::get('territories/{kind}/{id}', [InvestmentTerritoryController::class, 'show']);
Route::post('territories/{kind}/{id}', [InvestmentTerritoryController::class, 'save']);
$controller = PortalManagementController::class;
Route::prefix('education')->name('education.')->middleware(EducationEnabled::class)->group(function () {
Route::get('/', [EducationController::class, 'index'])->name('index');
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;
class InvestmentTerritoryTest extends TestCase
{
use RefreshDatabase;
public function test_confirmed_coordinates_create_a_marker_without_hiding_missing_records(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$sector = Sector::create(['name' => 'Synthetic map', 'slug' => 'synthetic-map', 'is_active' => true]);
$record = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'MAP-COORD', 'slug' => 'map-coord', 'title' => 'Synthetic marker', 'status' => 'published', 'published_at' => now()->subMinute()]);
$this->getJson('/api/v1/investment/map')->assertOk()->assertJsonPath('matched_count', 1)->assertJsonPath('complete', false)->assertJsonCount(1, 'unlocated');
$this->actingAs($manager);
$url = '/management/opportunities/'.$record->id.'/coordinates';
$payload = ['version' => 0, 'latitude' => -25.9, 'longitude' => 32.5, 'source_reference' => 'Synthetic coordinates for automated tests only', 'confirmed_site' => true];
$this->postJson($url, [...$payload, 'latitude' => 91])->assertUnprocessable();
$this->postJson($url, [...$payload, 'confirmed_site' => false])->assertUnprocessable();
$this->postJson($url, $payload)->assertOk()->assertJsonPath('version', 1);
$this->postJson($url, $payload)->assertConflict();
$this->getJson('/api/v1/investment/map')->assertOk()->assertJsonPath('complete', true)->assertJsonPath('features.0.properties.id', $record->id)->assertJsonPath('features.0.geometry.coordinates.0', 32.5);
$record->update(['status' => 'draft']);
$this->getJson('/api/v1/investment/map')->assertOk()->assertJsonCount(0, 'features')->assertJsonCount(0, 'unlocated');
}
public function test_public_cascade_filters_saved_locations_and_hides_unapproved_references(): void
{
$country = $this->location('country');
$province = $this->location('province', $country);
$district = $this->location('district', $province);
$other = $this->location('province', $country);
$private = $this->location('province', $country);
foreach ([$country, $province, $district, $other] as $id) {
DB::table('geo_locations')->where('id', $id)->update(['source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public']);
}
$sector = Sector::create(['name' => 'Synthetic', 'slug' => 'synthetic', 'is_active' => true]);
$opportunity = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'SYNTHETIC-PUBLIC', 'slug' => 'synthetic-public', 'title' => 'Synthetic', 'status' => 'published', 'published_at' => now()->subMinute(), 'primary_territory_id' => $district]);
$this->getJson('/api/v1/investment/territory-options?type=province')->assertOk()->assertJsonCount(2, 'data');
$this->getJson('/api/v1/investment/territory-options?type=district&parent_id='.$province)->assertOk()->assertJsonPath('data.0.id', $district);
$service = app(InvestmentCatalogue::class);
$this->assertSame([$opportunity->id], $service->query(['province_id' => $province])->pluck('id')->all());
$this->assertSame([], $service->query(['province_id' => $other])->pluck('id')->all());
$this->getJson('/oportunidades?province_id='.$other.'&district_id='.$district)->assertUnprocessable();
$this->getJson('/oportunidades?province_id='.$private)->assertUnprocessable();
$this->get('/oportunidades?province_id='.$province)->assertOk();
$this->assertCount(2, $service->context($opportunity->id)['primary_territory_path']);
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->post('/management/opportunities', ['sector_id' => $sector->id, 'reference' => 'SYNTHETIC-CREATE', 'slug' => 'synthetic-create', 'title' => 'Synthetic create',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
$this->assertDatabaseHas('opportunities', ['reference' => 'SYNTHETIC-CREATE', 'primary_territory_id' => $district, 'territory_version' => 1]);
$this->post('/management/clusters', ['code' => 'GEO-CREATE', 'name' => 'Synthetic cluster', 'sector' => 'Synthetic', 'status' => 'planned', 'province_id' => $province, 'district_id' => $district])->assertRedirect();
$this->assertDatabaseHas('economic_clusters', ['code' => 'GEO-CREATE', 'primary_territory_id' => $district]);
$manager->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
$zone = $this->postJson('/api/v2/investment/admin/zones', ['code' => 'GEO-ZEE', 'name' => 'Synthetic ZEE', 'category' => 'zee', 'province_id' => $province, 'district_id' => $district])->assertCreated()->json('id');
$this->assertDatabaseHas('investment_zones', ['id' => $zone, 'primary_territory_id' => $district]);
$this->getJson('/api/v2/investment/admin/zones?province_id='.$province)->assertOk()->assertJsonPath('records.total', 1);
$this->getJson('/api/v2/investment/admin/zones?province_id='.$other)->assertOk()->assertJsonPath('records.total', 0);
$this->postJson('/management/territories/zones/'.$zone, ['version' => 1, 'path' => [$province]])->assertOk();
$this->assertDatabaseHas('investment_zones', ['id' => $zone, 'primary_territory_id' => $province, 'version_no' => 2, 'publication_status' => 'draft']);
$this->assertSame(2, DB::table('investment_zone_revisions')->where('zone_id', $zone)->count());
DB::table('geo_locations')->where('id', $province)->update(['data_classification' => 'internal']);
$this->getJson('/api/v1/investment/territory-options?type=district&parent_id='.$province)->assertOk()->assertJsonCount(0, 'data');
}
public function test_cascade_assignment_persists_and_rejects_mismatched_parent_and_stale_write(): void
{
$user = User::factory()->create();
config(['portal.managers' => [$user->email]]);
$this->actingAs($user);
$province = $this->location('province');
$other = $this->location('province');
$district = $this->location('district', $province);
$cluster = (string) Str::uuid();
DB::table('economic_clusters')->insert(['id' => $cluster, 'code' => 'SYNTHETIC', 'name' => 'Synthetic cluster', 'sector' => 'Synthetic', 'status' => 'planned']);
$this->getJson('/management/territory-subjects?kind=clusters&q=Synthetic')->assertOk()->assertJsonPath('data.0.id', $cluster);
$this->get('/management/territory-associations')->assertOk();
$this->getJson('/management/territory-options?type=district&parent_id='.$province)->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', $district);
$this->getJson('/management/territory-options?type=district&parent_id='.$other)->assertOk()->assertJsonCount(0, 'data');
$url = '/management/territories/clusters/'.$cluster;
$this->postJson($url, ['version' => 0, 'path' => [$other, $district]])->assertUnprocessable();
$this->postJson($url, ['version' => 0, 'path' => [$province, $district]])->assertOk()->assertJsonPath('version', 1);
$this->getJson($url)->assertOk()->assertJsonPath('path.0.id', $district);
$this->assertDatabaseHas('economic_clusters', ['id' => $cluster, 'primary_territory_id' => $district]);
$this->postJson($url, ['version' => 0, 'path' => []])->assertConflict();
$this->postJson($url, ['version' => 1, 'path' => []])->assertOk();
$this->assertDatabaseHas('economic_clusters', ['id' => $cluster, 'primary_territory_id' => null]);
}
public function test_non_managers_cannot_read_internal_options(): void
{
config(['portal.managers' => []]);
$this->actingAs(User::factory()->create())->getJson('/management/territory-options?type=province')->assertForbidden();
}
private function location(string $type, ?string $parent = null): string
{
$id = (string) Str::uuid();
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Synthetic '.$id, 'location_type' => $type, 'parent_id' => $parent]);
return $id;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Tests\Unit;
use App\Services\TerritorialCsvAudit;
use PHPUnit\Framework\TestCase;
use RuntimeException;
class TerritorialCsvAuditTest extends TestCase
{
private string $path;
protected function setUp(): void
{
$this->path = tempnam(sys_get_temp_dir(), 'geo-csv-');
}
protected function tearDown(): void
{
unlink($this->path);
}
private function inspect(string $rows): array
{
file_put_contents($this->path, implode(';', TerritorialCsvAudit::HEADERS)."\n".$rows);
return (new TerritorialCsvAudit)->inspect($this->path, ['01']);
}
public function test_preserves_leading_zero_codes_and_counts_identical_duplicates(): void
{
$row = '01;Synthetic;0101;District;010101;Post;01010101;Locality;village;-12.5;35.1';
$report = $this->inspect($row."\n".$row);
$this->assertSame('structurally_valid_requires_review', $report['status']);
$this->assertSame(1, $report['duplicate_records']);
$this->assertSame(1, $report['candidate_counts_by_province']['01']['locality']);
$this->assertSame(hash_file('sha256', $this->path), $report['sha256']);
}
public function test_conflicting_names_and_coordinates_cancel_whole_dataset(): void
{
$report = $this->inspect("01;Synthetic;0101;First;010101;Post;01010101;Locality;village;0;0\n01;Synthetic;0101;Other;010101;Post;01010101;Locality;village;1;0");
$this->assertSame('rejected', $report['status']);
$this->assertContains('conflicting_code:district', $report['errors'][0]['issues']);
$this->assertContains('conflicting_code:locality', $report['errors'][0]['issues']);
}
public function test_no_inferred_parent_or_coordinates_and_unknown_province_are_rejected(): void
{
$report = $this->inspect('02;Synthetic;0201;District;;;02010101;Locality;village;91;');
$this->assertSame('rejected', $report['status']);
foreach (['unknown_province', 'locality_requires_reviewed_parent_exception', 'incomplete_coordinates', 'invalid_coordinate:latitude'] as $issue) {
$this->assertContains($issue, $report['errors'][0]['issues']);
}
}
public function test_district_only_rows_do_not_create_localities(): void
{
$report = $this->inspect('01;Synthetic;0101;District;;;;;;;');
$this->assertSame('structurally_valid_requires_review', $report['status']);
$this->assertArrayNotHasKey('locality', $report['candidate_counts_by_province']['01']);
}
public function test_empty_csv_is_rejected(): void
{
$this->assertSame('rejected', $this->inspect('')['status']);
}
public function test_wrong_delimiter_is_not_silently_interpreted(): void
{
file_put_contents($this->path, implode(',', TerritorialCsvAudit::HEADERS));
$this->expectException(RuntimeException::class);
(new TerritorialCsvAudit)->inspect($this->path, ['01']);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local', 'testing') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Local/testing PostgreSQL required.');
}
$path = storage_path('app/private/territory-workbook-candidates.json');
$data = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
$expected = '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff';
if ($data['checksum'] !== $expected || hash_file('sha256', 'C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx') !== $expected) {
throw new RuntimeException('Authorised source checksum mismatch.');
}
$created = DB::transaction(function () use ($data, $expected) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$created = 0;
foreach ($data['nodes'] as $node) {
$existing = DB::table('geo_locations')->where('id', $node['id'])->first();
if ($existing) {
foreach (['name', 'parent_id', 'location_type', 'code'] as $field) {
if ($node[$field] !== $existing->$field) {
throw new RuntimeException('Existing territory differs: '.$node['id']);
}
}
continue;
}
if ($node['parent_id'] && ! DB::table('geo_locations')->where('id', $node['parent_id'])->exists()) {
throw new RuntimeException('Missing parent.');
}
DB::table('geo_locations')->insert([...$node, 'source_system' => 'cartografia-workbook', 'external_id' => $expected.':'.$node['id'], 'data_classification' => 'internal']);
$created++;
}
if ($created) {
DB::table('audit_events')->insert(['source_system' => 'cartografia-workbook', 'entity_type' => 'geo_locations', 'action' => 'owner_authorised_reference_import',
'after_data' => json_encode(['checksum' => $expected, 'created' => $created, 'rejected_rows' => $data['rejected_rows'],
'approval_basis' => 'workspace_user_instructions', 'codes' => 'internal_identifiers_not_official_ine', 'classification' => 'internal'], JSON_THROW_ON_ERROR)]);
}
return $created;
});
echo json_encode(['created' => $created, 'rejected_rows' => count($data['rejected_rows']), 'scope' => 'internal_management_dropdowns'], JSON_THROW_ON_ERROR).PHP_EOL;
+29
View File
@@ -0,0 +1,29 @@
// Synthetic QA only; tile traffic is blocked to avoid automated OSM downloads.
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let sequence = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.text); if (message.id) { const callback = pending.get(message.id); pending.delete(message.id); message.error ? callback.reject(message.error) : callback.resolve(message.result); } };
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++sequence; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => { const response = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (response.exceptionDetails) throw Error(JSON.stringify(response.exceptionDetails)); return response.result?.value; };
const wait = async expression => { for (let i = 0; i < 150; i++) { if (await evaluate(expression)) return; await new Promise(resolve => setTimeout(resolve, 100)); } throw Error('Timed out: '+expression); };
try {
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable');
await send('Network.setBlockedURLs', { urls: ['*://tile.openstreetmap.org/*'] });
await send('Page.navigate', { url: 'http://127.0.0.1:18027/oportunidades' });
await wait(`document.querySelectorAll('.leaflet-marker-icon').length===1`);
await evaluate(`document.querySelector('.leaflet-marker-icon').click()`);
await wait(`document.querySelector('.leaflet-popup-content')?.textContent.includes('Synthetic map marker')`);
if (!await evaluate(`document.querySelector('.leaflet-popup-content a')?.getAttribute('href')==='/oportunidades/synthetic-map-browser'`)) throw Error('Missing detail link');
await evaluate(`document.querySelector('.leaflet-control-zoom-in').click()`);
const layouts = [];
for (const width of [390, 1440]) { await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 }); layouts.push({ width, noOverflow: await evaluate('document.documentElement.scrollWidth<=innerWidth+1') }); }
await evaluate(`window.__mapSentinel=1;document.querySelector('main form input').focus()`);
await send('Input.insertText', { text: 'NoMatchingSyntheticInvestment' });
await wait(`location.search.includes('NoMatchingSyntheticInvestment') && document.querySelectorAll('.leaflet-marker-icon').length===0`);
if (!await evaluate(`window.__mapSentinel===1`)) throw Error('Full page reload detected');
if (errors.length || layouts.some(l=>!l.noOverflow)) throw Error('Browser validation failed');
console.log(JSON.stringify({ marker: true, popup: true, detail_link: true, zoom_control: true, automatic_filter: true, no_full_reload: true, layouts, javascript_errors: errors.length, tiles_blocked_for_test: true }));
} finally { ws.close(); await fetch(`http://127.0.0.1:9224/json/close/${target.id}`); }
+22
View File
@@ -0,0 +1,22 @@
<?php
use App\Services\InvestmentCatalogue;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local', 'testing') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Local/testing PostgreSQL required.');
}
$report = DB::transaction(function () {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$map = app(InvestmentCatalogue::class)->map([]);
return ['generated_at' => date(DATE_ATOM), 'published_opportunities' => $map['matched_count'],
'georeferenced' => $map['georeferenced_count'], 'complete' => $map['complete'], 'truncated' => $map['truncated'],
'unlocated' => $map['unlocated'], 'geo_geometries' => DB::table('geo_geometries')->whereNull('deleted_at')->count(),
'zone_geometries' => DB::table('investment_zone_geometries')->count()];
});
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
+69
View File
@@ -0,0 +1,69 @@
"""B:G contain codes. Designacao names only the deepest populated level."""
import hashlib
import json
from pathlib import Path
import uuid
import xml.etree.ElementTree as ET
import zipfile
source = Path('C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx')
checksum = hashlib.sha256(source.read_bytes()).hexdigest()
if checksum != '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff':
raise ValueError('Authorised workbook changed')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
country = str(uuid.uuid5(uuid.NAMESPACE_URL, 'investmoz:workbook:MZ'))
nodes = {(): dict(id=country, parent_id=None, name='Moçambique', location_type='country', code='508')}
definitions = {}
conflicts = set()
rejected = []
ignored = 0
with zipfile.ZipFile(source) as archive:
strings = [''.join(si.itertext()) for si in ET.fromstring(archive.read('xl/sharedStrings.xml')).findall('s:si', ns)]
sheet = ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
for row in sheet.findall('s:sheetData/s:row', ns)[1:]:
cells = {}
invalid = False
for cell in row:
column = ''.join(c for c in cell.attrib['r'] if c.isalpha())
value = cell.findtext('s:v', '', ns)
if cell.attrib.get('t') == 's':
value = strings[int(value)]
elif cell.attrib.get('t') == 'inlineStr':
value = ''.join(cell.find('s:is', ns).itertext())
if cell.find('s:f', ns) is not None or cell.attrib.get('t') == 'e':
invalid = True
cells[column] = ' '.join(value.split())
if cells.get('F') or cells.get('G'):
ignored += 1
continue
segments = [cells.get(column, '') for column in 'BCDE']
while segments and not segments[-1]:
segments.pop()
if invalid or not segments or '' in segments or not cells.get('H'):
rejected.append({'row': row.attrib['r'], 'reason': 'missing_code_path_or_designation'})
continue
key = tuple(segments)
name = cells['H']
if key in conflicts:
rejected.append({'row': row.attrib['r'], 'reason': 'conflicting_designation'})
continue
if key in definitions and definitions[key]['name'] != name:
rejected.extend([{'row': definitions[key]['row'], 'reason': 'conflicting_designation'}, {'row': row.attrib['r'], 'reason': 'conflicting_designation'}])
conflicts.add(key)
del definitions[key]
continue
definitions[key] = {'name': name, 'row': row.attrib['r']}
for key, definition in sorted(definitions.items(), key=lambda item: (len(item[0]), item[0])):
if key[:-1] not in nodes:
rejected.append({'row': definition['row'], 'reason': 'missing_parent_definition'})
continue
parent = nodes[key[:-1]]['id']
kind = ['province', 'district', 'admin_post', 'locality'][len(key)-1]
identifier = str(uuid.uuid5(uuid.NAMESPACE_URL, parent + ':' + kind + ':' + key[-1].casefold()))
nodes[key] = dict(id=identifier, parent_id=parent, name=definition['name'], location_type=kind, code='XLSX-' + identifier)
output = Path('storage/app/private/territory-workbook-candidates.json')
previous = output.with_name('territory-workbook-candidates-before-designation-fix.json')
if output.exists() and not previous.exists():
previous.write_bytes(output.read_bytes())
output.write_text(json.dumps(dict(checksum=checksum, mapping_version=2, nodes=list(nodes.values()), rejected_rows=rejected, ignored_sub_locality_rows=ignored), ensure_ascii=False), encoding='utf-8')
print(json.dumps(dict(counts={kind: sum(n['location_type'] == kind for n in nodes.values()) for kind in ['country','province','district','admin_post','locality']}, rejected_rows=len(rejected), ignored_sub_locality_rows=ignored)))
+67
View File
@@ -0,0 +1,67 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Local PostgreSQL only.');
}
$checksum = '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff';
if (hash_file('sha256', 'C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx') !== $checksum) {
throw new RuntimeException('Source changed.');
}
$data = json_decode(file_get_contents(storage_path('app/private/territory-workbook-candidates.json')), true, 512, JSON_THROW_ON_ERROR);
$result = DB::transaction(function () use ($data, $checksum) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$ids = [];
foreach ($data['nodes'] as $node) {
$record = DB::table('geo_locations')->where('id', $node['id'])->where('source_system', 'cartografia-workbook')->where('external_id', $checksum.':'.$node['id'])->whereNull('deleted_at')->first();
if (! $record || ! in_array($record->data_classification, ['internal', 'public'], true)) {
throw new RuntimeException('Reference unavailable or reclassified.');
}
foreach (['name', 'parent_id', 'location_type', 'code'] as $field) {
if ($node[$field] !== $record->$field) {
throw new RuntimeException('Reference changed since import.');
}
}
$ids[] = $node['id'];
}
$published = DB::table('geo_locations')->whereIn('id', $ids)->where('data_classification', 'internal')->update(['data_classification' => 'public']);
$matched = $unmatched = [];
foreach (['opportunities', 'investment_zones'] as $table) {
foreach (DB::table($table)->whereNull('deleted_at')->whereNull('primary_territory_id')->whereNotNull('province')->get() as $record) {
$normalise = fn ($value) => mb_strtolower(Str::ascii(trim((string) $value)));
$province = DB::table('geo_locations')->whereIn('id', $ids)->where('location_type', 'province')->get()->filter(fn ($node) => $normalise($node->name) === $normalise($record->province))->values();
if ($province->count() !== 1) {
$unmatched[] = ['table' => $table, 'id' => $record->id, 'reason' => 'province_not_unique'];
continue;
}
$location = $province->first();
if (trim((string) $record->district) !== '') {
$district = DB::table('geo_locations')->whereIn('id', $ids)->where('location_type', 'district')->where('parent_id', $location->id)->get()->filter(fn ($node) => $normalise($node->name) === $normalise($record->district))->values();
if ($district->count() !== 1) {
$unmatched[] = ['table' => $table, 'id' => $record->id, 'reason' => 'district_not_unique'];
continue;
}
$location = $district->first();
}
DB::table($table)->where('id', $record->id)->update(['primary_territory_id' => $location->id, 'territory_version' => $record->territory_version + 1]);
$matched[] = ['table' => $table, 'id' => $record->id, 'territory_id' => $location->id];
}
}
$result = ['published_reference_names' => $published, 'matched' => $matched, 'unmatched' => $unmatched,
'basis' => 'owner_authorised_public_dropdowns', 'checksum' => $checksum, 'official_territory_publication' => false];
if ($published || $matched !== []) {
DB::table('audit_events')->insert(['source_system' => 'territory-reference-publication', 'entity_type' => 'geo_locations', 'action' => 'reference_names_enabled', 'after_data' => json_encode($result, JSON_THROW_ON_ERROR)]);
}
return $result;
});
file_put_contents(storage_path('app/private/territory-publication-'.date('Ymd-His').'.json'), json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
echo json_encode(['reference_names_enabled' => $result['published_reference_names'], 'exact_associations' => count($result['matched']), 'unmatched' => count($result['unmatched'])], JSON_THROW_ON_ERROR).PHP_EOL;
@@ -0,0 +1,73 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('This recorded instruction applies to the local PostgreSQL installation only.');
}
// Specific owner instruction received in this workspace; never impersonate an application user.
$directory = storage_path('app/private/geo-ine-review-20260928-v2');
$expected = [
'original.pdf' => '5c69b6f189a1d3f5307e0cf7dd8224554705158a682637d3105afad615db26e2',
'layout.txt' => '783a7886ed5a27b8f9d0063a787928e9a4b09723c28a260c019c2de215a670a3',
];
foreach ($expected as $file => $hash) {
if (! is_file($directory.'/'.$file) || hash_file('sha256', $directory.'/'.$file) !== $hash) {
throw new RuntimeException('Source differs from the reviewed package: '.$file);
}
}
$receipt = [
'schema_version' => 1,
'decision_date' => '2026-09-28',
'decision' => 'accepted_by_owner',
'validation' => 'declared_validated_by_owner',
'authenticity' => 'declared_authenticated_by_owner',
'approval_basis' => 'explicit_workspace_user_instruction',
'instruction' => 'aplicar os resultados e aceite os dados como validados e autenticados',
'source_checksums' => $expected,
'scope' => 'INE PDF and extraction package geo-ine-review-20260928-v2',
'independent_authentication_performed' => false,
'technical_mapping_status' => 'unresolved_column_alignment',
'territories_imported_by_this_decision' => 0,
];
$json = json_encode($receipt, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
$hash = hash('sha256', $json);
$path = $directory.'/owner-acceptance-'.$hash.'.json';
if (is_file($path)) {
if (file_get_contents($path) !== $json) {
throw new RuntimeException('Existing acceptance receipt differs.');
}
} else {
// Exclusive creation preserves any existing evidence.
$stream = fopen($path, 'x');
if ($stream === false) {
throw new RuntimeException('Could not create acceptance receipt.');
}
try {
if (fwrite($stream, $json) !== strlen($json)) {
throw new RuntimeException('Incomplete acceptance receipt.');
}
} finally {
fclose($stream);
}
}
$created = DB::transaction(function () use ($receipt, $hash): bool {
DB::select('SELECT pg_advisory_xact_lock(280926, 150001)');
if (DB::table('audit_events')->where('source_system', 'territorial-owner-acceptance')->where('external_id', $hash)->exists()) {
return false;
}
DB::table('audit_events')->insert([
'source_system' => 'territorial-owner-acceptance', 'external_id' => $hash,
'entity_type' => 'territorial_source_package', 'action' => 'owner_accepted',
'after_data' => json_encode($receipt, JSON_THROW_ON_ERROR),
]);
return true;
});
echo json_encode(['status' => 'accepted_by_owner', 'audit_created' => $created,
'receipt' => $path, 'territories_imported' => 0], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL;
+58
View File
@@ -0,0 +1,58 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Local PostgreSQL only.');
}
$root = storage_path('app/private/');
$before = json_decode(file_get_contents($root.'territory-workbook-candidates-before-designation-fix.json'), true, 512, JSON_THROW_ON_ERROR);
$after = json_decode(file_get_contents($root.'territory-workbook-candidates.json'), true, 512, JSON_THROW_ON_ERROR);
if (($after['mapping_version'] ?? 0) !== 2 || $before['checksum'] !== $after['checksum'] || hash_file('sha256', 'C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx') !== $after['checksum']) {
throw new RuntimeException('Source or mapping mismatch.');
}
$old = array_column($before['nodes'], null, 'id');
$new = array_column($after['nodes'], null, 'id');
$result = DB::transaction(function () use ($old, $new, $after) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$renamed = $quarantined = 0;
foreach ($old as $id => $prior) {
$record = DB::table('geo_locations')->where('id', $id)->where('source_system', 'cartografia-workbook')->where('external_id', $after['checksum'].':'.$id)->lockForUpdate()->first();
if (! $record || $record->parent_id !== $prior['parent_id'] || $record->code !== $prior['code'] || $record->location_type !== $prior['location_type']) {
throw new RuntimeException('Existing reference changed: '.$id);
}
$desired = $new[$id] ?? null;
if (! $desired) {
if ($record->deleted_at === null) {
DB::table('geo_locations')->where('id', $id)->update(['data_classification' => 'internal', 'deleted_at' => now(), 'version_no' => $record->version_no + 1]);
$quarantined++;
}
continue;
}
if ($record->name === $desired['name']) {
continue;
}
if ($record->name !== $prior['name'] || $record->deleted_at !== null) {
throw new RuntimeException('Manual edit must not be overwritten: '.$id);
}
DB::table('geo_locations')->where('id', $id)->update(['name' => $desired['name'], 'version_no' => $record->version_no + 1]);
$renamed++;
}
foreach ($new as $id => $node) {
if (! isset($old[$id])) {
throw new RuntimeException('Unexpected identity change: '.$id);
}
}
$result = ['renamed' => $renamed, 'quarantined' => $quarantined, 'mapping_version' => 2, 'checksum' => $after['checksum']];
if ($renamed || $quarantined) {
DB::table('audit_events')->insert(['source_system' => 'cartografia-workbook', 'entity_type' => 'geo_locations', 'action' => 'designation_mapping_corrected', 'after_data' => json_encode($result, JSON_THROW_ON_ERROR)]);
}
return $result;
});
echo json_encode($result, JSON_THROW_ON_ERROR).PHP_EOL;
+79
View File
@@ -0,0 +1,79 @@
"""Preserve an INE PDF and prepare a review package; never guess table joins.
The 2012 INE PDF contains independently wrapped columns. A line-based merge
would associate valid codes with the wrong district. This tool deliberately
exports the extracted lines for review instead of manufacturing such joins.
"""
import argparse
import csv
import hashlib
import json
from pathlib import Path
import re
import subprocess
def prepare(source, destination, executable, source_url, source_version):
source = Path(source).resolve(strict=True)
destination = Path(destination).resolve()
original = source.read_bytes()
if len(original) > 20 * 1024 * 1024 or not original.startswith(b"%PDF-"):
raise ValueError("Expected a PDF of at most 20 MiB")
# Never overwrite an earlier review or its preserved original.
destination.mkdir(parents=True, exist_ok=False)
(destination / "original.pdf").write_bytes(original)
version = subprocess.run([executable, "-v"], capture_output=True, timeout=10, check=False)
# Xpdf 4.00 exits 99 after printing its version; Poppler exits zero.
if version.returncode not in (0, 99) or b"pdftotext version" not in version.stdout + version.stderr:
raise RuntimeError("Could not identify the pdftotext executable")
subprocess.run(
[executable, "-enc", "UTF-8", "-layout", str(destination / "original.pdf"), str(destination / "layout.txt")],
capture_output=True, timeout=60, check=True,
)
extracted = (destination / "layout.txt").read_text(encoding="utf-8")
counts = {}
records = 0
with (destination / "review.csv").open("w", encoding="utf-8", newline="") as stream:
writer = csv.writer(stream, delimiter=";")
writer.writerow(["page", "line", "extracted_text", "issue"])
for page, contents in enumerate(extracted.split("\f"), 1):
for line, text in enumerate(contents.splitlines(), 1):
if not text.strip():
continue
records += 1
# Preserve text exactly. A code occurrence is not a joined territory.
code = re.match(r"^\s*([0-9]{6})\s", text)
if code:
prefix = code.group(1)[:2]
counts[prefix] = counts.get(prefix, 0) + 1
writer.writerow([page, line, text, "manual_column_alignment_required"])
report = {
"schema_version": 1,
"source_url": source_url,
"source_version_declared": source_version,
"sha256": hashlib.sha256(original).hexdigest(),
"extraction_sha256": hashlib.sha256((destination / "layout.txt").read_bytes()).hexdigest(),
"extractor": (version.stdout + version.stderr).decode("utf-8", errors="replace").strip(),
"status": "requires_steward_review",
"license_status": "unverified",
"nonempty_extracted_lines": records,
"lines_requiring_review": records,
"automatically_accepted_territories": 0,
"observed_admin_post_code_lines_by_prefix": dict(sorted(counts.items())),
"source_totals_comparison": "pending_independent_visual_review",
"notice": "Code occurrences are not validated totals. No administrative relation or locality was inferred."
}
(destination / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source")
parser.add_argument("destination")
parser.add_argument("--pdftotext", required=True)
parser.add_argument("--source-url", required=True)
parser.add_argument("--source-version", required=True)
args = parser.parse_args()
report = prepare(args.source, args.destination, args.pdftotext, args.source_url, args.source_version)
print(json.dumps({"status": report["status"], "sha256": report["sha256"], "review_lines": report["lines_requiring_review"]}))
+39
View File
@@ -0,0 +1,39 @@
// Synthetic QA server only; never writes to the application database.
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let sequence = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.text); if (message.id) { const callback = pending.get(message.id); pending.delete(message.id); message.error ? callback.reject(message.error) : callback.resolve(message.result); } };
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++sequence; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => { const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw Error(JSON.stringify(result.exceptionDetails)); return result.result?.value; };
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(resolve => setTimeout(resolve, 100)); } throw Error('Timed out: ' + expression); };
const select = async (index, value) => evaluate(`(()=>{const element=document.querySelectorAll('select')[${index}];Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(element,${JSON.stringify(value)});element.dispatchEvent(new Event('change',{bubbles:true}));})()`);
try {
await send('Runtime.enable'); await send('Page.enable');
await send('Page.navigate', { url: 'http://127.0.0.1:18027/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
for (const [selector, text] of [['input[type="email"]', 'mne-browser@example.test'], ['input[type="password"]', 'SyntheticMneOnly-2026!']]) { await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`); await send('Input.insertText', { text }); }
await evaluate(`document.querySelector('button[type="submit"]').click()`); await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: 'http://127.0.0.1:18027/management/territory-associations' });
await wait(`document.querySelectorAll('select').length===2`);
await select(0, 'clusters');
await wait(`document.querySelectorAll('select')[1].options.length>1`);
await select(1, '20000000-0000-4000-8000-000000000001');
await wait(`document.querySelectorAll('select').length===6`);
for (let level = 0; level < 4; level++) {
await wait(`!document.querySelectorAll('select')[${level+2}].disabled && document.querySelectorAll('select')[${level+2}].options.length>1`);
await select(level+2, `10000000-0000-4000-8000-${String(level+2).padStart(12, '0')}`);
}
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Guardar localização').click()`);
await wait(`document.body.innerText.includes('Localização associada e guardada.')`);
await select(2, '');
await wait(`[...document.querySelectorAll('section[aria-label="Localização do investimento"] select')].slice(1).every(s=>s.value===''&&s.disabled)`);
const widths = [];
for (const width of [390, 1440]) { await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 }); widths.push({ width, noOverflow: await evaluate('document.documentElement.scrollWidth<=innerWidth+1') }); }
if (errors.length || widths.some(w => !w.noOverflow)) throw Error('Browser validation failed');
console.log(JSON.stringify({ cascade: true, save: true, reset_descendants: true, widths, javascript_errors: errors.length }));
} finally { ws.close(); await fetch(`http://127.0.0.1:9224/json/close/${target.id}`); }
+36
View File
@@ -0,0 +1,36 @@
// Read-only checks against the local public portal and real reference names.
const base = 'http://127.0.0.1:8000';
const provinces = await (await fetch(`${base}/api/v1/investment/territory-options?type=province`)).json();
if (provinces.data.length !== 11 || provinces.data.some(p => /^\d+$/.test(p.name))) throw Error('Province designations invalid');
const province = provinces.data.find(p => p.name === 'Zambezia');
if (!province) throw Error('Expected source designation missing');
const result = await (await fetch(`${base}/api/v1/investment/search?province_id=${province.id}`)).json();
if (result.total < 1) throw Error('Existing opportunities not linked to province');
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let sequence = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => { const message = JSON.parse(event.data); if (message.method === 'Network.requestWillBeSent' && message.params.request.url.includes('/oportunidades?')) console.log(message.params.request.url); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.text); if (message.id) { const callback = pending.get(message.id); pending.delete(message.id); message.error ? callback.reject(message.error) : callback.resolve(message.result); } };
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++sequence; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => { const response = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (response.exceptionDetails) throw Error(JSON.stringify(response.exceptionDetails)); return response.result?.value; };
const wait = async expression => { for (let i = 0; i < 100; i++) { if (await evaluate(expression)) return; await new Promise(resolve => setTimeout(resolve, 150)); } throw Error('Timed out: '+expression); };
const element = label => `document.getElementById([...document.querySelectorAll('label')].find(l=>l.textContent===${JSON.stringify(label)})?.htmlFor)`;
try {
await send('Runtime.enable'); await send('Page.enable'); await send('Network.enable');
await send('Page.navigate', { url: `${base}/oportunidades` });
await wait(`${element('Província')}?.options.length===12`);
await evaluate(`(()=>{const e=${element('Província')};Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(e,${JSON.stringify(province.id)});e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await wait(`${element('Distrito')}?.options.length>1`);
const districtNames = await evaluate(`[...${element('Distrito')}.options].slice(1).map(o=>o.textContent)`);
if (districtNames.some(n => /^\d+$/.test(n))) throw Error('District designations invalid');
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='Aplicar filtros').click()`);
await wait(`location.search.includes('province_id=') && ${element('Província')}?.value===${JSON.stringify(province.id)}`);
await send('Page.reload');
await wait(`${element('Província')}?.value===${JSON.stringify(province.id)} && ${element('Distrito')}?.options.length>1`);
const layouts = [];
for (const width of [390, 1440]) { await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 }); layouts.push({ width, noOverflow: await evaluate('document.documentElement.scrollWidth<=innerWidth+1') }); }
if (errors.length || layouts.some(l=>!l.noOverflow)) throw Error('Browser check failed');
console.log(JSON.stringify({ named_provinces: 11, named_district_options: districtNames.length, matching_opportunities: result.total, persisted_url_filter: true, layouts, javascript_errors: errors.length }));
} catch (error) { console.log(JSON.stringify(await evaluate(`({url:location.href,alerts:[...document.querySelectorAll('[role="alert"]')].map(e=>e.textContent),selected:[...document.querySelectorAll('select')].map(e=>e.value)})`))); throw error; } finally { ws.close(); await fetch(`http://127.0.0.1:9224/json/close/${target.id}`); }