Implementa recomendação formal com adopção e conferência independente

This commit is contained in:
2026-10-28 20:49:30 +02:00
parent 29cd859385
commit ee17ff7764
33 changed files with 2233 additions and 37 deletions
+76
View File
@@ -0,0 +1,76 @@
<?php
use App\Enums\PurchasingRole;
use App\Models\ProposalEvaluation;
use App\Models\Supplier;
use App\Models\User;
use App\Services\DocumentaryQualificationWorkflow;
use App\Services\FormalAdmission;
use App\Services\FormalRecommendation;
use App\Services\ProposalOpeningWorkflow;
require_once __DIR__.'/QualificationFixture.php';
/** Genuine three-quotation basis; underlying fixture refuses non-isolated databases. */
function recommendationFixture(int $quorum = 2): array
{
$f = qualificationFixture();
$tenantId = $f['tenant']->id;
$departmentId = $f['r']->department_id;
$admin = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Admin]);
$buyer = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::Procurement]);
$head = User::factory()->create(['tenant_id' => $tenantId, 'department_id' => $departmentId, 'purchasing_role' => PurchasingRole::DepartmentHead]);
$reviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
$otherReviewer = User::factory()->create(['tenant_id' => $tenantId, 'purchasing_role' => PurchasingRole::Finance]);
auth()->guard()->setUser($buyer);
$f['r']->update(['approval_snapshot' => ['first_limit_minor' => 2500000, 'second_limit_minor' => 15000000, 'approval_includes_tax' => true, 'rules_provisional' => true]]);
$f['r']->steps()->create(['tenant_id' => $tenantId, 'position' => 1, 'role' => 'department_head', 'decision' => 'approved', 'approver_id' => $head->id, 'decided_at' => now()]);
$supplier = Supplier::query()->findOrFail($f['quote']->supplier_id);
$quotes = [$f['quote']];
for ($i = 1; $i <= 2; $i++) {
$otherSupplier = $supplier->replicate();
$otherSupplier->fill(['nuit' => '12345678'.$i, 'name' => 'Fornecedor fictício '.$i])->save();
$quote = $f['quote']->replicate();
$quote->fill(['supplier_id' => $otherSupplier->id, 'reference' => 'PROP-FICTICIA-'.$i])->save();
$quotes[] = $quote;
}
$item = $f['r']->items()->firstOrFail();
foreach ($quotes as $index => $quote) {
$amount = 10000 * ($index + 1);
$quote->update(['created_by' => $buyer->id, 'subtotal_minor' => $amount, 'total_minor' => $amount]);
$quote->items()->create(['tenant_id' => $tenantId, 'requisition_item_id' => $item->id, 'description' => $item->description, 'quantity' => '1.000', 'unit_price_minor' => $amount, 'tax_rate' => '0', 'subtotal_minor' => $amount, 'tax_minor' => 0, 'total_minor' => $amount]);
foreach ([$buyer, $f['analyst']] as $evaluator) {
ProposalEvaluation::query()->create(['tenant_id' => $tenantId, 'quotation_id' => $quote->id, 'evaluator_id' => $evaluator->id, 'quality' => '8', 'history' => '5', 'payment' => '8', 'no_conflict' => true, 'comment' => 'Avaliação exclusivamente fictícia']);
}
}
$opening = app(ProposalOpeningWorkflow::class);
$documentary = app(DocumentaryQualificationWorkflow::class);
$set = $documentary->define($buyer, $f['r'], [...$f['definition'], 'basis_hash' => $opening->basis($opening->proposals($f['r']))]);
$answers = $f['assessment']['answers'];
$answers[1] = ['criterion_id' => 2, 'result' => 'met', 'evidence' => 'Documento fictício', 'reason' => 'Conformidade exclusivamente fictícia'];
foreach ($quotes as $quote) {
auth()->guard()->setUser($f['analyst']);
$documentary->assess($f['analyst'], $set, [...$f['assessment'], 'quotation_id' => $quote->id, 'answers' => $answers]);
}
$admission = app(FormalAdmission::class);
auth()->guard()->setUser($admin);
$admissionPolicy = $admission->policy($admin, ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance'], 'required_count' => 1, 'reason' => 'Política de admissão exclusivamente fictícia']);
auth()->guard()->setUser($buyer);
$admissionAdoption = $admission->adopt($buyer, $f['r'], $admissionPolicy->id, 0, 'Adopção de admissão exclusivamente fictícia');
foreach ($quotes as $quote) {
auth()->guard()->setUser($buyer);
$decision = $admission->propose($buyer, $f['r'], ['quotation_id' => $quote->id, 'previous_id' => 0, 'basis_hash' => $admission->basis($f['r'], $quote->id)['hash'], 'outcome' => 'admit', 'reason' => 'Proposta de admissão exclusivamente fictícia']);
auth()->guard()->setUser($f['analyst']);
$admission->review($f['analyst'], $decision, 'approved', 'Conferência de admissão exclusivamente fictícia');
}
$workflow = app(FormalRecommendation::class);
$policyData = ['previous_id' => 0, 'enabled' => true, 'roles' => ['finance', 'department_head'], 'required_count' => $quorum, 'reason' => 'Política de recomendação exclusivamente fictícia'];
auth()->guard()->setUser($admin);
$policy = $workflow->policy($admin, $policyData);
auth()->guard()->setUser($buyer);
$adoption = $workflow->adopt($buyer, $f['r'], $policy->id, 0, 'Adopção de recomendação exclusivamente fictícia');
$proposal = ['previous_id' => 0, 'quotation_id' => $quotes[0]->id, 'basis_hash' => $workflow->basis($f['r'])['hash'], 'reason' => 'Recomendação exclusivamente fictícia'];
$selection = ['quotation_id' => $quotes[0]->id, 'justification' => 'Proposta de maior pontuação com recomendação.', 'delivery_date' => today()->addWeek()->format('Y-m-d'), 'delivery_location' => 'Armazém fictício'];
return [...$f, 'quotes' => $quotes, 'set' => $set, 'admin' => $admin, 'buyer' => $buyer, 'head' => $head, 'reviewer' => $reviewer, 'other_reviewer' => $otherReviewer, 'admission_policy' => $admissionPolicy, 'admission_adoption' => $admissionAdoption, 'policy' => $policy, 'policy_data' => $policyData, 'adoption' => $adoption, 'proposal' => $proposal, 'selection' => $selection];
}
@@ -0,0 +1,111 @@
<?php
// Explicit operational entry point: backup, restore verification, then only migration 000020.
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$connection = DB::connection();
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
}
$pdo = $connection->getPdo();
$migration = '2026_10_28_000020_create_formal_recommendations';
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
throw new LogicException('Migração já aplicada; não repetir.');
}
$config = $connection->getConfig();
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
if (! is_file($bin.$tool)) {
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
}
}
$envHash = hash_file('sha256', base_path('.env'));
$directory = storage_path('app/private/backups/formal-recommendation-000020-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
if (! mkdir($directory, 0700, true)) {
throw new RuntimeException('Não foi possível criar backup privado.');
}
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
$columns = [];
$fingerprints = function (PDO $db, array $tables) use ($quote, &$columns): array {
$result = [];
foreach ($tables as $table) {
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM (SELECT '.implode(', ', array_map($quote, $columns[$table])).' FROM public.'.$quote($table).') t')->fetchAll(PDO::FETCH_COLUMN);
sort($rows, SORT_STRING);
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
}
return $result;
};
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$statement = $pdo->prepare("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ? ORDER BY ordinal_position");
$statement->execute([$table]);
$columns[$table] = $statement->fetchAll(PDO::FETCH_COLUMN);
}
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
$restoreCreated = false;
try {
// pg_dump and the manifest read exactly the same committed snapshot.
$pdo->beginTransaction();
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
$backupState = $fingerprints($pdo, $tables);
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
$dump->setTimeout(120);
$dump->run();
if (! $dump->isSuccessful()) {
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
}
$pdo->commit();
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
$restoreCreated = true;
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
$restore->setTimeout(120);
$restore->run();
if (! $restore->isSuccessful()) {
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
}
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
$restored = DB::connection('budget_restore')->getPdo();
if ($fingerprints($restored, $tables) !== $backupState) {
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
}
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'columns' => $columns, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
DB::disconnect('budget_restore');
unset($restored);
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
$restoreCreated = false;
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
$before = $fingerprints($pdo, $businessTables);
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
if ($exit !== 0) {
throw new RuntimeException('Migração falhou; consultar backup privado.');
}
$after = $fingerprints($pdo, $businessTables);
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
}
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'recommendation_policies_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_policies')->fetchColumn(), 'recommendation_adoptions_count' => (int) $pdo->query('SELECT count(*) FROM recommendation_adoptions')->fetchColumn(), 'operational_validation' => false];
file_put_contents(base_path('docs/recomendacao-migracao-000020.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
DB::disconnect('budget_restore');
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
}
}