| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621 |
- <?php
- namespace Tests\Unit\Services;
- use App\Models\InventoryMovement;
- use App\Models\Reclamation;
- use App\Models\Reservation;
- use App\Models\SparePart;
- use App\Models\SparePartOrder;
- use App\Models\User;
- use App\Services\IssueResult;
- use App\Services\SparePartIssueService;
- use Illuminate\Foundation\Testing\RefreshDatabase;
- use Tests\TestCase;
- class SparePartIssueServiceTest extends TestCase
- {
- use RefreshDatabase;
- protected $seed = true;
- private SparePartIssueService $service;
- protected function setUp(): void
- {
- parent::setUp();
- $this->service = new SparePartIssueService();
- }
- // ==================== issueReservation ====================
- public function test_issue_reservation_decreases_available_qty(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(false)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- $reservation = Reservation::factory()
- ->active()
- ->withQuantity(3)
- ->withDocuments(false)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Act
- $result = $this->service->issueReservation($reservation);
- // Assert
- $this->assertInstanceOf(IssueResult::class, $result);
- $this->assertEquals(3, $result->issued);
- $order->refresh();
- $this->assertEquals(7, $order->available_qty);
- }
- public function test_issue_reservation_marks_reservation_as_issued(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- $reservation = Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Act
- $this->service->issueReservation($reservation);
- // Assert
- $reservation->refresh();
- $this->assertEquals(Reservation::STATUS_ISSUED, $reservation->status);
- $this->assertTrue($reservation->isIssued());
- }
- public function test_issue_reservation_creates_inventory_movement(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(true)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- $reservation = Reservation::factory()
- ->active()
- ->withQuantity(4)
- ->withDocuments(true)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Act
- $result = $this->service->issueReservation($reservation, 'Тестовое примечание');
- // Assert
- $this->assertDatabaseHas('inventory_movements', [
- 'spare_part_id' => $sparePart->id,
- 'spare_part_order_id' => $order->id,
- 'qty' => 4,
- 'movement_type' => InventoryMovement::TYPE_ISSUE,
- 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
- 'source_id' => $reclamation->id,
- 'with_documents' => true,
- 'user_id' => $user->id,
- ]);
- $this->assertNotNull($result->movement);
- $this->assertEquals(InventoryMovement::TYPE_ISSUE, $result->movement->movement_type);
- }
- public function test_issue_reservation_changes_order_status_to_shipped_when_empty(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(5)
- ->forSparePart($sparePart)
- ->create();
- $reservation = Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Act
- $this->service->issueReservation($reservation);
- // Assert
- $order->refresh();
- $this->assertEquals(0, $order->available_qty);
- $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
- }
- public function test_issue_reservation_throws_exception_for_non_active_reservation(): void
- {
- // Arrange
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->forSparePart($sparePart)
- ->create();
- $reservation = Reservation::factory()
- ->cancelled()
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Assert
- $this->expectException(\InvalidArgumentException::class);
- $this->expectExceptionMessage('Резерв не активен');
- // Act
- $this->service->issueReservation($reservation);
- }
- public function test_issue_reservation_throws_exception_when_insufficient_stock(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(2)
- ->forSparePart($sparePart)
- ->create();
- // Create reservation with more qty than available
- $reservation = Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Manually reduce available_qty to simulate inconsistency
- $order->update(['available_qty' => 2]);
- // Assert
- $this->expectException(\RuntimeException::class);
- $this->expectExceptionMessage('Недостаточно товара в партии');
- // Act
- $this->service->issueReservation($reservation);
- }
- // ==================== issueForReclamation ====================
- public function test_issue_for_reclamation_issues_all_active_reservations(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(20)
- ->forSparePart($sparePart)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(3)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->fromOrder($order)
- ->forReclamation($reclamation)
- ->create();
- // Act
- $results = $this->service->issueForReclamation($reclamation->id);
- // Assert
- $this->assertCount(2, $results);
- $this->assertEquals(3 + 5, array_sum(array_map(fn($r) => $r->issued, $results)));
- $order->refresh();
- $this->assertEquals(20 - 8, $order->available_qty);
- }
- public function test_issue_for_reclamation_filters_by_spare_part(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart1 = SparePart::factory()->create();
- $sparePart2 = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $order1 = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart1)
- ->create();
- $order2 = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart2)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(3)
- ->fromOrder($order1)
- ->forReclamation($reclamation)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->fromOrder($order2)
- ->forReclamation($reclamation)
- ->create();
- // Act - issue only for sparePart1
- $results = $this->service->issueForReclamation(
- $reclamation->id,
- sparePartId: $sparePart1->id
- );
- // Assert
- $this->assertCount(1, $results);
- $this->assertEquals(3, $results[0]->issued);
- // Check sparePart2 reservation is still active
- $this->assertDatabaseHas('reservations', [
- 'spare_part_id' => $sparePart2->id,
- 'reclamation_id' => $reclamation->id,
- 'status' => Reservation::STATUS_ACTIVE,
- ]);
- }
- public function test_issue_for_reclamation_filters_by_with_documents(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $reclamation = Reclamation::factory()->create();
- $orderWithDocs = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(true)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- $orderWithoutDocs = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(false)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(3)
- ->withDocuments(true)
- ->fromOrder($orderWithDocs)
- ->forReclamation($reclamation)
- ->create();
- Reservation::factory()
- ->active()
- ->withQuantity(5)
- ->withDocuments(false)
- ->fromOrder($orderWithoutDocs)
- ->forReclamation($reclamation)
- ->create();
- // Act - issue only with documents
- $results = $this->service->issueForReclamation(
- $reclamation->id,
- withDocuments: true
- );
- // Assert
- $this->assertCount(1, $results);
- $this->assertEquals(3, $results[0]->issued);
- }
- // ==================== directIssue ====================
- public function test_direct_issue_decreases_available_qty(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $result = $this->service->directIssue($order, 4, 'Ручное списание');
- // Assert
- $this->assertEquals(4, $result->issued);
- $this->assertNull($result->reservation);
- $order->refresh();
- $this->assertEquals(6, $order->available_qty);
- }
- public function test_direct_issue_creates_manual_movement(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(true)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $result = $this->service->directIssue($order, 3, 'Тестовое списание');
- // Assert
- $this->assertDatabaseHas('inventory_movements', [
- 'spare_part_id' => $sparePart->id,
- 'spare_part_order_id' => $order->id,
- 'qty' => 3,
- 'movement_type' => InventoryMovement::TYPE_ISSUE,
- 'source_type' => InventoryMovement::SOURCE_MANUAL,
- 'source_id' => null,
- 'with_documents' => true,
- 'note' => 'Тестовое списание',
- ]);
- }
- public function test_direct_issue_throws_exception_when_insufficient_stock(): void
- {
- // Arrange
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(5)
- ->forSparePart($sparePart)
- ->create();
- // Assert
- $this->expectException(\InvalidArgumentException::class);
- $this->expectExceptionMessage('Недостаточно товара в партии');
- // Act
- $this->service->directIssue($order, 10, 'Попытка списать больше');
- }
- public function test_direct_issue_changes_status_to_shipped_when_empty(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(5)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $this->service->directIssue($order, 5, 'Полное списание');
- // Assert
- $order->refresh();
- $this->assertEquals(0, $order->available_qty);
- $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
- }
- // ==================== correctInventory ====================
- public function test_correct_inventory_increases_quantity(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $movement = $this->service->correctInventory($order, 15, 'Найдено при инвентаризации');
- // Assert
- $order->refresh();
- $this->assertEquals(15, $order->available_qty);
- $this->assertEquals(InventoryMovement::TYPE_CORRECTION_PLUS, $movement->movement_type);
- $this->assertEquals(5, $movement->qty);
- }
- public function test_correct_inventory_decreases_quantity(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $movement = $this->service->correctInventory($order, 7, 'Недостача');
- // Assert
- $order->refresh();
- $this->assertEquals(7, $order->available_qty);
- $this->assertEquals(InventoryMovement::TYPE_CORRECTION_MINUS, $movement->movement_type);
- $this->assertEquals(3, $movement->qty);
- }
- public function test_correct_inventory_creates_movement_with_inventory_source(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withDocuments(false)
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $this->service->correctInventory($order, 12, 'Коррекция');
- // Assert
- $this->assertDatabaseHas('inventory_movements', [
- 'spare_part_id' => $sparePart->id,
- 'spare_part_order_id' => $order->id,
- 'qty' => 2,
- 'movement_type' => InventoryMovement::TYPE_CORRECTION_PLUS,
- 'source_type' => InventoryMovement::SOURCE_INVENTORY,
- 'with_documents' => false,
- 'note' => 'Коррекция',
- ]);
- }
- public function test_correct_inventory_changes_status_to_shipped_when_zero(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(5)
- ->forSparePart($sparePart)
- ->create();
- // Act
- $this->service->correctInventory($order, 0, 'Списано всё');
- // Assert
- $order->refresh();
- $this->assertEquals(0, $order->available_qty);
- $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
- }
- public function test_correct_inventory_restores_status_to_in_stock_from_shipped(): void
- {
- // Arrange
- $user = User::factory()->create();
- $this->actingAs($user);
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->shipped()
- ->forSparePart($sparePart)
- ->create();
- // Act
- $this->service->correctInventory($order, 5, 'Найдено на складе');
- // Assert
- $order->refresh();
- $this->assertEquals(5, $order->available_qty);
- $this->assertEquals(SparePartOrder::STATUS_IN_STOCK, $order->status);
- }
- public function test_correct_inventory_throws_exception_for_negative_quantity(): void
- {
- // Arrange
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(5)
- ->forSparePart($sparePart)
- ->create();
- // Assert
- $this->expectException(\InvalidArgumentException::class);
- $this->expectExceptionMessage('Остаток не может быть отрицательным');
- // Act
- $this->service->correctInventory($order, -1, 'Отрицательный остаток');
- }
- public function test_correct_inventory_throws_exception_when_quantity_unchanged(): void
- {
- // Arrange
- $sparePart = SparePart::factory()->create();
- $order = SparePartOrder::factory()
- ->inStock()
- ->withQuantity(10)
- ->forSparePart($sparePart)
- ->create();
- // Assert
- $this->expectException(\InvalidArgumentException::class);
- $this->expectExceptionMessage('Количество не изменилось');
- // Act
- $this->service->correctInventory($order, 10, 'Без изменений');
- }
- }
|