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

116 lines
18 KiB
PHP

<?php
// Existing regression book only. No server, new database, deployment or durable grant.
use Illuminate\Support\Facades\{Auth,DB};
use Illuminate\Http\Request;
use App\Modules\Inventory\StockOperationsController;
use App\Modules\Purchasing\ReceiptRuntime;
use App\Shared\Context\{CompanyContext,RequireModuleContext};
$repo=dirname(__DIR__);$run=json_decode(file_get_contents('C:/laragon/data/sgf-tempo/run/modular-regression-current.json'),true);
$s=json_decode(file_get_contents($run['root'].'/private/receipt-composition-b4093c6f6707/state.json'),true);
if($run['id']!=='2576427d1939'||$s['tag']!=='b4093c6f6707'||!is_file($s['root'].'/integrated-result.json')||is_file($s['root'].'/demo-server.json'))throw new LogicException('Existing regression only');
$proof=$run['root'].'/private/stock-operations-proof-'.bin2hex(random_bytes(6));mkdir($proof,0700,true);
foreach($s['env'] as $key=>$value)if(preg_match('/^(APP_|DB_|SGF_|SESSION_|CACHE_|QUEUE_|BCRYPT_)/',$key)){putenv($key.'='.$value);$_ENV[$key]=$value;$_SERVER[$key]=$value;}
foreach(['APP_BASE_PATH'=>$repo,'APP_CONFIG_CACHE'=>$proof.'/config.php','APP_ROUTES_CACHE'=>$proof.'/routes.php','APP_SERVICES_CACHE'=>$proof.'/services.php','APP_PACKAGES_CACHE'=>$proof.'/packages.php','SESSION_DRIVER'=>'array','CACHE_STORE'=>'array'] as $key=>$value){putenv($key.'='.$value);$_ENV[$key]=$value;$_SERVER[$key]=$value;}
require $repo.'/vendor/autoload.php';$app=require $repo.'/bootstrap/app.php';$app->addAbsoluteCachePathPrefix('C:');$app->useStoragePath($proof.'/storage');
foreach(['logs','framework/views','framework/sessions','framework/cache/data'] as $dir)mkdir($proof.'/storage/'.$dir,0700,true);
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$f=json_decode(file_get_contents($s['root'].'/fixture.json'),true);$nf=json_decode(file_get_contents($s['root'].'/native-fixture.json'),true);
config(['database.connections.receipt_native'=>$s['nativeConnection'],'stock_operations.enabled'=>true,'receipt_qa.obligations_enabled'=>false]);
$host=DB::connection();$native=DB::connection('receipt_native');
$snapshot=function($db){$out=[];foreach($db->select("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename") as $r){$table=$r->tablename;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),\'\')) hash FROM "'.$table.'" t');}return hash('sha256',json_encode($out));};
$before=[$snapshot($host),$snapshot($native)];$checks=[];
$ok=function($v,$name)use(&$checks,$proof){if(!$v)throw new RuntimeException($name);$checks[]=$name;file_put_contents($proof.'/progress.json',json_encode($checks));};
$controller=new StockOperationsController;$rt=new ReceiptRuntime;$session=app('session')->driver();$session->start();$token=bin2hex(random_bytes(20));
$send=function($method,$data=[],string $path='/api/stock-operations',array $selection=[])use($app,$host,$f,$controller,$rt,$session,$token){
Auth::forgetGuards();Auth::shouldUse('web');$user=App\Models\User::findOrFail($f['validator']);Auth::guard('web')->setUser($user);
$scope=$f['scope']+['token'=>$token,'module_id'=>'stock'];$scope=array_replace($scope,$selection);$session->put('company_context',$scope);
$r=Request::create($path,in_array($method,['preview','confirm'],true)?'POST':'GET',$data,[],[],['HTTP_ACCEPT'=>'application/json','HTTP_X_SGF_CONTEXT'=>$scope['token']]);
$r->setLaravelSession($session);$r->setUserResolver(fn($guard=null)=>$user);$app->instance('request',$r);app(CompanyContext::class)->selectFor($user->id,$scope['tenant_id'],$scope['company_id'],$scope['unit_id']);
try{app(RequireModuleContext::class)->check($r,app(CompanyContext::class));$response=$controller->$method($r,$rt);return ['status'=>$response->getStatusCode(),'data'=>str_contains($response->headers->get('Content-Type',''),'json')?$response->getData(true):$response->getContent()];}
catch(Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){return ['status'=>$e->getStatusCode(),'message'=>$e->getMessage(),'trace'=>array_map(fn($t)=>array_intersect_key($t,array_flip(['file','line','function'])),array_slice($e->getTrace(),0,8))];}
catch(Illuminate\Validation\ValidationException $e){return ['status'=>422,'message'=>$e->errors()];}
catch(Illuminate\Database\Eloquent\ModelNotFoundException $e){return ['status'=>404];}
finally{app(CompanyContext::class)->clear();}
};
$profile=$s['root'].'/stock-operations-binding.json';if(is_file($profile))throw new LogicException('Preserve existing map');
$host->beginTransaction();$native->beginTransaction();
try{
// Transaction-local proposal only: original rights and schema return on rollback.
$constraint=$host->selectOne("SELECT pg_get_constraintdef(oid) def FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->def;
if(!preg_match('/^CHECK \((.*)\)$/s',$constraint,$m))throw new LogicException('Constraint');
$host->statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
$host->statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK ((".$m[1].") OR (unit_id IS NOT NULL AND permission IN ('stock.view','stock.operations.prepare','stock.operations.confirm')))");
foreach(['stock.view','stock.operations.prepare','stock.operations.confirm'] as $right)$host->table('permission_grants')->insertOrIgnore($f['scope']+['user_id'=>$f['validator'],'permission'=>$right]);
config(['modules.contracted'=>['purchasing','stock'],'modules.companies.'.$f['tenant'].':'.$f['company']=>['contracted'=>['purchasing','stock'],'enabled'=>['purchasing','stock']]]);
$warehouse=(array)$native->table('warehouses')->where('id',$nf['warehouse'])->first();unset($warehouse['id']);$warehouse['code']='QA-STOCK-'.bin2hex(random_bytes(3));$warehouse['name']='Armazém temporário de regressão';$second=$native->table('warehouses')->insertGetId($warehouse);
$map=['composition'=>$s['tag'],'warehouse_bindings'=>[implode(':',array_values($f['scope'])).':'.$f['validator']=>[$nf['warehouse'],$second]]];
file_put_contents($profile,json_encode($map,JSON_THROW_ON_ERROR));config(['stock_operations.profile_sha256'=>hash_file('sha256',$profile)]);
$stockBefore=$native->table('stock_movements')->count();$operationsBefore=$native->table('stock_operations')->count();$auditBefore=$native->table('audit_logs')->count();
$screen=$send('screen',[], '/stock/operations');file_put_contents($proof.'/screen.json',json_encode($screen));
$ok($screen['status']===200,'HTML workspace available in explicit Stock context');
$ok($send('index',['view'=>'warehouses'])['data']['total']===2,'warehouse catalogue restricted to explicit map');
$ok($send('index',['view'=>'warehouses','page'=>1,'per_page'=>100,'search'=>'%'])['data']['total']===0,'literal wildcard search and page size 100');
$ok($send('index',['view'=>'items','per_page'=>25])['status']===200,'item catalogue paginated at 25');
$ok($send('index',['view'=>'people'])['status']===200,'responsible catalogue restricted to source tenant');
$ok($send('index',['view'=>'items','company_id'=>999])['status']===422,'query cannot inject company authority');
$ok($send('index',['view'=>'history'],selection:['module_id'=>'purchasing'])['status']===409,'Purchasing context cannot execute Stock routes');
$base=['kind'=>'issue','warehouse_id'=>$nf['warehouse'],'stock_item_id'=>$nf['stock'],'responsible_id'=>$nf['receiver']];
$basis=$send('basis',$base,'/api/stock-operations/basis');$ok($basis['status']===200&&$basis['data']['basis']['balances'][0]['quantity']>=1000,'current physical basis from the original book');
$ok($send('basis',array_replace($base,['warehouse_id'=>$nf['foreign_warehouse']]),'/api/stock-operations/basis')['status']===403,'foreign warehouse refused');
$ok($send('basis',array_replace($base,['stock_item_id'=>$nf['foreign_stock']]),'/api/stock-operations/basis')['status']===404,'foreign item refused');
$doc=$base+['quantity'=>'0.125','reference'=>'QA-SGF-ISSUE-'.bin2hex(random_bytes(5)),'reason'=>'Saída física apenas para ensaio transaccional','destination'=>'Utilização de ensaio'];
$payload=['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$basis['data']['revision'],'data'=>$doc];
$preview=$send('preview',$payload,'/api/stock-operations/preview');
$ok($preview['status']===200,'original writer validates reversible preview');
$ok($native->table('stock_movements')->count()===$stockBefore&&$native->table('stock_operations')->count()===$operationsBefore&&$native->table('audit_logs')->count()===$auditBefore,'preview leaves no movements operations or audit rows');
$cut=$preview['data']['preview'];$confirmed=$send('confirm',['preview'=>$cut],'/api/stock-operations/confirm');
$ok($confirmed['status']===201,'individual confirmation applies original physical writer');
$ok($native->table('stock_movements')->count()===$stockBefore+1&&$native->table('stock_operations')->count()===$operationsBefore+1,'one issue creates exactly one physical movement');
$retry=$send('confirm',['preview'=>$cut],'/api/stock-operations/confirm');
$ok($retry['status']===200&&$retry['data']['operation_id']===$confirmed['data']['operation_id'],'uncertain response recovers the same receipt without duplicate');
$event=$native->table('audit_logs')->where('action','sgf.stock.command')->orderByDesc('id')->first();$after=json_decode($event->after,true);$ok($after['reason']===$doc['reason']&&$after['result']['operation_id']===$confirmed['data']['operation_id']&&$event->before!==null,'audit retains actor reason before after and operation receipt');
$stale=$payload;$stale['operation_key']=(string)Illuminate\Support\Str::uuid();$stale['data']['reference'].='-STALE';$ok($send('preview',$stale,'/api/stock-operations/preview')['status']===409,'old ledger version requires new conference');
$ok($send('confirm',['preview'=>$cut],'/api/stock-operations/confirm',['token'=>'different-generation'])['status']===409,'receipt cannot be reused after context generation changes');
$host->table('permission_grants')->where('user_id',$f['validator'])->where('permission','stock.operations.confirm')->delete();
$ok($send('confirm',['preview'=>$cut],'/api/stock-operations/confirm')['status']===403,'confirmation permission revalidated even on replay');
$host->table('permission_grants')->insert($f['scope']+['user_id'=>$f['validator'],'permission'=>'stock.operations.confirm']);
$transfer=$base+['destination_warehouse_id'=>$second];$transfer['kind']='transfer';$basis=$send('basis',$transfer,'/api/stock-operations/basis');
$payload=['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$basis['data']['revision'],'data'=>$transfer+['quantity'=>'0.100','reference'=>'QA-SGF-TRANSFER-'.bin2hex(random_bytes(5)),'reason'=>'Transferência física apenas para ensaio transaccional']];
$preview=$send('preview',$payload,'/api/stock-operations/preview');$ok($preview['status']===200,'transfer preview validates both bound warehouses');
$other=$payload;$other['operation_key']=(string)Illuminate\Support\Str::uuid();$other['data']['reference'].='-OTHER';$otherPreview=$send('preview',$other,'/api/stock-operations/preview');
$ok($otherPreview['status']===200,'two independent conferences can observe the same original revision');
$confirmed=$send('confirm',['preview'=>$preview['data']['preview']],'/api/stock-operations/confirm');$ok($confirmed['status']===201,'transfer confirms through the same writer');
$movements=$native->table('stock_movements')->where('stock_operation_id',$confirmed['data']['operation_id'])->get();
$ok($movements->count()===2&&$movements->sum('quantity_milli')===0,'transfer produces two balanced physical movements atomically');
$ok($send('confirm',['preview'=>$otherPreview['data']['preview']],'/api/stock-operations/confirm')['status']===409,'second conference refuses the revision changed by the first confirmation');
$basis=$send('basis',$base,'/api/stock-operations/basis');$failure=['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$basis['data']['revision'],'data'=>array_replace($doc,['reference'=>$doc['reference'].'-FAIL'])];
$failurePreview=$send('preview',$failure,'/api/stock-operations/preview');$ok($failurePreview['status']===200,'failure-recovery conference prepared on current state');
$beforeFailure=[$native->table('stock_movements')->count(),$native->table('stock_operations')->count(),$native->table('audit_logs')->count()];
$eventName='eloquent.creating: Tempo\\Purchasing\\Embedded\\Models\\AuditLog';
Illuminate\Support\Facades\Event::listen($eventName,function($event){if($event->action==='sgf.stock.command')throw new RuntimeException('Injected receipt failure');});
$failed=false;try{$send('confirm',['preview'=>$failurePreview['data']['preview']],'/api/stock-operations/confirm');}catch(RuntimeException $e){$failed=$e->getMessage()==='Injected receipt failure';}finally{Illuminate\Support\Facades\Event::forget($eventName);}
$ok($failed&&$beforeFailure===[$native->table('stock_movements')->count(),$native->table('stock_operations')->count(),$native->table('audit_logs')->count()],'failure after physical write rolls back operation movement and native audit');
$ok($send('confirm',['preview'=>$failurePreview['data']['preview']],'/api/stock-operations/confirm')['status']===201,'same confirmation succeeds once after transient audit failure is removed');
$ok($send('index',['view'=>'equipment'])['status']===200,'equipment catalogue available without selecting a destination automatically');
$countData=array_replace($base,['kind'=>'count']);$countBasis=$send('basis',$countData,'/api/stock-operations/basis');
$countPayload=['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$countBasis['data']['revision'],'data'=>$countData+['quantity'=>'1.500','ledger_version'=>$countBasis['data']['basis']['balances'][0]['version'],'reference'=>'QA-SGF-COUNT-'.bin2hex(random_bytes(5)),'reason'=>'Contagem física sintética pelo percurso original']];
$beforeCount=$native->table('stock_movements')->count();$countPreview=$send('preview',$countPayload,'/api/stock-operations/preview');$ok($countPreview['status']===200,'count preview uses original current ledger revision');
$count=$send('confirm',['preview'=>$countPreview['data']['preview']],'/api/stock-operations/confirm');$ok($count['status']===201&&$native->table('stock_movements')->count()===$beforeCount,'count stores the physical observation without changing stock');
$counts=$send('index',['view'=>'counts','warehouse_id'=>$base['warehouse_id'],'stock_item_id'=>$base['stock_item_id']]);$ok($counts['status']===200&&in_array($count['data']['operation_id'],array_column($counts['data']['data'],'id'),true),'only counts for the selected bound pair are offered');
$adjustData=array_replace($base,['kind'=>'adjustment','count_id'=>$count['data']['operation_id']]);$adjustBasis=$send('basis',$adjustData,'/api/stock-operations/basis');
$adjustPayload=['operation_key'=>(string)Illuminate\Support\Str::uuid(),'revision'=>$adjustBasis['data']['revision'],'data'=>$adjustData+['reference'=>'QA-SGF-ADJUST-'.bin2hex(random_bytes(5)),'reason'=>'Aplicação explícita da diferença da contagem sintética']];
$adjustPreview=$send('preview',$adjustPayload,'/api/stock-operations/preview');$ok($adjustPreview['status']===200,'adjustment impact is calculated by the original count workflow');
$adjust=$send('confirm',['preview'=>$adjustPreview['data']['preview']],'/api/stock-operations/confirm');$ok($adjust['status']===201&&(int)$native->table('stock_movements')->where('warehouse_id',$base['warehouse_id'])->where('stock_item_id',$base['stock_item_id'])->sum('quantity_milli')===1500,'adjustment reconciles the physical book with the explicit count');
$ok($send('confirm',['preview'=>$adjustPreview['data']['preview']],'/api/stock-operations/confirm')['status']===200,'count adjustment receipt is recovered without applying the difference twice');
$counts=$send('index',['view'=>'counts','warehouse_id'=>$base['warehouse_id'],'stock_item_id'=>$base['stock_item_id']]);$ok(!in_array($count['data']['operation_id'],array_column($counts['data']['data'],'id'),true),'an applied count is no longer offered for a second adjustment');
$ok($send('confirm',['preview'=>'tampered'],'/api/stock-operations/confirm')['status']===409,'tampered conference refused');
config(['stock_operations.enabled'=>false]);$ok($send('index',['view'=>'history'])['status']===404,'feature remains independently disabled');
}finally{
while($native->transactionLevel()>0)$native->rollBack();while($host->transactionLevel()>0)$host->rollBack();if(is_file($profile))unlink($profile);
}
$ok($before===[$snapshot($host),$snapshot($native)],'all preexisting host and native rows restored after isolated regression');
$files=['app/Modules/Inventory/StockOperationsController.php','app/Modules/Purchasing/ReceiptRuntime.php','config/stock_operations.php','routes/web.php','app/Shared/Context/ModuleNavigation.php','app/Modules/Identity/Services/IdentityWorkflow.php','resources/js/StockMenuWorkspace.jsx','resources/views/stock-operations-workspace.blade.php','public/stock-operations-workspace.mjs','scripts/stock-operations-proof.php'];$hashes=[];foreach($files as $file)$hashes[$file]=hash_file('sha256',$repo.'/'.$file);
$result=['passed'=>true,'checks'=>$checks,'book'=>$s['tag'],'transactions_rolled_back'=>true,'qa_sequences_may_advance'=>true,'new_database'=>false,'new_server'=>false,'target8085_touched'=>false,'browser_visual_review'=>false,'source_hashes'=>$hashes];
file_put_contents($proof.'/result.json',json_encode($result,JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));echo json_encode(['passed'=>true,'checks'=>count($checks),'proof'=>$proof.'/result.json']).PHP_EOL;