make(Kernel::class)->bootstrap(); $owner = DB::connection('pgsql')->getConfig(); unset($owner['name']); if ($owner['host'] !== '127.0.0.1') { throw new LogicException('Controlo de restauro exclusivamente local.'); } $qa = [...$owner, 'url' => null, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']]; config(['database.connections.pilot_apply' => $qa, 'database.connections.pilot_owner' => [...$owner, 'database' => 'postgres', 'url' => null], 'app.env' => 'payable-testing', 'work_context.composition' => 'sgc']); $app->detectEnvironment(fn () => 'payable-testing'); DB::setDefaultConnection('pilot_apply'); $db = DB::connection(); $identity = $db->selectOne('select current_database() as db, current_user as role'); if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== $settings['DB_USERNAME']) { throw new LogicException('Identidade do destino inesperada.'); } $request = Request::create($proposal['environment'].'/administracao/parametrizacao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']); $app->instance('request', $request); $request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user()); function pilotFingerprint($db, array $tables, bool $tenantProjection = false): array { $hashes = []; foreach ($tables as $table) { $quoted = '"'.str_replace('"', '""', $table).'"'; $projection = $tenantProjection && $table === 'tenants' ? "(to_jsonb(t) - ARRAY['rules_provisional','work_context_policy','updated_at'])::text" : 'row_to_json(t)::text'; $rows = $db->select('select '.$projection.' as row from '.$quoted.' t order by '.$projection); $hashes[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR)); } return $hashes; } function pilotPg(string $binary, array $arguments, array $connection): void { $process = new Process([$binary, ...$arguments], null, ['PGHOST' => $connection['host'], 'PGPORT' => (string) $connection['port'], 'PGUSER' => $connection['username'], 'PGPASSWORD' => $connection['password']], timeout: 120); $process->run(); if (! $process->isSuccessful()) { throw new RuntimeException('Backup/restauro de controlo falhou. Nenhuma activação confirmada.'); } } $restore = null; $admin = DB::connection('pilot_owner'); $failure = null; $result = null; try { $result = $db->transaction(function () use ($db, $proposal, $proposalHash, $apply, $qa, $owner, $admin, &$restore) { DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); $actor = User::query()->findOrFail(8); auth()->guard('web')->setUser($actor); app(ConflictGuard::class)->lockCompany($actor); $configuration = app(OrganizationConfiguration::class); $configuration->administrator($actor); $access = app(OrganizationAccess::class); $contexts = app(WorkContext::class); $state = DB::table('organization_access')->where('tenant_id', $actor->tenant_id)->lockForUpdate()->first(); if (($state->active ?? false) || (int) ($state->version ?? 1) !== $proposal['source_version'] || DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->exists() || DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->exists()) { throw new LogicException('A origem mudou; não substituir perfis ou atribuições existentes.'); } $company = Tenant::query()->findOrFail($actor->tenant_id); $expectedRules = OrganizationConfiguration::rules($proposal['current_rules']); foreach (['first_limit_minor', 'second_limit_minor', 'approval_includes_tax'] as $field) { if ($expectedRules[$field] !== $company->$field) { throw new LogicException('As alçadas mudaram desde a proposta.'); } } if ($company->rules_provisional !== $proposal['current_rules']['provisional'] || (string) $company->default_tax_rate !== $proposal['current_rules']['default_tax_rate'] || $contexts->policy($actor)['enabled'] !== $proposal['enabled_contexts'] || User::query()->where('tenant_id', $actor->tenant_id)->count() !== count($proposal['users'])) { throw new LogicException('A configuração ou a lista de avaliação mudou.'); } $before = []; foreach ($proposal['users'] as $person) { $user = User::query()->where('tenant_id', $actor->tenant_id)->findOrFail($person['id']); if ($user->name !== $person['name'] || $user->department_id !== $person['department_id'] || $access->permissions($user) !== $person['current_permissions'] || $contexts->available($user) !== $person['current_contexts'] || $access->canRequest($user) !== $person['current_can_request']) { throw new LogicException('Uma identidade ou competência mudou desde a conferência.'); } $before[$user->id] = ['permissions' => $access->permissions($user), 'contexts' => $contexts->available($user), 'requester' => $access->canRequest($user), 'department_scoped' => $access->departmentScoped($user)]; } if (! $apply) { return ['result' => 'preflight_passed_no_changes', 'users' => count($before), 'source_version' => $proposal['source_version'], 'proposal_sha256' => $proposalHash]; } $tables = array_map(fn ($row) => $row->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename")); $baseline = pilotFingerprint($db, $tables); $protected = array_values(array_diff($tables, ['organization_access', 'organization_profiles', 'organization_memberships', 'audit_logs', 'tenants'])); $tenantBaseline = pilotFingerprint($db, ['tenants'], true); $directory = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-access-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)); if (! mkdir($directory, 0700, true)) { throw new RuntimeException('Não foi possível guardar a cópia de recuperação.'); } $dump = $directory.'/before.dump'; $snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot; $bin = 'C:/Program Files/PostgreSQL/17/bin/'; pilotPg($bin.'pg_dump.exe', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa); $restoreName = 'compras_pilot_restore_'.bin2hex(random_bytes(6)); $admin->statement('CREATE DATABASE "'.$restoreName.'"'); $restore = $restoreName; pilotPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner); config(['database.connections.pilot_restore' => [...$owner, 'database' => $restore, 'url' => null]]); if (pilotFingerprint(DB::connection('pilot_restore'), $tables) !== $baseline) { throw new RuntimeException('O restauro não coincide com a origem. Nenhuma atribuição aplicada.'); } $reason = 'Piloto sintético SGC8005 autorizado pelo proprietário neste chat: resposta «autorizo» à proposta concreta de 02/11/2026.'; $version = $proposal['source_version']; $profiles = []; foreach ($proposal['profiles'] as $profile) { $configuration->execute($actor, 'profile', ['version' => $version++, 'name' => $profile['name'], 'permissions' => $profile['permissions'], 'work_contexts' => $profile['work_contexts']]); $profiles[$profile['key']] = DB::table('organization_profiles')->where('tenant_id', $actor->tenant_id)->where('name', $profile['name'])->sole()->id; } foreach ($proposal['users'] as $person) { $configuration->execute($actor, 'assign', ['version' => $version++, 'user_id' => $person['id'], 'profile_ids' => array_map(fn ($key) => $profiles[$key], $person['profile_keys']), 'maintenance' => false, 'expires_at' => null, 'reason' => $reason]); } $rules = $proposal['proposed_rules']; $configuration->execute($actor, 'rules', ['version' => $version++, 'first_limit' => $rules['first_limit'], 'second_limit' => $rules['second_limit'], 'includes_tax' => $rules['includes_tax'], 'reason' => $reason]); $configuration->execute($actor, 'work_contexts', ['version' => $version++, 'enabled' => $proposal['enabled_contexts'], 'reason' => $reason]); $preview = app(OrganizationActivationPreview::class)->build($actor); $administrators = $configuration->permanentAdministrators($actor->tenant_id); sort($administrators); if (! $preview['can_activate'] || $preview['losing_access'] !== 0 || $administrators !== $proposal['permanent_administrator_source_ids']) { throw new RuntimeException('A conferência anterior à activação não coincide com a proposta.'); } $configuration->execute($actor, 'activate', ['version' => $version, 'reason' => $reason]); foreach ($proposal['users'] as $person) { $user = User::query()->findOrFail($person['id']); $permissions = $access->permissions($user); $expected = $person['planned_permissions']; sort($permissions); sort($expected); if ($permissions !== $expected || $contexts->available($user) !== $before[$user->id]['contexts'] || $access->canRequest($user) !== $before[$user->id]['requester'] || $access->departmentScoped($user) !== $before[$user->id]['department_scoped']) { throw new RuntimeException('O acesso efectivo diverge da distribuição autorizada.'); } } if (pilotFingerprint($db, $protected) !== array_intersect_key($baseline, array_flip($protected)) || pilotFingerprint($db, ['tenants'], true) !== $tenantBaseline) { throw new RuntimeException('Foi detectada alteração fora do âmbito autorizado; transacção cancelada.'); } return ['result' => 'applied', 'checked_at' => gmdate('c'), 'environment' => $proposal['environment'], 'proposal_sha256' => $proposalHash, 'source_version' => $proposal['source_version'], 'active_version' => $version + 1, 'users_preserved' => count($before), 'profiles_created' => count($profiles), 'memberships_created' => DB::table('organization_memberships')->where('tenant_id', $actor->tenant_id)->count(), 'administrators_preserved' => $proposal['permanent_administrator_source_ids'], 'backup_file' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restore_verified_tables' => count($tables), 'protected_tables_preserved' => count($protected), 'tenant_other_fields_preserved' => true, 'business_data_preserved' => true, 'payables_specific_grants_preserved' => true, 'new_accounts' => false, 'new_stock_publications_or_policies' => false, 'business_decisions_executed' => false, 'sgf_installation_changed' => false, 'atomic_transaction' => true]; }); } catch (Throwable $error) { $failure = $error; fwrite(STDERR, 'Operação não concluída ('.get_class($error).'). '.$error->getMessage().PHP_EOL); } finally { if ($restore !== null && preg_match('/^compras_pilot_restore_[a-f0-9]{12}$/D', $restore) && $restore !== $qa['database']) { DB::purge('pilot_restore'); $admin->select('select pg_terminate_backend(pid) from pg_stat_activity where datname = ? and pid <> pg_backend_pid()', [$restore]); $admin->statement('DROP DATABASE "'.$restore.'"'); } } if ($failure !== null) { exit(1); } if ($apply) { file_put_contents($root.'/docs/proofs/pilot-access-activation.json', json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); } echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;