196 lines
11 KiB
PHP
196 lines
11 KiB
PHP
<?php
|
|
|
|
// Every job is confined to the fresh disposable payables proof database.
|
|
$settingsFile = 'C:/laragon/data/sgf-tempo/secrets/payables-proof-compras.json';
|
|
$settings = json_decode(file_get_contents($settingsFile), true, 64, JSON_THROW_ON_ERROR);
|
|
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', $settings['DB_DATABASE'] ?? '') || ($settings['DB_HOST'] ?? '') !== '127.0.0.1') {
|
|
throw new LogicException('Disposable proof only.');
|
|
}
|
|
foreach ($settings as $key => $value) {
|
|
putenv($key.'='.$value);
|
|
$_ENV[$key] = $_SERVER[$key] = $value;
|
|
}
|
|
require getcwd().'/vendor/autoload.php';
|
|
$app = require getcwd().'/bootstrap/app.php';
|
|
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
|
|
use App\Models\Invoice;
|
|
use App\Models\PurchaseOrder;
|
|
use App\Models\Supplier;
|
|
use App\Models\User;
|
|
use App\Services\ExternalPaymentWorkflow;
|
|
use App\Services\SupplierBankWorkflow;
|
|
use App\Services\SupplierPayables;
|
|
use Illuminate\Database\Events\QueryExecuted;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tempo\Finance\Payables\Payables;
|
|
use Tempo\Finance\Payables\Workspace;
|
|
|
|
$identity = DB::selectOne('select current_database() as db, current_user as role');
|
|
if ($identity->db !== $settings['DB_DATABASE'] || $identity->role !== 'payable_qa_'.substr($identity->db, -12)) {
|
|
throw new LogicException('Proof identity mismatch.');
|
|
}
|
|
config(['common-payables.history_enabled' => true]);
|
|
$preparer = User::where('email', 'preparacao-obrigacoes@example.test')->firstOrFail();
|
|
$validator = User::where('email', 'validacao-obrigacoes@example.test')->firstOrFail();
|
|
auth()->guard()->setUser($preparer);
|
|
$invoice = Invoice::query()->sole();
|
|
$service = app(SupplierPayables::class);
|
|
$payments = app(ExternalPaymentWorkflow::class);
|
|
$job = $argv[1] ?? '';
|
|
function historyProofFingerprint(): string
|
|
{
|
|
$all = [];
|
|
foreach (['invoices', 'invoice_items', 'external_payments', 'budget_entries', 'budget_consumptions', 'stock_movements'] as $table) {
|
|
$all[$table] = DB::table($table)->orderBy('id')->get()->map(fn ($r) => (array) $r)->all();
|
|
}
|
|
|
|
return hash('sha256', Payables::canonical($all));
|
|
}
|
|
if ($job === 'setup') {
|
|
if (DB::table('external_payments')->exists() || DB::table('pa_proposals')->exists()) {
|
|
throw new LogicException('Fresh scenario required.');
|
|
}
|
|
$order = PurchaseOrder::query()->findOrFail($invoice->purchase_order_id);
|
|
$issuer = User::findOrFail($order->issued_by);
|
|
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
|
|
auth()->guard()->setUser($issuer);
|
|
$bank = app(SupplierBankWorkflow::class)->propose($issuer, $supplier, ['previous_id' => 0, 'bank_name' => 'Banco sintético', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular sintético', 'account_identifier' => 'HISTORY-PROOF-ONLY', 'evidence_reference' => 'Documento sintético', 'reason' => 'Preparar histórico exclusivamente na base descartável']);
|
|
foreach ([$preparer, $validator] as $reviewer) {
|
|
auth()->guard()->setUser($reviewer);
|
|
app(SupplierBankWorkflow::class)->review($reviewer, $bank, 'approved', 'Conferência sintética independente');
|
|
}
|
|
foreach ([['3000.00', 'confirmed'], ['1000.00', 'pending'], ['500.00', 'rejected']] as [$amount,$status]) {
|
|
auth()->guard()->setUser($preparer);
|
|
$payment = $payments->register($preparer, $invoice, ['supplier_bank_detail_id' => $bank->id, 'amount' => $amount, 'paid_on' => now()->format('Y-m-d'), 'external_reference' => 'HISTORY-'.$status, 'evidence_reference' => 'Prova sintética '.$status]);
|
|
if ($status !== 'pending') {
|
|
auth()->guard()->setUser($validator);
|
|
$payments->review($validator, $payment, $status, 'Decisão original sintética');
|
|
}
|
|
}
|
|
auth()->guard()->setUser($preparer);
|
|
$proposal = $service->engine($preparer)->command('prepare_history', $invoice->id, 'Preparar passagem com histórico existente', (string) Str::uuid());
|
|
if ($proposal['state'] !== 'prepared') {
|
|
throw new RuntimeException('History proposal not ready.');
|
|
}
|
|
$result = ['invoice_id' => $invoice->id, 'proposal_id' => $proposal['proposal_id'], 'business_fingerprint' => historyProofFingerprint(), 'balance_before' => $payments->outstanding($invoice)];
|
|
} elseif ($job === 'validate' || $job === 'fault') {
|
|
auth()->guard()->setUser($validator);
|
|
$proposal = DB::table('pa_proposals')->orderByDesc('id')->first();
|
|
if ($job === 'fault') {
|
|
$fail = true;
|
|
DB::listen(function (QueryExecuted $event) use (&$fail) {
|
|
if ($fail && str_contains($event->sql, 'insert into "pa_imported_payments"')) {
|
|
$fail = false;
|
|
throw new RuntimeException('HISTORY_PROOF_INJECTED_FAULT');
|
|
}
|
|
});
|
|
try {
|
|
$service->engine($validator)->command('validate_history', $proposal->id, 'Prova de recuperação atómica', (string) Str::uuid());
|
|
throw new LogicException('Fault not injected.');
|
|
} catch (RuntimeException $e) {
|
|
if ($e->getMessage() !== 'HISTORY_PROOF_INJECTED_FAULT') {
|
|
throw $e;
|
|
}
|
|
}
|
|
$clean = DB::table('pa_obligations')->count() === 0 && DB::table('pa_history_imports')->count() === 0 && DB::table('pa_imported_payments')->count() === 0;
|
|
if (! $clean) {
|
|
throw new RuntimeException('Partial history import remained.');
|
|
}
|
|
$result = ['fault_rolled_back' => true, 'business_fingerprint' => historyProofFingerprint()];
|
|
} else {
|
|
$result = $service->engine($validator)->command('validate_history', $proposal->id, 'Validação concorrente independente', (string) Str::uuid());
|
|
}
|
|
} elseif ($job === 'inspect') {
|
|
auth()->guard()->setUser($validator);
|
|
$listing = $service->listing($validator);
|
|
$position = $listing['obligations'][0];
|
|
$html = Workspace::render($listing, ['host' => 'Prova PostgreSQL', 'actor' => 'Validador sintético', 'organisation' => 'Empresa sintética', 'api' => '/api/supplier-payables', 'csrf' => 'proof-only']);
|
|
$result = ['principal_minor' => $service->principal($invoice), 'balance_minor' => $payments->outstanding($invoice),
|
|
'confirmed_minor' => $position['confirmed_payments_minor'], 'pending_minor' => $position['pending_payments_minor'], 'rejected_minor' => $position['rejected_payments_minor'],
|
|
'obligations' => DB::table('pa_obligations')->count(), 'imports' => DB::table('pa_history_imports')->count(), 'payment_links' => DB::table('pa_imported_payments')->count(),
|
|
'business_fingerprint' => historyProofFingerprint(), 'ui_balance_correct' => str_contains($html, 'Saldo por liquidar: 8 600.00 MZN'),
|
|
'due_on' => $position['due_on'], 'days_overdue' => $position['days_overdue']];
|
|
$protected = [];
|
|
foreach (['pa_history_imports', 'pa_imported_payments'] as $table) {
|
|
try {
|
|
DB::transaction(fn () => DB::table($table)->delete());
|
|
throw new LogicException('History was deletable.');
|
|
} catch (QueryException $e) {
|
|
if (! str_contains($e->getMessage(), 'PA_APPEND_ONLY')) {
|
|
throw $e;
|
|
}$protected[] = $table;
|
|
}
|
|
}
|
|
$result['append_only_tables'] = $protected;
|
|
$db = DB::connection();
|
|
$db->beginTransaction();
|
|
try {
|
|
DB::table('external_payments')->where('status', 'pending')->increment('amount_minor');
|
|
try {
|
|
$service->balance($invoice);
|
|
throw new LogicException('Changed original history was accepted.');
|
|
} catch (HttpException $e) {
|
|
if ($e->getStatusCode() !== 409) {
|
|
throw $e;
|
|
}$result['changed_history_refused'] = true;
|
|
}
|
|
} finally {
|
|
$db->rollBack();
|
|
}
|
|
if (historyProofFingerprint() !== $result['business_fingerprint']) {
|
|
throw new RuntimeException('Original history not restored.');
|
|
}
|
|
} elseif (in_array($job, ['decision_fault', 'decision_confirm', 'decision_inspect'], true)) {
|
|
config(['common-payables.history_decisions_enabled' => true]);
|
|
auth()->guard()->setUser($validator);
|
|
$payment = \App\Models\ExternalPayment::query()->where('amount_minor', 100000)->sole();
|
|
$frozen = fn () => hash('sha256', Payables::canonical(['imports' => DB::table('pa_history_imports')->get()->toArray(), 'links' => DB::table('pa_imported_payments')->get()->toArray()]));
|
|
$original = $frozen();
|
|
if ($job === 'decision_fault') {
|
|
$fingerprint = historyProofFingerprint();
|
|
$auditCount = DB::table('audit_logs')->count();
|
|
$fail = true;
|
|
DB::listen(function (QueryExecuted $event) use (&$fail) {
|
|
if ($fail && str_contains($event->sql, 'insert into "pa_payment_decisions"')) {
|
|
$fail = false;
|
|
throw new RuntimeException('DECISION_PROOF_FAULT');
|
|
}
|
|
});
|
|
try {
|
|
$payments->review($validator, $payment, 'confirmed', 'Prova de reversão atómica');
|
|
throw new LogicException('Expected injected decision failure.');
|
|
} catch (RuntimeException $e) {
|
|
if ($e->getMessage() !== 'DECISION_PROOF_FAULT') {
|
|
throw $e;
|
|
}
|
|
}
|
|
if ($payment->fresh()->status !== 'pending' || DB::table('pa_payment_decisions')->count() !== 0 || $auditCount !== DB::table('audit_logs')->count() || $fingerprint !== historyProofFingerprint()) {
|
|
throw new LogicException('Partial decision remained.');
|
|
}
|
|
$result = ['rolled_back' => true, 'balance_minor' => $service->balance($invoice)];
|
|
} elseif ($job === 'decision_confirm') {
|
|
$payments->review($validator, $payment, 'confirmed', 'Conferência concorrente sintética');
|
|
$result = ['balance_minor' => $service->balance($invoice), 'frozen' => $frozen(), 'original' => $original];
|
|
} else {
|
|
$position = $service->listing($validator)['obligations'][0];
|
|
try {
|
|
DB::transaction(fn () => DB::table('pa_payment_decisions')->delete());
|
|
throw new LogicException('Decision was deletable.');
|
|
} catch (QueryException $e) {
|
|
if (! str_contains($e->getMessage(), 'PA_APPEND_ONLY')) {
|
|
throw $e;
|
|
}
|
|
}
|
|
$result = ['balance_minor' => $service->balance($invoice), 'confirmed_minor' => $position['confirmed_payments_minor'], 'pending_minor' => $position['pending_payments_minor'],
|
|
'decisions' => DB::table('pa_payment_decisions')->count(), 'events' => DB::table('pa_events')->where('kind', 'payable.imported_payment_decided')->count(),
|
|
'original_status' => $payment->fresh()->status, 'append_only' => true, 'frozen' => $original];
|
|
}
|
|
} else {
|
|
throw new LogicException('Unknown proof job.');
|
|
}
|
|
echo json_encode($result,JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE).PHP_EOL;
|