This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
@@ -10,6 +10,8 @@ use Tests\TestCase;
final class CatalogueAccountingTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase;
private array $f;
private string $token;
+2
View File
@@ -10,6 +10,8 @@ use Tests\Support\DirectCostFixture;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
final class DirectCostsTest 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=DirectCostFixture::create();$this->login();}
@@ -12,6 +12,8 @@ use Tests\TestCase;
final class DocumentPreparationTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $fixture;
private string $token;
+2
View File
@@ -13,6 +13,8 @@ use Tests\TestCase;
final class DocumentPreviewTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $fixture;
private string $token;
+2
View File
@@ -8,6 +8,8 @@ use Illuminate\Support\Str;
use Tests\Support\{IndustrialBudgetFixture,IdentityFixture};
use Tests\TestCase;
final class IndustrialBudgetsTest 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=IndustrialBudgetFixture::create();$this->login();}
+2
View File
@@ -9,6 +9,8 @@ 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();}
@@ -12,6 +12,8 @@ use Tests\TestCase;
final class InternalConfirmationTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $fixture;
private string $token;
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Support\IdentityFixture;
use Tests\TestCase;
// Transactions only: never migrate/fresh or reset the persistent demonstration.
#[\PHPUnit\Framework\Attributes\Group('modular-focused')]
final class ModuleNavigationTest extends TestCase
{
use DatabaseTransactions;
private array $f;
protected function setUp(): void
{
parent::setUp();
$this->f = IdentityFixture::create('-module-'.bin2hex(random_bytes(6)));
foreach (['clients.view','services.view'] as $p) IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],$p);
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($this->f['reader'])->withSession(['auth_version'=>$this->f['reader']->auth_version]);
}
private function select(?string $module, ?int $company=null)
{
return $this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$company??$this->f['company'],'unit_id'=>$this->f['unit'],'module_id'=>$module]);
}
public function test_explicit_selection_direct_access_and_old_generation(): void
{
$this->select(null)->assertOk();
$this->getJson('/api/a/clients')->assertStatus(409);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk();
$this->getJson('/api/workshop')->assertStatus(409);
$next=$this->select('workshop')->assertOk()->json('context.token');
$this->assertNotSame($token,$next);
$this->withHeader('X-SGF-Context',$token)->getJson('/api/workshop')->assertStatus(409);
$this->withHeader('X-SGF-Context',$next)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
$this->postJson('/api/a/clients',['name'=>'Must not be inserted'])->assertForbidden();
$this->assertFalse(DB::table('a_clients')->where('name','Must not be inserted')->exists());
}
public function test_company_configuration_and_revocation_are_checked_again(): void
{
$token=$this->select('commercial')->assertOk()->json('context.token');
DB::table('permission_grants')->where('user_id',$this->f['reader']->id)->whereIn('permission',['clients.view','services.view'])->delete();
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertForbidden();
$this->getJson('/api/session')->assertOk()->assertJsonPath('context.module_id',null);
$this->select('commercial')->assertForbidden();
$this->select('workshop',$this->f['sibling'])->assertForbidden();
config(['modules.companies.'.$this->f['tenant'].':'.$this->f['company']=>['contracted'=>['workshop'],'enabled'=>[]]]);
$this->select('workshop')->assertForbidden();
}
public function test_development_discovery_never_grants_operations_or_applies_in_production(): void
{
config(['modules.contracted'=>[], 'modules.development_profiles'=>[$this->f['reader']->id.':'.$this->f['tenant'].':'.$this->f['company']]]);
$selected=$this->select('purchasing')->assertOk();
$this->assertCount(6,$selected->json('context.modules'));
$this->withHeader('X-SGF-Context',$selected->json('context.token'))->getJson('/api/supplier-payables')->assertForbidden();
app()->detectEnvironment(fn()=>'production');
$this->getJson('/api/session')->assertOk()->assertJsonPath('context.modules',[])->assertJsonPath('context.module_id',null);
}
public function test_failed_selection_preserves_context_and_audit_contains_module_without_token(): void
{
$selected=$this->select('commercial')->assertOk();
$this->select('invented')->assertForbidden();
$session=$this->getJson('/api/session')->assertOk();
$session->assertJsonPath('context.module_id','commercial')->assertJsonPath('context.token',$selected->json('context.token'));
$audit=DB::table('identity_audit')->where('actor_id',$this->f['reader']->id)->where('action','context.selected')->orderByDesc('id')->first();
$this->assertSame('commercial',json_decode($audit->details,true)['module_id']);
$this->assertStringNotContainsString($selected->json('context.token'),$audit->details);
}
public function test_local_environment_and_copied_profile_cannot_enable_discovery_for_client_database_configuration(): void
{
$this->select(null)->assertOk();
config(['modules.contracted'=>[], 'modules.development_profiles'=>[$this->f['reader']->id.':'.$this->f['tenant'].':'.$this->f['company']]]);
$connection=config('database.default');$original=config("database.connections.$connection.database");
config(["database.connections.$connection.database"=>'sgf_tempo_dev']);
app()->detectEnvironment(fn()=>'local');
try { $this->getJson('/api/session')->assertOk()->assertJsonPath('context.modules',[]); }
finally { config(["database.connections.$connection.database"=>$original]);app()->detectEnvironment(fn()=>'testing'); }
}
public function test_shared_records_require_explicit_association_and_company_scope(): void
{
$actor=$this->f['manager'];
foreach(['clients.view','clients.create','clients.edit'] as $p)IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],$p);
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeaders(['X-SGF-Context'=>$token,'Idempotency-Key'=>'module-client-proof-001']);
$id=$this->postJson('/api/a/clients',['name'=>'Module proof client'])->assertCreated()->json('id');
$this->getJson('/api/a/clients')->assertOk()->assertJsonPath('records.0.id',$id);
$token=$this->select('workshop')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Explicit synthetic association'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk()->assertJsonPath('name','Module proof client');
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->assertSame(2,DB::table('identity_audit')->where('action','module.record.associated')->where('company_id',$this->f['company'])->count());
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Correct association without rewriting original','operation'=>'remove'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.modules',['commercial']);
$this->assertSame(1,DB::table('a_clients')->where('id',$id)->value('version'));
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'workshop','reason'=>'Re-association explicitly reviewed'])->assertOk();
$this->getJson('/api/a/clients/'.$id)->assertOk();
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['sibling'],'clients.view');
$token=$this->postJson('/api/context',['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['sibling'],'unit_id'=>$this->f['siblingUnit'],'module_id'=>'commercial'])->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[]);
}
public function test_unassociated_legacy_records_remain_in_explicit_administrative_inventory(): void
{
$id=DB::table('a_clients')->insertGetId(['tenant_id'=>$this->f['tenant'],'company_id'=>$this->f['company'],'unit_id'=>null,'name'=>'Existing unassociated record','version'=>1,'data'=>'{}','created_at'=>now(),'updated_at'=>now()]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->getJson('/api/a/clients')->assertOk()->assertJsonPath('records',[])->assertJsonStructure(['scope_notice']);
$this->getJson('/api/a/clients/'.$id)->assertForbidden();
$this->getJson('/api/module-records')->assertForbidden();
IdentityFixture::grant($this->f['reader']->id,$this->f['tenant'],$this->f['company'],'access.manage');
$this->getJson('/api/module-records')->assertOk()->assertJsonPath('records.clients.0.id',$id)->assertJsonPath('records.clients.0.modules',[]);
$this->postJson('/api/module-records',['area'=>'clients','id'=>$id,'version'=>1,'module_id'=>'commercial','reason'=>'Cannot assign without edit permission'])->assertForbidden();
$this->assertSame('Existing unassociated record',DB::table('a_clients')->where('id',$id)->value('name'));
}
public function test_draft_creation_preserves_source_history_and_does_not_share_dependencies_implicitly(): void
{
$actor=$this->f['manager'];
foreach(['clients','services','service_drafts','billing_drafts'] as $area){
foreach(['view','create','edit'] as $action)IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],"$area.$action",str_ends_with($area,'_drafts')?$this->f['unit']:null);
}
IdentityFixture::grant($actor->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);
$this->actingAs($actor)->withSession(['auth_version'=>$actor->auth_version]);
$token=$this->select('commercial')->assertOk()->json('context.token');
$write=function($area,$body)use($token){return $this->withHeaders(['X-SGF-Context'=>$token,'Idempotency-Key'=>(string)\Illuminate\Support\Str::uuid()])->postJson('/api/a/'.$area,$body)->assertCreated()->json('id');};
$client=$write('clients',['name'=>'Synthetic modular client']);
$service=$write('services',['name'=>'Synthetic modular service']);
$draft=$write('service_drafts',['client_id'=>$client,'currency'=>'MZN','lines'=>[['service_id'=>$service,'quantity'=>'2','unit_price'=>'75']]]);
$bill=$write('billing_drafts',['source_id'=>$draft]);
$original=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($draft,$original['source_id']);
$token=$this->select('workshop')->assertOk()->json('context.token');
$this->withHeader('X-SGF-Context',$token)->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>99,'module_id'=>'workshop','reason'=>'Reject stale review'])->assertStatus(409);
$this->postJson('/api/module-records',['area'=>'billing_drafts','id'=>$bill,'version'=>1,'module_id'=>'workshop','reason'=>'Explicitly share this preserved draft'])->assertOk();
$record=$this->getJson('/api/a/billing_drafts/'.$bill)->assertOk()->json();
$this->assertSame($original['data'],$record['data']);
$this->assertSame($original['history'],$record['history']);
$this->assertSame($draft,$record['source_id']);
$this->getJson('/api/a/service_drafts/'.$draft)->assertForbidden();
$this->getJson('/api/a/clients/'.$client)->assertForbidden();
$this->getJson('/api/a/services/'.$service)->assertForbidden();
$this->assertSame(1,DB::table('a_billing_drafts')->where('id',$bill)->value('version'));
}
}
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceCalculationTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $f;
private int $draft;
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceFiscalTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'accounting'; }
use RefreshDatabase;
private array $f; private string $token; private int $draft; private int $service;
protected function setUp(): void
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceInvoiceTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $f; private string $token; private string $key;
protected function setUp(): void
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServiceReceivablesTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'finance'; }
use RefreshDatabase;
private array $f; private string $token; private int $ob;
protected function setUp(): void { parent::setUp(); $this->f=ReceivablesFixture::create(); $this->login(); }
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class ServicesDeliveryATest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'commercial'; }
use RefreshDatabase;
private array $fixture;
private string $token;
+2
View File
@@ -9,6 +9,8 @@ use Illuminate\Support\Str;
use Tests\Support\{DirectCostFixture,IdentityFixture};
use Tests\TestCase;
final class WorkshopBillingTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'workshop'; }
use RefreshDatabase;
private array $f;private int $service;private string $token;
protected function setUp(): void {parent::setUp();$this->f=DirectCostFixture::create();foreach(['manager'=>['view','prepare','generate'],'reader'=>['view','review']] as $who=>$rights){foreach($rights as $r)IdentityFixture::grant($this->f[$who]->id,$this->f['tenant'],$this->f['company'],'workshop_billing.'.$r,$this->f['unit']);IdentityFixture::grant($this->f[$who]->id,$this->f['tenant'],$this->f['company'],'workshop.view',$this->f['unit']);}$this->service=app(ServiceWorkflow::class)->save('services',null,$this->f['manager']->id,['name'=>'Serviço comercial sintético'],(string)Str::uuid())['id'];$this->login();}
@@ -9,6 +9,8 @@ use Illuminate\Support\Str;
use Tests\Support\{WorkshopCorrectionFixture,IdentityFixture};
use Tests\TestCase;
final class WorkshopCorrectionsTest extends TestCase {
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'workshop'; }
use RefreshDatabase;
private array $f;private string $token;
protected function setUp(): void {parent::setUp();$this->f=WorkshopCorrectionFixture::create();$this->login();}
+2
View File
@@ -11,6 +11,8 @@ use Tests\TestCase;
final class WorkshopTest extends TestCase
{
use \Tests\Support\UsesModuleFixture;
protected function moduleFixture(): string { return 'workshop'; }
use RefreshDatabase;
private array $f; private string $token; private int $order; private int $task; private int $auth;
protected function setUp(): void { parent::setUp(); $this->f=WorkshopFixture::create(); $this->login(); }
+4 -4
View File
@@ -8,7 +8,7 @@ use Illuminate\Support\Facades\DB;
final class IdentityFixture
{
public static function create(): array
public static function create(string $emailSuffix = ''): array
{
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$now = now();
@@ -21,9 +21,9 @@ final class IdentityFixture
$hidden = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Unidade Reservada', 'created_at' => $now, 'updated_at' => $now]);
$siblingUnit = DB::table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $sibling, 'name' => 'Unidade Maré', 'created_at' => $now, 'updated_at' => $now]);
$password = bin2hex(random_bytes(20));
$manager = User::create(['name' => 'Gestor de Demonstração', 'email' => 'gestor@example.test', 'password' => $password]);
$reader = User::create(['name' => 'Consulta de Demonstração', 'email' => 'consulta@example.test', 'password' => $password]);
$limited = User::create(['name' => 'Gestor Limitado', 'email' => 'limitado@example.test', 'password' => $password]);
$manager = User::create(['name' => 'Gestor de Demonstração', 'email' => 'gestor'.$emailSuffix.'@example.test', 'password' => $password]);
$reader = User::create(['name' => 'Consulta de Demonstração', 'email' => 'consulta'.$emailSuffix.'@example.test', 'password' => $password]);
$limited = User::create(['name' => 'Gestor Limitado', 'email' => 'limitado'.$emailSuffix.'@example.test', 'password' => $password]);
foreach ([$manager, $reader, $limited] as $user) {
DB::table('company_memberships')->insert(['user_id' => $user->id, 'tenant_id' => $tenant, 'company_id' => $company]);
self::grant($user->id, $tenant, $company, 'company.view');
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace Tests\Support;
use App\Shared\Context\ModuleRecords;
use Illuminate\Support\Facades\{Auth,DB};
/** Explicit suite fixture metadata, never a production compatibility bypass.
* These suites recreate their database; only the disposable regression runner
* may seed their synthetic associations and discovery profiles.
*/
trait UsesModuleFixture
{
protected function moduleFixture(): string { return 'commercial'; }
public function postJson($uri, array $data = [], array $headers = [], $options = 0)
{
if ($uri === '/api/context' && !array_key_exists('module_id',$data)) {
if (!getenv('SGF_ISOLATED_REGRESSION_RUN')) throw new \RuntimeException('Use the disposable modular regression runner.');
$actor=Auth::user();
$permitted=$actor && isset($data['tenant_id'],$data['company_id'])
&& DB::table('company_memberships')->where('user_id',$actor->id)->where('tenant_id',$data['tenant_id'])->where('company_id',$data['company_id'])->exists()
&& app(\App\Modules\Identity\Services\Permissions::class)->has($actor->id,(int)$data['tenant_id'],(int)$data['company_id'],'company.view');
if($permitted) {
$module=$this->moduleFixture();
$profile=$actor->id.':'.$data['tenant_id'].':'.$data['company_id'];
config(['modules.development_profiles'=>array_values(array_unique([...config('modules.development_profiles',[]),$profile]))]);
// The suite's freshly created fixture has this explicitly declared purpose.
// This does not run on existing/demo data, or in application middleware.
foreach(ModuleRecords::AREAS as $area) {
$scope=['tenant_id'=>$data['tenant_id'],'company_id'=>$data['company_id'],'unit_id'=>str_ends_with($area,'_drafts')?($data['unit_id']??null):null];
foreach(DB::table('a_'.$area)->where($scope)->get(['id','version']) as $row) {
$exists=DB::table('identity_audit')->where('action','module.record.associated')->where('tenant_id',$scope['tenant_id'])->where('company_id',$scope['company_id'])
->whereRaw("details::jsonb->>'area' = ?",[$area])->whereRaw("details::jsonb->>'record_id' = ?",[(string)$row->id])->whereRaw("details::jsonb->>'module_id' = ?",[$module])->exists();
if(!$exists)ModuleRecords::associate($area,$row->id,$actor->id,$scope,$module,'Explicit newly-created regression fixture: '.static::class,false,(int)$row->version);
}
}
$data['module_id']=$module;
}
}
return parent::postJson($uri,$data,$headers,$options);
}
public function getJson($uri, array $headers = [], $options = 0)
{
// Historical read assertions previously had no generation header. Writes
// retain their explicitly supplied tokens, including intentional stale ones.
$token=app('session.store')->get('company_context.token');
if($token && !array_key_exists('X-SGF-Context',$headers))$headers['X-SGF-Context']=$token;
return parent::getJson($uri,$headers,$options);
}
}
+15
View File
@@ -8,7 +8,22 @@ abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
if (in_array(\Illuminate\Foundation\Testing\RefreshDatabase::class, class_uses_recursive(static::class), true)
&& !preg_match('/^[a-f0-9]{12}$/D', (string)getenv('SGF_ISOLATED_REGRESSION_RUN'))) {
throw new \RuntimeException('SGF_DESTRUCTIVE_SUITE_REFUSED: use scripts/modular-regression-run.mjs; preserve the persistent demonstration.');
}
parent::setUp();
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
}
protected function setUpTraits()
{
// Called after application boot, before RefreshDatabase can issue DDL.
if ($run=getenv('SGF_ISOLATED_REGRESSION_RUN')) {
\App\Shared\Testing\TestingDatabaseGuard::assertSafe();
$cluster=\Illuminate\Support\Facades\DB::selectOne("SELECT current_setting('cluster_name') AS name")->name;
if ($cluster!=='sgf_modular_regression_'.$run) throw new \RuntimeException('SGF_DESTRUCTIVE_SUITE_REFUSED: unexpected cluster identity.');
}
return parent::setUpTraits();
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use Tempo\Distribution\Release;
use Tempo\Purchasing\Supply\ReplenishmentProjection;
require_once __DIR__.'/../../packages/purchasing/bootstrap.php';
final class PurchasingPackageTest extends TestCase
{
private function policy(): array
{
return ['daily_milli' => 10000, 'safety_milli' => 20000, 'target_milli' => 100000, 'maximum_milli' => 120000,
'lead_days' => 4, 'approval_days' => 1, 'minimum_milli' => 24000, 'pack_milli' => 12000, 'horizon_days' => 30];
}
public function test_sgf_executes_the_transferred_purchasing_engine_without_a_source_checkout(): void
{
$release = Release::current(dirname(__DIR__, 2));
self::assertNotNull($release);
self::assertContains('inventory.replenishment_projection', $release['capabilities']);
$class = new \ReflectionClass(ReplenishmentProjection::class);
self::assertStringStartsWith(realpath(dirname(__DIR__, 2).'/packages/purchasing/releases'), $class->getFileName());
$result = (new ReplenishmentProjection)->calculate($this->policy(), 120000, '2026-11-01');
self::assertSame('2026-11-10', $result['risk_date']);
self::assertSame('2026-11-06', $result['order_date']);
self::assertSame('2026-11-05', $result['request_date']);
self::assertSame(84000, $result['quantity_milli']);
}
public function test_sgf_preserves_exact_historical_rate_and_lot_expiry_results(): void
{
$engine = new ReplenishmentProjection;
$policy = [...$this->policy(), 'horizon_days' => 3, 'daily_rate' => ['numerator_milli' => 1000, 'denominator_days' => 3]];
$rate = $engine->calculate($policy, 10000, '2026-11-01');
self::assertSame([334, 333, 333], array_column($rate['series'], 'demand_milli'));
self::assertSame(9000, $rate['series'][2]['balance_milli']);
$lots = $engine->calculate([...$this->policy(), 'horizon_days' => 2], 10000, '2026-11-01', [], [], 0, [], [['quantity' => 10000, 'last_usable_on' => '2026-11-01']]);
self::assertSame(0, $lots['series'][1]['expired_milli']);
self::assertSame(-10000, $lots['series'][1]['balance_milli']);
}
}