Files
compras/scripts/apply-pilot-replenishment-proposal.php
2026-11-03 11:51:44 +02:00

401 lines
34 KiB
PHP

<?php
use App\Http\Controllers\QuotationController;
use App\Models\ProposalEvaluation;
use App\Models\Quotation;
use App\Models\Requisition;
use App\Models\StockItem;
use App\Models\Supplier;
use App\Models\SupplyRecord;
use App\Models\User;
use App\Models\Warehouse;
use App\Services\ApprovalRouting;
use App\Services\AutomaticPurchases;
use App\Services\ConflictGuard;
use App\Services\ProposalComparison;
use App\Services\RequisitionCatalogue;
use App\Services\RequisitionWorkflow;
use App\Services\SupplyPlanning;
use App\Services\WarehouseAvailability;
use App\Services\WorkflowTaskInbox;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
require dirname(__DIR__).'/vendor/autoload.php';
$root = dirname(__DIR__);
$read = fn ($name) => json_decode(ltrim(file_get_contents($root.'/docs/proofs/'.$name.'.json'), "\xEF\xBB\xBF"), true, 64, JSON_THROW_ON_ERROR);
$proposal = $read('pilot-replenishment-proposal');
$tested = $read('pilot-replenishment-dry-run');
$authorization = $read('pilot-replenishment-authorization');
$hash = hash_file('sha256', $root.'/docs/proofs/pilot-replenishment-proposal.json');
$apply = in_array('--apply-authorized', $argv, true);
$installCommands = in_array('--install-command-receipts', $argv, true);
$submitPurchase = in_array('--submit-purchase', $argv, true);
$approvePurchase = in_array('--approve-purchase', $argv, true);
$marketScenario = in_array('--market', $argv, true);
$evaluationScenario = in_array('--evaluations', $argv, true);
$evaluation = $evaluationScenario ? $read('pilot-evaluation-proposal') : null;
if ($evaluationScenario) {
$evaluationHash = hash_file('sha256', $root.'/docs/proofs/pilot-evaluation-proposal.json');
$evaluationTested = $read('pilot-evaluation-dry-run');
$evaluationAuthorization = $read('pilot-evaluation-authorization');
if ($marketScenario || $approvePurchase || $submitPurchase || $installCommands || $evaluationTested['result'] !== 'passed'
|| $evaluationTested['proposal_sha256'] !== $evaluationHash || $evaluationAuthorization['proposal_sha256'] !== $evaluationHash
|| $evaluationAuthorization['environment'] !== $evaluation['environment'] || $evaluation['scope'] !== 'synthetic_local_pilot_only') {
throw new LogicException('Avaliações exigem a proposta exacta ensaiada e autorizada.');
}
}
$market = $marketScenario ? $read('pilot-market-proposal') : null;
if ($marketScenario) {
$marketHash = hash_file('sha256', $root.'/docs/proofs/pilot-market-proposal.json');
$marketTested = $read('pilot-market-dry-run');
$marketAuthorization = $read('pilot-market-authorization');
if ($approvePurchase || $submitPurchase || $installCommands || $marketTested['result'] !== 'passed' || $marketTested['proposal_sha256'] !== $marketHash
|| $marketAuthorization['proposal_sha256'] !== $marketHash || $marketAuthorization['environment'] !== $market['environment']
|| $market['scope'] !== 'synthetic_local_pilot_only' || $market['quotation_valid_until'] < date('Y-m-d')) {
throw new LogicException('Consulta sintética exige a proposta exacta ensaiada e autorizada.');
}
}
$verifyApproved = in_array('--verify-approval-committed', $argv, true);
if ($verifyApproved && ! $approvePurchase) {
throw new LogicException('Verificação limitada à aprovação autorizada.');
}
$approval = $approvePurchase ? $read('pilot-approval-proposal') : null;
if ($approvePurchase) {
$approvalHash = hash_file('sha256', $root.'/docs/proofs/pilot-approval-proposal.json');
$approvalAuthorization = $read('pilot-approval-authorization');
$approvalTested = $read('pilot-approval-dry-run');
if ($installCommands || $submitPurchase || $approvalTested['result'] !== 'passed' || $approvalTested['proposal_sha256'] !== $approvalHash
|| $approvalAuthorization['proposal_sha256'] !== $approvalHash || $approvalAuthorization['environment'] !== $approval['environment']
|| $approval['action'] !== 'approve' || $approval['scope'] !== 'synthetic_local_pilot_only') {
throw new LogicException('Aprovação exige a proposta exacta ensaiada e autorizada.');
}
}
$submission = $submitPurchase ? $read('pilot-submission-proposal') : null;
if ($submitPurchase) {
$submissionHash = hash_file('sha256', $root.'/docs/proofs/pilot-submission-proposal.json');
$submissionAuthorization = $read('pilot-submission-authorization');
$submissionTested = $read('pilot-submission-dry-run');
if ($installCommands || $submissionTested['result'] !== 'passed' || $submissionTested['proposal_sha256'] !== $submissionHash
|| $submissionAuthorization['proposal_sha256'] !== $submissionHash || $submissionAuthorization['owner_reply'] !== 'autorizo'
|| $submissionAuthorization['environment'] !== $submission['environment'] || $submission['action'] !== 'submit') {
throw new LogicException('Submissão exige a proposta exacta ensaiada e autorizada.');
}
}
if ($tested['result'] !== 'passed' || $tested['proposal_sha256'] !== $hash || $authorization['proposal_sha256'] !== $hash
|| $authorization['environment'] !== 'http://127.0.0.1:8005' || $proposal['environment'] !== $authorization['environment']
|| $proposal['scope'] !== 'synthetic_local_pilot_only' || $proposal['rule']['valid_until'] < date('Y-m-d')) {
throw new LogicException('Proposta, ensaio e autorização têm de coincidir e estar vigentes.');
}
$settings = json_decode(file_get_contents('C:/laragon/data/sgf-tempo/secrets/payables-demo-compras.json'), true, 32, JSON_THROW_ON_ERROR);
if (($settings['APP_ENV'] ?? '') !== 'payable-testing' || ($settings['DB_HOST'] ?? '') !== '127.0.0.1'
|| realpath($settings['HOST_ROOT'] ?? '') !== realpath($root)
|| ! preg_match('/^compras_receipts_test_([a-f0-9]{12})$/D', $settings['DB_DATABASE'] ?? '', $match)
|| ($settings['DB_USERNAME'] ?? '') !== 'payable_qa_'.$match[1]) {
throw new LogicException('Aplicação exclusivamente na avaliação local autorizada.');
}
$app = require $root.'/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$owner = DB::connection('pgsql')->getConfig();
unset($owner['name']);
if ($owner['host'] !== '127.0.0.1') {
throw new LogicException('Restauro de controlo 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.replenishment_apply' => $qa, 'database.connections.replenishment_owner' => [...$owner, 'url' => null, 'database' => 'postgres'],
'app.env' => 'payable-testing', 'work_context.composition' => 'sgc']);
$app->detectEnvironment(fn () => 'payable-testing');
DB::setDefaultConnection('replenishment_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('Destino diferente do autorizado.');
}
$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());
$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;
};
$check = function (bool $valid, string $message) {
if (! $valid) {
throw new LogicException($message);
}
};
$fingerprint = function ($connection, array $tables) {
$result = [];
foreach ($tables as $table) {
$quoted = '"'.str_replace('"', '""', $table).'"';
$rows = $connection->select('select row_to_json(t)::text as row from '.$quoted.' t order by row_to_json(t)::text');
$result[$table] = hash('sha256', json_encode(array_column($rows, 'row'), JSON_THROW_ON_ERROR));
}
return $result;
};
$pg = function (string $binary, array $arguments, array $connection) {
$process = new Process(['C:/Program Files/PostgreSQL/17/bin/'.$binary.'.exe', ...$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 de controlo não concluído.');
}
};
$restore = null;
$failure = null;
$admin = DB::connection('replenishment_owner');
try {
$result = $db->transaction(function () use ($db, $actor, $check, $proposal, $hash, $apply, $installCommands, $submitPurchase, $submission, $approvePurchase, $verifyApproved, $approval, $marketScenario, $market, $evaluationScenario, $evaluation, $app, $fingerprint, $pg, $qa, $owner, $admin, &$restore) {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
app(ConflictGuard::class)->lockCompany($actor('catalogue'));
$check((int) DB::table('organization_access')->where('tenant_id', $proposal['tenant_id'])->lockForUpdate()->value('version') === $proposal['expected_access_version'], 'Os acessos mudaram.');
$stock = StockItem::query()->lockForUpdate()->findOrFail($proposal['source']['stock_item_id']);
$warehouse = Warehouse::query()->findOrFail($proposal['source']['warehouse_id']);
$check($stock->only(['code', 'name', 'unit']) === array_intersect_key($proposal['source'], array_flip(['code', 'name', 'unit'])) && $warehouse->code === $proposal['source']['warehouse_code'], 'Cadastro 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.');
$planning = app(SupplyPlanning::class);
if ($evaluationScenario) {
$purchase = Requisition::query()->lockForUpdate()->findOrFail($evaluation['purchase_id']);
$quotes = Quotation::query()->where('requisition_id', $purchase->id)->orderBy('id')->get();
$check($purchase->number === $evaluation['purchase_number'] && $purchase->status === 'approved'
&& $quotes->pluck('id')->all() === $evaluation['quotation_ids'] && $quotes->sum(fn ($quote) => $quote->evaluations()->count()) === 0,
'Compra, propostas ou avaliações já alteradas; não reaplicar.');
} elseif ($marketScenario) {
$purchase = Requisition::query()->lockForUpdate()->findOrFail($market['purchase_id']);
$check($purchase->number === $market['purchase_number'] && $purchase->status === 'approved' && $purchase->total_minor === 2320
&& Quotation::query()->where('requisition_id', $purchase->id)->count() === 0, 'Consulta já iniciada ou compra alterada; não duplicar.');
$check(! Supplier::query()->whereIn('nuit', array_column($market['new_synthetic_suppliers'], 'nuit'))->exists(), 'Cadastro sintético já existe; não reaplicar.');
} elseif ($approvePurchase) {
$purchase = Requisition::query()->lockForUpdate()->findOrFail($approval['purchase_id']);
$check($purchase->number === $approval['purchase_number'] && $purchase->status === ($verifyApproved ? 'approved' : 'pending') && $purchase->items()->sole()->quantity === $approval['quantity']
&& $purchase->total_minor === $approval['total_minor_mzn'] && $purchase->steps()->count() === 1 && $purchase->steps()->sole()->role === $approval['current_role'], 'Compra ou etapa diferente da proposta.');
} elseif ($submitPurchase) {
$purchase = Requisition::query()->lockForUpdate()->findOrFail($submission['purchase_id']);
$check($purchase->number === $submission['purchase_number'] && $purchase->requester_id === $submission['requester_id'] && $purchase->status === 'draft'
&& $purchase->items()->sole()->quantity === $submission['quantity'] && $purchase->total_minor === $submission['total_minor_mzn'] && $purchase->steps()->count() === 0,
'Rascunho diferente da proposta; não repetir submissão.');
} elseif ($installCommands) {
$testedSubmission = json_decode(file_get_contents(base_path('docs/proofs/pilot-submission-dry-run.json')), true, 32, JSON_THROW_ON_ERROR);
$check($testedSubmission['result'] === 'passed' && $testedSubmission['command_receipt_migration_tested_in_copy'] === true, 'Instalação tem de estar ensaiada na cópia.');
$check(! Schema::hasTable('requisition_commands') && ! Schema::hasColumn('requisitions', 'process_version'), 'Instalação já existe ou está parcial; não reaplicar.');
} else {
$check(! DB::table('requisition_catalogue')->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->exists(), 'Artigo já publicado/configurado; não reaplicar.');
foreach (['policy', 'auto_rule'] as $kind) {
$check(! $planning->latest($kind)->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id)->exists(), 'Já existe parametrização; não duplicar.');
}
}
if (! $apply) {
return ['result' => 'preflight_passed_no_changes', 'proposal_sha256' => $hash];
}
$tables = array_map(fn ($r) => $r->tablename, $db->select("select tablename from pg_tables where schemaname='public' order by tablename"));
$baseline = $fingerprint($db, $tables);
$protected = array_values(array_diff($tables, ['requisition_catalogue', 'supply_records', 'requisitions', 'requisition_items', 'audit_logs', 'replenishment_work']));
if ($verifyApproved) {
// Original consistent pre-decision backup, retained when a post-commit callback failed.
$dump = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-20261102-193430-6b25f8/before.dump';
$check(is_file($dump), 'Cópia original de recuperação não disponível.');
} else {
$directory = 'C:/laragon/data/sgf-tempo/backups/compras-pilot-replenishment-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3));
$check(mkdir($directory, 0700, true), 'Não foi possível preparar a cópia de recuperação.');
$dump = $directory.'/before.dump';
$snapshot = $db->selectOne('select pg_export_snapshot() as snapshot')->snapshot;
$pg('pg_dump', ['--format=custom', '--snapshot='.$snapshot, '--file='.$dump, '--dbname='.$qa['database']], $qa);
}
$restore = 'compras_pilot_restore_'.bin2hex(random_bytes(6));
$admin->statement('CREATE DATABASE "'.$restore.'"');
$pg('pg_restore', ['--no-owner', '--no-privileges', '--exit-on-error', '--dbname='.$restore, $dump], $owner);
config(['database.connections.replenishment_restore' => [...$owner, 'url' => null, 'database' => $restore]]);
$restoredBaseline = $fingerprint(DB::connection('replenishment_restore'), $tables);
if ($verifyApproved) {
$baseline = $restoredBaseline;
} else {
$check($restoredBaseline === $baseline, 'Restauro diferente da origem.');
}
if ($evaluationScenario) {
$evaluationTable = (new ProposalEvaluation)->getTable();
$protected = array_values(array_diff($tables, [$evaluationTable, 'audit_logs']));
foreach ($evaluation['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($evaluation['environment'].'/cotacoes/'.$quote->id.'/avaliar', 'POST', $evaluation['grades_for_each_quotation_and_each_evaluator']);
$input->setUserResolver(fn () => $evaluator);
app(QuotationController::class)->evaluate($input, $quote, app(RequisitionWorkflow::class));
}
}
$records = ProposalEvaluation::query()->whereIn('quotation_id', $evaluation['quotation_ids'])->orderBy('id')->get();
$check($records->count() === 6 && $records->every(fn ($record) => in_array($record->evaluator_id, $evaluation['evaluators'], true)
&& (string) $record->quality === '5.00' && (string) $record->history === '5.00' && (string) $record->payment === '5.00'), 'Avaliações diferentes das autorizadas.');
$check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora da avaliação autorizada; cancelar.');
$oldEvaluations = DB::connection('replenishment_restore')->table($evaluationTable)->orderBy('id')->get()->toJson();
$check(DB::table($evaluationTable)->whereNotIn('id', $records->modelKeys())->orderBy('id')->get()->toJson() === $oldEvaluations, 'Avaliações anteriores alteradas.');
$preparer = $actor('prepare');
$comparison = app(ProposalComparison::class)->inspect($purchase);
$check($comparison['ready'] && collect(app(WorkflowTaskInbox::class)->build($preparer, 'procurement'))->contains('id', 'quotation:'.$purchase->id.':result'), 'Sem encaminhamento da comparação para conferência humana.');
return ['result' => 'evaluations_registered', 'registered_at' => now()->toIso8601String(), 'environment' => $evaluation['environment'],
'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-evaluation-proposal.json')), 'purchase_id' => $purchase->id,
'evaluation_ids' => $records->modelKeys(), 'evaluators' => $evaluation['evaluators'], 'evaluations' => 6, 'grades' => '5.00',
'comparison_ready' => true, 'human_comparison_review_task' => true, 'supplier_selection_award_or_order' => false, 'outbound_communication' => false,
'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected),
'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
}
if ($marketScenario) {
$protected = array_values(array_diff($tables, ['suppliers', 'quotations', 'quotation_items', 'audit_logs']));
require_once __DIR__.'/support/pilot-market-scenario.php';
$scenario = pilotMarketScenario($app, $proposal, $market, $actor, $check);
$check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora da consulta autorizada; cancelar.');
$oldQuotes = DB::connection('replenishment_restore')->table('quotations')->orderBy('id')->get()->toJson();
$check(DB::table('quotations')->whereNotIn('id', $scenario['quotation_ids'])->orderBy('id')->get()->toJson() === $oldQuotes, 'Propostas anteriores alteradas.');
return ['result' => 'market_registered', 'registered_at' => now()->toIso8601String(), 'environment' => $market['environment'],
'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-market-proposal.json')), 'purchase_id' => $purchase->id,
'supplier_ids' => $scenario['supplier_ids'], 'quotation_ids' => $scenario['quotation_ids'], 'totals_minor_mzn' => [2320, 2552, 2784],
'evaluations' => 0, 'supplier_selection_award_or_order' => false, 'outbound_communication' => false,
'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected),
'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
}
if ($approvePurchase) {
$protected = array_values(array_diff($tables, ['requisitions', 'approval_steps', 'requisition_commands', 'audit_logs', 'workflow_notices', 'replenishment_work']));
$othersBefore = DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson();
$approver = User::query()->where('tenant_id', $proposal['tenant_id'])->findOrFail($approval['approver_id']);
auth()->guard('web')->setUser($approver);
request()->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $approver->id, 'tenant_id' => $approver->tenant_id]);
if ($verifyApproved) {
$receipt = DB::table('requisition_commands')->where('tenant_id', $approver->tenant_id)->where('actor_id', $approver->id)->get()
->filter(fn ($row) => (json_decode($row->result, true, 64, JSON_THROW_ON_ERROR)['id'] ?? null) === $purchase->id)->sole();
$recorded = json_decode($receipt->result, true, 64, JSON_THROW_ON_ERROR);
$check($recorded['status'] === 'approved' && $purchase->steps()->sole()->decision === 'approved'
&& $purchase->steps()->sole()->comment === $approval['decision_comment'], 'Recibo ou decisão diferente da autorização.');
$commandKey = $receipt->operation_key;
$othersBefore = DB::connection('replenishment_restore')->table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson();
} else {
$check($purchase->canDecide($approver), 'A competência actual não permite a decisão.');
$commandKey = (string) Str::uuid();
app(RequisitionWorkflow::class)->decide($approver, $purchase, 'approved', $approval['decision_comment'], $commandKey, (int) $purchase->process_version);
}
$purchase->refresh();
$check($purchase->status === 'approved' && $purchase->steps()->sole()->approver_id === $approver->id
&& $fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected))
&& DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson() === $othersBefore, 'Alteração fora da decisão autorizada; cancelar.');
$check(collect(app(WorkflowTaskInbox::class)->build($actor('prepare'), 'procurement'))->contains('id', 'quotation:'.$purchase->id.':prepare'), 'Sem encaminhamento para consulta ao mercado.');
// Keep the command actor until after-commit callbacks have finished.
auth()->guard('web')->setUser($approver);
request()->session()->put('work_context', ['key' => 'procurement.internal', 'user_id' => $approver->id, 'tenant_id' => $approver->tenant_id]);
return ['result' => 'approved', 'approved_at' => (string) $purchase->steps()->sole()->decided_at, 'verified_at' => now()->toIso8601String(), 'environment' => $approval['environment'],
'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-approval-proposal.json')), 'purchase_id' => $purchase->id,
'purchase_number' => $purchase->number, 'status' => $purchase->status, 'approved_by' => $approver->id, 'quantity' => $purchase->items()->sole()->quantity,
'total_minor_mzn' => $purchase->total_minor, 'market_consultation_task' => true, 'budget_entries_preserved' => true,
'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected),
'command_key' => $commandKey, 'verified_existing_committed_receipt_without_replay' => $verifyApproved,
'supplier_selection_or_order' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
}
if ($submitPurchase) {
$protected = array_values(array_diff($tables, ['requisitions', 'approval_steps', 'requisition_commands', 'audit_logs', 'workflow_notices', 'budget_entries', 'replenishment_work']));
$otherRequests = fn () => DB::table('requisitions')->where('id', '!=', $purchase->id)->orderBy('id')->get()->toJson();
$othersBefore = $otherRequests();
$budgetBefore = DB::table('budget_entries')->orderBy('id')->get()->toJson();
$workOutside = fn () => DB::table('replenishment_work')->whereNot(fn ($query) => $query->where('tenant_id', $proposal['tenant_id'])->where('stock_item_id', $stock->id)->where('warehouse_id', $warehouse->id))->orderBy('id')->get()->toJson();
$workOutsideBefore = $workOutside();
$beforeCount = Requisition::query()->count();
$requester = $actor('requester');
$check($requester->id === $submission['requester_id'], 'Requisitante diferente do autorizado.');
$commandKey = (string) Str::uuid();
app(RequisitionWorkflow::class)->submit($requester, $purchase, $commandKey, (int) $purchase->process_version);
$purchase->refresh();
$check($purchase->status === 'pending' && $purchase->steps()->count() > 0 && $purchase->steps()->where('decision', '!=', 'pending')->count() === 0, 'As decisões têm de permanecer pendentes.');
$protectedAfter = $fingerprint($db, $protected);
$changed = array_keys(array_diff_assoc($protectedAfter, array_intersect_key($baseline, array_flip($protected))));
$check(Requisition::query()->count() === $beforeCount && $otherRequests() === $othersBefore && $changed === [],
'Alteração fora da submissão autorizada; cancelar. Tabelas: '.implode(', ', $changed));
$check(DB::table('budget_entries')->where('requisition_id', '!=', $purchase->id)->orderBy('id')->get()->toJson() === $budgetBefore
&& $workOutside() === $workOutsideBefore, 'Movimentos de outro processo alterados.');
$reservation = DB::table('budget_entries')->where('requisition_id', $purchase->id)->sole();
$check($reservation->kind === 'reserve' && (int) $reservation->created_by === $requester->id && (int) $reservation->committed_minor === 0
&& (int) $reservation->reserved_minor > 0 && (int) $reservation->reserved_minor <= $purchase->total_minor, 'Reserva orçamental incompatível com a submissão.');
$routing = app(ApprovalRouting::class)->current($purchase, $requester);
$check($routing['status'] === 'available' && array_column($routing['people'], 'id') === [10], 'Encaminhamento diferente do ensaiado.');
return ['result' => 'submitted', 'submitted_at' => now()->toIso8601String(), 'environment' => $submission['environment'],
'proposal_sha256' => hash_file('sha256', base_path('docs/proofs/pilot-submission-proposal.json')), 'purchase_id' => $purchase->id,
'purchase_number' => $purchase->number, 'status' => $purchase->status, 'quantity' => $purchase->items()->sole()->quantity,
'total_minor_mzn' => $purchase->total_minor, 'current_role' => $routing['role'], 'eligible_people' => $routing['people'],
'all_approval_steps_pending' => true, 'command_key' => $commandKey, 'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump),
'budget_reserved_minor' => (int) $reservation->reserved_minor, 'budget_committed_minor' => 0, 'other_budget_entries_and_replenishment_scopes_preserved' => true,
'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected), 'other_requisitions_preserved' => true,
'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
}
if ($installCommands) {
$migration = 'database/migrations/2026_11_01_000046_add_requisition_command_versions.php';
$protected = array_values(array_diff($tables, ['requisitions', 'migrations']));
$requisitionProjection = fn () => array_column($db->select("select (to_jsonb(t) - 'process_version')::text as row from requisitions t order by (to_jsonb(t) - 'process_version')::text"), 'row');
$beforeRequisitions = $requisitionProjection();
$check(Artisan::call('migrate', ['--database' => 'replenishment_apply', '--path' => $migration, '--force' => true]) === 0, 'Actualização técnica não concluída.');
$check(Schema::hasTable('requisition_commands') && Schema::hasColumn('requisitions', 'process_version') && DB::table('requisition_commands')->count() === 0, 'Estrutura de comandos inesperada.');
$check($requisitionProjection() === $beforeRequisitions && DB::table('requisitions')->where('process_version', '!=', 1)->count() === 0
&& $fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'A instalação alterou registos existentes; cancelar.');
$check((int) $db->selectOne("select count(*) as total from pg_trigger where tgrelid='requisition_commands'::regclass and not tgisinternal and tgname in ('rq_command_update','rq_command_delete')")->total === 2, 'Protecções de imutabilidade não instaladas.');
return ['result' => 'installed', 'installed_at' => now()->toIso8601String(), 'environment' => $proposal['environment'], 'migration' => $migration,
'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables), 'protected_tables_checked' => count($protected),
'existing_requisitions_preserved_except_initial_process_version' => true, 'command_receipts' => 0, 'immutability_triggers' => 2,
'business_commands' => false, 'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
}
$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' => 'Proposta de reposição sintética SGC8005 autorizada expressamente pelo proprietário neste chat em 02/11/2026.', 'data' => $data];
$count = Requisition::query()->count();
app(RequisitionCatalogue::class)->save($actor('catalogue'), $stock->id, [...$proposal['catalogue'], 'version' => 0,
'stock_basis' => app(RequisitionCatalogue::class)->basis($stock), 'reason' => 'Publicação sintética ART-TEST expressamente autorizada pelo proprietário para o piloto local.']);
$policy = $planning->save($actor('prepare'), 'policy', $envelope($proposal['policy']));
$policy = $planning->save($actor('review'), 'policy', $envelope([], 'approve', $policy));
$automatic = app(AutomaticPurchases::class);
$rule = $automatic->save($actor('prepare'), $envelope([...$proposal['rule'], 'policy_key' => $policy->record_key]));
$review = $actor('review');
$rule = $automatic->save($review, $envelope([], 'approve', $rule));
$check(Requisition::query()->count() === $count + 1, 'Esperado exactamente um novo rascunho.');
$purchase = Requisition::query()->latest('id')->firstOrFail();
$check($purchase->status === 'draft' && $purchase->requester_id === $proposal['actors']['requester'] && $purchase->steps()->count() === 0
&& $purchase->items()->sole()->quantity === $proposal['expected']['quantity'] && $purchase->subtotal_minor === 2000 && $purchase->tax_minor === 320 && $purchase->total_minor === 2320, 'Rascunho diferente da proposta.');
$check($fingerprint($db, $protected) === array_intersect_key($baseline, array_flip($protected)), 'Alteração fora do âmbito autorizado; cancelar aplicação.');
$check(collect(app(WorkflowTaskInbox::class)->build($actor('requester'), 'procurement'))->contains('id', 'purchase:'.$purchase->id.':draft'), 'Sem encaminhamento ao requisitante.');
return ['result' => 'applied', 'applied_at' => now()->toIso8601String(), 'proposal_sha256' => $hash, 'environment' => $proposal['environment'],
'policy_id' => $policy->id, 'rule_id' => $rule->id, 'purchase_id' => $purchase->id, 'purchase_number' => $purchase->number,
'quantity' => $purchase->items()->sole()->quantity, 'total_minor_mzn' => $purchase->total_minor, 'purchase_status' => $purchase->status,
'physical_milli_after' => $balance['physical_milli'], 'human_submission_and_approvals_pending' => true, 'protected_tables_checked' => count($protected),
'backup' => $dump, 'backup_sha256' => hash_file('sha256', $dump), 'restored_tables_checked' => count($tables),
'accounts_or_grants_changed' => false, 'sgf_installation_changed' => false];
});
} catch (Throwable $error) {
$failure = 'Aplicação não concluída: '.get_class($error).($error instanceof LogicException ? ' '.$error->getMessage() : '');
$failure .= ' '.json_encode(array_map(fn ($frame) => ['file' => $frame['file'] ?? null, 'line' => $frame['line'] ?? null], array_slice($error->getTrace(), 0, 8)), JSON_THROW_ON_ERROR);
} finally {
DB::purge('replenishment_restore');
if ($restore !== null && preg_match('/^compras_pilot_restore_[a-f0-9]{12}$/D', $restore)) {
$admin->statement('DROP DATABASE IF EXISTS "'.$restore.'" WITH (FORCE)');
}
}
if ($failure !== null) {
fwrite(STDERR, $failure.PHP_EOL);
exit(1);
}
if ($apply) {
$result['disposable_restore_removed'] = true;
$proofName = $evaluationScenario ? 'pilot-evaluation-activation' : ($marketScenario ? 'pilot-market-activation' : ($approvePurchase ? 'pilot-approval-activation' : ($submitPurchase ? 'pilot-submission-activation' : ($installCommands ? 'pilot-command-receipts-installation' : 'pilot-replenishment-activation'))));
file_put_contents($root.'/docs/proofs/'.$proofName.'.json', json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL);
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR).PHP_EOL;