Files
SGF/scripts/sgf-stock-final-proof.php
2026-11-11 09:00:17 +02:00

53 lines
11 KiB
PHP

<?php
require __DIR__.'/sgf-stock-final-proof-bootstrap.php';
$host->beginTransaction();$native->beginTransaction();$before=$native->table('stock_movements')->orderBy('id')->get()->toJson();
try{
$state=$invoke('warehouse','index');$ok($state['basis']['lot_balance']['quantity']===4000,'same native receipt book contains4UN for selected lot article');
$reject(fn()=>$invoke('read_only','index'),403,'unbound read-only profile cannot acquire detailed physical authority');
$data=['stock_item_id'=>$policy['transfer_item_id'],'source_warehouse_id'=>$policy['source_warehouse_id'],'destination_warehouse_id'=>1,'transfer_key'=>(string)Str::uuid(),'quantity'=>'1.000'];
$reject(fn()=>$command('warehouse','dispatch',array_replace($data,['destination_warehouse_id'=>2])),403,'foreign destination is refused before movement');
[$sent,$preview]=$command('warehouse','dispatch',$data);$ok($sent['impact']['in_transit_after_milli']===1000&&$sent['impact']['ownership_change_milli']===0,'dispatch exposes1000milli in transit without consumption');
$again=$invoke('warehouse','confirm',['preview'=>$preview['preview']]);$ok($again['replayed']&&$again['movement_id']===$sent['movement_id'],'dispatch uncertain response recovers one existing movement');
$reject(fn()=>$command('warehouse','receive',$data),403,'dispatcher cannot receive its own transfer');
$reject(fn()=>$command('workshop_verify','receive',array_replace($data,['quantity'=>'1.001'])),409,'over-receipt cannot exceed dispatched remainder');
$state=$invoke('workshop_verify','index');$stale=$invoke('workshop_verify','preview',['action'=>'receive','data'=>$data,'operation_key'=>(string)Str::uuid(),'revision'=>$state['revision'],'reason'=>'Prévia antes da recepção parcial concorrente.']);
[$partial]=$command('workshop_verify','receive',array_replace($data,['quantity'=>'0.400']));$ok($partial['impact']['in_transit_after_milli']===600,'partial receipt leaves600milli in transit');
$reject(fn()=>$invoke('workshop_verify','confirm',['preview'=>$stale['preview']]),409,'earlier full receipt refuses after a competing partial commit');
[$received]=$command('workshop_verify','receive',array_replace($data,['quantity'=>'0.600']));$ok($received['impact']['in_transit_after_milli']===0,'remaining parcel completes transit without duplication');
[$setup]=$command('warehouse','prepare',['version'=>0,'data'=>['stock_item_id'=>$policy['lot_item_id'],'warehouse_id'=>$policy['source_warehouse_id'],'requires_expiry'=>true,'minimum_remaining_days'=>0,'lots'=>[['code'=>'QA-A','expires_on'=>'2027-12-31','quantity'=>'2.000'],['code'=>'QA-B','expires_on'=>'2027-12-31','quantity'=>'2.000']]]]);
$reject(fn()=>$command('warehouse','activate',['record_key'=>$setup['record_key'],'version'=>1,'data'=>[]]),403,'lot preparer cannot activate its own configuration');
[$active]=$command('workshop_verify','activate',['record_key'=>$setup['record_key'],'version'=>1,'data'=>[]]);$state=$invoke('warehouse','index');$lots=$state['basis']['lot_context']['lots'];$ok(count($lots)===2&&$state['basis']['lot_context']['reconciled'],'original lot service partitions two2UN lots without a new physical receipt');
foreach($lots as $lot){$meta=$native->table('supply_records')->where('id',$lot['metadata_id'])->first();[$released]=$command('workshop_verify','release',['record_key'=>$meta->record_key,'version'=>$meta->version,'data'=>['lot_id'=>$lot['id'],'warehouse_id'=>$policy['source_warehouse_id'],'state_version'=>0]]);}
$state=$invoke('warehouse','index');$ok($state['basis']['lot_context']['usable_milli']===4000,'independent release makes the exact identified4UN usable');
$lot=$state['basis']['lot_context']['lots'][0];$meta=$native->table('supply_records')->where('id',$lot['metadata_id'])->first();
[$held]=$command('warehouse','hold',['record_key'=>$meta->record_key,'version'=>$meta->version,'data'=>['lot_id'=>$lot['id'],'warehouse_id'=>$policy['source_warehouse_id'],'state_version'=>$lot['state_version']]]);
$state=$invoke('warehouse','index');$ok($state['basis']['lot_context']['usable_milli']===2000,'held lot is excluded from available quantity');
$reject(fn()=>$command('warehouse','release',['record_key'=>$meta->record_key,'version'=>$meta->version,'data'=>['lot_id'=>$lot['id'],'warehouse_id'=>$policy['source_warehouse_id'],'state_version'=>$held['version']]]),403,'warehouse cannot release the lot it prepared');
[$released]=$command('workshop_verify','release',['record_key'=>$meta->record_key,'version'=>$meta->version,'data'=>['lot_id'=>$lot['id'],'warehouse_id'=>$policy['source_warehouse_id'],'state_version'=>$held['version']]]);
$native->beginTransaction();try{
[$identified]=$command('warehouse','identify',['record_key'=>$meta->record_key,'version'=>$meta->version,'data'=>['code'=>'QA-EXPIRADO','expires_on'=>'2026-01-01','no_expiry'=>false]]);
$state=$invoke('warehouse','index');$expired=$state['basis']['lot_context']['lots'][0];
$reject(fn()=>$command('workshop_verify','release',['record_key'=>$identified['record_key'],'version'=>$identified['version'],'data'=>['lot_id'=>$expired['id'],'warehouse_id'=>$policy['source_warehouse_id'],'state_version'=>$expired['state_version']]]),422,'expired lot cannot be released by an otherwise authorised reviewer');
}finally{$native->rollBack();}
$state=$invoke('warehouse','index');
$sourceHash=hash('sha256',json_encode(['setup_id'=>$state['basis']['lot_context']['setup_id'],'lots'=>array_map(fn($l)=>['lot_id'=>$l['id'],'metadata_id'=>$l['metadata_id'],'quantity_milli'=>$l['quantity_milli'],'code'=>$l['data']['code'],'expires_on'=>$l['data']['expires_on'],'no_expiry'=>$l['data']['no_expiry']],$state['basis']['lot_context']['lots'])],JSON_THROW_ON_ERROR));
$count=['quantity'=>'3.500','ledger_version'=>$state['basis']['lot_balance']['version'],'lot_version'=>$sourceHash,'lot_counts'=>[['lot_id'=>$lots[0]['id'],'quantity'=>'1.500'],['lot_id'=>$lots[1]['id'],'quantity'=>'2.000']]];
$reject(fn()=>$command('warehouse','count',array_replace($count,['quantity'=>'3.600'])),422,'lot quantities must sum exactly to explicit total');
[$counted]=$command('warehouse','count',$count);$ok($counted['impact']['physical_change_milli']===0,'physical observation alone does not adjust stock');
$adjust=['quantity'=>'0.000','ledger_version'=>$state['basis']['lot_balance']['version'],'count_id'=>$counted['operation_id']];
$reject(fn()=>$command('warehouse','adjustment',$adjust),403,'original counter cannot review its own adjustment');
$state=$invoke('workshop_verify','index');$pending=$invoke('workshop_verify','preview',['action'=>'adjustment','data'=>$adjust,'operation_key'=>(string)Str::uuid(),'revision'=>$state['revision'],'reason'=>'Ajuste previamente conferido para ensaiar falha e recuperação.']);
$host->beginTransaction();try{$host->table('permission_grants')->where(['user_id'=>$profiles['workshop_verify']['user_id'],'permission'=>'stock.lots.review'])->delete();$reject(fn()=>$invoke('workshop_verify','confirm',['preview'=>$pending['preview']]),403,'revoked host right refuses an already prepared adjustment');}finally{$host->rollBack();}
$native->unprepared("CREATE FUNCTION sgf_final_fail() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''SGF_FINAL_TEST_FAILURE''; END'");$native->unprepared('REVOKE ALL ON FUNCTION sgf_final_fail() FROM PUBLIC');$native->unprepared("CREATE TRIGGER sgf_final_fail BEFORE INSERT ON audit_logs FOR EACH ROW WHEN(NEW.action='sgf.stock.final.command') EXECUTE FUNCTION sgf_final_fail()");$n=$native->table('stock_movements')->count();
$failed=false;try{$invoke('workshop_verify','confirm',['preview'=>$pending['preview']]);}catch(Illuminate\Database\QueryException){$failed=true;}$ok($failed&&$native->table('stock_movements')->count()===$n,'failure after lot writer rolls back adjustment and all allocations with its receipt');$native->unprepared('DROP TRIGGER sgf_final_fail ON audit_logs');$native->unprepared('DROP FUNCTION sgf_final_fail()');
$adjusted=$invoke('workshop_verify','confirm',['preview'=>$pending['preview']]);$retry=$invoke('workshop_verify','confirm',['preview'=>$pending['preview']]);$ok($retry['replayed']&&$retry['operation_id']===$adjusted['operation_id'],'same adjustment conference recovers without a second writer call');$ok($adjusted['impact']['physical_after_milli']===3500,'distinct original workflow applies exact negative500milli adjustment');
$state=$invoke('workshop_verify','index');$ok(array_column($state['basis']['lot_context']['lots'],'quantity_milli')===[1500,2000]&&$state['basis']['lot_context']['reconciled'],'lot partitions reconcile with one physical balance3500');
$bookId=(int)$native->table('gl_stock_books')->where($f['scope'])->where('stock_item_id',$policy['lot_item_id'])->value('id');
$rate=$call('accounting_prepare','prepare',fn($e,$a,$b,$o,$s,$stock)=>$stock->command('rate-prepare',['book_id'=>$bookId,'version'=>0,'unit_minor'=>2000,'reason'=>'Taxa artificial20MZN por UN do artigo por lotes.'],(string)Str::uuid()));$call('accounting_review','validate',fn($e,$a,$b,$o,$s,$stock)=>$stock->command('rate-validate',['rate_id'=>$rate['rate_id'],'version'=>1,'decision'=>'approved','reason'=>'Revisão distinta da taxa de valorização do artigo de lotes.'],(string)Str::uuid()));
$amounts=[];foreach($native->table('stock_movements')->where('stock_item_id',$policy['lot_item_id'])->orderBy('id')->get() as $movement){$kind=Tempo\Finance\GeneralLedger\StockValuation::TYPES[$movement->movement_type];$proposal=$call('accounting_prepare','prepare',fn($e)=>$e->command('effect-prepare',['effect_type'=>$kind,'source_id'=>$movement->id,'journal_on'=>'2026-11-10','period_key'=>'QA-2026-INTEGRADO','rule_key'=>$kind,'reason'=>'Valorização individual da entrada ou diferença efectivas.'],(string)Str::uuid()));$call('accounting_review','validate',fn($e)=>$e->command('validate',['proposal_id'=>$proposal['proposal_id'],'revision'=>$proposal['revision'],'decision'=>'approved','reason'=>'Revisão distinta da origem, contagem e taxa20.'],(string)Str::uuid()));$posted=$call('accounting_prepare','post',fn($e)=>$e->command('post',['proposal_id'=>$proposal['proposal_id'],'revision'=>$proposal['revision'],'reason'=>'Aplicação única da valorização após revisão distinta.'],(string)Str::uuid()));$amounts[]=(int)$native->table('gl_journals')->where('id',$posted['journal_id'])->value('debit_minor');}
$ok($amounts===[8000,1000],'same native journal recognises80MZN entry and10MZN inventory difference');
$native->statement('SET CONSTRAINTS ALL IMMEDIATE');$ok(true,'native deferred physical and accounting constraints remain valid');
}finally{while($native->transactionLevel()>0)$native->rollBack();while($host->transactionLevel()>0)$host->rollBack();}
$ok($before===$native->table('stock_movements')->orderBy('id')->get()->toJson(),'directed proof rolls back all tested movements');
file_put_contents($work.'/stock-final-result.json',json_encode(['passed'=>true,'checks'=>$checks,'proof_changes_rolled_back'=>true,'visual_review'=>false],JSON_PRETTY_PRINT));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$work.'/stock-final-result.json']).PHP_EOL;