Browse Source

spare parts tests

Alexander Musikhin 2 weeks ago
parent
commit
7da82d7707

+ 278 - 0
tests/Feature/SparePartControllerTest.php

@@ -0,0 +1,278 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Role;
+use App\Models\SparePart;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SparePartControllerTest 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_spare_parts_index(): void
+    {
+        $response = $this->get(route('spare_parts.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_access_spare_parts_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_parts.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    public function test_manager_can_access_spare_parts_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_parts.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    // ==================== Show ====================
+
+    public function test_admin_can_view_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_parts.show', $sparePart));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.edit');
+    }
+
+    public function test_manager_can_view_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_parts.show', $sparePart));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.edit');
+    }
+
+    public function test_guest_cannot_view_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->get(route('spare_parts.show', $sparePart));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Store (create) ====================
+
+    public function test_admin_can_create_spare_part(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_parts.store'), [
+                'article'         => 'SP-TEST-001',
+                'used_in_maf'     => 'MAF-100',
+                'customer_price'  => 150.00,
+                'purchase_price'  => 100.00,
+                'min_stock'       => 5,
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('spare_parts', [
+            'article'     => 'SP-TEST-001',
+            'used_in_maf' => 'MAF-100',
+        ]);
+    }
+
+    public function test_store_requires_article(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_parts.store'), [
+                'customer_price' => 150.00,
+            ]);
+
+        $response->assertSessionHasErrors('article');
+    }
+
+    public function test_manager_cannot_create_spare_part(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_parts.store'), [
+                'article'        => 'SP-TEST-MANAGER',
+                'customer_price' => 150.00,
+            ]);
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_create_spare_part(): void
+    {
+        $response = $this->post(route('spare_parts.store'), [
+            'article'        => 'SP-TEST-GUEST',
+            'customer_price' => 150.00,
+        ]);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Update ====================
+
+    public function test_admin_can_update_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create(['article' => 'SP-OLD', 'min_stock' => 3]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->put(route('spare_parts.update', $sparePart), [
+                'article'   => 'SP-UPDATED',
+                'min_stock' => 10,
+            ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('spare_parts', [
+            'id'        => $sparePart->id,
+            'article'   => 'SP-UPDATED',
+            'min_stock' => 10,
+        ]);
+    }
+
+    public function test_manager_cannot_update_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->put(route('spare_parts.update', $sparePart), [
+                'article' => 'SP-MANAGER-UPDATE',
+            ]);
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_update_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->put(route('spare_parts.update', $sparePart), [
+            'article' => 'SP-GUEST-UPDATE',
+        ]);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Destroy ====================
+
+    public function test_admin_can_delete_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $sparePartId = $sparePart->id;
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('spare_parts.destroy', $sparePart));
+
+        $response->assertRedirect(route('spare_parts.index'));
+        $this->assertSoftDeleted('spare_parts', ['id' => $sparePartId]);
+    }
+
+    public function test_cannot_delete_spare_part_with_orders(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        // Создаём заказ запчасти через фабрику чтобы привязать к запчасти
+        \App\Models\SparePartOrder::factory()->create(['spare_part_id' => $sparePart->id]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('spare_parts.destroy', $sparePart));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('error');
+        $this->assertDatabaseHas('spare_parts', ['id' => $sparePart->id, 'deleted_at' => null]);
+    }
+
+    public function test_manager_cannot_delete_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->delete(route('spare_parts.destroy', $sparePart));
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_delete_spare_part(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->delete(route('spare_parts.destroy', $sparePart));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Export ====================
+
+    public function test_admin_can_trigger_export(): void
+    {
+        Queue::fake();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_parts.export'));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        Queue::assertPushed(\App\Jobs\Export\ExportSparePartsJob::class);
+    }
+
+    public function test_manager_cannot_trigger_export(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_parts.export'));
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_trigger_export(): void
+    {
+        $response = $this->post(route('spare_parts.export'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Search API ====================
+
+    public function test_admin_can_search_spare_parts(): void
+    {
+        SparePart::factory()->create(['article' => 'SP-SEARCH-001']);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('spare_parts.search', ['query' => 'SP-SEARCH']));
+
+        $response->assertStatus(200);
+        $response->assertJsonStructure([['id', 'article', 'used_in_maf']]);
+    }
+
+    public function test_guest_cannot_search_spare_parts(): void
+    {
+        $response = $this->get(route('spare_parts.search', ['query' => 'SP-SEARCH']));
+
+        $response->assertRedirect(route('login'));
+    }
+}

+ 456 - 0
tests/Feature/SparePartOrderControllerTest.php

@@ -0,0 +1,456 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Reservation;
+use App\Models\Role;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartOrderControllerTest 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_spare_part_orders_index(): void
+    {
+        $response = $this->get(route('spare_part_orders.index'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_access_spare_part_order_create(): void
+    {
+        $response = $this->get(route('spare_part_orders.create'));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_guest_cannot_store_spare_part_order(): void
+    {
+        $response = $this->post(route('spare_part_orders.store'), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Index ====================
+
+    public function test_admin_can_access_spare_part_orders_index(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_orders.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    public function test_manager_can_access_spare_part_orders_index(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_part_orders.index'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_parts.index');
+    }
+
+    // ==================== Show ====================
+
+    public function test_admin_can_view_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_orders.show', $sparePartOrder));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_part_orders.edit');
+    }
+
+    public function test_manager_can_view_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_part_orders.show', $sparePartOrder));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_part_orders.edit');
+    }
+
+    public function test_guest_cannot_view_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->get(route('spare_part_orders.show', $sparePartOrder));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Create ====================
+
+    public function test_admin_can_access_create_form(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->get(route('spare_part_orders.create'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_part_orders.edit');
+    }
+
+    public function test_manager_can_access_create_form(): void
+    {
+        $response = $this->actingAs($this->managerUser)
+            ->get(route('spare_part_orders.create'));
+
+        $response->assertStatus(200);
+        $response->assertViewIs('spare_part_orders.edit');
+    }
+
+    // ==================== Store ====================
+
+    public function test_admin_can_create_spare_part_order(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.store'), [
+                'spare_part_id' => $sparePart->id,
+                'status' => SparePartOrder::STATUS_ORDERED,
+                'ordered_quantity' => 10,
+                'source_text' => 'Test Supplier',
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'spare_part_id' => $sparePart->id,
+            'status' => SparePartOrder::STATUS_ORDERED,
+            'ordered_quantity' => 10,
+        ]);
+    }
+
+    public function test_manager_can_create_spare_part_order(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_orders.store'), [
+                'spare_part_id' => $sparePart->id,
+                'status' => SparePartOrder::STATUS_ORDERED,
+                'ordered_quantity' => 5,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    public function test_store_validates_required_spare_part_id(): void
+    {
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.store'), [
+                'status' => SparePartOrder::STATUS_ORDERED,
+                'ordered_quantity' => 10,
+            ]);
+
+        $response->assertSessionHasErrors('spare_part_id');
+    }
+
+    public function test_store_validates_required_ordered_quantity(): void
+    {
+        $sparePart = SparePart::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.store'), [
+                'spare_part_id' => $sparePart->id,
+                'status' => SparePartOrder::STATUS_ORDERED,
+            ]);
+
+        $response->assertSessionHasErrors('ordered_quantity');
+    }
+
+    // ==================== Update ====================
+
+    public function test_admin_can_update_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->ordered()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->put(route('spare_part_orders.update', $sparePartOrder), [
+                'spare_part_id' => $sparePartOrder->spare_part_id,
+                'status' => SparePartOrder::STATUS_ORDERED,
+                'ordered_quantity' => 20,
+                'order_number' => 'ORD-2024-001',
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sparePartOrder->id,
+            'ordered_quantity' => 20,
+            'order_number' => 'ORD-2024-001',
+        ]);
+    }
+
+    public function test_manager_can_update_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->ordered()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->put(route('spare_part_orders.update', $sparePartOrder), [
+                'spare_part_id' => $sparePartOrder->spare_part_id,
+                'status' => SparePartOrder::STATUS_ORDERED,
+                'ordered_quantity' => 15,
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    public function test_guest_cannot_update_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->put(route('spare_part_orders.update', $sparePartOrder), []);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Destroy ====================
+
+    public function test_admin_can_delete_spare_part_order_without_active_reservations(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('spare_part_orders.destroy', $sparePartOrder));
+
+        $response->assertRedirect(route('spare_part_orders.index'));
+        $response->assertSessionHas('success');
+        $this->assertSoftDeleted('spare_part_orders', ['id' => $sparePartOrder->id]);
+    }
+
+    public function test_admin_cannot_delete_spare_part_order_with_active_reservations(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->create();
+        Reservation::factory()->create([
+            'spare_part_order_id' => $sparePartOrder->id,
+            'spare_part_id' => $sparePartOrder->spare_part_id,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->delete(route('spare_part_orders.destroy', $sparePartOrder));
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('error');
+        $this->assertDatabaseHas('spare_part_orders', ['id' => $sparePartOrder->id, 'deleted_at' => null]);
+    }
+
+    public function test_manager_cannot_delete_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->delete(route('spare_part_orders.destroy', $sparePartOrder));
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_delete_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->delete(route('spare_part_orders.destroy', $sparePartOrder));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== SetInStock ====================
+
+    public function test_admin_can_set_order_in_stock(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->ordered()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.set_in_stock', $sparePartOrder));
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sparePartOrder->id,
+            'status' => SparePartOrder::STATUS_IN_STOCK,
+        ]);
+    }
+
+    public function test_manager_can_set_order_in_stock(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->ordered()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_orders.set_in_stock', $sparePartOrder));
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('success');
+    }
+
+    public function test_guest_cannot_set_order_in_stock(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->post(route('spare_part_orders.set_in_stock', $sparePartOrder));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Ship ====================
+
+    public function test_admin_can_ship_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->withQuantity(20)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.ship', $sparePartOrder), [
+                'quantity' => 5,
+                'note' => 'Тестовая отгрузка',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sparePartOrder->id,
+            'available_qty' => 15,
+        ]);
+    }
+
+    public function test_ship_fails_if_quantity_exceeds_available(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->withQuantity(3)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.ship', $sparePartOrder), [
+                'quantity' => 10,
+                'note' => 'Попытка отгрузить больше чем есть',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('error');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sparePartOrder->id,
+            'available_qty' => 3,
+        ]);
+    }
+
+    public function test_ship_validates_required_quantity(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.ship', $sparePartOrder), [
+                'note' => 'Без количества',
+            ]);
+
+        $response->assertSessionHasErrors('quantity');
+    }
+
+    public function test_manager_can_ship_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->withQuantity(10)->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_orders.ship', $sparePartOrder), [
+                'quantity' => 2,
+                'note' => 'Отгрузка менеджером',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('success');
+    }
+
+    public function test_guest_cannot_ship_spare_part_order(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->post(route('spare_part_orders.ship', $sparePartOrder), [
+            'quantity' => 1,
+            'note' => 'Test',
+        ]);
+
+        $response->assertRedirect(route('login'));
+    }
+
+    // ==================== Correct (Inventory) ====================
+
+    public function test_admin_can_correct_inventory(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->withQuantity(10)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.correct', $sparePartOrder), [
+                'new_quantity' => 8,
+                'reason' => 'Инвентаризация — обнаружена недостача',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $sparePartOrder->id,
+            'available_qty' => 8,
+        ]);
+    }
+
+    public function test_correct_fails_if_quantity_unchanged(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->withQuantity(10)->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_orders.correct', $sparePartOrder), [
+                'new_quantity' => 10,
+                'reason' => 'Без изменений',
+            ]);
+
+        $response->assertRedirect(route('spare_part_orders.show', $sparePartOrder));
+        $response->assertSessionHas('error');
+    }
+
+    public function test_manager_cannot_correct_inventory(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->inStock()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_orders.correct', $sparePartOrder), [
+                'new_quantity' => 5,
+                'reason' => 'Test',
+            ]);
+
+        $response->assertStatus(403);
+    }
+
+    public function test_guest_cannot_correct_inventory(): void
+    {
+        $sparePartOrder = SparePartOrder::factory()->create();
+
+        $response = $this->post(route('spare_part_orders.correct', $sparePartOrder), [
+            'new_quantity' => 5,
+            'reason' => 'Test',
+        ]);
+
+        $response->assertRedirect(route('login'));
+    }
+}

