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

77 lines
11 KiB
PHP

<?php
use App\Modules\Purchasing\SupplierPayables;
use Illuminate\Support\Str;
use Tempo\Finance\Payables\Payables;
$out=$s['root'].'/'.$phase.'.json';if(file_exists($out)||!($g['cycle_enabled']??false))throw new LogicException('Fresh cycle proof required');
$finance=$cycleFinance??app(SupplierPayables::class);
$checks=0;$ok=function(bool $v,string $why)use(&$checks){$checks++;if(!$v)throw new LogicException($why);};
$refuse=function(Closure $fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException $e){$caught=true;}$ok($caught,'Expected current-state/authority refusal');};
$fin=fn(string $role,string $action,array $body)=>$finance->engine($enter($role))->settlements()->command($action,$body,(string)Str::uuid());
$post=function(array $p,string $reason='Apply reviewed synthetic cycle effect.')use($command){$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'decision'=>'approved','reason'=>'Independent exact cycle effect review.']);return $command('prepare','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>$reason]);};
$effect=fn(string $type,int $id,string $rule)=>$command('prepare','effect-prepare',['effect_type'=>$type,'source_id'=>$id,'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','rule_key'=>$rule,'reason'=>'Capture confirmed financial fact without changing debt.']);
$baseline=$financialFingerprint();$rollback=in_array($phase,['ledger-cycle-cases','native-cycle-cases'],true);if($rollback)DB::beginTransaction();
try{
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Cannot close unaccounted recognised debt.']));
$recognition=$post($command('prepare','prepare',$g['body']));
$sourceRevision=DB::table('pa_obligations')->where('id',$g['obligation_id'])->value('source_revision');
$payment=$fin('prepare','payment-prepare',['obligation_id'=>$g['obligation_id'],'source_revision'=>$sourceRevision,'amount'=>'20.00','currency'=>'MZN','paid_on'=>'2026-10-29','fact_reference'=>'SIM-CYCLE-PAYMENT-20','evidence_reference'=>'SIM-CYCLE-EVIDENCE-20','reason'=>'Explicit synthetic payment fact; no execution.']);
$fin('validate','payment-decide',['payment_id'=>$payment['payment_id'],'decision'=>'confirmed','evidence_reference'=>'SIM-CYCLE-CONFIRMED-20','reason'=>'Independent synthetic payment evidence.']);
$plan=$fin('prepare','allocation-prepare',['payment_id'=>$payment['payment_id'],'reference'=>'SIM-CYCLE-ALLOCATION-20','allocations'=>[['obligation_id'=>$g['obligation_id'],'source_revision'=>$sourceRevision,'amount'=>'20.00']],'reason'=>'Explicit partial allocation of twenty.']);
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Pending financial allocation prevents closing.']));
$fin('validate','allocation-decide',['proposal_id'=>$plan['proposal_id'],'decision'=>'confirmed','reason'=>'Independent partial allocation confirmation.']);
$allocation=(int)DB::table('ps_allocations')->where('obligation_id',$g['obligation_id'])->value('id');
$position=$finance->engine($enter('read'))->positionForSource($g['local_source_id']);$ok($position['principal_minor']===6000&&$position['balance_minor']===4000,'Partial allocation preserves principal and balance40');
$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Unaccounted confirmed allocation prevents closing.']));
$p=$effect('allocation.confirmed',$allocation,'QA-ALLOCATION');
$refuse(fn()=>$command('read','post',['proposal_id'=>$p['proposal_id'],'revision'=>$p['revision'],'reason'=>'Reader cannot post financial effect.']));
$refuse(fn()=>$command('validate','validate',['proposal_id'=>$p['proposal_id'],'revision'=>str_repeat('0',64),'decision'=>'approved','reason'=>'Old revision must refuse.']));
$allocated=$post($p);$j=DB::table('gl_journals')->where('id',$allocated['journal_id'])->first();$ok((int)$j->debit_minor===2000&&json_decode($j->snapshot,true)['source']['principal_minor']===6000,'Journal amount20 is distinct from principal60');
$r=$fin('prepare','reversal-prepare',['allocation_id'=>$allocation,'source_revision'=>$sourceRevision,'reason'=>'Reverse the full synthetic allocation.']);
$fin('validate','reversal-confirm',['proposal_id'=>$r['proposal_id'],'reason'=>'Independent confirmation of integral reversal.']);
$reversal=(int)DB::table('ps_reversals')->where('allocation_id',$allocation)->value('id');
$reversed=$post($effect('allocation.reversed',$reversal,'QA-REVERSAL'));
$position=$finance->engine($enter('read'))->positionForSource($g['local_source_id']);$ok($position['balance_minor']===6000,'Confirmed reversal restores balance60 without changing principal');
$correction=$command('prepare','correct-prepare',['journal_id'=>$recognition['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'Technical correction with unchanged financial debt.']);
$refuse(fn()=>$command('prepare','correct-post',['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'reason'=>'Cannot apply unreviewed correction.']));
$command('validate','correct-validate',['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'decision'=>'approved','reason'=>'Independent review of both corrective journal impacts.']);
$body=['correction_id'=>$correction['correction_id'],'revision'=>$correction['revision'],'reason'=>'Atomically apply full reversal and replacement.'];$key=(string)Str::uuid();
if($phase==='ledger-cycle-race-prepare'){
file_put_contents($s['root'].'/ledger-cycle-race-prepared.json',json_encode(['passed'=>true,'checks'=>$checks,'correction_body'=>$body,'recognition_journal_id'=>$recognition['journal_id'],'allocation_id'=>$allocation,'reversal_id'=>$reversal],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Prepared independent corrective pair for real concurrent workers.\n";exit;
}
if($rollback){
$n=DB::table('gl_journals')->count();$fault=function($at){if($at==='after_correction_first')throw new RuntimeException('QA pair fault');};
try{$command('prepare','correct-post',$body,$key,$fault);throw new LogicException('Fault required');}catch(RuntimeException $e){$ok($e->getMessage()==='QA pair fault','Fault reached first corrective journal');}
$ok(DB::table('gl_journals')->count()===$n&&!DB::table('gl_correction_results')->exists(),'First journal rolled back with whole corrective pair');
}
$corrected=$command('prepare','correct-post',$body,$key);$repeat=$command('prepare','correct-post',$body,$key);$other=$command('prepare','correct-post',$body);
$ok($repeat['replayed']&&$other['result_id']===$corrected['result_id']&&DB::table('gl_correction_results')->count()===1,'Same/different keys preserve one correction pair');
$original=DB::table('gl_journals')->where('id',$recognition['journal_id'])->first();$ok($original&&DB::table('gl_journals')->count()===5&&DB::table('gl_lines')->count()===10,'Original plus allocation/reversal and atomic corrective pair retained');
$period=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture complete reconciled artificial period.']);
$refuse(fn()=>$command('read','period-close',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Reader cannot close.']));
if($rollback){
DB::beginTransaction();try{
$rule=(array)DB::table('gl_rules')->where('rule_key','QA-ALLOCATION')->first();unset($rule['id']);$rule['version']++;DB::table('gl_rules')->insert($rule);
$refuse(fn()=>$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Changed evidence requires a fresh close conference.']));
}finally{DB::rollBack();}
DB::beginTransaction();try{
if($native??false)DB::table('pa_access')->where($scope)->where(['user_id'=>$f['actors']['validate'],'action'=>'general_ledger.period_validate'])->delete();else DB::table('permission_grants')->where($scope)->where(['user_id'=>$f['actors']['validate'],'permission'=>'general_ledger.period_validate'])->delete();
$refuse(fn()=>$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Revoked period right must refuse.']));
}finally{DB::rollBack();}
}
$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Independent complete period evidence and totals review.']);
$close=['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Close artificial period with preserved evidence.'];$closeKey=(string)Str::uuid();
if($rollback){
try{$command('prepare','period-close',$close,$closeKey,function($at){if($at==='after_period_version')throw new RuntimeException('QA close fault');});throw new LogicException('Fault required');}catch(RuntimeException $e){$ok($e->getMessage()==='QA close fault','Close fault reached version append');}
$ok(DB::table('gl_periods')->count()===1&&!DB::table('gl_period_closures')->exists(),'Failed close leaves open period intact');
}
$closed=$command('prepare','period-close',$close,$closeKey);$again=$command('prepare','period-close',$close,$closeKey);$semantic=$command('prepare','period-close',$close);
$ok($closed['state']==='closed'&&$again['replayed']&&$semantic['closure_id']===$closed['closure_id'],'Closed period recovers receipt without reopening');
$refuse(fn()=>$effect('allocation.confirmed',$allocation,'QA-ALLOCATION'));
$refuse(fn()=>$command('prepare','correct-prepare',['journal_id'=>$recognition['journal_id'],'journal_on'=>'2026-10-29','period_key'=>'QA-2026-10','reason'=>'No second correction to same original.']));
DB::select('SELECT gl_assert_balanced(id) FROM gl_journals');
$totals=$ledger->listing($enter('read'))['totals'];$ok($totals===['debit_minor'=>22000,'credit_minor'=>22000,'net_minor'=>0],'Five journals reconcile gross debits and credits220');
$result=['passed'=>true,'checks'=>$checks,'rollback_only'=>$rollback,'principal_minor'=>6000,'allocated_balance_minor'=>4000,'final_balance_minor'=>6000,'debit_minor'=>22000,'credit_minor'=>22000,'journals'=>5,'lines'=>10,'correction_pairs'=>1,'period_closed'=>true,'obligation_id'=>$g['obligation_id'],'recognition_journal_id'=>$recognition['journal_id'],'allocation_id'=>$allocation,'reversal_id'=>$reversal,'correction_id'=>$correction['correction_id'],'period_check_id'=>$period['check_id']];
}finally{if($rollback)while(DB::transactionLevel()>0)DB::rollBack();}
if($rollback){$ok($financialFingerprint()===$baseline&&DB::table('gl_journals')->count()===0,'Adversarial cycle fully rolled back');$result['checks']=$checks;}
file_put_contents($out,json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cycle complete: $checks checks, ".($rollback?'rollback-only':'persisted QA workflow').".\n";