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

19 lines
12 KiB
PHP

<?php
$tenant=$n['tenant'];$ok($db->table('purchase_orders')->where('tenant_id',$tenant)->count()===0&&$db->table('invoices')->count()===0,'main fixture begins before order receipt or invoice');$ok($call('issuer',$base)['status']===200&&count($call('issuer',$base)['data']['data'])===0,'finance sees no invented source order');$ok($call('actor',$base)['status']===403&&$call('validator',$base)['status']===403,'procurement and warehouse cannot inspect invoice economics');$ok($call('issuer',$base.'/orders/'.$n['foreign_order'])['status']===403,'actual foreign-tenant order is inaccessible');
$source='/api/purchasing-orders/awards/'.$n['award'];$r=$call('actor',$source);$p=$call('actor',$source.'/preview',['revision'=>$r['data']['revision']]);$r=$call('actor',$source.'/issue',['preview'=>$p['data']['preview']]);$ok($r['status']===201,'source adjudication issues an actual SGF order');$order=$r['data']['order'];$id=$order['id'];$line=$order['items'][0]['id'];$url=$base.'/orders/'.$id;
$preview=function($role,$action,$data,$key=null,$i=0)use($call,$url){$r=$call($role,$url,null,$i);if($r['status']!==200)throw new RuntimeException('Invoice state unavailable');return $call($role,$url.'/preview',['operation_key'=>$key??(string)Illuminate\Support\Str::uuid(),'revision'=>$r['data']['revision'],'action'=>$action,'reason'=>'Conferência documental exclusivamente sintética.','data'=>$data],$i);};$confirm=fn($role,$cut,$i=0)=>$call($role,$url.'/confirm',['preview'=>$cut],$i);$document=fn($number,$quantity,$amount)=>['number'=>$number,'issued_on'=>date('Y-m-d'),'subtotal'=>$amount,'tax'=>'0.00','total'=>$amount,'items'=>[['purchase_order_item_id'=>$line,'quantity'=>$quantity]]];
$r=$call('issuer',$url);$ok($r['status']===200&&$r['data']['can_prepare']===true&&$r['data']['invoices']===[],'issued order reaches invoice workspace without source rebinding');$ok($call('issuer','/purchasing/invoices')['status']===200,'supplier invoice workspace served');$ok($preview('issuer','register',$document('QA-NF-01','0.400','40.00'))['status']===422,'no invoice is registered before accepted receipt');
$receipt='/api/purchasing-receipts/orders/'.$id;$r=$call('validator',$receipt);$p=$call('validator',$receipt.'/preview',['revision'=>$r['data']['revision'],'idempotency_key'=>(string)Illuminate\Support\Str::uuid(),'delivery_note'=>'QA-INVOICE-ACCEPT','received_on'=>date('Y-m-d'),'warehouse_id'=>$n['warehouse'],'items'=>[['purchase_order_item_id'=>$line,'received_quantity'=>'1.000','accepted_quantity'=>'1.000','quality_status'=>'compliant','stock_item_id'=>$n['stock'],'notes'=>null]]]);$r=$call('validator',$receipt.'/confirm',['preview'=>$p['data']['preview']]);$ok($r['status']===201&&count($r['data']['movements'])===1,'warehouse accepts actual order once through original writer');$physical=fn()=>hash('sha256',json_encode([$db->table('goods_receipts')->orderBy('id')->get(),$db->table('goods_receipt_items')->orderBy('id')->get(),$db->table('stock_movements')->orderBy('id')->get()]));$physicalBefore=$physical();
$fingerprint=function()use($db){$h=hash_init('sha256');foreach(['invoices','invoice_items','budget_consumptions','budget_entries','audit_logs']as$table)foreach($db->table($table)->orderBy('id')->get()as$row)hash_update($h,json_encode((array)$row));return hash_final($h);};$doc=$document(' qa-nf-01 ','0.400','40.00');$bad=$doc;$bad['total']='41.00';$ok($preview('issuer','register',$bad)['status']===422,'document total mismatch is refused');$bad=$doc;$bad['items'][0]['purchase_order_item_id']=$n['foreign_order_line'];$ok($preview('issuer','register',$bad)['status']===422,'foreign source line cannot be substituted');$bad=$doc;$bad['items'][0]['unit_price']='101.00';$ok($preview('issuer','register',$bad)['status']===422,'price divergence from frozen order is refused');$bad=$doc;$bad['items'][0]['quantity']='1.001';$ok($preview('issuer','register',$bad)['status']===422,'quantity beyond accepted source is refused');
$before=$fingerprint();$key=(string)Illuminate\Support\Str::uuid();$p0=$preview('issuer','register',$doc,$key);$p1=$preview('issuer','register',$doc,$key,1);$ok($p0['status']===200&&$p0['data']['impact']['invoice']['number']==='QA-NF-01'&&!isset($p0['data']['impact']['invoice']['id'])&&$p0['data']['impact']['budget_consumption_minor']===0,'original registration preview normalises document and consumes no budget');$ok($fingerprint()===$before,'invoice preview persists no invoice audit budget entry or consumption');$other=$doc;$other['number']='QA-OTHER';$alternate=$preview('issuer','register',$other,$key)['data']['preview'];$ok($confirm('issuer',$p0['data']['preview'].'broken')['status']===409,'tampered invoice preview refused');
$db->unprepared("CREATE FUNCTION public.invoice_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.action='sgf.invoice.command' THEN RAISE EXCEPTION 'synthetic invoice receipt failure'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER invoice_qa_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION public.invoice_qa_fail()");try{$ok($confirm('issuer',$p0['data']['preview'])['status']===500,'failure recording SGF origin aborts registration');$ok($fingerprint()===$before,'invoice lines and native audit roll back with failed command receipt');}finally{$db->unprepared('DROP TRIGGER invoice_qa_fail ON audit_logs; DROP FUNCTION public.invoice_qa_fail()');}
$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url.'/confirm',$options('issuer',0,['preview'=>$p0['data']['preview']])),$clients[1]->postAsync($url.'/confirm',$options('issuer',1,['preview'=>$p1['data']['preview']]))]);$registration=array_map(fn($r)=>$r->getStatusCode(),$responses);sort($registration);$ok($registration===[200,201],'independent sessions recover same registered invoice once');$invoice=json_decode((string)$responses[0]->getBody(),true)['invoice_id'];$ok($db->table('invoices')->count()===1&&$db->table('invoice_items')->count()===1&&$db->table('budget_consumptions')->count()===0,'pending invoice is unique and consumes no commitment');$ok($confirm('issuer',$alternate)['status']===409,'same operation key with another number refused');$ok($preview('issuer','register',$doc)['status']===422,'normalised supplier document number cannot be registered twice');$ok($preview('issuer','validate',['invoice_id'=>$invoice])['status']===403,'preparer with validation right cannot approve own invoice');
$p=$preview('reviewer','validate',['invoice_id'=>$invoice]);$stale=$p['data']['preview'];$p=$preview('issuer','register',$document('QA-OVERLAP','0.700','70.00'));$r=$confirm('issuer',$p['data']['preview']);$overlap=$r['data']['invoice_id'];$ok($r['status']===201&&$db->table('invoices')->count()===2,'another pending documentary invoice is retained without consuming budget');$ok($confirm('reviewer',$stale)['status']===409,'new invoice after preview requires renewed review');$pOverlap=$preview('reviewer','validate',['invoice_id'=>$overlap]);$ok($pOverlap['status']===200,'overlapping pending invoice has original matching before other validation');$a=$preview('reviewer','validate',['invoice_id'=>$invoice]);$b=$preview('auditor','validate',['invoice_id'=>$invoice],null,1);$ok($a['status']===200&&$a['data']['impact']['budget_consumption_minor']===4000,'validation impact shows original40MZN commitment consumption');$before=$fingerprint();
$db->unprepared("CREATE FUNCTION public.invoice_qa_fail() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN IF NEW.action='sgf.invoice.command' THEN RAISE EXCEPTION 'synthetic validation receipt failure'; END IF; RETURN NEW; END \$\$; CREATE TRIGGER invoice_qa_fail BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION public.invoice_qa_fail()");try{$ok($confirm('reviewer',$a['data']['preview'])['status']===500&&$fingerprint()===$before,'failed validation receipt rolls back status native audit and consumption');}finally{$db->unprepared('DROP TRIGGER invoice_qa_fail ON audit_logs; DROP FUNCTION public.invoice_qa_fail()');}
$grant=DB::table('permission_grants')->where($f['scope'])->where('user_id',$f['reviewer'])->where('permission','purchasing_invoices.validate')->first();DB::table('permission_grants')->where('id',$grant->id)->delete();try{$ok($confirm('reviewer',$a['data']['preview'])['status']===403,'validation permission is rechecked after preview');}finally{DB::table('permission_grants')->insert((array)$grant);}
$responses=GuzzleHttp\Promise\Utils::unwrap([$clients[0]->postAsync($url.'/confirm',$options('reviewer',0,['preview'=>$a['data']['preview']])),$clients[1]->postAsync($url.'/confirm',$options('auditor',1,['preview'=>$b['data']['preview']]))]);$validation=array_map(fn($r)=>$r->getStatusCode(),$responses);$winner=$validation[0]===201?'reviewer':'auditor';$winnerCut=$validation[0]===201?$a['data']['preview']:$b['data']['preview'];sort($validation);$ok($validation===[201,409],'distinct eligible reviewers commit once and refuse stale concurrent decision');$ok($db->table('budget_consumptions')->count()===1&&(int)$db->table('budget_consumptions')->sum('amount_minor')===4000,'one original40MZN consumption is recorded');$ok($confirm($winner,$winnerCut,$winner==='auditor'?1:0)['status']===200&&$db->table('budget_consumptions')->count()===1,'validated command replay does not consume twice');$ok($confirm('reviewer',$pOverlap['data']['preview'])['status']===409,'validation of another invoice invalidates old matching preview');$ok($preview('reviewer','validate',['invoice_id'=>$overlap])['status']===422&&$db->table('invoices')->where('id',$overlap)->value('status')==='pending','overlapping document remains pending when cumulative accepted quantity is exceeded');
$p=$preview('issuer','register',$document('QA-NF-02','0.600','60.00'));$r=$confirm('issuer',$p['data']['preview']);$second=$r['data']['invoice_id'];$ok($r['status']===201,'remaining0.6UN is registered with the original cumulative matching');$p=$preview('reviewer','validate',['invoice_id'=>$second]);$r=$confirm('reviewer',$p['data']['preview']);$ok($r['status']===201&&$r['data']['status']==='validated','distinct finance validates remaining60MZN');$ok($db->table('budget_consumptions')->count()===2&&(int)$db->table('budget_consumptions')->sum('amount_minor')===10000,'two partial invoices consume exactly100MZN in the same budget');$ok((int)$db->table('budget_entries')->where('tenant_id',$tenant)->sum('committed_minor')===0&&$db->table('budget_entries')->where('tenant_id',$tenant)->where('kind','commit')->count()===1,'single order commitment is consumed without another reservation or commitment');$ok($db->table('invoices')->where('status','validated')->count()===2&&$db->table('invoices')->where('status','pending')->count()===1,'original documents and incompatible pending document are preserved');$ok($physical()===$physicalBefore,'invoice registration and matching create no additional physical movement');
$r=$call('reviewer',$url.'/history?page=1');$r2=$call('reviewer',$url.'/history?page=2');$ok($r['status']===200&&$r['data']['per_page']===15&&count($r['data']['data'])===10&&$r2['data']['data']===[],'invoice history contains only invoice events with server pagination');$enter('issuer',1,['foreign'=>1]);$ok($call('issuer',$url,null,1)['status']===403,'unmapped unit cannot reuse an invoice source');$p=$preview('issuer','register',$document('QA-FULL','0.001','0.10'));$ok($p['status']===422,'fully invoiced receipt cannot be invoiced again');
$obligations=$db->getSchemaBuilder()->hasTable('pa_obligations');$ok(!$obligations||$db->table('pa_obligations')->count()===0,'matching does not recognise a payable automatically');$ok($db->table('external_payments')->count()===0,'matching creates no payment');
file_put_contents($out,json_encode(['passed'=>true,'checks'=>$checks,'registration_statuses'=>$registration,'validation_statuses'=>$validation,'validated_invoices'=>2,'pending_invoices'=>1,'consumed_minor'=>10000,'receipt_movements_unchanged'=>true,'physical_fingerprint'=>$physicalBefore,'payable_table_present'=>$obligations,'payable_auto_recognised'=>false,'payment_created'=>false,'visual_review'=>false,'deployment_capacity_validated'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo count($checks)." supplier invoice integration checks passed.\n";