+ 467 - 0
tests/Feature/SparePartReservationControllerTest.php

@@ -0,0 +1,467 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Models\Reclamation;
+use App\Models\Reservation;
+use App\Models\Role;
+use App\Models\Shortage;
+use App\Models\SparePart;
+use App\Models\SparePartOrder;
+use App\Models\User;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
+
+class SparePartReservationControllerTest 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]);
+    }
+
+    // ==================== forReclamation (JSON API) ====================
+
+    public function test_guest_cannot_get_reservations_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->get(route('spare_part_reservations.for_reclamation', $reclamation->id));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_get_reservations_for_reclamation(): void
+    {
+        $sparePart = SparePart::factory()->create(['article' => 'SP-RESRV-001']);
+        $order = SparePartOrder::factory()->inStock()->withQuantity(10)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 3,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('spare_part_reservations.for_reclamation', $reclamation->id));
+
+        $response->assertStatus(200);
+        $response->assertJsonStructure([
+            'reservations' => [['id', 'spare_part_id', 'article', 'reserved_qty', 'status']],
+        ]);
+        $response->assertJsonFragment(['spare_part_id' => $sparePart->id]);
+    }
+
+    public function test_manager_can_get_reservations_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->getJson(route('spare_part_reservations.for_reclamation', $reclamation->id));
+
+        $response->assertStatus(200);
+        $response->assertJsonStructure(['reservations']);
+    }
+
+    public function test_returns_only_active_reservations_for_reclamation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->withQuantity(20)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        // Активный резерв
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        // Отменённый резерв — не должен попасть
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 2,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('spare_part_reservations.for_reclamation', $reclamation->id));
+
+        $response->assertStatus(200);
+        $this->assertCount(1, $response->json('reservations'));
+    }
+
+    // ==================== shortagesForReclamation (JSON API) ====================
+
+    public function test_guest_cannot_get_shortages_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->get(route('spare_part_reservations.shortages_for_reclamation', $reclamation->id));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_get_shortages_for_reclamation(): void
+    {
+        $sparePart = SparePart::factory()->create(['article' => 'SP-SHORTAGE-001']);
+        $reclamation = Reclamation::factory()->create();
+
+        Shortage::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'reclamation_id' => $reclamation->id,
+            'required_qty' => 10,
+            'reserved_qty' => 3,
+            'missing_qty' => 7,
+            'status' => Shortage::STATUS_OPEN,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->getJson(route('spare_part_reservations.shortages_for_reclamation', $reclamation->id));
+
+        $response->assertStatus(200);
+        $response->assertJsonStructure([
+            'shortages' => [['id', 'spare_part_id', 'article', 'required_qty', 'missing_qty', 'status']],
+        ]);
+        $response->assertJsonFragment(['spare_part_id' => $sparePart->id]);
+    }
+
+    public function test_manager_can_get_shortages_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->getJson(route('spare_part_reservations.shortages_for_reclamation', $reclamation->id));
+
+        $response->assertStatus(200);
+        $response->assertJsonStructure(['shortages']);
+    }
+
+    // ==================== cancel ====================
+
+    public function test_guest_cannot_cancel_reservation(): void
+    {
+        $reservation = Reservation::factory()->active()->create();
+
+        $response = $this->post(route('spare_part_reservations.cancel', $reservation));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_cancel_active_reservation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->withQuantity(10)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.cancel', $reservation), [
+                'reason' => 'Тестовая отмена',
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+    }
+
+    public function test_manager_can_cancel_active_reservation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->withQuantity(10)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 3,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_reservations.cancel', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    public function test_cannot_cancel_inactive_reservation(): void
+    {
+        $reservation = Reservation::factory()->cancelled()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.cancel', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('error');
+        // Статус не изменился
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_CANCELLED,
+        ]);
+    }
+
+    public function test_cannot_cancel_issued_reservation(): void
+    {
+        $reservation = Reservation::factory()->issued()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.cancel', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('error');
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+    }
+
+    // ==================== issue ====================
+
+    public function test_guest_cannot_issue_reservation(): void
+    {
+        $reservation = Reservation::factory()->active()->create();
+
+        $response = $this->post(route('spare_part_reservations.issue', $reservation));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_issue_active_reservation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 20,
+            'available_qty' => 20,
+        ]);
+        $reclamation = Reclamation::factory()->create();
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.issue', $reservation), [
+                'note' => 'Тестовое списание',
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        $this->assertDatabaseHas('reservations', [
+            'id' => $reservation->id,
+            'status' => Reservation::STATUS_ISSUED,
+        ]);
+
+        $this->assertDatabaseHas('spare_part_orders', [
+            'id' => $order->id,
+            'available_qty' => 15,
+        ]);
+    }
+
+    public function test_manager_can_issue_active_reservation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 10,
+            'available_qty' => 10,
+        ]);
+        $reclamation = Reclamation::factory()->create();
+
+        $reservation = Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 3,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_reservations.issue', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    public function test_cannot_issue_inactive_reservation(): void
+    {
+        $reservation = Reservation::factory()->cancelled()->create();
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.issue', $reservation));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('error');
+    }
+
+    // ==================== issueAllForReclamation ====================
+
+    public function test_guest_cannot_issue_all_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->post(route('spare_part_reservations.issue_all', $reclamation->id));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_issue_all_for_reclamation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->forSparePart($sparePart)->create([
+            'ordered_quantity' => 50,
+            'available_qty' => 50,
+        ]);
+        $reclamation = Reclamation::factory()->create();
+
+        // Два активных резерва для рекламации
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 5,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 3,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.issue_all', $reclamation->id));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        // Все резервы рекламации должны быть списаны
+        $this->assertDatabaseMissing('reservations', [
+            'reclamation_id' => $reclamation->id,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+    }
+
+    public function test_manager_can_issue_all_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_reservations.issue_all', $reclamation->id));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    // ==================== cancelAllForReclamation ====================
+
+    public function test_guest_cannot_cancel_all_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->post(route('spare_part_reservations.cancel_all', $reclamation->id));
+
+        $response->assertRedirect(route('login'));
+    }
+
+    public function test_admin_can_cancel_all_for_reclamation(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->withQuantity(30)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 4,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 6,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.cancel_all', $reclamation->id), [
+                'reason' => 'Тестовая массовая отмена',
+            ]);
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+
+        // Все активные резервы рекламации отменены
+        $this->assertDatabaseMissing('reservations', [
+            'reclamation_id' => $reclamation->id,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+    }
+
+    public function test_manager_can_cancel_all_for_reclamation(): void
+    {
+        $reclamation = Reclamation::factory()->create();
+
+        $response = $this->actingAs($this->managerUser)
+            ->post(route('spare_part_reservations.cancel_all', $reclamation->id));
+
+        $response->assertRedirect();
+        $response->assertSessionHas('success');
+    }
+
+    public function test_cancel_all_includes_success_message_with_quantity(): void
+    {
+        $sparePart = SparePart::factory()->create();
+        $order = SparePartOrder::factory()->inStock()->withQuantity(20)->forSparePart($sparePart)->create();
+        $reclamation = Reclamation::factory()->create();
+
+        Reservation::factory()->create([
+            'spare_part_id' => $sparePart->id,
+            'spare_part_order_id' => $order->id,
+            'reclamation_id' => $reclamation->id,
+            'reserved_qty' => 7,
+            'status' => Reservation::STATUS_ACTIVE,
+        ]);
+
+        $response = $this->actingAs($this->adminUser)
+            ->post(route('spare_part_reservations.cancel_all', $reclamation->id));
+
+        $response->assertSessionHas('success');
+        $this->assertStringContainsString('7', session('success'));
+    }
+}