assertMatchesRegularExpression('/^compras_receipts_test_([a-f0-9]{12})$/D',$i->name);$tag=substr($i->name,-12); $this->assertSame('stock_receipt_qa_'.$tag,$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.getenv('SGF_ISOLATED_REGRESSION_RUN'),$i->cluster); if(!DB::getSchemaBuilder()->hasTable('users')){Artisan::call('migrate',['--force'=>true]);} $this->assertSame(0,DB::table('users')->count(),'Use the fresh QA database, never a retained fixture'); $root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root); $f=\receiptFixture('10.000');$this->actingAs($f['receiver']);$url='/ordens/'.$f['order']->id.'/recepcoes'; $body=\receiptPayload($f,'6.000');$body['items'][0]['accepted_quantity']='4.000';$body['items'][0]['quality_status']='reservations';$body['items'][0]['notes']='Synthetic acceptance: four accepted, two damaged.'; $receipt=$this->postJson($url,$body)->assertCreated()->json('id'); $this->postJson($url,$body)->assertOk()->assertJsonPath('id',$receipt)->assertJsonPath('created',false); $line=GoodsReceiptItem::query()->where('goods_receipt_id',$receipt)->firstOrFail();$movement=StockMovement::query()->where('goods_receipt_item_id',$line->id)->firstOrFail(); $this->assertSame(4000,(int)$movement->quantity_milli);$this->assertSame(2000,(int)$line->rejected_milli); $this->assertMatchesRegularExpression('/^[a-f0-9]{64}$/D',$movement->snapshot['inventory_source_hash']); $coreReplay=DB::transaction(function () use ($f,$line,$movement){ app(ConflictGuard::class)->lockCompany($f['receiver']);$w=app(ReceiptStockMovement::class)->for($f['receiver']); return $w->write($line->id,$movement->snapshot['inventory_source_hash']); });$this->assertTrue($coreReplay['replayed']);$this->assertSame($movement->id,$coreReplay['movement_id']); try{DB::transaction(function()use($f,$line){app(ConflictGuard::class)->lockCompany($f['receiver']);app(ReceiptStockMovement::class)->for($f['receiver'])->write($line->id,str_repeat('0',64));});$this->fail('Old origin hash must be refused');} catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e){$this->assertSame(409,$e->getStatusCode());} $bad=$body;$bad['items'][0]['accepted_quantity']='3.000';$this->postJson($url,$bad)->assertUnprocessable(); $rejected=\receiptPayload($f,'2.000');$rejected['warehouse_id']=null;$rejected['items'][0]['stock_item_id']=null;$rejected['items'][0]['accepted_quantity']='0';$rejected['items'][0]['quality_status']='nonconforming';$rejected['items'][0]['notes']='Synthetic rejection.'; $this->postJson($url,$rejected)->assertCreated()->assertJsonPath('status','rejected'); $this->assertSame(1,StockMovement::query()->count()); $this->actingAs($f['issuer'])->postJson($url,\receiptPayload($f,'1.000'))->assertForbidden();$this->actingAs($f['receiver']); // Fault after the shared SQL insert must undo receipt, movement and native audit. $failure=\receiptFixture();$this->actingAs($failure['receiver']);$payload=\receiptPayload($failure,'1.000');$fault=true; $before=array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs']); DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*stock_movements/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic failure after inventory insert');}}); try{app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload);$this->fail('Injected failure missing');} catch(\RuntimeException $e){$this->assertSame('Synthetic failure after inventory insert',$e->getMessage());} $this->assertSame($before,array_map(fn($t)=>DB::table($t)->count(),['goods_receipts','goods_receipt_items','stock_movements','audit_logs'])); $recovered=app(ReceiptWorkflow::class)->confirm($failure['receiver'],$failure['order'],$payload); $this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$recovered->items->pluck('id'))->count()); // Two real PHP processes contend on the company lock, then recover the same receipt. $concurrent=\receiptFixture();$this->actingAs($concurrent['receiver']);$payload=\receiptPayload($concurrent,'1.000');$process=null; DB::beginTransaction(); try{ $winner=app(ReceiptWorkflow::class)->confirm($concurrent['receiver'],$concurrent['order'],$payload); $input=$root.'/worker.json';$application='stock_receipt_worker_'.$tag; file_put_contents($input,json_encode(['actor'=>$concurrent['receiver']->id,'order'=>$concurrent['order']->id,'payload'=>$payload,'application'=>$application],JSON_THROW_ON_ERROR)); $pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/common-receipt-worker.php'),$input],[0=>['pipe','r'],1=>['file',$root.'/worker.stdout','w'],2=>['file',$root.'/worker.stderr','w']],$pipes,base_path()); $this->assertIsResource($process);fclose($pipes[0]);$deadline=microtime(true)+10;$wait=null;$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS pid')->pid; do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT wait_event,to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$application]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline); $this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));DB::commit(); $deadline=microtime(true)+15;do{$status=proc_get_status($process);if(!$status['running'])break;usleep(25000);}while(microtime(true)<$deadline); $this->assertFalse($status['running']);$exit=proc_close($process);$process=null;if($exit===-1)$exit=$status['exitcode'];$this->assertSame(0,$exit); $second=json_decode(file_get_contents($input.'.result'),true,512,JSON_THROW_ON_ERROR);$this->assertSame(200,$second['status']);$this->assertSame($winner->id,$second['receipt_id']); $this->assertSame(1,GoodsReceipt::query()->where('purchase_order_id',$concurrent['order']->id)->count()); $this->assertSame(1,StockMovement::query()->whereIn('goods_receipt_item_id',$winner->items->pluck('id'))->count()); }finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();} file_put_contents($root.'/receipt-results.json',json_encode(['identity'=>$i,'physical_accepted_milli'=>4000,'rejected_milli'=>2000,'movement_count_main'=>1, 'source_version_stale_refused'=>true,'core_replay_same_movement'=>true,'fault_atomic_and_recovered'=>true,'concurrency'=>['actual_lock_wait'=>true,'wait_event'=>$wait->wait_event,'same_receipt'=>true,'one_movement'=>true], 'link'=>['tenant'=>(int)$f['tenant']->id,'warehouse'=>(int)$f['warehouse']->id,'item'=>(int)$f['stock']->id,'movement'=>(int)$movement->id,'reference'=>$movement->snapshot['reference']], 'writer_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-write/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR)); } }