177 lines
28 KiB
PHP
177 lines
28 KiB
PHP
<?php
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\{StockMovement,StockOperation,SupplyRecord,User,WarehouseRequestEvent};
|
|
use App\Services\{ReceiptWorkflow,WarehouseFulfilment,WarehouseRequests};
|
|
use Illuminate\Support\Facades\{Artisan,DB};
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
require_once __DIR__.'/../Support/ReceiptFixture.php';
|
|
require_once __DIR__.'/../Support/SupplyFixture.php';
|
|
|
|
final class WarehouseReturnIntegrationTest extends TestCase
|
|
{
|
|
private array $f;private User $reviewer;private int $requestId;private int $delivery;private array $payload;
|
|
protected function setUp():void
|
|
{
|
|
parent::setUp();$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,inet_server_port() AS port,current_setting('cluster_name') AS cluster");
|
|
$this->assertMatchesRegularExpression('/^compras_receipts_test_[a-f0-9]{12}$/D',$i->db);$this->assertSame('stock_receipt_qa_'.substr($i->db,-12),$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]);config(['stock_returns.enabled'=>true]);
|
|
$this->f=\receiptFixture('10.000');$this->reviewer=User::factory()->create(['tenant_id'=>$this->f['tenant']->id,'department_id'=>$this->f['issuer']->department_id]);
|
|
\supplyGrant($this->f['issuer'],['warehouse_request_prepare','warehouse_return_prepare','warehouse_return_read','warehouse_return_confirm','warehouse']);
|
|
\supplyGrant($this->reviewer,['warehouse_request_review']);\supplyGrant($this->f['receiver'],['warehouse','warehouse_request_deliver','warehouse_return_read','warehouse_return_confirm']);\supplyGrant($this->f['otherReceiver'],['warehouse','warehouse_return_read']);
|
|
$this->actingAs($this->f['receiver']);app(ReceiptWorkflow::class)->confirm($this->f['receiver'],$this->f['order'],\receiptPayload($this->f,'10.000'));
|
|
$this->actingAs($this->f['issuer']);$r=app(WarehouseRequests::class)->draft($this->f['issuer'],['operation_key'=>(string)Str::uuid(),'warehouse_id'=>$this->f['warehouse']->id,'cost_center_id'=>$this->f['r']->cost_center_id,'destination'=>'Synthetic internal workshop','reason'=>'Synthetic authorised internal material use.','priority'=>'normal','lines'=>[['stock_item_id'=>$this->f['stock']->id,'quantity'=>'5.000','use'=>'internal','equipment_id'=>null]]]);$this->requestId=$r->id;
|
|
$this->command($this->f['issuer'],'submit',['partial_allowed'=>false]);$this->command($this->reviewer,'authorize',['partial_allowed'=>false]);$this->command($this->f['receiver'],'deliver',['lines'=>[['index'=>0,'quantity'=>'5.000']]]);
|
|
$this->delivery=(int)WarehouseRequestEvent::query()->where('warehouse_request_id',$r->id)->where('action','deliver')->value('id');$this->command($this->f['issuer'],'receive',['delivery_event_id'=>$this->delivery]);
|
|
$this->payload=['operation_key'=>(string)Str::uuid(),'delivery_event_id'=>$this->delivery,'line_index'=>0,'quantity'=>'3.000','declared_unused'=>true,'evidence'=>'Synthetic unused quantity and physical custody evidence.','reason'=>'Synthetic unused material returned to its original warehouse.'];$this->actingAs($this->f['issuer']);
|
|
}
|
|
private function command(User $actor,string $action,array $extra):void
|
|
{
|
|
$this->actingAs($actor);$r=\App\Models\WarehouseRequest::query()->findOrFail($this->requestId);$d=['operation_key'=>(string)Str::uuid(),'version'=>app(WarehouseFulfilment::class)->progress($r)['version'],'action'=>$action,'reason'=>'Synthetic explicit request intervention.']+$extra;
|
|
$this->postJson('/stock/requisicoes/'.$r->id.'/eventos',$d)->assertRedirect();
|
|
}
|
|
private function prepare(?array $payload=null){return $this->postJson('/api/stock-returns/prepare',$payload??$this->payload);}
|
|
private function confirm(int $id,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/confirm',['version'=>$version,'received_in_warehouse'=>true,'reason'=>'Synthetic independent physical warehouse receipt.']);}
|
|
private function closeProposal(int $id,string $action,int $version=1){return $this->postJson('/api/stock-returns/'.$id.'/'.$action,['version'=>$version,'reason'=>'Synthetic reason for closing without physical movement.']);}
|
|
private function balance():int{return (int)StockMovement::query()->sum('quantity_milli');}
|
|
public function test_native_receipt_issue_acknowledgement_and_return_same_book_once():void
|
|
{
|
|
$this->assertSame(5000,$this->balance());$p=$this->prepare()->assertOk()->assertJsonPath('impact.physical_before','5000')->assertJsonPath('impact.physical_after','8000')->json('id');$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p)->assertForbidden();
|
|
$prepared=SupplyRecord::query()->findOrFail($p);$this->assertSame($this->f['issuer']->id,$prepared->snapshot['source']['acknowledgement']['actor_id']);
|
|
$this->actingAs($this->f['receiver']);$this->postJson('/api/stock-returns/'.$p.'/confirm',['version'=>1,'reason'=>'Synthetic receipt without acknowledgement'])->assertStatus(422);$m=$this->confirm($p)->assertOk()->json('movement_id');$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$m)->assertJsonPath('replayed',true);
|
|
$this->assertSame(8000,$this->balance());$this->assertSame(3,StockMovement::query()->count());$this->assertSame(2,StockOperation::query()->count());$this->assertSame(2,SupplyRecord::query()->where('kind','warehouse_return')->count());
|
|
$history=$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->component('stock/operations')->where('canViewReturns',true)->where('operations.data.0.kind','workshop_return')->where('operations.data.0.snapshot.stock_item.id',$this->f['stock']->id)->where('operations.data.0.snapshot.warehouse.id',$this->f['warehouse']->id)->where('operations.data.0.snapshot.actor.id',$this->f['receiver']->id)->where('operations.data.0.snapshot.responsible.id',$this->f['receiver']->id));
|
|
$historyRoot=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($historyRoot);file_put_contents($historyRoot.'/native-stock-history-page.json',json_encode($history->viewData('page'),JSON_THROW_ON_ERROR));
|
|
$this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1)->assertJsonPath('rows.0.version',2);$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);
|
|
$this->assertTrue(DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.confirmed')->exists());
|
|
$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$this->assertNotFalse($root);file_put_contents($root.'/return-results.json',json_encode(['kind'=>'native_receipt_internal_issue_and_return','tenant'=>$this->f['tenant']->id,'warehouse'=>$this->f['warehouse']->id,'item'=>$this->f['stock']->id,'return_movement'=>$m,'request_id'=>$this->requestId,'physical_milli'=>'8000','native_movements'=>3,'shared_return_manifest_sha256'=>hash_file('sha256',base_path('packages/inventory-return/releases/0.1.0/manifest.json'))],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
}
|
|
public function test_stale_capacity_idempotency_versions_rights_and_context_refuse_effect():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$changed=$this->payload;$changed['quantity']='2.000';$this->prepare($changed)->assertConflict();$second=$this->payload;$second['operation_key']=(string)Str::uuid();$other=$this->prepare($second)->assertOk()->json('id');
|
|
$this->actingAs($this->f['otherReceiver']);$this->confirm($p)->assertForbidden();$this->actingAs($this->f['receiver']);$this->confirm($p,2)->assertStatus(422);$this->confirm($p)->assertOk();$this->confirm($other)->assertConflict();$this->assertSame(8000,$this->balance());
|
|
$this->actingAs($this->f['issuer']);$second['quantity']='2.001';$second['operation_key']=(string)Str::uuid();$this->prepare($second)->assertConflict();$second['quantity']='2.000';$second['operation_key']=(string)Str::uuid();$p=$this->prepare($second)->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(10000,$this->balance());
|
|
config(['stock_returns.enabled'=>false]);$this->getJson('/api/stock-returns')->assertStatus(503);$this->assertSame(10000,$this->balance());
|
|
}
|
|
public function test_decision_failure_rolls_back_physical_movement_and_retry_recovers():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*supply_records/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic native return decision failure');}});
|
|
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Missing fault');}catch(\RuntimeException $e){$this->assertSame('Synthetic native return decision failure',$e->getMessage());}$this->withExceptionHandling();
|
|
$this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_undeclared_fraction_and_foreign_delivery_are_refused():void
|
|
{
|
|
$d=$this->payload;$d['declared_unused']=false;$this->prepare($d)->assertStatus(422);$d=$this->payload;$d['quantity']='0.0001';$this->prepare($d)->assertStatus(422);
|
|
$foreign=User::factory()->create(['tenant_id'=>\App\Models\Tenant::query()->create(['name'=>'Synthetic other native company'])->id]);\supplyGrant($foreign,['warehouse_return_prepare','warehouse_return_confirm','warehouse_return_read','warehouse']);$this->actingAs($foreign);$this->prepare()->assertNotFound();$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',0);
|
|
}
|
|
public function test_source_options_and_page_reuse_request_and_acknowledged_line():void
|
|
{
|
|
$this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('options.0.delivery_event_id',$this->delivery)->assertJsonPath('options.0.item.code',$this->f['stock']->code)->assertJsonPath('can_prepare',true);
|
|
$this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertOk()->assertHeader('Cache-Control','max-age=0, no-store, private')->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',$this->requestId)->where('canPrepare',true));
|
|
$this->getJson('/api/stock-returns?state=wrong')->assertStatus(422);$this->getJson('/api/stock-returns?page=0')->assertStatus(422);$this->getJson('/api/stock-returns?tenant_id=999')->assertStatus(422);
|
|
$this->actingAs($this->f['receiver']);$this->getJson('/api/stock-returns/sources?request_id='.$this->requestId)->assertOk()->assertJsonPath('can_prepare',false);$this->assertSame(5000,$this->balance());
|
|
$this->actingAs($this->f['otherReceiver']);$this->get('/stock/operacoes')->assertOk()->assertInertia(fn($page)=>$page->where('canViewReturns',true));$this->get('/stock/devolucoes-internas')->assertOk()->assertInertia(fn($page)=>$page->component('stock/returns')->where('requestId',null)->where('canConfirm',false));$this->get('/stock/devolucoes-internas?request_id='.$this->requestId)->assertForbidden();
|
|
}
|
|
public function test_source_issue_with_return_cannot_be_reversed_as_a_whole():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$operation=(int)StockOperation::query()->where('kind','issue')->value('id');
|
|
try{app(\App\Services\InventoryReversals::class)->basis(['source_kind'=>'operation','source_operation_id'=>$operation]);$this->fail('Original issue with return must not be reversed');}catch(\Illuminate\Validation\ValidationException $e){$this->assertStringContainsString('devolução física',implode(' ',array_merge(...array_values($e->errors()))));}
|
|
$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_concurrent_native_confirmation_waits_then_replays_one_movement():void
|
|
{
|
|
$this->nativeRace();
|
|
}
|
|
private function nativeRace(?int $given=null,?int $workerProposal=null,int $workerStatus=200,bool $parentWithdraw=false,?User $workerActor=null,string $workerAction='confirm'):void
|
|
{
|
|
$p=$given??$this->prepare()->assertOk()->json('id');$this->actingAs($this->f[$parentWithdraw?'issuer':'receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-race-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='native_return_'.bin2hex(random_bytes(6));$process=null;
|
|
file_put_contents($input,json_encode(['actor'=>($workerActor??$this->f['receiver'])->id,'proposal'=>$workerProposal??$p,'application'=>$application,'action'=>$workerAction],JSON_THROW_ON_ERROR));DB::beginTransaction();
|
|
try{$winner=$parentWithdraw?$this->closeProposal($p,'withdraw')->assertOk()->json('id'):$this->confirm($p)->assertOk()->json('movement_id');$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
|
|
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT 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);$result=json_decode(file_get_contents($input.'.result'),true);$this->assertSame($workerStatus,$result['status']);if($workerStatus===200){$this->assertTrue($result['result']['replayed']);$this->assertSame($winner,$result['result']['movement_id']);}
|
|
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
|
$this->assertSame($parentWithdraw?5000:8000,$this->balance());$this->assertSame($parentWithdraw?2:3,StockMovement::query()->count());$this->assertSame(1,SupplyRecord::query()->where('kind','warehouse_return')->where('state',$parentWithdraw?'withdrawn':'confirmed')->count());
|
|
}
|
|
public function test_competing_native_returns_cannot_exceed_the_original_delivery():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$d=$this->payload;$d['operation_key']=(string)Str::uuid();$other=$this->prepare($d)->assertOk()->json('id');$this->nativeRace($p,$other,409);
|
|
}
|
|
public function test_expired_and_maintenance_profiles_are_revalidated_at_confirmation():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>true]);$this->confirm($p)->assertForbidden();DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['maintenance'=>false,'expires_at'=>now()->subMinute()]);$this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->update(['expires_at'=>null]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_configured_context_and_permissions_cannot_be_inherited_from_people_profile():void
|
|
{
|
|
$tenant=$this->f['tenant']->id;$this->f['tenant']->update(['rules_provisional'=>false,'work_context_policy'=>['revision'=>1,'enabled'=>['procurement.internal','people.internal']]]);config(['work_context.entitlements.'.$tenant=>['procurement.internal','people.internal']]);DB::table('organization_profiles')->where('tenant_id',$tenant)->update(['work_contexts'=>json_encode(['procurement.internal'])]);
|
|
$this->prepare()->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertForbidden();$this->post('/contexto',['area'=>'procurement','option'=>'procurement.internal'])->assertRedirect('/dashboard');
|
|
$profiles=DB::table('organization_memberships')->where('user_id',$this->f['receiver']->id)->pluck('profile_id');DB::table('organization_profiles')->whereIn('id',$profiles)->update(['permissions'=>json_encode(['warehouse','warehouse_request_deliver','warehouse_return_read'])]);\supplyGrant($this->f['receiver'],['warehouse_return_confirm']);$other=DB::table('organization_profiles')->where('tenant_id',$tenant)->whereNotIn('id',$profiles)->orderByDesc('id')->value('id');DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['people.internal'])]);
|
|
$this->confirm($p)->assertForbidden();$this->assertSame(5000,$this->balance());DB::table('organization_profiles')->where('id',$other)->update(['work_contexts'=>json_encode(['procurement.internal'])]);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_return_signals_existing_replenishment_only_once_and_inside_transaction():void
|
|
{
|
|
$spy=new class extends \App\Services\AutomaticPurchases {public array $calls=[];public function signal(User $actor,int $item,int $warehouse):void {$this->calls[]=['actor'=>$actor->id,'item'=>$item,'warehouse'=>$warehouse,'transaction_level'=>DB::transactionLevel()];}};
|
|
$this->app->instance(\App\Services\AutomaticPurchases::class,$spy);$p=$this->prepare()->assertOk()->json('id');$this->assertCount(0,$spy->calls);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);
|
|
$this->assertCount(1,$spy->calls);$this->assertSame($this->f['stock']->id,$spy->calls[0]['item']);$this->assertSame($this->f['warehouse']->id,$spy->calls[0]['warehouse']);$this->assertGreaterThan(0,$spy->calls[0]['transaction_level']);$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_catalogue_rename_requires_new_review_preserving_original_and_unit_change_refuses():void
|
|
{
|
|
$name=$this->f['stock']->name;$p=$this->prepare()->assertOk()->json('id');$this->f['stock']->update(['name'=>'Synthetic renamed article']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->assertSame(5000,$this->balance());
|
|
$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->assertJsonPath('impact.item.name','Synthetic renamed article')->json('id');$this->assertSame($name,SupplyRecord::query()->findOrFail($p)->snapshot['source']['original_item']['name']);$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
$this->f['stock']->update(['unit'=>'KG']);$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$this->payload['quantity']='1.000';$this->prepare()->assertConflict();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_unavailable_private_package_returns_503_and_rolls_back_before_recovery():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$manifest=realpath(base_path('packages/inventory-return/releases/0.1.0/manifest.json'));$this->assertStringStartsWith($root.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR,$manifest);$original=file_get_contents($manifest);$this->assertNotFalse($original);
|
|
try{file_put_contents($manifest,'{"synthetic_private_fault":true}');$this->confirm($p)->assertStatus(503);}finally{file_put_contents($manifest,$original);}
|
|
$this->assertSame(5000,$this->balance());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(2,StockMovement::query()->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_http_busy_response_leaves_proposal_unconfirmed_then_manual_retry_succeeds():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$root=realpath((string)getenv('STOCK_RECEIPT_ROOT'));$dir=$root.'/return-busy-'.bin2hex(random_bytes(6));mkdir($dir,0700,true);$input=$dir.'/worker.json';$appName='native_return_busy_'.bin2hex(random_bytes(6));$process=null;file_put_contents($input,json_encode(['actor'=>$this->f['receiver']->id,'proposal'=>$p,'application'=>$appName,'http'=>true],JSON_THROW_ON_ERROR));DB::beginTransaction();
|
|
try{DB::table('tenants')->where('id',$this->f['tenant']->id)->lockForUpdate()->first();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/warehouse-return-worker.php'),$input],[0=>['pipe','r'],1=>['file',$dir.'/stdout','w'],2=>['file',$dir.'/stderr','w']],$pipes,base_path());$this->assertIsResource($process);fclose($pipes[0]);$parent=(int)DB::selectOne('SELECT pg_backend_pid() AS id')->id;$wait=null;$deadline=microtime(true)+10;
|
|
do{DB::select('SELECT pg_stat_clear_snapshot()');$wait=DB::selectOne("SELECT to_json(pg_blocking_pids(pid)) AS blockers FROM pg_stat_activity WHERE application_name=? AND wait_event_type='Lock'",[$appName]);if(!$wait)usleep(25000);}while(!$wait&µtime(true)<$deadline);$this->assertNotNull($wait);$this->assertContains($parent,json_decode($wait->blockers,true));
|
|
$deadline=microtime(true)+12;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);$r=json_decode(file_get_contents($input.'.result'),true);$this->assertSame(423,$r['status']);$this->assertSame('warehouse_return_busy',$r['result']['code']);$this->assertArrayNotHasKey('exception',$r['result']);$this->assertSame(5000,$this->balance());DB::commit();
|
|
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
|
|
$this->assertSame(0,SupplyRecord::query()->where('kind','warehouse_return')->where('state','confirmed')->count());$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());
|
|
}
|
|
public function test_withdrawal_and_independent_rejection_close_pending_without_stock_effect():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->closeProposal($p,'reject')->assertForbidden();\supplyGrant($this->f['receiver'],['warehouse_return_prepare']);$this->actingAs($this->f['receiver']);$this->closeProposal($p,'withdraw')->assertForbidden();$this->actingAs($this->f['issuer']);$this->closeProposal($p,'withdraw',2)->assertConflict();$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('state','withdrawn');$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);$this->prepare()->assertOk()->assertJsonPath('id',$p)->assertJsonPath('state','withdrawn');
|
|
$this->actingAs($this->f['receiver']);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',0);$this->getJson('/api/stock-returns?state=withdrawn')->assertOk()->assertJsonPath('pagination.total',1);
|
|
$this->actingAs($this->f['issuer']);$this->payload['operation_key']=(string)Str::uuid();$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('state','rejected');$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('replayed',true);$this->confirm($p)->assertConflict();$this->getJson('/api/stock-returns?state=rejected')->assertOk()->assertJsonPath('pagination.total',1);
|
|
$this->assertSame(5000,$this->balance());$this->assertSame(2,StockMovement::query()->count());$this->assertSame(1,StockOperation::query()->count());$this->assertSame(4,SupplyRecord::query()->where('kind','warehouse_return')->count());$this->assertSame(1,DB::table('audit_logs')->where('tenant_id',$this->f['tenant']->id)->where('action','warehouse_return.withdrawn')->count());
|
|
}
|
|
public function test_withdrawal_wins_before_confirmation_without_a_movement():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,true);
|
|
}
|
|
public function test_confirmation_wins_before_withdrawal_without_duplicate_terminal_state():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$this->nativeRace($p,$p,409,false,$this->f['issuer'],'withdraw');
|
|
}
|
|
public function test_return_changes_physical_history_without_silently_changing_gross_issue_forecast():void
|
|
{
|
|
$history=app(\App\Services\SupplyHistory::class);$date=now()->toDateString();
|
|
$before=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
|
|
$p=$this->prepare()->assertOk()->json('id');$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();
|
|
$after=$history->analyse($this->f['stock']->id,$this->f['warehouse']->id,$date,$date);
|
|
$this->assertSame(5000,$before['total_milli']);$this->assertSame(5000,$after['total_milli']);
|
|
$this->assertSame($before['daily_mean_milli'],$after['daily_mean_milli']);
|
|
$this->assertSame(5000,$before['series'][0]['balance_milli']);$this->assertSame(8000,$after['series'][0]['balance_milli']);
|
|
$this->assertSame(1,$after['movement_types']['workshop_return_in']);$this->assertNotSame($before['source_hash'],$after['source_hash']);
|
|
$this->assertSame($before['convention'],$after['convention']);$this->assertSame(3,$after['movement_count']);
|
|
$this->assertSame(8000,$this->balance());
|
|
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-forecast-boundary.json',json_encode(['gross_issue_milli'=>5000,'returned_milli'=>3000,'physical_before_milli'=>5000,'physical_after_milli'=>8000,'historical_mean_unchanged'=>true,'source_hash_changed'=>true,'planning_convention_unchanged'=>true,'net_consumption_forecast_implemented'=>false],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
}
|
|
public function test_history_pages_and_batch_preparer_lookup_cover_the_full_pending_universe():void
|
|
{
|
|
$p=$this->prepare()->assertOk()->json('id');$row=SupplyRecord::query()->findOrFail($p)->getAttributes();unset($row['id']);for($i=0;$i<50;$i++){$copy=$row;$key=(string)Str::uuid();$copy['record_key']=$key;$copy['operation_key']=$key;$data=json_decode($copy['data'],true,512,JSON_THROW_ON_ERROR);$data['operation_key']=$key;$copy['data']=json_encode($data,JSON_THROW_ON_ERROR);$copy['input_hash']=hash('sha256',$copy['data']);DB::table('supply_records')->insert($copy);}
|
|
$queries=[];$capture=true;DB::listen(function($q)use(&$queries,&$capture){if($capture&&str_starts_with(strtolower($q->sql),'select')&&str_contains($q->sql,'"supply_records"'))$queries[]=$q->sql;});$first=$this->getJson('/api/stock-returns')->assertOk()->assertJsonPath('pagination.total',51)->assertJsonPath('pagination.last_page',3)->assertJsonCount(25,'rows')->json('rows');$capture=false;$this->assertLessThanOrEqual(4,count($queries));
|
|
$second=$this->getJson('/api/stock-returns?page=2')->assertOk()->assertJsonCount(25,'rows')->json('rows');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));$this->assertGreaterThan($second[0]['id'],$first[24]['id']);$this->getJson('/api/stock-returns?page=3')->assertOk()->assertJsonCount(1,'rows');
|
|
$this->actingAs($this->f['receiver']);$this->confirm($p)->assertOk();$this->getJson('/api/stock-returns?state=prepared')->assertOk()->assertJsonPath('pagination.total',50);$this->getJson('/api/stock-returns?state=confirmed')->assertOk()->assertJsonPath('pagination.total',1);$this->assertSame(8000,$this->balance());
|
|
file_put_contents((string)getenv('STOCK_RECEIPT_ROOT').'/return-history-metrics.json',json_encode(['fixture_records'=>51,'page_size'=>25,'supply_record_selects_for_page'=>count($queries),'pages_disjoint'=>true,'state_filters_cover_latest_versions'=>true,'not_a_load_capacity_benchmark'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
|
|
}
|
|
}
|