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