varias alteracoes
This commit is contained in:
@@ -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']));
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
'© <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>
|
||||
);
|
||||
}
|
||||
@@ -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,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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`); }
|
||||
@@ -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;
|
||||
@@ -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)))
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"]}))
|
||||
@@ -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}`); }
|
||||
@@ -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}`); }
|
||||
Reference in New Issue
Block a user