Files
SGF/tests/Feature/IndustrialCostsTest.php
T
2026-11-02 08:06:45 +02:00

77 lines
14 KiB
PHP

<?php
namespace Tests\Feature;
use App\Modules\Industry\IndustrialCosts;
use App\Shared\Context\CompanyContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\Support\IndustrialCostFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class IndustrialCostsTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=IndustrialCostFixture::create();$this->login();}
private function login(string $who='manager'): void {$this->postJson('/api/login',['email'=>$this->f[$who]->email,'password'=>$this->f['password']])->assertOk();$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['unit']])->assertOk()->json('context.token');}
private function send(string $action,array $in,?string $key=null,?int $lot=null){return $this->withHeaders(['X-SGF-Context'=>$this->token,'Idempotency-Key'=>$key??(string)Str::uuid()])->postJson('/api/industrial-costs/lots/'.($lot??$this->f['lot']).'/'.$action,$in);}
private function calc(array $extra=[],?string $key=null){return $this->send('calculations',array_replace(['currency'=>'MZN','version'=>1,'previous_id'=>null,'refs'=>$this->f['refs']],$extra),$key);}
private function snapshot(int $id): array {return json_decode(DB::table('ic_results')->find($id)->snapshot,true,64,JSON_THROW_ON_ERROR);}
private function decide(string $type,int $id,array $extra=[],?string $key=null){$r=DB::table($type==='input'?'ic_inputs':'ic_results')->find($id);return $this->send('decisions',array_replace(['type'=>$type,'id'=>$id,'version'=>$r->version,'decision'=>'approved','reason'=>'Revisão sintética da versão industrial.'],$extra),$key);}
private function change(string $kind,array $data,array $extra=[]){$old=DB::table('ic_inputs')->find($this->f['refs'][$kind]['id']);return $this->send('inputs',array_replace(['kind'=>$kind,'source_ref'=>$old->source_ref,'version'=>$old->version+1,'previous_id'=>$old->id,'data'=>$data,'evidence'=>'Correcção sintética explícita, histórico preservado.'],$extra));}
private function listing(){return $this->getJson('/api/industrial-costs')->assertOk();}
public function test_full_and_partial_scenarios_with_exact_reconciliations_and_separate_approval(): void {
$before=DB::table('wo_entries')->count();$id=$this->calc()->assertOk()->json('id');$s=$this->snapshot($id);
foreach(['material_exact'=>'1000','labour_exact'=>'500','labour_hours_exact'=>'5','indirect_rate_exact'=>'60','allocated_exact'=>'300','residual_exact'=>'300','known_cost_exact'=>'1800','unit_cost_exact'=>'180','good_units'=>'10'] as $k=>$v)$this->assertSame($v,$s[$k]);$this->assertSame('2100',$s['reconciliation']['known_origins_exact']);$this->assertTrue($s['reconciliation']['balanced']);$this->assertFalse($s['incomplete']);
IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],'industrial_costs.validate',$this->f['unit']);$this->decide('result',$id)->assertForbidden();$this->login('reader');$this->decide('result',$id)->assertOk();$this->login();
$refs=$this->f['refs'];$refs['material_rate']=null;$partial=$this->calc(['version'=>2,'previous_id'=>$id,'refs'=>$refs])->assertOk()->json('id');$p=$this->snapshot($partial);$this->assertSame('800',$p['known_cost_exact']);$this->assertSame('1100',$p['reconciliation']['known_origins_exact']);$this->assertSame('partial_known_only',$p['reconciliation']['coverage']);$this->assertNull($p['selected_total_exact']);$this->assertNull($p['unit_cost_exact']);$this->assertTrue($p['incomplete']);$this->assertCount(1,$p['pending']);$this->assertSame($before,DB::table('wo_entries')->count());$this->listing()->assertJsonPath('lots.0.results.0.current',false);
}
public function test_zero_rate_and_nonfinite_representation_remain_exact(): void {
$r=DB::table('ic_inputs')->find($this->f['refs']['material_rate']['id']);$d=json_decode($r->data,true);$d['amount']='0';$zero=$this->change('material_rate',$d)->assertOk()->json('id');$this->login('reader');$this->decide('input',$zero)->assertOk();$this->login();$refs=$this->f['refs'];$refs['material_rate']=['id'=>$zero,'version'=>2];$first=$this->calc(['refs'=>$refs])->assertOk()->json('id');$s=$this->snapshot($first);$this->assertSame('0',$s['material_exact']);$this->assertSame('800',$s['selected_total_exact']);$this->assertSame('80',$s['unit_cost_exact']);$this->assertFalse($s['incomplete']);
$production=DB::table('ic_inputs')->find($refs['production']['id']);$pd=json_decode($production->data,true);$pd['quantity']='3';$new=$this->change('production',$pd)->assertOk()->json('id');$this->login('reader');$this->decide('input',$new)->assertOk();$this->login();$refs['production']=['id'=>$new,'version'=>2];$second=$this->calc(['refs'=>$refs,'version'=>2,'previous_id'=>$first])->assertOk()->json('id');$this->assertSame('800/3',$this->snapshot($second)['unit_cost_exact']);
}
public function test_capacity_zero_overuse_unknown_sources_units_and_numeric_coercion_refused(): void {
$criterion=$this->f['criterionData'];foreach(['0','-1','1e2','1.000000001',''] as $capacity)$this->change('criterion',array_replace($criterion,['capacity'=>$capacity]))->assertStatus(422);
$this->change('criterion',array_replace($criterion,['capacity'=>10]))->assertStatus(422);$this->change('criterion',array_replace($criterion,['driver_unit'=>'H']))->assertStatus(422);
$small=$this->change('criterion',array_replace($criterion,['capacity'=>'4']))->assertOk()->json('id');$this->login('reader');$this->decide('input',$small)->assertOk();$this->login();$refs=$this->f['refs'];$refs['criterion']=['id'=>$small,'version'=>2];$this->calc(['refs'=>$refs])->assertStatus(409);
$pd=['quantity'=>'0','unit'=>'UN','event_date'=>'2026-10-27','completed'=>true];$this->change('production',$pd)->assertStatus(422);$this->change('production',array_replace($pd,['quantity'=>'10','completed'=>false]))->assertStatus(422);
$refs=$this->f['refs'];$refs['material']=['id'=>$refs['pool']['id'],'version'=>1];$this->calc(['refs'=>$refs])->assertStatus(409);$this->calc(['currency'=>'USD'])->assertStatus(422);$refs=$this->f['refs'];$refs['labour']=null;$this->calc(['refs'=>$refs])->assertStatus(422);
}
public function test_criterion_source_and_rate_changes_invalidate_results_without_editing_history(): void {
$id=$this->calc()->assertOk()->json('id');$snapshot=DB::table('ic_results')->find($id)->snapshot;$this->login('reader');$this->decide('result',$id)->assertOk();$this->login();
$c=$this->change('criterion',array_replace($this->f['criterionData'],['capacity'=>'20']))->assertOk()->json('id');$this->listing()->assertJsonPath('lots.0.results.0.current',false);$this->calc(['version'=>2,'previous_id'=>$id])->assertStatus(409);$this->login('reader');$this->decide('input',$this->f['refs']['criterion']['id'])->assertStatus(409);$this->decide('input',$c)->assertOk();$this->login();
$refs=$this->f['refs'];$refs['criterion']=['id'=>$c,'version'=>2];$new=$this->calc(['refs'=>$refs,'version'=>2,'previous_id'=>$id])->assertOk()->json('id');$this->assertSame('150',$this->snapshot($new)['allocated_exact']);$this->assertSame('450',$this->snapshot($new)['residual_exact']);$this->assertSame($snapshot,DB::table('ic_results')->find($id)->snapshot);
$rate=DB::table('ic_inputs')->find($refs['labour_rate']['id']);$d=json_decode($rate->data,true);$d['amount']='101';$this->change('labour_rate',$d)->assertOk();$this->listing()->assertJsonPath('lots.0.results.1.current',false);
}
public function test_idempotency_recovery_after_outbox_failure_and_response_loss(): void {
$key=(string)Str::uuid();DB::unprepared("CREATE FUNCTION ic_fail_test() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RAISE EXCEPTION 'IC_TEST_OUTBOX_FAILURE'; END; \$\$; CREATE TRIGGER ic_fail_test BEFORE INSERT ON ic_outbox FOR EACH ROW EXECUTE FUNCTION ic_fail_test()");$before=DB::table('ic_commands')->count();$this->assertGreaterThanOrEqual(500,$this->calc([],$key)->status());$this->assertSame(0,DB::table('ic_results')->count());$this->assertSame($before,DB::table('ic_commands')->count());DB::unprepared('DROP TRIGGER ic_fail_test ON ic_outbox; DROP FUNCTION ic_fail_test()');
$id=$this->calc([],$key)->assertOk()->json('id');$this->calc([],$key)->assertOk()->assertJsonPath('id',$id)->assertJsonPath('replayed',true);$refs=$this->f['refs'];$refs['material_rate']=null;$this->calc(['refs'=>$refs],$key)->assertStatus(409);$this->assertSame(1,DB::table('ic_results')->count());$this->assertSame(DB::table('ic_commands')->count(),DB::table('ic_events')->count());$this->assertSame(DB::table('ic_events')->count(),DB::table('ic_outbox')->count());
}
public function test_permissions_scope_revocation_and_direct_cost_grants_do_not_imply_industrial(): void {
$id=$this->calc()->assertOk()->json('id');$this->login('reader');$this->calc()->assertForbidden();$this->login('limited');IdentityFixture::grant($this->f['limited']->id,$this->f['tenant'],$this->f['company'],'direct_costs.view',$this->f['unit']);$this->getJson('/api/industrial-costs')->assertForbidden();$this->login();
$this->send('calculations',['currency'=>'MZN','version'=>1,'refs'=>$this->f['refs']],null,$this->f['lot']+999)->assertForbidden();DB::table('permission_grants')->where(['user_id'=>$this->f['manager']->id,'permission'=>'industrial_costs.prepare'])->delete();$this->calc()->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertForbidden();
$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit']])->assertOk();$this->getJson('/api/industrial-costs')->assertForbidden();
}
public function test_canonical_origin_cannot_move_between_lots_or_direct_and_indirect_roles(): void {
$id=$this->f['refs']['material']['id'];$source=DB::table('ic_inputs')->find($id);$pool=DB::table('ic_inputs')->find($this->f['refs']['pool']['id']);$this->change('pool',json_decode($pool->data,true),['source_ref'=>$source->source_ref])->assertStatus(409);
$c=app(IndustrialCosts::class);app(CompanyContext::class)->selectFor($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$this->f['unit']);$lot=$c->open($this->f['manager']->id,['reference'=>'LOT-OTHER','product_reference'=>'PROD-OTHER','product_name'=>'Produto sintético 2','centre'=>'Centro teste','period_start'=>'2026-10-01','period_end'=>'2026-10-31','currency'=>'MZN'],(string)Str::uuid())['id'];
$this->send('inputs',['kind'=>'material','source_ref'=>$source->source_ref,'version'=>1,'data'=>json_decode($source->data,true),'evidence'=>'Duplicação proibida.'],null,$lot)->assertStatus(409);
$this->assertSame(0,DB::table('wo_entries')->count());
}
public function test_refusal_old_decisions_bulk_sql_and_environment_blocks(): void {
$new=$this->change('criterion',$this->f['criterionData'])->assertOk()->json('id');$this->login('reader');$this->decide('input',$new,['decision'=>'refused'])->assertOk();$this->login();$refs=$this->f['refs'];$refs['criterion']=['id'=>$new,'version'=>2];$this->calc(['refs'=>$refs])->assertStatus(409);
foreach(['UPDATE ic_inputs SET unit_id=999','DELETE FROM ic_inputs'] as $sql){try{DB::transaction(fn()=>DB::statement($sql));$this->fail('Immutable SQL accepted');}catch(\Illuminate\Database\QueryException){$this->assertTrue(true);}}
$this->app['env']='local';$this->getJson('/api/industrial-costs')->assertOk()->assertJsonPath('blocked',true);try{IndustrialCosts::guard();$this->fail('Real costing allowed');}catch(\Symfony\Component\HttpKernel\Exception\HttpException $e){$this->assertSame(403,$e->getStatusCode());}$this->app['env']='testing';$old=config('database.connections.pgsql.database');config(['database.connections.pgsql.database'=>'sgf_tempo_dev']);try{IndustrialCosts::guard();$this->fail('Wrong database accepted');}catch(\RuntimeException $e){$this->assertStringContainsString('SGF_TEST_DATABASE_REFUSED',$e->getMessage());}finally{config(['database.connections.pgsql.database'=>$old]);}
}
public function test_corrected_material_requires_new_rate_and_cannot_be_read_from_another_unit(): void {
$first=$this->calc()->assertOk()->json('id');$oldSnapshot=DB::table('ic_results')->find($first)->snapshot;
$material=DB::table('ic_inputs')->find($this->f['refs']['material']['id']);$data=json_decode($material->data,true);$data['quantity']='21';$new=$this->change('material',$data)->assertOk()->json('id');$this->listing()->assertJsonPath('lots.0.results.0.current',false);
$this->login('reader');$this->decide('input',$new)->assertOk();$this->decide('result',$first)->assertStatus(409);$this->login();$refs=$this->f['refs'];$refs['material']=['id'=>$new,'version'=>2];$this->calc(['version'=>2,'previous_id'=>$first,'refs'=>$refs])->assertStatus(409);
$rate=DB::table('ic_inputs')->find($refs['material_rate']['id']);$rd=json_decode($rate->data,true);$rd['source_id']=$new;$rd['source_version']=2;$newRate=$this->change('material_rate',$rd)->assertOk()->json('id');$this->login('reader');$this->decide('input',$newRate)->assertOk();$this->login();$refs['material_rate']=['id'=>$newRate,'version'=>2];$second=$this->calc(['version'=>2,'previous_id'=>$first,'refs'=>$refs])->assertOk()->json('id');$this->assertSame('1850',$this->snapshot($second)['selected_total_exact']);$this->assertSame($oldSnapshot,DB::table('ic_results')->find($first)->snapshot);
foreach(['unit.view','industrial_costs.view','industrial_costs.prepare'] as $right)IdentityFixture::grant($this->f['manager']->id,$this->f['tenant'],$this->f['company'],$right,$this->f['hidden']);
$this->token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>$this->f['hidden']])->assertOk()->json('context.token');$this->listing()->assertJsonCount(0,'lots');$this->calc()->assertForbidden();
}
}