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

57 lines
9.7 KiB
PHP

<?php
if(!($s['stockPeriod']??false))throw new LogicException('New period fixture required');
$seedFile=$s['root'].'/stock-period-seed.json';
if(is_file($seedFile)){$seed=json_decode(file_get_contents($seedFile),true,32,JSON_THROW_ON_ERROR);}else{
if($db->table('stock_movements')->count())throw new LogicException('Fresh physical fixture required');
$seed=$db->transaction(function()use($db,$n){
$base=(array)$db->table('stock_items')->where('id',$n['stock'])->first();unset($base['id']);
$item=function($code,$name,$unit='UN')use($db,$base){return $db->table('stock_items')->insertGetId(array_replace($base,['code'=>$code,'name'=>$name,'unit'=>$unit]));};
$wh=(array)$db->table('warehouses')->where('id',$n['warehouse'])->first();unset($wh['id']);
$second=$db->table('warehouses')->insertGetId(array_replace($wh,['code'=>'QA-PERIOD-SECOND','name'=>'Second mapped QA warehouse']));
$excluded=$db->table('warehouses')->insertGetId(array_replace($wh,['code'=>'QA-PERIOD-EXCLUDED','name'=>'Unmapped QA warehouse']));
$movement=function($item,$warehouse,$quantity,$at,$tenant=null,$actor=null)use($db,$n){$db->table('stock_movements')->insert(['tenant_id'=>$tenant??$n['tenant'],'stock_item_id'=>$item,'warehouse_id'=>$warehouse,'moved_by'=>$actor??$n['receiver'],'movement_type'=>'qa_period_fixture','quantity_milli'=>$quantity,'moved_at'=>$at,'snapshot'=>json_encode(['reference'=>'QA-PERIOD-REPORT-FIXTURE'])]);};
$b=$item('QA-PERIOD-KG','Literal%_ opening only','KG');$large=$item('QA-PERIOD-LARGE','Exact quantity beyond JS integer');$future=$item('QA-PERIOD-FUTURE','Future only');
foreach([['10000','2026-10-31 23:59:59'],['2000','2026-11-01 00:00:00'],['-3000','2026-11-02 12:00:00'],['500','2026-11-03 23:59:59'],['700000','2026-11-04 00:00:00']]as[$qty,$at])$movement($n['stock'],$n['warehouse'],$qty,$at);
$movement($n['stock'],$second,'4000','2026-10-30 12:00:00');$movement($n['stock'],$second,'-1000','2026-11-02 12:00:00');
$movement($b,$n['warehouse'],'6000','2026-10-30 12:00:00');
$movement($large,$n['warehouse'],'9007199254740994','2026-10-31 12:00:00');$movement($large,$n['warehouse'],'-1','2026-11-02 12:00:00');
$movement($future,$n['warehouse'],'888','2026-11-04 00:00:00');
for($i=1;$i<=101;$i++)$movement($item('QA-PERIOD-'.str_pad((string)$i,3,'0',STR_PAD_LEFT),'Paged QA article '.$i),$n['warehouse'],'1','2026-11-02 10:00:00');
$movement($n['stock'],$excluded,'777','2026-11-02 10:00:00');
$tenant=(int)$db->table('stock_items')->where('id',$n['foreign_stock'])->value('tenant_id');$actor=(int)$db->table('users')->where('tenant_id',$tenant)->value('id');
$movement($n['foreign_stock'],$n['foreign_warehouse'],'9999','2026-11-02 10:00:00',$tenant,$actor);
return ['passed'=>true,'second'=>$second,'excluded'=>$excluded,'opening_only'=>$b,'large'=>$large,'future'=>$future,'report_pairs'=>105,'fixture_movements'=>$db->table('stock_movements')->count(),'business_workflow_claim'=>false];
});
$profile=$s['root'].'/stock-binding.json';$p=json_decode(file_get_contents($profile),true);foreach($p['bindings']as&$binding)$binding['warehouse_ids'][]=$seed['second'];unset($binding);file_put_contents($profile,json_encode($p,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
file_put_contents($seedFile,json_encode($seed,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
$fingerprint=fn()=>hash('sha256',json_encode([$db->table('stock_items')->orderBy('id')->get(),$db->table('warehouses')->orderBy('id')->get(),$db->table('stock_movements')->orderBy('id')->get(),$db->table('audit_logs')->orderBy('id')->get(),DB::table('permission_grants')->orderBy('id')->get(),DB::table('identity_audit')->orderBy('id')->get()]));
$before=$fingerprint();$enter('validator',0,['module'=>'stock']);$query=['view'=>'period','from'=>'2026-11-01','to'=>'2026-11-03'];
$report=fn($extra=[])=>$call('validator','/api/stock-read?'.http_build_query(array_replace($query,$extra)));
$r=$report();$ok($r['status']===200&&$r['data']['pagination']['total']===105&&count($r['data']['rows'])===25,'period report groups105 eligible pairs and returns only25 rows');$first=$r['data'];
$all=[];for($page=1;$page<=5;$page++){$r=$report(['page'=>$page,'snapshot'=>$first['snapshot']]);$ok($r['status']===200,'stable reference admits period page'.$page);$all=array_merge($all,$r['data']['rows']);}
$keys=array_map(fn($v)=>$v['stock_item_id'].':'.$v['warehouse_id'],$all);$ok(count($keys)===105&&count(array_unique($keys))===105,'all period pairs are reachable once without pagination overlap');
$find=function($item,$warehouse)use($all){foreach($all as$row)if($row['stock_item_id']===$item&&$row['warehouse_id']===$warehouse)return $row;throw new RuntimeException('Missing expected report pair');};
$a=$find($n['stock'],$n['warehouse']);$ok([$a['opening_milli'],$a['incoming_milli'],$a['outgoing_milli'],$a['closing_milli'],$a['period_movements']]===['10000','2500','3000','9500','3'],'UTC boundary includes first midnight and last second but excludes next midnight');
$a=$find($n['stock'],$seed['second']);$ok([$a['opening_milli'],$a['incoming_milli'],$a['outgoing_milli'],$a['closing_milli']]===['4000','0','1000','3000'],'same article in another mapped warehouse has its own opening and closing');
$a=$find($seed['opening_only'],$n['warehouse']);$ok($a['unit']==='KG'&&$a['opening_milli']==='6000'&&$a['closing_milli']==='6000'&&$a['period_movements']==='0','opening-only pair remains visible without inventing period movements or mixing units');
$a=$find($seed['large'],$n['warehouse']);$ok($a['opening_milli']==='9007199254740994'&&$a['outgoing_milli']==='1'&&$a['closing_milli']==='9007199254740993','SQL numeric sums remain exact beyond JavaScript safe integers');
$ok(!in_array($seed['future'],array_column($all,'stock_item_id'),true)&&!in_array($n['foreign_stock'],array_column($all,'stock_item_id'),true)&&!in_array($seed['excluded'],array_column($all,'warehouse_id'),true),'future-only foreign tenant and unmapped warehouse pairs are absent');
$r=$report(['per_page'=>100]);$r2=$report(['per_page'=>100,'page'=>2,'snapshot'=>$r['data']['snapshot']]);$ok(count($r['data']['rows'])===100&&count($r2['data']['rows'])===5,'pages100 and5 cover the same report universe');
$r=$report(['search'=>'%_']);$ok($r['status']===200&&count($r['data']['rows'])===1&&$r['data']['rows'][0]['stock_item_id']===$seed['opening_only'],'period search treats percent and underscore literally');
$r=$report(['warehouse_id'=>$seed['second']]);$ok($r['status']===200&&$r['data']['pagination']['total']===1,'warehouse filter applies to opening entries issues and closing together');
$ok($report(['warehouse_id'=>$seed['excluded']])['status']===403&&$report(['warehouse_id'=>$n['foreign_warehouse']])['status']===403,'explicit unmapped and foreign warehouse filters are refused');
$ok($report(['from'=>'2026-11-04'])['status']===422&&$report(['from'=>'2026-02-30'])['status']===422&&$report(['per_page'=>500])['status']===422,'reversed impossible dates and excessive page size are rejected');
$ok($call('validator','/api/stock-read?view=period')['status']===422&&$call('validator','/api/stock-read?view=balances&from=2026-11-01')['status']===422,'period requires explicit dates and legacy views reject report-only fields');
$ok($report(['snapshot'=>$first['snapshot'],'to'=>'2026-11-02'])['status']===409&&$report(['snapshot'=>$first['snapshot'],'per_page'=>100])['status']===409,'date or page-size change cannot reuse another report reference');
$ok($report(['snapshot'=>'invalid'])['status']===409,'tampered report reference is rejected');
$ok(in_array($call('actor','/api/stock-read?'.http_build_query($query))['status'],[403,409],true),'Purchasing authority alone cannot read the physical period report');
$enter('validator',1);$ok(in_array($call('validator','/api/stock-read?'.http_build_query($query),null,1)['status'],[403,409],true),'Purchasing module context cannot bypass Stock report isolation');
$enter('validator',1,['module'=>'stock','foreign'=>1]);$ok($call('validator','/api/stock-read?'.http_build_query($query),null,1)['status']===403,'another unit cannot reuse the report binding');
$enter('validator',0,['module'=>'stock']);$ok($report(['snapshot'=>$first['snapshot']])['status']===409,'new session context invalidates an older report reference');
$ok($fingerprint()===$before,'all report reads leave fixture metadata movements audit and grants unchanged');
$r=$report();$token=$r['data']['snapshot'];$db->table('stock_movements')->insert(['tenant_id'=>$n['tenant'],'stock_item_id'=>$n['stock'],'warehouse_id'=>$n['warehouse'],'moved_by'=>$n['receiver'],'movement_type'=>'qa_period_fixture','quantity_milli'=>'100','moved_at'=>'2026-11-02 13:00:00','snapshot'=>'{}']);
$ok($report(['snapshot'=>$token])['status']===409,'new append invalidates a previously observed report');$r=$report(['search'=>$db->table('stock_items')->where('id',$n['stock'])->value('code')]);$row=array_values(array_filter($r['data']['rows'],fn($v)=>$v['warehouse_id']===$n['warehouse']))[0];$ok($row['closing_milli']==='9600','new reference recovers the updated exact physical closing');
$ok($db->table('goods_receipts')->count()===0&&$db->table('invoices')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('pa_obligations')->where('tenant_id',$n['tenant'])->count()===0&&$db->table('external_payments')->where('tenant_id',$n['tenant'])->count()===0,'report fixture and reads produce no business receipt invoice obligation or payment');
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'period_pairs'=>105,'page_sizes'=>[25,100],'fixture'=>$seed,'read_fingerprint_before'=>$before,'read_fingerprint_after_before_explicit_append'=>$before,'explicit_revision_fixture_appends'=>1,'sql_fixture_not_workflow'=>true,'business_commands'=>0,'visual_review'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." stock period checks passed.\n";