Files
SGF/scripts/stock-prefix-proof.php
2026-11-04 09:37:20 +02:00

46 lines
10 KiB
PHP

<?php
use Illuminate\Support\Facades\DB;
use Tempo\Finance\GeneralLedger\{StockValuation,StockValuationBaseline};
use Tempo\Finance\Payables\Payables;
$output=$s['root'].'/'.$phase.'.json';if(file_exists($output))throw new LogicException('Completed prefix proof preserved');
if(hash_file('sha256',$s['root'].'/StockValuationBaseline.php')!==$s['reference']['baseline_sha256'])throw new LogicException('Baseline changed');require $s['root'].'/StockValuationBaseline.php';
$checks=[];$ok=function($v,$name)use(&$checks){if(!$v)throw new RuntimeException($name);$checks[]=$name;};$refuse=function($fn,$name)use($ok){try{$fn();}catch(DomainException){$ok(true,$name);return;}throw new RuntimeException('Expected refusal: '.$name);};
$compat=json_decode(file_get_contents($s['compatibility_settings']),true,64,JSON_THROW_ON_ERROR);$config=DB::connection()->getConfig();config(['database.connections.stock_prefix_compat'=>array_replace($config,['database'=>$compat['database'],'username'=>$compat['role'],'password'=>$compat['password']])]);$oldDefault=DB::getDefaultConnection();
DB::setDefaultConnection('stock_prefix_compat');$c=DB::connection();$c->beginTransaction();$c->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
try{
$f=json_decode(file_get_contents($compat['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);$u=App\Models\User::findOrFail($f['actors']['read']);auth()->setUser($u);config(['stock-ledger.synthetic_enabled'=>true]);require_once dirname(__DIR__).'/integrations/purchasing/StockValuationAdapter.php';$composition=Tempo\Integration\Purchasing\StockValuationAdapter::for($u);$auth=(new ReflectionProperty($composition,'authorize'))->getValue($composition);$read=(new ReflectionProperty($composition,'readAuthorize'))->getValue($composition);$baseline=new StockValuationBaseline($c,$f['scope'],$u->id,'qa-stock-person-'.$u->id,$auth,$read);
foreach($f['movements']as$m){$type=StockValuation::TYPES[$m['movement_type']];$a=$baseline->source($type,$m['id']);$b=$composition->stock->source($type,$m['id']);$ok(Payables::canonical($a)===Payables::canonical($b),'small source snapshot remains byte-equivalent '.$m['id']);$posted=json_decode($c->table('gl_journals')->where('snapshot->source->stock_movement_id',$m['id'])->value('snapshot'),true)['source'];$ok(Payables::canonical($posted)===Payables::canonical($b),'accepted posted evidence and revisions remain exact '.$m['id']);}
}finally{$c->rollBack();DB::setDefaultConnection($oldDefault);auth()->forgetGuards();}
$db=DB::connection();$schema=match($phase){'prefix-review-final'=>'prefix_final_','prefix-review-join'=>'prefix_join_','prefix-review-cursor'=>'prefix_cursor_','prefix-review-selected'=>'prefix_selected_','prefix-review-measured'=>'prefix_measured_','prefix-review-types'=>'prefix_types_','prefix-review-safe'=>'prefix_safe_',default=>'prefix_'}; $schema.=substr($s['tag'],0,8);$db->statement('CREATE SCHEMA '.$schema);$db->statement('SET search_path TO '.$schema);$db->statement("SET statement_timeout='30s'");
try{
// Metadata-only infrastructure fixtures, NOT posted journals or workflows.
$db->statement('CREATE TABLE stock_movements(id bigint PRIMARY KEY,tenant_id bigint,stock_item_id bigint,warehouse_id bigint)');$db->statement('CREATE TABLE gl_journals(id bigint PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,source_revision text,snapshot jsonb)');
$tracked=new class($db->getPdo(),$db->getDatabaseName(),'', $db->getConfig()) extends Illuminate\Database\PostgresConnection {
public int $maxRows=0;
public ?int $deadlineNs=null;
public function select($query,$bindings=[],$useReadPdo=true,array $fetchUsing=[]){if($this->deadlineNs!==null&&hrtime(true)>$this->deadlineNs)throw new RuntimeException('PREFIX_QA_CALL_BUDGET');$rows=parent::select($query,$bindings,$useReadPdo,$fetchUsing);if($this->deadlineNs!==null&&hrtime(true)>$this->deadlineNs)throw new RuntimeException('PREFIX_QA_CALL_BUDGET');$this->maxRows=max($this->maxRows,count($rows));return $rows;}
};$tracked->setEventDispatcher($db->getEventDispatcher());
$scope=['tenant_id'=>1,'company_id'=>0,'unit_id'=>0];$new=new StockValuation($tracked,$scope,1,'prefix-test',fn()=>null,fn()=>null);$old=new StockValuationBaseline($tracked,$scope,1,'prefix-test',fn()=>null,fn()=>null);$method=new ReflectionMethod($new,'priorJournals');$book=(object)['id'=>1,'stock_item_id'=>1,'warehouse_id'=>1];$rate=(object)['id'=>1];$active=null;$queryCount=0;$maxReturned=0;
DB::listen(function($q)use(&$active,&$queryCount){if($active!==null)$queryCount++;});
$callNew=fn($n)=>$method->invoke($new,$book,(object)['id'=>$n+1],$rate);$callOld=fn($n)=>$old->benchmarkPrefix($book,(object)['id'=>$n+1],$rate);
$seed=function(int $n,int $padding=0)use($db){$db->statement('TRUNCATE stock_movements,gl_journals');$db->insert("INSERT INTO stock_movements SELECT i,1,1,1 FROM generate_series(1,?) i",[$n]);$db->insert("INSERT INTO gl_journals SELECT i,1,0,0,repeat(md5('source-'||i),2),jsonb_build_object('source',jsonb_build_object('stock_book_id',1,'stock_movement_id',i,'stock_rate_id',1,'stock_fact_revision',repeat(md5('fact-'||i),2)),'qa_padding',repeat('x',?)) FROM generate_series(1,?) i",[$padding,$n]);$db->statement('ANALYZE stock_movements');$db->statement('ANALYZE gl_journals');};
foreach([0,1,25,26,501]as$n){$seed($n);$a=$callOld($n);$b=$callNew($n);if($n<=25)$ok($a===$b,'exact legacy representation at prefix '.$n);else{$h=hash_init('sha256');foreach($a as$ref)hash_update($h,Payables::canonical($ref)."\n");$ok($b['count']===$n&&$b['sha256']===hash_final($h)&&$b['first_references']===array_slice($a,0,25)&&count($b['first_references'])===25,'full evidence digest and bounded preview at prefix '.$n);}}
$seed(1000);$before=$callNew(1000);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_fact_revision}',to_jsonb(repeat('a',64))) WHERE id=1000");$after=$callNew(1000);$ok($before['sha256']!==$after['sha256']&&$before['first_references']===$after['first_references'],'change outside preview changes evidence digest');
$db->delete('DELETE FROM gl_journals WHERE id=1000');$refuse(fn()=>$callNew(1000),'missing predecessor refused');$seed(1000);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_rate_id}','2'::jsonb) WHERE id=1000");$refuse(fn()=>$callNew(1000),'changed rate in late chunk refused');$seed(1000);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_fact_revision}','null'::jsonb) WHERE id=1000");$refuse(fn()=>$callNew(1000),'incomplete evidence in late chunk refused');
$seed(26);$db->insert("INSERT INTO stock_movements VALUES(-1,99,1,1)");$db->insert("INSERT INTO gl_journals VALUES(100000,99,0,0,repeat('f',64),jsonb_build_object('source',jsonb_build_object('stock_book_id',1,'stock_movement_id',1,'stock_rate_id',1,'stock_fact_revision',repeat('f',64))))");$ok($callNew(26)['count']===26,'other tenant excluded');$db->update('UPDATE gl_journals SET company_id=99 WHERE id=26');$refuse(fn()=>$callNew(26),'other company cannot complete coverage');$seed(26);$db->update('UPDATE gl_journals SET unit_id=99 WHERE id=26');$refuse(fn()=>$callNew(26),'other unit cannot complete coverage');
$seed(26);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_movement_id}',to_jsonb('1'::text)) WHERE id=1");$a=$callOld(26);$b=$callNew(26);$h=hash_init('sha256');foreach($a as$ref)hash_update($h,Payables::canonical($ref)."\n");$ok($b['sha256']===hash_final($h)&&$b['first_references']===array_slice($a,0,25),'movement reference JSON type preserved exactly');
$seed(26);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_rate_id}',to_jsonb('1'::text)) WHERE id=26");$refuse(fn()=>$callOld(26),'baseline refuses string rate ID');$refuse(fn()=>$callNew(26),'projection also refuses string rate ID');
$seed(26);$db->update("UPDATE gl_journals SET snapshot=jsonb_set(snapshot,'{source,stock_rate_id}','1.0'::jsonb) WHERE id=26");$refuse(fn()=>$callOld(26),'baseline refuses fractional JSON rate ID');$refuse(fn()=>$callNew(26),'projection also refuses fractional JSON rate ID');
$ok((int)$db->selectOne("SELECT count(*) n FROM pg_cursors WHERE name LIKE 'stock_prefix_%'")->n===0,'normal completion and refusal leave no open prefix cursors');
$metrics=[];
foreach([1000,5000,10000]as$n){$seed($n,8192);$callNew($n);$callOld($n);$samples=['before'=>[],'after'=>[]];for($repeat=0;$repeat<3;$repeat++)foreach($repeat%2===0?['before','after']:['after','before']as$variant){
$tracked->beginTransaction();$tracked->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
try{gc_collect_cycles();memory_reset_peak_usage();$startMemory=memory_get_usage(false);$queryCount=0;$tracked->maxRows=0;$active=$variant;$start=hrtime(true);$tracked->deadlineNs=$start+15000000000;$value=$variant==='before'?$callOld($n):$callNew($n);$elapsed=(hrtime(true)-$start)/1e6;$peak=memory_get_peak_usage(false)-$startMemory;$active=null;$maxRows=$tracked->maxRows;
if($variant==='before'){$h=hash_init('sha256');foreach($value as$v)hash_update($h,Payables::canonical($v)."\n");$digest=hash_final($h);}else{$digest=$value['sha256'];$ok(count($value['first_references'])<=25,'bounded preview sample '.$n.'/'.$repeat);$ok($maxRows<=500,'actual result batch bounded '.$n.'/'.$repeat);}
$samples[$variant][]=['elapsed_ms'=>$elapsed,'queries'=>$queryCount,'incremental_peak_php_bytes'=>$peak,'max_result_rows'=>$maxRows,'dependency_count'=>$n,'digest'=>$digest,'evidence_bytes'=>strlen(Payables::canonical($value))];unset($value);
}finally{$active=null;$tracked->deadlineNs=null;$tracked->rollBack();}
}if(count(array_unique(array_column(array_merge($samples['before'],$samples['after']),'digest')))!==1)throw new RuntimeException('Benchmark evidence diverged');$metrics[]=['prefix_records'=>$n,'samples'=>$samples];}
$ok((int)$db->selectOne("SELECT count(*) n FROM pg_cursors WHERE name LIKE 'stock_prefix_%'")->n===0,'measurements close all prefix cursors');
file_put_contents($output,json_encode(['passed'=>true,'checks'=>$checks,'metadata_only_fixture'=>true,'new_financial_effects'=>0,'no_indices_added'=>true,'batch_limit'=>500,'inline_reference_limit'=>25,'payload_bytes_per_historical_snapshot'=>8192,'metrics'=>$metrics,'measurement'=>'warm sequential, three samples, alternating order, incremental peak PHP memory; SQL/server memory excluded','old_published_three_source_snapshots_unchanged'=>true,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." prefix checks and comparable metadata measurements passed.\n";
}finally{$db->statement('SET search_path TO public');}