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

15 lines
9.6 KiB
PHP

<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
$output=$s['root'].'/cost-correction-cases.json';if(file_exists($output)||DB::table('gl_corrections')->exists())throw new LogicException('Fresh correction regression required');$checks=0;$cases=[];$ok=function($v,$m)use(&$checks){$checks++;if(!$v)throw new LogicException($m);};$refuse=function($fn)use($ok){$caught=false;try{$fn();}catch(DomainException|Symfony\Component\HttpKernel\Exception\HttpException|Illuminate\Auth\Access\AuthorizationException){$caught=true;}$ok($caught,'Expected individual/group/source/authority refusal');};
$case=function($name,$fn)use(&$cases,&$checks,$enter){$at=$checks;DB::beginTransaction();try{$fn();$cases[]=['name'=>$name,'checks'=>$checks-$at,'passed'=>true];}finally{while(DB::transactionLevel())DB::rollBack();$enter('read');}};
$revise=function()use($dc,$enter,$g,$cg){$u=$enter('prepare');$r=$dc->rate($g['order_id'],$u->id,['entry_id'=>$g['material_id'],'entry_version'=>1,'identifier'=>'QA-COST-MATERIAL','version'=>3,'previous_id'=>$cg['replacement_rate_id'],'currency'=>'MZN','unit'=>'UN','amount'=>'60.00','evidence'=>'Version-only successor changes evidence without changing295.'],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'rate','id'=>$r['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent version-only successor rate review.'],(string)Str::uuid());$u=$enter('prepare');$calc=$dc->calculate($g['order_id'],$u->id,['version'=>3,'previous_id'=>$cg['replacement_calculation_id'],'currency'=>'MZN','lines'=>[['entry_id'=>$g['material_id'],'entry_version'=>1,'rate_id'=>$r['id'],'rate_version'=>3],['entry_id'=>$g['time_id'],'entry_version'=>1,'rate_id'=>$g['rate_ids']['time'],'rate_version'=>1]]],(string)Str::uuid());$u=$enter('validate');$dc->decide($g['order_id'],$u->id,['kind'=>'calculation','id'=>$calc['id'],'version'=>3,'decision'=>'approved','reason'=>'Independent unchanged295 with new source version.'],(string)Str::uuid());};
$case('explicit-asymmetric-impact-and-indivisible-application',function()use($captureCorrection,$approveCorrection,$command,$postCorrectionBody,$ledger,$enter,$g,$ok,$refuse){$refuse(fn()=>$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Uncorrected current295 blocks completeness.']));$p=$captureCorrection();$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$a=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$b=DB::table('gl_proposals')->where('id',$c->replacement_proposal_id)->first();$sa=json_decode($a->snapshot,true);$sb=json_decode($b->snapshot,true);$ok($sa['impact']['debit_minor']===27500&&$sb['impact']['debit_minor']===29500&&$sa['source']['effect_type']==='workshop.direct_cost.reversal'&&$sb['source']['effect_type']==='workshop.direct_cost.replacement','Four lines explicitly capture275/295 and distinct cost types');$refuse(fn()=>$command('validate','validate',['proposal_id'=>$a->id,'revision'=>$a->revision,'decision'=>'approved','reason'=>'Cannot review isolated pair part.']));$approveCorrection($p);$refuse(fn()=>$command('prepare','post',['proposal_id'=>$a->id,'revision'=>$a->revision,'reason'=>'Cannot post isolated pair part.']));$key=(string)Str::uuid();$r=$command('prepare','correct-post',$postCorrectionBody($p),$key);$same=$command('prepare','correct-post',$postCorrectionBody($p),$key);$other=$command('prepare','correct-post',$postCorrectionBody($p));$ok($same['replayed']&&$other['result_id']===$r['result_id']&&DB::table('gl_journals')->count()===3,'Same/other key and source identity recover one pair');$refuse($captureCorrection);$book=$ledger->listing($enter('read'),['cost_order_id'=>$g['order_id']]);$ok(count($book['journals'])===3&&count($book['events'])===9&&$book['totals']['debit_minor']===84500,'Typed original and both pair parts retain correct history and gross total');});
$case('faults-at-pair-boundaries-rollback-and-recovery',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$ok){$p=$captureCorrection();$approveCorrection($p);foreach(['after_header','after_lines','after_event','after_correction_first','after_correction_result','after_receipt'] as $step){$caught=false;$count=DB::table('gl_commands')->count();try{$command('prepare','correct-post',$postCorrectionBody($p),(string)Str::uuid(),function($name)use($step){if($name===$step)throw new RuntimeException('cost-pair-injected-'.$step);});}catch(RuntimeException $e){$caught=str_starts_with($e->getMessage(),'cost-pair-injected-');}$ok($caught&&DB::table('gl_journals')->count()===1&&DB::table('gl_lines')->count()===2&&!DB::table('gl_correction_results')->exists()&&DB::table('gl_commands')->count()===$count,'No partial pair/result/receipt at '.$step);}$r=$command('prepare','correct-post',$postCorrectionBody($p));$ok(DB::table('gl_journals')->count()===3&&DB::table('gl_correction_results')->count()===1,'Recovery completes both parts exactly once');});
$case('database-rejects-orphan-cost-reversal',function()use($captureCorrection,$approveCorrection,$ledger,$enter,$ok){$p=$captureCorrection();$approveCorrection($p);$c=DB::table('gl_corrections')->where('id',$p['correction_id'])->first();$part=DB::table('gl_proposals')->where('id',$c->reverse_proposal_id)->first();$engine=$ledger->engine($enter('prepare'));$caught=false;DB::beginTransaction();try{(new ReflectionMethod($engine,'post'))->invoke($engine,['proposal_id'=>$part->id,'revision'=>$part->revision],'SQL adversarial orphan probe, rollback only.');DB::statement('SET CONSTRAINTS ALL IMMEDIATE');}catch(Illuminate\Database\QueryException $e){$caught=str_contains($e->getMessage(),'GL_COST_CORRECTION_PAIR_REQUIRED');}finally{DB::rollBack();}$ok($caught&&DB::table('gl_journals')->count()===1&&!DB::table('gl_correction_results')->exists(),'Deferred SQL constraint rejects balanced individual orphan');});
$case('changed-source-version-requires-new-conference',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok){$old=$captureCorrection();$approveCorrection($old);$revise();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($old)));$fresh=$captureCorrection();$ok($fresh['revision']!==$old['revision']&&$fresh['correction_id']!==$old['correction_id'],'New exact version creates another immutable group capture');$refuse(fn()=>$approveCorrection($old));$approveCorrection($fresh);$command('prepare','correct-post',$postCorrectionBody($fresh));$ok(DB::table('gl_correction_results')->count()===1&&DB::table('gl_journals')->count()===3,'Only newly conferred group applies');});
$case('rights-person-and-replay-authority',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$scope,$g,$refuse){$p=$captureCorrection();DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['prepare']]);$refuse(fn()=>$approveCorrection($p));DB::table('pa_access')->where($scope)->where('user_id',$g['actors']['validate'])->update(['person'=>'cost-qa-person-'.$g['actors']['validate']]);$approveCorrection($p);$key=(string)Str::uuid();$command('prepare','correct-post',$postCorrectionBody($p),$key);DB::table('permission_grants')->where($scope)->where('user_id',$g['actors']['prepare'])->where('permission','general_ledger.correct_post')->delete();$refuse(fn()=>$command('prepare','correct-post',$postCorrectionBody($p),$key));});
$case('readers-foreign-original-disabled-capability-and-closed-period',function()use($command,$correctionBody,$captureCorrection,$scope,$g,$refuse){$refuse(fn()=>$command('read','correct-prepare',$correctionBody));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_id'=>999999])));$refuse(fn()=>$command('prepare','correct-prepare',array_replace($correctionBody,['journal_on'=>'2026-10-28'])));$old=config('cost_ledger.correction_enabled');config(['cost_ledger.correction_enabled'=>false]);try{$refuse($captureCorrection);}finally{config(['cost_ledger.correction_enabled'=>$old]);}$period=(array)DB::table('gl_periods')->where($scope)->first();unset($period['id']);$period['version']=2;$period['status']='closed';DB::table('gl_periods')->insert($period);$refuse($captureCorrection);});
$case('new-change-after-correction-blocks-close-no-implicit-chain',function()use($captureCorrection,$approveCorrection,$postCorrectionBody,$command,$revise,$refuse,$ok){$p=$captureCorrection();$approveCorrection($p);$command('prepare','correct-post',$postCorrectionBody($p));$period=$command('prepare','period-prepare',['period_key'=>'QA-2026-10','reason'=>'Capture resolved current correction before later change.']);$command('validate','period-validate',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'decision'=>'approved','reason'=>'Independent period snapshot.']);$revise();$refuse(fn()=>$command('prepare','period-close',['check_id'=>$period['check_id'],'revision'=>$period['revision'],'reason'=>'Changed source must refuse old period capture.']));$refuse($captureCorrection);$ok(!DB::table('gl_period_closures')->exists()&&DB::table('gl_correction_results')->count()===1,'No silent correction chain or close after subsequent source change');});
$ok($originalsIntact()&&DB::table('gl_journals')->count()===1&&!DB::table('gl_corrections')->exists(),'All probes rolled back, original/source/cost/outbox/rights remain exact');file_put_contents($output,json_encode(['passed'=>true,'cases'=>$cases,'checks'=>$checks,'rollback_only'=>true,'source_originals_outbox_rights_unchanged'=>true,'asymmetric_pair_and_orphan_sql_enforced'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo "Cost correction regression: ".count($cases)." cases/$checks checks, exact asymmetric pair, SQL orphan refusal, atomic failures and preserved originals.\n";