Files
SGF/scripts/receipt-composition-proof.php
T
2026-11-04 09:37:20 +02:00

59 lines
13 KiB
PHP

<?php
use Illuminate\Support\Facades\{DB,Schema};
use Illuminate\Support\Str;
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);if(file_exists($s['root'].'/result.json'))throw new LogicException('Completed receipt proof preserved');
require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$mode=getenv('RECEIPT_PROOF_RESUME');$resume=in_array($mode,['after-partial','after-complete'],true);$checks=$resume?json_decode(file_get_contents($s['root'].'/'.($mode==='after-complete'?'complete-accepted.json':'partial-accepted.json')),true,64,JSON_THROW_ON_ERROR):[];if($resume&&count($checks)!==($mode==='after-complete'?29:21))throw new LogicException('Exact partial evidence required');$check=function($value,$name)use(&$checks,$s){if(!$value)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/checks-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};
$finish=function()use(&$checks,$s){file_put_contents($s['root'].'/result.json',json_encode(['passed'=>true,'checks'=>$checks,'http'=>true,'canonical_receipts'=>2,'canonical_movements'=>2,'physical_milli'=>1750,'quality_pending'=>true,'original_workflow'=>true,'namespace_only_bundle'=>true,'real_rights_changed'=>false,'publication'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." integrated receipt checks passed.\n";};
if(!Schema::hasTable('users')){
require $s['app'].'/packages/budget-control/tests/sgf-schema.php';require $s['app'].'/packages/budget-control/tests/host-support.php';$f=DB::transaction(fn()=>budgetQaFixture('sgf'));
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((unit_id IS NULL AND permission IN ('company.view','company.edit','unit.create','access.manage')) OR (unit_id IS NOT NULL AND (permission LIKE 'purchasing_budget.%' OR permission IN ('unit.view','unit.edit','purchasing_receipts.view','purchasing_receipts.confirm'))))");
$now=now();$u=App\Models\User::create(['name'=>'Synthetic original issuer','email'=>'issuer-'.Str::uuid().'@example.test','password'=>Str::random(40)]);$f['issuer']=$u->id;$f['foreign_unit']=DB::table('units')->insertGetId(['tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'name'=>'Other QA unit','created_at'=>$now,'updated_at'=>$now]);DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company']]);
foreach([$f['actor'],$f['validator'],$f['issuer']]as$id){foreach(['company.view'=>null,'unit.view'=>$f['unit'],'purchasing_receipts.view'=>$f['unit']]as$p=>$unit)DB::table('permission_grants')->insertOrIgnore(['user_id'=>$id,'tenant_id'=>$f['tenant'],'company_id'=>$f['company'],'unit_id'=>$unit,'permission'=>$p,'created_at'=>$now,'updated_at'=>$now]);DB::table('permission_grants')->insertOrIgnore($f['scope']+['user_id'=>$id,'permission'=>'unit.view','unit_id'=>$f['foreign_unit'],'created_at'=>$now,'updated_at'=>$now]);}
foreach([$f['actor'],$f['issuer']]as$id)DB::table('permission_grants')->insert($f['scope']+['user_id'=>$id,'permission'=>'purchasing_receipts.confirm','created_at'=>$now,'updated_at'=>$now]);
file_put_contents($s['root'].'/fixture.json',json_encode($f,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}else{$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true,64,JSON_THROW_ON_ERROR);}
$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true,64,JSON_THROW_ON_ERROR);$bindings=[];foreach(['actor'=>'receiver','validator'=>'reader','issuer'=>'issuer']as$role=>$native)$bindings[implode(':',array_values($f['scope'])).':'.$f[$role]]=['tenant_id'=>$n['tenant'],'actor_id'=>$n[$native],'order_ids'=>[$n['order']],'warehouse_ids'=>[$n['warehouse']]];
file_put_contents($s['root'].'/binding.json',json_encode(['manifest_sha256'=>$s['manifest_sha256'],'connection'=>$s['nativeConnection'],'bindings'=>$bindings],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
config(['database.connections.receipt_verify'=>$s['nativeConnection']]);$verify=DB::connection('receipt_verify');if($mode==='after-complete'){if($verify->table('goods_receipts')->count()!==2||$verify->table('stock_movements')->count()!==2||(int)$verify->table('stock_movements')->sum('quantity_milli')!==1750)throw new LogicException('Completed business state changed');$check($verify->table('invoices')->count()===0,'receipt did not create a supplier invoice');$finish();exit;}if(!$resume)$check($verify->table('goods_receipts')->count()===0&&$verify->table('stock_movements')->count()===0,'fresh canonical book contains no receipt or movement');elseif($verify->table('goods_receipts')->count()!==1||$verify->table('stock_movements')->count()!==1||(int)$verify->table('stock_movements')->sum('quantity_milli')!==750)throw new LogicException('Partial business state changed');
if(getenv('RECEIPT_PROOF_PREPARE_ONLY')==='true'){file_put_contents($s['root'].'/demo-baseline.json',json_encode(['passed'=>true,'receipts'=>0,'movements'=>0,'explicit_new_qa_rights'=>['purchasing_receipts.view','purchasing_receipts.confirm']],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));exit;}
$socket=stream_socket_server('tcp://127.0.0.1:0');$address=stream_socket_get_name($socket,false);fclose($socket);$port=(int)substr(strrchr($address,':'),1);
$server=new Symfony\Component\Process\Process([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',$s['app'].'/public',dirname(__DIR__).'/scripts/receipt-composition-http.php'],$s['app'],null,null,180);$server->start();
try{
for($i=0;$i<80;$i++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}
$client=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);$sessions=[];
$enter=function($role,$query=[])use($client,&$sessions){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$client->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);if($r->getStatusCode()!==200)throw new RuntimeException('Entry failed');$sessions[$role]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};
$call=function($role,$url,$body=null,$headers=[])use($client,&$sessions,$s){$v=$sessions[$role];$options=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-CSRF-TOKEN'=>$v['csrf'],'X-SGF-Context'=>$v['context']]];if($body!==null)$options['json']=$body;$r=$client->request($body===null?'GET':'POST',$url,$options);$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/http-failure-body.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};
$enter('actor');$enter('validator');$enter('issuer');$base='/api/purchasing-receipts';$url=$base.'/orders/'.$n['order'];
if(!$resume){
$r=$call('actor',$base);$check($r['status']===200&&count($r['data']['data'])===1,'SGF lists only explicitly mapped canonical order');
$r=$call('actor','/purchasing/receipts');$check($r['status']===200&&str_contains($r['body'],'Recepção de mercadoria'),'SGF receipt workspace is mounted');
$r=$call('actor',$url);$check($r['status']===200&&$r['data']['progress']['state']==='not_received','native workflow supplies actual pending quantities');$detail=$r['data'];
$check($call('actor',$base.'/orders/'.$n['foreign_order'])['status']===403,'unmapped order from another tenant refused');
$check($call('actor',$url,null,['X-SGF-Context'=>'stale'])['status']===409,'stale context token refused on read');
$payload=['revision'=>$detail['revision'],'idempotency_key'=>(string)Str::uuid(),'delivery_note'=>'SGF-QA-PARTIAL','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],'items'=>[['purchase_order_item_id'=>$n['line'],'received_quantity'=>'1.000','accepted_quantity'=>'0.750','quality_status'=>'reservations','stock_item_id'=>$n['stock'],'notes'=>'Uma parte não foi aceite na conferência sintética.']]];
$check($call('validator',$url.'/preview',$payload)['status']===403,'reader cannot prepare a confirmation');$check($call('issuer',$url.'/preview',$payload)['status']===403,'original order issuer cannot receive the same order');
$r=$call('actor',$url.'/preview',$payload);$check($r['status']===200&&!$r['data']['impact']['accounting']&&!$r['data']['impact']['payments'],'explicit impact preview without financial effects');$preview=$r['data']['preview'];
$check($verify->table('goods_receipts')->count()===0&&$verify->table('stock_movements')->count()===0,'preview creates neither receipt nor physical movement');
$check($call('actor',$url.'/confirm',['preview'=>$preview.'invalid'])['status']===409,'tampered preview refused');
$r=$call('actor',$url.'/confirm',['preview'=>$preview]);$check($r['status']===201&&count($r['data']['movements'])===1&&(int)$r['data']['movements'][0]['quantity_milli']===750,'SGF confirmation invokes original workflow and shared writer exactly once');$receipt=$r['data']['receipt']['id'];
$r=$call('actor',$url.'/confirm',['preview'=>$preview]);$check($r['status']===200&&$r['data']['replayed']&&$r['data']['receipt']['id']===$receipt,'same confirmation recovers original receipt without duplication');
$check($verify->table('goods_receipts')->count()===1&&$verify->table('stock_movements')->count()===1,'canonical source remains sole physical authority');
$r=$call('validator',$base.'/history/'.$receipt);$check($r['status']===200&&(int)$r['data']['receipt']['items'][0]['rejected_milli']===250,'SGF history preserves accepted and rejected evidence with actual movement');
$check($verify->table('audit_logs')->where('action','receipt.confirmed')->count()===1,'original append-only receipt audit created once');
$r=$call('actor',$url);$check($r['data']['progress']['state']==='partial'&&(int)$r['data']['progress']['lines'][0]['received_milli']===1000,'next SGF read reflects cumulative native quantities');
$payload['idempotency_key']=(string)Str::uuid();$payload['delivery_note']='SGF-QA-SECOND';$check($call('actor',$url.'/preview',$payload)['status']===409,'stale order revision requires renewed conference');
$payload['revision']=$r['data']['revision'];$payload['items'][0]['received_quantity']='1.001';$payload['items'][0]['accepted_quantity']='1.001';$payload['items'][0]['quality_status']='compliant';$r=$call('actor',$url.'/preview',$payload);$check($r['status']===200,'second intent remains an uncommitted preview');$check($call('actor',$url.'/confirm',['preview'=>$r['data']['preview']])['status']===422,'native cumulative limit rejects over-receipt atomically');
$check($verify->table('goods_receipts')->count()===1&&$verify->table('stock_movements')->count()===1,'refused confirmation leaves all physical and receipt records unchanged');
}else{$r=$call('actor',$url);$payload=['revision'=>$r['data']['revision'],'idempotency_key'=>(string)Str::uuid(),'delivery_note'=>'SGF-QA-SECOND','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],'items'=>[['purchase_order_item_id'=>$n['line'],'received_quantity'=>'1.000','accepted_quantity'=>'1.000','quality_status'=>'compliant','stock_item_id'=>$n['stock'],'notes'=>null]]];}
$payload['items'][0]['received_quantity']='1.000';$payload['items'][0]['accepted_quantity']='1.000';$payload['warehouse_id']=$n['foreign_warehouse'];$check(in_array($call('actor',$url.'/preview',$payload)['status'],[403,422],true),'warehouse outside mapped unit refused');$payload['warehouse_id']=$n['warehouse'];
$r=$call('actor',$url.'/preview',$payload);$second=$r['data']['preview'];$grant=DB::table('permission_grants')->where($f['scope'])->where('user_id',$f['actor'])->where('permission','purchasing_receipts.confirm')->first();DB::table('permission_grants')->where('id',$grant->id)->delete();$check($call('actor',$url.'/confirm',['preview'=>$second])['status']===403,'revoked host permission is rechecked at confirmation');DB::table('permission_grants')->insert((array)$grant);
$check($call('validator',$url.'/confirm',['preview'=>$second])['status']===403,'preview cannot transfer authority to a reader');
$enter('actor',['foreign'=>1]);$check(in_array($call('actor',$url)['status'],[403,409],true),'other SGF unit cannot reuse source mapping');$enter('actor');
$r=$call('actor',$url.'/confirm',['preview'=>$second]);$check($r['status']===201&&(int)$r['data']['movements'][0]['quantity_milli']===1000,'remaining authorized quantity receives through same native workflow');
$r=$call('actor',$url);$check($r['data']['progress']['state']==='quality_pending'&&(int)$r['data']['progress']['lines'][0]['accepted_milli']===1750,'quality shortfall stays visible rather than falsely completing order');
$check($verify->table('goods_receipts')->count()===2&&$verify->table('stock_movements')->count()===2&&(int)$verify->table('stock_movements')->sum('quantity_milli')===1750,'two receipts and two unique movements; only1750 milli accepted');
$check($call('actor','/api/session')['status']===200,'host session and database restored after native success and refusals');
$check($verify->table('invoices')->count()===0,'receipt did not create a supplier invoice');$finish();
}finally{$server->stop(2);file_put_contents($s['root'].'/http-output-'.($resume?'resume':'initial').'.log',$server->getOutput()."\n".$server->getErrorOutput());}