Implementa recomendação formal com adopção e conferência independente
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user