Files
SGF/tests/Feature/WorkshopStockTest.php
T
2026-11-02 20:29:34 +02:00

380 lines
60 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Modules\Identity\Services\Audit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Carbon\CarbonImmutable;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class WorkshopStockTest extends TestCase
{
private array $f;private string $schema;private string $constraint;private string $bindingKey;private array $binding;private int $order;private int $task;private int $authorization;private int $entry;
protected function setUp(): void
{
parent::setUp();$run=(string)getenv('SGF_ISOLATED_REGRESSION_RUN');$this->assertMatchesRegularExpression('/^[a-f0-9]{12}$/D',$run);
$i=DB::selectOne("SELECT current_database() AS db,current_user AS role,current_setting('cluster_name') AS cluster,inet_server_port() AS port");
$this->assertSame('sgf_tempo_test',$i->db);$this->assertSame('sgf_test_runner',$i->role);$this->assertSame(55439,(int)$i->port);$this->assertSame('sgf_modular_regression_'.$run,$i->cluster);
$this->constraint=DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d;
DB::transaction(function(){
$old=substr($this->constraint,6);DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm') AND unit_id IS NOT NULL))");
$tag=bin2hex(random_bytes(6));$this->f=IdentityFixture::create('-ws-'.$tag);
foreach(['tech','reviewer'] as $name){$u=User::create(['name'=>'Synthetic '.$name,'email'=>$name.'-'.$tag.'@example.test','password'=>bin2hex(random_bytes(16))]);$this->f[$name]=$u;DB::table('company_memberships')->insert(['user_id'=>$u->id,'tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company']]);foreach(['company.view'=>null,'unit.view'=>$this->f['unit']] as $r=>$unit)IdentityFixture::grant($u->id,$this->f['tenant'],$this->f['company'],$r,$unit);}
foreach(['manager'=>['view','open','assign','authorize'],'tech'=>['view','record'],'reviewer'=>['view','verify'],'reader'=>['view']] as $name=>$rights)foreach($rights as $r)$this->grant($name,'workshop.'.$r);
foreach(['manager'=>['stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'],'reader'=>['stock.view','workshop_stock.view','workshop_stock.confirm']] as $name=>$rights)foreach($rights as $r)$this->grant($name,$r);
$client=DB::table('a_clients')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>null,'name'=>'Synthetic stock workshop client','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);$this->f['client']=$client;
Audit::record('module.record.associated',$this->f['manager']->id,$this->f['tenant'],$this->f['company'],details:['area'=>'clients','record_id'=>$client,'module_id'=>'workshop','reason'=>'Explicit new synthetic fixture association']);
$this->schema='sgf_stock_read_'.$tag;$s=$this->schema;
DB::statement("CREATE SCHEMA $s");
DB::statement("CREATE TABLE $s.stock_items(id bigint PRIMARY KEY,tenant_id bigint,code text,name text,unit text)");DB::statement("INSERT INTO $s.stock_items VALUES(1,77,'CABO','Synthetic cable','UN')");
DB::statement("CREATE TABLE $s.warehouses(id bigint PRIMARY KEY,tenant_id bigint,code text,name text)");DB::statement("INSERT INTO $s.warehouses VALUES(11,77,'WH','Synthetic warehouse')");
DB::statement("CREATE TABLE $s.stock_operations(id bigserial PRIMARY KEY,tenant_id bigint,created_by bigint,responsible_id bigint,warehouse_id bigint,stock_item_id bigint,kind text,reference text,idempotency_key uuid,payload_hash text,destination text,reason text,quantity_milli bigint,theoretical_milli bigint,ledger_version bigint,recorded_at timestamp,snapshot jsonb,UNIQUE(tenant_id,reference),UNIQUE(tenant_id,idempotency_key))");
DB::statement("CREATE TABLE $s.stock_movements(id bigserial PRIMARY KEY,tenant_id bigint,stock_operation_id bigint,stock_item_id bigint,warehouse_id bigint,moved_by bigint,equipment_id bigint,spare_part_id bigint,movement_type text,quantity_milli bigint,snapshot jsonb,moved_at timestamp,UNIQUE(stock_operation_id,warehouse_id))");
DB::statement("INSERT INTO $s.stock_movements(tenant_id,stock_item_id,warehouse_id,movement_type,quantity_milli,snapshot,moved_at) VALUES(77,1,11,'receipt_in',10000,'{}',now()-interval '1 hour')");
DB::statement("CREATE TABLE $s.ws_proposals(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,entry_id bigint,entry_version int,preparer_id bigint,command_key uuid,request_hash text,reason text,evidence text,source_hash text,snapshot jsonb,created_at timestamp,UNIQUE(tenant_id,company_id,unit_id,preparer_id,command_key))");
DB::statement("CREATE TABLE $s.ws_claims(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE,entry_id bigint UNIQUE,entry_version int,operation_id bigint UNIQUE,movement_id bigint UNIQUE,preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE TABLE $s.ws_allocations(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE REFERENCES $s.ws_proposals(id),entry_id bigint UNIQUE,entry_version int,operation_id bigint REFERENCES $s.stock_operations(id),movement_id bigint REFERENCES $s.stock_movements(id),quantity_milli bigint NOT NULL CHECK(quantity_milli>0),preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE INDEX ON $s.ws_allocations(movement_id)");
DB::statement("CREATE TABLE $s.ws_returns(id bigserial PRIMARY KEY,tenant_id bigint,company_id bigint,unit_id bigint,origin_key text UNIQUE,proposal_id bigint UNIQUE REFERENCES $s.ws_proposals(id),entry_id bigint UNIQUE,entry_version int,operation_id bigint UNIQUE REFERENCES $s.stock_operations(id),movement_id bigint UNIQUE REFERENCES $s.stock_movements(id),parent_movement_id bigint NOT NULL REFERENCES $s.stock_movements(id),quantity_milli bigint NOT NULL CHECK(quantity_milli>0),preparer_id bigint,confirmer_id bigint,reason text,before_milli text,after_milli text,source_hash text,created_at timestamp)");
DB::statement("CREATE INDEX ON $s.ws_returns(parent_movement_id)");
DB::statement("CREATE TABLE $s.ws_closures(id bigserial PRIMARY KEY,tenant_id bigint NOT NULL,company_id bigint NOT NULL,unit_id bigint NOT NULL,proposal_id bigint NOT NULL UNIQUE REFERENCES $s.ws_proposals(id),actor_id bigint NOT NULL,source_hash text NOT NULL,reason text NOT NULL,before_state text NOT NULL CHECK(before_state='pending'),state text NOT NULL CHECK(state IN ('withdrawn','rejected')),state_version int NOT NULL CHECK(state_version=2),created_at timestamp NOT NULL)");
DB::statement("CREATE INDEX ON $s.ws_closures(tenant_id,company_id,unit_id,state,proposal_id)");
DB::statement("CREATE FUNCTION $s.immutable() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RAISE EXCEPTION ''Append-only workshop stock proof''; END'");
foreach(['ws_proposals','ws_claims','ws_allocations','ws_returns','ws_closures','stock_operations','stock_movements'] as $table)DB::statement("CREATE TRIGGER immutable BEFORE UPDATE OR DELETE ON $s.$table FOR EACH ROW EXECUTE FUNCTION $s.immutable()");
});
config(['modules.companies.'.$this->f['tenant'].':'.$this->f['company']=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']]]);
CarbonImmutable::setTestNow(CarbonImmutable::now()->subMinutes(10)->startOfMinute());
$this->login('manager');$this->order=$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/orders',['reference'=>'QA-WS-'.Str::uuid(),'client_id'=>$this->f['client'],'equipment_identifier'=>'QA-EQ-'.Str::uuid(),'equipment_description'=>'Synthetic equipment','request'=>'Synthetic repair','location'=>'Synthetic place','evidence'=>'Synthetic work request'])->assertOk()->json('id');
$this->record('assignment',['technician_id'=>$this->f['tech']->id]);$this->login('tech');$this->task=$this->record('task',['description'=>'Synthetic authorised task']);
$this->login('manager');$this->authorization=$this->record('authorization',['task_id'=>$this->task,'authorized_by'=>'Synthetic client','capacity'=>'Synthetic owner','authorized_at'=>now()->toIso8601String(),'channel'=>'Synthetic record','scope'=>'This task only','evidence'=>'Synthetic authorisation']);
CarbonImmutable::setTestNow(CarbonImmutable::now()->addMinute());$this->entry=$this->material();
$this->bindingKey=$this->f['tenant'].':'.$this->f['company'].':'.$this->f['unit'];
$this->binding=['schema'=>$this->schema,'source_tenant_id'=>77,'eligible_order_ids'=>[$this->order],'lots'=>false,'reservations'=>false,'spare_parts'=>false,'resources'=>['CABO|UN'=>['stock_item_id'=>1,'stock_unit'=>'UN','warehouse_ids'=>[11]]]];
config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding,'stock_read.synthetic_sources.'.$this->bindingKey=>['schema'=>$this->schema,'source_tenant_id'=>77,'warehouse_ids'=>[11],'append_only'=>true]]);$this->login('manager');
}
protected function tearDown(): void
{
CarbonImmutable::setTestNow();
try{if(isset($this->constraint)){DB::transaction(function(){if(isset($this->f))DB::table('permission_grants')->whereIn('user_id',array_map(fn($n)=>$this->f[$n]->id,['manager','reader']))->whereIn('permission',['stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'])->delete();DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');DB::statement('ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check '.$this->constraint);});}}finally{parent::tearDown();}
}
private function grant(string $name,string $r):void {IdentityFixture::grant($this->f[$name]->id,$this->f['tenant'],$this->f['company'],$r,$this->f['unit']);}
private function login(string $name,string $module='workshop'):void {$u=$this->f[$name];$this->actingAs($u)->withSession(['auth_version'=>$u->auth_version]);$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);}
private function revision():int {return (int)DB::selectOne('SELECT sgf_wo_revision(?) AS v',[$this->order])->v;}
private function record(string $kind,array $extra):int {return $this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/orders/'.$this->order.'/entries',array_replace(['kind'=>$kind,'origin'=>'QA-'.Str::uuid(),'version'=>1,'revision'=>$this->revision()],$extra))->assertOk()->json('id');}
private function material(string $quantity='2',string $owner='company',string $event='consumed',bool $verify=true):int
{
$this->login('tech');$id=$this->record('material',['task_id'=>$this->task,'authorization_id'=>$this->authorization,'starts_at'=>now()->subSeconds(10)->toIso8601String(),'resource'=>$owner==='company'?'CABO':'CLIENT-CABO','quantity'=>$quantity,'unit'=>'UN','owner'=>$owner,'event'=>$event,'evidence'=>'Synthetic material evidence']);
if($verify){$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$id.'/verification',['version'=>1,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic independent technical verification'])->assertOk();}return $id;
}
private function prepare(?int $id=null,?string $key=null,int $version=1){return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare',['entry_id'=>$id??$this->entry,'version'=>$version,'warehouse_id'=>11,'reason'=>'Synthetic material issue justification.','evidence'=>'Synthetic physical delivery proof']);}
private function confirm(int $id,bool $received=false){return $this->postJson('/api/workshop-stock/'.$id.'/confirm',['reason'=>'Synthetic independent stock confirmation.']+($received?['received_in_warehouse'=>true]:[]));}
private function balance():int{return (int)DB::table($this->schema.'.stock_movements')->sum('quantity_milli');}
private function closeProposal(int $id,string $action,array $extra=[]){return $this->postJson('/api/workshop-stock/'.$id.'/'.$action,$extra+['state_version'=>1,'source_hash'=>DB::table($this->schema.'.ws_proposals')->where('id',$id)->value('source_hash'),'reason'=>'Synthetic closure without any physical movement.']);}
public function test_closure_withdraws_stale_preview_and_replay_does_not_reopen_it():void
{
$key=(string)Str::uuid();$p=$this->prepare(key:$key)->assertOk()->assertJsonPath('state','pending')->json('id');$original=DB::table($this->schema.'.ws_proposals')->find($p);
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>1000,'movement_type'=>'receipt_in','snapshot'=>'{}','moved_at'=>now()]);
$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('state','withdrawn')->assertJsonPath('physical_effect_milli','0');$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);
$this->prepare(key:$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('state','withdrawn')->assertJsonPath('state_version',2)->assertJsonPath('replayed',true);
$this->closeProposal($p,'withdraw',['reason'=>'Another changed synthetic closure justification.'])->assertConflict();
$this->assertEquals($original,DB::table($this->schema.'.ws_proposals')->find($p));$this->assertSame(1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(11000,$this->balance());
$this->login('reader');$this->confirm($p)->assertConflict();$this->closeProposal($p,'reject')->assertConflict();$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposals.0.state_version',2)->assertJsonPath('proposals.0.closure.before_state','pending')->assertJsonPath('proposal_pagination.total',1);
$this->login('manager');$new=$this->prepare()->assertOk()->json('id');$this->assertNotSame($p,$new);$this->login('reader');$this->confirm($new)->assertOk();$this->assertSame(9000,$this->balance());
$event=DB::table('identity_audit')->where(['company_id'=>$this->f['company'],'action'=>'workshop_stock.withdrawn'])->first();$this->assertNotNull($event);$details=json_decode($event->details,true);$this->assertSame($p,$details['record_id']);$this->assertSame(2,$details['version']);$this->assertArrayHasKey('decision_id',$details);
}
public function test_closure_requires_current_right_context_independence_hash_and_version():void
{
$p=$this->prepare()->assertOk()->json('id');$this->closeProposal($p,'reject')->assertForbidden();
$this->closeProposal($p,'withdraw',['state_version'=>2])->assertConflict();$this->closeProposal($p,'withdraw',['source_hash'=>str_repeat('0',64)])->assertConflict();$this->closeProposal($p,'withdraw',['source_hash'=>'invalid'])->assertStatus(422);$this->closeProposal($p,'withdraw',['tenant_id'=>999])->assertStatus(422);
$this->login('reader');$this->closeProposal($p,'withdraw')->assertForbidden();DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->closeProposal($p,'reject')->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$this->withHeader('X-SGF-Context','obsolete')->postJson('/api/workshop-stock/'.$p.'/reject',['state_version'=>1,'source_hash'=>DB::table($this->schema.'.ws_proposals')->value('source_hash'),'reason'=>'Synthetic stale context should be refused.'])->assertConflict();$this->login('reader');
$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('state','rejected');$this->closeProposal($p,'reject')->assertOk()->assertJsonPath('replayed',true);$this->confirm($p)->assertConflict();$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.stock_operations')->count());
}
public function test_closure_of_confirmed_proposal_requires_separate_physical_correction():void
{
$p=$this->prepare()->assertOk()->json('id');$this->login('reader');$movement=$this->confirm($p)->assertOk()->json('movement_id');$this->closeProposal($p,'reject')->assertConflict();$this->login('manager');$this->closeProposal($p,'withdraw')->assertConflict();$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(8000,$this->balance());$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposals.0.state','confirmed')->assertJsonPath('proposals.0.movement_id',$movement);
}
public function test_closure_failure_rolls_back_audit_and_recovers_with_one_decision():void
{
$p=$this->prepare()->assertOk()->json('id');$this->withoutExceptionHandling();$fault=true;DB::listen(function($q)use(&$fault){if($fault&&str_contains($q->sql,'insert into "identity_audit"')){$fault=false;throw new \RuntimeException('Synthetic closure audit failure');}});
try{$this->closeProposal($p,'withdraw');$this->fail('Fault expected');}catch(\RuntimeException $e){$this->assertSame('Synthetic closure audit failure',$e->getMessage());}
$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(0,DB::table('identity_audit')->where(['company_id'=>$this->f['company'],'action'=>'workshop_stock.withdrawn'])->count());$this->withExceptionHandling();$this->closeProposal($p,'withdraw')->assertOk();$this->closeProposal($p,'withdraw')->assertOk()->assertJsonPath('replayed',true);$this->assertSame(1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(10000,$this->balance());
try{DB::transaction(fn()=>DB::table($this->schema.'.ws_closures')->where('proposal_id',$p)->update(['state'=>'rejected']));$this->fail('Append-only expected');}catch(\Illuminate\Database\QueryException $e){$this->assertSame('P0001',$e->errorInfo[0]);}
}
public function test_closure_history_filters_and_pages_use_only_the_current_page():void
{
$p=$this->prepare()->assertOk()->json('id');$row=(array)DB::table($this->schema.'.ws_proposals')->find($p);unset($row['id']);for($i=0;$i<50;$i++){$row['command_key']=(string)Str::uuid();DB::table($this->schema.'.ws_proposals')->insert($row);}
$ids=DB::table($this->schema.'.ws_proposals')->orderBy('id')->pluck('id');foreach($ids->take(2) as $id)$this->closeProposal((int)$id,'withdraw')->assertOk();$this->login('reader');$this->closeProposal((int)$ids[2],'reject')->assertOk();
$queries=[];$capture=true;DB::listen(function($q)use(&$capture,&$queries){if($capture&&str_starts_with(strtolower($q->sql),'select')&&str_contains($q->sql,'ws_closures'))$queries[]=$q->sql;});
$first=$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('closure_supported',true)->assertJsonPath('proposal_pagination.total',51)->assertJsonCount(25,'proposals')->json('proposals');$capture=false;
$this->assertCount(2,$queries);$this->assertCount(1,array_filter($queries,fn($sql)=>str_contains($sql,'from "'.$this->schema.'"."ws_closures"')));$this->assertCount(1,array_filter($queries,fn($sql)=>str_contains($sql,'from pg_class')));
$second=$this->getJson('/api/workshop-stock?proposal_page=2')->assertOk()->assertJsonCount(25,'proposals')->json('proposals');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));
$this->getJson('/api/workshop-stock?status=pending')->assertOk()->assertJsonPath('proposal_pagination.total',48);$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposal_pagination.total',2);$this->getJson('/api/workshop-stock?status=rejected')->assertOk()->assertJsonPath('proposal_pagination.total',1);$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',0);$this->assertSame(10000,$this->balance());
file_put_contents(base_path('../private/workshop-closure-history.json'),json_encode(['fixture_proposals'=>51,'closed'=>3,'pending'=>48,'page_size'=>25,'closure_catalog_discovery_selects'=>1,'closure_batch_data_selects'=>1,'pages_disjoint'=>true,'not_a_capacity_benchmark'=>true],JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
}
public function test_closure_cannot_cross_company_or_unit_even_with_shared_source_binding():void
{
$p=$this->prepare()->assertOk()->json('id');$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->closeProposal($p,'withdraw')->assertForbidden();$this->closeProposal($p,'reject')->assertForbidden();$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonCount(0,'proposals');$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());$this->assertSame(10000,$this->balance());
}
public function test_closure_absent_in_legacy_synthetic_source_does_not_disable_confirmation():void
{
// Only this test's new empty table is removed; no retained fixture or installed schema is changed.
$this->assertSame(0,DB::table($this->schema.'.ws_closures')->count());DB::statement('DROP TABLE '.$this->schema.'.ws_closures');
$p=$this->prepare()->assertOk()->json('id');$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('closure_supported',false);$this->closeProposal($p,'withdraw')->assertStatus(503);$this->getJson('/api/workshop-stock?status=withdrawn')->assertOk()->assertJsonPath('proposal_pagination.total',0);
$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
}
public function test_closure_for_delivery_allocation_and_return_never_moves_or_claims_material():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->closeProposal($p,'withdraw')->assertOk();
$entry=$this->material('3','company','returned');$this->login('manager');$key=(string)Str::uuid();$p=$this->prepareReturn($movement,$entry,$key)->assertOk()->json('id');$this->login('reader');$this->closeProposal($p,'reject')->assertOk();$this->confirm($p,true)->assertConflict();
$this->login('manager');$this->prepareReturn($movement,$entry,$key)->assertOk()->assertJsonPath('state','rejected')->assertJsonPath('replayed',true);$p=$this->prepareReturn($movement,$entry)->assertOk()->json('id');$this->closeProposal($p,'withdraw')->assertOk();
$this->assertSame(5000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());foreach(['ws_claims','ws_allocations','ws_returns'] as $table)$this->assertSame(0,DB::table($this->schema.'.'.$table)->count());
}
public function test_closure_withdrawal_wins_over_concurrent_confirmation():void {$this->closureRace('withdrawn','confirm',409);}
public function test_closure_confirmation_wins_over_concurrent_withdrawal():void {$this->closureRace('confirm','withdrawn',409);}
public function test_closure_concurrent_identical_withdrawals_replay_one_decision():void {$this->closureRace('withdrawn','withdrawn',200);}
private function closureRace(string $first,string $second,int $expected):void
{
$p=$this->prepare()->assertOk()->json('id');$scope=['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']];$hash=DB::table($this->schema.'.ws_proposals')->where('id',$p)->value('source_hash');
$actor=$second==='confirm'?'reader':'manager';$dir=base_path('../private/workshop-closure-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='ws_closure_'.bin2hex(random_bytes(6));
file_put_contents($input,json_encode(['actor'=>$this->f[$actor]->id,'scope'=>$scope,'binding'=>$this->binding,'proposal'=>$p,'application'=>$application,'action'=>$second,'state_version'=>1,'source_hash'=>$hash,'reason'=>'Synthetic closure without any physical movement.'],JSON_THROW_ON_ERROR));
$this->login($first==='confirm'?'reader':'manager');$process=null;DB::beginTransaction();
try{
($first==='confirm'?$this->confirm($p):$this->closeProposal($p,'withdraw'))->assertOk();$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/workshop-stock-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&&microtime(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($expected,$result['status']);if($expected===200)$this->assertTrue($result['result']['replayed']);
}finally{if(is_resource($process)){proc_terminate($process);proc_close($process);}while(DB::transactionLevel()>0)DB::rollBack();}
$this->assertSame($first==='confirm'?1:0,DB::table($this->schema.'.ws_claims')->count());$this->assertSame($first==='confirm'?0:1,DB::table($this->schema.'.ws_closures')->count());$this->assertSame($first==='confirm'?8000:10000,$this->balance());
}
private function previousDelivery(int $quantity=2000):int
{
require_once base_path('packages/inventory-issue/bootstrap.php');
$movement=DB::transaction(function()use($quantity){
$book=DB::table($this->schema.'.stock_movements')->selectRaw('sum(quantity_milli) AS quantity,max(id) AS revision')->first();
$id=DB::table($this->schema.'.stock_operations')->insertGetId(['tenant_id'=>77,'created_by'=>$this->f['manager']->id,'warehouse_id'=>11,'stock_item_id'=>1,'kind'=>'issue','reference'=>'QA-DELIVERY-'.Str::uuid(),'idempotency_key'=>(string)Str::uuid(),'quantity_milli'=>$quantity,'ledger_version'=>$book->revision,'theoretical_milli'=>$book->quantity,'reason'=>'Synthetic previous delivery','recorded_at'=>now()->subMinutes(2),'snapshot'=>'{}']);
$w=new \Tempo\Inventory\IssueMovementWriter(DB::connection(),$this->schema,77,$this->f['manager']->id,fn()=>null);
// Clock is set before the consumed observation: the delivered movement is immutable.
$clock=CarbonImmutable::now();CarbonImmutable::setTestNow($clock->subMinutes(2));
try{return $w->write($id,$w->preview($id))['movement_id'];}finally{CarbonImmutable::setTestNow($clock);}
});
$this->binding['eligible_deliveries'][$movement]=['order_id'=>$this->order];config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);return $movement;
}
private function prepareDelivery(int $movement,?string $key=null,?int $entry=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-delivery',['entry_id'=>$entry??$this->entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic existing delivery reconciliation.','evidence'=>'Synthetic delivery document and consumed origin']);
}
private function prepareAllocation(int $movement,?int $entry=null,?string $key=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-allocation',['entry_id'=>$entry??$this->entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic individual delivery allocation.','evidence'=>'Synthetic consumed origin and delivery evidence']);
}
private function enableAllocations():void {$this->binding['partial_delivery_links']=true;config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);}
private function enableReturns():void {$this->binding['stock_returns']=true;$this->enableAllocations();}
private function prepareReturn(int $movement,int $entry,?string $key=null)
{
return $this->withHeader('Idempotency-Key',$key??(string)Str::uuid())->postJson('/api/workshop-stock/prepare-return',['entry_id'=>$entry,'version'=>1,'warehouse_id'=>11,'movement_id'=>$movement,'reason'=>'Synthetic warehouse return receiving confirmation.','evidence'=>'Synthetic physical warehouse receipt evidence']);
}
public function test_stock_return_unconsumed_material_restores_only_remaining_quantity_once():void
{
$opening=DB::table($this->schema.'.stock_movements')->where('movement_type','receipt_in')->value('moved_at');$this->assertTrue(CarbonImmutable::parse($opening)->lessThan(CarbonImmutable::now()->subMinutes(2)));
$movement=$this->previousDelivery(5000);$this->enableReturns();$original=DB::table($this->schema.'.stock_movements')->find($movement);$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');$this->confirm($p,true)->assertOk();
$returned=$this->material('3','company','returned');$this->login('manager');$key=(string)Str::uuid();$p=$this->prepareReturn($movement,$returned,$key)->assertOk()->assertJsonPath('impact.mode','return_existing_delivery')->assertJsonPath('impact.physical_before','5000')->assertJsonPath('impact.physical_after','8000')->assertJsonPath('impact.unallocated_after','0')->json('id');
$this->prepareReturn($movement,$returned,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p,true)->assertForbidden();$this->login('reader');$this->confirm($p)->assertStatus(422);$this->assertSame(5000,$this->balance());$returnMovement=$this->confirm($p,true)->assertOk()->json('movement_id');$this->confirm($p,true)->assertOk()->assertJsonPath('movement_id',$returnMovement)->assertJsonPath('replayed',true);
$this->assertSame(8000,$this->balance());$this->assertSame(3,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());$this->assertEquals($original,DB::table($this->schema.'.stock_movements')->find($movement));
$returnOp=(int)DB::table($this->schema.'.ws_returns')->value('operation_id');DB::transaction(function()use($returnOp,$returnMovement){$writer=new \Tempo\Inventory\DeliveryReturnWriter(DB::connection(),$this->schema,77,$this->f['reader']->id,fn()=>null);$r=$writer->write($returnOp,$writer->preview($returnOp));$this->assertTrue($r['replayed']);$this->assertSame($returnMovement,$r['movement_id']);});
$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',2)->assertJsonPath('proposals.0.movement_id',$returnMovement);
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');$this->login('manager');$this->prepareReturn($movement,$returned)->assertConflict();
$extra=$this->material('1');$this->login('manager');$this->prepareAllocation($movement,$extra)->assertConflict();$this->prepare($extra)->assertOk();
}
public function test_stock_return_refuses_consumed_client_unverified_excess_and_stale_capacity():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$this->prepareReturn($movement,$this->entry)->assertStatus(422);
foreach([[$this->material('1','client','returned'),422],[$this->material('1','company','returned',false),409],[$this->material('0.0001','company','returned'),422],[$this->material('6','company','returned'),409]] as [$entry,$status]){$this->login('manager');$this->prepareReturn($movement,$entry)->assertStatus($status);}
$return=$this->material('4','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$return)->assertOk()->json('id');$allocation=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');$this->confirm($allocation)->assertOk();$this->confirm($p,true)->assertConflict();$this->assertSame(5000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());
}
public function test_stock_return_failure_after_decision_rolls_back_movement_and_recovers():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$returned=$this->material('1','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$returned)->assertOk()->json('id');$this->login('reader');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_returns/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic return decision failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p,true);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic return decision failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(5000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());$this->confirm($p,true)->assertOk();$this->assertSame(6000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());
}
public function test_stock_return_concurrency_cannot_restore_quantity_twice():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$one=$this->material('3','company','returned');$two=$this->material('3','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$one)->assertOk()->json('id');$other=$this->prepareReturn($movement,$two)->assertOk()->json('id');
$this->concurrentConfirmation(false,$p,$other,409,true);$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_returns')->count());$this->assertSame(3,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(3000,(int)DB::table($this->schema.'.ws_returns')->sum('quantity_milli'));
}
public function test_stock_return_revoked_right_stale_context_and_foreign_company_do_not_write():void
{
$movement=$this->previousDelivery(5000);$this->enableReturns();$entry=$this->material('1','company','returned');$this->login('manager');$p=$this->prepareReturn($movement,$entry)->assertOk()->json('id');$this->login('reader');
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p,true)->assertForbidden();$this->grant('reader','workshop_stock.confirm');$this->withHeader('X-SGF-Context','obsolete')->postJson('/api/workshop-stock/'.$p.'/confirm',['reason'=>'Synthetic warehouse receiving','received_in_warehouse'=>true])->assertConflict();$this->login('manager');
$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->prepareReturn($movement,$entry)->assertForbidden();$this->prepareAllocation($movement)->assertForbidden();$this->confirm($p,true)->assertForbidden();$this->getJson('/api/workshop-stock')->assertOk()->assertJsonCount(0,'proposals')->assertJsonPath('pagination.total',0);$this->assertSame(5000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_returns')->count());
}
public function test_partial_delivery_links_multiple_origins_without_debit_and_rechecks_remaining():void
{
$movement=$this->previousDelivery(5000);$this->enableAllocations();$other=$this->material('3');$this->login('manager');$key=(string)Str::uuid();
$p=$this->prepareAllocation($movement,null,$key)->assertOk()->assertJsonPath('impact.mode','allocate_existing_delivery')->assertJsonPath('impact.unallocated_before','5000')->assertJsonPath('impact.unallocated_after','3000')->assertJsonPath('impact.physical_after','5000')->json('id');
$this->prepareAllocation($movement,null,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$stale=$this->prepareAllocation($movement,$other)->assertOk()->json('id');$this->confirm($p)->assertForbidden();
$this->login('reader');$this->confirm($p)->assertOk();$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);$this->confirm($stale)->assertConflict();
$this->login('manager');$this->prepare()->assertConflict();$p=$this->prepareAllocation($movement,$other)->assertOk()->assertJsonPath('impact.unallocated_before','3000')->assertJsonPath('impact.unallocated_after','0')->json('id');$this->login('reader');$this->confirm($p)->assertOk();
$this->assertSame(5000,$this->balance());$this->assertSame(5000,(int)DB::table($this->schema.'.ws_allocations')->sum('quantity_milli'));$this->assertSame(2,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
$this->login('manager');$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('sources.0.movement_id',$movement)->assertJsonPath('proposals.0.movement_id',$movement);
$new=$this->material('1');$this->login('manager');$this->prepareAllocation($movement,$new)->assertConflict();$this->prepare($new)->assertOk();
// Disabling new allocations must not erase protection for existing allocated origins.
unset($this->binding['partial_delivery_links']);config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);$this->prepare()->assertConflict();
}
public function test_partial_delivery_requires_enabled_capability_and_rolls_back_allocation_failure():void
{
$movement=$this->previousDelivery(5000);$this->prepareAllocation($movement)->assertStatus(503);$this->enableAllocations();$p=$this->prepareAllocation($movement)->assertOk()->json('id');$this->login('reader');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_allocations/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic allocation failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic allocation failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(0,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(5000,$this->balance());$this->confirm($p)->assertOk();$this->assertSame(1,DB::table($this->schema.'.ws_allocations')->count());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
}
public function test_partial_delivery_concurrent_proposals_cannot_overallocate():void
{
$one=$this->material('3');$two=$this->material('3');$this->login('manager');$movement=$this->previousDelivery(5000);$this->enableAllocations();$p=$this->prepareAllocation($movement,$one)->assertOk()->json('id');$other=$this->prepareAllocation($movement,$two)->assertOk()->json('id');
$this->concurrentConfirmation(false,$p,$other,409);$this->assertSame(5000,$this->balance());$this->assertSame(3000,(int)DB::table($this->schema.'.ws_allocations')->sum('quantity_milli'));$this->assertSame(1,DB::table($this->schema.'.ws_allocations')->count());
$this->login('manager');$this->prepareAllocation($movement,$two)->assertConflict();
}
public function test_history_filters_and_pagination_preserve_order_and_scope():void
{
$p=$this->prepare()->assertOk()->json('id');$original=(array)DB::table($this->schema.'.ws_proposals')->find($p);unset($original['id']);
for($i=0;$i<50;$i++){$row=$original;$row['command_key']=(string)Str::uuid();DB::table($this->schema.'.ws_proposals')->insert($row);}
$first=$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('proposal_pagination.total',51)->assertJsonPath('proposal_pagination.last_page',3)->assertJsonCount(25,'proposals')->json('proposals');
$second=$this->getJson('/api/workshop-stock?proposal_page=2')->assertOk()->assertJsonCount(25,'proposals')->json('proposals');$this->assertEmpty(array_intersect(array_column($first,'id'),array_column($second,'id')));$this->assertGreaterThan($second[0]['id'],$first[24]['id']);
$this->login('reader');$this->confirm($p)->assertOk();$this->getJson('/api/workshop-stock?status=confirmed')->assertOk()->assertJsonPath('proposal_pagination.total',1)->assertJsonPath('proposals.0.id',$p);$this->getJson('/api/workshop-stock?status=pending&proposal_page=2')->assertOk()->assertJsonPath('proposal_pagination.total',50)->assertJsonCount(25,'proposals');
$this->getJson('/api/workshop-stock?status=unknown')->assertStatus(422);$this->getJson('/api/workshop-stock?proposal_page=0')->assertStatus(422);$this->getJson('/api/workshop-stock?tenant_id=999')->assertStatus(422);$this->assertSame(8000,$this->balance());
}
public function test_delivery_link_has_zero_effect_independent_decision_and_no_duplicate():void
{
$movement=$this->previousDelivery();$frozen=DB::table($this->schema.'.stock_movements')->find($movement);$this->prepare()->assertConflict();$key=(string)Str::uuid();
$p=$this->prepareDelivery($movement,$key)->assertOk()->assertJsonPath('impact.mode','link_existing_delivery')->assertJsonPath('impact.physical_before','8000')->assertJsonPath('impact.physical_after','8000')->assertJsonPath('impact.quantity_milli','0')->assertJsonPath('impact.linked_quantity_milli','2000')->json('id');
$other=$this->prepareDelivery($movement)->assertOk()->json('id');$this->prepareDelivery($movement,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->confirm($p)->assertForbidden();
$this->login('reader');$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$movement);$this->confirm($p)->assertOk()->assertJsonPath('replayed',true);$this->confirm($other)->assertConflict();
$this->assertSame(8000,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
$this->assertEquals($frozen,DB::table($this->schema.'.stock_movements')->find($movement));
$claim=DB::table($this->schema.'.ws_claims')->first();$this->assertSame($claim->before_milli,$claim->after_milli);
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');
}
public function test_delivery_link_refuses_mismatch_stale_book_and_recovers_from_failure():void
{
$movement=$this->previousDelivery();$this->prepareDelivery(99999999)->assertForbidden();
$different=$this->material('3');$this->login('manager');$this->prepareDelivery($movement,null,$different)->assertConflict();
$p=$this->prepareDelivery($movement)->assertOk()->json('id');DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'movement_type'=>'receipt_in','quantity_milli'=>1000,'snapshot'=>'{}','moved_at'=>now()]);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('manager');$p=$this->prepareDelivery($movement)->assertOk()->json('id');$this->login('reader');
$b=$this->binding;unset($b['eligible_deliveries']);config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$b]);$this->confirm($p)->assertForbidden();config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p)->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_claims/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic delivery link failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic delivery link failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());$this->assertSame(9000,$this->balance());$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$movement);$this->assertSame(9000,$this->balance());
$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());
}
public function test_delivery_link_can_reconcile_fully_depleted_stock_without_new_issue():void
{
$entry=$this->material('10');$this->login('manager');$movement=$this->previousDelivery(10000);$this->assertSame(0,$this->balance());
$p=$this->prepareDelivery($movement,null,$entry)->assertOk()->assertJsonPath('impact.physical_before','0')->assertJsonPath('impact.physical_after','0')->json('id');$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(0,$this->balance());$this->assertSame(2,DB::table($this->schema.'.stock_movements')->count());
}
public function test_delivery_link_rejects_reversal_and_does_not_adopt_history():void
{
$movement=$this->previousDelivery();
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>2000,'movement_type'=>'reversal_in','snapshot'=>'{}','moved_at'=>now()]);
$this->prepareDelivery($movement)->assertConflict();$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_delivery_link_refuses_missing_original_hash():void
{
$original=$this->previousDelivery();$m=(array)DB::table($this->schema.'.stock_movements')->find($original);$o=(array)DB::table($this->schema.'.stock_operations')->find($m['stock_operation_id']);unset($o['id'],$m['id']);
$o['reference']='QA-UNTRUSTED-'.Str::uuid();$o['idempotency_key']=(string)Str::uuid();$m['stock_operation_id']=DB::table($this->schema.'.stock_operations')->insertGetId($o);$m['snapshot']='{}';$movement=DB::table($this->schema.'.stock_movements')->insertGetId($m);
$this->binding['eligible_deliveries'][$movement]=['order_id'=>$this->order];config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
$this->prepareDelivery($movement)->assertConflict()->assertJsonPath('message','Entrega sem fotografia íntegra do escritor comum; exige conferência histórica própria.');$this->assertSame(6000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_delivery_link_other_company_cannot_prepare_or_confirm():void
{
$movement=$this->previousDelivery();$p=$this->prepareDelivery($movement)->assertOk()->json('id');$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->prepareDelivery($movement)->assertForbidden();$this->confirm($p)->assertForbidden();$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('pagination.total',0)->assertJsonCount(0,'proposals');$this->assertSame(8000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_individual_impact_independence_exact_issue_and_replay():void
{
$r=$this->getJson('/api/workshop-stock');$this->assertSame(200,$r->status(),$r->getContent());$r->assertJsonPath('pagination.total',1);$key=(string)Str::uuid();
$p=$this->prepare(null,$key)->assertOk()->assertJsonPath('impact.physical_before','10000')->assertJsonPath('impact.physical_after','8000')->json('id');
$this->prepare(null,$key)->assertOk()->assertJsonPath('id',$p)->assertJsonPath('replayed',true);$this->assertSame(10000,$this->balance());
$this->confirm($p)->assertForbidden();$this->login('reader');$first=$this->confirm($p)->assertOk()->assertJsonPath('replayed',false)->json('movement_id');
$this->confirm($p)->assertOk()->assertJsonPath('movement_id',$first)->assertJsonPath('replayed',true);$this->assertSame(8000,$this->balance());
$this->login('reader','stock');$this->getJson('/api/stock-read')->assertOk()->assertJsonPath('rows.0.physical_milli','8000');
$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());$this->assertSame(1,DB::table($this->schema.'.stock_operations')->count());
$this->login('manager');$this->prepare()->assertConflict();
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('proposals.0.movement_id',$first);
$this->login('tech');$this->getJson('/api/workshop-stock')->assertForbidden();
}
public function test_client_unverified_fraction_and_unknown_delivery_do_not_debit():void
{
foreach([[$this->material('2','client'),422],[$this->material('2','company','delivered'),422],[$this->material('2','company','consumed',false),409],[$this->material('0.0001'),422],[$this->material('20'),409]] as [$id,$status]){$this->login('manager');$this->prepare($id)->assertStatus($status);}
DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>-1000,'movement_type'=>'issue','snapshot'=>'{}','moved_at'=>now()]);$this->prepare()->assertConflict();$this->assertSame(9000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_stale_book_mapping_permission_scope_and_failure_recovery():void
{
$p=$this->prepare()->assertOk()->json('id');DB::table($this->schema.'.stock_movements')->insert(['tenant_id'=>77,'stock_item_id'=>1,'warehouse_id'=>11,'quantity_milli'=>1000,'movement_type'=>'receipt_in','snapshot'=>'{}','moved_at'=>now()]);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('manager');$p=$this->prepare()->assertOk()->json('id');$this->login('reader');$b=$this->binding;$b['resources']['CABO|UN']['stock_unit']='KG';config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$b]);$this->confirm($p)->assertConflict();config(['workshop_stock.synthetic_sources.'.$this->bindingKey=>$this->binding]);
DB::table('permission_grants')->where(['user_id'=>$this->f['reader']->id,'permission'=>'workshop_stock.confirm'])->delete();$this->confirm($p)->assertForbidden();$this->grant('reader','workshop_stock.confirm');
$fault=true;DB::listen(function($q)use(&$fault){if($fault&&preg_match('/insert into .*ws_claims/i',$q->sql)){$fault=false;throw new \RuntimeException('Synthetic claim failure');}});
$this->withoutExceptionHandling();try{$this->confirm($p);$this->fail('Injected failure absent');}catch(\RuntimeException $e){$this->assertSame('Synthetic claim failure',$e->getMessage());}$this->withExceptionHandling();
$this->assertSame(11000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.stock_operations')->count());$this->confirm($p)->assertOk();$this->assertSame(9000,$this->balance());
$this->withHeader('X-SGF-Context','obsolete')->getJson('/api/workshop-stock')->assertConflict();
}
public function test_corrected_source_requires_review_and_never_consumes_the_same_origin_twice():void
{
$p=$this->prepare()->assertOk()->json('id');$old=DB::table('wo_entries')->find($this->entry);$this->login('tech');
$extra=['origin'=>$old->origin,'previous_id'=>$old->id,'version'=>2,'correction_reason'=>'Synthetic quantity correction','task_id'=>$this->task,'authorization_id'=>$this->authorization,'starts_at'=>CarbonImmutable::parse($old->starts_at)->utc()->toIso8601String(),'resource'=>'CABO','quantity'=>'3','unit'=>'UN','owner'=>'company','event'=>'consumed','evidence'=>'Synthetic corrected material evidence'];
$new=$this->record('material',$extra);$this->login('reader');$this->confirm($p)->assertConflict();
$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$new.'/verification',['version'=>2,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic corrected version verification'])->assertOk();
$this->login('manager');$p=$this->prepare($new,null,2)->assertOk()->json('id');$this->login('reader');$this->confirm($p)->assertOk();$this->assertSame(7000,$this->balance());
$this->login('tech');$extra['previous_id']=$new;$extra['version']=3;$extra['quantity']='4';$latest=$this->record('material',$extra);
$this->login('reviewer');$this->withHeader('Idempotency-Key',(string)Str::uuid())->postJson('/api/workshop/entries/'.$latest.'/verification',['version'=>3,'revision'=>$this->revision(),'decision'=>'verified','reason'=>'Synthetic later corrected verification'])->assertOk();
$this->login('manager');$this->prepare($latest,null,3)->assertConflict();$this->assertSame(7000,$this->balance());
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('sources.0.id',$latest);$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());
}
public function test_other_company_cannot_read_or_confirm_origin_and_changed_command_is_refused():void
{
$key=(string)Str::uuid();$p=$this->prepare(null,$key)->assertOk()->json('id');
$this->withHeader('Idempotency-Key',$key)->postJson('/api/workshop-stock/prepare',['entry_id'=>$this->entry,'version'=>1,'warehouse_id'=>11,'reason'=>'Changed synthetic justification.','evidence'=>'Synthetic physical delivery proof'])->assertConflict();
$this->login('manager','stock');$this->getJson('/api/workshop-stock')->assertConflict();
$tenant=$this->f['tenant'];$company=$this->f['sibling'];$unit=$this->f['siblingUnit'];
foreach(['workshop.view','stock.view','workshop_stock.view','workshop_stock.prepare','workshop_stock.confirm'] as $r)IdentityFixture::grant($this->f['manager']->id,$tenant,$company,$r,$unit);
config(['modules.companies.'.$tenant.':'.$company=>['contracted'=>['workshop','stock'],'enabled'=>['workshop','stock']],'workshop_stock.synthetic_sources.'.$tenant.':'.$company.':'.$unit=>$this->binding]);
$token=$this->postJson('/api/context',['tenant_id'=>$tenant,'company_id'=>$company,'unit_id'=>$unit,'module_id'=>'workshop'])->assertOk()->json('context.token');$this->withHeader('X-SGF-Context',$token);
$this->getJson('/api/workshop-stock')->assertOk()->assertJsonPath('pagination.total',0)->assertJsonCount(0,'proposals');
$this->prepare()->assertForbidden();$this->confirm($p)->assertForbidden();
$this->assertSame(10000,$this->balance());$this->assertSame(0,DB::table($this->schema.'.ws_claims')->count());
}
public function test_concurrent_confirmation_has_one_claim_and_movement():void
{
$this->concurrentConfirmation(false);
}
public function test_delivery_link_concurrent_confirmation_has_one_claim_without_movement():void
{
$this->concurrentConfirmation(true);
}
private function concurrentConfirmation(bool $delivery,?int $givenProposal=null,?int $workerProposal=null,int $workerStatus=200,bool $received=false):void
{
$p=$givenProposal??($delivery?$this->prepareDelivery($this->previousDelivery()):$this->prepare())->assertOk()->json('id');$this->login('reader');$scope=['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']];
$dir=base_path('../private/workshop-stock-'.bin2hex(random_bytes(6)));mkdir($dir,0700,true);$input=$dir.'/worker.json';$application='workshop_stock_'.bin2hex(random_bytes(6));$process=null;
file_put_contents($input,json_encode(['actor'=>$this->f['reader']->id,'scope'=>$scope,'binding'=>$this->binding,'proposal'=>$workerProposal??$p,'application'=>$application,'received_in_warehouse'=>$received],JSON_THROW_ON_ERROR));DB::beginTransaction();
try{$winner=$this->confirm($p,$received)->assertOk()->json('movement_id');$pipes=[];$process=proc_open([PHP_BINARY,base_path('tests/Support/workshop-stock-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&&microtime(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();}
if($givenProposal===null){$this->assertSame(8000,$this->balance());$this->assertSame(1,DB::table($this->schema.'.ws_claims')->count());}
}
}