Files
SGF/scripts/sgc-invoice-proof.php
T
2026-11-11 09:00:17 +02:00

136 lines
14 KiB
PHP

<?php
require __DIR__.'/sgc-invoice-proof-bootstrap.php';
use Illuminate\Support\Facades\{DB, Route};
use Illuminate\Support\Str;
if (($argv[1]??'') === '--worker') {
$job = json_decode(file_get_contents($proofRoot.'/'.basename($argv[2])), true);
$result = $send($job['body'],$job['key']);
file_put_contents($proofRoot.'/'.basename($argv[3]),json_encode($result,JSON_THROW_ON_ERROR));
exit;
}
if (file_exists($proofRoot.'/result.json')) throw new LogicException('Preserve completed proof');
$checks=[];
$ok=function($value,$name) use (&$checks,$proofRoot) { if (!$value) throw new RuntimeException($name); $checks[]=$name; file_put_contents($proofRoot.'/progress.json',json_encode($checks)); };
$snapshot = function ($db, ?array $tables=null) {
$tables ??= array_column($db->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"),'tablename');
$out=[];
foreach ($tables as $table) { if (!preg_match('/^[a-z_][a-z0-9_]*$/D',$table)) throw new LogicException('Table');
$out[$table]=(array)$db->selectOne('SELECT count(*) rows, md5(coalesce(string_agg(row_to_json(t)::text, chr(10) ORDER BY row_to_json(t)::text),\'\')) fingerprint FROM "'.$table.'" t'); }
$out['sequences']=$db->select("SELECT sequencename,last_value FROM pg_sequences WHERE schemaname='public' ORDER BY sequencename");
return hash('sha256',json_encode($out,JSON_THROW_ON_ERROR));
};
$selected=json_decode(file_get_contents($run['root'].'/private/receipt-composition-cc50a276c10c/state.json'),true);
config(['database.connections.sgc_protected_host'=>array_replace(config('database.connections.pgsql'),[
'host'=>'127.0.0.1','port'=>55439,'database'=>$selected['env']['DB_DATABASE'],'username'=>$selected['env']['DB_USERNAME'],'password'=>$selected['env']['DB_PASSWORD'],'url'=>null]),
'database.connections.sgc_protected_native'=>$selected['nativeConnection']]);
$protectedBefore=[$snapshot(DB::connection('sgc_protected_host')),$snapshot(DB::connection('sgc_protected_native'))];
$hostBefore=$snapshot($host);
$financialTables=['budget_entries','budget_consumptions','stock_movements','pa_obligations'];
$effectsBefore=[]; foreach ($financialTables as $table) if ($native->getSchemaBuilder()->hasTable($table)) $effectsBefore[$table]=$native->table($table)->count();
$existingInvoices=$native->table('invoices')->orderBy('id')->get()->toJson();
$priorIds=$native->table('invoices')->pluck('id')->all();
// SQL fixtures only: a distinct order/accepted line to exercise invoice intake. These
// are not claimed as newly executed request/award/receipt/physical-stock workflows.
$fixtureFile=$proofRoot.'/source-fixture.json';
if(!is_file($fixtureFile)){
$sourceOrder=$native->table('purchase_orders')->where('tenant_id',$nativeFixture['tenant'])->first();
$sourceLine=$native->table('purchase_order_items')->where('purchase_order_id',$sourceOrder->id)->first();
$copy=function($table,$sourceId,$replace=[])use($native){$row=(array)$native->table($table)->where('id',$sourceId)->first();unset($row['id']);return $native->table($table)->insertGetId(array_replace($row,$replace));};
$fixture=$native->transaction(function()use($native,$copy,$sourceOrder,$sourceLine,$proofRoot,$f){
$ref='QA-SGC-'.substr(basename($proofRoot),-12);
$request=$copy('requisitions',$sourceOrder->requisition_id,['number'=>$ref]);
$requestLine=$copy('requisition_items',$sourceLine->requisition_item_id,['requisition_id'=>$request]);
$oldAward=$native->table('purchase_awards')->where('id',$sourceOrder->purchase_award_id)->first();
$quote=$copy('quotations',$oldAward->quotation_id,['requisition_id'=>$request]);
$award=$copy('purchase_awards',$oldAward->id,['requisition_id'=>$request,'quotation_id'=>$quote]);
$order=$copy('purchase_orders',$sourceOrder->id,['number'=>$ref,'requisition_id'=>$request,'purchase_award_id'=>$award]);
$line=$copy('purchase_order_items',$sourceLine->id,['purchase_order_id'=>$order,'requisition_item_id'=>$requestLine]);
$receiptSource=$native->table('goods_receipt_items')->where('purchase_order_item_id',$sourceLine->id)->first();
$receipt=$copy('goods_receipts',$receiptSource->goods_receipt_id,['purchase_order_id'=>$order,'number'=>$ref,'idempotency_key'=>(string)Str::uuid(),'delivery_note'=>$ref]);
$copy('goods_receipt_items',$receiptSource->id,['goods_receipt_id'=>$receipt,'purchase_order_item_id'=>$line]);
$oldOrigin=$native->table('audit_logs')->where('requisition_id',$sourceOrder->requisition_id)->first();
$copy('audit_logs',$oldOrigin->id,['requisition_id'=>$request,'action'=>'sgf.request.command','before'=>null,
'after'=>json_encode(['fixture_only'=>true,'request_scope'=>hash('sha256',json_encode($f['scope'],JSON_THROW_ON_ERROR))])]);
return ['request'=>$request,'order'=>$order,'line'=>$line,'receipt'=>$receipt,'sql_fixture_only'=>true];
});file_put_contents($fixtureFile,json_encode($fixture,JSON_THROW_ON_ERROR));
}
$fixture=json_decode(file_get_contents($fixtureFile),true);
$order=$native->table('purchase_orders')->where('id',$fixture['order'])->first();
$line=$native->table('purchase_order_items')->where('id',$fixture['line'])->first();
$orderId=(int)$order->id;
Route::post('/api/_sgc_test_state', fn(Illuminate\Http\Request $r)=>app(App\Modules\Purchasing\InvoiceController::class)->show($orderId,app(App\Modules\Purchasing\ReceiptRuntime::class)))
->middleware(App\Http\Middleware\AuthenticateSgcInvoice::class);
$state=fn()=> $send([],null,[], '/api/_sgc_test_state');
$document=function(string $suffix)use($line,$proofRoot) {
return ['number'=>'QA-SGC-'.basename($proofRoot).'-'.$suffix, 'issued_on'=>'2026-11-10','subtotal'=>'100.00','tax'=>'0.00','total'=>'100.00',
'items'=>[['purchase_order_item_id'=>(int)$line->id,'quantity'=>'1.000','unit'=>$line->unit,'unit_price'=>'100.00','tax_rate'=>'0.00']]];
};
$preview=function(string $suffix, ?string $key=null)use($state,$send,$orderId,$document){
$view=$state(); if($view['status']!==200)throw new RuntimeException('Native read refused: '.$view['status']);
$body=['phase'=>'preview','order_id'=>$orderId,'revision'=>$view['data']['revision'],'reason'=>'Conferência sintética SGC, sem aprovação financeira.', 'data'=>$document($suffix)];
$key??=(string)Str::uuid();return [$send($body,$key),$key,$body];
};
$confirm=fn($p,$key)=>$send(['phase'=>'confirm','order_id'=>$orderId,'preview'=>$p],$key);
$firstState=$state(); $ok($firstState['status']===200,'existing regression native order readable with mapped identity');
[$p,$key,$body]=$preview('main');
file_put_contents($proofRoot.'/initial-preview.json', json_encode($p,JSON_THROW_ON_ERROR));
$ok($p['status']===200&&isset($p['data']['preview']),'actual invoice preview via API transport');
$ok($p['data']['impact']['budget_consumption_minor']===0&&!$p['data']['impact']['payable_created'],'preview has no budget consumption or payable');
$ok($native->table('invoices')->count()===count($priorIds),'preview rolls all business rows back');
$ok($p['no_store'],'successful response is not cached');
config(['sgc_invoice.enabled'=>false]);$ok($send($body,$key)['status']===404,'disabled endpoint refuses even valid credentials');config(['sgc_invoice.enabled'=>true]);
foreach ([['HTTP_AUTHORIZATION'=>null],['HTTP_AUTHORIZATION'=>'Bearer '.str_repeat('x',64)],['HTTP_X_SGC_CLIENT'=>'unknown']] as $headers)
$ok($send($body,$key,$headers)['status']===401,'missing or wrong principal/token refused');
$ok($send($body,$key,['HTTP_X_SGC_CONTEXT'=>str_repeat('0',64)])['status']===409,'stale context version refused');
$ok($send($body,$key,['REMOTE_ADDR'=>'192.0.2.1'])['status']===403,'non-TLS remote request refused');
$ok($send($body,$key,['CONTENT_TYPE'=>'text/plain'])['status']===415,'non JSON refused');
$ok($send($body,$key,[], '/api/v1/integration/sgc/invoices',str_repeat('x',131073))['status']===413,'oversized body refused');
$ok($send($body,$key,[], '/api/v1/integration/sgc/invoices','{bad')['status']===422,'malformed JSON refused');
$bad=$body; $bad['tenant_id']=1;$ok($send($bad,$key)['status']===422,'client cannot choose tenant');
$bad=$body; $bad['data']['supplier_id']=1;$ok($send($bad,$key)['status']===422,'supplier cannot be silently associated');
$bad=$body; $bad['data']['total']=100.0;$ok($send($bad,$key)['status']===422,'floating point amounts refused');
$bad=$body; $bad['data']['total']='101.00';$ok($send($bad,$key)['status']===422,'inconsistent total refused by original writer');
$bad=$body; $bad['data']['items'][0]['purchase_order_item_id']=$nativeFixture['foreign_order_line'];$ok($send($bad,$key)['status']===422,'foreign native line refused');
$bad=$body; $bad['order_id']=$nativeFixture['foreign_order'];$ok($send($bad,$key)['status']===403,'foreign native order refused');
$ok($send($body,'not-a-uuid')['status']===422,'malformed idempotency key refused');
$changed=$principal; $changed['auth_version']=2;config(['sgc_invoice.principals.synthetic'=>$changed]);$ok($send($body,$key)['status']===401,'revoked authentication version refused');
$changed=$principal;$changed['unit_id']=$f['foreign_unit'];config(['sgc_invoice.principals.synthetic'=>$changed]);$ok($send($body,$key)['status']===403,'unauthorised unit mapping refused');
config(['sgc_invoice.principals.synthetic'=>$principal]);
$host->beginTransaction();try{$host->table('permission_grants')->where('user_id',$f['issuer'])->where('permission','purchasing_invoices.prepare')->delete();$ok($send($body,$key)['status']===403,'revoked prepare right refused');}finally{$host->rollBack();}
$result=$confirm($p['data']['preview'],$key);$ok($result['status']===201&&$result['data']['status']==='pending','confirmation creates pending invoice only');
$invoiceId=$result['data']['invoice_id'];
$replay=$confirm($p['data']['preview'],$key);$ok($replay['status']===200&&$replay['data']['invoice_id']===$invoiceId&&$replay['data']['replayed'],'lost response recovered by same confirmation');
$ok($send($body,$key)['status']===200,'same request recovered through preview after commit');
$bad=$body; $bad['reason']='Outro fundamento sintético, conteúdo diferente.';$ok($send($bad,$key)['status']===409,'same key different content conflicts');
$ok($confirm($p['data']['preview'],(string)Str::uuid())['status']===409,'confirmation must use the preview key');
$ok($confirm(substr($p['data']['preview'],0,-4).'xxxx',$key)['status']===409,'tampered preview refused');
$host->beginTransaction();try{$host->table('permission_grants')->where('user_id',$f['issuer'])->where('permission','purchasing_invoices.prepare')->delete();$ok($confirm($p['data']['preview'],$key)['status']===403,'replay revalidates current authority');}finally{$host->rollBack();}
[$stale,$staleKey]=$preview('stale');[$fresh,$freshKey]=$preview('fresh');
$ok($confirm($fresh['data']['preview'],$freshKey)['status']===201,'second individually prepared document confirmed');
$ok($confirm($stale['data']['preview'],$staleKey)['status']===409,'changed source invalidates earlier preview');
[$failure,$failureKey]=$preview('failure');$beforeFailure=$native->table('invoices')->count();$inject=true;
Tempo\Purchasing\Embedded\Models\AuditLog::creating(function($log)use(&$inject){if($inject&&$log->action==='sgf.invoice.command')throw new RuntimeException('Synthetic failure immediately before durable receipt');});
$failed=$confirm($failure['data']['preview'],$failureKey);$inject=false;
$ok($failed['status']===500&&$native->table('invoices')->count()===$beforeFailure,'failure between writer and receipt rolls invoice back');
$ok($confirm($failure['data']['preview'],$failureKey)['status']===201,'same operation recovers after failure');
[$parallel,$parallelKey]=$preview('parallel');
file_put_contents($proofRoot.'/job.json',json_encode(['body'=>['phase'=>'confirm','order_id'=>$orderId,'preview'=>$parallel['data']['preview']],'key'=>$parallelKey],JSON_THROW_ON_ERROR));
$workers=[];foreach([0,1]as$i){$process=new Symfony\Component\Process\Process([PHP_BINARY,__FILE__,'--worker','job.json','worker-'.$i.'.json'],$repo,['SGC_INVOICE_PROOF_ROOT'=>$proofRoot],null,45);$process->start();$workers[]=$process;}
foreach($workers as$process){$process->wait();if(!$process->isSuccessful())throw new RuntimeException('Worker failed; inspect private proof output');}
$parallelResults=[];foreach([0,1]as$i)$parallelResults[]=json_decode(file_get_contents($proofRoot.'/worker-'.$i.'.json'),true);
$statuses=array_column($parallelResults,'status');sort($statuses);$ok($statuses===[200,201],'concurrent same-key confirmations return one create and one recovery');
$ok($parallelResults[0]['data']['invoice_id']===$parallelResults[1]['data']['invoice_id'],'concurrent receipts identify one invoice');
$afterEffects=[];foreach(array_keys($effectsBefore)as$table)$afterEffects[$table]=$native->table($table)->count();
$ok($effectsBefore===$afterEffects,'no new consumption stock obligation payment or ledger effects');
$ok($native->table('invoices')->whereIn('id',$priorIds)->orderBy('id')->get()->toJson()===$existingInvoices,'previous accepted invoice rows preserved');
$ok($snapshot($host)===$hostBefore,'host rows permissions budget and sequences unchanged');
$protectedAfter=[$snapshot(DB::connection('sgc_protected_host')),$snapshot(DB::connection('sgc_protected_native'))];
$ok($protectedBefore===$protectedAfter,'8085 host/native all rows and sequences unchanged');
$cleared=false;try{app(App\Shared\Context\CompanyContext::class)->identifiers();}catch(Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException){$cleared=true;}
$ok($cleared,'context cleaned after request');
file_put_contents($proofRoot.'/result.json',json_encode(['passed'=>true,'checks'=>$checks,'new_pending_invoices'=>$native->table('invoices')->count()-count($priorIds),
'concurrent_statuses'=>$statuses,'protected_8085_fingerprints'=>$protectedAfter,'database_reused'=>'receipt-composition-da77bb730751','new_database'=>false,'new_role'=>false,'new_server'=>false,
'kernel_http'=>true,'tcp_http'=>false,'sql_source_fixture'=>$fixture,'activation'=>false,'publication'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
echo json_encode(['passed'=>true,'checks'=>count($checks)]).PHP_EOL;