Files
SGF/scripts/payables-probe.php
2026-10-29 18:43:29 +02:00

84 lines
11 KiB
PHP

<?php
// Fault/adversarial checks are permitted ONLY in the disposable proof databases.
$mode=$argv[1]??'';$job=$argv[2]??'';$private='C:/laragon/data/sgf-tempo/secrets/';
if(!in_array($mode,['compras','sgf'],true))throw new LogicException('Unknown proof host');
$settings=json_decode(file_get_contents($private.'payables-proof-'.$mode.'.json'),true,32,JSON_THROW_ON_ERROR);
$team=$private.'payables-demo-'.$mode.'.json';if(is_file($team)&&json_decode(file_get_contents($team),true)['DB_DATABASE']===$settings['DB_DATABASE'])throw new LogicException('Team demo protected');
foreach($settings as $k=>$v){putenv($k.'='.$v);$_ENV[$k]=$_SERVER[$k]=$v;}
require $settings['HOST_ROOT'].'/vendor/autoload.php';$app=require $settings['HOST_ROOT'].'/bootstrap/app.php';$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
use Symfony\Component\Process\Process;
$db=DB::connection();if(!preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D',$db->getDatabaseName())||$db->selectOne('select current_database() as d')->d!==$settings['DB_DATABASE'])throw new LogicException('Unsafe probe target');
$f=json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']),true,32,JSON_THROW_ON_ERROR);
$input=json_decode(stream_get_contents(STDIN)?:'{}',true,64,JSON_THROW_ON_ERROR);
$class=$mode==='compras'?\App\Services\SupplierPayables::class:\App\Modules\Purchasing\SupplierPayables::class;
$select=function($role)use($mode,$f){$u=\App\Models\User::findOrFail($f['accounts'][$role]['id']);auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$f['scope']['tenant_id'],$f['scope']['company_id'],$f['scope']['unit_id']);return $u;};
if($job==='inspect'){
$counts=[];foreach(['sources','proposals','obligations','events','outbox','commands'] as $t)$counts[$t]=$db->table('pa_'.$t)->count();
$domain=[];if($mode==='compras')foreach(['invoices','invoice_items','budget_entries','budget_consumptions','stock_movements','external_payments'] as $t)$domain[$t]=hash('sha256',\Tempo\Finance\Payables\Payables::canonical($db->table($t)->orderBy('id')->get()->map(fn($r)=>(array)$r)->all()));
$u=$select('consulta');$d=app($class)->listing($u);$out=['counts'=>$counts,'domain_hashes'=>$domain,'core_sha256'=>hash_file('sha256',__DIR__.'/../packages/payables/src/Payables.php'),'recognised_events'=>$db->table('pa_events')->where('kind','payable.recognised')->count(),'obligations'=>$d['obligations']];
if($mode==='compras'){try{$out['legacy_balance']=app(\App\Services\ExternalPaymentWorkflow::class)->outstanding(\App\Models\Invoice::findOrFail($f['source_id']));}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out['legacy_balance_blocked']=$e->getStatusCode();}}
}elseif($job==='grant'){
$role=$input['role'];$action=$input['action'];if(!isset($f['accounts'][$role])||!in_array($action,['view','prepare','validate'],true))throw new LogicException('Invalid synthetic grant');
$match=$f['scope']+['user_id'=>$f['accounts'][$role]['id'],'action'=>$action];
if($input['enabled'])$db->table('pa_access')->insertOrIgnore($match+['person'=>'synthetic-person-'.$role]);else$db->table('pa_access')->where($match)->delete();$out=['changed'=>true];
}elseif($job==='source'){
$original=json_decode(file_get_contents($settings['PAYABLE_BUNDLE']),true,64,JSON_THROW_ON_ERROR);
$over=$input['override']??[];if(array_diff(array_keys($over),['status','total_minor','supplier_name','currency','payment_count']))throw new LogicException('Invalid adversarial fields');
$s=array_replace($original,$over);
if($mode==='compras'){
$row=$db->table('invoices')->where('id',$f['source_id'])->first();$snap=json_decode($row->snapshot,true);$snap['currency']=$s['currency'];$snap['supplier']['name']=$s['supplier_name'];
$db->table('invoices')->where('id',$f['source_id'])->update(['status'=>$s['status'],'total_minor'=>$s['total_minor'],'snapshot'=>json_encode($snap,JSON_THROW_ON_ERROR)]);
}else{$db->table('pa_imported_sources')->where('id',$f['source_id'])->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);}
$out=['source_revision_test_only'=>true];
}elseif($job==='fault'){
if($input['enabled'])$db->unprepared("CREATE FUNCTION pa_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='payable.recognised' THEN RAISE EXCEPTION 'PA_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER pa_qa_fault BEFORE INSERT ON pa_events FOR EACH ROW EXECUTE FUNCTION pa_qa_fault()");
else $db->unprepared('DROP TRIGGER pa_qa_fault ON pa_events; DROP FUNCTION pa_qa_fault()');$out=['fault_configured'=>true];
}elseif($job==='worker'){
$u=$select($input['role']);$db->select('select set_config(?, ?, false)',['application_name',$input['label']]);
try{$r=app($class)->engine($u)->command($input['action'],$input['id'],'Concurrent synthetic proof',$input['key']);$out=['status'=>200,'state'=>$r['state']];}
catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$out=['status'=>$e->getStatusCode()];}
$out['pid']=$db->selectOne('select pg_backend_pid() as p')->p;
}elseif($job==='race'){
$db->beginTransaction();$db->table($mode==='compras'?'tenants':'companies')->where('id',$mode==='compras'?$f['scope']['tenant_id']:$f['scope']['company_id'])->lockForUpdate()->firstOrFail();
$processes=[];$results=[];
try{
foreach([0,1] as $i){$p=new Process([PHP_BINARY,__FILE__,$mode,'worker'],$settings['HOST_ROOT']);$p->setInput(json_encode($input+['key'=>\Illuminate\Support\Str::uuid()->toString(),'label'=>'pa_proof_'.$i],JSON_THROW_ON_ERROR));$p->setTimeout(30);$p->start();$processes[]=$p;}
config(['database.connections.pa_monitor'=>$db->getConfig()]);$monitor=DB::connection('pa_monitor');$deadline=microtime(true)+15;$waits=0;
do{$waits=(int)$monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('pa_proof_0','pa_proof_1') and wait_event_type='Lock'",[$db->getDatabaseName()])->n;if($waits===2)break;foreach($processes as $p)if(!$p->isRunning())throw new RuntimeException('Worker exited: '.$p->getOutput().$p->getErrorOutput());usleep(20000);}while(microtime(true)<$deadline);
if($waits!==2)throw new RuntimeException('Concurrency overlap not observed');
if($input['revoke']??false)$db->table('pa_access')->where($f['scope'])->where(['user_id'=>$f['accounts']['validacao']['id'],'action'=>'validate'])->delete();
$db->commit();foreach($processes as $p){$p->wait();if(!$p->isSuccessful())throw new RuntimeException('Worker failed: '.$p->getOutput().$p->getErrorOutput());$results[]=json_decode($p->getOutput(),true,32,JSON_THROW_ON_ERROR);}
$out=['waits'=>$waits,'results'=>$results];
}finally{if($db->transactionLevel())$db->rollBack();foreach($processes as $p)if($p->isRunning())$p->stop();}
}elseif($job==='history'){
$u=$select('preparacao');
if($mode==='compras'){
$invoice=\App\Models\Invoice::findOrFail($f['source_id']);
$bank=\App\Models\SupplierBankDetail::query()->create(['tenant_id'=>$u->tenant_id,'supplier_id'=>$invoice->supplier_id,'created_by'=>$u->id,'revision'=>1,'bank_name'=>'Banco exclusivamente fictício','country'=>'MZ','currency'=>'MZN','beneficiary'=>'Titular de fixture histórica','account_identifier'=>'SYNTHETIC-HISTORY-NOT-A-BANK-ACCOUNT','evidence_reference'=>'Fixture histórica adversarial','reason'=>'Prova de exclusão de documentos com liquidações','created_at'=>now()]);
\App\Models\ExternalPayment::query()->create(['tenant_id'=>$u->tenant_id,'invoice_id'=>$invoice->id,'supplier_bank_detail_id'=>$bank->id,'created_by'=>$u->id,'reviewed_by'=>$f['accounts']['validacao']['id'],'status'=>'confirmed','amount_minor'=>30000,'currency'=>'MZN','paid_on'=>now()->format('Y-m-d'),'external_reference'=>'HISTORY-QA-ONLY','reference_hash'=>hash('sha256','HISTORY-QA-ONLY'),'payload_hash'=>hash('sha256','HISTORY-PAYLOAD'),'evidence_reference'=>'Historical test fixture, not executed payment','bank_snapshot'=>['synthetic_fixture'=>true],'review_reason'=>'Existing historical fact fixture','reviewed_at'=>now()]);
$rows=$db->table('external_payments')->orderBy('id')->get()->map(fn($r)=>(array)$r)->all();$out=['payment_history_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($rows))];
}else{
$r=$db->table('pa_imported_sources')->where('id',$f['source_id'])->first();$s=json_decode($r->payload,true);$s['payment_count']=1;
$db->table('pa_imported_sources')->where('id',$r->id)->update(['payload'=>\Tempo\Finance\Payables\Payables::canonical($s),'receipt_hash'=>hash('sha256',\Tempo\Finance\Payables\Payables::canonical($s))]);$out=['historical_source_projection'=>true];
}
}elseif($job==='foreign_actor'){
$now=now();$tenant=$db->table('tenants')->insertGetId(['name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);
if($mode==='compras'){
$u=\App\Models\User::factory()->create(['tenant_id'=>$tenant,'purchasing_role'=>'finance']);$scope=['tenant_id'=>$tenant,'company_id'=>0,'unit_id'=>0];
}else{
$company=$db->table('companies')->insertGetId(['tenant_id'=>$tenant,'name'=>'Outra empresa sintética','created_at'=>$now,'updated_at'=>$now]);$unit=$db->table('units')->insertGetId(['tenant_id'=>$tenant,'company_id'=>$company,'name'=>'Outra unidade','created_at'=>$now,'updated_at'=>$now]);$scope=['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit];
$u=\App\Models\User::create(['name'=>'Outro preparador sintético','email'=>'other-'.bin2hex(random_bytes(6)).'@example.test','password'=>bin2hex(random_bytes(20))]);
$db->table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company]);foreach(['company.view'=>null,'unit.view'=>$unit,'supplier_payables.view'=>$unit] as $permission=>$unitId)$db->table('permission_grants')->insert(['user_id'=>$u->id,'tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unitId,'permission'=>$permission,'created_at'=>$now,'updated_at'=>$now]);
}
foreach(['view','prepare'] as $action)$db->table('pa_access')->insert($scope+['user_id'=>$u->id,'person'=>'foreign-synthetic-person','action'=>$action]);
auth()->guard()->setUser($u);if($mode==='sgf')app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
try{app($class)->engine($u)->command('prepare',$f['source_id'],'Forbidden foreign document',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\Illuminate\Database\Eloquent\ModelNotFoundException|\Symfony\Component\HttpKernel\Exception\HttpException){$out=['refused'=>true];}
}elseif($job==='future'){
\Illuminate\Support\Carbon::setTestNow('2030-12-31 12:00:00');$out=app($class)->listing($select('consulta'))['obligations'];
}elseif($job==='foreign'){
$u=$select('consulta');$engine=app($class)->engine($u);$foreign=new \Tempo\Finance\Payables\Payables($db,$u->id,'foreign-person',['tenant_id'=>999999,'company_id'=>999999,'unit_id'=>999999],fn()=>null,fn($id)=>$mode==='compras'?app($class)->document($u,$f['source_id']):app($class)->document($u,$f['source_id']));
try{$foreign->command('prepare',$f['source_id'],'Wrong mapped context',\Illuminate\Support\Str::uuid()->toString());$out=['refused'=>false];}catch(\DomainException){$out=['refused'=>true];}
}else throw new LogicException('Unknown probe');
echo json_encode($out,JSON_THROW_ON_ERROR).PHP_EOL;