diff --git a/app/Console/Commands/ImportTerritoryWorkbook.php b/app/Console/Commands/ImportTerritoryWorkbook.php new file mode 100644 index 00000000..acadfe3c --- /dev/null +++ b/app/Console/Commands/ImportTerritoryWorkbook.php @@ -0,0 +1,36 @@ +environment('local', 'testing')) { + $this->error('Local/testing only.'); + + return self::FAILURE; + } + foreach ([[strval($this->option('python')), base_path('tools/prepare-territory-workbook.py')], + ...($this->option('inspect') ? [] : [[PHP_BINARY, base_path('tools/import-territory-workbook.php')]])] as $command) { + $process = new Process($command, base_path()); + $process->setTimeout(300); + $process->run(); + $this->output->write($process->getOutput()); + if (! $process->isSuccessful()) { + $this->error($process->getErrorOutput()); + + return self::FAILURE; + } + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/InvestmentStructureController.php b/app/Http/Controllers/InvestmentStructureController.php index 087551c4..653d7186 100644 --- a/app/Http/Controllers/InvestmentStructureController.php +++ b/app/Http/Controllers/InvestmentStructureController.php @@ -82,7 +82,7 @@ class InvestmentStructureController extends Controller } $data = $request->validate($rules); if (! $id && $request->has('province_id')) { - $path = app(InvestmentGeography::class)->selection($data); + $path = app(InvestmentGeography::class)->selection($data, true); $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; diff --git a/app/Http/Controllers/InvestmentTerritoryController.php b/app/Http/Controllers/InvestmentTerritoryController.php index f12a30d5..09561095 100644 --- a/app/Http/Controllers/InvestmentTerritoryController.php +++ b/app/Http/Controllers/InvestmentTerritoryController.php @@ -30,13 +30,18 @@ class InvestmentTerritoryController extends Controller $query = DB::table('geo_locations')->whereNull('deleted_at')->where('location_type', $data['type']) ->whereIn('data_classification', ['public', 'internal']); if ($data['type'] !== 'province') { + $parentType = ['district' => 'province', 'admin_post' => 'district', 'locality' => 'admin_post'][$data['type']]; + if (! DB::table('geo_locations')->where('id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal'])->where('location_type', $parentType)->exists()) { + throw ValidationException::withMessages(['parent_id' => 'Seleccione um pai activo do nível anterior.']); + } $query->where('parent_id', $data['parent_id'] ?? '00000000-0000-0000-0000-000000000000'); } if (! empty($data['q'])) { - $query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']); + $term = '%'.addcslashes($data['q'], '%_\\').'%'; + $query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term])); } - return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type'])); + return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])); } public function show(string $kind, string $id): JsonResponse diff --git a/app/Http/Controllers/InvestmentZoneController.php b/app/Http/Controllers/InvestmentZoneController.php index eb0d52df..7f8d4cc8 100644 --- a/app/Http/Controllers/InvestmentZoneController.php +++ b/app/Http/Controllers/InvestmentZoneController.php @@ -78,7 +78,7 @@ class InvestmentZoneController extends Controller 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); + $path = app(InvestmentGeography::class)->selection($geo, true); $data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id; $data['territory_version'] = $path === [] ? 0 : 1; $data['province'] = $path[0]->name ?? null; diff --git a/app/Http/Controllers/PortalManagementController.php b/app/Http/Controllers/PortalManagementController.php index 59994e07..de3eb8da 100644 --- a/app/Http/Controllers/PortalManagementController.php +++ b/app/Http/Controllers/PortalManagementController.php @@ -140,10 +140,12 @@ class PortalManagementController extends Controller public function edit(string $resource, ?int $id = null): Response { $model = $resource === 'sectors' ? Sector::class : Opportunity::class; + $record = $id ? $model::findOrFail($id) : null; return Inertia::render('management/editor', [ 'resource' => $resource, - 'record' => $id ? $model::findOrFail($id) : null, + 'record' => $record, + 'territoryPath' => $record instanceof Opportunity ? app(InvestmentGeography::class)->path($record->primary_territory_id, true) : [], 'sectors' => Sector::orderBy('name')->get(['id', 'name']), ]); } @@ -178,20 +180,13 @@ class PortalManagementController extends Controller 'status' => ['required', Rule::in(['draft', 'published', 'archived'])], 'is_featured' => ['required', 'boolean'], ]; - if ($resource === 'opportunities' && ! $id) { + if ($resource === 'opportunities') { foreach (InvestmentGeography::FIELDS as $field => $type) { $rules[$field] = ['nullable', 'uuid']; } + $rules['territory_version'] = [$id && $request->hasAny(array_keys(InvestmentGeography::FIELDS)) ? 'required' : 'nullable', 'integer', 'min:0']; } $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()) { @@ -205,7 +200,24 @@ class PortalManagementController extends Controller $data['maturity_label'] = $labels[$data['maturity_level'] ?? 0] ?? null; $data['published_at'] = $data['status'] === 'published' ? ($record->published_at ?? now()) : null; } - PortalAudit::save($record, $data, $request->user()->id); + DB::transaction(function () use ($record, $data, $request, $resource) { + if ($resource === 'opportunities' && $request->hasAny(array_keys(InvestmentGeography::FIELDS))) { + DB::select('SELECT pg_advisory_xact_lock(280926, 150000)'); + if ($record->exists) { + $current = Opportunity::whereKey($record->id)->lockForUpdate()->firstOrFail(); + abort_unless((int) $current->territory_version === (int) $data['territory_version'], 409, 'Localização alterada. Recarregue a ficha.'); + } + $path = app(InvestmentGeography::class)->selection($data, true); + $data = array_diff_key($data, InvestmentGeography::FIELDS); + $data['primary_territory_id'] = $path === [] ? null : $path[array_key_last($path)]->id; + $data['territory_version'] = (int) ($data['territory_version'] ?? 0) + 1; + $data['province'] = $path[0]->name ?? null; + $data['district'] = $path[1]->name ?? null; + } else { + unset($data['territory_version']); + } + PortalAudit::save($record, $data, $request->user()->id); + }); return redirect('/management/'.$resource)->with('success', 'Registo guardado.'); } diff --git a/app/Http/Controllers/PublicGeographyController.php b/app/Http/Controllers/PublicGeographyController.php index f8c92834..4050f551 100644 --- a/app/Http/Controllers/PublicGeographyController.php +++ b/app/Http/Controllers/PublicGeographyController.php @@ -18,9 +18,10 @@ class PublicGeographyController extends Controller $query->where('parent_id', $parent ?? '00000000-0000-0000-0000-000000000000'); } if (! empty($data['q'])) { - $query->whereRaw('name ILIKE ?', ['%'.addcslashes($data['q'], '%_\\').'%']); + $term = '%'.addcslashes($data['q'], '%_\\').'%'; + $query->where(fn ($q) => $q->whereRaw('name ILIKE ?', [$term])->orWhereRaw('code ILIKE ?', [$term])->orWhereRaw('source_code ILIKE ?', [$term])); } - return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type'])); + return response()->json($query->orderBy('name')->orderBy('id')->paginate(100, ['id', 'name', 'parent_id', 'location_type', 'source_code'])); } } diff --git a/app/Http/Middleware/PreserveFormFeedback.php b/app/Http/Middleware/PreserveFormFeedback.php new file mode 100644 index 00000000..f8fb0155 --- /dev/null +++ b/app/Http/Middleware/PreserveFormFeedback.php @@ -0,0 +1,20 @@ +isMethod('GET') && $request->expectsJson() && ! $request->header('X-Inertia') && $request->hasSession()) { + $request->session()->keep(['errors', '_old_input', 'success']); + } + + return $next($request); + } +} diff --git a/app/Models/GeoLocation.php b/app/Models/GeoLocation.php new file mode 100644 index 00000000..f0fab44e --- /dev/null +++ b/app/Models/GeoLocation.php @@ -0,0 +1,29 @@ + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + /** @return HasMany */ + public function opportunities(): HasMany + { + return $this->hasMany(Opportunity::class, 'primary_territory_id'); + } +} diff --git a/app/Models/Opportunity.php b/app/Models/Opportunity.php index 7ac3ce1c..1fc94b0e 100644 --- a/app/Models/Opportunity.php +++ b/app/Models/Opportunity.php @@ -7,7 +7,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -/** @property string|null $primary_territory_id */ +/** + * @property string|null $primary_territory_id + * @property int $territory_version + */ class Opportunity extends Model { use SoftDeletes; @@ -24,6 +27,7 @@ class Opportunity extends Model protected $casts = [ 'is_featured' => 'boolean', + 'territory_version' => 'integer', 'published_at' => 'datetime', 'last_validated_at' => 'datetime', 'estimated_investment_amount' => 'decimal:2', @@ -35,6 +39,12 @@ class Opportunity extends Model return $this->belongsTo(Sector::class); } + /** @return BelongsTo */ + public function primaryTerritory(): BelongsTo + { + return $this->belongsTo(GeoLocation::class, 'primary_territory_id'); + } + /** @param Builder $query * @return Builder */ diff --git a/app/Services/InvestmentGeography.php b/app/Services/InvestmentGeography.php index efb10ced..14beeeea 100644 --- a/app/Services/InvestmentGeography.php +++ b/app/Services/InvestmentGeography.php @@ -26,10 +26,17 @@ final class InvestmentGeography }); } + public function references(bool $management = false): Builder + { + return $management + ? DB::table('geo_locations')->whereNull('deleted_at')->whereIn('data_classification', ['public', 'internal']) + : $this->publicReferences(); + } + /** @param array $input * @return list<\stdClass> */ - public function selection(array $input): array + public function selection(array $input, bool $management = false): array { $path = []; $parent = null; @@ -40,7 +47,7 @@ final class InvestmentGeography continue; } - $node = $this->publicReferences()->where('id', $input[$field])->where('location_type', $type)->first(['id', 'name', 'parent_id', 'location_type']); + $node = $this->references($management)->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.']); } @@ -64,11 +71,12 @@ final class InvestmentGeography } /** @return list<\stdClass> */ - public function path(?string $id): array + public function path(?string $id, bool $management = false): array { $path = []; for ($i = 0; $id && $i < 5; $i++) { - $node = $this->publicReferences()->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type']); + $query = $management ? DB::table('geo_locations') : $this->publicReferences(); + $node = $query->where('id', $id)->first(['id', 'name', 'parent_id', 'location_type', 'deleted_at']); if (! $node) { return []; } diff --git a/bootstrap/app.php b/bootstrap/app.php index e95b56b4..e48105c2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\PreserveFormFeedback; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -21,6 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ HandleAppearance::class, HandleInertiaRequests::class, + PreserveFormFeedback::class, AddLinkHeadersForPreloadedAssets::class, ]); }) diff --git a/database/migrations/2026_09_29_100000_territory_integrity.php b/database/migrations/2026_09_29_100000_territory_integrity.php new file mode 100644 index 00000000..1f3bb84d --- /dev/null +++ b/database/migrations/2026_09_29_100000_territory_integrity.php @@ -0,0 +1,56 @@ +expected_type) THEN + RAISE EXCEPTION 'Invalid geographic parent'; + END IF; + IF EXISTS(WITH RECURSIVE ancestors AS ( + SELECT id,parent_id,ARRAY[id] as visited FROM geo_locations WHERE id=NEW.parent_id + UNION ALL SELECT g.id,g.parent_id,a.visited||g.id FROM geo_locations g JOIN ancestors a ON g.id=a.parent_id WHERE NOT g.id=ANY(a.visited) + ) SELECT 1 FROM ancestors WHERE id=NEW.id) THEN RAISE EXCEPTION 'Geographic cycle'; END IF; + ELSIF expected_type IS NOT NULL AND NEW.source_system='cartografia-workbook' THEN + RAISE EXCEPTION 'Workbook administrative node requires a parent'; + END IF; + IF EXISTS(SELECT 1 FROM geo_locations c WHERE c.parent_id=NEW.id AND c.deleted_at IS NULL AND + CASE c.location_type WHEN 'province' THEN 'country' WHEN 'district' THEN 'province' WHEN 'admin_post' THEN 'district' WHEN 'locality' THEN 'admin_post' END <> NEW.location_type) THEN + RAISE EXCEPTION 'Geographic children incompatible with new level'; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER geo_hierarchy_guard BEFORE INSERT OR UPDATE OF parent_id,location_type,deleted_at,source_system ON geo_locations FOR EACH ROW EXECUTE FUNCTION enforce_geo_hierarchy(); +SQL); + } + + public function down(): void + { + if (DB::table('geo_locations')->whereNotNull('source_code')->exists()) { + throw new RuntimeException('Preserve imported source codes before rollback.'); + } + DB::statement('DROP TRIGGER geo_hierarchy_guard ON geo_locations'); + DB::statement('DROP FUNCTION enforce_geo_hierarchy()'); + DB::statement('DROP INDEX geo_source_parent_code_uq'); + DB::statement('DROP INDEX geo_parent_type_name_idx'); + DB::statement('ALTER TABLE geo_locations DROP COLUMN source_code'); + } +}; diff --git a/database/seeders/InvestmentAreaSeeder.php b/database/seeders/InvestmentAreaSeeder.php new file mode 100644 index 00000000..1cf4aecd --- /dev/null +++ b/database/seeders/InvestmentAreaSeeder.php @@ -0,0 +1,30 @@ +get(['name'])->contains(fn ($sector) => str_replace('-', '', Str::slug($sector->name)) === $key); + if ($exists) { + continue; + } + $slug = Str::slug($name); + if (Sector::withTrashed()->where('slug', $slug)->exists()) { + $slug .= '-especifica'; + } + Sector::create(['name' => $name, 'slug' => $slug, 'is_active' => true, 'sort_order' => 100, 'published_at' => now()]); + } + }); + } +} diff --git a/deploy/docker/.gitignore b/deploy/docker/.gitignore new file mode 100644 index 00000000..11ee7581 --- /dev/null +++ b/deploy/docker/.gitignore @@ -0,0 +1 @@ +.env.local diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 00000000..91d3883f --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,15 @@ +# Ambiente de desenvolvimento em Compose + +Reutiliza Laravel Sail 8.5 de `composer.lock`, Node 24, PostgreSQL/PostGIS da configuração CI e Nginx. Cache e filas usam PostgreSQL, conforme a aplicação existente. Não é um perfil de produção: o servidor HTTP é o servidor de desenvolvimento do Sail, ligado apenas a localhost através do proxy. + +Pré-requisitos: Docker Engine com Compose, contentores Linux e `composer install` concluído. Na raiz: + +```powershell +./deploy/docker/start.ps1 +``` + +O script gera chaves próprias em `.env.local`, ignorado pelo Git; nunca imprime segredos nem altera a `.env` da aplicação existente. Base de dados e storage têm volumes próprios. Conserva estes volumes e a chave para restauro. Não executar `down -v` para uma actualização. O código e os assets de desenvolvimento são partilhados com o workspace. + +Verificação em 29/09/2026: **não executado em Docker**. `docker version` falhou: `The term 'docker' is not recognized as the name of a cmdlet, function, script file, or operable program.` É necessário disponibilizar Docker Engine/Compose e executar o script e os health checks. A presença destes ficheiros não comprova o gate de reprodutibilidade. + +O disco S3 já tem configuração em `config/filesystems.php`, mas falta instalar e testar o adaptador Flysystem S3 e disponibilizar um endpoint autorizado. O perfil usa storage local isolado até essa validação. TLS, métricas, scheduler, limites de recursos e escalabilidade de produção continuam no backlog; modelos Linux existentes permanecem válidos em `deploy/linux`. diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml new file mode 100644 index 00000000..685d43f1 --- /dev/null +++ b/deploy/docker/compose.yaml @@ -0,0 +1,83 @@ +name: investmoz-development +services: + app: + build: + context: ../../vendor/laravel/sail/runtimes/8.5 + args: + WWWGROUP: '${WWWGROUP:-1000}' + NODE_VERSION: '24' + POSTGRES_VERSION: '17' + image: investmoz-development-php85 + environment: &application + WWWUSER: '${WWWUSER:-1000}' + APP_ENV: local + APP_DEBUG: 'false' + APP_KEY: '${APP_KEY:?Generate the isolated development key first}' + APP_URL: 'http://localhost:${PORTAL_PORT:-8088}' + DB_CONNECTION: pgsql + DB_HOST: postgres + DB_PORT: '5432' + DB_DATABASE: investmoz_development + DB_USERNAME: investmoz + DB_PASSWORD: '${DB_PASSWORD:?Generate a local database password first}' + DB_URL: '' + CACHE_STORE: database + QUEUE_CONNECTION: database + SESSION_DRIVER: database + SESSION_SECURE_COOKIE: 'false' + MAIL_MAILER: array + PORTAL_NOTIFICATIONS_ENABLED: 'false' + LOG_CHANNEL: stderr + FILESYSTEM_DISK: local + volumes: + - ../..:/var/www/html + - ./runtime.env.example:/var/www/html/.env:ro + - application-storage:/var/www/html/storage + - application-cache:/var/www/html/bootstrap/cache + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '--fail', 'http://127.0.0.1/up'] + interval: 15s + timeout: 5s + retries: 5 + web: + image: nginx:stable-alpine + ports: + - '127.0.0.1:${PORTAL_PORT:-8088}:80' + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + app: + condition: service_healthy + queue: + image: investmoz-development-php85 + environment: + <<: *application + SUPERVISOR_PHP_COMMAND: '/usr/bin/php /var/www/html/artisan queue:work --sleep=3 --tries=3 --timeout=90' + volumes: + - ../..:/var/www/html + - ./runtime.env.example:/var/www/html/.env:ro + - application-storage:/var/www/html/storage + - application-cache:/var/www/html/bootstrap/cache + depends_on: + app: + condition: service_healthy + postgres: + image: postgis/postgis:17-3.5 + environment: + POSTGRES_DB: investmoz_development + POSTGRES_USER: investmoz + POSTGRES_PASSWORD: '${DB_PASSWORD:?Generate a local database password first}' + volumes: + - database:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U investmoz -d investmoz_development'] + interval: 5s + timeout: 5s + retries: 10 +volumes: + database: + application-storage: + application-cache: diff --git a/deploy/docker/nginx.conf b/deploy/docker/nginx.conf new file mode 100644 index 00000000..092f4751 --- /dev/null +++ b/deploy/docker/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name _; + client_max_body_size 10m; + location ~ /\. { deny all; } + location / { + proxy_pass http://app:80; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/docker/runtime.env.example b/deploy/docker/runtime.env.example new file mode 100644 index 00000000..66a36c60 --- /dev/null +++ b/deploy/docker/runtime.env.example @@ -0,0 +1,8 @@ +# Non-secret fallback only. Deployment-specific values are supplied by Compose. +APP_NAME=InvestMoz +APP_ENV=local +APP_DEBUG=false +LOG_CHANNEL=stderr +MAIL_MAILER=array +PORTAL_NOTIFICATIONS_ENABLED=false +INVESTMOZ_AI_ENABLED=false diff --git a/deploy/docker/start.ps1 b/deploy/docker/start.ps1 new file mode 100644 index 00000000..71f95a1f --- /dev/null +++ b/deploy/docker/start.ps1 @@ -0,0 +1,32 @@ +$ErrorActionPreference = 'Stop' +Get-Command docker -ErrorAction Stop | Out-Null +$workspace = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$settings = Join-Path $PSScriptRoot '.env.local' +if (-not (Test-Path -LiteralPath $settings)) { + $keyBytes = New-Object byte[] 32 + $passwordBytes = New-Object byte[] 32 + $generator = [System.Security.Cryptography.RandomNumberGenerator]::Create() + $generator.GetBytes($keyBytes) + $generator.GetBytes($passwordBytes) + $generator.Dispose() + $contents = "APP_KEY=base64:$([Convert]::ToBase64String($keyBytes))`nDB_PASSWORD=$([Convert]::ToBase64String($passwordBytes))`nPORTAL_PORT=8088`n" + [IO.File]::WriteAllText($settings, $contents, (New-Object Text.UTF8Encoding($false))) +} +if (-not (Test-Path -LiteralPath (Join-Path $workspace 'vendor/laravel/sail/runtimes/8.5/Dockerfile'))) { + throw 'Run composer install using composer.lock before starting the existing Sail runtime.' +} +$compose = @('compose', '--env-file', $settings, '-f', (Join-Path $PSScriptRoot 'compose.yaml')) +function Invoke-Compose([string[]] $ComposeArguments) { + & docker @compose @ComposeArguments + if ($LASTEXITCODE -ne 0) { throw "Docker Compose failed with exit code $LASTEXITCODE" } +} +Invoke-Compose -ComposeArguments @('config', '--quiet') +Invoke-Compose -ComposeArguments @('build', 'app') +Invoke-Compose -ComposeArguments @('up', '-d', 'postgres') +Invoke-Compose -ComposeArguments @('run', '--rm', 'app', 'sh', '-lc', 'mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && chown -R sail:sail storage bootstrap/cache') +Invoke-Compose -ComposeArguments @('run', '--rm', 'app', 'php', 'artisan', 'config:clear') +Invoke-Compose -ComposeArguments @('run', '--rm', 'app', 'php', 'artisan', 'migrate', '--force') +Invoke-Compose -ComposeArguments @('run', '--rm', 'app', 'npm', 'ci') +Invoke-Compose -ComposeArguments @('run', '--rm', 'app', 'npm', 'run', 'build') +Invoke-Compose -ComposeArguments @('up', '-d', 'app', 'web', 'queue') +Write-Output 'Local development: http://localhost:8088. Inspect docker compose ps and /up before use.' diff --git a/docs/investmoz/gis/opportunity-dropdown-browser.json b/docs/investmoz/gis/opportunity-dropdown-browser.json new file mode 100644 index 00000000..6f9048a3 --- /dev/null +++ b/docs/investmoz/gis/opportunity-dropdown-browser.json @@ -0,0 +1,95 @@ +{ + "date": "2026-09-29T11:28:43.868Z", + "observations": [ + "create_save_reopen_full_path", + "validation_preserves_selection", + "edit_save_reopen_and_descendant_reset", + "network_failure_and_retry", + "empty_search", + "rapid_parent_change_cancels_and_clears_children" + ], + "requests": [ + { + "method": "POST", + "path": "/management/opportunities", + "payload": { + "province_id": "10000000-0000-4000-8000-000000000002", + "district_id": "10000000-0000-4000-8000-000000000003", + "admin_post_id": "10000000-0000-4000-8000-000000000004", + "locality_id": "10000000-0000-4000-8000-000000000005", + "territory_version": "0", + "reference": "geo-browser-1790681317445" + } + }, + { + "method": "PUT", + "path": "/management/opportunities/53", + "payload": { + "province_id": "10000000-0000-4000-8000-000000000002", + "district_id": "10000000-0000-4000-8000-000000000003", + "admin_post_id": "10000000-0000-4000-8000-000000000004", + "locality_id": "10000000-0000-4000-8000-000000000005", + "territory_version": "1", + "reference": "" + } + }, + { + "method": "PUT", + "path": "/management/opportunities/53", + "payload": { + "province_id": "10000000-0000-4000-8000-000000000002", + "district_id": "10000000-0000-4000-8000-000000000003", + "admin_post_id": "", + "locality_id": "", + "territory_version": "1", + "reference": "geo-browser-1790681317445" + } + } + ], + "responses": [ + { + "path": "/management/opportunities/create", + "status": 200 + }, + { + "path": "/management/opportunities", + "status": 200 + }, + { + "path": "/management/opportunities/53/edit", + "status": 200 + }, + { + "path": "/management/opportunities/53/coordinates", + "status": 200 + }, + { + "path": "/management/opportunities/53/edit", + "status": 200 + }, + { + "path": "/management/opportunities", + "status": 200 + }, + { + "path": "/management/opportunities/53/edit", + "status": 200 + }, + { + "path": "/management/opportunities/53/coordinates", + "status": 200 + } + ], + "widths": [ + { + "width": 390, + "noOverflow": true + }, + { + "width": 1440, + "noOverflow": true + } + ], + "javascript_errors": 0, + "scope": "Synthetic isolated QA" +} diff --git a/docs/investmoz/gis/sql-workbook-inspection.json b/docs/investmoz/gis/sql-workbook-inspection.json new file mode 100644 index 00000000..96d6bd2c --- /dev/null +++ b/docs/investmoz/gis/sql-workbook-inspection.json @@ -0,0 +1,18 @@ +{ + "source_sha256": "1e36c769c4767420b10f1ff5ee0c2955cdb1b03c98179c5cad549e832ffb1f15", + "ddl_cells": "SQL_DDL!A1:A14", + "insert_cells": "SQL_INSERTS!B2:B21415", + "counts": { + "provincia": 11, + "distrito": 150, + "posto_administrativo": 451, + "localidade": 1377, + "bairro_urbano": 2838, + "aldeia_quarteirao": 16583 + }, + "total": 21410, + "auxiliary_parents_from_source": 1075, + "quality_rows": 1147, + "has_coordinates": false, + "warning": "1075 parents explicitly marked NAO INFORMADO by the workbook; these are unresolved references, not authenticated administrative designations." +} diff --git a/docs/investmoz/gis/sql-workbook-validation.json b/docs/investmoz/gis/sql-workbook-validation.json new file mode 100644 index 00000000..c321751a --- /dev/null +++ b/docs/investmoz/gis/sql-workbook-validation.json @@ -0,0 +1,29 @@ +{ + "scope": "isolated QA only; source staging tables, not application master data", + "results": [ + { + "run": 1, + "counts": { + "provincia": 11, + "distrito": 150, + "posto_administrativo": 451, + "localidade": 1377, + "bairro_urbano": 2838, + "aldeia_quarteirao": 16583 + }, + "total": 21410 + }, + { + "run": 2, + "counts": { + "provincia": 11, + "distrito": 150, + "posto_administrativo": 451, + "localidade": 1377, + "bairro_urbano": 2838, + "aldeia_quarteirao": 16583 + }, + "total": 21410 + } + ] +} diff --git a/docs/investmoz/gis/territory-integrity-results.json b/docs/investmoz/gis/territory-integrity-results.json new file mode 100644 index 00000000..1c895e4d --- /dev/null +++ b/docs/investmoz/gis/territory-integrity-results.json @@ -0,0 +1,122 @@ +{ + "options_query_plan": [ + { + "QUERY PLAN": "[\n {\n \"Plan\": {\n \"Node Type\": \"Limit\",\n \"Parallel Aware\": false,\n \"Async Capable\": false,\n \"Startup Cost\": 0.28,\n \"Total Cost\": 8.30,\n \"Plan Rows\": 1,\n \"Plan Width\": 26,\n \"Plans\": [\n {\n \"Node Type\": \"Index Scan\",\n \"Parent Relationship\": \"Outer\",\n \"Parallel Aware\": false,\n \"Async Capable\": false,\n \"Scan Direction\": \"Forward\",\n \"Index Name\": \"geo_parent_type_name_idx\",\n \"Relation Name\": \"geo_locations\",\n \"Alias\": \"geo_locations\",\n \"Startup Cost\": 0.28,\n \"Total Cost\": 8.30,\n \"Plan Rows\": 1,\n \"Plan Width\": 26,\n \"Index Cond\": \"((parent_id = '059b02ca-7095-577a-8705-4d4c46896747'::uuid) AND (location_type = 'district'::text))\"\n }\n ]\n }\n }\n]" + } + ], + "counts": [ + { + "location_type": "admin_post", + "count": 437 + }, + { + "location_type": "country", + "count": 1 + }, + { + "location_type": "district", + "count": 148 + }, + { + "location_type": "locality", + "count": 1324 + }, + { + "location_type": "province", + "count": 11 + } + ], + "duplicate_names": [ + { + "parent_id": "5ddd807c-ea68-54c8-9bbb-35ef83bb8127", + "location_type": "admin_post", + "name": "mandie", + "count": 2 + }, + { + "parent_id": "d2d4d4c0-63a0-5216-a0ea-70381cc1755d", + "location_type": "locality", + "name": "buene", + "count": 2 + }, + { + "parent_id": "ab1f5f97-6685-52a5-bb7e-1d820a2d18f2", + "location_type": "locality", + "name": "25 de setembro", + "count": 2 + }, + { + "parent_id": "c51bea81-d5bd-5383-9b63-167c6419d022", + "location_type": "locality", + "name": "matemo", + "count": 2 + } + ], + "invalid_parents": [], + "sectors": [ + { + "id": 1, + "name": "Agronegócio", + "slug": "agronegocio" + }, + { + "id": 2, + "name": "Energia e Mineração", + "slug": "energia" + }, + { + "id": 3, + "name": "Turismo", + "slug": "turismo" + }, + { + "id": 4, + "name": "Infraestruturas", + "slug": "infraestruturas" + }, + { + "id": 5, + "name": "Economia Azul", + "slug": "economia-azul" + }, + { + "id": 6, + "name": "Mercado Financeiro", + "slug": "mercado-financeiro" + }, + { + "id": 7, + "name": "Mineração", + "slug": "mineracao" + }, + { + "id": 8, + "name": "Energia", + "slug": "energia-especifica" + }, + { + "id": 9, + "name": "Fintech", + "slug": "fintech" + }, + { + "id": 10, + "name": "Imobiliário", + "slug": "imobiliario" + }, + { + "id": 11, + "name": "Bens de Consumo e Franquias", + "slug": "bens-de-consumo-e-franquias" + }, + { + "id": 12, + "name": "Tecnologias de Informação", + "slug": "tecnologias-de-informacao" + } + ], + "php": "8.5.11", + "laravel": "13.30.1", + "postgres": "17.11", + "postgis": "3.6.2" +} diff --git a/resources/js/components/investment-territory.tsx b/resources/js/components/investment-territory.tsx index d6327888..d5717cca 100644 --- a/resources/js/components/investment-territory.tsx +++ b/resources/js/components/investment-territory.tsx @@ -7,6 +7,7 @@ export type TerritoryOption = { parent_id: string | null; location_type: string; deleted_at?: string | null; + source_code?: string | null; }; type Option = TerritoryOption; const levels = ["province", "district", "admin_post", "locality"]; @@ -16,9 +17,11 @@ const control = "w-full rounded border bg-background p-2 text-foreground"; export function TerritoryFields({ initial = [], onChange, + endpoint = "/api/v1/investment/territory-options", }: { initial?: TerritoryOption[]; onChange?: (path: TerritoryOption[]) => void; + endpoint?: string; }) { const [path, setPath] = useState(initial); return ( @@ -31,7 +34,7 @@ export function TerritoryFields({ index={index} parent={path[index - 1]?.id ?? ""} selected={path[index]} - endpoint="/api/v1/investment/territory-options" + endpoint={endpoint} onChange={(value) => { const next = [...path.slice(0, index), ...(value ? [value] : [])]; setPath(next); @@ -51,12 +54,14 @@ export function TerritoryLevel({ selected, onChange, endpoint = "/management/territory-options", + fieldError, }: { index: number; parent: string; selected?: Option; onChange: (value?: Option) => void; endpoint?: string; + fieldError?: string; }) { const id = useId(); const [q, setQ] = useState(""); @@ -65,9 +70,13 @@ export function TerritoryLevel({ const [more, setMore] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [retry, setRetry] = useState(0); const enabled = index === 0 || Boolean(parent); useEffect(() => { + setOptions([]); + setMore(false); if (!enabled) return; + setLoading(true); const controller = new AbortController(); const timer = setTimeout(async () => { setLoading(true); @@ -85,6 +94,7 @@ export function TerritoryLevel({ }); if (!response.ok) throw new Error("Não foi possível carregar as localizações."); const data = await response.json(); + if (controller.signal.aborted) return; setOptions(data.data); setMore(Boolean(data.next_page_url)); } catch (e) { @@ -98,7 +108,7 @@ export function TerritoryLevel({ clearTimeout(timer); controller.abort(); }; - }, [index, parent, q, page, enabled, endpoint]); + }, [index, parent, q, page, enabled, endpoint, retry]); return (