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

24 lines
12 KiB
PHP

<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
$s=json_decode(file_get_contents(getenv('RECEIPT_COMPOSITION_STATE')),true,64,JSON_THROW_ON_ERROR);$out=$s['root'].'/sourcing-result.json';if(file_exists($out))throw new LogicException('Completed sourcing proof preserved');require $s['app'].'/vendor/autoload.php';$app=require $s['app'].'/bootstrap/app.php';$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$n=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);config(['database.connections.sourcing_verify'=>$s['nativeConnection']]);$db=DB::connection('sourcing_verify');$checks=[];$ok=function($v,$name)use(&$checks,$s){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($s['root'].'/sourcing-progress.json',json_encode($checks,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));};$servers=[];$clients=[];$sessions=[];
try{
foreach([0,1]as$i){$socket=stream_socket_server('tcp://127.0.0.1:0');$port=(int)substr(strrchr(stream_socket_get_name($socket,false),':'),1);fclose($socket);$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();$servers[]=$server;for($j=0;$j<80;$j++){if($socket=@fsockopen('127.0.0.1',$port,$errno,$error,.1)){fclose($socket);break;}usleep(50000);}$clients[]=new GuzzleHttp\Client(['base_uri'=>'http://127.0.0.1:'.$port,'http_errors'=>false,'timeout'=>25]);}
$enter=function($role,$i=0,$query=[])use(&$sessions,$clients){$jar=new GuzzleHttp\Cookie\CookieJar;$r=$clients[$i]->get('/qa/enter/'.$role,['cookies'=>$jar,'query'=>$query]);$sessions[$role.$i]=['jar'=>$jar]+json_decode((string)$r->getBody(),true,32,JSON_THROW_ON_ERROR);};$options=function($role,$i=0,$data=null,$headers=[])use(&$sessions){$v=$sessions[$role.$i];$o=['cookies'=>$v['jar'],'headers'=>$headers+['Accept'=>'application/json','X-SGF-Context'=>$v['context'],'X-CSRF-TOKEN'=>$v['csrf']]];if($data!==null)$o['json']=$data;return $o;};$call=function($role,$path,$data=null,$i=0,$headers=[])use($clients,$options,$s){$r=$clients[$i]->request($data===null?'GET':'POST',$path,$options($role,$i,$data,$headers));$text=(string)$r->getBody();if($r->getStatusCode()>=500)file_put_contents($s['root'].'/sourcing-last-500.txt',$text);return ['status'=>$r->getStatusCode(),'data'=>json_decode($text,true),'body'=>$text];};foreach(['actor','issuer','validator',...(($s['requests']??false)?['approver']:[])]as$role)$enter($role);$decisionRole=($s['requests']??false)?'approver':'issuer';$enter('actor',1);$base='/api/purchasing-sourcing';$url=$base.'/requests/'.$n['requisition'];
$fingerprint=function()use($db){$h=hash_init('sha256');foreach(['quotations','quotation_items','proposal_evaluations','purchase_awards','purchase_award_approvals','purchase_orders','budget_entries','audit_logs']as$table){hash_update($h,$table);foreach($db->table($table)->orderBy('id')->get()as$row)hash_update($h,json_encode((array)$row,JSON_THROW_ON_ERROR));}return hash_final($h);};
$ok($db->table('quotations')->count()===0&&$db->table('proposal_evaluations')->count()===0&&$db->table('purchase_awards')->count()===0,'source starts with no proposal evaluation or adjudication');$r=$call('actor',$base);$ok($r['status']===200&&count($r['data']['data'])===1,'only mapped approved source request is listed');$ok($call('actor','/purchasing/sourcing')['status']===200,'functional sourcing workspace is served');$ok($call('validator',$url)['status']===403,'warehouse cannot access supplier comparison');$ok($call('actor',$base.'/requests/'.$n['foreign_requisition'])['status']===403,'another tenant process is refused');$ok($call('actor',$url,null,0,['X-SGF-Context'=>'old-context'])['status']===409,'stale SGF context is refused');
$preview=function($role,$action,$data,$key=null)use($call,$url){$state=$call($role,$url);if($state['status']!==200)throw new RuntimeException('Source state unavailable');return $call($role,$url.'/preview',['operation_key'=>$key??(string)Str::uuid(),'revision'=>$state['data']['revision'],'action'=>$action,'data'=>$data]);};$commit=function($role,$cut)use($call,$url){return $call($role,$url.'/confirm',['preview'=>$cut]);};
$quote=function($i)use($n){return ['supplier_id'=>$n['supplier_ids'][$i],'reference'=>'SGF-Q-'.($i+1),'delivery_days'=>5,'valid_until'=>date('Y-m-d',strtotime('+30 days')),'payment_terms'=>'30 dias no ensaio','no_conflict'=>true,'items'=>[['requisition_item_id'=>$n['line'],'unit_price'=>(string)(100+$i*20),'discount_type'=>'none','discount_value'=>'0']]];};$key=(string)Str::uuid();$data=$quote(0);$before=$fingerprint();$p=$preview('actor','quote',$data,$key);$ok($p['status']===200&&$p['data']['impact']['total_minor']===10000,'original quotation pricing supplies the preview total');$ok($fingerprint()===$before,'preview savepoint commits no proposal evaluation budget or audit records');$cut=$p['data']['preview'];$other=$data;$other['reference']='Other contents';$alternate=$preview('actor','quote',$other,$key)['data']['preview'];$ok($commit('actor',$cut.'tampered')['status']===409,'tampered preview is refused');
$db->unprepared("CREATE FUNCTION public.sourcing_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.action='sgf.sourcing.command' THEN RAISE EXCEPTION 'synthetic sourcing receipt failure'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER sourcing_qa_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION public.sourcing_qa_fail()");try{$ok($commit('actor',$cut)['status']===500,'failure to record the command receipt fails the confirmation');$ok($fingerprint()===$before,'native proposal lines and audit roll back with the command receipt');}finally{$db->unprepared('DROP TRIGGER sourcing_qa_fail ON audit_logs; DROP FUNCTION public.sourcing_qa_fail()');}
$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url.'/confirm',$options('actor',0,['preview'=>$cut])),$clients[1]->postAsync($url.'/confirm',$options('actor',1,['preview'=>$cut]))]);$statuses=array_map(fn($r)=>$r->getStatusCode(),$responses);sort($statuses);$ok($statuses===[200,201],'two independent workers recover the same proposal command once');$q1=json_decode((string)$responses[0]->getBody(),true)['quotation_id'];$ok($db->table('quotations')->count()===1&&$db->table('quotation_items')->count()===1,'concurrent repetition creates one proposal and one line');$ok($commit('actor',$alternate)['status']===409,'same operation key with other contents is refused');
$quotes=[$q1];foreach([1,2]as$i){$p=$preview('actor','quote',$quote($i));$r=$commit('actor',$p['data']['preview']);$ok($r['status']===201,'explicit proposal registered '.($i+1));$quotes[]=$r['data']['quotation_id'];}
$selection=['quotation_id'=>$q1,'justification'=>'Escolha expressa com base na comparação sintética.','delivery_date'=>date('Y-m-d',strtotime('+7 days')),'delivery_location'=>'Armazém QA'];$ok($preview('actor','select',$selection)['status']===422&&$db->table('purchase_awards')->count()===0,'missing evaluations cannot be bypassed to create an award');
foreach($quotes as$q)foreach(['actor','issuer']as$role){$data=['quotation_id'=>$q,'quality'=>'8','history'=>'5','payment'=>'8','comment'=>'Fundamento individual exclusivamente sintético.','no_conflict'=>true];$p=$preview($role,'evaluate',$data);$ok($p['status']===200,'native evaluation preview available '.$role.'/'.$q);$r=$commit($role,$p['data']['preview']);$ok($r['status']===201,'individual evaluation recorded '.$role.'/'.$q);}
$r=$call('actor',$url);$ok($r['data']['comparison']['ready']===true&&$r['data']['comparison']['top_ids']===[$q1],'original comparison identifies eligible top without choosing it');$duplicate=['quotation_id'=>$q1,'quality'=>'8','history'=>'5','payment'=>'8','comment'=>'Tentativa repetida de outra avaliação.','no_conflict'=>true];$ok($preview('actor','evaluate',$duplicate)['status']===422&&$db->table('proposal_evaluations')->count()===6,'another key cannot create a second evaluation by the same person');
$p=$preview('actor','select',$selection);$selectCut=$p['data']['preview'];$grant=DB::table('permission_grants')->where($f['scope'])->where('user_id',$f['actor'])->where('permission','purchasing_sourcing.select')->first();DB::table('permission_grants')->where('id',$grant->id)->delete();try{$ok($commit('actor',$selectCut)['status']===403,'selection right is revalidated at confirmation');}finally{DB::table('permission_grants')->insert((array)$grant);}$r=$commit('actor',$selectCut);$ok($r['status']===201&&$r['data']['status']==='pending','chosen proposal starts original distinct award approval');$award=$r['data']['award_id'];$ok($commit('actor',$selectCut)['status']===200&&$db->table('purchase_awards')->count()===1,'selection repetition creates no second adjudication');$ok($preview('actor','quote',$quote(0))['status']===422,'proposal window closes after adjudication');$ok($commit('actor',$cut)['status']===200,'historical proposal receipt remains recoverable after the window closes');
$decision=['award_id'=>$award,'decision'=>'approved','comment'=>'Conferência independente da base e do valor final.'];$ok($preview('actor','decide',$decision)['status']===403,'award preparer cannot approve their own decision');$p=$preview($decisionRole,'decide',$decision);$r=$commit($decisionRole,$p['data']['preview']);$ok($r['status']===201&&$r['data']['status']==='approved','distinct eligible head approves through original workflow');$ok($commit($decisionRole,$p['data']['preview'])['status']===200&&$db->table('purchase_award_approvals')->where('decision','approved')->count()===1,'approval repetition preserves one recorded decision');
$orders='/api/purchasing-orders/awards/'.$award;$r=$call('actor',$orders);$ok($r['status']===200&&$r['data']['can_issue']===true,'adjudication created in SGF reaches order workflow without rebinding IDs');$p=$call('actor',$orders.'/preview',['revision'=>$r['data']['revision']]);$r=$call('actor',$orders.'/issue',['preview'=>$p['data']['preview']]);$ok($r['status']===201,'approved SGF adjudication issues one canonical order');$order=$r['data']['order'];$receipts='/api/purchasing-receipts/orders/'.$order['id'];$r=$call('validator',$receipts);$payload=['revision'=>$r['data']['revision'],'idempotency_key'=>(string)Str::uuid(),'delivery_note'=>'SGF-SOURCING-RECEIPT','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],'items'=>[['purchase_order_item_id'=>$order['items'][0]['id'],'received_quantity'=>'1.000','accepted_quantity'=>'1.000','quality_status'=>'compliant','stock_item_id'=>$n['stock'],'notes'=>null]]];$p=$call('validator',$receipts.'/preview',$payload);$r=$call('validator',$receipts.'/confirm',['preview'=>$p['data']['preview']]);$ok($r['status']===201&&count($r['data']['movements'])===1,'warehouse receives the actual order created from SGF sourcing');$r=$call('validator',$receipts);$ok($r['data']['progress']['state']==='complete','proposal-to-adjudication-to-order-to-receipt path completes');$ok($db->table('quotations')->count()===3&&$db->table('proposal_evaluations')->count()===6&&$db->table('purchase_awards')->count()===1&&$db->table('purchase_orders')->count()===1&&$db->table('stock_movements')->count()===1,'complete path leaves exactly the intended business facts');$ok($db->table('invoices')->count()===0&&$db->table('budget_consumptions')->count()===0&&(int)$db->table('budget_entries')->where('tenant_id',$n['tenant'])->sum('committed_minor')===10000,'one source-budget commitment without invoice payment or consumption');
$h=$call('issuer',$url.'/history?page=1');$ok($h['status']===200&&$h['data']['per_page']===25&&count($h['data']['data'])===25,'full append-only history is paginated on server');$h2=$call('issuer',$url.'/history?page=2');$ids1=array_column($h['data']['data'],'id');$ids2=array_column($h2['data']['data'],'id');$ok($h2['status']===200&&count($ids2)>0&&array_intersect($ids1,$ids2)===[],'history pages are distinct and expose older evidence');
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'quotes'=>3,'evaluations'=>6,'awards'=>1,'approved_decisions'=>1,'orders'=>1,'receipts'=>1,'movements'=>1,'commit_minor'=>10000,'whole_selected_path_complete'=>true,'concurrency_statuses'=>$statuses,'preview_persistent_fingerprint_unchanged'=>true,'rollback_recovery'=>true,'original_controllers_and_workflows'=>true,'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." sourcing-to-receipt checks passed.\n";
}finally{foreach($servers as$i=>$server){$server->stop(2);file_put_contents($s['root'].'/sourcing-http-'.$i.'.log',$server->getOutput()."\n".$server->getErrorOutput());}}