commit 30/9
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-09-30 09:05:43 +02:00
parent 67dad461d9
commit 3dc7c5c649
75 changed files with 4123 additions and 107 deletions
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\Mne\CompatibilityBackfill;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class BackfillMneCompatibility extends Command
{
protected $signature = 'mne:backfill-compatibility {--batch=100} {--dry-run : Execute validation and roll back all changes}';
protected $description = 'Reconcile compatibility identifiers without copying canonical business records';
public function handle(CompatibilityBackfill $backfill): int
{
$batch = filter_var($this->option('batch'), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000]]);
if ($batch === false) {
$this->error('Batch must be between 1 and 1000');
return self::FAILURE;
}
if ($this->option('dry-run')) {
DB::beginTransaction();
try {
$counts = $backfill->run($batch);
} finally {
DB::rollBack();
}
$this->line(json_encode(['dry_run' => true, 'rolled_back' => true, 'counts' => $counts], JSON_THROW_ON_ERROR));
} else {
$this->line(json_encode($backfill->run($batch), JSON_THROW_ON_ERROR));
}
return self::SUCCESS;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
final class ReconcileMneCompatibility extends Command
{
protected $signature = 'mne:reconcile-compatibility {--fail-on-difference : Return failure while any reconciliation or activation gate remains open}';
protected $description = 'Read-only compatibility counts, fingerprints and activation blockers';
public function handle(): int
{
$report = DB::transaction(function (): array {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$domains = [];
foreach (['identity' => ['users', 'identity_links', 'user_id'], 'sector' => ['sectors', 'sector_links', 'sector_id'],
'business' => ['existing_businesses', 'business_links', 'legacy_business_id']] as $domain => [$source, $target, $key]) {
$checksums = [];
$stale = 0;
$missing = 0;
foreach (DB::table('public.'.$source)->orderBy('id')->cursor() as $row) {
$checksum = hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR));
$checksums[] = $checksum;
$link = DB::table('mne.'.$target)->where($key, $row->id)->first();
$missing += $link === null ? 1 : 0;
$stale += $link !== null && $link->source_checksum !== $checksum ? 1 : 0;
}
$domains[$domain] = ['source_count' => count($checksums), 'link_count' => DB::table('mne.'.$target)->count(),
'pending_count' => DB::table('mne.'.$target)->where('status', '<>', 'active')->count(),
'missing' => $missing, 'stale_checksums' => $stale, 'checksum' => hash('sha256', implode('', $checksums))];
}
$role = DB::selectOne('SELECT rolsuper,rolcreaterole FROM pg_roles WHERE rolname=current_user');
return ['domains' => $domains, 'unresolved_rejections' => DB::table('mne.compatibility_rejections')->whereNull('resolved_at')->count(),
'runtime_is_superuser' => (bool) $role->rolsuper, 'runtime_can_create_roles' => (bool) $role->rolcreaterole,
'canonical_reads_ready' => false, 'canonical_writes_ready' => false,
'blockers' => ['Full canonical response mapping and authorization parity not certified',
'Legacy intake does not capture a sector', 'Runtime privilege separation requires verification with deployment credentials']];
});
$this->line(json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
// This release has no certified canonical adapter: strict gate checks cannot succeed yet.
return $this->option('fail-on-difference') ? self::FAILURE : self::SUCCESS;
}
}
@@ -4,10 +4,14 @@ namespace App\Http\Controllers;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject; use App\Models\ExistingBusinessObject;
use App\Models\Sector;
use App\Services\BusinessFormSchema;
use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects; use App\Services\ExistingBusinessObjects;
use App\Services\ExistingBusinessVerification; use App\Services\ExistingBusinessVerification;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityShadow;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
@@ -21,7 +25,7 @@ class ExistingBusinessController extends Controller
public function index(Request $request): Response|JsonResponse public function index(Request $request): Response|JsonResponse
{ {
$management = $request->is('management/*', 'api/v1/existing-business/review*'); $management = $request->is('management/*', 'api/v1/existing-business/review*');
$query = ExistingBusiness::query(); $query = app(BusinessRepository::class)->query();
if ($management) { if ($management) {
Gate::authorize('reviewQueue', ExistingBusiness::class); Gate::authorize('reviewQueue', ExistingBusiness::class);
$query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']); $query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']);
@@ -36,9 +40,10 @@ class ExistingBusinessController extends Controller
public function show(Request $request, string $id): Response|JsonResponse public function show(Request $request, string $id): Response|JsonResponse
{ {
$business = ExistingBusiness::findOrFail($id); $business = app(BusinessRepository::class)->find($id);
Gate::authorize('view', $business); Gate::authorize('view', $business);
$props = ['record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), app(CompatibilityShadow::class)->schedule($id);
$props = ['sectors' => Sector::where('is_active', true)->orderBy('name')->get(['id', 'name']), 'formSchema' => BusinessFormSchema::fields(), 'record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business),
'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business),
'modalities' => ExistingBusinessDrafts::MODALITIES, 'modalities' => ExistingBusinessDrafts::MODALITIES,
'objectKinds' => ExistingBusinessObjects::KINDS, 'objectKinds' => ExistingBusinessObjects::KINDS,
@@ -63,8 +68,8 @@ class ExistingBusinessController extends Controller
public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse
{ {
$data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'], $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'],
'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean']]); 'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean'], 'confirmation_code' => ['nullable', 'string', 'max:100']]);
$business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false)); $business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false), $data['confirmation_code'] ?? null);
return response()->json($this->record($business)); return response()->json($this->record($business));
} }
@@ -79,12 +84,12 @@ class ExistingBusinessController extends Controller
$saved = $objects->save($request->user(), $id, $request->all(), $object); $saved = $objects->save($request->user(), $id, $request->all(), $object);
return response()->json(['id' => $saved->id, 'verification_status' => 'pending', return response()->json(['id' => $saved->id, 'verification_status' => 'pending',
'version_no' => ExistingBusiness::findOrFail($id)->version_no], $object ? 200 : 201); 'version_no' => app(BusinessRepository::class)->find($id)->version_no], $object ? 200 : 201);
} }
/** @return array<string,mixed> */ /** @return array<string,mixed> */
private function record(ExistingBusiness $business): array private function record(ExistingBusiness $business): array
{ {
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no']), 'payload' => $business->payload]; return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no', 'sector_id']), 'payload' => $business->payload];
} }
} }
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Jobs;
use App\Services\Mne\LegacyBusinessRepository;
use App\Services\Mne\MneBusinessRepository;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class CompareMneBusiness implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public string $businessId) {}
public function handle(LegacyBusinessRepository $legacy, MneBusinessRepository $canonical): void
{
$start = hrtime(true);
try {
$source = $legacy->find($this->businessId);
$comparison = $canonical->comparison($this->businessId);
$expected = $source->only(['code', 'modality', 'status', 'classification', 'version_no']);
$outcome = $comparison === null ? 'unmapped' : ($expected == $comparison ? 'core_match' : 'divergence');
} catch (\Throwable) {
$outcome = 'comparison_error';
}
// No payloads, query parameters, identifiers or exception messages in telemetry.
Log::info('mne.compatibility.shadow', ['outcome' => $outcome, 'latency_ms' => (hrtime(true) - $start) / 1000000]);
}
}
+3 -1
View File
@@ -5,6 +5,8 @@ namespace App\Providers;
use App\Models\Opportunity; use App\Models\Opportunity;
use App\Models\Sector; use App\Models\Sector;
use App\Models\User; use App\Models\User;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibleBusinessRepository;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -23,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->bind(BusinessRepository::class, CompatibleBusinessRepository::class);
} }
/** /**
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Validator;
final class BusinessFormSchema
{
/** @return array<string, array{step:int,pt:string,en:string,type:string,max:int,options?:array<string,string>}> */
public static function fields(): array
{
return [
'proponent_type' => ['step' => 1, 'pt' => 'Tipo de proponente', 'en' => 'Applicant type', 'type' => 'select', 'max' => 30, 'options' => ['person' => 'Pessoa / Person', 'organization' => 'Organização / Organization']],
'proponent_name' => ['step' => 1, 'pt' => 'Nome ou denominação', 'en' => 'Name', 'type' => 'text', 'max' => 200],
'tax_identifier' => ['step' => 1, 'pt' => 'NUIT ou identificador', 'en' => 'Tax identifier', 'type' => 'text', 'max' => 50],
'contact_email' => ['step' => 1, 'pt' => 'Email de contacto', 'en' => 'Contact email', 'type' => 'email', 'max' => 254],
'contact_phone' => ['step' => 1, 'pt' => 'Telefone', 'en' => 'Phone', 'type' => 'tel', 'max' => 40],
'country' => ['step' => 1, 'pt' => 'País (código de duas letras)', 'en' => 'Country (two-letter code)', 'type' => 'text', 'max' => 2],
'acting_capacity' => ['step' => 2, 'pt' => 'Qualidade em que actua', 'en' => 'Acting capacity', 'type' => 'select', 'max' => 30, 'options' => ['holder' => 'Titular / Holder', 'representative' => 'Representante / Representative']],
'declared_holder' => ['step' => 2, 'pt' => 'Titular declarado', 'en' => 'Declared holder', 'type' => 'text', 'max' => 200],
'mandate_reference' => ['step' => 2, 'pt' => 'Referência do mandato', 'en' => 'Mandate reference', 'type' => 'text', 'max' => 500],
'mandate_valid_until' => ['step' => 2, 'pt' => 'Validade do mandato', 'en' => 'Mandate expiry', 'type' => 'date', 'max' => 10],
'legitimacy_declaration' => ['step' => 2, 'pt' => 'Declaro legitimidade para esta submissão', 'en' => 'I declare authority to submit', 'type' => 'select', 'max' => 3, 'options' => ['yes' => 'Sim / Yes', 'no' => 'Não / No']],
'title' => ['step' => 3, 'pt' => 'Título proposto', 'en' => 'Proposed title', 'type' => 'text', 'max' => 200],
'description' => ['step' => 3, 'pt' => 'Descrição reservada', 'en' => 'Private description', 'type' => 'textarea', 'max' => 10000],
'declared_reason' => ['step' => 3, 'pt' => 'Motivo e objectivo', 'en' => 'Reason and objective', 'type' => 'textarea', 'max' => 2000],
'valid_until' => ['step' => 3, 'pt' => 'Validade declarada', 'en' => 'Declared expiry', 'type' => 'date', 'max' => 10],
'asking_value' => ['step' => 4, 'pt' => 'Valor pedido', 'en' => 'Asking value', 'type' => 'decimal', 'max' => 24],
'currency' => ['step' => 4, 'pt' => 'Moeda (código de três letras)', 'en' => 'Currency (three-letter code)', 'type' => 'text', 'max' => 3],
'value_nature' => ['step' => 4, 'pt' => 'Natureza do valor', 'en' => 'Value basis', 'type' => 'select', 'max' => 30, 'options' => ['asking' => 'Pedido / Asking', 'indicative' => 'Indicativo / Indicative', 'appraised' => 'Avaliado, sujeito a prova / Appraised, evidence required']],
'equity_percentage' => ['step' => 4, 'pt' => 'Participação disponível (%)', 'en' => 'Available equity (%)', 'type' => 'decimal', 'max' => 8],
'funds_use' => ['step' => 4, 'pt' => 'Utilização dos fundos', 'en' => 'Use of funds', 'type' => 'textarea', 'max' => 2000],
'location_notes' => ['step' => 5, 'pt' => 'Localização declarada (reservada)', 'en' => 'Declared location (private)', 'type' => 'textarea', 'max' => 2000],
'asset_notes' => ['step' => 6, 'pt' => 'Resumo dos activos e direitos declarados', 'en' => 'Declared assets and rights summary', 'type' => 'textarea', 'max' => 2000],
'source_reference' => ['step' => 7, 'pt' => 'Referência da fonte', 'en' => 'Source reference', 'type' => 'textarea', 'max' => 2000],
'document_reference' => ['step' => 7, 'pt' => 'Referências documentais (não são ficheiros validados)', 'en' => 'Document references (not validated files)', 'type' => 'textarea', 'max' => 2000],
];
}
/** @return array<string, array<int,string>> */
public static function rules(): array
{
$rules = [];
foreach (self::fields() as $key => $field) {
$rules['payload.'.$key] = ['nullable', 'string', 'max:'.$field['max']];
if (isset($field['options'])) {
$rules['payload.'.$key][] = 'in:'.implode(',', array_keys($field['options']));
}
}
$rules['payload.contact_email'][] = 'email';
$rules['payload.country'][] = 'regex:/^[A-Z]{2}$/';
$rules['payload.currency'][] = 'regex:/^[A-Z]{3}$/';
$rules['payload.asking_value'][] = 'regex:/^\d{1,15}(\.\d{1,4})?$/';
$rules['payload.equity_percentage'][] = 'numeric';
$rules['payload.equity_percentage'][] = 'between:0,100';
foreach (['valid_until', 'mandate_valid_until'] as $date) {
$rules['payload.'.$date][] = 'date_format:Y-m-d';
}
$rules['payload.wizard_version'] = ['nullable', 'in:1'];
$rules['payload.wizard_step'] = ['nullable', 'integer', 'between:1,8'];
$rules['payload.title'] = ['required', 'string', 'max:200'];
return $rules;
}
/** @param array<string,mixed> $payload */
public static function validateSubmission(array $payload): void
{
$rules = [];
foreach (['proponent_type', 'proponent_name', 'tax_identifier', 'contact_email', 'country', 'acting_capacity', 'declared_holder', 'location_notes'] as $field) {
$rules[$field] = ['required'];
}
$rules['legitimacy_declaration'] = ['required', 'in:yes'];
$rules['mandate_reference'] = ['required_if:acting_capacity,representative'];
$rules['mandate_valid_until'] = ['required_if:acting_capacity,representative', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'];
$rules['currency'] = ['required_with:asking_value'];
$rules['value_nature'] = ['required_with:asking_value'];
Validator::make($payload, $rules)->validate();
}
}
+13 -3
View File
@@ -5,11 +5,13 @@ namespace App\Services;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject; use App\Models\ExistingBusinessObject;
use App\Models\User; use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
final class ExistingBusinessDrafts final class ExistingBusinessDrafts
{ {
@@ -20,20 +22,25 @@ final class ExistingBusinessDrafts
{ {
$data = Validator::make($input, [ $data = Validator::make($input, [
'modality' => ['required', 'in:'.implode(',', self::MODALITIES)], 'modality' => ['required', 'in:'.implode(',', self::MODALITIES)],
'sector_id' => ['sometimes', 'nullable', 'integer', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
'classification' => ['required', 'in:internal,confidential,restricted'], 'classification' => ['required', 'in:internal,confidential,restricted'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], 'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'],
'reason' => ['required', 'string', 'min:10', 'max:1000'], 'reason' => ['required', 'string', 'min:10', 'max:1000'],
'payload' => ['required', 'array:title,description,declared_reason,source_reference,valid_until'], 'payload' => ['required', 'array:'.implode(',', [...array_keys(BusinessFormSchema::fields()), 'wizard_version', 'wizard_step'])],
'payload.title' => ['required', 'string', 'max:200'], 'payload.title' => ['required', 'string', 'max:200'],
'payload.description' => ['nullable', 'string', 'max:10000'], 'payload.description' => ['nullable', 'string', 'max:10000'],
'payload.declared_reason' => ['nullable', 'string', 'max:2000'], 'payload.declared_reason' => ['nullable', 'string', 'max:2000'],
'payload.source_reference' => ['nullable', 'string', 'max:2000'], 'payload.source_reference' => ['nullable', 'string', 'max:2000'],
'payload.valid_until' => ['nullable', 'date_format:Y-m-d'], 'payload.valid_until' => ['nullable', 'date_format:Y-m-d'],
...BusinessFormSchema::rules(),
])->validate(); ])->validate();
return DB::transaction(function () use ($actor, $id, $data) { return DB::transaction(function () use ($actor, $id, $data) {
if ($id) { if ($id) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); $business = app(BusinessRepository::class)->find($id, true);
if (($business->payload['wizard_version'] ?? null) === '1') {
$data['payload']['wizard_version'] = '1';
}
Gate::forUser($actor)->authorize('update', $business); Gate::forUser($actor)->authorize('update', $business);
abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.'); abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.');
$business->version_no++; $business->version_no++;
@@ -49,6 +56,9 @@ final class ExistingBusinessDrafts
$business->status = 'draft'; $business->status = 'draft';
} }
$business->fill(['modality' => $data['modality'], 'classification' => $data['classification'], 'payload' => $data['payload']]); $business->fill(['modality' => $data['modality'], 'classification' => $data['classification'], 'payload' => $data['payload']]);
if (array_key_exists('sector_id', $data)) {
$business->sector_id = $data['sector_id'];
}
$business->save(); $business->save();
$this->snapshot($business, $actor, $id ? 'updated' : 'created', $data['reason']); $this->snapshot($business, $actor, $id ? 'updated' : 'created', $data['reason']);
@@ -60,7 +70,7 @@ final class ExistingBusinessDrafts
{ {
Validator::make(['reason' => $reason], ['reason' => ['required', 'string', 'min:10', 'max:1000']])->validate(); Validator::make(['reason' => $reason], ['reason' => ['required', 'string', 'min:10', 'max:1000']])->validate();
DB::transaction(function () use ($actor, $id, $version, $reason) { DB::transaction(function () use ($actor, $id, $version, $reason) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); $business = app(BusinessRepository::class)->find($id, true);
Gate::forUser($actor)->authorize('delete', $business); Gate::forUser($actor)->authorize('delete', $business);
abort_unless($business->version_no === $version, 409); abort_unless($business->version_no === $version, 409);
$business->version_no++; $business->version_no++;
+21 -3
View File
@@ -3,30 +3,48 @@
namespace App\Services; namespace App\Services;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User; use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class ExistingBusinessIntake final class ExistingBusinessIntake
{ {
public function transition(User $actor, string $id, int $version, string $action, string $note, bool $consent = false): ExistingBusiness public function transition(User $actor, string $id, int $version, string $action, string $note, bool $consent = false, ?string $confirmationCode = null): ExistingBusiness
{ {
Validator::make(compact('action', 'note'), [ Validator::make(compact('action', 'note'), [
'action' => ['required', 'in:submitted,in_review,needs_information,withdrawn'], 'action' => ['required', 'in:submitted,in_review,needs_information,withdrawn'],
'note' => ['required', 'string', 'min:10', 'max:2000'], 'note' => ['required', 'string', 'min:10', 'max:2000'],
])->validate(); ])->validate();
return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent) { return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent, $confirmationCode) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); $business = app(BusinessRepository::class)->find($id, true);
Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business); Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business);
abort_unless($business->version_no === $version, 409, 'Versão desactualizada.'); abort_unless($business->version_no === $version, 409, 'Versão desactualizada.');
$allowed = ['submitted' => ['draft', 'needs_information'], 'in_review' => ['submitted'], $allowed = ['submitted' => ['draft', 'needs_information'], 'in_review' => ['submitted'],
'needs_information' => ['in_review'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'needs_information']]; 'needs_information' => ['in_review'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'needs_information']];
abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.'); abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.');
if ($action === 'submitted') { if ($action === 'submitted') {
if (($business->payload['wizard_version'] ?? null) === '1') {
BusinessFormSchema::validateSubmission($business->payload);
$requiredKind = ['asset_sale' => 'asset', 'asset_lease' => 'asset', 'real_estate' => 'property',
'agricultural_investment' => 'agricultural_investment', 'land_right' => 'land_right',
'mining_license' => 'license', 'forestry_license' => 'license', 'capital_increase' => 'capital_request',
'participation_sale' => 'participation'][$business->modality] ?? null;
if ($requiredKind !== null && ! ExistingBusinessObject::where('business_id', $business->id)->where('kind', $requiredKind)->exists()) {
throw ValidationException::withMessages(['objects' => 'Declare o elemento aplicável na etapa 6: '.$requiredKind]);
}
Validator::make(['confirmation_code' => $confirmationCode, 'sector_id' => $business->sector_id], [
'confirmation_code' => ['required', Rule::in([$business->code])],
'sector_id' => ['required', Rule::exists('sectors', 'id')->whereNull('deleted_at')->where('is_active', true)],
])->validate();
}
Validator::make([...$business->payload, 'consent' => $consent], [ Validator::make([...$business->payload, 'consent' => $consent], [
'description' => ['required', 'string', 'min:30'], 'source_reference' => ['required', 'string', 'min:10'], 'description' => ['required', 'string', 'min:30'], 'source_reference' => ['required', 'string', 'min:10'],
'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], 'consent' => ['accepted'], 'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], 'consent' => ['accepted'],
+2 -2
View File
@@ -2,9 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject; use App\Models\ExistingBusinessObject;
use App\Models\User; use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@@ -58,7 +58,7 @@ final class ExistingBusinessObjects
Validator::make($data['payload'], $rules)->validate(); Validator::make($data['payload'], $rules)->validate();
return DB::transaction(function () use ($actor, $businessId, $objectId, $data) { return DB::transaction(function () use ($actor, $businessId, $objectId, $data) {
$business = ExistingBusiness::whereKey($businessId)->lockForUpdate()->firstOrFail(); $business = app(BusinessRepository::class)->find($businessId, true);
Gate::forUser($actor)->authorize('update', $business); Gate::forUser($actor)->authorize('update', $business);
abort_unless($business->version_no === $data['version_no'], 409, 'Versão desactualizada.'); abort_unless($business->version_no === $data['version_no'], 409, 'Versão desactualizada.');
abort_if(! $objectId && ExistingBusinessObject::where('business_id', $businessId)->count() >= 100, 422, 'Máximo de 100 elementos por negócio.'); abort_if(! $objectId && ExistingBusinessObject::where('business_id', $businessId)->count() >= 100, 422, 'Máximo de 100 elementos por negócio.');
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Models\ExistingBusiness; use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject; use App\Models\ExistingBusinessObject;
use App\Models\User; use App\Models\User;
use App\Services\Mne\BusinessRepository;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -34,7 +35,7 @@ final class ExistingBusinessVerification
])->validate(); ])->validate();
return DB::transaction(function () use ($actor, $id, $data) { return DB::transaction(function () use ($actor, $id, $data) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); $business = app(BusinessRepository::class)->find($id, true);
Gate::forUser($actor)->authorize('review', $business); Gate::forUser($actor)->authorize('review', $business);
abort_unless($business->status === 'in_review' && $business->version_no === $data['version_no'], 409, 'Reabra a versão actual em revisão.'); abort_unless($business->status === 'in_review' && $business->version_no === $data['version_no'], 409, 'Reabra a versão actual em revisão.');
if (! empty($data['object_id'])) { if (! empty($data['object_id'])) {
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
interface BusinessRepository
{
/** @return Builder<ExistingBusiness> */
public function query(): Builder;
public function find(string $id, bool $forUpdate = false): ExistingBusiness;
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
use Ramsey\Uuid\Uuid;
final class CompatibilityBackfill
{
/** @return array<string, int> */
public function run(int $batchSize = 100): array
{
$counts = [];
foreach (['identity' => ['users', 'identity_links', 'user_id'], 'sector' => ['sectors', 'sector_links', 'sector_id'],
'business' => ['existing_businesses', 'business_links', 'legacy_business_id']] as $domain => [$source, $target, $key]) {
// Start again on each invocation to detect edits behind a previous checkpoint.
$last = null;
$processed = 0;
do {
$batch = DB::transaction(function () use ($domain, $source, $target, $key, $last, $processed, $batchSize) {
DB::select('SELECT pg_advisory_xact_lock(290926,170004)');
$query = DB::table('public.'.$source)->orderBy('id')->limit($batchSize)->lockForUpdate();
if ($last !== null) {
$query->where('id', '>', $last);
}
$rows = $query->get();
$checksums = [];
foreach ($rows as $row) {
$checksum = hash('sha256', json_encode((array) $row, JSON_THROW_ON_ERROR));
$checksums[] = $checksum;
$stableId = (string) Uuid::uuid5(Uuid::NAMESPACE_URL, 'investmoz:'.$domain.':'.$row->id);
$data = ['id' => $stableId, $key => $row->id, 'source_system' => 'public.'.$source,
'source_checksum' => $checksum, 'status' => 'pending'];
if ($domain === 'sector') {
$data['canonical_sector_id'] = $stableId;
$data['source_code'] = $row->slug;
$data['status'] = $row->is_active && $row->deleted_at === null ? 'active' : 'pending';
}
$existing = DB::table('mne.'.$target)->where($key, $row->id)->first();
if (! $existing) {
DB::table('mne.'.$target)->insert($data);
} elseif ($existing->source_checksum !== $checksum) {
// Changed sources invalidate previous reconciliation; never activate by inference.
DB::table('mne.'.$target)->where($key, $row->id)->update(['source_checksum' => $checksum, 'status' => 'pending']);
}
if ($domain !== 'sector' && (! $existing || $existing->status !== 'active' || $existing->source_checksum !== $checksum)) {
DB::table('mne.compatibility_rejections')->upsert([[
'id' => $stableId, 'domain' => $domain, 'source_key' => (string) $row->id,
'code' => 'CANONICAL_MAPPING_REQUIRED', 'cause' => 'Explicit canonical mapping and reconciliation required',
'resolved_at' => null, 'resolution' => null,
]], ['domain', 'source_key', 'code'], ['cause', 'resolved_at', 'resolution']);
}
}
if ($rows->isNotEmpty() || $processed === 0) {
DB::table('mne.compatibility_checkpoints')->updateOrInsert(['domain' => $domain], [
'last_key' => $rows->isEmpty() ? $last : (string) $rows->last()->id,
'processed' => $processed + $rows->count(), 'checksum' => hash('sha256', implode('', $checksums)), 'updated_at' => now(),
]);
}
return $rows;
});
$processed += $batch->count();
if ($batch->isNotEmpty()) {
$last = $batch->last()->id;
}
} while ($batch->count() === $batchSize);
$counts[$domain] = $processed;
}
return $counts;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Services\Mne;
use App\Jobs\CompareMneBusiness;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class CompatibilityShadow
{
/** Invoke only after authorization; optional comparison must never affect the response. */
public function schedule(string $businessId): void
{
if (! config('existing-business.compatibility_shadow', false)) {
return;
}
try {
$control = DB::transaction(fn () => DB::table('mne.compatibility_controls')->where('capability', 'shadow')->first());
if (! $control || ! $control->enabled || (hexdec(substr(hash('sha256', $businessId), 0, 6)) % 100) >= $control->percentage) {
return;
}
$connection = (string) config('existing-business.shadow_queue', 'database');
if (config('queue.connections.'.$connection.'.driver') !== 'database') {
Log::info('mne.compatibility.shadow', ['outcome' => 'async_queue_required']);
return;
}
CompareMneBusiness::dispatch($businessId)->onConnection($connection)->onQueue('mne-shadow')->afterCommit();
} catch (\Throwable) {
Log::info('mne.compatibility.shadow', ['outcome' => 'dispatch_error']);
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
final class CompatibleBusinessRepository implements BusinessRepository
{
public function __construct(private LegacyBusinessRepository $legacy) {}
/** @return Builder<ExistingBusiness> */
public function query(): Builder
{
return $this->legacy->query();
}
public function find(string $id, bool $forUpdate = false): ExistingBusiness
{
// No configuration can transfer writes or bypass existing policies.
return $this->legacy->find($id, $forUpdate);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
final class IdentityResolver
{
public function resolve(int $userId): ?string
{
$id = DB::table('mne.identity_links as l')->join('mne.parties as p', 'p.id', '=', 'l.party_id')
->join('public.users as u', 'u.id', '=', 'l.user_id')
->where('l.user_id', $userId)->where('l.status', 'active')->where('p.party_type', 'person')
->whereNull('p.deleted_at')->where('l.valid_from', '<=', now())
->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->value('l.party_id');
return is_string($id) ? $id : null;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Mne;
use App\Models\ExistingBusiness;
use Illuminate\Database\Eloquent\Builder;
final class LegacyBusinessRepository implements BusinessRepository
{
/** @return Builder<ExistingBusiness> */
public function query(): Builder
{
return ExistingBusiness::query();
}
public function find(string $id, bool $forUpdate = false): ExistingBusiness
{
$query = $this->query()->whereKey($id);
if ($forUpdate) {
$query->lockForUpdate();
}
return $query->firstOrFail();
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
/** Read-only comparison adapter. Does not hydrate a legacy model or grant authority. */
final class MneBusinessRepository
{
/** @return array<string, mixed>|null */
public function comparison(string $legacyId): ?array
{
$row = DB::table('mne.business_links as l')
->join('mne.existing_businesses as b', 'b.id', '=', 'l.canonical_business_id')
->join('mne.business_modalities as m', 'm.id', '=', 'b.modality_id')
->join('mne.workflow_states as s', 's.id', '=', 'b.status_id')
->join('mne.data_classifications as c', 'c.id', '=', 'b.confidentiality_id')
->where('l.legacy_business_id', $legacyId)->where('l.status', 'active')
->where('l.valid_from', '<=', now())->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->whereNull('b.deleted_at')->first(['b.public_code as code', 'm.code as modality', 's.code as status',
'c.code as classification', 'b.version as version_no']);
return $row ? (array) $row : null;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Mne;
use Illuminate\Support\Facades\DB;
final class SectorResolver
{
public const VERSION = 1;
public function resolve(int $legacyId): ?string
{
$id = DB::table('mne.sector_links as l')->join('public.sectors as s', 's.id', '=', 'l.sector_id')
->where('l.sector_id', $legacyId)->where('l.resolver_version', self::VERSION)
->where('l.status', 'active')->where('s.is_active', true)->whereNull('s.deleted_at')
->whereColumn('l.source_code', 's.slug')->where('l.valid_from', '<=', now())
->where(fn ($q) => $q->whereNull('l.valid_until')->orWhere('l.valid_until', '>', now()))
->value('l.canonical_sector_id');
return is_string($id) ? $id : null;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Support;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
abstract class SkillsEducationMigration extends Migration
{
protected int $block;
/** @var list<string> */
protected array $tables;
public function up(): void
{
if (DB::getDriverName() !== 'pgsql') {
throw new \RuntimeException('PostgreSQL 17 with existing PostGIS/pgcrypto required.');
}
DB::select("SELECT pg_advisory_xact_lock(hashtextextended('skills_edu:migrations',0))");
DB::statement("SET LOCAL TIME ZONE 'UTC'");
$version = (int) DB::selectOne("SELECT current_setting('server_version_num') AS n")->n;
if ($version < 170000 || $version >= 180000 || DB::table('pg_extension')->whereIn('extname', ['pgcrypto', 'postgis'])->count() !== 2) {
throw new \RuntimeException('Expected PostgreSQL 17 and extensions managed by existing migrations.');
}
foreach (['users' => 'bigint', 'sectors' => 'bigint', 'skills' => 'uuid', 'occupations' => 'uuid', 'courses' => 'uuid', 'training_providers' => 'uuid',
'organizations' => 'uuid', 'projects' => 'uuid', 'economic_clusters' => 'uuid', 'development_corridors' => 'uuid', 'investment_zones' => 'uuid',
'geo_locations' => 'uuid', 'labour_demands' => 'uuid', 'education_investment_proposals' => 'uuid', 'documents' => 'uuid', 'consents' => 'uuid'] as $table => $type) {
$actual = DB::selectOne("SELECT format_type(atttypid,atttypmod) AS type FROM pg_attribute WHERE attrelid=to_regclass(?) AND attname='id' AND NOT attisdropped", ['public.'.$table]);
if (! $actual || $actual->type !== $type) {
throw new \RuntimeException('Unexpected master identifier: public.'.$table);
}
}
if ($this->block === 1) {
if (DB::selectOne("SELECT to_regnamespace('skills_edu') AS id")->id !== null) {
throw new \RuntimeException('Unregistered skills_edu schema exists; reconcile before migration.');
}
DB::statement('CREATE SCHEMA skills_edu');
DB::unprepared('CREATE FUNCTION skills_edu.set_updated_at() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$');
} else {
$predecessor = match ($this->block) {
2 => 'occupation_skills', 3 => 'institutional_candidate_registrations', 4 => 'institutional_authorisations',
5 => 'human_resource_need_certifications', 6 => 'profile_need_matches',
default => throw new \RuntimeException('Unknown skills education migration block'),
};
if (DB::selectOne('SELECT to_regclass(?) AS id', ['skills_edu.'.$predecessor])->id === null) {
throw new \RuntimeException('Missing previous skills_edu block: '.$predecessor);
}
}
$sql = file_get_contents(database_path('sql/skills-education/'.sprintf('%02d', $this->block).'.sql'));
if ($sql === false) {
throw new \RuntimeException('Versioned SQL block missing');
}
if (DB::connection()->getPdo()->exec($sql) === false) {
throw new \RuntimeException('Versioned SQL block failed');
}
DB::unprepared(<<<'SQL'
DO $indexes$
DECLARE fk record;
BEGIN
FOR fk IN SELECT c.conrelid,c.conkey,c.conname,t.relname,
(SELECT string_agg(quote_ident(a.attname),', ' ORDER BY k.p) FROM unnest(c.conkey) WITH ORDINALITY k(n,p)
JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=k.n) AS cols
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid JOIN pg_namespace ns ON ns.oid=t.relnamespace
WHERE ns.nspname='skills_edu' AND c.contype='f'
LOOP
IF NOT EXISTS (SELECT 1 FROM pg_index i JOIN pg_class x ON x.oid=i.indexrelid JOIN pg_am am ON am.oid=x.relam
WHERE i.indrelid=fk.conrelid AND i.indisvalid AND i.indisready AND i.indpred IS NULL AND am.amname='btree'
AND i.indnkeyatts>=cardinality(fk.conkey)
AND ARRAY(SELECT n FROM unnest(i.indkey::smallint[]) WITH ORDINALITY k(n,p) WHERE p<=cardinality(fk.conkey) ORDER BY p)=fk.conkey)
THEN EXECUTE format('CREATE INDEX %I ON skills_edu.%I (%s)','se_fk_'||md5(fk.relname||fk.conname),fk.relname,fk.cols);
END IF;
END LOOP;
END $indexes$;
SQL);
if ($this->block === 6) {
DB::unprepared(<<<'SQL'
CREATE FUNCTION skills_edu.reject_audit_mutation() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
BEGIN RAISE EXCEPTION 'Skills education audit is append-only' USING ERRCODE='23514'; END $$;
CREATE TRIGGER immutable_skills_education_audit BEFORE UPDATE OR DELETE OR TRUNCATE ON skills_edu.audit_events
FOR EACH STATEMENT EXECUTE FUNCTION skills_edu.reject_audit_mutation();
SQL);
}
}
public function down(): void
{
DB::select("SELECT pg_advisory_xact_lock(hashtextextended('skills_edu:migrations',0))");
foreach ($this->tables as $table) {
if (DB::table('skills_edu.'.$table)->exists()) {
throw new \RuntimeException('Skills education data exists; destructive rollback refused: '.$table);
}
}
foreach (array_reverse($this->tables) as $table) {
DB::statement('DROP TABLE skills_edu.'.$table.' RESTRICT');
}
if ($this->block === 6) {
DB::statement('DROP FUNCTION skills_edu.reject_audit_mutation() RESTRICT');
}
if ($this->block === 3) {
DB::statement('DROP FUNCTION skills_edu.validate_consent_dates() RESTRICT');
}
if ($this->block === 1) {
DB::statement('DROP FUNCTION skills_edu.set_updated_at() RESTRICT');
DB::statement('DROP SCHEMA skills_edu RESTRICT');
}
}
}
+2
View File
@@ -1,6 +1,8 @@
<?php <?php
return [ return [
'compatibility_shadow' => (bool) env('INVESTMOZ_MNE_COMPATIBILITY_SHADOW', false),
'shadow_queue' => 'database',
'enabled' => (bool) env('INVESTMOZ_MNE_ENABLED', false), 'enabled' => (bool) env('INVESTMOZ_MNE_ENABLED', false),
// Designations do not establish legal competence or an institutional mandate. // Designations do not establish legal competence or an institutional mandate.
'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_REVIEWERS', '')))))), 'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_REVIEWERS', '')))))),
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('existing_businesses', function (Blueprint $table) {
$table->foreignId('sector_id')->nullable()->constrained('sectors')->restrictOnDelete();
$table->index('sector_id');
});
}
public function down(): void
{
if (DB::table('existing_businesses')->whereNotNull('sector_id')->exists()) {
throw new RuntimeException('Sector associations exist; destructive rollback refused.');
}
Schema::table('existing_businesses', function (Blueprint $table) {
$table->dropForeign(['sector_id']);
$table->dropIndex(['sector_id']);
$table->dropColumn('sector_id');
});
}
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 1;
protected array $tables = ['skill_details', 'occupation_details', 'occupation_skills'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 2;
protected array $tables = ['professional_profiles', 'profile_contacts', 'profile_skills', 'profile_experiences', 'profile_languages', 'institutional_candidate_registrations'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 3;
protected array $tables = ['profile_qualifications', 'qualification_document_details', 'consent_details', 'institutional_authorisations'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 4;
protected array $tables = ['human_resource_need_details', 'human_resource_need_skills', 'human_resource_need_languages', 'human_resource_need_certifications'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 5;
protected array $tables = ['program_details', 'education_program_skills', 'skill_gaps', 'educational_opportunity_details', 'educational_opportunity_skills', 'profile_need_matches'];
};
@@ -0,0 +1,10 @@
<?php
use App\Support\SkillsEducationMigration;
return new class extends SkillsEducationMigration
{
protected int $block = 6;
protected array $tables = ['audit_events'];
};
@@ -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::unprepared(<<<'SQL'
CREATE FUNCTION public.lock_project_dependency_graph() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION 'Dependency writes require READ COMMITTED isolation' USING ERRCODE='25000';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended('investmoz:project-dependency-graph', 0));
RETURN NULL;
END $$;
CREATE FUNCTION public.check_project_dependency_graph() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF EXISTS (
WITH RECURSIVE edges AS (
SELECT anchor_project_id AS origin, complementary_project_id AS destination
FROM public.complementary_project_links
WHERE relation_type='dependency' AND deleted_at IS NULL
), paths(origin,destination) AS (
SELECT origin,destination FROM edges
UNION
SELECT p.origin,e.destination FROM paths p JOIN edges e ON e.origin=p.destination
) SELECT 1 FROM paths WHERE origin=destination
) THEN
RAISE EXCEPTION 'Project dependency cycle is not permitted' USING ERRCODE='23514';
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER project_dependency_lock BEFORE INSERT OR UPDATE ON public.complementary_project_links
FOR EACH STATEMENT EXECUTE FUNCTION public.lock_project_dependency_graph();
CREATE TRIGGER project_dependency_check AFTER INSERT OR UPDATE ON public.complementary_project_links
FOR EACH STATEMENT EXECUTE FUNCTION public.check_project_dependency_graph();
-- Validate the complete existing graph, without modifying a row.
UPDATE public.complementary_project_links SET relation_type=relation_type WHERE false;
SQL);
}
public function down(): void
{
DB::unprepared(<<<'SQL'
DROP TRIGGER project_dependency_check ON public.complementary_project_links;
DROP TRIGGER project_dependency_lock ON public.complementary_project_links;
DROP FUNCTION public.check_project_dependency_graph() RESTRICT;
DROP FUNCTION public.lock_project_dependency_graph() RESTRICT;
SQL);
}
};
+63
View File
@@ -0,0 +1,63 @@
CREATE TABLE skills_edu.skill_details (
id uuid PRIMARY KEY REFERENCES public.skills(id) ON DELETE RESTRICT,
name_en varchar(255),
description_pt text,
description_en text,
category varchar(30) NOT NULL CHECK (category IN ('technical','transversal','digital','management','language','other')),
skill_type varchar(30) NOT NULL DEFAULT 'competency',
default_level varchar(30),
source_name varchar(255),
source_version varchar(100),
status varchar(20) NOT NULL DEFAULT 'active' CHECK (status IN ('draft','active','inactive','archived')),
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.occupation_details (
id uuid PRIMARY KEY REFERENCES public.occupations(id) ON DELETE RESTRICT,
name_en varchar(255),
description_pt text,
description_en text,
occupational_family varchar(150),
sector_master_id bigint,
qualification_level varchar(80),
minimum_experience_months integer CHECK (minimum_experience_months IS NULL OR minimum_experience_months >= 0),
status varchar(20) NOT NULL DEFAULT 'active' CHECK (status IN ('draft','active','inactive','archived')),
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.occupation_skills (
occupation_id uuid NOT NULL REFERENCES public.occupations(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
requirement_type varchar(20) NOT NULL DEFAULT 'required' CHECK (requirement_type IN ('required','desired')),
minimum_level varchar(30),
weight numeric(5,2) CHECK (weight IS NULL OR weight BETWEEN 0 AND 100),
approved_at timestamptz,
approved_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (occupation_id, skill_id, requirement_type)
);
CREATE INDEX idx_skills_category_status ON skills_edu.skill_details(category,status) WHERE deleted_at IS NULL;
CREATE INDEX idx_occupations_sector_status ON skills_edu.occupation_details(sector_master_id,status) WHERE deleted_at IS NULL;
CREATE TRIGGER trg_skills_updated_at BEFORE UPDATE ON skills_edu.skill_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_occupations_updated_at BEFORE UPDATE ON skills_edu.occupation_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.skill_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_details ADD FOREIGN KEY (sector_master_id) REFERENCES public.sectors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.occupation_skills ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+123
View File
@@ -0,0 +1,123 @@
CREATE TABLE skills_edu.professional_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_master_id bigint,
registration_mode varchar(20) NOT NULL CHECK (registration_mode IN ('individual','institution')),
full_name varchar(255) NOT NULL,
identity_hash char(64),
professional_title varchar(255),
professional_summary text,
occupation_id uuid REFERENCES public.occupations(id) ON DELETE SET NULL,
location_master_id uuid,
location_level varchar(30) CHECK (location_level IS NULL OR location_level IN ('national','region','corridor','province','district','municipality','influence_area','zee','zfi','industrial_park','indicative')),
available_from date,
availability_type varchar(30),
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_investors','institutional','public_summary')),
status varchar(30) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','submitted','in_validation','validated','rejected','suspended','deactivated')),
submitted_at timestamptz,
validated_at timestamptz,
validated_by bigint,
created_by bigint,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE UNIQUE INDEX uq_professional_profiles_user_active ON skills_edu.professional_profiles(user_master_id) WHERE user_master_id IS NOT NULL AND deleted_at IS NULL AND status <> 'deactivated';
CREATE UNIQUE INDEX uq_professional_profiles_identity_active ON skills_edu.professional_profiles(identity_hash) WHERE identity_hash IS NOT NULL AND deleted_at IS NULL AND status <> 'deactivated';
CREATE TABLE skills_edu.profile_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
contact_type varchar(20) NOT NULL CHECK (contact_type IN ('email','phone')),
contact_value_encrypted text NOT NULL,
contact_hash char(64) NOT NULL,
is_primary boolean NOT NULL DEFAULT false,
is_confirmed boolean NOT NULL DEFAULT false,
confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (profile_id, contact_type, contact_hash)
);
CREATE TABLE skills_edu.profile_skills (
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
proficiency_level varchar(30) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','assessed','validated','rejected','expired')),
years_experience numeric(5,2) CHECK (years_experience IS NULL OR years_experience >= 0),
verified_by bigint,
verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (profile_id, skill_id)
);
CREATE TABLE skills_edu.profile_experiences (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
job_title varchar(255) NOT NULL,
sector_master_id bigint,
organisation_name varchar(255),
started_on date NOT NULL,
ended_on date,
description text,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','in_validation','validated','rejected')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (ended_on IS NULL OR ended_on >= started_on)
);
CREATE TABLE skills_edu.profile_languages (
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
language_code varchar(10) NOT NULL,
proficiency_level varchar(30) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','assessed','validated')),
PRIMARY KEY (profile_id, language_code)
);
CREATE TABLE skills_edu.institutional_candidate_registrations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL UNIQUE REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
institution_master_id uuid NOT NULL,
program_master_id uuid,
internal_candidate_code varchar(100),
academic_level varchar(100),
class_name varchar(100),
study_status varchar(30) CHECK (study_status IS NULL OR study_status IN ('enrolled','finalist','completed','suspended','withdrawn')),
expected_completion_date date,
institution_operator_user_id bigint NOT NULL,
institution_validation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (institution_validation_status IN ('pending','validated','rejected','suspended')),
candidate_confirmation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (candidate_confirmation_status IN ('pending','confirmed','rejected','revoked')),
candidate_confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (institution_master_id, internal_candidate_code)
);
CREATE INDEX idx_profiles_searchable ON skills_edu.professional_profiles(status,visibility_level,occupation_id,location_master_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_profile_skills_skill ON skills_edu.profile_skills(skill_id,verification_status);
CREATE INDEX idx_profile_contacts_hash ON skills_edu.profile_contacts(contact_hash);
CREATE UNIQUE INDEX uq_profile_primary_contact_type ON skills_edu.profile_contacts(profile_id,contact_type) WHERE is_primary = true;
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON skills_edu.professional_profiles FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_profile_skills_updated_at BEFORE UPDATE ON skills_edu.profile_skills FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (user_master_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (validated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.professional_profiles ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_skills ADD FOREIGN KEY (verified_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_experiences ADD FOREIGN KEY (sector_master_id) REFERENCES public.sectors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (institution_operator_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_candidate_registrations ADD FOREIGN KEY (program_master_id) REFERENCES public.courses(id) ON DELETE RESTRICT;
+101
View File
@@ -0,0 +1,101 @@
CREATE TABLE skills_edu.profile_qualifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
qualification_type varchar(30) NOT NULL CHECK (qualification_type IN ('degree','diploma','certificate','licence','completion_statement','other')),
name varchar(255) NOT NULL,
field_of_study varchar(255),
qualification_level varchar(100),
issuing_institution_name varchar(255) NOT NULL,
institution_master_id uuid,
issued_on date NOT NULL,
expires_on date,
holder_name varchar(255) NOT NULL,
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','in_validation','validated','rejected','expired')),
verified_by bigint,
verified_at timestamptz,
verification_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (expires_on IS NULL OR expires_on >= issued_on)
);
CREATE TABLE skills_edu.qualification_document_details (
id uuid PRIMARY KEY REFERENCES public.documents(id) ON DELETE RESTRICT,
qualification_id uuid REFERENCES skills_edu.profile_qualifications(id) ON DELETE RESTRICT,
experience_id uuid REFERENCES skills_edu.profile_experiences(id) ON DELETE RESTRICT,
document_type varchar(30) NOT NULL CHECK (document_type IN ('diploma','certificate','licence','completion_statement','academic_record','experience_proof','other')),
file_size_bytes bigint NOT NULL CHECK (file_size_bytes > 0),
document_group_id uuid NOT NULL DEFAULT gen_random_uuid(),
version_no integer NOT NULL DEFAULT 1 CHECK (version_no > 0),
replaces_document_id uuid REFERENCES skills_edu.qualification_document_details(id) ON DELETE RESTRICT,
malware_scan_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (malware_scan_status IN ('pending','clean','infected','error')),
malware_scanned_at timestamptz,
quarantine_status varchar(20) NOT NULL DEFAULT 'quarantined' CHECK (quarantine_status IN ('quarantined','released','blocked','deleted')),
validation_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending','in_validation','validated','rejected','expired','replaced')),
validation_reason text,
validated_by bigint,
validated_at timestamptz,
uploaded_by bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (((qualification_id IS NOT NULL)::integer + (experience_id IS NOT NULL)::integer) = 1),
CHECK (replaces_document_id IS NULL OR replaces_document_id <> id),
UNIQUE (document_group_id, version_no)
);
CREATE TABLE skills_edu.consent_details (
id uuid PRIMARY KEY REFERENCES public.consents(id) ON DELETE RESTRICT,
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
legal_basis varchar(100),
policy_version varchar(50) NOT NULL,
channel varchar(30) NOT NULL,
expires_at timestamptz,
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE skills_edu.institutional_authorisations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
institution_master_id uuid NOT NULL,
purpose_code varchar(100) NOT NULL,
policy_version varchar(50) NOT NULL,
status varchar(20) NOT NULL CHECK (status IN ('granted','expired','revoked')),
granted_at timestamptz NOT NULL,
expires_at timestamptz,
revoked_at timestamptz,
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (expires_at IS NULL OR expires_at > granted_at),
CHECK (revoked_at IS NULL OR revoked_at >= granted_at)
);
CREATE INDEX idx_qualifications_profile_status ON skills_edu.profile_qualifications(profile_id,verification_status) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_qualification_status ON skills_edu.qualification_document_details(qualification_id,validation_status) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_scan_status ON skills_edu.qualification_document_details(malware_scan_status,quarantine_status);
CREATE UNIQUE INDEX uq_documents_current_version ON skills_edu.qualification_document_details(document_group_id) WHERE deleted_at IS NULL AND validation_status <> 'replaced';
CREATE INDEX idx_authorisations_profile_institution ON skills_edu.institutional_authorisations(profile_id,institution_master_id,status);
ALTER TABLE skills_edu.profile_qualifications ADD FOREIGN KEY (verified_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_qualifications ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.qualification_document_details ADD FOREIGN KEY (validated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.qualification_document_details ADD FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.institutional_authorisations ADD FOREIGN KEY (institution_master_id) REFERENCES public.training_providers(id) ON DELETE RESTRICT;
CREATE FUNCTION skills_edu.validate_consent_dates() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
DECLARE granted timestamptz; subject text;
BEGIN
SELECT granted_at,subject_ref INTO granted,subject FROM public.consents WHERE id=NEW.id;
IF subject IS DISTINCT FROM NEW.profile_id::text OR granted IS NULL OR (NEW.expires_at IS NOT NULL AND NEW.expires_at<=granted) THEN
RAISE EXCEPTION 'Consent master profile or validity mismatch' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER validate_consent_master BEFORE INSERT OR UPDATE ON skills_edu.consent_details
FOR EACH ROW EXECUTE FUNCTION skills_edu.validate_consent_dates();
+83
View File
@@ -0,0 +1,83 @@
CREATE TABLE skills_edu.human_resource_need_details (
id uuid PRIMARY KEY REFERENCES public.labour_demands(id) ON DELETE RESTRICT,
company_master_id uuid NOT NULL,
cluster_master_id uuid,
corridor_master_id uuid,
zee_master_id uuid,
zfi_master_id uuid,
requires_qualified_hr boolean NOT NULL,
profile_description text,
field_of_study varchar(255),
minimum_experience_months integer CHECK (minimum_experience_months IS NULL OR minimum_experience_months >= 0),
quantity_unit varchar(50) DEFAULT 'professionals',
engagement_type varchar(50),
training_available boolean NOT NULL DEFAULT false,
internship_places integer CHECK (internship_places IS NULL OR internship_places >= 0),
priority varchar(20) CHECK (priority IS NULL OR priority IN ('low','medium','high','critical')),
source_version varchar(100),
confidence_level varchar(20) CHECK (confidence_level IS NULL OR confidence_level IN ('low','medium','high','confirmed')),
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_users','public_summary')),
responsible_user_id bigint NOT NULL,
approved_by bigint,
approved_at timestamptz,
created_by bigint NOT NULL,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK ((requires_qualified_hr = false AND profile_description IS NULL) OR (requires_qualified_hr = true AND profile_description IS NOT NULL))
);
CREATE TABLE skills_edu.human_resource_need_skills (
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
minimum_level varchar(30),
weight numeric(5,2) CHECK (weight IS NULL OR weight BETWEEN 0 AND 100),
is_approved boolean NOT NULL DEFAULT false,
approved_by bigint,
approved_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (need_id, skill_id, requirement_type)
);
CREATE TABLE skills_edu.human_resource_need_languages (
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
language_code varchar(10) NOT NULL,
minimum_level varchar(30) NOT NULL,
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
PRIMARY KEY (need_id, language_code)
);
CREATE TABLE skills_edu.human_resource_need_certifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
certification_name varchar(255) NOT NULL,
issuer_name varchar(255),
requirement_type varchar(20) NOT NULL CHECK (requirement_type IN ('required','desired')),
must_be_valid_on date,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_hr_need_skills_skill ON skills_edu.human_resource_need_skills(skill_id,requirement_type,is_approved);
CREATE TRIGGER trg_hr_needs_updated_at BEFORE UPDATE ON skills_edu.human_resource_need_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (responsible_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (company_master_id) REFERENCES public.organizations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (zee_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_details ADD FOREIGN KEY (zfi_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.human_resource_need_skills ADD FOREIGN KEY (approved_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+137
View File
@@ -0,0 +1,137 @@
CREATE TABLE skills_edu.program_details (
id uuid PRIMARY KEY REFERENCES public.courses(id) ON DELETE RESTRICT,
title_en varchar(255),
study_area varchar(255),
qualification_level varchar(100),
duration_value integer CHECK (duration_value IS NULL OR duration_value > 0),
duration_unit varchar(20),
delivery_mode varchar(20) CHECK (delivery_mode IS NULL OR delivery_mode IN ('in_person','hybrid','distance')),
capacity integer CHECK (capacity IS NULL OR capacity >= 0),
available_places integer CHECK (available_places IS NULL OR available_places >= 0),
cost_amount numeric(18,2) CHECK (cost_amount IS NULL OR cost_amount >= 0),
currency char(3),
location_master_id uuid,
accreditation_status varchar(30) NOT NULL DEFAULT 'declared' CHECK (accreditation_status IN ('declared','in_validation','confirmed','expired','not_accredited')),
accrediting_authority varchar(255),
accreditation_valid_until date,
source_name varchar(255),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CHECK (available_places IS NULL OR capacity IS NULL OR available_places <= capacity)
);
CREATE TABLE skills_edu.education_program_skills (
program_id uuid NOT NULL REFERENCES public.courses(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
target_level varchar(30),
verification_status varchar(20) NOT NULL DEFAULT 'declared' CHECK (verification_status IN ('declared','validated','rejected')),
PRIMARY KEY (program_id, skill_id)
);
CREATE TABLE skills_edu.skill_gaps (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
occupation_id uuid REFERENCES public.occupations(id) ON DELETE SET NULL,
cluster_master_id uuid,
corridor_master_id uuid,
project_master_id uuid,
location_master_id uuid,
period_start date NOT NULL,
period_end date,
demand_quantity numeric(18,2) NOT NULL DEFAULT 0,
supply_quantity numeric(18,2) NOT NULL DEFAULT 0,
gap_quantity numeric(18,2) GENERATED ALWAYS AS (demand_quantity - supply_quantity) STORED,
criticality varchar(20) CHECK (criticality IS NULL OR criticality IN ('low','medium','high','critical')),
evidence jsonb NOT NULL DEFAULT '{}'::jsonb,
validation_status varchar(20) NOT NULL DEFAULT 'draft' CHECK (validation_status IN ('draft','in_validation','validated','rejected')),
responsible_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (period_end IS NULL OR period_end >= period_start)
);
CREATE TABLE skills_edu.educational_opportunity_details (
id uuid PRIMARY KEY REFERENCES public.education_investment_proposals(id) ON DELETE RESTRICT,
gap_id uuid REFERENCES skills_edu.skill_gaps(id) ON DELETE SET NULL,
title_en varchar(255),
opportunity_type varchar(40) NOT NULL CHECK (opportunity_type IN ('new_course','course_expansion','training_centre','laboratory','internship','apprenticeship','upskilling','reskilling','trainer_training','certification_support','digital_learning','infrastructure','scholarship_fund','partnership','other')),
description_en text,
target_audience text,
required_capacity integer CHECK (required_capacity IS NULL OR required_capacity > 0),
cluster_master_id uuid,
corridor_master_id uuid,
project_master_id uuid,
zee_master_id uuid,
zfi_master_id uuid,
location_master_id uuid,
investment_mode varchar(100),
maturity varchar(30),
risks jsonb NOT NULL DEFAULT '[]'::jsonb,
source_records jsonb NOT NULL DEFAULT '[]'::jsonb,
visibility_level varchar(20) NOT NULL DEFAULT 'private' CHECK (visibility_level IN ('private','validated_users','public_summary')),
valid_until date,
created_by bigint NOT NULL,
updated_by bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE TABLE skills_edu.educational_opportunity_skills (
opportunity_id uuid NOT NULL REFERENCES skills_edu.educational_opportunity_details(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE RESTRICT,
target_level varchar(30),
PRIMARY KEY (opportunity_id, skill_id)
);
CREATE TABLE skills_edu.profile_need_matches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES skills_edu.professional_profiles(id) ON DELETE CASCADE,
need_id uuid NOT NULL REFERENCES skills_edu.human_resource_need_details(id) ON DELETE CASCADE,
rule_version varchar(50) NOT NULL,
score numeric(5,2) CHECK (score IS NULL OR score BETWEEN 0 AND 100),
required_conditions_met boolean NOT NULL DEFAULT false,
matched_skills jsonb NOT NULL DEFAULT '[]'::jsonb,
skill_gaps jsonb NOT NULL DEFAULT '[]'::jsonb,
explanation jsonb NOT NULL DEFAULT '{}'::jsonb,
confidence_level varchar(20) CHECK (confidence_level IS NULL OR confidence_level IN ('low','medium','high')),
review_status varchar(20) NOT NULL DEFAULT 'pending' CHECK (review_status IN ('pending','approved','rejected','contested')),
reviewed_by bigint,
reviewed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (profile_id, need_id, rule_version)
);
CREATE INDEX idx_skill_gaps_context ON skills_edu.skill_gaps(skill_id,cluster_master_id,corridor_master_id,project_master_id,validation_status);
CREATE INDEX idx_matches_need_score ON skills_edu.profile_need_matches(need_id,required_conditions_met,score DESC);
ALTER TABLE skills_edu.program_details ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (responsible_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (project_master_id) REFERENCES public.projects(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.skill_gaps ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (updated_by) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (project_master_id) REFERENCES public.projects(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (cluster_master_id) REFERENCES public.economic_clusters(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (corridor_master_id) REFERENCES public.development_corridors(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (zee_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (zfi_master_id) REFERENCES public.investment_zones(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.educational_opportunity_details ADD FOREIGN KEY (location_master_id) REFERENCES public.geo_locations(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.profile_need_matches ADD FOREIGN KEY (reviewed_by) REFERENCES public.users(id) ON DELETE RESTRICT;
+46
View File
@@ -0,0 +1,46 @@
CREATE TABLE skills_edu.audit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
occurred_at timestamptz NOT NULL DEFAULT now(),
actor_user_id bigint,
actor_role varchar(100),
organisation_master_id uuid,
action varchar(100) NOT NULL,
object_type varchar(100) NOT NULL,
object_id uuid,
previous_state jsonb,
new_state jsonb,
result varchar(20) NOT NULL CHECK (result IN ('success','denied','failed')),
reason text,
correlation_id uuid NOT NULL,
ip_hash char(64),
user_agent_hash char(64),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
REVOKE UPDATE, DELETE, TRUNCATE ON skills_edu.audit_events FROM PUBLIC;
CREATE INDEX idx_audit_object ON skills_edu.audit_events(object_type,object_id,occurred_at DESC);
CREATE INDEX idx_audit_actor ON skills_edu.audit_events(actor_user_id,occurred_at DESC);
CREATE INDEX idx_audit_correlation ON skills_edu.audit_events(correlation_id);
CREATE INDEX idx_profiles_location ON skills_edu.professional_profiles(location_master_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_uploaded_by ON skills_edu.qualification_document_details(uploaded_by,created_at DESC);
CREATE INDEX idx_opportunity_skills_skill ON skills_edu.educational_opportunity_skills(skill_id);
CREATE INDEX idx_program_skills_skill ON skills_edu.education_program_skills(skill_id);
CREATE TRIGGER trg_profile_contacts_updated_at BEFORE UPDATE ON skills_edu.profile_contacts FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_profile_experiences_updated_at BEFORE UPDATE ON skills_edu.profile_experiences FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_institutional_registrations_updated_at BEFORE UPDATE ON skills_edu.institutional_candidate_registrations FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_qualifications_updated_at BEFORE UPDATE ON skills_edu.profile_qualifications FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_documents_updated_at BEFORE UPDATE ON skills_edu.qualification_document_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_programs_updated_at BEFORE UPDATE ON skills_edu.program_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_skill_gaps_updated_at BEFORE UPDATE ON skills_edu.skill_gaps FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
CREATE TRIGGER trg_opportunities_updated_at BEFORE UPDATE ON skills_edu.educational_opportunity_details FOR EACH ROW EXECUTE FUNCTION skills_edu.set_updated_at();
COMMENT ON COLUMN skills_edu.professional_profiles.identity_hash IS 'Hash normalizado para deduplicação; nunca guardar o identificador em claro nesta coluna.';
COMMENT ON COLUMN skills_edu.profile_contacts.contact_value_encrypted IS 'Valor cifrado pela aplicação ou serviço de segredos; não indexar.';
COMMENT ON COLUMN skills_edu.profile_need_matches.explanation IS 'Critérios, pesos, dados, fonte, limitações e lacunas usados na recomendação.';
ALTER TABLE skills_edu.audit_events ADD FOREIGN KEY (actor_user_id) REFERENCES public.users(id) ON DELETE RESTRICT;
ALTER TABLE skills_edu.audit_events ADD FOREIGN KEY (organisation_master_id) REFERENCES public.organizations(id) ON DELETE RESTRICT;
@@ -0,0 +1,71 @@
# Script skills_edu — execução local de 30/09/2026
Executado o anexo de tabelas recebido em 30/09/2026, adaptado aos mestres existentes como exigido no próprio documento. O prompt funcional anterior foi cancelado e não foi retomado. Esta entrega é de estrutura relacional; não activa formulários, matching, publicação, acesso a documentos ou integrações.
## Resultado
- PostgreSQL **17.11**, pgcrypto/PostGIS já geridos pelas migrations existentes; nenhuma extensão reinstalada.
- Seis migrations Laravel registadas: `2026_09_30_100100_skills_education_1` até `2026_09_30_100600_skills_education_6`.
- **24 tabelas, 83 FK e 111 índices** no schema `skills_edu`. Todas as FK têm cobertura B-tree completa; índices parciais adicionais mantidos.
- Novas tabelas vazias. Nenhuma pessoa, competência, curso, procura ou oportunidade inventada/importada.
- Backup com restauro de **210 tabelas**: `storage/app/private/skills-education-backup-20260930-034646/`. A reconciliação posterior não encontrou diferenças, descontando os seis registos novos na tabela Laravel `migrations`.
- Evidência e checksums SQL: `applied-verification.json` no mesmo directório privado.
## Adaptação aos mestres
As seguintes tabelas são extensões com **PK também FK obrigatória** para o mestre. Não geram um segundo identificador da entidade. Nomes/códigos e outros campos indicados continuam a ser lidos no mestre; não foram copiados.
| Extensão em skills_edu | Mestre em public | Informação mantida no mestre |
|---|---|---|
| skill_details | skills | UUID, código e nome PT |
| occupation_details | occupations | UUID, código e nome PT |
| program_details | courses | UUID, código, título PT, instituição/provider e estado |
| human_resource_need_details | labour_demands | UUID, projecto, localização, ocupação, quantidade, qualificação, período, fonte e estado |
| educational_opportunity_details | education_investment_proposals | UUID da proposta; título, descrição, valor e moeda continuam na oportunidade ligada por opportunity_id |
| qualification_document_details | documents | UUID, nome do ficheiro, MIME, storage_key e SHA-256 |
| consent_details | consents | UUID, finalidade, estado, concessão e retirada |
Os estados destas entidades seguem o mestre existente: por exemplo, consentimento retirado usa `withdrawn`, não uma segunda coluna `revoked`. A unicidade de códigos de competências/ocupações/cursos conserva as regras actuais dos mestres, incluindo soft delete. A versão do grupo documental na extensão refere-se ao comprovativo; `documents.version_no` continua a ser a versão do registo mestre.
O consentimento deve ter `public.consents.subject_ref` igual ao UUID do perfil. O trigger da extensão valida esta ligação e que a expiração é posterior à concessão. Não converte consentimento de outra finalidade nem muda o mestre.
| Referência do anexo | Mestre e tipo confirmados |
|---|---|
| user_master_id e campos de actores *_by/*_user_id | public.users.id — bigint |
| sector_master_id | public.sectors.id — bigint |
| company_master_id / organisation_master_id | public.organizations.id — UUID |
| institution_master_id | public.training_providers.id — UUID |
| program_master_id | public.courses.id — UUID |
| project_master_id | public.projects.id — UUID |
| cluster_master_id | public.economic_clusters.id — UUID |
| corridor_master_id | public.development_corridors.id — UUID |
| zee_master_id / zfi_master_id | public.investment_zones.id — UUID |
| location_master_id | public.geo_locations.id — UUID |
As FK novas para mestres usam DELETE RESTRICT. Relações internas mantêm CASCADE/SET NULL/RESTRICT definidos no anexo. Não há conversão bigint–UUID. A escolha ZEE versus ZFI exige ainda validação de categoria na futura aplicação: a FK confirma a existência da zona, não a sua categoria.
## Migrations e segurança
SQL adaptado em `database/sql/skills-education/01.sql` a `06.sql`; execução por `App\Support\SkillsEducationMigration`. Cada migration é transaccional no PostgreSQL, adquire bloqueio consultivo, valida versão/extensões/tipos dos mestres e exige o bloco anterior. A primeira recusa um schema preexistente não registado. A repetição via Artisan terminou em `Nothing to migrate`; não se deve repetir o SQL bruto por fora do ledger.
O DOWN verifica tabelas vazias e remove somente as tabelas/funções/schema novos em ordem inversa, com RESTRICT. Recusa perda de dados e não apaga mestres nem extensões PostgreSQL. O rollback integral e a reaplicação foram executados na base isolada. Em produção/local com dados, preparar arquivo e reconciliação antes de qualquer reversão.
O anexo não define novas colunas geométricas; a localização aponta para o mestre geográfico existente. Não foram criadas coordenadas nem alterada a sua visibilidade. Perfis e necessidades conservam os valores privados por defeito.
A auditoria recusa UPDATE, DELETE e TRUNCATE, incluindo comandos sem linhas afectadas. Isto complementa o REVOKE do anexo; um administrador da BD continua capaz de alterar DDL. Cifragem de contactos, scanner, autorização de downloads e verificação humana são obrigações da futura aplicação, não capacidades fornecidas pelo DDL.
## Verificação
- Suite completa: **183 testes, 1871 verificações aprovadas**.
- PHPStan e Pint aprovados.
- Testes específicos: tipos/FK, extensão sem mestre recusada, utilizador inexistente, valores privados, intervalo inválido, auditoria imutável, cobertura de índices, rollback vazio, reaplicação e recusa de rollback com dados.
- O teste de rollback das zonas passou a reverter primeiro as extensões dependentes. O reset PHPUnit do schema fica restrito às bases conhecidas de teste em localhost:55441; não ocorre no ambiente local operacional.
- Não houve alterações de API ou frontend, nem commit Git.
Para inspeccionar o resultado sem modificar dados:
```powershell
php tools/verify-skills-education.php
```
Para novos ambientes, executar a cadeia Laravel normal após as migrations dos mestres: `php artisan migrate`. `tools/prepare-skills-education.py` documenta a transformação inicial do anexo; não deve ser usado para reescrever migrations já aplicadas. Correcções futuras exigem novas migrations.
@@ -0,0 +1,71 @@
# Matriz de competências — diagnóstico do novo prompt
> Actualização de 30/09/2026: o anexo posterior `ce3b9157-6463-4c3f-af50-6d53cba78a23` determina assumir todas as fases aprovadas e solicita reconciliação transversal do modelo de dados. A pendência de autorização genérica abaixo foi ultrapassada. Ver [reconciliação do capítulo 22](../reference-schema-reconciliation.md) para o âmbito e estado técnico actuais; este diagnóstico é histórico, não um pedido renovado de autorização.
Data: 30/09/2026. Fonte: anexo `bf1d310a-d3a8-48fc-953f-0648622a6c85/Pasted text.txt`. Este pedido retoma o âmbito funcional com uma nova especificação; não retoma automaticamente o prompt anteriormente cancelado.
A autorização do utilizador para executar está registada na conversa. Não se solicita novamente autorização genérica. O novo documento exige diagnóstico antes de codificar e determina: «Não iniciar desenvolvimento se não houver mapeamento aos RF aplicáveis, proprietário funcional, base legal/finalidade de tratamento, matriz de permissões, critérios de aceitação, plano de testes, estratégia de migração/rollback, definição de evidências, integração com o CRM, tratamento de consentimento e decisão Go/No-Go». Este diagnóstico não substitui esses elementos por aprovações inventadas.
## Estado observado no código
- `routes/web.php`: seis rotas internas Education, incluindo cadastro, procura/capacidade, revisão, análise e preparação de oportunidade. Não constituem a API REST versionada requerida.
- `EducationController`: pesquisa por texto, estado e localização; catálogos de competências, ocupações, instituições e cursos; não fornece o conjunto de filtros contextuais do novo pedido.
- `EducationEnabled` e `config/education.php`: uma flag global; não existem flags independentes para os novos fluxos.
- `EducationGapCalculator`: conserva desconhecido, expirado e incomparável; não converte vagas formativas em trabalhadores disponíveis.
- `education/index.tsx` e `education/analysis.tsx`: gestão interna; não são painéis individuais, institucionais e de investidores.
- As seis migrations `2026_09_30_100100`–`100600` fornecem as extensões `skills_edu`, descritas em [skills-education-schema.md](skills-education-schema.md). A existência das tabelas não fornece serviços de candidatura, documentos, consentimento ou correspondência.
- `labour_demands` exige campos preenchidos e admite apenas submitted/reviewed/rejected. Não suporta rascunho incompleto nem arquivo. Uma implementação de CA-01 exige evolução explícita deste contrato; não se deve criar outra tabela concorrente de procura.
- A integração `app/Integrations/SuiteCrm` existe. A sua existência não prova contrato nem autorização para transmitir candidatos.
- Os testes `EducationTest`, `EducationGapCalculatorTest` e `SkillsEducationSchemaTest` cobrem partes existentes. Não comprovam os novos CT-01–CT-16. Nenhuma nova execução de testes funcionais foi realizada neste diagnóstico documental.
## Conservar–Melhorar–Substituir–Descontinuar
| Decisão | Componentes | Justificação |
|---|---|---|
| Conservar | users, organizations, sectors, projects, economic_clusters, development_corridors, investment_zones, geo_locations | Identidades mestre; não converter bigint em UUID nem duplicar taxonomias. |
| Conservar | skills, occupations, courses, training_providers, consents, documents e extensões skills_edu | Reutilizar PK/FK e estados do mestre; novos dados por migrations aditivas. |
| Conservar | Fluxos internos, revisão independente, cálculo de lacunas e oportunidade canónica | Compatibilidade; capacidade de formação não equivale a oferta laboral. |
| Melhorar | labour_demands e serviços de procura | Rascunhos, arquivo, várias competências, contexto, versões e autorização por organização. |
| Melhorar | Interfaces e pesquisa | Painéis por actor, filtros combinados, PT/EN, acessibilidade e precisão territorial autorizada. |
| Melhorar | Consentimento, auditoria e documentos | Finalidades separadas, revogação, scanner real, quarentena e autorização em cada leitura. |
| Substituir | Nenhum componente nesta fase | Não há evidência que justifique reconstrução integral. |
| Descontinuar | Nenhum contrato nesta fase | Preservar consumidores e dados existentes. |
## Plano por ficheiro — proposto, ainda não executado
| Ordem | Caminhos | Alteração e verificação necessária |
|---|---|---|
| 1 | `docs/investmoz/education/requirements.csv`, nova matriz funcional | Relacionar RF-CAN, CA/CT, RF-EDU/CRM/EII/GEO e A.31 com entidades, APIs, ecrãs, evidências e responsáveis. Não atribuir IDs normativos sem conferir a fonte. |
| 2 | `config/education.php`, `app/Http/Middleware/EducationEnabled.php` | Flags independentes; desligar sem apagar dados. Testar acesso negado e contratos actuais. |
| 3 | Nova migration Laravel posterior a `100600` | Evoluir rascunho/arquivo, concorrência e escopos conforme desenho aprovado; não reescrever SQL aplicado. Testar ida/volta em base isolada e recusa de perda de dados. |
| 4 | Novos serviços em `app/Services/Education/`, requests e policies | Escritas transaccionais, segregação, organização vigente, validação contextual e auditoria sem conteúdo pessoal. |
| 5 | Novos controllers e rotas versionadas | Perfis, necessidades, instituições, consentimentos e documentos; paginação, limites, conflitos de versão e respostas sem enumeração. Manter rotas internas. |
| 6 | `resources/js/pages/education/` e componentes partilhados | Formulários por actor, rascunho/retoma, confirmação, revisão e estados; PT/EN, teclado e móvel. |
| 7 | Serviços de pesquisa/correspondência e jobs | Autorização antes de resultados; condições aprovadas antes da pontuação; versão e explicação; revogação verificada também na execução de jobs. Sem encaminhamento automático. |
| 8 | `app/Integrations/SuiteCrm/`, contratos e reconciliação | Só campos e finalidades aprovados; deduplicação, idempotência e recuperação; alternativa manual enquanto contrato externo pendente. |
| 9 | `tests/Feature/`, `tests/Unit/`, ferramentas de navegador e contratos | CT-01–CT-16: fluxos, fronteiras, acesso, documentos maliciosos, revogação, concorrência, PT/EN, recuperação e carga com linha de base real. |
## Regras de desenho propostas para revisão
Proprietário consulta e edita o próprio rascunho; operador institucional precisa de vínculo vigente à instituição verificada e autorização do candidato; revisor não pode validar a própria submissão; investidor só pesquisa perfis validados, consentidos e no âmbito permitido. Contactos e documentos exigem autorização própria, não herdada da pesquisa. Gestão global não comprova mandato institucional.
Separar consentimentos de pesquisa, correspondência, partilha de contactos e transmissão ao CRM. Não reutilizar consentimento comercial. Revogação deve impedir a próxima leitura e execução de tarefa dependente; tempos de propagação externa, retenção e backups precisam de política aprovada. Esta proposta não declara base legal nem prazo de retenção.
Sem mudança da ligação principal de dados nesta etapa. Antes de futuras migrations: backup/restauro, contagens e checksums dos mestres, teste isolado, aplicação aditiva e reconciliação. Reversão operacional por flags, preservando registos; reversão estrutural apenas sem perda de dados. Sem deploy nem commit nesta entrega.
## Gate de diagnóstico
**Não concluído. Não iniciar desenvolvimento funcional sob este prompt até resolver os bloqueadores materiais expressamente definidos nele.**
| Elemento | Evidência disponível / falta concreta |
|---|---|
| Autorização para executar | Recebida; não precisa de repetição. |
| Rastreabilidade | Há `requirements.csv`, `requirements-audit.csv` e especificação anterior. Há referências a A.31, mas não foi identificada nesta pesquisa a secção normativa integral com versão e correspondência ao novo anexo. |
| Âmbito | O novo pedido inclui indivíduos, instituições e correspondência; a decisão antiga D-EDU-06 excluía dados individuais. Actualizar formalmente o registo de âmbito com o novo pedido, sem tratar a autorização de desenvolvimento como consentimento dos candidatos. |
| Responsabilidade e tratamento | Faltam identificação do responsável funcional e política aplicável de finalidade, retenção, menores e destinatários. Papéis genéricos não são designação de pessoas/entidades. |
| Correspondência | Faltam regras, pesos e conjunto de referência aprovados; não adoptar exemplos fictícios como dados operacionais. |
| Desempenho | Falta linha de base da Fase Zero; não criar limites arbitrários para declarar CT-14 aprovado. |
| Integração | Falta contrato específico de candidatos/consentimento com CRM e confirmação da aplicabilidade de AsyncAPI. |
| Aceite | UAT com quatro perfis e decisões dos responsáveis não foram realizados; nenhuma aprovação fabricada. |
Próxima entrada necessária: localização da versão normativa de A.31 e do pacote de decisões da Fase Zero, incluindo os responsáveis e políticas acima. Se esses documentos ainda não existirem, é necessário resolver a divergência entre a ordem de implementar e a proibição explícita de iniciar desenvolvimento contida neste anexo.
@@ -0,0 +1,23 @@
# Reexecução do prompt — anexos D/E — 29/09/2026
1. **Fase e estado:** correcção incremental dos comandos de validação e do bloqueio de leitura canónica, aplicada localmente. O módulo completo e os anexos D/E continuam parcialmente implementados.
2. **Diagnóstico:** os nomes históricos são `001_mne_schema.sql` e `002_mne_existing_territories.sql`, não os nomes ilustrados no novo anexo. Os SHA-256 de 001–004 foram comparados com os SQL conservados nas quatro evidências de aplicação e coincidem. Não foram renomeados, divididos ou reescritos ficheiros já aplicados.
3. **Plano aplicado:** adicionar simulação transaccional ao backfill; dar código de saída não-zero à reconciliação bloqueada; criar 005 para recusar activação/rollout canónico; testar com papel sem superuser.
4. **Ficheiros:** criados `005_mne_compatibility_guards.sql` e respectivo DOWN; alterados `BackfillMneCompatibility`, `ReconcileMneCompatibility` e `tools/test-mne-compatibility.php`. Documentação actualizada. Nenhum ficheiro removido; nenhum commit Git criado.
5. **Migrations/reversão:** 005 acrescenta somente uma constraint aos controlos; exige 004 e recusa um controlo canónico já activo em vez de o desligar silenciosamente. Usa transacção e advisory lock próprio. DOWN remove apenas essa constraint. UP repetido, DOWN e reaplicação passaram em QA. Depois de 005, não reaplicar 004 como se fosse uma migration pendente: a sua verificação de estrutura detectará a constraint adicional.
6. **Impacto:** nenhuma mudança de API, interface, integração externa ou autoridade de escrita. `public` permanece mestre. Nenhuma pessoa ou negócio canónico criado.
7. **Testes:** teste SQL de compatibilidade aprovado, incluindo dry-run sem persistência e activação canónica recusada; 11 testes MNE / 91 verificações aprovados. Papel sintético NOLOGIN/NOSUPERUSER conseguiu inserir auditoria e teve 12 mutações de históricos e duas operações DDL recusadas. Papel e dados de teste revertidos integralmente. Não equivale à mudança das credenciais runtime reais.
8. **Evidência:** backup integral com restauro de 210 tabelas em `storage/app/private/mne-integrity-backup-20260929-194732/`; aplicação em `storage/app/private/mne-guards-20260929-194806/`; contagens de 97 tabelas anteriores `mne`/`investmoz` preservadas. Execução local de dry-run: 1 identidade, 12 sectores, 0 negócios, rollback confirmado. Reconciliação com `--fail-on-difference` devolveu 1, conforme esperado.
9. **Lacunas:** continuam a faltar convergência de todas as colunas do anexo D, histórico persistente de migrations SQL, instalador da cadeia com ordem/idempotência completas, testes de concorrência em duas sessões, navegador, SLA/notificações e deduplicação. O runtime real ainda é superuser, a identidade permanece pendente e não existem negócios reais reconciliados. Não se considera a cadeia 001–005 integralmente certificada.
10. **Rastreabilidade:** D.3/E.1 dry-run → comando de backfill → teste de rollback; E.1 fail-on-difference → comando de reconciliação → saída local 1; D.1 canonical_read desactivado → 005 → dois testes negativos; E.2 privilégios → papel isolado → 14 recusas. Separação efectiva dos papéis de operação permanece pendente.
11. **Decisão:** Go condicionado para este incremento técnico local; **No-Go** para leitura/escrita canónica, publicação ou produção. Os controlos shadow/canonical_read locais continuam desligados.
12. **Próxima fase proposta, não iniciada neste incremento:** completar o registo e o executor da cadeia SQL preservando os checksums históricos, seguido da separação efectiva de owner/migrator/runtime e dos testes de concorrência. As aprovações de desenvolvimento existentes não são consideradas prova de identidade ou reconciliação dos dados.
Comandos disponíveis:
```powershell
php artisan mne:backfill-compatibility --batch=100 --dry-run
php artisan mne:reconcile-compatibility --fail-on-difference
```
O dry-run executa as mesmas validações numa transacção exterior e reverte tudo; pode adquirir locks durante a simulação. Sem `--dry-run`, continua a gravar apenas vínculos/checkpoints/rejeições. `--fail-on-difference` é estrito: enquanto os gates de activação estiverem abertos, devolve falha mesmo com contagens e checksums iguais.
@@ -0,0 +1,25 @@
# Integridade do schema canónico — 29/09/2026
## Diagnóstico e âmbito entregue
A aplicação continua a usar `public.existing_businesses` e os serviços de intake/revisão privados. As 70 tabelas `mne` são um modelo físico separado, ainda sem ligação aos fluxos da aplicação. A presença das tabelas não significa que o mercado completo esteja implementado.
Foi aplicado `migrations/003_mne_integrity.sql` ao PostgreSQL 17 local. Acrescenta 81 índices onde nenhuma chave de índice B-tree completo cobre o prefixo da chave estrangeira. Índices parciais não asseguram cobertura de todos os registos. Não cria tabelas, copia negócios ou altera dados.
Os históricos `audit_events`, `business_status_history`, `document_versions` e `negotiation_events` passam a recusar UPDATE, DELETE e TRUNCATE, incluindo operações sem linhas afectadas. Novas evidências continuam a ser inseridas. Correcções devem ser novos eventos. Os triggers não impedem um administrador da base de dados de os desactivar; a separação de papéis operacionais permanece necessária.
## Validação e reversibilidade
- Teste reproduzível: `tools/test-mne-integrity.php`, exclusivamente em `portal_services_test`, porta 55441, `APP_ENV=testing`.
- Criação isolada de 001 e 002; aplicação dupla de 003 sem índices duplicados; todas as FK com cobertura; 12 mutações recusadas; DOWN seguido de reaplicação; rollback integral da transacção de teste.
- Backup completo e restauro isolado com contagens reconciliadas de 204 tabelas: `storage/app/private/mne-integrity-backup-20260929-185518/`.
- Aplicação local com contagens preservadas de 91 tabelas `mne`/`investmoz`: `storage/app/private/mne-integrity-20260929-185534/`.
- Reversão deste incremento: `migrations/003_mne_integrity_down.sql`. Remove apenas os triggers, a função e os índices identificados como pertencentes a 003. Não executar o DOWN integral 001 para reverter este incremento.
Nenhuma mudança de UI, API ou eventos externos; o OpenAPI mantém o contrato privado existente. Não foi executada a suite completa da aplicação neste incremento SQL. Nenhum commit Git foi criado.
## Trabalho ainda necessário
A fase de modelo e integração continua incompleta. Antes de adoptar o schema `mne` nos serviços é necessário resolver a identidade dos utilizadores (`public.users.id` bigint versus `mne.parties.user_id` UUID), o vínculo dos sectores e a migração reconciliada dos negócios existentes, sem criar cópias concorrentes. Publicação com duas aprovações, documentos/scanner, catálogo anonimizado, CRM, sala segura, negociação e facturação continuam pendentes conforme a matriz de implementação. Este incremento não constitui aprovação de produção nem entrega integral do prompt.
Incremento seguinte iniciado e aplicado: [ponte de compatibilidade 004](compatibility.md), com vínculos, backfill e repositório legado. A integração canónica completa continua pendente; consultar os gates e resultados nesse documento.
@@ -0,0 +1,56 @@
# Ponte de compatibilidade — 29/09/2026
Actualização: [reexecução dos anexos D/E](annex-de-execution.md), com migration 005 aplicada, dry-run, saída estrita de reconciliação e testes de privilégios numa conta isolada não-superuser. As limitações de activação abaixo mantêm-se.
## Estado da entrega
Incremento aplicado e testado localmente. **O anexo D ainda não está integralmente concluído.** `public` continua como autoridade de leitura e escrita dos formulários. Não há escrita dupla, migração de negócios, publicação nem activação de leitura canónica.
## Inventário confirmado
`public.users.id` e `public.sectors.id` são bigint; `public.existing_businesses.id`, `mne.parties.id` e `mne.existing_businesses.id` são UUID. O formulário privado não possui campo sectorial. A associação territorial de `mne.locations` continua a usar os identificadores inteiros de `investmoz`, sem nova taxonomia.
`004_mne_compatibility.sql` cria seis tabelas: identity_links, sector_links, business_links, compatibility_checkpoints, compatibility_rejections e compatibility_controls. Todas as novas FK têm cobertura por índices completos das constraints UNIQUE. O script verifica pré-condições, usa bloqueio consultivo, recusa tabelas de ponte com definição divergente e pode ser reaplicado. Não altera os ficheiros 001–003.
A coluna UUID `mne.parties.user_id` é mantida para reversibilidade, mas a nova constraint exige NULL. Contas autenticadas passam a ser resolvidas exclusivamente através de identity_links. A migration recusa dados antigos nesse campo em vez de tentar converter bigint em UUID. Pessoas podem existir sem conta; IdentityResolver só devolve uma ligação activa, vigente, a uma pessoa não arquivada. O resolver não autentica nem atribui permissões.
## Aplicação e backfill
BusinessRepository, LegacyBusinessRepository e CompatibleBusinessRepository estão ligados aos pontos de consulta dos controladores e dos quatro serviços privados. Políticas, bloqueios de escrita, versões e payloads HTTP existentes são preservados. Criação e gravação continuam nos modelos legados.
MneBusinessRepository é, por enquanto, um adaptador **apenas para comparação de cinco campos**: código, modalidade, estado, classificação e versão. Não implementa ainda a hidratação completa de um negócio nem substitui a autorização legada. SectorResolver v1 devolve UUIDs estáveis de ligação à taxonomia existente; não copia nomes ou cria sectores canónicos concorrentes.
```powershell
php artisan mne:backfill-compatibility --batch=100
php artisan mne:reconcile-compatibility
```
Usar um PHP com extensão pgsql. O backfill faz transacção por lote, bloqueio consultivo, IDs determinísticos, checksums e checkpoint do último lote não vazio. Cada nova execução percorre novamente a origem, para detectar também alterações anteriores ao checkpoint. Dados modificados invalidam a reconciliação. Vínculos sem destino canónico ficam pendentes, com código, causa e responsável `data-steward` na fila de excepções. A resolução não é aceite automaticamente. O backfill não cria pessoas ou negócios canónicos.
Resultado local: **1 vínculo de identidade pendente, 12 vínculos sectoriais activos, 0 negócios**, nenhuma chave em falta nem checksum divergente na execução. Há uma excepção de identidade não resolvida. A inexistência de negócios não conta como validação de uma migração real.
## Comparação sombra e reversão operacional
A comparação só é agendada após autorização de consulta da ficha. Requer `INVESTMOZ_MNE_COMPATIBILITY_SHADOW=true` e o controlo `shadow` em compatibility_controls activado com referência de evidência e percentagem. Ambos estão desligados na instalação local. O consumidor é `php artisan queue:work database --queue=mne-shadow`; filas síncronas são recusadas. O evento contém apenas resultado e latência, sem payload, identificador ou mensagem de excepção. Desligar o controlo interrompe novos agendamentos; trabalhos já enfileirados continuam somente de leitura.
Comparação parcial igual não abre o gate de leitura canónica. `canonical_read` está reservado, sem adaptador de resposta completo habilitado. Alterar a flag não transfere leitura ou escrita. Não se apresentam métricas de fallback canónico como se essa capacidade já existisse.
O DOWN 004 recusa qualquer vínculo, checkpoint, rejeição, controlo activo ou pessoa dependente. Esta é a situação local após backfill: para reverter a estrutura, é necessário arquivar/reconciliar primeiro essas dependências. Para continuar a operar pelo legado não é necessário executar DOWN. O script não remove dados de negócios nem históricos.
## Evidência
- Backup/restauro verificado de 204 tabelas antes de 004: `storage/app/private/mne-integrity-backup-20260929-191334/`.
- Aplicação: `storage/app/private/mne-compatibility-20260929-191400/`; contagens de 91 tabelas anteriores preservadas; schema mne passa de 70 para 76 tabelas.
- `tools/test-mne-compatibility.php`: 001–004 em base isolada, repetição, DOWN/reaplicação, backfill repetido, negócio sintético, acesso proprietário/terceiro, unicidade, FK, identidade revogada, sector desconhecido/alterado e recusa de estrutura divergente. Transacção integralmente revertida.
- Suite completa: 178 testes / 1820 verificações aprovados antes do teste adicional de isolamento da sombra. Esse teste adicional passou com 5 verificações.
- PHPStan aprovado. Contrato OpenAPI privado preservado. Sem alterações de interface; navegador não validado neste incremento.
## Gates ainda não satisfeitos
1. Correspondência canónica da identidade pendente e resolução formal das excepções.
2. Campo/relação sectorial nos negócios e reconciliação real de proprietários, estados e conteúdo completo.
3. Adaptador de leitura canónica completo, comparação integral, rollout e fallback testados.
4. Separação owner/migrator/runtime e testes de privilégios: a ligação local actual é superuser. Os triggers existem, mas esta conta pode desactivá-los. Nenhuma garantia de privilégio mínimo é declarada.
5. Concorrência com duas sessões, jornadas de navegador nos dois modos, notificações/SLA e deduplicação ponta a ponta.
Permanecem bloqueadas a transferência de escrita, a publicação e a produção. Não foi criado commit Git.
@@ -0,0 +1,187 @@
{
"proponent_type": {
"step": 1,
"pt": "Tipo de proponente",
"en": "Applicant type",
"type": "select",
"max": 30,
"options": {
"person": "Pessoa \/ Person",
"organization": "Organização \/ Organization"
}
},
"proponent_name": {
"step": 1,
"pt": "Nome ou denominação",
"en": "Name",
"type": "text",
"max": 200
},
"tax_identifier": {
"step": 1,
"pt": "NUIT ou identificador",
"en": "Tax identifier",
"type": "text",
"max": 50
},
"contact_email": {
"step": 1,
"pt": "Email de contacto",
"en": "Contact email",
"type": "email",
"max": 254
},
"contact_phone": {
"step": 1,
"pt": "Telefone",
"en": "Phone",
"type": "tel",
"max": 40
},
"country": {
"step": 1,
"pt": "País (código de duas letras)",
"en": "Country (two-letter code)",
"type": "text",
"max": 2
},
"acting_capacity": {
"step": 2,
"pt": "Qualidade em que actua",
"en": "Acting capacity",
"type": "select",
"max": 30,
"options": {
"holder": "Titular \/ Holder",
"representative": "Representante \/ Representative"
}
},
"declared_holder": {
"step": 2,
"pt": "Titular declarado",
"en": "Declared holder",
"type": "text",
"max": 200
},
"mandate_reference": {
"step": 2,
"pt": "Referência do mandato",
"en": "Mandate reference",
"type": "text",
"max": 500
},
"mandate_valid_until": {
"step": 2,
"pt": "Validade do mandato",
"en": "Mandate expiry",
"type": "date",
"max": 10
},
"legitimacy_declaration": {
"step": 2,
"pt": "Declaro legitimidade para esta submissão",
"en": "I declare authority to submit",
"type": "select",
"max": 3,
"options": {
"yes": "Sim \/ Yes",
"no": "Não \/ No"
}
},
"title": {
"step": 3,
"pt": "Título proposto",
"en": "Proposed title",
"type": "text",
"max": 200
},
"description": {
"step": 3,
"pt": "Descrição reservada",
"en": "Private description",
"type": "textarea",
"max": 10000
},
"declared_reason": {
"step": 3,
"pt": "Motivo e objectivo",
"en": "Reason and objective",
"type": "textarea",
"max": 2000
},
"valid_until": {
"step": 3,
"pt": "Validade declarada",
"en": "Declared expiry",
"type": "date",
"max": 10
},
"asking_value": {
"step": 4,
"pt": "Valor pedido",
"en": "Asking value",
"type": "decimal",
"max": 24
},
"currency": {
"step": 4,
"pt": "Moeda (código de três letras)",
"en": "Currency (three-letter code)",
"type": "text",
"max": 3
},
"value_nature": {
"step": 4,
"pt": "Natureza do valor",
"en": "Value basis",
"type": "select",
"max": 30,
"options": {
"asking": "Pedido \/ Asking",
"indicative": "Indicativo \/ Indicative",
"appraised": "Avaliado, sujeito a prova \/ Appraised, evidence required"
}
},
"equity_percentage": {
"step": 4,
"pt": "Participação disponível (%)",
"en": "Available equity (%)",
"type": "decimal",
"max": 8
},
"funds_use": {
"step": 4,
"pt": "Utilização dos fundos",
"en": "Use of funds",
"type": "textarea",
"max": 2000
},
"location_notes": {
"step": 5,
"pt": "Localização declarada (reservada)",
"en": "Declared location (private)",
"type": "textarea",
"max": 2000
},
"asset_notes": {
"step": 6,
"pt": "Resumo dos activos e direitos declarados",
"en": "Declared assets and rights summary",
"type": "textarea",
"max": 2000
},
"source_reference": {
"step": 7,
"pt": "Referência da fonte",
"en": "Source reference",
"type": "textarea",
"max": 2000
},
"document_reference": {
"step": 7,
"pt": "Referências documentais (não são ficheiros validados)",
"en": "Document references (not validated files)",
"type": "textarea",
"max": 2000
}
}
@@ -1,5 +1,13 @@
# Estado actual — Mercado de Negócios Existentes # Estado actual — Mercado de Negócios Existentes
29/09/2026 — [Formulário privado por oito etapas](wizard-implementation.md) disponível com autosave, retoma, sector e confirmação no servidor. Testado por API e navegador; os anexos A–C completos continuam pendentes.
29/09/2026 — [Anexos D/E: incremento 005](annex-de-execution.md) aplicado. Simulação do backfill, reconciliação com saída de falha e bloqueio SQL de activação canónica testados; a entrega integral continua pendente.
29/09/2026 — [Ponte de compatibilidade 004](compatibility.md) aplicada: seis tabelas de vínculo/controlo, backfill, resolvers e interface de repositório nos serviços privados. Legado permanece autoridade; identidade pendente, leitura canónica completa e privilégios mínimos ainda impedem a activação.
29/09/2026 — [Integridade do schema canónico](canonical-integrity.md): aplicado incremento SQL com 81 índices e protecção de quatro históricos; teste isolado de idempotência/reversão aprovado. As 70 tabelas `mne` ainda não alimentam a interface privada. A integração do modelo e as fases seguintes continuam incompletas.
Actualização 29/09/2026: [revisão granular com evidência, validade e invalidação por alteração](verification-evidence.md) implementada na ficha privada. Suite: 173 testes / 1785 verificações aprovadas. Os limites e contagens abaixo descrevem a entrega anterior; o módulo integral continua incompleto. Actualização 29/09/2026: [revisão granular com evidência, validade e invalidação por alteração](verification-evidence.md) implementada na ficha privada. Suite: 173 testes / 1785 verificações aprovadas. Os limites e contagens abaixo descrevem a entrega anterior; o módulo integral continua incompleto.
28/09/2026. A instrução mais recente autoriza executar todas as fases e avançar automaticamente. Substitui a paragem entre fases do prompt inicial. Não presume evidências jurídicas, nomeação de revisores, publicação ou integração externa efectiva. 28/09/2026. A instrução mais recente autoriza executar todas as fases e avançar automaticamente. Substitui a paragem entre fases do prompt inicial. Não presume evidências jurídicas, nomeação de revisores, publicação ou integração externa efectiva.
+635 -16
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "InvestMoz — private existing-business intake", "title": "InvestMoz — private existing-business intake",
"version": "1.1.0", "version": "1.2.0",
"description": "Internal preparation and review only. No publication, certification, legal transfer, NDA, payment or external CRM operation." "description": "Internal preparation and review only. No publication, certification, legal transfer, NDA, payment or external CRM operation."
}, },
"servers": [ "servers": [
@@ -165,37 +165,241 @@
"properties": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
"maxLength": 200 "maxLength": 200,
"description": "Proposed title"
}, },
"description": { "description": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 10000 "maxLength": 10000,
"description": "Private description"
}, },
"declared_reason": { "declared_reason": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Reason and objective"
}, },
"source_reference": { "source_reference": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Source reference"
}, },
"valid_until": { "valid_until": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date" "maxLength": 10,
"format": "date",
"description": "Declared expiry"
},
"proponent_type": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"person",
"organization",
null
],
"description": "Applicant type"
},
"proponent_name": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Name"
},
"tax_identifier": {
"type": [
"string",
"null"
],
"maxLength": 50,
"description": "Tax identifier"
},
"contact_email": {
"type": [
"string",
"null"
],
"maxLength": 254,
"description": "Contact email"
},
"contact_phone": {
"type": [
"string",
"null"
],
"maxLength": 40,
"description": "Phone"
},
"country": {
"type": [
"string",
"null"
],
"maxLength": 2,
"description": "Country (two-letter code)"
},
"acting_capacity": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"holder",
"representative",
null
],
"description": "Acting capacity"
},
"declared_holder": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Declared holder"
},
"mandate_reference": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Mandate reference"
},
"mandate_valid_until": {
"type": [
"string",
"null"
],
"maxLength": 10,
"format": "date",
"description": "Mandate expiry"
},
"legitimacy_declaration": {
"type": [
"string",
"null"
],
"maxLength": 3,
"enum": [
"yes",
"no",
null
],
"description": "I declare authority to submit"
},
"asking_value": {
"type": [
"string",
"null"
],
"maxLength": 24,
"description": "Asking value"
},
"currency": {
"type": [
"string",
"null"
],
"maxLength": 3,
"description": "Currency (three-letter code)"
},
"value_nature": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"asking",
"indicative",
"appraised",
null
],
"description": "Value basis"
},
"equity_percentage": {
"type": [
"string",
"null"
],
"maxLength": 8,
"description": "Available equity (%)"
},
"funds_use": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Use of funds"
},
"location_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared location (private)"
},
"asset_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared assets and rights summary"
},
"document_reference": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Document references (not validated files)"
},
"wizard_version": {
"type": [
"string",
"null"
],
"enum": [
"1",
null
]
},
"wizard_step": {
"type": [
"string",
"null"
],
"pattern": "^[1-8]$"
} }
} }
},
"sector_id": {
"type": [
"integer",
"null"
],
"description": "Existing active public.sectors identifier; required on wizard submission."
} }
} }
} }
@@ -516,41 +720,245 @@
"properties": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
"maxLength": 200 "maxLength": 200,
"description": "Proposed title"
}, },
"description": { "description": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 10000 "maxLength": 10000,
"description": "Private description"
}, },
"declared_reason": { "declared_reason": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Reason and objective"
}, },
"source_reference": { "source_reference": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Source reference"
}, },
"valid_until": { "valid_until": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date" "maxLength": 10,
"format": "date",
"description": "Declared expiry"
},
"proponent_type": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"person",
"organization",
null
],
"description": "Applicant type"
},
"proponent_name": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Name"
},
"tax_identifier": {
"type": [
"string",
"null"
],
"maxLength": 50,
"description": "Tax identifier"
},
"contact_email": {
"type": [
"string",
"null"
],
"maxLength": 254,
"description": "Contact email"
},
"contact_phone": {
"type": [
"string",
"null"
],
"maxLength": 40,
"description": "Phone"
},
"country": {
"type": [
"string",
"null"
],
"maxLength": 2,
"description": "Country (two-letter code)"
},
"acting_capacity": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"holder",
"representative",
null
],
"description": "Acting capacity"
},
"declared_holder": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Declared holder"
},
"mandate_reference": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Mandate reference"
},
"mandate_valid_until": {
"type": [
"string",
"null"
],
"maxLength": 10,
"format": "date",
"description": "Mandate expiry"
},
"legitimacy_declaration": {
"type": [
"string",
"null"
],
"maxLength": 3,
"enum": [
"yes",
"no",
null
],
"description": "I declare authority to submit"
},
"asking_value": {
"type": [
"string",
"null"
],
"maxLength": 24,
"description": "Asking value"
},
"currency": {
"type": [
"string",
"null"
],
"maxLength": 3,
"description": "Currency (three-letter code)"
},
"value_nature": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"asking",
"indicative",
"appraised",
null
],
"description": "Value basis"
},
"equity_percentage": {
"type": [
"string",
"null"
],
"maxLength": 8,
"description": "Available equity (%)"
},
"funds_use": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Use of funds"
},
"location_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared location (private)"
},
"asset_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared assets and rights summary"
},
"document_reference": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Document references (not validated files)"
},
"wizard_version": {
"type": [
"string",
"null"
],
"enum": [
"1",
null
]
},
"wizard_step": {
"type": [
"string",
"null"
],
"pattern": "^[1-8]$"
} }
} }
}, },
"version_no": { "version_no": {
"type": "integer", "type": "integer",
"minimum": 1 "minimum": 1
},
"sector_id": {
"type": [
"integer",
"null"
],
"description": "Existing active public.sectors identifier; required on wizard submission."
} }
} }
} }
@@ -641,6 +1049,13 @@
"review_consent": { "review_consent": {
"type": "boolean", "type": "boolean",
"description": "Must be true for submission. Stored in encrypted review evidence. Does not authorize publication." "description": "Must be true for submission. Stored in encrypted review evidence. Does not authorize publication."
},
"confirmation_code": {
"type": [
"string",
"null"
],
"description": "Exact business code required for wizard_version=1 submission, in addition to internal review consent."
} }
} }
} }
@@ -1359,37 +1774,241 @@
"properties": { "properties": {
"title": { "title": {
"type": "string", "type": "string",
"maxLength": 200 "maxLength": 200,
"description": "Proposed title"
}, },
"description": { "description": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 10000 "maxLength": 10000,
"description": "Private description"
}, },
"declared_reason": { "declared_reason": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Reason and objective"
}, },
"source_reference": { "source_reference": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"maxLength": 2000 "maxLength": 2000,
"description": "Source reference"
}, },
"valid_until": { "valid_until": {
"type": [ "type": [
"string", "string",
"null" "null"
], ],
"format": "date" "maxLength": 10,
"format": "date",
"description": "Declared expiry"
},
"proponent_type": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"person",
"organization",
null
],
"description": "Applicant type"
},
"proponent_name": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Name"
},
"tax_identifier": {
"type": [
"string",
"null"
],
"maxLength": 50,
"description": "Tax identifier"
},
"contact_email": {
"type": [
"string",
"null"
],
"maxLength": 254,
"description": "Contact email"
},
"contact_phone": {
"type": [
"string",
"null"
],
"maxLength": 40,
"description": "Phone"
},
"country": {
"type": [
"string",
"null"
],
"maxLength": 2,
"description": "Country (two-letter code)"
},
"acting_capacity": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"holder",
"representative",
null
],
"description": "Acting capacity"
},
"declared_holder": {
"type": [
"string",
"null"
],
"maxLength": 200,
"description": "Declared holder"
},
"mandate_reference": {
"type": [
"string",
"null"
],
"maxLength": 500,
"description": "Mandate reference"
},
"mandate_valid_until": {
"type": [
"string",
"null"
],
"maxLength": 10,
"format": "date",
"description": "Mandate expiry"
},
"legitimacy_declaration": {
"type": [
"string",
"null"
],
"maxLength": 3,
"enum": [
"yes",
"no",
null
],
"description": "I declare authority to submit"
},
"asking_value": {
"type": [
"string",
"null"
],
"maxLength": 24,
"description": "Asking value"
},
"currency": {
"type": [
"string",
"null"
],
"maxLength": 3,
"description": "Currency (three-letter code)"
},
"value_nature": {
"type": [
"string",
"null"
],
"maxLength": 30,
"enum": [
"asking",
"indicative",
"appraised",
null
],
"description": "Value basis"
},
"equity_percentage": {
"type": [
"string",
"null"
],
"maxLength": 8,
"description": "Available equity (%)"
},
"funds_use": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Use of funds"
},
"location_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared location (private)"
},
"asset_notes": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Declared assets and rights summary"
},
"document_reference": {
"type": [
"string",
"null"
],
"maxLength": 2000,
"description": "Document references (not validated files)"
},
"wizard_version": {
"type": [
"string",
"null"
],
"enum": [
"1",
null
]
},
"wizard_step": {
"type": [
"string",
"null"
],
"pattern": "^[1-8]$"
} }
} }
},
"sector_id": {
"type": [
"integer",
"null"
],
"description": "Existing active public.sectors identifier; required on wizard submission."
} }
} }
} }
@@ -0,0 +1,24 @@
{
"passed": true,
"observations": [
{
"scenario": "autosave_and_reload",
"passed": true
},
{
"scenario": "responsive_summary",
"width": 390,
"passed": true
},
{
"scenario": "responsive_summary",
"width": 1440,
"passed": true
},
{
"scenario": "private_submission",
"passed": true
}
],
"errors": []
}
@@ -0,0 +1,43 @@
# Formulário por etapas — entrega local, 29/09/2026
**Disponível em `/investidor/negocios`: criar rascunho e abrir a ficha. O prompt A–C completo ainda não está entregue.** Este incremento implementa a preparação privada por etapas, autosave, retoma, sector e confirmação. Não implementa ainda publicação, uploads seguros, manifestações MNE ou operação integral.
1. **Diagnóstico tecnológico:** Laravel 13.30.1, PHP de validação 8.5.11, React 19.2.8, TypeScript 5.9.3, Inertia, Vite Plus, Leaflet e PostgreSQL 17.11/PostGIS. Mantidos componentes e autenticação por sessão/email verificado. Filas database/Redis e disco S3 são configuráveis; conectividade Redis/S3/ClamAV e CRM não ensaiada neste incremento. Sem novas dependências.
2. **Pressupostos:** formulário autenticado, classificação reservada e `public.existing_businesses` como autoridade. Identidade e titularidade são declarações pendentes. Referências documentais não são uploads, mandatos validados ou autorizações de transmissão. O preenchimento permite rascunhos incompletos; a submissão aplica validações adicionais. Clientes anteriores sem wizard_version conservam o contrato legado; converter todos os clientes exige uma migração de contrato posterior.
3. **Mapa A–C:** ver matriz abaixo. Não há equivalência entre oito etapas visíveis e implementação de todos os requisitos dessas etapas.
4. **Plano por fase:** responsável técnico da implementação: Codex. Incremento A1 executado: schema/validação, FK sectorial, wizard e testes. A2 pendente: localização normalizada/cartografia, contexto económico, anexos/scanner, consentimentos separados e acessibilidade integral. B pendente: manifestação MNE ligada a anúncio publicado, perfil e CRM idempotente. C pendente: aprovações, tarefas, SLA, alertas e dashboard. Dependências e gates mantidos em `implementation-status.md`; autorizações de desenvolvimento existentes preservadas.
5. **Modelo PostgreSQL:** coluna nullable bigint `existing_businesses.sector_id`, FK RESTRICT para a taxonomia existente e índice B-tree completo. Dados declarativos privados ficam no payload cifrado já existente; activos/direitos continuam nas entidades tipadas existentes. Não foram criadas pessoas, sectores ou negócios no schema mne. Localização deste formulário é ainda descritiva, não georreferenciada.
6. **Migration/modelos:** `2026_09_29_200000_existing_business_form_sector.php` aplicada localmente. DOWN remove a coluna somente se não houver associações sectoriais. Modelo ExistingBusiness existente preservado; `BusinessFormSchema` centraliza campos, rótulos PT/EN e validação. Snapshot/auditoria cifrados existentes conservados.
7. **API:** mesmos nove endpoints privados `/api/v1/existing-business`. `sector_id` é adicional no draft; `confirmation_code` exigido na submissão de wizard_version=1. GET da ficha fornece formSchema e sectores activos. Erros 403/409/422 e ausência de publicação preservados. OpenAPI actualizado para 1.2.0; schema exportável por `tools/export-business-form-schema.php`.
8. **Permissões:** proprietário verificado cria/edita rascunho ou pedido de informação; revisor designado, independente e com MFA consulta submissões. Terceiros recusados. Revisores recebem os novos campos apenas após a policy de consulta. Autosave usa as mesmas autorizações e versões da gravação manual.
9. **Estados:** rascunho → submetido; revisão/pedido de informação/retirada existentes. Confirmação explícita por código e consentimento de análise interna no servidor. Repetir submissão com versão anterior dá 409 sem duplicar histórico; ainda não há comprovativo idempotente por chave de pedido. As duas aprovações e os demais estados de A–C continuam pendentes.
10. **Serviços/eventos/Jobs:** reutilizados ExistingBusinessDrafts, ExistingBusinessIntake, objectos, snapshots e ponte legada. Nenhum Job externo, tarefa de triagem, notificação ou envio CRM novo. O consentimento actual é de revisão interna; não é o catálogo completo de consentimentos versionados do anexo.
11. **React:** `business-wizard.tsx` oferece oito etapas, progresso, PT/EN nos controlos principais, autosave após três segundos, retoma no servidor, resumo e confirmação. Navegação/fecho avisam sobre alterações não gravadas; conflitos não substituem silenciosamente dados. Representante revela campos de mandato. Activos/direitos reutilizam BusinessObjects; estes componentes existentes ainda não estão integralmente traduzidos. Criação/consulta administrativa por representação de terceiros permanece pendente.
12. **Código por ficheiro:** novos `app/Services/BusinessFormSchema.php`, migration sectorial, `resources/js/pages/investment/business-wizard.tsx`, `tests/Feature/ExistingBusinessWizardTest.php`, exportador de schema e ferramentas de navegador. Alterados controlador, Drafts, Intake, business.tsx, businesses.tsx, business-types.ts e gerador OpenAPI. Código completo nesses ficheiros; sem ficheiros removidos nem commit Git.
13. **Testes:** suite completa executada: 180 testes / 1845 verificações. Depois do controlo adicional por modalidade e do teste de rollback, suite específica do wizard: 2 testes / 30 verificações aprovados. Cobertura inclui cifragem, terceiro recusado, retoma, sector, dados inválidos, conflito de versão, mandato, activo obrigatório, confirmação, submissão duplicada e rollback. Navegador: autosave/recarga, resumo em 390/1440 px e submissão, sem excepções JS. Isto não é uma auditoria WCAG completa.
14. **Comandos:** abaixo. Não foi necessário instalar pacotes Composer/npm. Backend requer PHP com pgsql. Migration local aplicada após backup/restauro, usando `--path` específico. Os scripts de navegador e fixtures só usam QA em 55441/18027, nunca a base local operacional.
15. **Resultados:** PHPStan, Pint, TypeScript, lint do wizard e build aprovados. Backup com restauro de 210 tabelas: `storage/app/private/mne-integrity-backup-20260929-200103/`. Evidência do navegador: `wizard-browser-validation.json`. Auditoria de dependências e ensaios de serviços externos não executados neste incremento.
16. **Riscos residuais:** restantes campos e relações A; identidade normalizada; coordenadas/geometria; CRM MNE; scanner/upload; redis em operação; consentimentos separados; comprovativo/notificações; SLA; aprovações/publicação; tradução integral; testes de acessibilidade, carga e serviços externos. A conta de BD local ainda é superuser. Nenhuma alegação de prontidão para produção.
17. **Go/No-Go:** Go para testar preparação privada local; **No-Go para entrega integral A–C ou Go-Live**. Os fluxos incompletos não foram expostos como funcionais.
| Requisito | Implementação actual | Estado |
|---|---|---|
| A: proponente/representação | Campos cifrados, mandato condicional, declaração e validação | Parcial; sem verificação de identidade/documentos |
| A: modalidade/sector/valor | Taxonomia existente, FK, limites, moeda e natureza na submissão | Parcial; faltam intervalos e estruturas completas |
| A: localização/contexto | Descrição privada | Geografia estruturada e ligações pendentes |
| A: activos/direitos | Editor tipado existente; objecto aplicável exigido por modalidade | Parcial |
| A: fontes/documentos | Referências declaradas | Upload/scanner pendentes |
| A: rascunho/retoma/resumo | Autosave, versão, oito etapas, confirmação no servidor | Testado |
| B: interesse/CRM | Serviço geral existente preservado | Integração MNE pendente |
| C: revisão | Revisão privada e histórico existentes | Tarefas/SLA/dupla aprovação pendentes |
```powershell
php artisan migrate --path=database/migrations/2026_09_29_200000_existing_business_form_sector.php
php tools/export-business-form-schema.php
node tools/existing-business-openapi.mjs
php vendor/bin/phpunit --filter ExistingBusinessWizard --do-not-cache-result
npm.cmd run types:check
npm.cmd run build
```
Em ambiente QA isolado (`APP_ENV=testing`, base `portal_services_test`, porta 55441): `php tools/business-wizard-browser-seed.php`, seguido de `node tools/business-wizard-browser.mjs`, com servidor QA 18027 e navegador CDP 9224. As fixtures sintéticas ficam apenas nessa base de teste.
@@ -0,0 +1,55 @@
# Reconciliação do DDL de referência — 30/09/2026
Origem: anexo `ce3b9157-6463-4c3f-af50-6d53cba78a23/Pasted text.txt`, capítulo 22. A instrução «Assumir que todas as fase estão aprovadas» substitui a pendência de autorização de fases do diagnóstico anterior. Não se exige nova aprovação genérica. O requisito de concluir a correspondência antes do DDL continua aplicável.
## Evidência executada
`tools/inventory-reference-schema.php` consulta PostgreSQL numa transacção REPEATABLE READ, READ ONLY. Inventaria tabelas e vistas, colunas/tipos/defaults, constraints, índices, triggers e contagens, sem exportar registos pessoais nem credenciais.
Resultado: **237 tabelas, 2.343 colunas, 806 índices, 1.171 constraints**. Inventário privado: `storage/app/private/reference-reconciliation-20260930-042147/inventory.json`; SHA-256 `bbd0cd93d590be0d4121efad6009e66f1a545c280f2491fd845831070712d723`.
O total inclui schemas legados e tabelas de extensões. Não significa 237 entidades aplicacionais distintas. A evidência é um inventário, não um backup restaurável nem certificado de qualidade dos dados.
## Matriz de correspondência
As decisões abaixo seleccionam objectos reais. «Estender» não significa que a alteração já foi aplicada. Nenhuma equivalência parcial é declarada cobertura integral.
| Conceito proposto | Objecto existente / evidência no repositório | Cobertura e lacunas | Decisão técnica |
|---|---|---|---|
| Fontes / data_sources | `public.geo_sources` (migration territorial_registry); `public.investment_legal_sources` (investment_zone_foundation); `public.ai_sources`; `investmoz.fontes_dados`; `mne.source_records` | Há cinco mecanismos especializados, com identidades, validações e consumidores diferentes. A fonte jurídica não é automaticamente autorização para IA nem proveniência cartográfica. Não existe correspondência transversal comprovada. | Mapear por compatibilidade, preservando tipo e schema na identidade. Não criar `data_sources` nem fundir registos por URL apenas. A definição do adaptador e da referência transversal ainda falta. |
| Diplomas / legal_instruments | `public.investment_legal_sources`; modelo InvestmentLegalSource; serviço InvestmentZones | Diploma, artigo, entidade competente, URL, vigência, versão, proprietário e revisor já existem. Tipo, número estruturado e publicação não têm colunas próprias. | Estender este mestre com os campos efectivamente necessários; não duplicar diplomas em legal_instruments. Documentos legais do portal conservam a referência ao ficheiro. |
| Organizações / organisations | `public.organizations`, `memberships`; migration investmoz_identity; `mne.organizations` separado | Mestre público UUID; pertenças existentes. MNE não está integrado como autoridade. Tax identifier, contactos e site não devem ser copiados silenciosamente entre módulos. | Reutilizar public.organizations; mapear MNE apenas por vínculo comprovado. Preservar IDs e contas bigint. |
| Áreas administrativas | `public.geo_locations`, `geo_territory_profiles`, `geo_names`; seis tabelas `investmoz.provincia` a `aldeia_quarteirao`; TerritorialRegistry | Mestre aplicacional UUID e cadastro administrativo inteiro coexistem: 1.990 geo_locations e 21.410 unidades nas seis tabelas. Códigos/níveis precisam de correspondência, não de conversão por posição ou nome. | Mapear por compatibilidade; não criar administrative_areas. Preservar a hierarquia completa, IDs e precisão. |
| Entidades territoriais | `economic_clusters`, `development_corridors`, `investment_zones`, `value_chains`, `value_chain_links`; migrations clusters/corridors/zone_foundation | Tipos especializados e relações já existem. Subcluster/nó territorial não têm um mestre identificado; não confundir elo de cadeia com nó geográfico. | Reutilizar mestres especializados e estender relações explícitas; não criar territorial_entities como segundo cadastro. |
| Investimentos | `investment_items`, `investment_item_projects`, `investment_item_opportunities`, `projects`, `opportunities`, `existing_businesses`; InvestmentIdentity | 6 items ligados a 6 oportunidades, 16 projectos e 0 negócios. O item é identidade de ligação, não cópia completa da ficha. IDs UUID e bigint têm contratos distintos. | Reutilizar InvestmentIdentity e os mestres. Não criar investments; não cadastrar projectos em massa só para preencher a ponte. |
| Relações | `cluster_projects`, `corridor_projects`, `corridor_clusters`, `opportunity_*`, `investment_zone_links`, `investment_zone_relations` | FK explícitas; relações de zona já têm fonte, vigência, validação, associação, prioridade e justificação. Nem todos os pivots têm essa cobertura. | Estender somente pivots deficientes; manter os actuais. Não criar investment_relations polimórfica concorrente. |
| Dependências | `complementary_project_links`, migration investmoz_clusters | Direcção anchor→complementary, tipos dependency/enabler/supplier/market, auto-relação recusada. Não foi identificado controlo de ciclos indirectos ou concorrentes. | Estender esta tabela e o serviço de escrita para dependency; não tratar supplier/market como dependência acíclica. Falta implementar/testar controlo de ciclos. |
| Geometrias | `geo_geometries`, `project_locations`, `investment_zone_geometries`; migrations geography/zone_foundation | PostGIS 4326, 2D, não vazio, válido, tipo consistente e limites geográficos já impostos. Zonas distinguem perímetro oficial/influência/site e fonte legal. | Reutilizar. Não criar spatial_features nem duplicar geometria em cada entidade. Pertença jurídica nunca inferida por ST_Intersects. |
| Instalações | `existing_business_objects`; `mne.assets`, `real_estates`, `real_estate_units`, `improvements`; respectivas migrations e serviços | Existem activos/instalações de negócios, mas não um inventário operacional de lotes/capacidade de zonas. Payload privado não é uma tabela de reservas. | Criar apenas a relação/entidade operacional de instalação territorial ausente, após distinguir instalação de activo patrimonial. Não copiar os activos MNE. Ainda não implementada. |
| Regras regulatórias | `investment_legal_sources`, regras de publicação em InvestmentZones, `mne.restrictions`, `portal_resources` | Diplomas, restrições patrimoniais e recursos existem; não foi encontrado cadastro de regras territoriais versionadas dos cinco tipos do anexo. | Criar regras territoriais referenciando o diploma existente, com FK explícitas; preservar restrições patrimoniais. Ainda não implementado. |
| Documentos | `public.documents`, pivots `*_documents`, portal_resources; `mne.documents` e qualificações skills_edu | Mestre público tem UUID, chave, MIME, checksum, classificação e versão; domínios mantêm ligações próprias. Novos campos de vigência/título/fonte exigem mapeamento. | Reutilizar documents e pivots; não criar outro documents nem copiar ficheiros. MNE conserva contrato enquanto não reconciliado. |
| Indicadores | Contagens calculadas em EducationController; `skills_edu.skill_gaps`; conteúdo de investment_dossiers | Existem métricas calculadas, mas não série genérica com definição, unidade, natureza, período, fonte e aprovação. Lacuna de competências não equivale a qualquer indicador. | Criar série de indicadores apenas para essa lacuna, com proprietário por FK explícita. Não persistir cópia das contagens dos dashboards. Ainda não implementado. |
| Manifestações | `interest_requests`, `interest_submission_receipts`, leads, contacts, consents, outbox_events; InterestSubmission e ConvertInterestToLead | Fluxo de interesse e CRM existente; consentimentos e recibos separados. Manifestação não é acto administrativo nem candidatura. | Reutilizar fluxo e IDs; estender contexto/SLA onde faltar, sem expressions_of_interest paralelo. |
| Versões | version_no dos mestres; investment_zone_revisions, geo_territory_revisions, existing_business_revisions, investment_submission_revisions | Snapshots especializados existem; version_no isolado não permite reconstrução. Não há histórico completo de todos os mestres. | Reutilizar históricos por domínio e estender cobertura; não criar entity_versions que duplique snapshots existentes. |
| Auditoria | `public.audit_events`, PortalAudit, trigger imz_audit_events_immutable; auditorias especializadas mne/skills_edu | Auditoria pública já recusa UPDATE/DELETE/TRUNCATE por statement, incluindo zero linhas. Conta proprietária pode alterar DDL. | Reutilizar; preservar tipos e segregação de dados. Não substituir UUID por bigserial nem criar outro audit_events público. |
## Decisões e limites
Responsável pela autorização das fases: utilizador, instrução de 30/09/2026. Decisões técnicas: reconciliação acima, baseada no inventário e no código. Esta identificação não inventa revisores humanos, mandatos públicos, aprovação jurídica ou testes não executados.
Não executar o bloco BEGIN…COMMIT do anexo: criaria mestres paralelos e `IF NOT EXISTS` ocultaria divergências, por exemplo em `documents` e `audit_events`. Não eliminar os schemas existentes ou aplicar DROP CASCADE. Nenhuma migration nova foi preparada nesta etapa: ainda faltam contratos de compatibilidade e desenho detalhado das extensões/criações identificadas. O gate técnico de fecho da reconciliação está, por isso, **incompleto**, apesar da autorização de todas as fases.
## Reconciliação, testes e rollback a executar com as migrations
1. Guardar inventário e backup restaurável de todos os schemas; restaurar isoladamente e reconciliar contagens. O inventário acima não substitui este passo.
2. Congelar por migration os objectos/colunas/restrições novos e a decisão desta matriz que os justifica. Não reescrever migrations já aplicadas.
3. Validar FK, unicidade e duplicados lógicos com relatório de excepções; não eliminar ou fundir por semelhança de nome.
4. Verificar geometrias por validade, vazio, SRID e igualdade geométrica quando houver transformação. Manter geometrias restritas fora de respostas públicas.
5. Exercitar dependências directas/indirectas, updates, restauro lógico e duas sessões concorrentes. A auto-relação actualmente recusada não prova ausência de ciclos.
6. Exercitar autorização, datas, versões, consentimento, idempotência e contratos de URL/API. Testes devem usar dados sintéticos numa base isolada, nunca limpar a base operacional.
7. Comparar contagens/checksums antes/depois e IDs dos mestres. Aceitar somente as diferenças explicitadas no plano da migration.
8. DOWN remove apenas objectos introduzidos, com RESTRICT, recusando perda de novos dados. Consolidar dados exige mapa reversível de IDs e restauro testado; não foi realizada consolidação nesta etapa.
## Estado real da entrega
Executados: inventário real e análise dos 16 conceitos, com decisões de reaproveitamento e lacunas identificadas. Não executados: novas migrations, consolidação, testes de concorrência, reserva de instalações, motor regulatório, séries de indicadores, alterações de UI ou deploy. O pacote completo pedido ainda não está concluído. A aprovação das fases não está pendente; o trabalho restante é técnico.
+115
View File
@@ -0,0 +1,115 @@
-- Adaptado para PostgreSQL 17 por autorização do utilizador. Executar isoladamente e interromper no primeiro erro.
-- UP MIGRATION: PostgreSQL 17 + PostGIS — esquema mne
BEGIN;
DO $pg17$
BEGIN
IF current_setting('server_version_num')::integer < 170000 OR current_setting('server_version_num')::integer >= 180000 THEN
RAISE EXCEPTION 'Este script requer PostgreSQL 17.x; versão detectada: %', current_setting('server_version');
END IF;
END
$pg17$;
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA public;
CREATE SCHEMA IF NOT EXISTS mne;
SET LOCAL search_path = mne, public;
COMMENT ON SCHEMA mne IS 'Mercado de Negócios Existentes — PostgreSQL 17/PostGIS';
DO $postgis$
BEGIN
IF postgis_lib_version() IS NULL THEN
RAISE EXCEPTION 'PostGIS não está disponível';
END IF;
END
$postgis$;
CREATE TABLE data_classifications (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(30) NOT NULL UNIQUE, name varchar(100) NOT NULL, rank smallint NOT NULL UNIQUE CHECK (rank>=0), public_allowed boolean NOT NULL DEFAULT false, encryption_required boolean NOT NULL DEFAULT false, default_retention_days integer CHECK (default_retention_days>0));
CREATE TABLE business_modalities (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(50) NOT NULL UNIQUE, name_pt varchar(150) NOT NULL, name_en varchar(150), requires_equity boolean NOT NULL DEFAULT false, requires_asset boolean NOT NULL DEFAULT false, active boolean NOT NULL DEFAULT true);
CREATE TABLE workflow_states (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entity_type varchar(50) NOT NULL, code varchar(40) NOT NULL, name varchar(100) NOT NULL, sequence smallint NOT NULL, terminal boolean NOT NULL DEFAULT false, public_visible boolean NOT NULL DEFAULT false, UNIQUE(entity_type,code), UNIQUE(entity_type,sequence));
CREATE TABLE organizations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), legal_name varchar(255) NOT NULL, trade_name varchar(255), registration_number varchar(100), tax_number varchar(50), country_code char(2) NOT NULL, organization_type varchar(50) NOT NULL, verification_status varchar(30) NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, version integer NOT NULL DEFAULT 1);
CREATE UNIQUE INDEX uq_organizations_registration ON organizations(country_code,registration_number) WHERE registration_number IS NOT NULL AND deleted_at IS NULL;
CREATE TABLE parties (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_type varchar(20) NOT NULL CHECK (party_type IN ('person','organization')), organization_id uuid REFERENCES organizations(id) ON UPDATE CASCADE ON DELETE RESTRICT, user_id uuid, display_name varchar(255) NOT NULL, verification_status varchar(30) NOT NULL DEFAULT 'pending', master_external_id varchar(150) UNIQUE, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, version integer NOT NULL DEFAULT 1, CHECK ((party_type='organization' AND organization_id IS NOT NULL AND user_id IS NULL) OR (party_type='person' AND user_id IS NOT NULL AND organization_id IS NULL)));
CREATE TABLE party_roles (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_id uuid NOT NULL REFERENCES parties(id) ON UPDATE CASCADE ON DELETE RESTRICT, role_code varchar(50) NOT NULL, valid_from date NOT NULL DEFAULT CURRENT_DATE, valid_until date, status varchar(20) NOT NULL DEFAULT 'active', UNIQUE(party_id,role_code,valid_from), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE TABLE issuing_authorities (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(50) NOT NULL, official_name varchar(255) NOT NULL, jurisdiction varchar(100) NOT NULL, country_code char(2) NOT NULL, authority_type varchar(50) NOT NULL, active boolean NOT NULL DEFAULT true, UNIQUE(code,jurisdiction));
CREATE TABLE source_records (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), source_type varchar(50) NOT NULL, title varchar(255) NOT NULL, authority_id uuid REFERENCES issuing_authorities(id) ON UPDATE CASCADE ON DELETE RESTRICT, reference varchar(255) NOT NULL, source_date date, verification_url text, checksum char(64), UNIQUE(source_type,reference));
CREATE TABLE existing_businesses (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), legacy_code varchar(100), public_code varchar(40) NOT NULL UNIQUE, modality_id uuid NOT NULL REFERENCES business_modalities(id) ON UPDATE CASCADE ON DELETE RESTRICT, title_public varchar(255) NOT NULL, description_private text NOT NULL, sector_id uuid NOT NULL, asking_value numeric(19,4) CHECK(asking_value>=0), currency_code char(3), available_equity_pct numeric(5,2) CHECK(available_equity_pct BETWEEN 0 AND 100), maturity varchar(30) NOT NULL, status_id uuid NOT NULL REFERENCES workflow_states(id) ON UPDATE CASCADE ON DELETE RESTRICT, confidentiality_id uuid NOT NULL REFERENCES data_classifications(id) ON UPDATE CASCADE ON DELETE RESTRICT, valid_from date NOT NULL, valid_until date, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, version integer NOT NULL DEFAULT 1, CHECK((asking_value IS NULL AND currency_code IS NULL) OR (asking_value IS NOT NULL AND currency_code IS NOT NULL)), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE INDEX ix_businesses_filters ON existing_businesses(status_id,modality_id,sector_id,valid_until) WHERE deleted_at IS NULL;
CREATE TABLE business_owners (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON UPDATE CASCADE ON DELETE RESTRICT, owner_party_id uuid NOT NULL REFERENCES parties(id) ON UPDATE CASCADE ON DELETE RESTRICT, ownership_type varchar(40) NOT NULL, ownership_pct numeric(5,2) CHECK(ownership_pct BETWEEN 0 AND 100), declared boolean NOT NULL DEFAULT true, verified boolean NOT NULL DEFAULT false, valid_from date NOT NULL, valid_until date, UNIQUE(business_id,owner_party_id,valid_from), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE TABLE business_status_history (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON UPDATE CASCADE ON DELETE RESTRICT, from_status_id uuid REFERENCES workflow_states(id) ON UPDATE CASCADE ON DELETE RESTRICT, to_status_id uuid NOT NULL REFERENCES workflow_states(id) ON UPDATE CASCADE ON DELETE RESTRICT, reason_code varchar(60) NOT NULL, changed_by uuid NOT NULL, changed_at timestamptz NOT NULL DEFAULT now(), correlation_id uuid NOT NULL);
CREATE INDEX ix_business_status_history ON business_status_history(business_id,changed_at DESC);
CREATE TABLE publication_authorizations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON UPDATE CASCADE ON DELETE RESTRICT, requested_by uuid NOT NULL, first_approved_by uuid, second_approved_by uuid, scope jsonb NOT NULL DEFAULT '{}'::jsonb, approved_at timestamptz, expires_at timestamptz, revoked_at timestamptz, CHECK(first_approved_by IS NULL OR first_approved_by<>requested_by), CHECK(second_approved_by IS NULL OR second_approved_by<>requested_by), CHECK(first_approved_by IS NULL OR second_approved_by IS NULL OR first_approved_by<>second_approved_by));
CREATE UNIQUE INDEX uq_publication_active ON publication_authorizations(business_id) WHERE approved_at IS NOT NULL AND revoked_at IS NULL;
CREATE TABLE mandates (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), principal_party_id uuid NOT NULL REFERENCES parties(id) ON UPDATE CASCADE ON DELETE RESTRICT, representative_party_id uuid NOT NULL REFERENCES parties(id) ON UPDATE CASCADE ON DELETE RESTRICT, scope text NOT NULL, issued_at date NOT NULL, expires_at date, verification_status varchar(30) NOT NULL DEFAULT 'pending', document_id uuid, CHECK(principal_party_id<>representative_party_id), CHECK(expires_at IS NULL OR expires_at>=issued_at));
CREATE TABLE locations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), country_code char(2) NOT NULL, province_code varchar(20), district_code varchar(20), locality varchar(150), geom geometry(MultiPolygon,4326) NOT NULL, precision_level varchar(20) NOT NULL, classification_id uuid NOT NULL REFERENCES data_classifications(id) ON UPDATE CASCADE ON DELETE RESTRICT, CHECK(ST_SRID(geom)=4326 AND ST_IsValid(geom) AND NOT ST_IsEmpty(geom)));
CREATE INDEX ix_locations_geom ON locations USING GIST(geom);
CREATE TABLE business_locations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON UPDATE CASCADE ON DELETE CASCADE, location_id uuid NOT NULL REFERENCES locations(id) ON UPDATE CASCADE ON DELETE RESTRICT, location_role varchar(30) NOT NULL, is_public boolean NOT NULL DEFAULT false, valid_from date NOT NULL, valid_until date, UNIQUE(business_id,location_id,location_role,valid_from), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE TABLE business_ecosystem_links (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON UPDATE CASCADE ON DELETE CASCADE, ecosystem_type varchar(30) NOT NULL, external_id varchar(150) NOT NULL, relation_type varchar(40) NOT NULL, source_system varchar(80) NOT NULL, verified_at timestamptz, UNIQUE(business_id,ecosystem_type,external_id));
CREATE TABLE external_identifiers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entity_type varchar(50) NOT NULL, entity_id uuid NOT NULL, system_code varchar(80) NOT NULL, external_id varchar(180) NOT NULL, valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz, UNIQUE(system_code,external_id), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE INDEX ix_external_identifiers_entity ON external_identifiers(entity_type,entity_id);
-- P1 — documentos, activos, direitos e capital
CREATE TABLE documents (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), storage_key varchar(500) NOT NULL, document_type varchar(60) NOT NULL, classification_id uuid NOT NULL REFERENCES data_classifications(id) ON DELETE RESTRICT, checksum char(64) NOT NULL, mime_type varchar(120) NOT NULL, size_bytes bigint NOT NULL CHECK(size_bytes>0), malware_scan_status varchar(30) NOT NULL DEFAULT 'pending', retention_policy_id uuid, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX ix_documents_checksum ON documents(checksum);
ALTER TABLE mandates ADD CONSTRAINT fk_mandates_document FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE RESTRICT;
CREATE TABLE document_versions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT, version_no integer NOT NULL CHECK(version_no>0), storage_key varchar(500) NOT NULL, checksum char(64) NOT NULL, size_bytes bigint NOT NULL CHECK(size_bytes>0), created_by uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(document_id,version_no));
CREATE TABLE business_documents (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE CASCADE, document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT, document_role varchar(50) NOT NULL, required boolean NOT NULL DEFAULT false, publishable boolean NOT NULL DEFAULT false, valid_from date NOT NULL, valid_until date, UNIQUE(business_id,document_id,document_role), CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE TABLE document_access_policies (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT, principal_type varchar(30) NOT NULL, principal_id uuid, permission varchar(20) NOT NULL CHECK(permission IN ('view','download','manage')), valid_from timestamptz NOT NULL, valid_until timestamptz, granted_by uuid NOT NULL, CHECK(valid_until IS NULL OR valid_until>=valid_from));
CREATE TABLE assets (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, asset_type varchar(40) NOT NULL, public_name varchar(255) NOT NULL, private_description text, declared_owner_party_id uuid REFERENCES parties(id) ON DELETE RESTRICT, verification_status varchar(30) NOT NULL DEFAULT 'pending', estimated_value numeric(19,4) CHECK(estimated_value>=0), currency_code char(3), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz, version integer NOT NULL DEFAULT 1, CHECK((estimated_value IS NULL AND currency_code IS NULL) OR (estimated_value IS NOT NULL AND currency_code IS NOT NULL)));
CREATE INDEX ix_assets_business ON assets(business_id,asset_type) WHERE deleted_at IS NULL;
CREATE TABLE real_estates (asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE, property_type varchar(50) NOT NULL, area_value numeric(18,4) NOT NULL CHECK(area_value>0), area_unit varchar(20) NOT NULL, occupancy_status varchar(30) NOT NULL, use_status varchar(30) NOT NULL, conservation_status varchar(30) NOT NULL, geometry geometry(MultiPolygon,4326) NOT NULL, geometry_precision varchar(20) NOT NULL, CHECK(ST_SRID(geometry)=4326 AND ST_IsValid(geometry) AND NOT ST_IsEmpty(geometry)));
CREATE INDEX ix_real_estates_geometry ON real_estates USING GIST(geometry);
CREATE TABLE real_estate_units (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), real_estate_asset_id uuid NOT NULL REFERENCES real_estates(asset_id) ON DELETE CASCADE, unit_code varchar(60) NOT NULL, unit_type varchar(40) NOT NULL, floor_label varchar(30), gross_area numeric(18,4) CHECK(gross_area>0), area_unit varchar(20), occupancy_status varchar(30) NOT NULL, UNIQUE(real_estate_asset_id,unit_code));
CREATE TABLE improvements (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), real_estate_asset_id uuid NOT NULL REFERENCES real_estates(asset_id) ON DELETE RESTRICT, improvement_type varchar(50) NOT NULL, description text NOT NULL, completion_date date, declared_value numeric(19,4) CHECK(declared_value>=0), currency_code char(3), ownership_status varchar(30) NOT NULL, CHECK((declared_value IS NULL AND currency_code IS NULL) OR (declared_value IS NOT NULL AND currency_code IS NOT NULL)));
CREATE TABLE agricultural_investments (asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE, activity_type varchar(60) NOT NULL, crop_or_livestock varchar(120), cultivated_area numeric(18,4) CHECK(cultivated_area>0), area_unit varchar(20), irrigation boolean NOT NULL DEFAULT false, operational_status varchar(30) NOT NULL, seasonality jsonb);
CREATE TABLE land_rights (asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE RESTRICT, right_type varchar(50) NOT NULL, document_reference varchar(150) NOT NULL, issuing_authority_id uuid NOT NULL REFERENCES issuing_authorities(id) ON DELETE RESTRICT, authorized_purpose text NOT NULL, issued_at date NOT NULL, expires_at date, transfer_status varchar(30) NOT NULL, verification_status varchar(30) NOT NULL DEFAULT 'pending', UNIQUE(document_reference,issuing_authority_id), CHECK(expires_at IS NULL OR expires_at>=issued_at));
CREATE TABLE licences (asset_id uuid PRIMARY KEY REFERENCES assets(id) ON DELETE RESTRICT, licence_type varchar(60) NOT NULL, licence_number_masked varchar(100) NOT NULL, licence_number_hash varchar(128) NOT NULL UNIQUE, issuing_authority_id uuid NOT NULL REFERENCES issuing_authorities(id) ON DELETE RESTRICT, purpose text NOT NULL, issued_at date NOT NULL, expires_at date, renewal_status varchar(30) NOT NULL, transfer_status varchar(30) NOT NULL, verification_status varchar(30) NOT NULL DEFAULT 'pending', CHECK(expires_at IS NULL OR expires_at>=issued_at));
CREATE TABLE ownership_records (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, ownership_type varchar(40) NOT NULL, share_pct numeric(5,2) CHECK(share_pct BETWEEN 0 AND 100), declared_at date NOT NULL, verified_at timestamptz, source_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT);
CREATE INDEX ix_ownership_asset ON ownership_records(asset_id,declared_at DESC);
CREATE TABLE encumbrances (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, encumbrance_type varchar(50) NOT NULL, holder_party_id uuid REFERENCES parties(id) ON DELETE RESTRICT, amount numeric(19,4) CHECK(amount>=0), currency_code char(3), start_date date, end_date date, status varchar(30) NOT NULL, source_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT, CHECK((amount IS NULL AND currency_code IS NULL) OR (amount IS NOT NULL AND currency_code IS NOT NULL)), CHECK(end_date IS NULL OR start_date IS NULL OR end_date>=start_date));
CREATE TABLE restrictions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, restriction_type varchar(50) NOT NULL, legal_basis text, description text NOT NULL, starts_at date, ends_at date, status varchar(30) NOT NULL, source_id uuid REFERENCES source_records(id) ON DELETE RESTRICT, CHECK(ends_at IS NULL OR starts_at IS NULL OR ends_at>=starts_at));
CREATE TABLE transfer_conditions (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT, condition_type varchar(50) NOT NULL, description text NOT NULL, required_authority_id uuid REFERENCES issuing_authorities(id) ON DELETE RESTRICT, approval_status varchar(30) NOT NULL, fulfilled_at timestamptz, CHECK(approval_status<>'fulfilled' OR fulfilled_at IS NOT NULL));
CREATE TABLE capital_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, request_type varchar(40) NOT NULL, amount_min numeric(19,4) CHECK(amount_min>=0), amount_max numeric(19,4) CHECK(amount_max>=0), currency_code char(3) NOT NULL, use_of_funds text NOT NULL, target_date date, status varchar(30) NOT NULL, CHECK(amount_min IS NULL OR amount_max IS NULL OR amount_min<=amount_max));
CREATE TABLE equity_offers (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, equity_pct numeric(5,2) NOT NULL CHECK(equity_pct>0 AND equity_pct<=100), share_class varchar(40), indicative_value numeric(19,4) CHECK(indicative_value>=0), currency_code char(3), valid_until date, status varchar(30) NOT NULL, CHECK((indicative_value IS NULL AND currency_code IS NULL) OR (indicative_value IS NOT NULL AND currency_code IS NOT NULL)));
CREATE TABLE valuations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT, valuation_type varchar(40) NOT NULL, amount numeric(19,4) NOT NULL CHECK(amount>=0), currency_code char(3) NOT NULL, valuation_date date NOT NULL, valuer_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, methodology varchar(100) NOT NULL, source_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT, status varchar(30) NOT NULL);
CREATE TABLE business_values (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, value_type varchar(30) NOT NULL, amount numeric(19,4) NOT NULL CHECK(amount>=0), currency_code char(3) NOT NULL, effective_at timestamptz NOT NULL, source_type varchar(40) NOT NULL, source_id uuid, confirmed boolean NOT NULL DEFAULT false);
CREATE INDEX ix_business_values_latest ON business_values(business_id,value_type,effective_at DESC);
-- P2 — verificação, interesse e diligência
CREATE TABLE verification_records (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_type varchar(50) NOT NULL, subject_id uuid NOT NULL, verification_type varchar(50) NOT NULL, status varchar(30) NOT NULL, source_id uuid NOT NULL REFERENCES source_records(id) ON DELETE RESTRICT, verified_by uuid NOT NULL, verified_at timestamptz NOT NULL, expires_at timestamptz, notes_classification_id uuid NOT NULL REFERENCES data_classifications(id) ON DELETE RESTRICT);
CREATE INDEX ix_verification_subject ON verification_records(subject_type,subject_id);
CREATE TABLE audit_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_id uuid, action varchar(100) NOT NULL, subject_type varchar(50) NOT NULL, subject_id uuid NOT NULL, occurred_at timestamptz NOT NULL DEFAULT now(), correlation_id uuid NOT NULL, ip_hash varchar(128), metadata_redacted jsonb NOT NULL DEFAULT '{}'::jsonb);
CREATE INDEX ix_audit_subject ON audit_events(subject_type,subject_id,occurred_at DESC);
CREATE TABLE investor_profiles (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_id uuid NOT NULL UNIQUE REFERENCES parties(id) ON DELETE RESTRICT, investor_type varchar(40) NOT NULL, ticket_min numeric(19,4), ticket_max numeric(19,4), currency_code char(3), sectors jsonb NOT NULL DEFAULT '[]'::jsonb, geographies jsonb NOT NULL DEFAULT '[]'::jsonb, horizon_months integer CHECK(horizon_months>0), qualification_status varchar(30) NOT NULL, CHECK(ticket_min IS NULL OR ticket_min>=0), CHECK(ticket_max IS NULL OR ticket_max>=0), CHECK((ticket_min IS NULL AND ticket_max IS NULL) OR currency_code IS NOT NULL), CHECK(ticket_min IS NULL OR ticket_max IS NULL OR ticket_min<=ticket_max));
CREATE TABLE consent_records (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, purpose_code varchar(60) NOT NULL, legal_basis varchar(60) NOT NULL, granted boolean NOT NULL, granted_at timestamptz NOT NULL, expires_at timestamptz, withdrawn_at timestamptz, evidence_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT);
CREATE UNIQUE INDEX uq_consent_active ON consent_records(party_id,purpose_code) WHERE granted=true AND withdrawn_at IS NULL;
CREATE TABLE interests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, investor_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, modality_id uuid NOT NULL REFERENCES business_modalities(id) ON DELETE RESTRICT, source varchar(50) NOT NULL, qualification_status varchar(30) NOT NULL, crm_external_id varchar(150), consent_id uuid NOT NULL REFERENCES consent_records(id) ON DELETE RESTRICT, dedupe_period date NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(investor_party_id,business_id,modality_id,dedupe_period));
CREATE TABLE match_results (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), interest_id uuid NOT NULL REFERENCES interests(id) ON DELETE RESTRICT, business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, score numeric(6,3) NOT NULL CHECK(score BETWEEN 0 AND 100), reasons jsonb NOT NULL, model_version varchar(80) NOT NULL, reviewed_by uuid, reviewed_at timestamptz, decision varchar(30) NOT NULL);
CREATE TABLE crm_links (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), local_entity_type varchar(50) NOT NULL, local_entity_id uuid NOT NULL, crm_system varchar(50) NOT NULL, crm_entity_type varchar(50) NOT NULL, crm_external_id varchar(180) NOT NULL, sync_status varchar(30) NOT NULL, last_synced_at timestamptz, UNIQUE(crm_system,crm_entity_type,crm_external_id));
CREATE TABLE notifications (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), recipient_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, channel varchar(20) NOT NULL, template_code varchar(80) NOT NULL, payload_redacted jsonb NOT NULL, status varchar(30) NOT NULL, scheduled_at timestamptz NOT NULL, sent_at timestamptz, failure_code varchar(80));
CREATE TABLE access_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, requester_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, request_type varchar(40) NOT NULL, purpose text NOT NULL, status varchar(30) NOT NULL, reviewed_by uuid, reviewed_at timestamptz, expires_at timestamptz);
CREATE UNIQUE INDEX uq_access_request_open ON access_requests(business_id,requester_party_id,request_type) WHERE status IN ('submitted','under_review','approved');
CREATE TABLE ndas (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), access_request_id uuid NOT NULL UNIQUE REFERENCES access_requests(id) ON DELETE RESTRICT, template_version varchar(40) NOT NULL, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, requester_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, signed_at timestamptz, expires_at timestamptz, document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT, status varchar(30) NOT NULL, CHECK(status<>'active' OR signed_at IS NOT NULL));
CREATE TABLE data_rooms (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, name varchar(150) NOT NULL, classification_id uuid NOT NULL REFERENCES data_classifications(id) ON DELETE RESTRICT, status varchar(30) NOT NULL, opened_at timestamptz, closed_at timestamptz, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT);
CREATE TABLE data_room_members (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), data_room_id uuid NOT NULL REFERENCES data_rooms(id) ON DELETE CASCADE, party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, role_code varchar(40) NOT NULL, can_view boolean NOT NULL DEFAULT true, can_download boolean NOT NULL DEFAULT false, valid_from timestamptz NOT NULL, valid_until timestamptz, revoked_at timestamptz, UNIQUE(data_room_id,party_id,valid_from));
CREATE TABLE due_diligence_checklists (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, checklist_type varchar(40) NOT NULL, template_version varchar(30) NOT NULL, status varchar(30) NOT NULL, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, started_at timestamptz NOT NULL, completed_at timestamptz, UNIQUE(business_id,checklist_type,template_version));
CREATE TABLE due_diligence_items (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), checklist_id uuid NOT NULL REFERENCES due_diligence_checklists(id) ON DELETE CASCADE, code varchar(60) NOT NULL, title varchar(255) NOT NULL, requirement text NOT NULL, status varchar(30) NOT NULL, responsible_party_id uuid REFERENCES parties(id) ON DELETE RESTRICT, due_at timestamptz, evidence_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT, UNIQUE(checklist_id,code));
CREATE TABLE information_requests (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, checklist_item_id uuid REFERENCES due_diligence_items(id) ON DELETE RESTRICT, requested_by uuid NOT NULL, assigned_to uuid NOT NULL, question text NOT NULL, response text, status varchar(30) NOT NULL, due_at timestamptz, responded_at timestamptz, CHECK(status<>'responded' OR (response IS NOT NULL AND responded_at IS NOT NULL)));
-- P3–P4 — negociação, monetização e operação
CREATE TABLE negotiations (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, interest_id uuid NOT NULL REFERENCES interests(id) ON DELETE RESTRICT, stage varchar(40) NOT NULL, owner_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, opened_at timestamptz NOT NULL DEFAULT now(), closed_at timestamptz, outcome varchar(40));
CREATE UNIQUE INDEX uq_negotiation_open ON negotiations(business_id,interest_id) WHERE closed_at IS NULL;
CREATE TABLE proposals (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), negotiation_id uuid NOT NULL REFERENCES negotiations(id) ON DELETE RESTRICT, proposal_no integer NOT NULL CHECK(proposal_no>0), proposal_type varchar(40) NOT NULL, amount numeric(19,4), currency_code char(3), equity_pct numeric(5,2) CHECK(equity_pct BETWEEN 0 AND 100), terms jsonb NOT NULL, binding boolean NOT NULL DEFAULT false, submitted_at timestamptz NOT NULL, expires_at timestamptz, status varchar(30) NOT NULL, UNIQUE(negotiation_id,proposal_no), CHECK((amount IS NULL)=(currency_code IS NULL)), CHECK(equity_pct IS NULL OR equity_pct>0));
CREATE TABLE negotiation_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), negotiation_id uuid NOT NULL REFERENCES negotiations(id) ON DELETE RESTRICT, event_type varchar(50) NOT NULL, occurred_at timestamptz NOT NULL DEFAULT now(), actor_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, summary text NOT NULL, payload_redacted jsonb NOT NULL DEFAULT '{}'::jsonb, correlation_id uuid NOT NULL);
CREATE INDEX ix_negotiation_events ON negotiation_events(negotiation_id,occurred_at);
CREATE TABLE conditions_precedent (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), negotiation_id uuid NOT NULL REFERENCES negotiations(id) ON DELETE RESTRICT, code varchar(60) NOT NULL, description text NOT NULL, responsible_party_id uuid REFERENCES parties(id) ON DELETE RESTRICT, due_at date, status varchar(30) NOT NULL, satisfied_at timestamptz, evidence_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT, UNIQUE(negotiation_id,code));
CREATE TABLE advisors (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), negotiation_id uuid NOT NULL REFERENCES negotiations(id) ON DELETE RESTRICT, party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, advisor_role varchar(50) NOT NULL, appointed_at date NOT NULL, ended_at date, conflict_status varchar(30) NOT NULL, UNIQUE(negotiation_id,party_id,advisor_role,appointed_at));
CREATE TABLE transaction_closures (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), negotiation_id uuid NOT NULL UNIQUE REFERENCES negotiations(id) ON DELETE RESTRICT, outcome varchar(40) NOT NULL, confirmed_value numeric(19,4), currency_code char(3), closed_at timestamptz NOT NULL, approved_by uuid NOT NULL, closure_document_id uuid REFERENCES documents(id) ON DELETE RESTRICT, CHECK((confirmed_value IS NULL)=(currency_code IS NULL)), CHECK(confirmed_value IS NULL OR confirmed_value>=0));
CREATE TABLE commercial_contracts (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE RESTRICT, client_party_id uuid NOT NULL REFERENCES parties(id) ON DELETE RESTRICT, contract_number varchar(80) NOT NULL, version_no integer NOT NULL CHECK(version_no>0), starts_at date NOT NULL, ends_at date, status varchar(30) NOT NULL, document_id uuid NOT NULL REFERENCES documents(id) ON DELETE RESTRICT, UNIQUE(contract_number,version_no));
CREATE TABLE commission_rules (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contract_id uuid NOT NULL REFERENCES commercial_contracts(id) ON DELETE RESTRICT, rule_version integer NOT NULL, basis_type varchar(40) NOT NULL, rate_pct numeric(7,4), fixed_amount numeric(19,4), currency_code char(3), min_amount numeric(19,4), max_amount numeric(19,4), effective_from date NOT NULL, effective_until date, UNIQUE(contract_id,rule_version), CHECK(fixed_amount IS NULL OR fixed_amount>=0), CHECK(min_amount IS NULL OR min_amount>=0), CHECK(max_amount IS NULL OR max_amount>=0), CHECK(rate_pct IS NULL OR rate_pct BETWEEN 0 AND 100), CHECK(effective_until IS NULL OR effective_until>=effective_from), CHECK((fixed_amount IS NULL AND min_amount IS NULL AND max_amount IS NULL) OR currency_code IS NOT NULL));
CREATE TABLE billable_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), contract_id uuid NOT NULL REFERENCES commercial_contracts(id) ON DELETE RESTRICT, negotiation_id uuid REFERENCES negotiations(id) ON DELETE RESTRICT, event_type varchar(50) NOT NULL, occurred_at timestamptz NOT NULL, base_amount numeric(19,4) NOT NULL CHECK(base_amount>=0), currency_code char(3) NOT NULL, verified_by uuid NOT NULL, verified_at timestamptz NOT NULL, status varchar(30) NOT NULL);
CREATE TABLE invoices (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), billable_event_id uuid NOT NULL REFERENCES billable_events(id) ON DELETE RESTRICT, invoice_number varchar(80) NOT NULL UNIQUE, issued_at date NOT NULL, due_at date NOT NULL, subtotal numeric(19,4) NOT NULL, tax_amount numeric(19,4) NOT NULL, total_amount numeric(19,4) NOT NULL, currency_code char(3) NOT NULL, status varchar(30) NOT NULL, external_id varchar(150), CHECK(subtotal>=0), CHECK(tax_amount>=0), CHECK(total_amount>=0), CHECK(due_at>=issued_at), CHECK(total_amount=subtotal+tax_amount));
CREATE TABLE payments (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id uuid NOT NULL REFERENCES invoices(id) ON DELETE RESTRICT, payment_reference varchar(120) NOT NULL UNIQUE, amount numeric(19,4) NOT NULL CHECK(amount>0), currency_code char(3) NOT NULL, paid_at timestamptz NOT NULL, status varchar(30) NOT NULL, provider_external_id varchar(150));
CREATE TABLE reconciliation_records (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), entity_type varchar(40) NOT NULL, entity_id uuid NOT NULL, expected_amount numeric(19,4) NOT NULL, actual_amount numeric(19,4) NOT NULL, currency_code char(3) NOT NULL, reconciled_at timestamptz NOT NULL, reconciled_by uuid NOT NULL, status varchar(30) NOT NULL, difference_reason text);
CREATE TABLE favorites (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_id uuid NOT NULL REFERENCES parties(id) ON DELETE CASCADE, business_id uuid NOT NULL REFERENCES existing_businesses(id) ON DELETE CASCADE, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(party_id,business_id));
CREATE TABLE saved_searches (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), party_id uuid NOT NULL REFERENCES parties(id) ON DELETE CASCADE, name varchar(120) NOT NULL, filters jsonb NOT NULL, query_version integer NOT NULL DEFAULT 1, active boolean NOT NULL DEFAULT true, UNIQUE(party_id,name));
CREATE TABLE alerts (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), saved_search_id uuid NOT NULL REFERENCES saved_searches(id) ON DELETE CASCADE, frequency varchar(20) NOT NULL, channel varchar(20) NOT NULL, last_run_at timestamptz, next_run_at timestamptz, active boolean NOT NULL DEFAULT true);
CREATE TABLE migration_batches (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), source_system varchar(80) NOT NULL, batch_reference varchar(120) NOT NULL, started_at timestamptz NOT NULL, completed_at timestamptz, status varchar(30) NOT NULL, total_rows integer NOT NULL CHECK(total_rows>=0), accepted_rows integer NOT NULL CHECK(accepted_rows>=0), rejected_rows integer NOT NULL CHECK(rejected_rows>=0), checksum char(64) NOT NULL, UNIQUE(source_system,batch_reference), CHECK(total_rows=accepted_rows+rejected_rows));
CREATE TABLE migration_rejections (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), batch_id uuid NOT NULL REFERENCES migration_batches(id) ON DELETE CASCADE, source_row_ref varchar(150) NOT NULL, error_code varchar(80) NOT NULL, error_details jsonb NOT NULL, payload_redacted jsonb NOT NULL, resolved_at timestamptz, resolved_by uuid, UNIQUE(batch_id,source_row_ref,error_code));
CREATE TABLE feature_flags (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), "key" varchar(100) NOT NULL, description text NOT NULL, enabled boolean NOT NULL DEFAULT false, rollout_pct numeric(5,2) NOT NULL DEFAULT 0 CHECK(rollout_pct BETWEEN 0 AND 100), conditions jsonb NOT NULL DEFAULT '{}'::jsonb, environment varchar(30) NOT NULL, changed_by uuid NOT NULL, changed_at timestamptz NOT NULL DEFAULT now(), UNIQUE("key",environment));
CREATE TABLE integration_outbox (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), aggregate_type varchar(50) NOT NULL, aggregate_id uuid NOT NULL, event_type varchar(80) NOT NULL, payload jsonb NOT NULL, occurred_at timestamptz NOT NULL DEFAULT now(), available_at timestamptz NOT NULL DEFAULT now(), attempts integer NOT NULL DEFAULT 0 CHECK(attempts>=0), processed_at timestamptz, last_error_redacted text);
CREATE INDEX ix_outbox_pending ON integration_outbox(available_at) WHERE processed_at IS NULL;
COMMIT;
+7
View File
@@ -0,0 +1,7 @@
-- CODEX: guardar este segundo bloco como migrations/001_mne_schema_down.sql; não o concatenar nem executar juntamente com a migração UP.
-- DOWN MIGRATION: rollback integral do esquema mne
BEGIN;
SET LOCAL search_path = mne, public;
DROP TABLE IF EXISTS integration_outbox, feature_flags, migration_rejections, migration_batches, alerts, saved_searches, favorites, reconciliation_records, payments, invoices, billable_events, commission_rules, commercial_contracts, transaction_closures, advisors, conditions_precedent, negotiation_events, proposals, negotiations, information_requests, due_diligence_items, due_diligence_checklists, data_room_members, data_rooms, ndas, access_requests, notifications, crm_links, match_results, interests, consent_records, investor_profiles, audit_events, verification_records, business_values, valuations, equity_offers, capital_requests, transfer_conditions, restrictions, encumbrances, ownership_records, licences, land_rights, agricultural_investments, improvements, real_estate_units, real_estates, assets, document_access_policies, business_documents, document_versions, documents, external_identifiers, business_ecosystem_links, business_locations, locations, mandates, publication_authorizations, business_status_history, business_owners, existing_businesses, source_records, issuing_authorities, party_roles, parties, organizations, workflow_states, business_modalities, data_classifications CASCADE;
DROP SCHEMA IF EXISTS mne;
COMMIT;
@@ -0,0 +1,39 @@
-- PostgreSQL 17: reuse investmoz.provincia/distrito; do not create duplicate masters.
-- Run after 001_mne_schema.sql and the investmoz territorial DDL/import.
BEGIN;
SELECT pg_advisory_xact_lock(290926, 170001);
ALTER TABLE mne.locations ADD COLUMN IF NOT EXISTS province_id integer;
ALTER TABLE mne.locations ADD COLUMN IF NOT EXISTS district_id integer;
-- A composite key prevents selecting a district belonging to another province.
CREATE UNIQUE INDEX IF NOT EXISTS uq_distrito_id_provincia
ON investmoz.distrito(id, provincia_id);
DO $links$
BEGIN
IF EXISTS (SELECT 1 FROM mne.locations WHERE province_id IS NULL) THEN
RAISE EXCEPTION 'Existing locations require an explicit province mapping before this migration';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='mne.locations'::regclass AND conname='fk_locations_province') THEN
ALTER TABLE mne.locations ADD CONSTRAINT fk_locations_province
FOREIGN KEY (province_id) REFERENCES investmoz.provincia(id)
ON UPDATE CASCADE ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='mne.locations'::regclass AND conname='fk_locations_district') THEN
ALTER TABLE mne.locations ADD CONSTRAINT fk_locations_district
FOREIGN KEY (district_id, province_id) REFERENCES investmoz.distrito(id, provincia_id)
ON UPDATE CASCADE ON DELETE RESTRICT;
END IF;
-- These existing administrative tables contain Mozambique only.
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='mne.locations'::regclass AND conname='ck_locations_territory_country') THEN
ALTER TABLE mne.locations ADD CONSTRAINT ck_locations_territory_country CHECK(country_code='MZ');
END IF;
END
$links$;
ALTER TABLE mne.locations ALTER COLUMN province_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS ix_locations_territory ON mne.locations(country_code, province_id, district_id);
COMMENT ON COLUMN mne.locations.province_id IS 'Existing master: investmoz.provincia.id (integer).';
COMMENT ON COLUMN mne.locations.district_id IS 'Existing master: investmoz.distrito.id; must belong to province_id.';
COMMENT ON COLUMN mne.locations.province_code IS 'Legacy optional field retained for compatibility; province_id is the authoritative relation.';
COMMENT ON COLUMN mne.locations.district_code IS 'Legacy optional field retained for compatibility; district_id is the authoritative relation.';
COMMIT;
+48
View File
@@ -0,0 +1,48 @@
-- Incremental hardening; no records or application tables are copied.
BEGIN;
SET LOCAL lock_timeout = '10s';
SELECT pg_advisory_xact_lock(290926, 170003);
DO $indexes$
DECLARE fk record; index_name text;
BEGIN
FOR fk IN
SELECT c.conrelid, c.conkey, c.conname, t.relname,
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.position)
FROM unnest(c.conkey) WITH ORDINALITY k(attnum, position)
JOIN pg_attribute a ON a.attrelid=c.conrelid AND a.attnum=k.attnum) AS columns
FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid
JOIN pg_namespace n ON n.oid=t.relnamespace
WHERE n.nspname='mne' AND c.contype='f'
ORDER BY t.relname,c.conname
LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_index i JOIN pg_class ix ON ix.oid=i.indexrelid
JOIN pg_am am ON am.oid=ix.relam
WHERE i.indrelid=fk.conrelid AND i.indisvalid AND i.indisready
AND i.indpred IS NULL AND am.amname='btree'
AND i.indnkeyatts >= cardinality(fk.conkey)
AND ARRAY(SELECT x FROM unnest(i.indkey::smallint[]) WITH ORDINALITY u(x,p)
WHERE p <= cardinality(fk.conkey) ORDER BY p)=fk.conkey
) THEN
index_name := 'ix_mne_003_' || md5(fk.relname || ':' || fk.conname);
EXECUTE format('CREATE INDEX %I ON mne.%I (%s)', index_name, fk.relname, fk.columns);
EXECUTE format('COMMENT ON INDEX mne.%I IS %L', index_name, 'Owned by 003_mne_integrity');
END IF;
END LOOP;
END $indexes$;
CREATE OR REPLACE FUNCTION mne.reject_evidence_mutation() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog AS $guard$
BEGIN
RAISE EXCEPTION 'MNE evidence is append-only: %.%', TG_TABLE_SCHEMA, TG_TABLE_NAME
USING ERRCODE = '23514';
END $guard$;
DO $triggers$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY['audit_events','business_status_history','document_versions','negotiation_events'] LOOP
EXECUTE format('CREATE OR REPLACE TRIGGER mne_evidence_immutable BEFORE UPDATE OR DELETE OR TRUNCATE ON mne.%I FOR EACH STATEMENT EXECUTE FUNCTION mne.reject_evidence_mutation()', table_name);
END LOOP;
END $triggers$;
COMMIT;
+18
View File
@@ -0,0 +1,18 @@
-- Reverses only 003; never execute together with UP.
BEGIN;
SET LOCAL lock_timeout = '10s';
SELECT pg_advisory_xact_lock(290926, 170003);
DO $reverse$
DECLARE table_name text; owned_index record;
BEGIN
FOREACH table_name IN ARRAY ARRAY['audit_events','business_status_history','document_versions','negotiation_events'] LOOP
EXECUTE format('DROP TRIGGER IF EXISTS mne_evidence_immutable ON mne.%I', table_name);
END LOOP;
FOR owned_index IN SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
WHERE n.nspname='mne' AND c.relkind='i' AND obj_description(c.oid,'pg_class')='Owned by 003_mne_integrity'
LOOP
EXECUTE format('DROP INDEX mne.%I', owned_index.relname);
END LOOP;
END $reverse$;
DROP FUNCTION IF EXISTS mne.reject_evidence_mutation();
COMMIT;
+118
View File
@@ -0,0 +1,118 @@
BEGIN;
SET LOCAL lock_timeout = '10s';
SET LOCAL search_path = pg_catalog, public;
SELECT pg_advisory_xact_lock(290926,170004);
-- A pre-existing bridge must match the schema fingerprint recorded on its first application.
DO $existing$
DECLARE name text; relation regclass; signature text;
BEGIN
FOREACH name IN ARRAY ARRAY['identity_links','sector_links','business_links','compatibility_checkpoints','compatibility_rejections','compatibility_controls'] LOOP
relation := to_regclass('mne.'||name);
IF relation IS NOT NULL THEN
SELECT md5(COALESCE((SELECT string_agg(a.attname||':'||format_type(a.atttypid,a.atttypmod)||':'||a.attnotnull::text||':'||COALESCE(pg_get_expr(d.adbin,d.adrelid),''),'|' ORDER BY a.attnum)
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum WHERE a.attrelid=relation AND a.attnum>0 AND NOT a.attisdropped),'')||COALESCE((SELECT string_agg(conname||':'||pg_get_constraintdef(oid),'|' ORDER BY conname) FROM pg_constraint WHERE conrelid=relation),'')) INTO signature;
IF obj_description(relation,'pg_class') IS DISTINCT FROM '004_mne_compatibility:'||signature THEN
RAISE EXCEPTION 'Unexpected compatibility table definition: %',name;
END IF;
END IF;
END LOOP;
END $existing$;
DO $preflight$
DECLARE item record;
BEGIN
FOR item IN SELECT * FROM (VALUES
('public.users','id','bigint'),('public.sectors','id','bigint'),
('public.existing_businesses','id','uuid'),('mne.parties','id','uuid'),
('mne.parties','user_id','uuid'),('mne.existing_businesses','id','uuid')
) AS expected(table_name,column_name,type_name) LOOP
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid=to_regclass(item.table_name)
AND attname=item.column_name AND NOT attisdropped AND format_type(atttypid,atttypmod)=item.type_name) THEN
RAISE EXCEPTION 'Unexpected compatibility prerequisite: %.%',item.table_name,item.column_name;
END IF;
END LOOP;
IF EXISTS(SELECT 1 FROM mne.parties WHERE user_id IS NOT NULL) THEN
RAISE EXCEPTION 'Legacy UUID party identities require explicit reconciliation first';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid='mne.audit_events'::regclass AND tgname='mne_evidence_immutable') THEN
RAISE EXCEPTION '003 integrity prerequisite absent';
END IF;
END $preflight$;
-- Retain the unused UUID column for reversibility, but prohibit account bindings through it.
DO $party$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='mne.parties'::regclass AND conname='ck_parties_identity_links') THEN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid='mne.parties'::regclass
AND conname='parties_check' AND contype='c') THEN
RAISE EXCEPTION 'Unexpected parties shape constraint';
END IF;
ALTER TABLE mne.parties DROP CONSTRAINT parties_check;
ALTER TABLE mne.parties ADD CONSTRAINT ck_parties_identity_links CHECK
(user_id IS NULL AND ((party_type='person' AND organization_id IS NULL)
OR (party_type='organization' AND organization_id IS NOT NULL)));
END IF;
END $party$;
CREATE TABLE IF NOT EXISTS mne.identity_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id bigint NOT NULL UNIQUE REFERENCES public.users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
party_id uuid UNIQUE REFERENCES mne.parties(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
source_system text NOT NULL CHECK(source_system='public.users'),
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','active','revoked')),
valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz,
source_checksum char(64) NOT NULL,
CHECK(valid_until IS NULL OR valid_until>valid_from), CHECK(status<>'active' OR party_id IS NOT NULL)
);
-- This is an identifier mapping, not a second sector taxonomy.
CREATE TABLE IF NOT EXISTS mne.sector_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sector_id bigint NOT NULL UNIQUE REFERENCES public.sectors(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
canonical_sector_id uuid NOT NULL UNIQUE,
source_system text NOT NULL CHECK(source_system='public.sectors'), source_code text NOT NULL UNIQUE,
resolver_version integer NOT NULL DEFAULT 1 CHECK(resolver_version=1),
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','active','revoked')),
valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz, source_checksum char(64) NOT NULL,
CHECK(valid_until IS NULL OR valid_until>valid_from)
);
CREATE TABLE IF NOT EXISTS mne.business_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
legacy_business_id uuid NOT NULL UNIQUE REFERENCES public.existing_businesses(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
canonical_business_id uuid UNIQUE REFERENCES mne.existing_businesses(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
source_system text NOT NULL CHECK(source_system='public.existing_businesses'),
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','active','revoked')),
write_authority text NOT NULL DEFAULT 'legacy' CHECK(write_authority='legacy'),
valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz,
source_checksum char(64) NOT NULL, reconciled_version integer,
CHECK(valid_until IS NULL OR valid_until>valid_from),
CHECK(status<>'active' OR (canonical_business_id IS NOT NULL AND reconciled_version IS NOT NULL))
);
CREATE TABLE IF NOT EXISTS mne.compatibility_checkpoints (
domain text PRIMARY KEY CHECK(domain IN ('identity','sector','business')),
last_key text, processed bigint NOT NULL DEFAULT 0, checksum char(64), updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS mne.compatibility_rejections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), domain text NOT NULL, source_key text NOT NULL,
code text NOT NULL, cause text NOT NULL, responsible text NOT NULL DEFAULT 'data-steward',
resolution text, resolved_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(domain,source_key,code), CHECK((resolved_at IS NULL)=(resolution IS NULL))
);
CREATE TABLE IF NOT EXISTS mne.compatibility_controls (
capability text PRIMARY KEY CHECK(capability IN ('shadow','canonical_read')),
enabled boolean NOT NULL DEFAULT false,
percentage integer NOT NULL DEFAULT 0 CHECK(percentage BETWEEN 0 AND 100),
-- Activation also requires a compatible adapter and per-record reconciliation.
evidence_reference text, updated_at timestamptz NOT NULL DEFAULT now(),
CHECK(NOT enabled OR length(btrim(evidence_reference))>=10)
);
INSERT INTO mne.compatibility_controls(capability) VALUES ('shadow'),('canonical_read') ON CONFLICT DO NOTHING;
DO $record$
DECLARE name text; relation regclass; signature text;
BEGIN
FOREACH name IN ARRAY ARRAY['identity_links','sector_links','business_links','compatibility_checkpoints','compatibility_rejections','compatibility_controls'] LOOP
relation := to_regclass('mne.'||name);
SELECT md5(COALESCE((SELECT string_agg(a.attname||':'||format_type(a.atttypid,a.atttypmod)||':'||a.attnotnull::text||':'||COALESCE(pg_get_expr(d.adbin,d.adrelid),''),'|' ORDER BY a.attnum)
FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid=a.attrelid AND d.adnum=a.attnum WHERE a.attrelid=relation AND a.attnum>0 AND NOT a.attisdropped),'')||COALESCE((SELECT string_agg(conname||':'||pg_get_constraintdef(oid),'|' ORDER BY conname) FROM pg_constraint WHERE conrelid=relation),'')) INTO signature;
EXECUTE format('COMMENT ON TABLE mne.%I IS %L',name,'004_mne_compatibility:'||signature);
END LOOP;
END $record$;
COMMIT;
+22
View File
@@ -0,0 +1,22 @@
BEGIN;
SET LOCAL lock_timeout = '10s';
SELECT pg_advisory_xact_lock(290926,170004);
DO $guard$
BEGIN
IF EXISTS(SELECT 1 FROM mne.compatibility_controls WHERE enabled)
OR EXISTS(SELECT 1 FROM mne.identity_links)
OR EXISTS(SELECT 1 FROM mne.sector_links)
OR EXISTS(SELECT 1 FROM mne.business_links)
OR EXISTS(SELECT 1 FROM mne.compatibility_rejections)
OR EXISTS(SELECT 1 FROM mne.compatibility_checkpoints)
OR EXISTS(SELECT 1 FROM mne.parties WHERE party_type='person') THEN
RAISE EXCEPTION 'Compatibility evidence or dependencies exist; archive and reconcile before DOWN';
END IF;
END $guard$;
DROP TABLE mne.compatibility_controls,mne.compatibility_rejections,mne.compatibility_checkpoints,
mne.business_links,mne.sector_links,mne.identity_links;
ALTER TABLE mne.parties DROP CONSTRAINT ck_parties_identity_links;
ALTER TABLE mne.parties ADD CONSTRAINT parties_check CHECK
((party_type='organization' AND organization_id IS NOT NULL AND user_id IS NULL)
OR (party_type='person' AND user_id IS NOT NULL AND organization_id IS NULL));
COMMIT;
@@ -0,0 +1,23 @@
-- Corrective migration: preserve the already applied 001-004 files.
BEGIN;
SET LOCAL lock_timeout = '10s';
SELECT pg_advisory_xact_lock(hashtextextended('mne:005_compatibility_guards',0));
DO $guard$
BEGIN
IF to_regclass('mne.compatibility_controls') IS NULL THEN
RAISE EXCEPTION '004 compatibility migration is required';
END IF;
IF EXISTS(SELECT 1 FROM mne.compatibility_controls WHERE capability='canonical_read' AND (enabled OR percentage<>0)) THEN
RAISE EXCEPTION 'Canonical control is not disabled; explicit reconciliation required';
END IF;
IF EXISTS(SELECT 1 FROM pg_constraint WHERE conrelid='mne.compatibility_controls'::regclass AND conname='ck_mne_005_canonical_disabled') THEN
IF (SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid='mne.compatibility_controls'::regclass AND conname='ck_mne_005_canonical_disabled')
<> $$CHECK (((capability <> 'canonical_read'::text) OR ((NOT enabled) AND (percentage = 0))))$$ THEN
RAISE EXCEPTION 'Unexpected canonical control constraint';
END IF;
ELSE
ALTER TABLE mne.compatibility_controls ADD CONSTRAINT ck_mne_005_canonical_disabled
CHECK (capability <> 'canonical_read' OR (NOT enabled AND percentage=0));
END IF;
END $guard$;
COMMIT;
@@ -0,0 +1,5 @@
BEGIN;
SET LOCAL lock_timeout = '10s';
SELECT pg_advisory_xact_lock(hashtextextended('mne:005_compatibility_guards',0));
ALTER TABLE mne.compatibility_controls DROP CONSTRAINT IF EXISTS ck_mne_005_canonical_disabled;
COMMIT;
@@ -5,7 +5,9 @@ export type Business = {
status: string; status: string;
classification: string; classification: string;
version_no: number; version_no: number;
sector_id?: number | null;
payload: { payload: {
[key: string]: string | undefined;
title: string; title: string;
description?: string; description?: string;
declared_reason?: string; declared_reason?: string;
@@ -0,0 +1,401 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { router } from "@inertiajs/react";
import { Frame, Field, input, button, card, api } from "./shared";
import type { Business } from "./business-types";
import { businessModalities } from "./business-types";
import BusinessObjects from "./business-objects";
import type { BusinessObject } from "./business-objects";
export type FormSchema = Record<
string,
{
step: number;
pt: string;
en: string;
type: string;
max: number;
options?: Record<string, string>;
}
>;
type Props = {
record: Business;
modalities: string[];
formSchema: FormSchema;
objects: BusinessObject[];
objectKinds: string[];
sectors: { id: number; name: string }[];
};
const steps = [
["Proponente", "Applicant"],
["Representação", "Representation"],
["Oportunidade", "Opportunity"],
["Valor", "Value"],
["Localização", "Location"],
["Activos e direitos", "Assets and rights"],
["Fontes", "Sources"],
["Resumo e confirmação", "Summary and confirmation"],
];
export default function BusinessWizard({
record,
modalities,
formSchema,
objects,
objectKinds,
sectors,
}: Props) {
const [english, setEnglish] = useState(false);
const t = (pt: string, en: string) => (english ? en : pt);
const [payload, setPayload] = useState<Business["payload"]>({
...record.payload,
wizard_version: "1",
});
const [modality, setModality] = useState(record.modality);
const [sectorId, setSectorId] = useState(record.sector_id ?? null);
const [version, setVersion] = useState(record.version_no);
const versionRef = useRef(record.version_no);
const saved = useRef(
JSON.stringify({
payload: record.payload,
modality: record.modality,
sectorId: record.sector_id ?? null,
}),
);
const inFlight = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
const [confirmation, setConfirmation] = useState("");
const heading = useRef<HTMLHeadingElement>(null);
const step = Math.max(1, Math.min(8, Number(payload.wizard_step ?? 1)));
const serialized = JSON.stringify({ payload, modality, sectorId });
const dirty = serialized !== saved.current;
const save = useCallback(async () => {
if (inFlight.current) return false;
if (serialized === saved.current) return true;
inFlight.current = true;
setBusy(true);
setError("");
try {
const result: Business = await api(`/api/v1/existing-business/listings/${record.id}`, {
_method: "PUT",
payload,
modality,
sector_id: sectorId,
classification: record.classification,
reason: "Gravação do formulário privado por etapas",
version_no: versionRef.current,
});
versionRef.current = result.version_no;
saved.current = serialized;
setVersion(result.version_no);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Não foi possível guardar / Save failed");
return false;
} finally {
inFlight.current = false;
setBusy(false);
}
}, [serialized, payload, modality, sectorId, record.id, record.classification]);
useEffect(() => {
if (!dirty || busy || error) return;
const timer = setTimeout(() => {
void save();
}, 3000);
return () => clearTimeout(timer);
}, [dirty, busy, error, save]);
useEffect(() => {
const protect = (event: BeforeUnloadEvent) => {
if (dirty || busy) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("beforeunload", protect);
return () => window.removeEventListener("beforeunload", protect);
}, [dirty, busy]);
useEffect(() => {
heading.current?.focus();
}, [step]);
useEffect(
() =>
router.on("before", (event) => {
if (
serialized !== saved.current &&
!window.confirm(
"Existem alterações por guardar. Sair? / Unsaved changes. Leave?",
)
)
event.preventDefault();
}),
[serialized],
);
const update = (key: string, value: string) => {
setError("");
setPayload((current) => ({ ...current, [key]: value }));
};
const submit = async () => {
if (!consent || confirmation !== record.code || !(await save())) return;
setBusy(true);
try {
await api(`/api/v1/existing-business/listings/${record.id}/transitions`, {
action: "submitted",
version_no: versionRef.current,
review_consent: true,
confirmation_code: confirmation,
note: "Submissão confirmada do formulário privado por etapas",
});
router.reload();
} catch (e) {
setError(e instanceof Error ? e.message : "Falha na submissão / Submission failed");
setBusy(false);
}
};
return (
<Frame title={t("Preparar oportunidade de negócio", "Prepare business opportunity")}>
<div lang={english ? "en" : "pt"} className="space-y-5">
<button type="button" className="underline" onClick={() => setEnglish(!english)}>
{english ? "Português" : "English"}
</button>
<p>
{record.code} · {t("Versão", "Version")} {version}
</p>
<p>
{t(
"Área privada. Guardar ou submeter não publica a oportunidade nem confirma a titularidade ou a transmissibilidade.",
"Private area. Saving or submitting does not publish the opportunity or verify ownership or transferability.",
)}
</p>
<ol className="flex flex-wrap gap-3" aria-label={t("Etapas", "Steps")}>
{steps.map((labels, i) => (
<li key={labels[1]}>
<button
type="button"
className="rounded border px-3 py-2 focus-visible:outline-2"
aria-current={step === i + 1 ? "step" : undefined}
disabled={busy}
onClick={() => update("wizard_step", String(i + 1))}
>
{i + 1}. {labels[english ? 1 : 0]}
</button>
</li>
))}
</ol>
<progress
className="w-full"
max={8}
value={step}
aria-label={t("Progresso", "Progress")}
/>
<h2 ref={heading} tabIndex={-1} className="text-xl font-bold">
{step}/8 — {steps[step - 1][english ? 1 : 0]}
</h2>
<form
className={`${card} space-y-4`}
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<fieldset className="space-y-4">
<legend className="sr-only">{steps[step - 1][english ? 1 : 0]}</legend>
{step === 3 && (
<Field label={t("Sector", "Sector")}>
<select
className={input}
value={sectorId ?? ""}
onChange={(e) =>
setSectorId(e.target.value ? Number(e.target.value) : null)
}
>
<option value="">{t("Seleccionar", "Select")}</option>
{sectors.map((sector) => (
<option key={sector.id} value={sector.id}>
{sector.name}
</option>
))}
</select>
</Field>
)}
{step === 3 && (
<Field label={t("Modalidade", "Modality")}>
<select
className={input}
value={modality}
onChange={(e) => setModality(e.target.value)}
>
{modalities.map((m) => (
<option key={m} value={m}>
{english
? m.replaceAll("_", " ")
: (businessModalities[m] ?? m)}
</option>
))}
</select>
</Field>
)}
{Object.entries(formSchema)
.filter(
([key, field]) =>
field.step === step &&
(!(
key === "mandate_reference" || key === "mandate_valid_until"
) ||
payload.acting_capacity === "representative"),
)
.map(([key, field]) => (
<Field key={key} label={english ? field.en : field.pt}>
{field.type === "select" ? (
<select
className={input}
value={payload[key] ?? ""}
onChange={(e) => update(key, e.target.value)}
>
<option value="">{t("Seleccionar", "Select")}</option>
{Object.entries(field.options ?? {}).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
),
)}
</select>
) : field.type === "textarea" ? (
<textarea
className={input}
rows={4}
maxLength={field.max}
value={payload[key] ?? ""}
onChange={(e) => update(key, e.target.value)}
/>
) : (
<input
className={input}
type={field.type === "decimal" ? "text" : field.type}
inputMode={
field.type === "decimal" ? "decimal" : undefined
}
maxLength={field.max}
value={payload[key] ?? ""}
onChange={(e) => update(key, e.target.value)}
/>
)}
</Field>
))}
{step === 7 && (
<p role="note">
{t(
"Este formulário regista referências. O envio de ficheiros com antivírus ainda não está disponível; não cole conteúdo reservado de documentos neste campo.",
"This form records references. File upload with antivirus is not available yet; do not paste confidential document content here.",
)}
</p>
)}
{step === 8 && (
<dl className="space-y-3 break-words">
{Object.entries(formSchema).map(([key, field]) => (
<div key={key}>
<dt className="font-semibold">
{english ? field.en : field.pt}
</dt>
<dd className="whitespace-pre-wrap">
{payload[key] || "—"}
</dd>
</div>
))}
</dl>
)}
</fieldset>
<p role="status" aria-live="polite">
{busy
? t("A guardar…", "Saving…")
: dirty
? t("Alterações por guardar", "Unsaved changes")
: t("Rascunho guardado no servidor", "Draft saved on server")}
</p>
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
<button className={button} disabled={busy}>
{t("Guardar agora", "Save now")}
</button>
</form>
{step === 6 && (
<BusinessObjects
businessId={record.id}
version={version}
editable={!dirty && !busy}
objects={objects}
objectKinds={objectKinds}
/>
)}
{step === 8 && (
<div className={`${card} space-y-4`}>
<label className="flex gap-3">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
{t(
"Confirmo a exactidão da declaração e autorizo a análise interna. Não autorizo publicação por esta acção.",
"I confirm this declaration and authorize internal review. This action does not authorize publication.",
)}
</label>
<Field
label={t(
"Digite o código acima para confirmar",
"Type the code above to confirm",
)}
>
<input
className={input}
autoComplete="off"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</Field>
<button
className={button}
disabled={busy || !consent || confirmation !== record.code}
onClick={() => void submit()}
>
{t(
"Confirmar e submeter para análise",
"Confirm and submit for review",
)}
</button>
</div>
)}
<div className="flex flex-wrap gap-4">
<button
className={button}
disabled={busy || step === 1}
onClick={() => update("wizard_step", String(step - 1))}
>
{t("Anterior", "Previous")}
</button>
<button
className={button}
disabled={busy || step === 8}
onClick={() => update("wizard_step", String(step + 1))}
>
{t("Seguinte", "Next")}
</button>
<button
className="underline"
disabled={busy}
onClick={async () => {
if (await save()) router.visit("/investidor/negocios");
}}
>
{t("Guardar e sair", "Save and exit")}
</button>
</div>
</div>
</Frame>
);
}
@@ -7,8 +7,12 @@ import BusinessObjects from "./business-objects";
import type { BusinessObject } from "./business-objects"; import type { BusinessObject } from "./business-objects";
import BusinessVerifications from "./business-verifications"; import BusinessVerifications from "./business-verifications";
import type { BusinessVerification } from "./business-verifications"; import type { BusinessVerification } from "./business-verifications";
import BusinessWizard from "./business-wizard";
import type { FormSchema } from "./business-wizard";
type Props = { type Props = {
formSchema: FormSchema;
sectors: { id: number; name: string }[];
record: Business; record: Business;
canEdit: boolean; canEdit: boolean;
owner: boolean; owner: boolean;
@@ -21,6 +25,8 @@ type Props = {
history: { version_no: number; action: string; created_at: string; note: string }[]; history: { version_no: number; action: string; created_at: string; note: string }[];
}; };
export default function BusinessPage(props: Props) { export default function BusinessPage(props: Props) {
if (props.canEdit)
return <BusinessWizard key={props.record.id + ":" + props.record.version_no} {...props} />;
return <Editor key={`${props.record.id}:${props.record.version_no}`} {...props} />; return <Editor key={`${props.record.id}:${props.record.version_no}`} {...props} />;
} }
function Editor({ function Editor({
@@ -34,6 +40,8 @@ function Editor({
objectKinds, objectKinds,
verificationChecks, verificationChecks,
verifications, verifications,
formSchema,
sectors,
}: Props) { }: Props) {
const [payload, setPayload] = useState(record.payload); const [payload, setPayload] = useState(record.payload);
const [modality, setModality] = useState(record.modality); const [modality, setModality] = useState(record.modality);
@@ -86,6 +94,35 @@ function Editor({
Informação declarada, pendente de validação. Não constitui anúncio público, Informação declarada, pendente de validação. Não constitui anúncio público,
certificação, avaliação ou autorização de transmissão. certificação, avaliação ou autorização de transmissão.
</p> </p>
{!canEdit && (
<dl className={`${card} space-y-3 break-words`}>
<div>
<dt className="font-bold">Sector</dt>
<dd>
{sectors.find((sector) => sector.id === record.sector_id)?.name ?? "—"}
</dd>
</div>
{Object.entries(formSchema)
.filter(
([key]) =>
![
"title",
"description",
"declared_reason",
"source_reference",
"valid_until",
].includes(key),
)
.map(([key, field]) => (
<div key={key}>
<dt className="font-bold">{field.pt}</dt>
<dd className="whitespace-pre-wrap">
{record.payload[key] || "—"}
</dd>
</div>
))}
</dl>
)}
<form <form
className={`${card} space-y-4`} className={`${card} space-y-4`}
onSubmit={(e) => { onSubmit={(e) => {
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Businesses({
modality, modality,
classification: "confidential", classification: "confidential",
reason: "Criação de rascunho pelo proprietário", reason: "Criação de rascunho pelo proprietário",
payload: { title }, payload: { title, wizard_version: "1", wizard_step: "1" },
}); });
router.visit(`/investidor/negocios/${result.id}`); router.visit(`/investidor/negocios/${result.id}`);
} catch (err) { } catch (err) {
+1 -1
View File
@@ -142,7 +142,7 @@ Route::middleware(['auth', 'verified', 'can:manage-portal'])->prefix('management
// Serialize management requests for the same session so background dropdown/coordinate // Serialize management requests for the same session so background dropdown/coordinate
// reads cannot overwrite validation feedback from a concurrent form submission. // reads cannot overwrite validation feedback from a concurrent form submission.
foreach (Route::getRoutes() as $managementRoute) { foreach (Route::getRoutes()->getRoutes() as $managementRoute) {
if (str_starts_with($managementRoute->uri(), 'management/')) { if (str_starts_with($managementRoute->uri(), 'management/')) {
$managementRoute->block(10, 10); $managementRoute->block(10, 10);
} }
@@ -0,0 +1,29 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ExistingBusinessDrafts;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class ExistingBusinessCompatibilityTest extends TestCase
{
use RefreshDatabase;
public function test_shadow_unavailability_does_not_change_private_contract_or_authorization(): void
{
config(['existing-business.enabled' => true, 'existing-business.compatibility_shadow' => true]);
Queue::fake();
$owner = User::factory()->create();
$other = User::factory()->create();
$business = app(ExistingBusinessDrafts::class)->save($owner, ['modality' => 'company_sale',
'classification' => 'confidential', 'reason' => 'Synthetic compatibility case', 'payload' => ['title' => 'Reserved synthetic title']]);
$url = '/api/v1/existing-business/listings/'.$business->id;
$this->actingAs($owner)->getJson($url)->assertOk()->assertJsonPath('record.payload.title', 'Reserved synthetic title');
$this->actingAs($other)->getJson($url)->assertForbidden();
Queue::assertNothingPushed();
$this->assertDatabaseCount('existing_business_revisions', 1);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class ExistingBusinessWizardTest extends TestCase
{
use RefreshDatabase;
public function test_sector_migration_reverses_empty_and_refuses_data_loss(): void
{
$migration = require database_path('migrations/2026_09_29_200000_existing_business_form_sector.php');
$migration->down();
$this->assertFalse(Schema::hasColumn('existing_businesses', 'sector_id'));
$migration->up();
$this->assertTrue(Schema::hasColumn('existing_businesses', 'sector_id'));
config(['existing-business.enabled' => true]);
$sector = DB::table('sectors')->insertGetId(['name' => 'Synthetic', 'slug' => 'rollback-synthetic', 'is_active' => true]);
$this->actingAs(User::factory()->create())->postJson('/api/v1/existing-business/listings', [
'modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic rollback check',
'sector_id' => $sector, 'payload' => ['title' => 'Synthetic rollback'],
])->assertCreated();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Sector associations exist');
$migration->down();
}
public function test_draft_resume_validation_concurrency_confirmation_and_private_submission(): void
{
config(['existing-business.enabled' => true]);
$owner = User::factory()->create();
$sector = DB::table('sectors')->insertGetId(['name' => 'Synthetic', 'slug' => 'wizard-synthetic', 'is_active' => true]);
$this->actingAs($owner);
$draft = ['modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic form draft', 'payload' => ['title' => 'Synthetic wizard', 'wizard_version' => '1', 'wizard_step' => '1']];
$record = $this->postJson('/api/v1/existing-business/listings', $draft)->assertCreated()->json();
$url = '/api/v1/existing-business/listings/'.$record['id'];
$transition = ['action' => 'submitted', 'version_no' => 1, 'note' => 'Synthetic submission request', 'review_consent' => true, 'confirmation_code' => $record['code']];
$this->postJson($url.'/transitions', $transition)->assertUnprocessable();
$payload = [...$draft['payload'], 'wizard_step' => '8', 'proponent_type' => 'person', 'proponent_name' => 'Synthetic owner',
'tax_identifier' => 'SYNTHETIC', 'contact_email' => 'synthetic@example.test', 'country' => 'MZ', 'acting_capacity' => 'representative',
'declared_holder' => 'Synthetic holder', 'legitimacy_declaration' => 'yes', 'location_notes' => 'Synthetic private location',
'description' => 'Synthetic description long enough for review.', 'source_reference' => 'Synthetic source reference',
'valid_until' => today()->addMonth()->toDateString(), 'asking_value' => '1234.50', 'currency' => 'MZN', 'value_nature' => 'asking'];
$save = [...$draft, 'version_no' => 1, 'sector_id' => $sector, 'payload' => $payload];
$this->putJson($url, [...$save, 'payload' => [...$payload, 'equity_percentage' => '101']])->assertUnprocessable();
$this->putJson($url, $save)->assertOk()->assertJsonPath('version_no', 2);
$this->getJson($url)->assertOk()->assertJsonPath('record.payload.wizard_step', '8')->assertJsonPath('record.sector_id', $sector);
$this->putJson($url, $save)->assertConflict();
$this->postJson($url.'/transitions', [...$transition, 'version_no' => 2])->assertUnprocessable()->assertJsonValidationErrors('mandate_reference');
$payload['mandate_reference'] = 'Synthetic mandate reference';
$payload['mandate_valid_until'] = today()->addMonth()->toDateString();
$this->putJson($url, [...$save, 'version_no' => 2, 'payload' => $payload])->assertOk();
$this->putJson($url, [...$save, 'version_no' => 3, 'payload' => $payload, 'modality' => 'real_estate'])->assertOk();
$this->postJson($url.'/transitions', [...$transition, 'version_no' => 4])->assertUnprocessable()->assertJsonValidationErrors('objects');
$this->putJson($url, [...$save, 'version_no' => 4, 'payload' => $payload])->assertOk();
$this->postJson($url.'/transitions', [...$transition, 'version_no' => 5, 'confirmation_code' => 'wrong'])->assertUnprocessable();
$this->postJson($url.'/transitions', [...$transition, 'version_no' => 5])->assertOk()->assertJsonPath('status', 'submitted');
$this->postJson($url.'/transitions', [...$transition, 'version_no' => 5])->assertConflict();
$this->actingAs(User::factory()->create())->getJson($url)->assertForbidden();
$this->assertDatabaseCount('existing_business_reviews', 1);
$raw = DB::table('existing_businesses')->where('id', $record['id'])->value('payload');
$this->assertStringNotContainsString('synthetic@example.test', $raw);
}
}
+7
View File
@@ -63,6 +63,10 @@ class InvestmentZoneTest extends TestCase
public function test_rollback_refuses_populated_foundation_and_empty_rollback_is_reversible(): void public function test_rollback_refuses_populated_foundation_and_empty_rollback_is_reversible(): void
{ {
$skillsMigrations = glob(database_path('migrations/2026_09_30_*_skills_education_*.php'));
foreach (array_reverse($skillsMigrations) as $file) {
(require $file)->down();
}
$migration = require database_path('migrations/2026_09_28_100000_investment_zone_foundation.php'); $migration = require database_path('migrations/2026_09_28_100000_investment_zone_foundation.php');
$originMigration = require database_path('migrations/2026_09_28_120000_investment_submission_zones.php'); $originMigration = require database_path('migrations/2026_09_28_120000_investment_submission_zones.php');
$geometryMigration = require database_path('migrations/2026_09_28_110000_zone_geometry_review.php'); $geometryMigration = require database_path('migrations/2026_09_28_110000_zone_geometry_review.php');
@@ -78,6 +82,9 @@ class InvestmentZoneTest extends TestCase
$originMigration->up(); $originMigration->up();
$businessMigration->up(); $businessMigration->up();
$intakeMigration->up(); $intakeMigration->up();
foreach ($skillsMigrations as $file) {
(require $file)->up();
}
$this->assertTrue(Schema::hasTable('investment_zone_links')); $this->assertTrue(Schema::hasTable('investment_zone_links'));
$this->actingAs($this->manager())->postJson('/api/v2/investment/admin/zones', ['code' => 'QA', 'name' => 'Synthetic', 'category' => 'pending'])->assertCreated(); $this->actingAs($this->manager())->postJson('/api/v2/investment/admin/zones', ['code' => 'QA', 'name' => 'Synthetic', 'category' => 'pending'])->assertCreated();
try { try {
@@ -0,0 +1,69 @@
<?php
namespace Tests\Feature;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SkillsEducationSchemaTest extends TestCase
{
use RefreshDatabase;
private function rejected(string $sql, string $state): void
{
DB::statement('SAVEPOINT invalid_skills_case');
try {
DB::statement($sql);
$this->fail('Invalid write accepted');
} catch (QueryException $e) {
$this->assertSame($state, $e->getCode());
} finally {
DB::statement('ROLLBACK TO SAVEPOINT invalid_skills_case');
}
}
public function test_master_types_private_defaults_and_immutable_audit(): void
{
$this->assertSame(24, DB::table('pg_tables')->where('schemaname', 'skills_edu')->count());
$missing = DB::selectOne(<<<'SQL'
SELECT count(*) AS n FROM pg_constraint c JOIN pg_namespace ns ON ns.oid=c.connamespace
WHERE ns.nspname='skills_edu' AND c.contype='f' AND NOT EXISTS (
SELECT 1 FROM pg_index i JOIN pg_class ix ON ix.oid=i.indexrelid JOIN pg_am am ON am.oid=ix.relam
WHERE i.indrelid=c.conrelid AND i.indisvalid AND i.indisready AND i.indpred IS NULL
AND am.amname='btree' AND i.indnkeyatts>=cardinality(c.conkey)
AND ARRAY(SELECT x FROM unnest(i.indkey::smallint[]) WITH ORDINALITY k(x,p) WHERE p<=cardinality(c.conkey) ORDER BY p)=c.conkey)
SQL);
$this->assertSame(0, (int) $missing->n);
$this->assertNull(DB::selectOne("SELECT to_regclass('skills_edu.skills') AS id")->id);
$this->assertSame('bigint', DB::selectOne("SELECT format_type(atttypid,atttypmod) AS type FROM pg_attribute WHERE attrelid='skills_edu.professional_profiles'::regclass AND attname='user_master_id'")->type);
$this->rejected("INSERT INTO skills_edu.skill_details(id,category) VALUES(gen_random_uuid(),'technical')", '23503');
$this->rejected("INSERT INTO skills_edu.professional_profiles(full_name,registration_mode,user_master_id) VALUES('Synthetic','individual',9223372036854775807)", '23503');
$profile = DB::selectOne("INSERT INTO skills_edu.professional_profiles(full_name,registration_mode) VALUES('Synthetic','individual') RETURNING id,visibility_level,status");
$this->assertSame('private', $profile->visibility_level);
$this->assertSame('draft', $profile->status);
$this->rejected("INSERT INTO skills_edu.profile_experiences(profile_id,job_title,started_on,ended_on) VALUES('$profile->id','Synthetic','2026-02-02','2026-01-01')", '23514');
DB::statement("INSERT INTO skills_edu.audit_events(action,object_type,result,correlation_id) VALUES('synthetic','test','success',gen_random_uuid())");
foreach (['UPDATE skills_edu.audit_events SET action=action', 'DELETE FROM skills_edu.audit_events', 'TRUNCATE skills_edu.audit_events'] as $sql) {
$this->rejected($sql, '23514');
}
}
public function test_reverse_order_rollback_reapply_and_nonempty_refusal(): void
{
$files = glob(database_path('migrations/2026_09_30_*_skills_education_*.php'));
foreach (array_reverse($files) as $file) {
(require $file)->down();
}
$this->assertNull(DB::selectOne("SELECT to_regnamespace('skills_edu') AS id")->id);
foreach ($files as $file) {
(require $file)->up();
}
$this->assertSame(24, DB::table('pg_tables')->where('schemaname', 'skills_edu')->count());
DB::statement("INSERT INTO skills_edu.audit_events(action,object_type,result,correlation_id) VALUES('synthetic','test','success',gen_random_uuid())");
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('destructive rollback refused');
(require end($files))->down();
}
}
+19
View File
@@ -2,11 +2,30 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
public function createApplication(): Application
{
$app = parent::createApplication();
// migrate:fresh only clears the connection's public search path. This module owns a separate schema.
if (! RefreshDatabaseState::$migrated && $app->environment('testing')) {
$db = $app->make('db')->connection();
if ($db->getDriverName() === 'pgsql'
&& in_array($db->getDatabaseName(), ['portal_services_test', 'investmoz_test'], true)
&& (string) $db->getConfig('port') === '55441'
&& in_array($db->getConfig('host'), ['127.0.0.1', 'localhost'], true)) {
$db->statement('DROP SCHEMA IF EXISTS skills_edu CASCADE');
}
}
return $app;
}
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
+20
View File
@@ -0,0 +1,20 @@
<?php
use App\Models\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
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 required');
}
$user = User::firstOrCreate(['email' => 'mne-browser@example.test'], [
'name' => 'Synthetic MNE browser', 'password' => Hash::make('SyntheticMneOnly-2026!'),
]);
$user->forceFill(['email_verified_at' => now()])->save();
$db->table('sectors')->updateOrInsert(['slug' => 'wizard-browser-synthetic'], ['name' => 'Synthetic wizard sector', 'is_active' => true]);
echo "Synthetic isolated fixture ready\n";
+92
View File
@@ -0,0 +1,92 @@
// Isolated QA only. Synthetic account is prepared separately, never against the application DB.
import fs from 'node:fs';
const base = 'http://127.0.0.1:18027';
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 seq = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.text);
if (m.id) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(m.error) : p.resolve(m.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw Error('Browser expression failed');
return r.result?.value;
};
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(r => setTimeout(r, 100)); } throw Error('Browser condition timed out: ' + expression); };
const fill = async (selector, value) => {
await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);
await send('Input.insertText', { text: value });
};
const field = async (label, value) => {
const selector = `([...document.querySelectorAll('label')].find(l=>l.textContent.includes(${JSON.stringify(label)}))?.querySelector('input,textarea,select'))`;
await wait(`!!${selector}`);
await evaluate(`(()=>{const e=${selector}; e.focus(); if(e.tagName==='SELECT'){ e.value=${JSON.stringify(value)}; e.dispatchEvent(new Event('change',{bubbles:true})); }else {e.select();}})()`);
if (!await evaluate(`${selector}.tagName==='SELECT'`)) await send('Input.insertText', { text: value });
};
const step = async number => {
await wait(`![...document.querySelectorAll('ol button')][${number - 1}].disabled`);
await evaluate(`[...document.querySelectorAll('ol button')][${number - 1}].click()`);
await wait(`document.querySelector('h2')?.textContent.startsWith('${number}/8')`);
};
const observations = [];
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true });
await send('Page.navigate', { url: base + '/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
await fill('input[type="email"]', 'mne-browser@example.test');
await fill('input[type="password"]', 'SyntheticMneOnly-2026!');
await evaluate(`document.querySelector('button[type="submit"]').click()`);
await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: base + '/investidor/negocios' });
await wait(`document.body.innerText.includes('Preparar negócio')`);
await fill('main form input', 'Synthetic wizard browser');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`document.querySelector('h2')?.textContent.startsWith('1/8')`);
await field('Tipo de proponente', 'person');
await field('Nome ou denominação', 'Synthetic owner');
await field('NUIT', 'SYNTHETIC');
await field('Email de contacto', 'synthetic@example.test');
await field('País', 'MZ');
await wait(`document.body.innerText.includes('Rascunho guardado no servidor')`);
await send('Page.reload');
await wait(`!![...document.querySelectorAll('input')].find(e=>e.value==='Synthetic owner')`);
observations.push({ scenario: 'autosave_and_reload', passed: true });
await step(2);
await field('Qualidade', 'holder'); await field('Titular declarado', 'Synthetic owner'); await field('Declaro legitimidade', 'yes');
await step(3);
await evaluate(`(()=>{const e=[...document.querySelectorAll('label')].find(l=>l.querySelector('span')?.textContent==='Sector')?.querySelector('select'); e.value=e.options[1].value;e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await field('Descrição reservada','Synthetic private description long enough for this browser-only review.');
await evaluate(`(()=>{const e=document.querySelector('input[type=date]');const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,'2027-12-31');e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await step(5); await field('Localização declarada','Synthetic location, no coordinates');
await step(7); await field('Referência da fonte','Synthetic source for browser testing');
await step(8);
await wait(`document.body.innerText.includes('Rascunho guardado no servidor')`);
const code = await evaluate(`document.querySelector('main').innerText.match(/MNE-[0-9a-f-]{36}/)[0]`);
await field('Digite o código',code);
await evaluate(`document.querySelector('input[type=checkbox]').click()`);
for (const width of [390,1440]) {
await send('Emulation.setDeviceMetricsOverride',{width,height:900,deviceScaleFactor:1,mobile:width===390});
const fits = await evaluate(`document.documentElement.scrollWidth<=innerWidth+1`);
if (!fits) throw Error('Horizontal overflow');
observations.push({scenario:'responsive_summary',width,passed:true});
}
await evaluate(`[...document.querySelectorAll('button')].find(e=>e.textContent.includes('Confirmar e submeter')).click()`);
await wait(`document.body.innerText.includes('Submetido') && !document.querySelector('progress')`);
observations.push({scenario:'private_submission',passed:true});
if(errors.length) throw Error('Browser exceptions');
fs.writeFileSync('docs/investmoz/existing-business/wizard-browser-validation.json',JSON.stringify({passed:true,observations,errors},null,2)+'\n');
console.log(JSON.stringify({passed:true,observations,errors}));
} finally {
ws.close();
await fetch('http://127.0.0.1:9224/json/close/'+target.id);
}
+2 -71
View File
@@ -1,71 +1,2 @@
// Isolated QA only. Synthetic account is prepared separately, never against the application DB. // Compatibility entry point: prepare tools/business-wizard-browser-seed.php in isolated QA first.
import fs from 'node:fs'; import './business-wizard-browser.mjs';
const base = 'http://127.0.0.1:18027';
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 seq = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.text);
if (m.id) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(m.error) : p.resolve(m.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw Error('Browser expression failed');
return r.result?.value;
};
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(r => setTimeout(r, 100)); } throw Error('Browser condition timed out: ' + expression); };
const fill = async (selector, value) => {
await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);
await send('Input.insertText', { text: value });
};
const observations = [];
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true });
await send('Page.navigate', { url: base + '/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
await fill('input[type="email"]', 'mne-browser@example.test');
await fill('input[type="password"]', 'SyntheticMneOnly-2026!');
await evaluate(`document.querySelector('button[type="submit"]').click()`);
await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: base + '/investidor/negocios' });
await wait(`!!document.querySelector('h1') && document.body.innerText.includes('Preparar neg')`);
await fill('main form input', 'Synthetic browser business');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`location.pathname.startsWith('/investidor/negocios/') && !!document.querySelector('textarea')`);
await fill('fieldset textarea', 'Synthetic private description for review of a browser-only business.');
await fill('fieldset input[maxlength="2000"]', 'Synthetic declared reason');
await fill('fieldset input[maxlength="2000"]:last-of-type', 'Synthetic source evidence');
// Find source input by its wrapping label; field labels are accessible label elements.
await evaluate(`(()=>{const labels=[...document.querySelectorAll('label')];const field=labels.find(l=>l.textContent.includes('Referência da fonte'))?.querySelector('input'); if(field) field.focus();})()`);
await send('Input.insertText', { text: 'Synthetic source evidence' });
await evaluate(`(()=>{const el=document.querySelector('input[type="date"]'); const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(el,'2027-12-31');el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await fill('main form > label textarea', 'Synthetic save for browser QA');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`document.body.innerText.includes('versão 2')`);
observations.push({ scenario: 'private_create_and_save', passed: true });
await fill('main form > label textarea', 'Synthetic submission for browser QA');
await evaluate(`document.querySelector('input[type="checkbox"]').click()`);
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Submeter para análise').click()`);
await wait(`document.body.innerText.includes('versão 3') && document.body.innerText.includes('Submetido')`);
observations.push({ scenario: 'private_submit_with_review_consent', passed: true });
for (const width of [390, 1440]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 });
const result = await evaluate(`(()=>{const a=[...document.querySelectorAll('a')].find(a=>getComputedStyle(a).position==='fixed'&&a.href.endsWith('/apoio'));const b=a?.getBoundingClientRect();return {width:innerWidth,breadcrumb:!!document.querySelector('nav[aria-label="Caminho de navegação"]'),supportVisible:!!b&&b.bottom<=innerHeight&&b.left>=0,overflow:document.documentElement.scrollWidth>innerWidth+1};})()`);
if (!result.breadcrumb || !result.supportVisible || result.overflow) throw Error('Layout failure');
observations.push(result);
}
if (errors.length) throw Error('JavaScript errors observed');
fs.writeFileSync('docs/investmoz/existing-business/browser-validation.json', JSON.stringify({ date: new Date().toISOString(), scope: 'Synthetic private create/save and responsive layout in isolated QA; institutional review covered by integration tests', observations, errors }, null, 2) + '\n');
console.log(JSON.stringify({ passed: observations.length, javascript_errors: errors.length }));
} catch (error) {
console.log(await evaluate(`({path:location.pathname,title:document.title,body:document.body.innerText.slice(0,1200)})`));
throw error;
} finally { await send('Page.close'); ws.close(); }
+13 -1
View File
@@ -46,6 +46,18 @@ document.paths['/listings/{id}/verifications'] = { post: operation('Record indep
document.paths['/listings/{id}'].get.responses['200'].content['application/json'].schema.properties.verifications = { document.paths['/listings/{id}'].get.responses['200'].content['application/json'].schema.properties.verifications = {
type: 'array', items: { ...verification, properties: { ...verification.properties, effective_outcome: { enum: ['confirmed', 'rejected', 'inconclusive', 'revoked', 'stale', 'expired'] }, content_version: { type: 'integer' }, created_at: text } }, type: 'array', items: { ...verification, properties: { ...verification.properties, effective_outcome: { enum: ['confirmed', 'rejected', 'inconclusive', 'revoked', 'stale', 'expired'] }, content_version: { type: 'integer' }, created_at: text } },
}; };
document.info.version = '1.1.0'; const formSchema = JSON.parse(fs.readFileSync('docs/investmoz/existing-business/form-schema.json', 'utf8'));
for (const [key, field] of Object.entries(formSchema)) {
payload.properties[key] = { type: key === 'title' ? 'string' : ['string', 'null'], maxLength: field.max,
...(field.options ? { enum: [...Object.keys(field.options), null] } : {}),
...(field.type === 'date' ? { format: 'date' } : {}), description: field.en };
}
payload.properties.wizard_version = { type: ['string', 'null'], enum: ['1', null] };
payload.properties.wizard_step = { type: ['string', 'null'], pattern: '^[1-8]$' };
input.properties.sector_id = { type: ['integer', 'null'], description: 'Existing active public.sectors identifier; required on wizard submission.' };
document.paths['/listings/{id}'].put.requestBody.content['application/json'].schema.properties.sector_id = input.properties.sector_id;
document.components.schemas.Business.properties.sector_id = input.properties.sector_id;
document.paths['/listings/{id}/transitions'].post.requestBody.content['application/json'].schema.properties.confirmation_code = { type: ['string', 'null'], description: 'Exact business code required for wizard_version=1 submission, in addition to internal review consent.' };
document.info.version = '1.2.0';
fs.writeFileSync('docs/investmoz/existing-business/openapi.json', JSON.stringify(document, null, 2) + '\n'); fs.writeFileSync('docs/investmoz/existing-business/openapi.json', JSON.stringify(document, null, 2) + '\n');
console.log('OpenAPI written: 9 operations, private session API.'); console.log('OpenAPI written: 9 operations, private session API.');
+8
View File
@@ -0,0 +1,8 @@
<?php
use App\Services\BusinessFormSchema;
require __DIR__.'/../vendor/autoload.php';
file_put_contents(__DIR__.'/../docs/investmoz/existing-business/form-schema.json', json_encode(
BusinessFormSchema::fields(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
).PHP_EOL);
+38
View File
@@ -0,0 +1,38 @@
<?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();
$report = DB::transaction(function (): array {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$tables = DB::select("SELECT table_schema,table_name,table_type FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1,2");
$counts = [];
foreach ($tables as $table) {
if ($table->table_type === 'BASE TABLE') {
$counts[$table->table_schema.'.'.$table->table_name] = DB::table($table->table_schema.'.'.$table->table_name)->count();
}
}
return [
'captured_at' => gmdate('c'),
'server' => DB::selectOne('SHOW server_version')->server_version,
'extensions' => DB::select('SELECT extname,extversion FROM pg_extension ORDER BY extname'),
'tables' => $tables,
'columns' => DB::select("SELECT table_schema,table_name,column_name,data_type,udt_name,is_nullable,column_default FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY table_schema,table_name,ordinal_position"),
'indexes' => DB::select("SELECT schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'constraints' => DB::select("SELECT n.nspname AS schema,c.relname AS table_name,k.conname,k.contype,k.convalidated,pg_get_constraintdef(k.oid) AS definition FROM pg_constraint k JOIN pg_class c ON c.oid=k.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'views' => DB::select("SELECT schemaname,viewname,definition FROM pg_views WHERE schemaname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2"),
'triggers' => DB::select("SELECT n.nspname AS schema,c.relname AS table_name,t.tgname,pg_get_triggerdef(t.oid) AS definition FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE NOT t.tgisinternal AND n.nspname NOT IN ('pg_catalog','information_schema') ORDER BY 1,2,3"),
'row_counts' => $counts,
];
});
$directory = storage_path('app/private/reference-reconciliation-'.gmdate('Ymd-His'));
if (! mkdir($directory, 0700, true) && ! is_dir($directory)) {
throw new RuntimeException('Cannot create private evidence directory.');
}
$path = $directory.'/inventory.json';
file_put_contents($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
echo json_encode(['path' => $path, 'sha256' => hash_file('sha256', $path), 'tables' => count($report['row_counts']), 'columns' => count($report['columns']), 'indexes' => count($report['indexes']), 'constraints' => count($report['constraints'])], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
+89
View File
@@ -0,0 +1,89 @@
"""Convert the supplied baseline to versioned, master-linked SQL. No database writes."""
import re
from pathlib import Path
root = Path(__file__).resolve().parents[1]
source = Path(r'C:/Users/macbook/.codex/attachments/8528a9d2-b934-4bb3-a132-a18b7e13414d/Pasted text.txt')
text = source.read_text(encoding='utf-8-sig')
parts = re.split(r'5\.10\.[1-6]\. [^\r\n]+\r?\n', text)[1:]
assert len(parts) == 6
parts[-1] = parts[-1].split('COMMIT;')[0]
extensions = {
'qualification_documents': ('qualification_document_details', 'documents', {'storage_key','original_filename','mime_type','sha256_checksum'}),
'consents': ('consent_details', 'consents', {'purpose_code','status','granted_at','revoked_at'}),
'skills': ('skill_details', 'skills', {'code','name_pt'}),
'occupations': ('occupation_details', 'occupations', {'code','name_pt'}),
'education_programs': ('program_details', 'courses', {'institution_master_id','code','title_pt','status'}),
'human_resource_needs': ('human_resource_need_details', 'labour_demands', {'occupation_id','project_master_id','location_master_id','qualification_level','estimated_quantity','needed_from','needed_until','source_name','status'}),
'educational_opportunities': ('educational_opportunity_details', 'education_investment_proposals', {'title_pt','description_pt','indicative_value','currency','status'}),
}
renames = {k:v[0] for k,v in extensions.items()}
user_columns = ['user_master_id','created_by','updated_by','approved_by','validated_by','verified_by','uploaded_by','reviewed_by','responsible_user_id','actor_user_id','institution_operator_user_id']
masters = {**{k:('users','bigint') for k in user_columns},
'sector_master_id':('sectors','bigint'), 'company_master_id':('organizations','uuid'),
'organisation_master_id':('organizations','uuid'), 'institution_master_id':('training_providers','uuid'),
'program_master_id':('courses','uuid'), 'project_master_id':('projects','uuid'),
'cluster_master_id':('economic_clusters','uuid'), 'corridor_master_id':('development_corridors','uuid'),
'zee_master_id':('investment_zones','uuid'), 'zfi_master_id':('investment_zones','uuid'), 'location_master_id':('geo_locations','uuid')}
out = root/'database/sql/skills-education'
out.mkdir(parents=True,exist_ok=True)
all_tables=[]
for number, sql in enumerate(parts,1):
for original,(new,master,omit) in extensions.items():
pattern = rf'CREATE TABLE skills_edu\.{original} \(\n(.*?)\n\);'
def adapt(match):
lines=match.group(1).splitlines()
result=[]
for line in lines:
field=line.strip().split()[0]
if field in omit: continue
if original=='human_resource_needs' and 'CHECK (needed_until' in line: continue
if original=='education_programs' and 'UNIQUE (institution_master_id' in line: continue
if original=='qualification_documents' and 'UNIQUE (storage_key)' in line: continue
if original=='consents' and ('CHECK (expires_at' in line or 'CHECK (revoked_at' in line): continue
if field=='id': line=f' id uuid PRIMARY KEY REFERENCES public.{master}(id) ON DELETE RESTRICT,'
result.append(line)
return 'CREATE TABLE skills_edu.'+new+' (\n'+'\n'.join(result).rstrip().rstrip(',')+'\n);'
sql=re.sub(pattern,adapt,sql,flags=re.S)
# Foreign keys for shared catalogues always point at the existing masters.
for original,master in [('skills','skills'),('occupations','occupations'),('education_programs','courses')]:
sql=sql.replace(f'REFERENCES skills_edu.{original}(id)',f'REFERENCES public.{master}(id)')
for original,new in renames.items():
sql=re.sub(rf'skills_edu\.{original}\b',f'skills_edu.{new}',sql)
sql=re.sub(r'^CREATE INDEX idx_(hr_needs_search|hr_needs_dates|programs_institution_status|opportunities_context).*?;\n','',sql,flags=re.M)
sql=re.sub(r'^CREATE INDEX idx_(documents_checksum|consents_profile_purpose).*?;\n','',sql,flags=re.M)
sql=re.sub(r'^COMMENT ON COLUMN skills_edu\.qualification_document_details\.storage_key.*?;\n','',sql,flags=re.M)
for column,(master,typ) in masters.items():
sql=re.sub(rf'\b{column} uuid\b', f'{column} {typ}', sql)
tables=re.findall(r'CREATE TABLE skills_edu\.(\w+)',sql)
all_tables += tables
for table in tables:
body=re.search(rf'CREATE TABLE skills_edu\.{table} \((.*?)\n\);',sql,re.S).group(1)
for column,(master,typ) in masters.items():
if re.search(rf'^ {column} ',body,re.M):
sql+=f'\nALTER TABLE skills_edu.{table} ADD FOREIGN KEY ({column}) REFERENCES public.{master}(id) ON DELETE RESTRICT;\n'
if number == 3:
sql += '''
CREATE FUNCTION skills_edu.validate_consent_dates() RETURNS trigger LANGUAGE plpgsql SET search_path=pg_catalog AS $$
DECLARE granted timestamptz; subject text;
BEGIN
SELECT granted_at,subject_ref INTO granted,subject FROM public.consents WHERE id=NEW.id;
IF subject IS DISTINCT FROM NEW.profile_id::text OR granted IS NULL OR (NEW.expires_at IS NOT NULL AND NEW.expires_at<=granted) THEN
RAISE EXCEPTION 'Consent master profile or validity mismatch' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER validate_consent_master BEFORE INSERT OR UPDATE ON skills_edu.consent_details
FOR EACH ROW EXECUTE FUNCTION skills_edu.validate_consent_dates();
'''
(out/f'{number:02d}.sql').write_text(sql,encoding='utf-8')
migration=f'''<?php
return new class extends \\App\\Support\\SkillsEducationMigration
{{
protected int $block = {number};
protected array $tables = {str(tables).replace('[','[').replace(']',']')};
}};
'''
(root/f'database/migrations/2026_09_30_10{number:02d}00_skills_education_{number}.php').write_text(migration,encoding='utf-8')
print(f'Prepared {len(parts)} versioned blocks, {len(all_tables)} tables; existing catalogues remain masters.')
+126
View File
@@ -0,0 +1,126 @@
<?php
use App\Models\User;
use App\Services\ExistingBusinessDrafts;
use App\Services\Mne\BusinessRepository;
use App\Services\Mne\CompatibilityBackfill;
use App\Services\Mne\IdentityResolver;
use App\Services\Mne\SectorResolver;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
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 required');
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA schema mne must be absent');
}
$run = function (string $file) use ($db): void {
$sql = preg_replace('/^BEGIN;\s*$/m', '', file_get_contents(base_path('migrations/'.$file)));
$db->unprepared(preg_replace('/COMMIT;\s*$/', '', $sql));
};
$check = function (bool $condition, string $message): void {
if (! $condition) {
throw new RuntimeException($message);
}
};
$reject = function (callable $operation, string $state) use ($db, $check): void {
$db->statement('SAVEPOINT rejection');
$rejected = false;
try {
$operation();
} catch (QueryException $e) {
$rejected = $e->getCode() === $state;
}
$db->statement('ROLLBACK TO SAVEPOINT rejection');
$check($rejected, 'Expected database rejection '.$state);
};
$db->beginTransaction();
try {
foreach (['001_mne_schema.sql', '002_mne_existing_territories.sql', '003_mne_integrity.sql', '004_mne_compatibility.sql'] as $file) {
$run($file);
}
$run('004_mne_compatibility.sql');
$run('004_mne_compatibility_down.sql');
$run('004_mne_compatibility.sql');
$user = User::factory()->create();
config(['existing-business.enabled' => true]);
$business = app(ExistingBusinessDrafts::class)->save($user, [
'modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic compatibility test',
'payload' => ['title' => 'Synthetic private business'],
]);
$stranger = User::factory()->create();
$loaded = app(BusinessRepository::class)->find($business->id);
$check(Gate::forUser($user)->allows('view', $loaded), 'Owner lost access');
$check(Gate::forUser($stranger)->denies('view', $loaded), 'Unauthorized access');
$sector = $db->table('public.sectors')->insertGetId(['name' => 'Synthetic compatibility', 'slug' => 'synthetic-compatibility', 'is_active' => true]);
$backfill = app(CompatibilityBackfill::class);
$dryStatus = Artisan::call('mne:backfill-compatibility', ['--batch' => 2, '--dry-run' => true]);
$check($dryStatus === 0, 'Dry run failed');
foreach (['identity_links', 'sector_links', 'business_links', 'compatibility_checkpoints', 'compatibility_rejections'] as $table) {
$check($db->table('mne.'.$table)->count() === 0, 'Dry run persisted data: '.$table);
}
$first = $backfill->run(2);
$links = $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson();
$second = $backfill->run(2);
$check($first === $second, 'Backfill counts changed');
$check($links === $db->table('mne.identity_links')->orderBy('user_id')->get()->toJson(), 'Backfill not idempotent');
$check(app(SectorResolver::class)->resolve($sector) !== null, 'Sector not resolved');
$check(app(SectorResolver::class)->resolve(2147483647) === null, 'Unknown sector invented');
$row = (array) $db->table('mne.identity_links')->where('user_id', $user->id)->first();
unset($row['id']);
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23505');
$row['user_id'] = 9223372036854775807;
$reject(fn () => $db->table('mne.identity_links')->insert($row), '23503');
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'active']), '23514');
$party = $db->selectOne("INSERT INTO mne.parties(party_type,display_name) VALUES ('person','Synthetic party') RETURNING id")->id;
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['party_id' => $party, 'status' => 'active']);
$check(app(IdentityResolver::class)->resolve($user->id) === $party, 'Identity link not resolved');
$reject(fn () => $db->table('mne.identity_links')->where('user_id', $stranger->id)->update(['party_id' => $party]), '23505');
$db->table('mne.identity_links')->where('user_id', $user->id)->update(['status' => 'revoked']);
$check(app(IdentityResolver::class)->resolve($user->id) === null, 'Revoked identity resolved');
$reject(fn () => $run('004_mne_compatibility_down.sql'), 'P0001');
$check($db->table('mne.existing_businesses')->count() === 0, 'Backfill copied business data');
$check($db->table('mne.parties')->count() === 1, 'Backfill invented identities');
$db->statement('SAVEPOINT schema_drift');
$db->statement('ALTER TABLE mne.business_links ADD COLUMN unexpected text');
$reject(fn () => $run('004_mne_compatibility.sql'), 'P0001');
$db->statement('ROLLBACK TO SAVEPOINT schema_drift');
$db->table('public.sectors')->where('id', $sector)->update(['slug' => 'changed-compatibility']);
$check(app(SectorResolver::class)->resolve($sector) === null, 'Stale code resolved');
$backfill->run(2);
$check($db->table('mne.sector_links')->where('sector_id', $sector)->value('status') === 'pending', 'Changed source not invalidated');
$run('005_mne_compatibility_guards.sql');
$run('005_mne_compatibility_guards.sql');
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')
->update(['enabled' => true, 'evidence_reference' => 'Synthetic activation attempt']), '23514');
$reject(fn () => $db->table('mne.compatibility_controls')->where('capability', 'canonical_read')->update(['percentage' => 1]), '23514');
$run('005_mne_compatibility_guards_down.sql');
$run('005_mne_compatibility_guards.sql');
// Transactional test role: cannot own objects, create schemas, or disable protections.
$role = 'mne_runtime_test_'.getmypid();
$db->statement('CREATE ROLE '.$role.' NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS');
$db->statement('GRANT USAGE ON SCHEMA mne TO '.$role);
$db->statement('GRANT SELECT, INSERT ON mne.audit_events, mne.business_status_history, mne.document_versions, mne.negotiation_events TO '.$role);
$db->statement('SET LOCAL ROLE '.$role);
$check(! $db->selectOne('SELECT rolsuper FROM pg_roles WHERE rolname=current_user')->rolsuper, 'Runtime test is superuser');
$db->statement("INSERT INTO mne.audit_events(action,subject_type,subject_id,correlation_id) VALUES ('synthetic','test',gen_random_uuid(),gen_random_uuid())");
foreach (['audit_events', 'business_status_history', 'document_versions', 'negotiation_events'] as $table) {
foreach (["UPDATE mne.$table SET id=id", "DELETE FROM mne.$table", "TRUNCATE mne.$table"] as $sql) {
$reject(fn () => $db->statement($sql), '42501');
}
}
$reject(fn () => $db->statement('ALTER TABLE mne.audit_events DISABLE TRIGGER ALL'), '42501');
$reject(fn () => $db->statement('CREATE TABLE mne.forbidden_runtime_table(id integer)'), '42501');
$db->statement('RESET ROLE');
echo json_encode(['passed' => true, 'dry_run_rolled_back' => true, '005_repeated_and_reversed' => true, 'canonical_activation_rejected' => true, 'up_twice' => true, 'down_reapply' => true, 'backfill_replay' => true, 'uniqueness_fk_activation_down_guards' => true, 'counts' => $first]).PHP_EOL;
} finally {
$db->rollBack();
}
+78
View File
@@ -0,0 +1,78 @@
<?php
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$db = Illuminate\Support\Facades\DB::connection();
if (! $app->environment('testing') || $db->getDatabaseName() !== 'portal_services_test' || (string) $db->getConfig('port') !== '55441') {
throw new RuntimeException('Isolated QA required');
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA schema mne must be absent');
}
$run = function (string $file) use ($db): void {
$sql = file_get_contents(base_path('migrations/'.$file));
$sql = preg_replace('/^BEGIN;\s*$/m', '', $sql);
$db->unprepared(preg_replace('/COMMIT;\s*$/', '', $sql));
};
$indexes = fn () => (int) $db->selectOne("SELECT count(*) AS n FROM pg_indexes WHERE schemaname='mne'")->n;
$db->beginTransaction();
try {
$run('001_mne_schema.sql');
$run('002_mne_existing_territories.sql');
$before = $indexes();
$run('003_mne_integrity.sql');
$after = $indexes();
$missing = $db->selectOne(<<<'SQL'
SELECT count(*) AS n FROM pg_constraint c
JOIN pg_namespace n ON n.oid=c.connamespace
WHERE n.nspname='mne' AND c.contype='f' AND NOT EXISTS (
SELECT 1 FROM pg_index i JOIN pg_class ix ON ix.oid=i.indexrelid
JOIN pg_am am ON am.oid=ix.relam
WHERE i.indrelid=c.conrelid AND i.indisvalid AND i.indisready
AND i.indpred IS NULL AND am.amname='btree' AND i.indnkeyatts >= cardinality(c.conkey)
AND ARRAY(SELECT x FROM unnest(i.indkey::smallint[]) WITH ORDINALITY u(x,p)
WHERE p <= cardinality(c.conkey) ORDER BY p)=c.conkey
)
SQL);
if ((int) $missing->n !== 0) {
throw new RuntimeException('Foreign keys without index coverage');
}
$run('003_mne_integrity.sql');
if ($indexes() !== $after) {
throw new RuntimeException('Duplicate indexes');
}
$db->insert("INSERT INTO mne.audit_events(action,subject_type,subject_id,correlation_id) VALUES ('qa','synthetic',gen_random_uuid(),gen_random_uuid())");
$rejections = 0;
foreach (['audit_events', 'business_status_history', 'document_versions', 'negotiation_events'] as $table) {
foreach (["UPDATE mne.$table SET id=id", "DELETE FROM mne.$table", "TRUNCATE mne.$table"] as $sql) {
$db->statement('SAVEPOINT mutation');
$rejected = false;
try {
$db->unprepared($sql);
} catch (Illuminate\Database\QueryException $e) {
$rejected = $e->getCode() === '23514';
}
$db->statement('ROLLBACK TO SAVEPOINT mutation');
if (! $rejected) {
throw new RuntimeException('Mutation was not rejected by evidence guard');
}
$rejections++;
}
}
$run('003_mne_integrity_down.sql');
if ($indexes() !== $before) {
throw new RuntimeException('DOWN changed original indexes');
}
$db->delete('DELETE FROM mne.audit_events');
$run('003_mne_integrity.sql');
if ($indexes() !== $after) {
throw new RuntimeException('Reapply failed');
}
echo json_encode(['passed' => true, 'original_indexes' => $before, 'added_indexes' => $after - $before, 'mutation_rejections' => $rejections, 'idempotency_and_down' => true]).PHP_EOL;
} finally {
$db->rollBack();
}
if ($db->selectOne("SELECT count(*) AS n FROM pg_namespace WHERE nspname='mne'")->n) {
throw new RuntimeException('QA cleanup failed');
}
+98
View File
@@ -0,0 +1,98 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$config = DB::connection()->getConfig();
if (! app()->environment('testing') || $config['database'] !== 'portal_services_test' || (string) $config['port'] !== '55441' || ! in_array($config['host'], ['127.0.0.1', 'localhost'], true)) {
throw new RuntimeException('Requires isolated portal_services_test on localhost:55441.');
}
$migration = require database_path('migrations/2026_09_30_110000_project_dependency_integrity.php');
$assertions = 0;
$check = function (bool $condition) use (&$assertions): void {
if (! $condition) {
throw new RuntimeException('Dependency integrity assertion failed: '.($assertions + 1));
}
$assertions++;
};
$ids = array_map(fn () => (string) Str::uuid(), range(1, 3));
$link = fn (int $a, int $b, string $kind = 'dependency'): array => ['anchor_project_id' => $ids[$a], 'complementary_project_id' => $ids[$b], 'relation_type' => $kind];
$reject = function (callable $write) use ($check): void {
try {
$write();
} catch (QueryException $e) {
$check($e->getCode() === '23514');
return;
}
throw new RuntimeException('Cycle was accepted');
};
try {
foreach ($ids as $id) {
DB::table('projects')->insert(['id' => $id, 'code' => 'test-dependency-'.$id, 'name' => 'Synthetic dependency test', 'status' => 'draft']);
}
DB::table('complementary_project_links')->insert($link(0, 1));
$reject(fn () => DB::table('complementary_project_links')->insert($link(1, 0)));
DB::table('complementary_project_links')->insert($link(1, 2));
$reject(fn () => DB::table('complementary_project_links')->insert($link(2, 0)));
$reject(fn () => DB::table('complementary_project_links')->insert($link(0, 0)));
// Non-dependency relations may form a network; do not impose DAG semantics on them.
DB::table('complementary_project_links')->insert($link(2, 0, 'supplier'));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[2])->update(['relation_type' => 'dependency']));
DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => now()]);
DB::table('complementary_project_links')->insert($link(2, 0));
$reject(fn () => DB::table('complementary_project_links')->where('anchor_project_id', $ids[1])->update(['deleted_at' => null]));
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Two real sessions: B→A waits for A→B, then must reject the resulting cycle.
$parts = [];
foreach (['host' => 'host', 'port' => 'port', 'dbname' => 'database', 'user' => 'username', 'password' => 'password'] as $key => $name) {
$parts[] = $key."='".str_replace(['\\', "'"], ['\\\\', "\\'"], (string) ($config[$name] ?? ''))."'";
}
$second = pg_connect(implode(' ', $parts), PGSQL_CONNECT_FORCE_NEW);
if ($second === false) {
throw new RuntimeException('Cannot connect second isolated session');
}
pg_query($second, "SET statement_timeout='10s'");
DB::beginTransaction();
DB::table('complementary_project_links')->insert($link(0, 1));
pg_send_query_params($second, "INSERT INTO public.complementary_project_links(anchor_project_id,complementary_project_id,relation_type) VALUES($1,$2,'dependency')", [$ids[1], $ids[0]]);
// Confirm the second statement is waiting on our advisory lock, not merely unscheduled.
$waiting = false;
for ($attempt = 0; $attempt < 100; $attempt++) {
$state = DB::selectOne('SELECT wait_event FROM pg_stat_activity WHERE pid=?', [pg_get_pid($second)]);
if (($state->wait_event ?? null) === 'advisory') {
$waiting = true;
break;
}
usleep(20000);
DB::statement('SELECT pg_stat_clear_snapshot()');
}
$check($waiting);
DB::commit();
$result = pg_get_result($second);
$check($result !== false && pg_result_error_field($result, PGSQL_DIAG_SQLSTATE) === '23514');
pg_close($second);
$check(DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->count() === 1);
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
// Non-destructive DOWN/UP: schema reversibility without dropping any master or row.
DB::transaction(function () use ($migration, $check): void {
$migration->down();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id === null);
$migration->up();
$check(DB::selectOne("SELECT to_regprocedure('public.check_project_dependency_graph()') AS id")->id !== null);
});
echo json_encode(['passed' => true, 'assertions' => $assertions, 'two_session_cycle_rejected' => true, 'rollback_reapplication' => true], JSON_PRETTY_PRINT).PHP_EOL;
} finally {
while (DB::transactionLevel() > 0) {
DB::rollBack();
}
DB::table('complementary_project_links')->whereIn('anchor_project_id', $ids)->delete();
DB::table('projects')->whereIn('id', $ids)->delete();
}
+37
View File
@@ -0,0 +1,37 @@
<?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();
$baseline = isset($argv[1]) ? json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR) : null;
$report = $db->transaction(function () use ($db, $baseline): array {
$db->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$counts = [];
foreach ($db->select("SELECT tablename FROM pg_tables WHERE schemaname='skills_edu' ORDER BY tablename") as $table) {
$counts[$table->tablename] = $db->table('skills_edu.'.$table->tablename)->count();
}
$differences = [];
foreach ($baseline['row_counts'] ?? [] as $table => $before) {
$expected = $before + ($table === 'public.migrations' ? 6 : 0);
$actual = $db->table($table)->count();
if ($actual !== $expected) {
$differences[$table] = ['expected' => $expected, 'actual' => $actual];
}
}
return ['server' => $db->selectOne('SHOW server_version')->server_version,
'baseline_tables_checked' => count($baseline['row_counts'] ?? []), 'baseline_differences' => $differences,
'tables' => $counts, 'table_count' => count($counts),
'foreign_keys' => (int) $db->selectOne("SELECT count(*) AS n FROM pg_constraint c JOIN pg_namespace ns ON ns.oid=c.connamespace WHERE ns.nspname='skills_edu' AND c.contype='f'")->n,
'indexes' => $db->table('pg_indexes')->where('schemaname', 'skills_edu')->count(),
'registered_migrations' => $db->table('migrations')->where('migration', 'like', '2026_09_30_%_skills_education_%')->count(),
'sql_sha256' => array_combine(array_map('basename', glob(database_path('sql/skills-education/*.sql'))), array_map(fn ($file) => hash_file('sha256', $file), glob(database_path('sql/skills-education/*.sql')))),
];
});
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
exit($report['table_count'] === 24 && $report['registered_migrations'] === 6 && $report['baseline_differences'] === [] ? 0 : 1);