diff --git a/app/Console/Commands/BackfillMneCompatibility.php b/app/Console/Commands/BackfillMneCompatibility.php new file mode 100644 index 00000000..1f923a41 --- /dev/null +++ b/app/Console/Commands/BackfillMneCompatibility.php @@ -0,0 +1,37 @@ +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; + } +} diff --git a/app/Console/Commands/ReconcileMneCompatibility.php b/app/Console/Commands/ReconcileMneCompatibility.php new file mode 100644 index 00000000..d3589718 --- /dev/null +++ b/app/Console/Commands/ReconcileMneCompatibility.php @@ -0,0 +1,48 @@ + ['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; + } +} diff --git a/app/Http/Controllers/ExistingBusinessController.php b/app/Http/Controllers/ExistingBusinessController.php index 4a738bd6..39e345b3 100644 --- a/app/Http/Controllers/ExistingBusinessController.php +++ b/app/Http/Controllers/ExistingBusinessController.php @@ -4,10 +4,14 @@ namespace App\Http\Controllers; use App\Models\ExistingBusiness; use App\Models\ExistingBusinessObject; +use App\Models\Sector; +use App\Services\BusinessFormSchema; use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessObjects; use App\Services\ExistingBusinessVerification; +use App\Services\Mne\BusinessRepository; +use App\Services\Mne\CompatibilityShadow; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; @@ -21,7 +25,7 @@ class ExistingBusinessController extends Controller public function index(Request $request): Response|JsonResponse { $management = $request->is('management/*', 'api/v1/existing-business/review*'); - $query = ExistingBusiness::query(); + $query = app(BusinessRepository::class)->query(); if ($management) { Gate::authorize('reviewQueue', ExistingBusiness::class); $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 { - $business = ExistingBusiness::findOrFail($id); + $business = app(BusinessRepository::class)->find($id); 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), 'modalities' => ExistingBusinessDrafts::MODALITIES, 'objectKinds' => ExistingBusinessObjects::KINDS, @@ -63,8 +68,8 @@ class ExistingBusinessController extends Controller public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse { $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'], - 'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean']]); - $business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false)); + '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), $data['confirmation_code'] ?? null); return response()->json($this->record($business)); } @@ -79,12 +84,12 @@ class ExistingBusinessController extends Controller $saved = $objects->save($request->user(), $id, $request->all(), $object); 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 */ 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]; } } diff --git a/app/Jobs/CompareMneBusiness.php b/app/Jobs/CompareMneBusiness.php new file mode 100644 index 00000000..cc9cc669 --- /dev/null +++ b/app/Jobs/CompareMneBusiness.php @@ -0,0 +1,33 @@ +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]); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c0d3eca6..32ef4a2d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ namespace App\Providers; use App\Models\Opportunity; use App\Models\Sector; use App\Models\User; +use App\Services\Mne\BusinessRepository; +use App\Services\Mne\CompatibleBusinessRepository; use Carbon\CarbonImmutable; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -23,7 +25,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->bind(BusinessRepository::class, CompatibleBusinessRepository::class); } /** diff --git a/app/Services/BusinessFormSchema.php b/app/Services/BusinessFormSchema.php new file mode 100644 index 00000000..92a3536f --- /dev/null +++ b/app/Services/BusinessFormSchema.php @@ -0,0 +1,80 @@ +}> */ + 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> */ + 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 $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(); + } +} diff --git a/app/Services/ExistingBusinessDrafts.php b/app/Services/ExistingBusinessDrafts.php index 1ddba478..8c06ec62 100644 --- a/app/Services/ExistingBusinessDrafts.php +++ b/app/Services/ExistingBusinessDrafts.php @@ -5,11 +5,13 @@ namespace App\Services; use App\Models\ExistingBusiness; use App\Models\ExistingBusinessObject; use App\Models\User; +use App\Services\Mne\BusinessRepository; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; final class ExistingBusinessDrafts { @@ -20,20 +22,25 @@ final class ExistingBusinessDrafts { $data = Validator::make($input, [ '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'], 'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'], '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.description' => ['nullable', 'string', 'max:10000'], 'payload.declared_reason' => ['nullable', 'string', 'max:2000'], 'payload.source_reference' => ['nullable', 'string', 'max:2000'], 'payload.valid_until' => ['nullable', 'date_format:Y-m-d'], + ...BusinessFormSchema::rules(), ])->validate(); return DB::transaction(function () use ($actor, $id, $data) { 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); abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.'); $business->version_no++; @@ -49,6 +56,9 @@ final class ExistingBusinessDrafts $business->status = 'draft'; } $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(); $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(); 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); abort_unless($business->version_no === $version, 409); $business->version_no++; diff --git a/app/Services/ExistingBusinessIntake.php b/app/Services/ExistingBusinessIntake.php index bec5dbe8..00cac2f0 100644 --- a/app/Services/ExistingBusinessIntake.php +++ b/app/Services/ExistingBusinessIntake.php @@ -3,30 +3,48 @@ namespace App\Services; use App\Models\ExistingBusiness; +use App\Models\ExistingBusinessObject; use App\Models\User; +use App\Services\Mne\BusinessRepository; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; 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'), [ 'action' => ['required', 'in:submitted,in_review,needs_information,withdrawn'], 'note' => ['required', 'string', 'min:10', 'max:2000'], ])->validate(); - return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent) { - $business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); + return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent, $confirmationCode) { + $business = app(BusinessRepository::class)->find($id, true); Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business); abort_unless($business->version_no === $version, 409, 'Versão desactualizada.'); $allowed = ['submitted' => ['draft', 'needs_information'], 'in_review' => ['submitted'], '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.'); 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], [ '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'], diff --git a/app/Services/ExistingBusinessObjects.php b/app/Services/ExistingBusinessObjects.php index e5170c80..ca70358a 100644 --- a/app/Services/ExistingBusinessObjects.php +++ b/app/Services/ExistingBusinessObjects.php @@ -2,9 +2,9 @@ namespace App\Services; -use App\Models\ExistingBusiness; use App\Models\ExistingBusinessObject; use App\Models\User; +use App\Services\Mne\BusinessRepository; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -58,7 +58,7 @@ final class ExistingBusinessObjects Validator::make($data['payload'], $rules)->validate(); 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); 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.'); diff --git a/app/Services/ExistingBusinessVerification.php b/app/Services/ExistingBusinessVerification.php index 43fd1263..0f44887d 100644 --- a/app/Services/ExistingBusinessVerification.php +++ b/app/Services/ExistingBusinessVerification.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\ExistingBusiness; use App\Models\ExistingBusinessObject; use App\Models\User; +use App\Services\Mne\BusinessRepository; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; @@ -34,7 +35,7 @@ final class ExistingBusinessVerification ])->validate(); 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); 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'])) { diff --git a/app/Services/Mne/BusinessRepository.php b/app/Services/Mne/BusinessRepository.php new file mode 100644 index 00000000..4dec2d2a --- /dev/null +++ b/app/Services/Mne/BusinessRepository.php @@ -0,0 +1,14 @@ + */ + public function query(): Builder; + + public function find(string $id, bool $forUpdate = false): ExistingBusiness; +} diff --git a/app/Services/Mne/CompatibilityBackfill.php b/app/Services/Mne/CompatibilityBackfill.php new file mode 100644 index 00000000..015f2da4 --- /dev/null +++ b/app/Services/Mne/CompatibilityBackfill.php @@ -0,0 +1,73 @@ + */ + 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; + } +} diff --git a/app/Services/Mne/CompatibilityShadow.php b/app/Services/Mne/CompatibilityShadow.php new file mode 100644 index 00000000..a9e04f44 --- /dev/null +++ b/app/Services/Mne/CompatibilityShadow.php @@ -0,0 +1,33 @@ + 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']); + } + } +} diff --git a/app/Services/Mne/CompatibleBusinessRepository.php b/app/Services/Mne/CompatibleBusinessRepository.php new file mode 100644 index 00000000..a099da06 --- /dev/null +++ b/app/Services/Mne/CompatibleBusinessRepository.php @@ -0,0 +1,23 @@ + */ + 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); + } +} diff --git a/app/Services/Mne/IdentityResolver.php b/app/Services/Mne/IdentityResolver.php new file mode 100644 index 00000000..4e185db4 --- /dev/null +++ b/app/Services/Mne/IdentityResolver.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/app/Services/Mne/LegacyBusinessRepository.php b/app/Services/Mne/LegacyBusinessRepository.php new file mode 100644 index 00000000..3e060dc8 --- /dev/null +++ b/app/Services/Mne/LegacyBusinessRepository.php @@ -0,0 +1,25 @@ + */ + 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(); + } +} diff --git a/app/Services/Mne/MneBusinessRepository.php b/app/Services/Mne/MneBusinessRepository.php new file mode 100644 index 00000000..12fbb7c9 --- /dev/null +++ b/app/Services/Mne/MneBusinessRepository.php @@ -0,0 +1,25 @@ +|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; + } +} diff --git a/app/Services/Mne/SectorResolver.php b/app/Services/Mne/SectorResolver.php new file mode 100644 index 00000000..04142122 --- /dev/null +++ b/app/Services/Mne/SectorResolver.php @@ -0,0 +1,22 @@ +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; + } +} diff --git a/app/Support/SkillsEducationMigration.php b/app/Support/SkillsEducationMigration.php new file mode 100644 index 00000000..5c634ebb --- /dev/null +++ b/app/Support/SkillsEducationMigration.php @@ -0,0 +1,108 @@ + */ + 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'); + } + } +} diff --git a/config/existing-business.php b/config/existing-business.php index e1fe211c..073a1474 100644 --- a/config/existing-business.php +++ b/config/existing-business.php @@ -1,6 +1,8 @@ (bool) env('INVESTMOZ_MNE_COMPATIBILITY_SHADOW', false), + 'shadow_queue' => 'database', 'enabled' => (bool) env('INVESTMOZ_MNE_ENABLED', false), // 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', '')))))), diff --git a/database/migrations/2026_09_29_200000_existing_business_form_sector.php b/database/migrations/2026_09_29_200000_existing_business_form_sector.php new file mode 100644 index 00000000..d745f5b7 --- /dev/null +++ b/database/migrations/2026_09_29_200000_existing_business_form_sector.php @@ -0,0 +1,29 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_09_30_100100_skills_education_1.php b/database/migrations/2026_09_30_100100_skills_education_1.php new file mode 100644 index 00000000..aa1a2fd8 --- /dev/null +++ b/database/migrations/2026_09_30_100100_skills_education_1.php @@ -0,0 +1,10 @@ + '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); + } +}; diff --git a/database/sql/skills-education/01.sql b/database/sql/skills-education/01.sql new file mode 100644 index 00000000..de007629 --- /dev/null +++ b/database/sql/skills-education/01.sql @@ -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; diff --git a/database/sql/skills-education/02.sql b/database/sql/skills-education/02.sql new file mode 100644 index 00000000..995763b7 --- /dev/null +++ b/database/sql/skills-education/02.sql @@ -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; diff --git a/database/sql/skills-education/03.sql b/database/sql/skills-education/03.sql new file mode 100644 index 00000000..aad4b8aa --- /dev/null +++ b/database/sql/skills-education/03.sql @@ -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(); diff --git a/database/sql/skills-education/04.sql b/database/sql/skills-education/04.sql new file mode 100644 index 00000000..bc97e1df --- /dev/null +++ b/database/sql/skills-education/04.sql @@ -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; diff --git a/database/sql/skills-education/05.sql b/database/sql/skills-education/05.sql new file mode 100644 index 00000000..c47a819e --- /dev/null +++ b/database/sql/skills-education/05.sql @@ -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; diff --git a/database/sql/skills-education/06.sql b/database/sql/skills-education/06.sql new file mode 100644 index 00000000..0f463e0e --- /dev/null +++ b/database/sql/skills-education/06.sql @@ -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; diff --git a/docs/investmoz/education/skills-education-schema.md b/docs/investmoz/education/skills-education-schema.md new file mode 100644 index 00000000..c866e338 --- /dev/null +++ b/docs/investmoz/education/skills-education-schema.md @@ -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. diff --git a/docs/investmoz/education/skills-functional-diagnosis.md b/docs/investmoz/education/skills-functional-diagnosis.md new file mode 100644 index 00000000..f2573e1c --- /dev/null +++ b/docs/investmoz/education/skills-functional-diagnosis.md @@ -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. diff --git a/docs/investmoz/existing-business/annex-de-execution.md b/docs/investmoz/existing-business/annex-de-execution.md new file mode 100644 index 00000000..8d38cb00 --- /dev/null +++ b/docs/investmoz/existing-business/annex-de-execution.md @@ -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. diff --git a/docs/investmoz/existing-business/canonical-integrity.md b/docs/investmoz/existing-business/canonical-integrity.md new file mode 100644 index 00000000..f2917a9f --- /dev/null +++ b/docs/investmoz/existing-business/canonical-integrity.md @@ -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. diff --git a/docs/investmoz/existing-business/compatibility.md b/docs/investmoz/existing-business/compatibility.md new file mode 100644 index 00000000..ebbf157b --- /dev/null +++ b/docs/investmoz/existing-business/compatibility.md @@ -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. diff --git a/docs/investmoz/existing-business/form-schema.json b/docs/investmoz/existing-business/form-schema.json new file mode 100644 index 00000000..6f24eff6 --- /dev/null +++ b/docs/investmoz/existing-business/form-schema.json @@ -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 + } +} diff --git a/docs/investmoz/existing-business/implementation-status.md b/docs/investmoz/existing-business/implementation-status.md index 500a74f3..42f59546 100644 --- a/docs/investmoz/existing-business/implementation-status.md +++ b/docs/investmoz/existing-business/implementation-status.md @@ -1,5 +1,13 @@ # 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. 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. diff --git a/docs/investmoz/existing-business/openapi.json b/docs/investmoz/existing-business/openapi.json index 2da38d39..8af9e82c 100644 --- a/docs/investmoz/existing-business/openapi.json +++ b/docs/investmoz/existing-business/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "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." }, "servers": [ @@ -165,37 +165,241 @@ "properties": { "title": { "type": "string", - "maxLength": 200 + "maxLength": 200, + "description": "Proposed title" }, "description": { "type": [ "string", "null" ], - "maxLength": 10000 + "maxLength": 10000, + "description": "Private description" }, "declared_reason": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Reason and objective" }, "source_reference": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Source reference" }, "valid_until": { "type": [ "string", "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": { "title": { "type": "string", - "maxLength": 200 + "maxLength": 200, + "description": "Proposed title" }, "description": { "type": [ "string", "null" ], - "maxLength": 10000 + "maxLength": 10000, + "description": "Private description" }, "declared_reason": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Reason and objective" }, "source_reference": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Source reference" }, "valid_until": { "type": [ "string", "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": { "type": "integer", "minimum": 1 + }, + "sector_id": { + "type": [ + "integer", + "null" + ], + "description": "Existing active public.sectors identifier; required on wizard submission." } } } @@ -641,6 +1049,13 @@ "review_consent": { "type": "boolean", "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": { "title": { "type": "string", - "maxLength": 200 + "maxLength": 200, + "description": "Proposed title" }, "description": { "type": [ "string", "null" ], - "maxLength": 10000 + "maxLength": 10000, + "description": "Private description" }, "declared_reason": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Reason and objective" }, "source_reference": { "type": [ "string", "null" ], - "maxLength": 2000 + "maxLength": 2000, + "description": "Source reference" }, "valid_until": { "type": [ "string", "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." } } } diff --git a/docs/investmoz/existing-business/wizard-browser-validation.json b/docs/investmoz/existing-business/wizard-browser-validation.json new file mode 100644 index 00000000..67cc673c --- /dev/null +++ b/docs/investmoz/existing-business/wizard-browser-validation.json @@ -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": [] +} diff --git a/docs/investmoz/existing-business/wizard-implementation.md b/docs/investmoz/existing-business/wizard-implementation.md new file mode 100644 index 00000000..7f774c76 --- /dev/null +++ b/docs/investmoz/existing-business/wizard-implementation.md @@ -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. diff --git a/docs/investmoz/reference-schema-reconciliation.md b/docs/investmoz/reference-schema-reconciliation.md new file mode 100644 index 00000000..cd36f4ea --- /dev/null +++ b/docs/investmoz/reference-schema-reconciliation.md @@ -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. diff --git a/migrations/001_mne_schema.sql b/migrations/001_mne_schema.sql new file mode 100644 index 00000000..c183df47 --- /dev/null +++ b/migrations/001_mne_schema.sql @@ -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; diff --git a/migrations/001_mne_schema_down.sql b/migrations/001_mne_schema_down.sql new file mode 100644 index 00000000..b3ea6cc1 --- /dev/null +++ b/migrations/001_mne_schema_down.sql @@ -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; diff --git a/migrations/002_mne_existing_territories.sql b/migrations/002_mne_existing_territories.sql new file mode 100644 index 00000000..3c2d9106 --- /dev/null +++ b/migrations/002_mne_existing_territories.sql @@ -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; diff --git a/migrations/003_mne_integrity.sql b/migrations/003_mne_integrity.sql new file mode 100644 index 00000000..6eb0f62c --- /dev/null +++ b/migrations/003_mne_integrity.sql @@ -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; diff --git a/migrations/003_mne_integrity_down.sql b/migrations/003_mne_integrity_down.sql new file mode 100644 index 00000000..e391b7d2 --- /dev/null +++ b/migrations/003_mne_integrity_down.sql @@ -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; diff --git a/migrations/004_mne_compatibility.sql b/migrations/004_mne_compatibility.sql new file mode 100644 index 00000000..7cd2af89 --- /dev/null +++ b/migrations/004_mne_compatibility.sql @@ -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; diff --git a/migrations/004_mne_compatibility_down.sql b/migrations/004_mne_compatibility_down.sql new file mode 100644 index 00000000..4ecb645a --- /dev/null +++ b/migrations/004_mne_compatibility_down.sql @@ -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; diff --git a/migrations/005_mne_compatibility_guards.sql b/migrations/005_mne_compatibility_guards.sql new file mode 100644 index 00000000..bb543688 --- /dev/null +++ b/migrations/005_mne_compatibility_guards.sql @@ -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; diff --git a/migrations/005_mne_compatibility_guards_down.sql b/migrations/005_mne_compatibility_guards_down.sql new file mode 100644 index 00000000..ce6104aa --- /dev/null +++ b/migrations/005_mne_compatibility_guards_down.sql @@ -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; diff --git a/resources/js/pages/investment/business-types.ts b/resources/js/pages/investment/business-types.ts index 614e4c67..74920a84 100644 --- a/resources/js/pages/investment/business-types.ts +++ b/resources/js/pages/investment/business-types.ts @@ -5,7 +5,9 @@ export type Business = { status: string; classification: string; version_no: number; + sector_id?: number | null; payload: { + [key: string]: string | undefined; title: string; description?: string; declared_reason?: string; diff --git a/resources/js/pages/investment/business-wizard.tsx b/resources/js/pages/investment/business-wizard.tsx new file mode 100644 index 00000000..c8af469e --- /dev/null +++ b/resources/js/pages/investment/business-wizard.tsx @@ -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; + } +>; +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({ + ...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(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 ( + +
+ +

+ {record.code} · {t("Versão", "Version")} {version} +

+

+ {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.", + )} +

+
    + {steps.map((labels, i) => ( +
  1. + +
  2. + ))} +
+ +

+ {step}/8 — {steps[step - 1][english ? 1 : 0]} +

+
{ + event.preventDefault(); + void save(); + }} + > +
+ {steps[step - 1][english ? 1 : 0]} + {step === 3 && ( + + + + )} + {step === 3 && ( + + + + )} + {Object.entries(formSchema) + .filter( + ([key, field]) => + field.step === step && + (!( + key === "mandate_reference" || key === "mandate_valid_until" + ) || + payload.acting_capacity === "representative"), + ) + .map(([key, field]) => ( + + {field.type === "select" ? ( + + ) : field.type === "textarea" ? ( +