@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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,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');
|
||||
}
|
||||
};
|
||||
@@ -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()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.env.local
|
||||
@@ -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`.
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
@@ -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)))
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`); }
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user