make(Kernel::class)->bootstrap(); $owner = DB::connection('pgsql')->getConfig(); unset($owner['name']); if ($owner['host'] !== '127.0.0.1') { throw new LogicException('Cópia descartável exclusivamente local.'); } $clone = 'compras_receipts_test_'.bin2hex(random_bytes(6)); if ($clone === $settings['DB_DATABASE']) { throw new LogicException('Destino descartável tem de ser distinto da origem.'); } config(['database.connections.pilot_clone_admin' => [...$owner, 'url' => null, 'database' => 'postgres']]); $admin = DB::connection('pilot_clone_admin'); $bin = 'C:/Program Files/PostgreSQL/17/bin/'; // Resolve the installed binaries without embedding credentials in commands or logs. if (! is_file($bin.'pg_dump.exe')) { $candidates = glob('C:/laragon/bin/postgresql/*/bin/pg_dump.exe'); if (count($candidates) !== 1) { throw new LogicException('Instalação PostgreSQL não inequívoca.'); } $bin = dirname($candidates[0]).'/'; } $privateRoot = 'C:/laragon/data/sgf-tempo/proofs'; if (! is_dir($privateRoot)) { mkdir($privateRoot, 0700, true); } $dump = $privateRoot.'/replenishment-dry-run-'.bin2hex(random_bytes(6)).'.dump'; $runPg = function (string $binary, array $arguments, array $connection) { $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('Cópia/restauro descartável não concluído.'); } }; $created = false; $proof = null; $failure = null; try { $qa = [...$owner, 'host' => $settings['DB_HOST'], 'port' => $settings['DB_PORT'] ?? '5432', 'database' => $settings['DB_DATABASE'], 'username' => $settings['DB_USERNAME'], 'password' => $settings['DB_PASSWORD']]; $runPg($bin.'pg_dump.exe', ['--format=custom', '--file='.$dump, '--dbname='.$qa['database']], $qa); $admin->statement('CREATE DATABASE "'.$clone.'"'); $created = true; $runPg($bin.'pg_restore.exe', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$clone, $dump], $owner); config(['database.connections.pilot_clone' => [...$owner, 'url' => null, 'database' => $clone], 'work_context.composition' => 'sgc', 'app.env' => 'testing']); DB::setDefaultConnection('pilot_clone'); $app->detectEnvironment(fn () => 'testing'); // Reproduce only the existing evaluation entitlement fallback in this restored copy. // It is not a persisted customer contract or a new permission grant. $accessProposal = json_decode(file_get_contents($root.'/docs/proofs/pilot-access-proposal.json'), true, 64, JSON_THROW_ON_ERROR); config(['work_context.entitlements.'.$proposal['tenant_id'] => $accessProposal['enabled_contexts']]); if (DB::connection()->getDatabaseName() !== $clone || ! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $clone)) { throw new LogicException('Comandos proibidos fora da cópia descartável.'); } $request = Request::create($proposal['environment'].'/stock/reposicao', 'GET', server: ['REMOTE_ADDR' => '127.0.0.1']); $request->setLaravelSession($app['session']->driver('array')); $app->instance('request', $request); $request->setUserResolver(fn ($guard = null) => auth()->guard($guard)->user()); $check = function (bool $value, string $message) { if (! $value) { throw new LogicException($message); } }; $actor = function (string $task) use ($proposal, $request) { $user = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($proposal['actors'][$task]); auth()->guard('web')->setUser($user); $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); return $user; }; if (in_array('--evaluations', $argv, true)) { $nextFile = $root.'/docs/proofs/pilot-evaluation-proposal.json'; $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization', 'Proposta sintética de avaliação inválida.'); $preparer = $actor('prepare'); $purchase = Requisition::query()->findOrFail($next['purchase_id']); $check($purchase->status === 'approved' && $purchase->number === $next['purchase_number'], 'Compra diferente da proposta.'); $quotes = Quotation::query()->where('requisition_id', $purchase->id)->orderBy('id')->get(); $check($quotes->pluck('id')->all() === $next['quotation_ids'] && $quotes->sum(fn ($quote) => $quote->evaluations()->count()) === 0, 'Propostas ou avaliações já alteradas.'); $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'suppliers', 'quotations', 'quotation_items', 'purchase_orders', 'stock_movements', 'requisitions', 'requisition_items', 'budget_entries', 'purchase_awards']; $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); $baseline = $fingerprint(); $duplicateRefused = false; foreach ($next['evaluators'] as $id) { $evaluator = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($id); auth()->guard('web')->setUser($evaluator); $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $evaluator->id, 'tenant_id' => $evaluator->tenant_id]); foreach ($quotes as $quote) { $input = Request::create($proposal['environment'].'/cotacoes/'.$quote->id.'/avaliar', 'POST', $next['grades_for_each_quotation_and_each_evaluator']); $input->setUserResolver(fn () => $evaluator); app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class)); if (! $duplicateRefused) { try { app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class)); } catch (ValidationException|HttpException) { $duplicateRefused = true; } $check($duplicateRefused, 'Uma segunda avaliação da mesma pessoa não pode duplicar o registo.'); } } } $preparer = $actor('prepare'); $comparison = app(ProposalComparison::class)->inspect($purchase); $check($comparison['ready'] && $comparison['policy']['minimum_evaluators'] === $next['expected_minimum_qualified_evaluators_per_quotation'], 'Comparação não ficou pronta segundo a política existente.'); $check($quotes->sum(fn ($quote) => $quote->evaluations()->count()) === $next['expected_evaluations'] && $fingerprint() === $baseline, 'Resultados fora do âmbito de avaliação.'); $check(collect(app(WorkflowTaskInbox::class)->build($preparer, 'procurement'))->contains('id', 'quotation:'.$purchase->id.':result'), 'Comparação sem encaminhamento para conferência humana.'); $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'evaluations_in_copy' => 6, 'evaluators' => $next['evaluators'], 'duplicate_evaluation_refused' => $duplicateRefused, 'comparison_ready' => true, 'human_comparison_review_task' => true, 'supplier_selection_award_or_order' => false, 'protected_tables_unchanged' => $protected, 'outbound_communication' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; } elseif (in_array('--market', $argv, true)) { $nextFile = $root.'/docs/proofs/pilot-market-proposal.json'; $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization', 'Proposta sintética de consulta inválida.'); require_once __DIR__.'/support/pilot-market-scenario.php'; $scenario = pilotMarketScenario($app, $proposal, $next, $actor, $check); $protected = $scenario['protected_tables']; $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'new_synthetic_suppliers_in_copy' => 2, 'supplier_preparation_and_review_independent' => true, 'quotations_in_copy' => 3, 'totals_minor_mzn' => [2320, 2552, 2784], 'evaluations' => 0, 'supplier_selection_award_or_order' => false, 'protected_tables_unchanged' => $protected, 'outbound_communication' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false]; } elseif (in_array('--approval', $argv, true)) { $nextFile = $root.'/docs/proofs/pilot-approval-proposal.json'; $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization' && $next['action'] === 'approve', 'Proposta de aprovação inválida.'); $user = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($next['approver_id']); auth()->guard('web')->setUser($user); $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); $purchase = Requisition::query()->findOrFail($next['purchase_id']); $check($purchase->status === 'pending' && $purchase->number === $next['purchase_number'] && $purchase->canDecide($user) && $purchase->items()->sole()->quantity === $next['quantity'] && $purchase->total_minor === $next['total_minor_mzn'], 'Compra ou competência diferente da proposta.'); $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'purchase_orders', 'stock_movements', 'requisition_items', 'budget_entries']; $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); $baseline = $fingerprint(); $count = Requisition::query()->count(); $key = (string) Str::uuid(); $workflow = app(RequisitionWorkflow::class); $workflow->decide($user, $purchase, 'approved', $next['decision_comment'], $key); $workflow->decide($user, $purchase->fresh(), 'approved', $next['decision_comment'], $key); $purchase->refresh(); $check($purchase->status === 'approved' && $purchase->steps()->count() === 1 && $purchase->steps()->sole()->approver_id === $user->id, 'Resultado diferente da etapa de aprovação prevista.'); $check($fingerprint() === $baseline && Requisition::query()->count() === $count, 'A decisão alterou artigos, stock, contas ou ordens.'); $reservation = DB::table('budget_entries')->where('requisition_id', $purchase->id)->sole(); $check($reservation->kind === 'reserve' && (int) $reservation->reserved_minor === $next['total_minor_mzn'] && (int) $reservation->committed_minor === 0, 'A aprovação da requisição deve preservar a reserva existente, sem compromisso de encomenda.'); $procurement = $actor('prepare'); $tasks = app(WorkflowTaskInbox::class)->build($procurement, 'procurement'); $check(collect($tasks)->contains('id', 'quotation:'.$purchase->id.':prepare'), 'Compra aprovada sem encaminhamento para consulta ao mercado.'); $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'purchase_id' => $purchase->id, 'purchase_number' => $purchase->number, 'new_status_in_copy' => $purchase->status, 'approved_by_in_copy' => $user->id, 'budget_reserved_minor' => (int) $reservation->reserved_minor, 'budget_committed_minor' => 0, 'market_consultation_task_for_procurement' => true, 'same_command_repeated_without_duplication' => true, 'items_values_stock_orders_access_preserved' => true, 'sgf_installation_changed' => false]; } elseif (in_array('--submission', $argv, true)) { $nextFile = $root.'/docs/proofs/pilot-submission-proposal.json'; $next = json_decode(file_get_contents($nextFile), true, 32, JSON_THROW_ON_ERROR); $check($next['environment'] === $proposal['environment'] && $next['status'] === 'pending_owner_authorization' && $next['action'] === 'submit', 'Proposta de submissão inválida.'); $requester = $actor('requester'); $purchase = Requisition::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($next['purchase_id']); $check($purchase->number === $next['purchase_number'] && $purchase->requester_id === $next['requester_id'] && $purchase->status === 'draft' && $purchase->items()->sole()->quantity === $next['quantity'] && $purchase->total_minor === $next['total_minor_mzn'] && $purchase->steps()->count() === 0, 'Rascunho diferente da proposta de submissão.'); $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'purchase_orders', 'stock_movements']; $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); $baseline = $fingerprint(); $count = Requisition::query()->count(); $itemsBefore = $purchase->items()->get()->toArray(); $migration = 'database/migrations/2026_11_01_000046_add_requisition_command_versions.php'; $installedInCopy = false; if (! Schema::hasTable('requisition_commands')) { $check(! Schema::hasColumn('requisitions', 'process_version'), 'Instalação parcial inesperada.'); $check(Artisan::call('migrate', ['--database' => 'pilot_clone', '--path' => $migration, '--force' => true]) === 0, 'Actualização de recibos de comandos falhou na cópia.'); $installedInCopy = true; $purchase->refresh(); } $key = (string) Str::uuid(); $workflow = app(RequisitionWorkflow::class); $workflow->submit($requester, $purchase, $key); $purchase->refresh(); $stepsBefore = $purchase->steps()->get()->toArray(); $workflow->submit($requester, $purchase, $key); $purchase->refresh(); $check($purchase->status === 'pending' && $purchase->steps()->count() > 0 && $purchase->steps()->where('decision', '!=', 'pending')->count() === 0, 'As aprovações têm de permanecer humanas e pendentes.'); $check($purchase->steps()->get()->toArray() === $stepsBefore && Requisition::query()->count() === $count, 'A repetição não pode duplicar passos ou compras.'); $check($purchase->items()->get()->toArray() === $itemsBefore && $purchase->total_minor === $next['total_minor_mzn'] && $fingerprint() === $baseline, 'A submissão alterou dados fora do âmbito.'); $routing = app(ApprovalRouting::class)->current($purchase, $requester); $check($routing['status'] === 'available', 'A etapa não tem um responsável elegível.'); $eligible = []; foreach ($routing['people'] as $person) { $user = User::query()->findOrFail($person['id']); auth()->guard('web')->setUser($user); $request->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $user->id, 'tenant_id' => $user->tenant_id]); $check(collect(app(WorkflowTaskInbox::class)->build($user, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':'.$routing['step_id']), 'A etapa não chegou às tarefas do aprovador elegível.'); $eligible[] = $person; } $requester = $actor('requester'); $check(! collect(app(WorkflowTaskInbox::class)->build($requester, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Rascunho já submetido continua como preparação pendente.'); $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $nextFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'purchase_id' => $purchase->id, 'purchase_number' => $purchase->number, 'new_status_in_copy' => $purchase->status, 'current_role' => $routing['role'], 'eligible_people' => $eligible, 'all_approval_steps_pending' => true, 'same_command_repeated_without_duplication' => true, 'items_values_stock_orders_access_preserved' => true, 'sgf_installation_changed' => false]; $proof['required_command_receipt_migration'] = $migration; $proof['command_receipt_migration_tested_in_copy'] = $installedInCopy; } else { $envelope = fn (array $data, string $action = 'save', ?SupplyRecord $old = null) => ['operation_key' => (string) Str::uuid(), 'record_key' => $old?->record_key, 'version' => $old?->version ?? 0, 'action' => $action, 'reason' => 'Simulação descartável da proposta sintética, ainda sem aplicação no piloto.', 'data' => $data]; $publisher = $actor('catalogue'); $check((int) DB::table('organization_access')->where('tenant_id', $proposal['tenant_id'])->value('version') === $proposal['expected_access_version'], 'A configuração dos acessos mudou.'); $stock = StockItem::query()->findOrFail($proposal['source']['stock_item_id']); $check($stock->only(['code', 'name', 'unit']) === array_intersect_key($proposal['source'], array_flip(['code', 'name', 'unit'])), 'Cadastro diferente da proposta.'); $warehouse = Warehouse::query()->findOrFail($proposal['source']['warehouse_id']); $check($warehouse->code === $proposal['source']['warehouse_code'], 'Armazém diferente da proposta.'); $balance = app(WarehouseAvailability::class)->row($stock, $warehouse, false); $check($balance['physical_milli'] === $proposal['source']['physical_milli'] && $balance['free_milli'] === $proposal['source']['free_milli'] && $balance['ledger_version'] === $proposal['source']['ledger_version'], 'Disponibilidade diferente da proposta.'); $check(! DB::table('requisition_catalogue')->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->exists(), 'A publicação inicial mudou.'); $planning = app(SupplyPlanning::class); $check($planning->latest('policy')->where('state', 'approved')->where('stock_item_id', $stock->id)->count() === 0, 'Já existe uma política activa.'); $check($planning->latest('auto_rule')->where('state', 'approved')->where('stock_item_id', $stock->id)->count() === 0, 'Já existe uma regra activa.'); $protected = ['users', 'organization_access', 'organization_profiles', 'organization_memberships', 'approval_steps', 'purchase_orders', 'stock_movements']; $fingerprint = fn () => collect($protected)->mapWithKeys(fn ($table) => [$table => hash('sha256', json_encode(DB::table($table)->orderBy('id')->get(), JSON_THROW_ON_ERROR))])->all(); $before = $fingerprint(); $count = Requisition::query()->count(); app(RequisitionCatalogue::class)->save($publisher, $stock->id, [...$proposal['catalogue'], 'version' => 0, 'stock_basis' => app(RequisitionCatalogue::class)->basis($stock), 'reason' => 'Publicação proposta apenas para o ensaio sintético descartável.']); $prepare = $actor('prepare'); $policy = $planning->save($prepare, 'policy', $envelope($proposal['policy'])); $review = $actor('review'); $tasks = app(WorkflowTaskInbox::class)->build($review, 'procurement'); $check(collect($tasks)->contains('id', 'supply:'.$policy->id.':review'), 'Política sem encaminhamento independente.'); $approved = $planning->save($review, 'policy', $envelope([], 'approve', $policy)); $automatic = app(AutomaticPurchases::class); $prepare = $actor('prepare'); $rule = $automatic->save($prepare, $envelope([...$proposal['rule'], 'policy_key' => $approved->record_key])); $review = $actor('review'); $check(collect(app(WorkflowTaskInbox::class)->build($review, 'procurement'))->contains('id', 'supply:'.$rule->id.':review'), 'Regra sem encaminhamento independente.'); $command = $envelope([], 'approve', $rule); $automatic->save($review, $command); $automatic->save($review, $command); $automatic->evaluate($review, $stock->id, $warehouse->id); $automatic->evaluate($review, $stock->id, $warehouse->id); $check(Requisition::query()->count() === $count + 1, 'A proposta deve criar exactamente um rascunho.'); $purchase = Requisition::query()->latest('id')->firstOrFail(); $check($purchase->status === 'draft' && $purchase->requester_id === $proposal['actors']['requester'] && $purchase->steps()->count() === 0, 'Submissão/aprovações humanas têm de ficar pendentes.'); $check($purchase->items()->sole()->quantity === $proposal['expected']['quantity'], 'Quantidade gerada não coincide com a proposta.'); $check($purchase->subtotal_minor === 2000 && $purchase->tax_minor === 320 && $purchase->total_minor === 2320, 'Estimativa de custo diferente da proposta.'); $check(app(StockWorkflow::class)->balance($stock->id, $warehouse->id)['quantity'] === $proposal['expected']['physical_milli_after'], 'A criação do rascunho não pode alterar stock.'); $check($fingerprint() === $before, 'A simulação alterou acessos, aprovações, ordens ou stock.'); $requester = $actor('requester'); $check(collect(app(WorkflowTaskInbox::class)->build($requester, 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Rascunho sem encaminhamento ao requisitante.'); $proof = ['checked_at' => now()->toIso8601String(), 'proposal_sha256' => hash_file('sha256', $proposalFile), 'result' => 'passed', 'database' => 'disposable restored copy of local synthetic SGC', 'source_business_commands' => false, 'accounts_or_grants_changed' => false, 'drafts_created_in_copy' => 1, 'quantity' => $purchase->items()->sole()->quantity, 'duplicate_approval_and_two_reevaluations' => 'one draft', 'estimated_total_minor_mzn' => $purchase->total_minor, 'physical_milli_after' => $proposal['expected']['physical_milli_after'], 'independent_policy_and_rule_tasks' => true, 'requester_task' => true, 'human_submission_and_approvals_pending' => true, 'existing_evaluation_context_fallback_reproduced_only_in_copy_runtime' => true, 'protected_tables_unchanged_in_copy' => $protected, 'sgf_installation_changed' => false, 'visual_review' => false]; } } catch (Throwable $error) { $failure = ['result' => 'failed', 'type' => get_class($error), 'locations' => array_map(fn ($frame) => ['file' => $frame['file'] ?? null, 'line' => $frame['line'] ?? null], array_slice($error->getTrace(), 0, 9))]; if ($error instanceof ValidationException) { $failure['validation'] = $error->errors(); } } finally { DB::purge('pilot_clone'); if ($created) { $admin->statement('DROP DATABASE "'.$clone.'" WITH (FORCE)'); } if (is_file($dump) && dirname(realpath($dump)) === realpath($privateRoot)) { unlink($dump); } } if ($failure !== null) { fwrite(STDERR, json_encode($failure, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); exit(1); } if ($proof !== null) { $proof['database_and_private_copy_removed'] = true; $proofName = in_array('--evaluations', $argv, true) ? 'pilot-evaluation-dry-run' : (in_array('--market', $argv, true) ? 'pilot-market-dry-run' : (in_array('--approval', $argv, true) ? 'pilot-approval-dry-run' : (in_array('--submission', $argv, true) ? 'pilot-submission-dry-run' : 'pilot-replenishment-dry-run'))); file_put_contents($root.'/docs/proofs/'.$proofName.'.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL); echo 'Proposta ensaiada na cópia descartável; piloto sem comandos de negócio.'.PHP_EOL; }