Alexander Musikhin 2 週間 前
コミット
d5819e9ec3

+ 2 - 0
app/Models/Schedule.php

@@ -4,11 +4,13 @@ namespace App\Models;
 
 use App\Models\Dictionary\Area;
 use App\Models\Dictionary\District;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 class Schedule extends Model
 {
+    use HasFactory;
     protected $fillable = [
         'installation_date',
         'address_code',

+ 38 - 0
database/factories/ScheduleFactory.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Schedule;
+use App\Models\User;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends Factory<Schedule>
+ */
+class ScheduleFactory extends Factory
+{
+    protected $model = Schedule::class;
+
+    public function definition(): array
+    {
+        return [
+            'installation_date' => fake()->dateTimeBetween('now', '+3 months')->format('Y-m-d'),
+            'address_code'      => fake()->bothify('ADDR-####'),
+            'manual'            => false,
+            'source'            => 'Площадки',
+            'order_id'          => null,
+            'district_id'       => District::query()->inRandomOrder()->value('id'),
+            'area_id'           => Area::query()->inRandomOrder()->value('id'),
+            'object_address'    => fake()->address(),
+            'object_type'       => 'Площадка',
+            'mafs'              => 'МАФ-001 - 1',
+            'mafs_count'        => 1,
+            'brigadier_id'      => null,
+            'comment'           => '',
+            'transport'         => null,
+            'admin_comment'     => null,
+        ];
+    }
+}

+ 248 - 0
tests/Feature/ContractControllerTest.php

@@ -0,0 +1,248 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Contract;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class ContractControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    // ==================== Authentication ====================
+
+    public function test_guest_cannot_access_contracts_index(): void
+    {
+        $response = $this->get(route('contract.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_access_contract_create(): void
+    {
+        $response = $this->get(route('contract.create'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_store_contract(): void
+    {
+        $response = $this->post(route('contract.store'), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_update_contract(): void
+    {
+        $contract = Contract::factory()->create();
+
+        $response = $this->post(route('contract.update', $contract), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_delete_contract(): void
+    {
+        $contract = Contract::factory()->create();
+
+        $response = $this->delete(route('contract.delete', $contract));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Index ====================
+
+    public function test_admin_can_access_contracts_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('contract.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('contracts.index');
+    }
+
+    public function test_manager_can_access_contracts_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('contract.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('contracts.index');
+    }
+
+    // ==================== Create/Show ====================
+
+    public function test_admin_can_access_contract_create_form(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('contract.create'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('contracts.edit');
+    }
+
+    public function test_admin_can_view_contract(): void
+    {
+        $contract = Contract::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('contract.show', $contract));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('contracts.edit');
+    }
+
+    // ==================== Store ====================
+
+    public function test_admin_can_create_contract(): void
+    {
+        $contractData = [
+            'contract_number' => 'CONTRACT-2026-001',
+            'contract_date' => '2026-01-15',
+            'year' => 2026,
+        ];
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('contract.store'), $contractData);
+
+        $response->assertRedirect(route('contract.index'));
+        $this->assertDatabaseHas('contracts', [
+            'contract_number' => 'CONTRACT-2026-001',
+            'year' => 2026,
+        ]);
+    }
+
+    public function test_manager_cannot_create_contract(): void
+    {
+        $contractData = [
+            'contract_number' => 'CONTRACT-2026-002',
+            'contract_date' => '2026-01-15',
+            'year' => 2026,
+        ];
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('contract.store'), $contractData);
+
+        $response->assertStatus(403);
+        $this->assertDatabaseMissing('contracts', [
+            'contract_number' => 'CONTRACT-2026-002',
+        ]);
+    }
+
+    public function test_store_contract_requires_contract_number(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('contract.store'), [
+                'contract_date' => '2026-01-15',
+                'year' => 2026,
+            ]);
+
+        $response->assertSessionHasErrors('contract_number');
+    }
+
+    public function test_store_contract_requires_contract_date(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('contract.store'), [
+                'contract_number' => 'CONTRACT-2026-003',
+                'year' => 2026,
+            ]);
+
+        $response->assertSessionHasErrors('contract_date');
+    }
+
+    public function test_store_contract_requires_year(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('contract.store'), [
+                'contract_number' => 'CONTRACT-2026-004',
+                'contract_date' => '2026-01-15',
+            ]);
+
+        $response->assertSessionHasErrors('year');
+    }
+
+    // ==================== Update ====================
+
+    public function test_admin_can_update_contract(): void
+    {
+        $contract = Contract::factory()->create([
+            'contract_number' => 'OLD-NUMBER',
+            'year' => 2025,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('contract.update', $contract), [
+                'contract_number' => 'NEW-NUMBER',
+                'contract_date' => '2026-02-01',
+                'year' => 2026,
+            ]);
+
+        $response->assertRedirect(route('contract.index'));
+        $this->assertDatabaseHas('contracts', [
+            'id' => $contract->id,
+            'contract_number' => 'NEW-NUMBER',
+            'year' => 2026,
+        ]);
+    }
+
+    public function test_manager_cannot_update_contract(): void
+    {
+        $contract = Contract::factory()->create([
+            'contract_number' => 'ORIGINAL-NUMBER',
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('contract.update', $contract), [
+                'contract_number' => 'MODIFIED-NUMBER',
+                'contract_date' => '2026-02-01',
+                'year' => 2026,
+            ]);
+
+        $response->assertStatus(403);
+        $this->assertDatabaseHas('contracts', [
+            'id' => $contract->id,
+            'contract_number' => 'ORIGINAL-NUMBER',
+        ]);
+    }
+
+    // ==================== Delete ====================
+
+    public function test_admin_can_delete_contract(): void
+    {
+        $contract = Contract::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('contract.delete', $contract));
+
+        $response->assertRedirect(route('contract.index'));
+        $this->assertDatabaseMissing('contracts', ['id' => $contract->id]);
+    }
+
+    public function test_manager_can_delete_contract(): void
+    {
+        $contract = Contract::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->delete(route('contract.delete', $contract));
+
+        $response->assertRedirect(route('contract.index'));
+        $this->assertDatabaseMissing('contracts', ['id' => $contract->id]);
+    }
+}

+ 214 - 0
tests/Feature/ImportControllerTest.php

@@ -0,0 +1,214 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Jobs\Import\ImportJob;
+use App\Models\Import;
+use App\Models\Role;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\UploadedFile;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Storage;
+use Tests\TestCase;
+
+class ImportControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+    }
+
+    // ==================== Authentication ====================
+
+    public function test_guest_cannot_access_import_index(): void
+    {
+        $response = $this->get(route('import.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_access_import_show(): void
+    {
+        $import = Import::factory()->create();
+
+        $response = $this->get(route('import.show', $import));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_create_import(): void
+    {
+        $response = $this->post(route('import.create'), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Authorization ====================
+
+    public function test_manager_cannot_access_import_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('import.index'));
+
+        $response->assertStatus(403);
+    }
+
+    // ==================== Index ====================
+
+    public function test_admin_can_access_import_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('import.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('import.index');
+    }
+
+    // ==================== Show ====================
+
+    public function test_admin_can_view_import(): void
+    {
+        $import = Import::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('import.show', $import));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('import.show');
+    }
+
+    // ==================== Store (upload file + dispatch job) ====================
+
+    public function test_admin_can_upload_valid_xlsx_and_dispatches_job(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('orders_import.xlsx', 50, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'orders',
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('import.index'));
+        $response->assertSessionHas('success');
+
+        Bus::assertDispatched(ImportJob::class);
+
+        $this->assertDatabaseHas('imports', [
+            'type' => 'orders',
+            'status' => 'new',
+        ]);
+    }
+
+    public function test_import_store_requires_type(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('test.xlsx', 50);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'import_file' => $file,
+            ]);
+
+        $response->assertSessionHasErrors('type');
+        Bus::assertNotDispatched(ImportJob::class);
+    }
+
+    public function test_import_store_requires_valid_type(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('test.xlsx', 50);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'invalid_type',
+                'import_file' => $file,
+            ]);
+
+        $response->assertSessionHasErrors('type');
+        Bus::assertNotDispatched(ImportJob::class);
+    }
+
+    public function test_import_store_requires_file(): void
+    {
+        Bus::fake();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'orders',
+            ]);
+
+        $response->assertSessionHasErrors('import_file');
+        Bus::assertNotDispatched(ImportJob::class);
+    }
+
+    public function test_admin_can_import_reclamations_type(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('reclamations.xlsx', 50);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'reclamations',
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('import.index'));
+        Bus::assertDispatched(ImportJob::class);
+        $this->assertDatabaseHas('imports', ['type' => 'reclamations', 'status' => 'new']);
+    }
+
+    public function test_admin_can_import_mafs_type(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('mafs.xlsx', 50);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'mafs',
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('import.index'));
+        Bus::assertDispatched(ImportJob::class);
+    }
+
+    public function test_admin_can_import_catalog_type(): void
+    {
+        Bus::fake();
+        Storage::fake('upload');
+
+        $file = UploadedFile::fake()->create('catalog.xlsx', 50);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('import.create'), [
+                'type' => 'catalog',
+                'import_file' => $file,
+            ]);
+
+        $response->assertRedirect(route('import.index'));
+        Bus::assertDispatched(ImportJob::class);
+    }
+}

