401 lines
34 KiB
PHP
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;
|