Files
SGF/scripts/ledger-cycle-native-proof.php
T
2026-11-04 09:37:20 +02:00

87 lines
18 KiB
PHP

<?php
use App\Models\User;
use App\Services\{GeneralLedger,SupplierPayables,InvoiceWorkflow};
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tempo\Finance\GeneralLedger\{LedgerSchema,CycleSchema,PayableJournal};
use Tempo\Finance\Payables\{Payables,SettlementSchema,ReversalSchema};
$ledgerPackage=$bundle['ledger']??dirname(__DIR__).'/packages/general-ledger';
config(['general-ledger.synthetic_enabled'=>true,'general-ledger.cycle_enabled'=>true,'general-ledger.path'=>$ledgerPackage]);require_once $ledgerPackage.'/bootstrap.php';
if($phase==='native-cycle-setup'){
if(DB::getSchemaBuilder()->hasTable('users'))throw new LogicException('Fresh native database required');
Illuminate\Support\Facades\Artisan::call('migrate',['--force'=>true]);Illuminate\Support\Facades\Date::setTestNow('2026-10-29 12:00:00');require $c['root'].'/tests/Support/InvoiceFixture.php';$nativeFixture=invoiceFixture();
auth()->setUser($nativeFixture['validator']);app(InvoiceWorkflow::class)->validate($nativeFixture['validator'],$nativeFixture['invoice']);
$app->instance('env','payable-testing');
$scope=['tenant_id'=>(int)$nativeFixture['author']->tenant_id,'company_id'=>0,'unit_id'=>0];
$reader=User::factory()->create(['tenant_id'=>$scope['tenant_id'],'purchasing_role'=>App\Enums\PurchasingRole::Finance]);
$f=['scope'=>$scope,'actors'=>['prepare'=>$nativeFixture['author']->id,'validate'=>$nativeFixture['validator']->id,'read'=>$reader->id]];
foreach(['prepare'=>['view','prepare','payments_view','payment_prepare','allocation_prepare','reversal_prepare'],'validate'=>['view','validate','payments_view','payment_confirm','allocation_confirm','reversal_confirm'],'read'=>['view','payments_view']] as $role=>$actions)foreach($actions as $a)DB::table('pa_access')->insert($scope+['user_id'=>$f['actors'][$role],'action'=>$a,'person'=>'native-cycle-person-'.$f['actors'][$role]]);
foreach(['prepare'=>['view','prepare','post','correct_prepare','correct_post','period_prepare','period_close'],'validate'=>['view','validate','correct_validate','period_validate'],'read'=>['view']] as $role=>$actions)foreach($actions as $a)DB::table('pa_access')->insert($scope+['user_id'=>$f['actors'][$role],'action'=>'general_ledger.'.$a,'person'=>'native-cycle-person-'.$f['actors'][$role]]);
LedgerSchema::install(DB::connection());CycleSchema::install(DB::connection());SettlementSchema::install();ReversalSchema::install();
$accounts=[];foreach(['QA-CONTRAPARTIDA','QA-FORNECEDOR','QA-LIQUIDACAO'] as $code)$accounts[$code]=DB::table('gl_accounts')->insertGetId($scope+['code'=>$code,'label'=>'Artificial QA '.$code,'is_movement'=>true,'currency'=>'MZN']);
$common=['created_by'=>$f['actors']['prepare'],'reason'=>'Explicit authorised synthetic cycle configuration.','created_at'=>now()];
DB::table('gl_periods')->insert($scope+$common+['period_key'=>'QA-2026-10','version'=>1,'starts_on'=>'2026-10-01','ends_on'=>'2026-10-31','status'=>'open']);
foreach(['QA-PAYABLE-JOURNAL'=>['supplier_payable.recognised','QA-CONTRAPARTIDA','QA-FORNECEDOR'],'QA-ALLOCATION'=>['allocation.confirmed','QA-FORNECEDOR','QA-LIQUIDACAO'],'QA-REVERSAL'=>['allocation.reversed','QA-LIQUIDACAO','QA-FORNECEDOR'],'QA-CORRECTION-REVERSE'=>['journal.reversal','QA-FORNECEDOR','QA-CONTRAPARTIDA'],'QA-CORRECTION-REPLACE'=>['journal.replacement','QA-CONTRAPARTIDA','QA-FORNECEDOR']] as $key=>[$type,$d,$cr])DB::table('gl_rules')->insert($scope+$common+['rule_key'=>$key,'version'=>1,'policy'=>PayableJournal::POLICY,'effect_type'=>$type,'currency'=>'MZN','valid_from'=>'2026-10-01','valid_to'=>'2026-10-31','debit_account_id'=>$accounts[$d],'credit_account_id'=>$accounts[$cr]]);
$finance=app(SupplierPayables::class);auth()->setUser($nativeFixture['author']);$p=$finance->engine($nativeFixture['author'])->command('prepare',$nativeFixture['invoice']->id,'Explicit native synthetic payable capture.',(string)Str::uuid());auth()->setUser($nativeFixture['validator']);$o=$finance->engine($nativeFixture['validator'])->command('validate',$p['proposal_id'],'Independent native synthetic recognition.',(string)Str::uuid());
$g=['cycle_enabled'=>true,'obligation_id'=>$o['obligation_id'],'local_source_id'=>$nativeFixture['invoice']->id,'body'=>['obligation_id'=>$o['obligation_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-PAYABLE-JOURNAL','reason'=>'Individual native synthetic journal preparation.'],'native_originals_sha256'=>fingerprint()];
file_put_contents($s['root'].'/fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));file_put_contents($s['root'].'/ledger-fixture.json',json_encode($g,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "New native book: actual invoice workflow60, one financial obligation, no ledger or payment effects.\n";exit;
}
$app->instance('env','payable-testing');$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$g=json_decode(file_get_contents($s['root'].'/ledger-fixture.json'),true,64,JSON_THROW_ON_ERROR);$scope=$f['scope'];
$enter=function($role)use($f){$u=User::findOrFail($f['actors'][$role]);auth()->setUser($u);return $u;};$ledger=app(GeneralLedger::class);$cycleFinance=app(SupplierPayables::class);
$command=fn($role,$action,$body,$key=null,$fault=null)=>$ledger->engine($enter($role),$fault)->command($action,$body,$key??(string)Str::uuid());
$financialFingerprint=function(){ $h=hash_init('sha256');foreach(['pa_sources','pa_proposals','pa_obligations','pa_commands','pa_events','pa_access','invoices','invoice_items','external_payments','budget_entries','budget_consumptions','stock_movements'] as $t){hash_update($h,$t);foreach(DB::table($t)->orderBy('id')->cursor() as $r)hash_update($h,Payables::canonical((array)$r));}return hash_final($h);};
if(in_array($phase,['native-cycle-reports-seed','native-cycle-reports-cases'],true)){require __DIR__.'/ledger-reports-proof.php';exit;}
if($phase==='native-cycle-reports-isolation'){require __DIR__.'/ledger-reports-isolation.php';exit;}
if($phase==='native-cycle-http'){
require __DIR__.'/ledger-reports-observer.php';
auth()->forgetGuards();
$app->bind(Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,static fn($container)=>new class($container,$container->make(Illuminate\Contracts\Encryption\Encrypter::class)) extends Illuminate\Foundation\Http\Middleware\PreventRequestForgery {protected $addHttpCookie=false;});
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-enter/{role}',function(Illuminate\Http\Request $r,string $role)use($f,$g){abort_unless(isset($f['actors'][$role]),404);auth()->login(User::findOrFail($f['actors'][$role]));$r->session()->regenerate();$r->session()->put('ledger_demo',true);if($r->query('open')==='cycle')return redirect('/general-ledger/cycle');return response()->json(['csrf'=>csrf_token(),'obligation_id'=>$g['obligation_id'],'source_id'=>$g['local_source_id']])->header('Cache-Control','no-store');});
Illuminate\Support\Facades\Route::middleware('web')->get('/qa/ledger-demo',function()use($s,$g){$tag=htmlspecialchars($s['tag'],ENT_QUOTES,'UTF-8');return response('<!doctype html><html lang="pt"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ciclo SGC QA</title><main style="font:18px system-ui;max-width:950px;margin:40px auto;padding:24px" data-ledger-demo="'.$tag.'"><h1>SGC autónomo — ciclo contabilístico QA</h1><p>Uma factura efectivamente conferida pelo workflow nativo, obrigação #'.$g['obligation_id'].' de60MZN e núcleo contabilístico comum. Livro independente dos ensaios SGF. Sem execução monetária ou ligação a SGF remoto.</p><p><a href="/qa/ledger-enter/prepare?open=cycle">Preparar / aplicar</a> · <a href="/qa/ledger-enter/validate?open=cycle">Rever</a> · <a href="/qa/ledger-enter/read?open=cycle">Consultar</a></p><ol><li>Reconhecimento contabilístico60 com revisão distinta.</li><li>Facto de pagamento20, confirmação, afectação20 e confirmação financeiras; saldo40.</li><li>Conferir/contabilizar a afectação. Reverter financeiramente, saldo60, e contabilizar a reversão.</li><li>Preparar, rever e aplicar par técnico de estorno60/substituição60.</li><li>Conferir e fechar o períodoQA-2026-10 com outra pessoa.</li></ol><p>Contas/regras artificiais, data29/10/2026; sem reset, reabertura, utilização real ou aceitação visual presumida.</p></main></html>')->header('Cache-Control','no-store');});
$app->handleRequest(Illuminate\Http\Request::capture());exit;
}
if(in_array($phase,['native-cycle-cases','native-cycle-workflow'],true)){require __DIR__.'/ledger-cycle-cases.php';if(fingerprint()!==$g['native_originals_sha256'])throw new LogicException('Native original source/stock/budget changed');exit;}
if($phase==='native-cycle-final'){require __DIR__.'/ledger-cycle-final.php';exit;}
if($phase==='native-cycle-report-reconcile'){require __DIR__.'/ledger-reports-reconcile.php';exit;}
if($phase==='native-cycle-cost-refusal'){
$output=$s['root'].'/cost-capability-refusal.json';if(file_exists($output))throw new LogicException('Completed refusal preserved');$before=DB::table('gl_proposals')->count();$caught=false;
try{$command('prepare','effect-prepare',['effect_type'=>'workshop.direct_cost.validated','source_id'=>1,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-DIRECT-COST','reason'=>'Uncomposed workshop origin must refuse.']);}catch(DomainException $e){$caught=str_contains($e->getMessage(),'não composta');}
if(!$caught||DB::table('gl_proposals')->count()!==$before||DB::getSchemaBuilder()->hasTable('gl_cost_sources'))throw new LogicException('Native must not require or fabricate workshop source');file_put_contents($output,json_encode(['passed'=>true,'uncomposed_cost_refused'=>true,'cost_schema_not_required'=>true,'no_proposal_or_effect_created'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "SGC explicitly refuses uncomposed cost, without schema or effects.\n";exit;
}
if(in_array($phase,['native-cycle-reports-old-package','native-cycle-reports-old-package-postcheck'],true)){
$output=$s['root'].'/'.(str_ends_with($phase,'postcheck')?'ledger-reports-old-package-postcheck.json':'ledger-reports-old-package.json');if(file_exists($output)||($s['common_bundle_sha256']??null)!=='6a507ea7d708a12a8e526e4c610696d8db84079d18aede470237b65505630e7e')throw new LogicException('Explicit frozen previous package required');$count=DB::table('gl_journals')->count();$caught=false;
try{$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=$e->getStatusCode()===503;}
if(!$caught||DB::table('gl_journals')->count()!==$count)throw new LogicException('Missing packaged report capability must refuse without writes');file_put_contents($output,json_encode(['passed'=>true,'frozen_previous_package'=>true,'missing_capability_status'=>503,'journals_unchanged'=>true,'no_package_replacement'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Previous frozen package explicitly refuses unavailable report with503, no replacement or business write.\n";exit;
}
if($phase==='native-cycle-reports-package'){
$output=$s['root'].'/ledger-reports-package.json';if(file_exists($output)||!isset($s['common_bundle'])||DB::table('gl_proposals')->exists())throw new LogicException('Fresh pinned packaged report proof required');
$before=$financialFingerprint();$p=$command('prepare','prepare',$g['body']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent packaged source review.']);$command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply one packaged report source.']);
$data=$ledger->report($enter('read'),'trial-balance',['from_on'=>'2026-10-01','to_on'=>'2026-10-31']);$code=(new ReflectionClass(Tempo\Finance\GeneralLedger\PayableJournal::class))->getFileName();$ui=(new ReflectionClass(Tempo\Finance\GeneralLedger\ReportsWorkspace::class))->getFileName();
if(!str_starts_with(realpath($code),realpath($s['common_bundle']).DIRECTORY_SEPARATOR)||!str_starts_with(realpath($ui),realpath($s['common_bundle']).DIRECTORY_SEPARATOR)||count($data['rows'])!==2||$data['totals']['debit_minor']!==6000||$financialFingerprint()!==$before||DB::getSchemaBuilder()->hasTable('gl_sales_capability'))throw new LogicException('Packaged report parity or isolation failed');
$html=Tempo\Finance\GeneralLedger\ReportsWorkspace::render($data,['host'=>'SGC packaged QA','return_url'=>'/general-ledger/cycle']);if(!str_contains($html,'QA-FORNECEDOR'))throw new LogicException('Packaged report view missing');
file_put_contents($output,json_encode(['passed'=>true,'checks'=>6,'pinned_manifest_sha256'=>$s['common_bundle_sha256'],'runtime_from_package'=>true,'ui_from_package'=>true,'journals'=>1,'lines'=>2,'debit_minor'=>6000,'sales_schema_not_installed'=>true,'financial_authority_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Pinned native report package: six checks, source and UI loaded from artifact, no Sales composition or extra financial effect.\n";exit;
}
if($phase==='native-cycle-sales-refusal'){
$output=$s['root'].'/sales-capability-refusal.json';if(file_exists($output))throw new LogicException('Completed capability refusal preserved');$caught=false;
try{$command('prepare','effect-prepare',['effect_type'=>'sales.invoice','source_id'=>1,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>'QA-SALES','reason'=>'Standalone host must not gain Sales by package update.']);}catch(DomainException $e){$caught=str_contains($e->getMessage(),'não composta');}
if(!$caught||DB::table('gl_journals')->exists()||DB::table('gl_proposals')->exists()||DB::getSchemaBuilder()->hasTable('si_invoices'))throw new LogicException('Native Sales source must remain unavailable with no effect');
file_put_contents($output,json_encode(['passed'=>true,'sales_not_composed'=>true,'no_journal_or_proposal'=>true,'no_sgf_sales_table_dependency'=>true],JSON_PRETTY_PRINT));echo "Native composition refuses Sales explicitly without journal or SGF Sales dependency.\n";exit;
}
if($phase==='native-cycle-bundle'){
$output=$s['root'].'/finance-bundle-proof.json';if(file_exists($output)||!isset($s['common_bundle']))throw new LogicException('Fresh artifact proof required');$root=realpath($s['common_bundle']);$checks=0;
foreach([Payables::class,PayableJournal::class,Tempo\Finance\GeneralLedger\LedgerCycle::class,Tempo\Finance\GeneralLedger\CycleWorkspace::class,Tempo\Finance\Composition\Bundle::class] as $class){$path=realpath((new ReflectionClass($class))->getFileName());if(!str_starts_with($path,$root.DIRECTORY_SEPARATOR))throw new LogicException('Runtime escaped standalone artifact');$checks++;}
$before=fingerprint();$p=$command('prepare','prepare',$g['body']);$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent recognition using verified local artifact.']);$body=['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Apply standalone package recognition once.'];$key=(string)Str::uuid();$j=$command('prepare','post',$body,$key);$again=$command('prepare','post',$body,$key);
if(!$again['replayed']||$j['journal_id']!==$again['journal_id']||DB::table('gl_journals')->count()!==1||DB::table('gl_lines')->count()!==2||fingerprint()!==$before)throw new LogicException('Standalone recognition/replay/source preservation differs');$checks+=3;
$data=$ledger->workspace($enter('read'));if($data['book']['totals']['debit_minor']!==6000||$data['book']['totals']['credit_minor']!==6000)throw new LogicException('Standalone workspace totals differ');$checks++;
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'runtime_classes_from_artifact'=>5,'one_journal_two_lines'=>true,'source_stock_budget_preserved'=>true,'manifest_sha256'=>$s['common_bundle_sha256'],'no_sgf_core_source_loaded'=>true,'no_remote_sgf'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Verified standalone artifact: $checks checks, one recognition and replay without source changes.\n";exit;
}
if($phase==='native-cycle-actor-check'){
$output=$s['root'].'/ledger-cycle-actor-check.json';if(file_exists($output))throw new LogicException('Completed actor proof preserved');$before=$financialFingerprint();$checks=0;$controller=app(App\Http\Controllers\GeneralLedgerController::class);
foreach(['page','index','command','payment-link'] as $method){$r=Illuminate\Http\Request::create('/general-ledger/cycle','GET');$r->setUserResolver(fn()=>new App\Models\CandidateAccount);$caught=false;try{
if($method==='command')$controller->command($r,'prepare',$ledger);elseif($method==='payment-link')(new ReflectionMethod(App\Http\Controllers\SupplierPaymentsController::class,'ledgerSettings'))->invoke(app(App\Http\Controllers\SupplierPaymentsController::class),$r);else $controller->$method($r,$ledger);
}catch(Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=$e->getStatusCode()===403;}if(!$caught)throw new LogicException('Candidate account must be refused before ledger calls');$checks++;}
$reader=$enter('read');$r=Illuminate\Http\Request::create('/api/general-ledger/cycle','GET');$r->setUserResolver(fn()=>$reader);$response=$controller->index($r,$ledger);if($response->getStatusCode()!==200||count($response->getData(true)['book']['journals'])!==5||$financialFingerprint()!==$before)throw new LogicException('Typed controller read or preservation differs');$checks++;
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'candidate_account_refused'=>true,'typed_internal_user_read'=>true,'financial_unchanged'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Internal/candidate controller boundary: $checks checks.\n";exit;
}
throw new LogicException('Unknown native cycle phase');