+ 216 - 0
tests/Feature/ScheduleControllerTest.php

@@ -0,0 +1,216 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Dictionary\Area;
+use App\Models\Dictionary\District;
+use App\Models\Order;
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\Schedule;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Bus;
+use Tests\TestCase;
+
+class ScheduleControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+    private User $brigadierUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+        $this->brigadierUser = User::factory()->create(['role' => Role::BRIGADIER]);
+    }
+
+    // ==================== Authentication ====================
+
+    public function test_guest_cannot_access_schedule_index(): void
+    {
+        $response = $this->get(route('schedule.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_create_schedule_from_order(): void
+    {
+        $response = $this->post(route('schedule.create-from-order'), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_update_schedule(): void
+    {
+        $response = $this->post(route('schedule.update'), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_delete_schedule(): void
+    {
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $schedule = Schedule::factory()->create(['brigadier_id' => $brigadier->id]);
+
+        $response = $this->delete(route('schedule.delete', $schedule));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Authorization ====================
+
+    public function test_manager_cannot_create_schedule_from_order(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('schedule.create-from-order'), []);
+
+        $response->assertStatus(403);
+    }
+
+    public function test_manager_cannot_update_schedule(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('schedule.update'), []);
+
+        $response->assertStatus(403);
+    }
+
+    public function test_manager_cannot_delete_schedule(): void
+    {
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $schedule = Schedule::factory()->create(['brigadier_id' => $brigadier->id]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->delete(route('schedule.delete', $schedule));
+
+        $response->assertStatus(403);
+    }
+
+    // ==================== Index ====================
+
+    public function test_admin_can_access_schedule_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('schedule.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('schedule.index');
+    }
+
+    public function test_brigadier_can_access_schedule_index(): void
+    {
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('schedule.index'));
+
+        $response->assertStatus(200);
+    }
+
+    // ==================== Update (create manual schedule) ====================
+
+    public function test_admin_can_create_manual_schedule(): void
+    {
+        Bus::fake();
+
+        $district = District::query()->first();
+        $area = Area::query()->first();
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.update'), [
+                'installation_date' => '2026-03-15',
+                'address_code' => 'TEST-001',
+                'object_address' => 'ул. Тестовая, 1',
+                'object_type' => 'Площадка',
+                'mafs' => 'МАФ-001 - 2',
+                'mafs_count' => 2,
+                'district_id' => $district->id,
+                'area_id' => $area->id,
+                'brigadier_id' => $brigadier->id,
+                'comment' => 'Тестовый комментарий',
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('schedules', [
+            'address_code' => 'TEST-001',
+            'object_address' => 'ул. Тестовая, 1',
+            'manual' => true,
+        ]);
+    }
+
+    public function test_admin_can_update_existing_schedule(): void
+    {
+        Bus::fake();
+
+        $district = District::query()->first();
+        $area = Area::query()->first();
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+
+        $schedule = Schedule::factory()->create([
+            'installation_date' => '2026-03-10',
+            'object_address' => 'Старый адрес',
+            'brigadier_id' => $brigadier->id,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.update'), [
+                'id' => $schedule->id,
+                'installation_date' => '2026-03-20',
+                'address_code' => $schedule->address_code ?? 'UPD-001',
+                'object_address' => 'Новый адрес',
+                'object_type' => 'Площадка',
+                'mafs' => 'МАФ-002 - 1',
+                'mafs_count' => 1,
+                'district_id' => $district->id,
+                'area_id' => $area->id,
+                'brigadier_id' => $brigadier->id,
+                'comment' => '',
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('schedules', [
+            'id' => $schedule->id,
+            'object_address' => 'Новый адрес',
+            'installation_date' => '2026-03-20',
+        ]);
+    }
+
+    // ==================== Delete ====================
+
+    public function test_admin_can_delete_schedule(): void
+    {
+        $brigadier = User::factory()->create(['role' => Role::BRIGADIER]);
+        $schedule = Schedule::factory()->create(['brigadier_id' => $brigadier->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('schedule.delete', $schedule));
+
+        $response->assertRedirect();
+        $this->assertDatabaseMissing('schedules', ['id' => $schedule->id]);
+    }
+
+    // ==================== Export ====================
+
+    public function test_admin_can_export_schedule(): void
+    {
+        Bus::fake();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('schedule.export'), [
+                'start_date' => '2026-03-01',
+                'end_date' => '2026-03-31',
+                'week' => '10',
+                'year' => 2026,
+            ]);
+
+        $response->assertRedirect(route('schedule.index', ['week' => 10, 'year' => 2026]));
+        $response->assertSessionHas('success');
+    }
+}

+ 154 - 0
tests/Feature/SparePartInventoryControllerTest.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Reclamation;
+use App\Models\Role;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartInventoryControllerTest extends TestCase
+{
+    use RefreshDatabase;
+
+    protected $seed = true;
+
+    private User $adminUser;
+    private User $managerUser;
+    private User $brigadierUser;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->adminUser = User::factory()->create(['role' => Role::ADMIN]);
+        $this->managerUser = User::factory()->create(['role' => Role::MANAGER]);
+        $this->brigadierUser = User::factory()->create(['role' => Role::BRIGADIER]);
+    }
+
+    // ==================== Authentication ====================
+
+    public function test_guest_cannot_access_inventory(): void
+    {
+        $response = $this->get(route('spare_part_inventory.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Authorization ====================
+
+    public function test_brigadier_cannot_access_inventory(): void
+    {
+        $response = $this->actingAs($this->brigadierUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(403);
+    }
+
+    // ==================== Index ====================
+
+    public function test_admin_can_access_inventory_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    public function test_manager_can_access_inventory_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    public function test_inventory_view_has_required_data(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewHas('critical_shortages');
+        $response->assertViewHas('below_min_stock');
+        $response->assertViewHas('open_shortages');
+        $response->assertViewHas('tab');
+    }
+
+    public function test_inventory_shows_open_shortages(): void
+    {
+        $sparePart = SparePart::factory()->create([
+            'min_stock' => 5,
+        ]);
+
+        $reclamation = Reclamation::factory()->create();
+
+        $shortage = Shortage::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'reclamation_id' => $reclamation->id,
+            'status' => Shortage::STATUS_OPEN,
+            'required_qty' => 3,
+            'reserved_qty' => 0,
+            'missing_qty' => 3,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $openShortages = $response->viewData('open_shortages');
+        $this->assertTrue($openShortages->contains('id', $shortage->id));
+    }
+
+    public function test_inventory_does_not_show_closed_shortages(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $reclamation = Reclamation::factory()->create();
+
+        $closedShortage = Shortage::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'reclamation_id' => $reclamation->id,
+            'status' => Shortage::STATUS_CLOSED,
+            'required_qty' => 2,
+            'reserved_qty' => 2,
+            'missing_qty' => 0,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $openShortages = $response->viewData('open_shortages');
+        $this->assertFalse($openShortages->contains('id', $closedShortage->id));
+    }
+
+    public function test_inventory_shows_below_min_stock_parts(): void
+    {
+        // SparePart без SparePartOrders в STATUS_IN_STOCK => total_free_stock = 0
+        // При min_stock > 0 isBelowMinStock() вернёт true
+        $belowMinSparePart = SparePart::factory()->create([
+            'min_stock' => 10,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $belowMinStock = $response->viewData('below_min_stock');
+        $this->assertTrue($belowMinStock->contains('id', $belowMinSparePart->id));
+    }
+
+    public function test_inventory_tab_is_set_correctly(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_inventory.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewHas('tab', 'inventory');
+    }
+}