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, 'Без изменений'); } }