provincias
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-29 14:14:06 +02:00
parent e780e67e98
commit 67dad461d9
35 changed files with 1046 additions and 42 deletions
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class ImportTerritoryWorkbook extends Command
{
protected $signature = 'geo:import-workbook {--inspect : Inspect the authorised workbook without database writes} {--python=python : Python executable}';
protected $description = 'Inspect and idempotently import the authorised cartography workbook, preserving existing IDs';
public function handle(): int
{
if (! app()->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;
}
}
@@ -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;
@@ -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
@@ -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;
@@ -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.');
}
@@ -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']));
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PreserveFormFeedback
{
public function handle(Request $request, Closure $next): Response
{
// Auxiliary JSON reads must not consume feedback intended for the next Inertia page.
if ($request->isMethod('GET') && $request->expectsJson() && ! $request->header('X-Inertia') && $request->hasSession()) {
$request->session()->keep(['errors', '_old_input', 'success']);
}
return $next($request);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class GeoLocation extends DomainRecord
{
protected $fillable = ['name', 'location_type', 'parent_id', 'code'];
/** @return BelongsTo<GeoLocation, $this> */
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
/** @return HasMany<GeoLocation, $this> */
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
/** @return HasMany<Opportunity, $this> */
public function opportunities(): HasMany
{
return $this->hasMany(Opportunity::class, 'primary_territory_id');
}
}
+11 -1
View File
@@ -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<GeoLocation, $this> */
public function primaryTerritory(): BelongsTo
{
return $this->belongsTo(GeoLocation::class, 'primary_territory_id');
}
/** @param Builder<Opportunity> $query
* @return Builder<Opportunity>
*/
+12 -4
View File
@@ -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<string,mixed> $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 [];
}
+2
View File
@@ -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,
]);
})
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE geo_locations ADD COLUMN source_code text');
DB::statement('CREATE UNIQUE INDEX geo_source_parent_code_uq ON geo_locations(source_system, parent_id, location_type, source_code) NULLS NOT DISTINCT WHERE deleted_at IS NULL AND source_code IS NOT NULL');
DB::statement('CREATE INDEX geo_parent_type_name_idx ON geo_locations(parent_id, location_type, name) WHERE deleted_at IS NULL');
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION enforce_geo_hierarchy() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE parent_type text; expected_type text;
BEGIN
PERFORM pg_advisory_xact_lock(280926,150000);
IF NEW.deleted_at IS NOT NULL THEN RETURN NEW; END IF;
IF NEW.location_type='country' AND NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION 'Country cannot have a parent';
END IF;
expected_type := CASE NEW.location_type WHEN 'province' THEN 'country' WHEN 'district' THEN 'province' WHEN 'admin_post' THEN 'district' WHEN 'locality' THEN 'admin_post' END;
IF NEW.parent_id IS NOT NULL THEN
SELECT location_type INTO parent_type FROM geo_locations WHERE id=NEW.parent_id AND deleted_at IS NULL;
IF parent_type IS NULL OR (expected_type IS NOT NULL AND parent_type<>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');
}
};
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Database\Seeders;
use App\Models\Sector;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InvestmentAreaSeeder extends Seeder
{
public function run(): void
{
DB::transaction(function () {
DB::select('SELECT pg_advisory_xact_lock(290926, 100000)');
foreach (['Turismo', 'Mineração', 'Infra-estruturas', 'Energia', 'Mercado Financeiro', 'Fintech', 'Imobiliário', 'Agronegócio', 'Bens de Consumo e Franquias', 'Tecnologias de Informação'] as $name) {
$key = str_replace('-', '', Str::slug($name));
$exists = Sector::withTrashed()->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()]);
}
});
}
}
+1
View File
@@ -0,0 +1 @@
.env.local
+15
View File
@@ -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`.
+83
View File
@@ -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:
+13
View File
@@ -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;
}
}
+8
View File
@@ -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
+32
View File
@@ -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.'
@@ -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"
}
@@ -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."
}
@@ -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
}
]
}
@@ -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"
}
@@ -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 (
<div className="space-y-2">
<label htmlFor={id} className="font-medium">
@@ -113,10 +123,12 @@ export function TerritoryLevel({
setQ(e.target.value);
setPage(1);
}}
placeholder="Pesquisar por nome"
placeholder="Pesquisar por código ou nome"
/>
<select
id={id}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? `${id}-error` : undefined}
className={control}
disabled={!enabled || loading}
value={selected?.id ?? ""}
@@ -132,11 +144,24 @@ export function TerritoryLevel({
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
{o.source_code ? ` (${o.source_code})` : ""}
</option>
))}
</select>
{loading && <p role="status">A carregar…</p>}
{error && <p role="alert">{error}</p>}
{error && (
<div role="alert">
{error}{" "}
<button type="button" onClick={() => setRetry(retry + 1)}>
Tentar novamente
</button>
</div>
)}
{fieldError && (
<p id={`${id}-error`} role="alert">
{fieldError}
</p>
)}
{enabled && !loading && !error && options.length === 0 && (
<p className="text-sm">Sem opções para esta pesquisa.</p>
)}
+1 -1
View File
@@ -119,7 +119,7 @@ export default function Zones({
}}
>
<h2 className="text-xl font-bold">Criar rascunho</h2>
<TerritoryFields />
<TerritoryFields endpoint="/management/territory-options" />
<Field label="Código">
<input name="code" className={input} required maxLength={80} />
</Field>
+10 -5
View File
@@ -1,5 +1,4 @@
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";
@@ -40,10 +39,12 @@ export default function Editor({
resource,
record,
sectors,
territoryPath = [],
}: {
resource: "sectors" | "opportunities";
record: Entry | null;
sectors: { id: number; name: string }[];
territoryPath?: TerritoryOption[];
}) {
const isSector = resource === "sectors";
const fields = isSector ? sectorFields : opportunityFields;
@@ -66,9 +67,13 @@ export default function Editor({
initial.maturity_level = String(record?.maturity_level ?? "");
initial.status = String(record?.status ?? "draft");
initial.is_featured = Boolean(record?.is_featured ?? false);
initial.territory_version = String(record?.territory_version ?? 0);
["province_id", "district_id", "admin_post_id", "locality_id"].forEach((field, index) => {
initial[field] = territoryPath[index]?.id ?? "";
});
}
const form = useForm<Values>(initial);
const [creationPath, setCreationPath] = useState<TerritoryOption[]>([]);
const [creationPath, setCreationPath] = useState<TerritoryOption[]>(territoryPath);
return (
<Management
title={`${record ? "Editar" : "Criar"} ${isSector ? "sector" : "oportunidade"}`}
@@ -87,7 +92,7 @@ export default function Editor({
}}
>
<fieldset disabled={form.processing} className="space-y-5">
{!isSector && !record && (
{!isSector && (
<div className="grid gap-4 md:grid-cols-2">
{(
[
@@ -102,7 +107,8 @@ export default function Editor({
index={index}
parent={creationPath[index - 1]?.id ?? ""}
selected={creationPath[index]}
endpoint="/api/v1/investment/territory-options"
endpoint="/management/territory-options"
fieldError={form.errors[key]}
onChange={(value) => {
const path = [
...creationPath.slice(0, index),
@@ -214,7 +220,6 @@ export default function Editor({
</div>
</fieldset>
</form>
{!isSector && record && <InvestmentTerritory kind="opportunities" id={record.id} />}
{!isSector && record && <InvestmentCoordinates id={record.id} />}
</Management>
);
+1
View File
@@ -108,6 +108,7 @@ export default function Editor({
<p>Registo interno de gestão. O estado activo não publica este conteúdo no portal.</p>
{!record && (
<TerritoryFields
endpoint="/management/territory-options"
onChange={(path) =>
form.setData((data) => ({
...data,
+8
View File
@@ -139,3 +139,11 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
Route::post('{resource}', [$controller, 'save'])->whereIn('resource', ['sectors', 'opportunities'])->name('store');
Route::put('{resource}/{id}', [$controller, 'save'])->whereIn('resource', ['sectors', 'opportunities'])->whereNumber('id')->name('update');
});
// Serialize management requests for the same session so background dropdown/coordinate
// reads cannot overwrite validation feedback from a concurrent form submission.
foreach (Route::getRoutes() as $managementRoute) {
if (str_starts_with($managementRoute->uri(), 'management/')) {
$managementRoute->block(10, 10);
}
}
+94
View File
@@ -6,10 +6,13 @@ use App\Models\Opportunity;
use App\Models\Sector;
use App\Models\User;
use App\Services\InvestmentCatalogue;
use Database\Seeders\InvestmentAreaSeeder;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class InvestmentTerritoryTest extends TestCase
@@ -105,6 +108,97 @@ class InvestmentTerritoryTest extends TestCase
$this->actingAs(User::factory()->create())->getJson('/management/territory-options?type=province')->assertForbidden();
}
public function test_opportunity_edit_saves_the_geographic_fields_with_the_record(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$country = $this->location('country');
$province = $this->location('province', $country);
$sector = Sector::create(['name' => 'Synthetic edit', 'slug' => 'synthetic-edit', 'is_active' => true]);
$record = Opportunity::create(['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit', 'status' => 'draft']);
$this->actingAs($manager)->put('/management/opportunities/'.$record->id, [
'sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Synthetic edit',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
'province_id' => $province, 'territory_version' => 0,
])->assertRedirect();
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'primary_territory_id' => $province]);
$this->assertSame($province, $record->fresh()->primaryTerritory->id);
$this->get('/management/opportunities/'.$record->id.'/edit')->assertOk();
$this->getJson('/management/territory-options?type=locality&parent_id='.$province)->assertUnprocessable();
$this->getJson('/management/territory-options?type=district&parent_id=00000000-0000-4000-8000-000000000001')->assertUnprocessable();
$this->getJson('/management/territory-options?type=province&q='.$province)->assertOk()->assertJsonPath('data.0.id', $province);
$other = $this->location('province', $country);
$district = $this->location('district', $other);
$payload = ['sector_id' => $sector->id, 'reference' => 'EDIT-GEO', 'slug' => 'edit-geo', 'title' => 'Must not persist',
'status' => 'draft', 'is_featured' => false, 'estimated_investment_currency' => 'USD',
'province_id' => $province, 'district_id' => $district, 'territory_version' => 1];
$this->putJson('/management/opportunities/'.$record->id, $payload)->assertUnprocessable()->assertJsonValidationErrors('district_id');
$this->assertDatabaseHas('opportunities', ['id' => $record->id, 'title' => 'Synthetic edit', 'primary_territory_id' => $province]);
$this->putJson('/management/opportunities/'.$record->id, [...$payload, 'district_id' => null, 'territory_version' => 0])->assertConflict();
}
public function test_database_rejects_a_cycle_wrong_parent_and_duplicate_source_code(): void
{
$country = $this->location('country');
$province = $this->location('province', $country);
$district = $this->location('district', $province);
foreach ([
fn () => DB::table('geo_locations')->where('id', $province)->update(['parent_id' => $district]),
fn () => DB::table('geo_locations')->where('id', $district)->update(['parent_id' => $country]),
fn () => DB::table('geo_locations')->where('id', $country)->update(['parent_id' => $country]),
] as $invalid) {
try {
DB::transaction($invalid);
$this->fail('Invalid hierarchy accepted');
} catch (QueryException $error) {
$this->assertStringContainsString('ERROR', $error->getMessage());
}
}
DB::table('geo_locations')->where('id', $province)->update(['source_code' => '01']);
$other = $this->location('province', $country);
try {
DB::transaction(fn () => DB::table('geo_locations')->where('id', $other)->update(['source_code' => '01']));
$this->fail('Duplicate source code accepted');
} catch (QueryException $error) {
$this->assertStringContainsString('geo_source_parent_code_uq', $error->getMessage());
}
}
public function test_integrity_migration_reverses_without_data_and_preserves_imported_codes(): void
{
$migration = require database_path('migrations/2026_09_29_100000_territory_integrity.php');
$migration->down();
$migration->up();
$country = $this->location('country');
DB::table('geo_locations')->where('id', $country)->update(['source_code' => '508']);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Preserve imported source codes');
$migration->down();
}
public function test_background_options_do_not_consume_form_validation_feedback(): void
{
$manager = User::factory()->create();
config(['portal.managers' => [$manager->email]]);
$this->actingAs($manager)->from('/management/opportunities/create')
->post('/management/opportunities', [])->assertSessionHasErrors('reference');
$this->withCookie(config('session.cookie'), session()->getId())->withCredentials();
$this->getJson('/management/territory-options?type=province')->assertOk()->assertSessionHasErrors('reference');
$this->get('/management/opportunities/create')->assertInertia(fn (AssertableInertia $page) => $page->has('errors.reference'));
}
public function test_investment_areas_are_idempotent_and_preserve_legacy_sectors(): void
{
$old = Sector::create(['name' => 'Energia e Mineração', 'slug' => 'energia', 'is_active' => true]);
$this->seed(InvestmentAreaSeeder::class);
$count = Sector::count();
$this->seed(InvestmentAreaSeeder::class);
$this->assertSame($count, Sector::count());
$this->assertSame('Energia e Mineração', $old->fresh()->name);
$this->assertDatabaseHas('sectors', ['name' => 'Energia', 'slug' => 'energia-especifica']);
$this->assertDatabaseHas('sectors', ['name' => 'Mineração']);
}
private function location(string $type, ?string $parent = null): string
{
$id = (string) Str::uuid();
+14 -5
View File
@@ -15,9 +15,11 @@ $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) {
$counts = DB::transaction(function () use ($data, $expected) {
DB::select('SELECT pg_advisory_xact_lock(280926, 150000)');
$created = 0;
$updated = 0;
$unchanged = 0;
foreach ($data['nodes'] as $node) {
$existing = DB::table('geo_locations')->where('id', $node['id'])->first();
if ($existing) {
@@ -27,6 +29,13 @@ $created = DB::transaction(function () use ($data, $expected) {
}
}
if ($existing->source_code !== $node['source_code']) {
DB::table('geo_locations')->where('id', $node['id'])->update(['source_code' => $node['source_code'], 'updated_at' => now()]);
$updated++;
} else {
$unchanged++;
}
continue;
}
if ($node['parent_id'] && ! DB::table('geo_locations')->where('id', $node['parent_id'])->exists()) {
@@ -35,12 +44,12 @@ $created = DB::transaction(function () use ($data, $expected) {
DB::table('geo_locations')->insert([...$node, 'source_system' => 'cartografia-workbook', 'external_id' => $expected.':'.$node['id'], 'data_classification' => 'internal']);
$created++;
}
if ($created) {
if ($created || $updated) {
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'],
'after_data' => json_encode(['checksum' => $expected, 'created' => $created, 'updated' => $updated, 'rejected_rows' => $data['rejected_rows'],
'approval_basis' => 'workspace_user_instructions', 'codes' => 'internal_identifiers_not_official_ine', 'classification' => 'internal'], JSON_THROW_ON_ERROR)]);
}
return $created;
return compact('created', 'updated', 'unchanged');
});
echo json_encode(['created' => $created, 'rejected_rows' => count($data['rejected_rows']), 'scope' => 'internal_management_dropdowns'], JSON_THROW_ON_ERROR).PHP_EOL;
echo json_encode([...$counts, 'read' => $data['read'], 'ignored' => $data['ignored_sub_locality_rows'] + $data['duplicates'], 'rejected_rows' => count($data['rejected_rows']), 'errors_file' => $path, 'scope' => 'internal_management_dropdowns'], JSON_THROW_ON_ERROR).PHP_EOL;
+43
View File
@@ -0,0 +1,43 @@
"""Extract the user-supplied SQL workbook without executing it."""
import hashlib
import json
from pathlib import Path
import xml.etree.ElementTree as ET
import zipfile
source = Path('C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/prompt/cartografia_moz.xlsx')
checksum = hashlib.sha256(source.read_bytes()).hexdigest()
if checksum != '1e36c769c4767420b10f1ff5ee0c2955cdb1b03c98179c5cad549e832ffb1f15':
raise ValueError('Source changed; inspect and approve the new mapping before execution')
ns = {'s': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
with zipfile.ZipFile(source) as archive:
strings = [''.join(item.itertext()) for item in ET.fromstring(archive.read('xl/sharedStrings.xml')).findall('s:si', ns)]
def cells(number):
result = {}
for cell in ET.fromstring(archive.read(f'xl/worksheets/sheet{number}.xml')).findall('.//s:c', ns):
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:
raise ValueError('SQL package must not depend on evaluated formulas')
result[cell.attrib['r']] = value
return result
ddl_cells, insert_cells = cells(3), cells(10)
ddl = [ddl_cells[f'A{row}'] for row in range(1, 15)]
statements = [insert_cells[f'B{row}'] for row in range(2, 21416)]
tables = ['provincia', 'distrito', 'posto_administrativo', 'localidade', 'bairro_urbano', 'aldeia_quarteirao']
counts = {table: sum(sql.startswith('INSERT INTO ' + table + ' ') for sql in statements) for table in tables}
expected = dict(zip(tables, [11, 150, 451, 1377, 2838, 16583]))
if counts != expected or statements[2] != 'BEGIN;' or statements[-1] != 'COMMIT;':
raise ValueError('Unexpected transaction or counts')
quality = cells(11)
report = {'source_sha256': checksum, 'ddl_cells': 'SQL_DDL!A1:A14', 'insert_cells': 'SQL_INSERTS!B2:B21415',
'counts': counts, 'total': sum(counts.values()), '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.'}
output = Path('storage/app/private/cartography-sql-package.json')
output.write_text(json.dumps({'report': report, 'ddl': ddl, 'statements': statements, 'quality_cells': quality}, ensure_ascii=False), encoding='utf-8')
Path('docs/investmoz/gis/sql-workbook-inspection.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(json.dumps(report))
+18 -5
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import uuid
import xml.etree.ElementTree as ET
import zipfile
import unicodedata
source = Path('C:/Mac/Home/Documents/eBAU/cartografia_moz.xlsx')
checksum = hashlib.sha256(source.read_bytes()).hexdigest()
@@ -12,15 +13,24 @@ 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')}
nodes = {(): dict(id=country, parent_id=None, name='Moçambique', location_type='country', code='508', source_code='508')}
definitions = {}
conflicts = set()
rejected = []
ignored = 0
read = 0
duplicates = 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'))
headers = []
for cell in sheet.findall('s:sheetData/s:row', ns)[0]:
value = cell.findtext('s:v', '', ns)
headers.append(strings[int(value)] if cell.attrib.get('t') == 's' else value)
if len(headers) < 8 or 'designa' not in unicodedata.normalize('NFKD', headers[7]).casefold():
raise ValueError('Unexpected workbook headers; inspect before importing')
for row in sheet.findall('s:sheetData/s:row', ns)[1:]:
read += 1
cells = {}
invalid = False
for cell in row:
@@ -32,7 +42,7 @@ with zipfile.ZipFile(source) as archive:
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())
cells[column] = unicodedata.normalize('NFC', ' '.join(value.split()))
if cells.get('F') or cells.get('G'):
ignored += 1
continue
@@ -47,11 +57,14 @@ with zipfile.ZipFile(source) as archive:
if key in conflicts:
rejected.append({'row': row.attrib['r'], 'reason': 'conflicting_designation'})
continue
if key in definitions and definitions[key]['name'] != name:
if key in definitions and definitions[key]['name'].casefold() != name.casefold():
rejected.extend([{'row': definitions[key]['row'], 'reason': 'conflicting_designation'}, {'row': row.attrib['r'], 'reason': 'conflicting_designation'}])
conflicts.add(key)
del definitions[key]
continue
if key in definitions:
duplicates += 1
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:
@@ -60,10 +73,10 @@ for key, definition in sorted(definitions.items(), key=lambda item: (len(item[0]
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)
nodes[key] = dict(id=identifier, parent_id=parent, name=definition['name'], location_type=kind, code='XLSX-' + identifier, source_code=key[-1])
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')
output.write_text(json.dumps(dict(checksum=checksum, mapping_version=3, headers=headers, read=read, duplicates=duplicates, 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)))
+24
View File
@@ -0,0 +1,24 @@
<?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();
$db = DB::connection();
$report = $db->transaction(function () use ($db) {
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
return [
'options_query_plan' => $db->select("EXPLAIN (FORMAT JSON) SELECT id,name FROM geo_locations WHERE deleted_at IS NULL AND parent_id=? AND location_type='district' ORDER BY name LIMIT 100", [$db->table('geo_locations')->where('location_type', 'province')->whereNull('deleted_at')->value('id')]),
'counts' => $db->select('SELECT location_type,count(*) FROM geo_locations WHERE deleted_at IS NULL GROUP BY location_type ORDER BY location_type'),
'duplicate_names' => $db->select('SELECT parent_id,location_type,lower(trim(name)) as name,count(*) FROM geo_locations WHERE deleted_at IS NULL GROUP BY parent_id,location_type,lower(trim(name)) HAVING count(*)>1'),
'invalid_parents' => $db->select("SELECT g.id,g.location_type FROM geo_locations g LEFT JOIN geo_locations p ON p.id=g.parent_id WHERE g.deleted_at IS NULL AND (g.location_type='country' AND g.parent_id IS NOT NULL OR g.location_type IN ('province','district','admin_post','locality') AND (p.id IS NULL OR p.deleted_at IS NOT NULL OR p.location_type <> CASE g.location_type WHEN 'province' THEN 'country' WHEN 'district' THEN 'province' WHEN 'admin_post' THEN 'district' WHEN 'locality' THEN 'admin_post' END))"),
'sectors' => $db->table('sectors')->whereNull('deleted_at')->orderBy('id')->get(['id', 'name', 'slug']),
'php' => PHP_VERSION, 'laravel' => app()->version(),
'postgres' => $db->selectOne('SHOW server_version')->server_version,
'postgis' => $db->selectOne('SELECT postgis_lib_version() as version')->version,
];
});
echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;
+110
View File
@@ -0,0 +1,110 @@
// Synthetic QA server only; never writes to the application database.
import fs from 'node:fs';
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 = [];
const requests = [];
const responses = [];
const responseIds = new Set();
const responseBodies = [];
ws.onmessage = event => {
const message = JSON.parse(event.data);
if (message.method === 'Network.requestWillBeSent' && message.params.request.url.includes('/management/opportunities') && message.params.request.postData) {
const request = message.params.request; let data = {};
try { data = JSON.parse(request.postData); } catch {}
const keys = ['province_id','district_id','admin_post_id','locality_id','territory_version','reference'];
requests.push({ method: request.method, path: new URL(request.url).pathname, payload: Object.fromEntries(keys.filter(k => k in data).map(k => [k, data[k]])) });
}
if (message.method === 'Network.responseReceived' && message.params.response.url.includes('/management/opportunities')) {
responses.push({ path: new URL(message.params.response.url).pathname, status: message.params.response.status });
responseIds.add(message.params.requestId);
}
if (message.method === 'Network.loadingFinished' && responseIds.has(message.params.requestId)) {
send('Network.getResponseBody', { requestId: message.params.requestId }).then(result => {
try { const body = JSON.parse(result.body); responseBodies.push({component: body.component, errors: body.props?.errors}); } catch {}
}).catch(() => {});
}
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('Network.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')`);
}
const token = 'geo-browser-' + Date.now();
const observations = [];
await send('Page.navigate', { url: 'http://127.0.0.1:18027/management/opportunities/create' });
await wait(`document.querySelectorAll('select').length===7`);
const fill = async (label, value) => {
await evaluate(`(()=>{const label=[...document.querySelectorAll('label')].find(l=>l.textContent.startsWith(${JSON.stringify(label)}));const input=label.querySelector('input');input.focus();input.select();})()`);
if (value === '') {
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
} else await send('Input.insertText', { text: value });
};
await fill('Referência', token); await fill('Título', token); await fill('Slug', token);
await select(4, await evaluate(`document.querySelectorAll('select')[4].options[1].value`));
for (let level = 0; level < 4; level++) {
await wait(`!document.querySelectorAll('select')[${level}].disabled && document.querySelectorAll('select')[${level}].options.length>1`);
await select(level, `10000000-0000-4000-8000-${String(level+2).padStart(12, '0')}`);
}
const save = () => evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Guardar').click()`);
const reopen = async () => {
await wait(`location.pathname==='/management/opportunities' && document.body.innerText.includes(${JSON.stringify(token)})`);
await evaluate(`[...document.querySelectorAll('article')].find(a=>a.textContent.includes(${JSON.stringify(token)})).querySelector('a').click()`);
await wait(`location.pathname.endsWith('/edit') && document.querySelectorAll('select').length===7`);
};
await save(); await reopen();
await wait(`document.querySelectorAll('select')[3].value==='10000000-0000-4000-8000-000000000005'`);
observations.push('create_save_reopen_full_path');
// Server validation must retain the chain and expose the failing field.
await fill('Referência', ''); await save();
await wait(`document.querySelector('[role="alert"]')!==null`);
if (!(await evaluate(`document.querySelectorAll('select')[3].value==='10000000-0000-4000-8000-000000000005'`))) throw Error('Validation lost selection');
await fill('Referência', token);
observations.push('validation_preserves_selection');
await select(2, '');
await wait(`document.querySelectorAll('select')[3].disabled && document.querySelectorAll('select')[3].value===''`);
await save(); await reopen();
await wait(`document.querySelectorAll('select')[1].value==='10000000-0000-4000-8000-000000000003' && document.querySelectorAll('select')[2].value===''`);
observations.push('edit_save_reopen_and_descendant_reset');
await send('Network.setBlockedURLs', { urls: ['*://127.0.0.1:18027/management/territory-options?type=province*'] });
await evaluate(`document.querySelector('input[aria-label="Pesquisar Província"]').focus()`);
await send('Input.insertText', { text: 'Synthetic' });
await wait(`document.body.innerText.includes('Tentar novamente')`);
await send('Network.setBlockedURLs', { urls: [] });
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Tentar novamente').click()`);
await wait(`!document.body.innerText.includes('Tentar novamente') && document.querySelectorAll('select')[0].options.length>1 && !document.querySelectorAll('select')[0].disabled`);
observations.push('network_failure_and_retry');
await evaluate(`document.querySelector('input[aria-label="Pesquisar Distrito"]').focus()`);
await send('Input.insertText', { text: 'no-such-synthetic-district' });
await wait(`document.body.innerText.includes('Sem opções para esta pesquisa.')`);
observations.push('empty_search');
await select(0, '');
await select(0, '10000000-0000-4000-8000-000000000002');
await select(0, '');
await new Promise(resolve => setTimeout(resolve, 500));
if (!(await evaluate(`[...document.querySelectorAll('select')].slice(1,4).every(s=>s.disabled && s.value==='' && s.options.length===1)`))) throw Error('Stale children after rapid parent changes');
observations.push('rapid_parent_change_cancels_and_clears_children');
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');
const report = { date: new Date().toISOString(), observations, requests, responses, widths, javascript_errors: errors.length, scope: 'Synthetic isolated QA' };
fs.writeFileSync('docs/investmoz/gis/opportunity-dropdown-browser.json', JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report));
} catch (error) {
console.log(JSON.stringify({ requests, responses, responseBodies }));
console.log(await evaluate(`({path:location.pathname,body:document.body.innerText.slice(0,800)})`));
throw error;
} finally { ws.close(); await fetch(`http://127.0.0.1:9224/json/close/${target.id}`); }
+47
View File
@@ -0,0 +1,47 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
// Read a pinned, inspected package. Execute only in the isolated QA database.
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$db = DB::connection();
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
throw new RuntimeException('Isolated QA database required.');
}
$package = json_decode(file_get_contents(storage_path('app/private/cartography-sql-package.json')), true, 512, JSON_THROW_ON_ERROR);
$source = 'C:/Mac/Home/Documents/TEMPO/Portal Tempo/Servicos Tempo/prompt/cartografia_moz.xlsx';
if (hash_file('sha256', $source) !== $package['report']['source_sha256'] || $package['report']['source_sha256'] !== '1e36c769c4767420b10f1ff5ee0c2955cdb1b03c98179c5cad549e832ffb1f15') {
throw new RuntimeException('Source checksum mismatch.');
}
$results = [];
for ($run = 1; $run <= 2; $run++) {
$results[] = $db->transaction(function () use ($db, $package, $run) {
foreach ($package['ddl'] as $sql) {
if (! str_starts_with($sql, '--')) {
$db->unprepared($sql);
}
}
foreach ($package['statements'] as $sql) {
// Laravel owns the single transaction covering DDL and all INSERTs.
if (! in_array($sql, ['BEGIN;', 'COMMIT;'], true)) {
$db->unprepared($sql);
}
}
$counts = [];
foreach ($package['report']['counts'] as $table => $expected) {
$counts[$table] = $db->table('investmoz.'.$table)->count();
if ($counts[$table] !== $expected) {
throw new RuntimeException('Reconciliation failed: '.$table);
}
}
$db->statement('SET search_path TO public');
return ['run' => $run, 'counts' => $counts, 'total' => array_sum($counts)];
});
}
$report = ['scope' => 'isolated QA only; source staging tables, not application master data', 'results' => $results];
file_put_contents(base_path('docs/investmoz/gis/sql-workbook-validation.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_THROW_ON_ERROR).PHP_EOL;