SparePartIssueServiceTest.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  1. <?php
  2. namespace Tests\Unit\Services;
  3. use App\Models\InventoryMovement;
  4. use App\Models\Reclamation;
  5. use App\Models\Reservation;
  6. use App\Models\SparePart;
  7. use App\Models\SparePartOrder;
  8. use App\Models\User;
  9. use App\Services\IssueResult;
  10. use App\Services\SparePartIssueService;
  11. use Illuminate\Foundation\Testing\RefreshDatabase;
  12. use Tests\TestCase;
  13. class SparePartIssueServiceTest extends TestCase
  14. {
  15. use RefreshDatabase;
  16. protected $seed = true;
  17. private SparePartIssueService $service;
  18. protected function setUp(): void
  19. {
  20. parent::setUp();
  21. $this->service = new SparePartIssueService();
  22. }
  23. // ==================== issueReservation ====================
  24. public function test_issue_reservation_decreases_available_qty(): void
  25. {
  26. // Arrange
  27. $user = User::factory()->create();
  28. $this->actingAs($user);
  29. $sparePart = SparePart::factory()->create();
  30. $reclamation = Reclamation::factory()->create();
  31. $order = SparePartOrder::factory()
  32. ->inStock()
  33. ->withDocuments(false)
  34. ->withQuantity(10)
  35. ->forSparePart($sparePart)
  36. ->create();
  37. $reservation = Reservation::factory()
  38. ->active()
  39. ->withQuantity(3)
  40. ->withDocuments(false)
  41. ->fromOrder($order)
  42. ->forReclamation($reclamation)
  43. ->create();
  44. // Act
  45. $result = $this->service->issueReservation($reservation);
  46. // Assert
  47. $this->assertInstanceOf(IssueResult::class, $result);
  48. $this->assertEquals(3, $result->issued);
  49. $order->refresh();
  50. $this->assertEquals(7, $order->available_qty);
  51. }
  52. public function test_issue_reservation_marks_reservation_as_issued(): void
  53. {
  54. // Arrange
  55. $user = User::factory()->create();
  56. $this->actingAs($user);
  57. $sparePart = SparePart::factory()->create();
  58. $reclamation = Reclamation::factory()->create();
  59. $order = SparePartOrder::factory()
  60. ->inStock()
  61. ->withQuantity(10)
  62. ->forSparePart($sparePart)
  63. ->create();
  64. $reservation = Reservation::factory()
  65. ->active()
  66. ->withQuantity(5)
  67. ->fromOrder($order)
  68. ->forReclamation($reclamation)
  69. ->create();
  70. // Act
  71. $this->service->issueReservation($reservation);
  72. // Assert
  73. $reservation->refresh();
  74. $this->assertEquals(Reservation::STATUS_ISSUED, $reservation->status);
  75. $this->assertTrue($reservation->isIssued());
  76. }
  77. public function test_issue_reservation_creates_inventory_movement(): void
  78. {
  79. // Arrange
  80. $user = User::factory()->create();
  81. $this->actingAs($user);
  82. $sparePart = SparePart::factory()->create();
  83. $reclamation = Reclamation::factory()->create();
  84. $order = SparePartOrder::factory()
  85. ->inStock()
  86. ->withDocuments(true)
  87. ->withQuantity(10)
  88. ->forSparePart($sparePart)
  89. ->create();
  90. $reservation = Reservation::factory()
  91. ->active()
  92. ->withQuantity(4)
  93. ->withDocuments(true)
  94. ->fromOrder($order)
  95. ->forReclamation($reclamation)
  96. ->create();
  97. // Act
  98. $result = $this->service->issueReservation($reservation, 'Тестовое примечание');
  99. // Assert
  100. $this->assertDatabaseHas('inventory_movements', [
  101. 'spare_part_id' => $sparePart->id,
  102. 'spare_part_order_id' => $order->id,
  103. 'qty' => 4,
  104. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  105. 'source_type' => InventoryMovement::SOURCE_RECLAMATION,
  106. 'source_id' => $reclamation->id,
  107. 'with_documents' => true,
  108. 'user_id' => $user->id,
  109. ]);
  110. $this->assertNotNull($result->movement);
  111. $this->assertEquals(InventoryMovement::TYPE_ISSUE, $result->movement->movement_type);
  112. }
  113. public function test_issue_reservation_changes_order_status_to_shipped_when_empty(): void
  114. {
  115. // Arrange
  116. $user = User::factory()->create();
  117. $this->actingAs($user);
  118. $sparePart = SparePart::factory()->create();
  119. $reclamation = Reclamation::factory()->create();
  120. $order = SparePartOrder::factory()
  121. ->inStock()
  122. ->withQuantity(5)
  123. ->forSparePart($sparePart)
  124. ->create();
  125. $reservation = Reservation::factory()
  126. ->active()
  127. ->withQuantity(5)
  128. ->fromOrder($order)
  129. ->forReclamation($reclamation)
  130. ->create();
  131. // Act
  132. $this->service->issueReservation($reservation);
  133. // Assert
  134. $order->refresh();
  135. $this->assertEquals(0, $order->available_qty);
  136. $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
  137. }
  138. public function test_issue_reservation_throws_exception_for_non_active_reservation(): void
  139. {
  140. // Arrange
  141. $sparePart = SparePart::factory()->create();
  142. $reclamation = Reclamation::factory()->create();
  143. $order = SparePartOrder::factory()
  144. ->inStock()
  145. ->forSparePart($sparePart)
  146. ->create();
  147. $reservation = Reservation::factory()
  148. ->cancelled()
  149. ->fromOrder($order)
  150. ->forReclamation($reclamation)
  151. ->create();
  152. // Assert
  153. $this->expectException(\InvalidArgumentException::class);
  154. $this->expectExceptionMessage('Резерв не активен');
  155. // Act
  156. $this->service->issueReservation($reservation);
  157. }
  158. public function test_issue_reservation_throws_exception_when_insufficient_stock(): void
  159. {
  160. // Arrange
  161. $user = User::factory()->create();
  162. $this->actingAs($user);
  163. $sparePart = SparePart::factory()->create();
  164. $reclamation = Reclamation::factory()->create();
  165. $order = SparePartOrder::factory()
  166. ->inStock()
  167. ->withQuantity(2)
  168. ->forSparePart($sparePart)
  169. ->create();
  170. // Create reservation with more qty than available
  171. $reservation = Reservation::factory()
  172. ->active()
  173. ->withQuantity(5)
  174. ->fromOrder($order)
  175. ->forReclamation($reclamation)
  176. ->create();
  177. // Manually reduce available_qty to simulate inconsistency
  178. $order->update(['available_qty' => 2]);
  179. // Assert
  180. $this->expectException(\RuntimeException::class);
  181. $this->expectExceptionMessage('Недостаточно товара в партии');
  182. // Act
  183. $this->service->issueReservation($reservation);
  184. }
  185. // ==================== issueForReclamation ====================
  186. public function test_issue_for_reclamation_issues_all_active_reservations(): void
  187. {
  188. // Arrange
  189. $user = User::factory()->create();
  190. $this->actingAs($user);
  191. $sparePart = SparePart::factory()->create();
  192. $reclamation = Reclamation::factory()->create();
  193. $order = SparePartOrder::factory()
  194. ->inStock()
  195. ->withQuantity(20)
  196. ->forSparePart($sparePart)
  197. ->create();
  198. Reservation::factory()
  199. ->active()
  200. ->withQuantity(3)
  201. ->fromOrder($order)
  202. ->forReclamation($reclamation)
  203. ->create();
  204. Reservation::factory()
  205. ->active()
  206. ->withQuantity(5)
  207. ->fromOrder($order)
  208. ->forReclamation($reclamation)
  209. ->create();
  210. // Act
  211. $results = $this->service->issueForReclamation($reclamation->id);
  212. // Assert
  213. $this->assertCount(2, $results);
  214. $this->assertEquals(3 + 5, array_sum(array_map(fn($r) => $r->issued, $results)));
  215. $order->refresh();
  216. $this->assertEquals(20 - 8, $order->available_qty);
  217. }
  218. public function test_issue_for_reclamation_filters_by_spare_part(): void
  219. {
  220. // Arrange
  221. $user = User::factory()->create();
  222. $this->actingAs($user);
  223. $sparePart1 = SparePart::factory()->create();
  224. $sparePart2 = SparePart::factory()->create();
  225. $reclamation = Reclamation::factory()->create();
  226. $order1 = SparePartOrder::factory()
  227. ->inStock()
  228. ->withQuantity(10)
  229. ->forSparePart($sparePart1)
  230. ->create();
  231. $order2 = SparePartOrder::factory()
  232. ->inStock()
  233. ->withQuantity(10)
  234. ->forSparePart($sparePart2)
  235. ->create();
  236. Reservation::factory()
  237. ->active()
  238. ->withQuantity(3)
  239. ->fromOrder($order1)
  240. ->forReclamation($reclamation)
  241. ->create();
  242. Reservation::factory()
  243. ->active()
  244. ->withQuantity(5)
  245. ->fromOrder($order2)
  246. ->forReclamation($reclamation)
  247. ->create();
  248. // Act - issue only for sparePart1
  249. $results = $this->service->issueForReclamation(
  250. $reclamation->id,
  251. sparePartId: $sparePart1->id
  252. );
  253. // Assert
  254. $this->assertCount(1, $results);
  255. $this->assertEquals(3, $results[0]->issued);
  256. // Check sparePart2 reservation is still active
  257. $this->assertDatabaseHas('reservations', [
  258. 'spare_part_id' => $sparePart2->id,
  259. 'reclamation_id' => $reclamation->id,
  260. 'status' => Reservation::STATUS_ACTIVE,
  261. ]);
  262. }
  263. public function test_issue_for_reclamation_filters_by_with_documents(): void
  264. {
  265. // Arrange
  266. $user = User::factory()->create();
  267. $this->actingAs($user);
  268. $sparePart = SparePart::factory()->create();
  269. $reclamation = Reclamation::factory()->create();
  270. $orderWithDocs = SparePartOrder::factory()
  271. ->inStock()
  272. ->withDocuments(true)
  273. ->withQuantity(10)
  274. ->forSparePart($sparePart)
  275. ->create();
  276. $orderWithoutDocs = SparePartOrder::factory()
  277. ->inStock()
  278. ->withDocuments(false)
  279. ->withQuantity(10)
  280. ->forSparePart($sparePart)
  281. ->create();
  282. Reservation::factory()
  283. ->active()
  284. ->withQuantity(3)
  285. ->withDocuments(true)
  286. ->fromOrder($orderWithDocs)
  287. ->forReclamation($reclamation)
  288. ->create();
  289. Reservation::factory()
  290. ->active()
  291. ->withQuantity(5)
  292. ->withDocuments(false)
  293. ->fromOrder($orderWithoutDocs)
  294. ->forReclamation($reclamation)
  295. ->create();
  296. // Act - issue only with documents
  297. $results = $this->service->issueForReclamation(
  298. $reclamation->id,
  299. withDocuments: true
  300. );
  301. // Assert
  302. $this->assertCount(1, $results);
  303. $this->assertEquals(3, $results[0]->issued);
  304. }
  305. // ==================== directIssue ====================
  306. public function test_direct_issue_decreases_available_qty(): void
  307. {
  308. // Arrange
  309. $user = User::factory()->create();
  310. $this->actingAs($user);
  311. $sparePart = SparePart::factory()->create();
  312. $order = SparePartOrder::factory()
  313. ->inStock()
  314. ->withQuantity(10)
  315. ->forSparePart($sparePart)
  316. ->create();
  317. // Act
  318. $result = $this->service->directIssue($order, 4, 'Ручное списание');
  319. // Assert
  320. $this->assertEquals(4, $result->issued);
  321. $this->assertNull($result->reservation);
  322. $order->refresh();
  323. $this->assertEquals(6, $order->available_qty);
  324. }
  325. public function test_direct_issue_creates_manual_movement(): void
  326. {
  327. // Arrange
  328. $user = User::factory()->create();
  329. $this->actingAs($user);
  330. $sparePart = SparePart::factory()->create();
  331. $order = SparePartOrder::factory()
  332. ->inStock()
  333. ->withDocuments(true)
  334. ->withQuantity(10)
  335. ->forSparePart($sparePart)
  336. ->create();
  337. // Act
  338. $result = $this->service->directIssue($order, 3, 'Тестовое списание');
  339. // Assert
  340. $this->assertDatabaseHas('inventory_movements', [
  341. 'spare_part_id' => $sparePart->id,
  342. 'spare_part_order_id' => $order->id,
  343. 'qty' => 3,
  344. 'movement_type' => InventoryMovement::TYPE_ISSUE,
  345. 'source_type' => InventoryMovement::SOURCE_MANUAL,
  346. 'source_id' => null,
  347. 'with_documents' => true,
  348. 'note' => 'Тестовое списание',
  349. ]);
  350. }
  351. public function test_direct_issue_throws_exception_when_insufficient_stock(): void
  352. {
  353. // Arrange
  354. $sparePart = SparePart::factory()->create();
  355. $order = SparePartOrder::factory()
  356. ->inStock()
  357. ->withQuantity(5)
  358. ->forSparePart($sparePart)
  359. ->create();
  360. // Assert
  361. $this->expectException(\InvalidArgumentException::class);
  362. $this->expectExceptionMessage('Недостаточно товара в партии');
  363. // Act
  364. $this->service->directIssue($order, 10, 'Попытка списать больше');
  365. }
  366. public function test_direct_issue_changes_status_to_shipped_when_empty(): void
  367. {
  368. // Arrange
  369. $user = User::factory()->create();
  370. $this->actingAs($user);
  371. $sparePart = SparePart::factory()->create();
  372. $order = SparePartOrder::factory()
  373. ->inStock()
  374. ->withQuantity(5)
  375. ->forSparePart($sparePart)
  376. ->create();
  377. // Act
  378. $this->service->directIssue($order, 5, 'Полное списание');
  379. // Assert
  380. $order->refresh();
  381. $this->assertEquals(0, $order->available_qty);
  382. $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
  383. }
  384. // ==================== correctInventory ====================
  385. public function test_correct_inventory_increases_quantity(): void
  386. {
  387. // Arrange
  388. $user = User::factory()->create();
  389. $this->actingAs($user);
  390. $sparePart = SparePart::factory()->create();
  391. $order = SparePartOrder::factory()
  392. ->inStock()
  393. ->withQuantity(10)
  394. ->forSparePart($sparePart)
  395. ->create();
  396. // Act
  397. $movement = $this->service->correctInventory($order, 15, 'Найдено при инвентаризации');
  398. // Assert
  399. $order->refresh();
  400. $this->assertEquals(15, $order->available_qty);
  401. $this->assertEquals(InventoryMovement::TYPE_CORRECTION_PLUS, $movement->movement_type);
  402. $this->assertEquals(5, $movement->qty);
  403. }
  404. public function test_correct_inventory_decreases_quantity(): void
  405. {
  406. // Arrange
  407. $user = User::factory()->create();
  408. $this->actingAs($user);
  409. $sparePart = SparePart::factory()->create();
  410. $order = SparePartOrder::factory()
  411. ->inStock()
  412. ->withQuantity(10)
  413. ->forSparePart($sparePart)
  414. ->create();
  415. // Act
  416. $movement = $this->service->correctInventory($order, 7, 'Недостача');
  417. // Assert
  418. $order->refresh();
  419. $this->assertEquals(7, $order->available_qty);
  420. $this->assertEquals(InventoryMovement::TYPE_CORRECTION_MINUS, $movement->movement_type);
  421. $this->assertEquals(3, $movement->qty);
  422. }
  423. public function test_correct_inventory_creates_movement_with_inventory_source(): void
  424. {
  425. // Arrange
  426. $user = User::factory()->create();
  427. $this->actingAs($user);
  428. $sparePart = SparePart::factory()->create();
  429. $order = SparePartOrder::factory()
  430. ->inStock()
  431. ->withDocuments(false)
  432. ->withQuantity(10)
  433. ->forSparePart($sparePart)
  434. ->create();
  435. // Act
  436. $this->service->correctInventory($order, 12, 'Коррекция');
  437. // Assert
  438. $this->assertDatabaseHas('inventory_movements', [
  439. 'spare_part_id' => $sparePart->id,
  440. 'spare_part_order_id' => $order->id,
  441. 'qty' => 2,
  442. 'movement_type' => InventoryMovement::TYPE_CORRECTION_PLUS,
  443. 'source_type' => InventoryMovement::SOURCE_INVENTORY,
  444. 'with_documents' => false,
  445. 'note' => 'Коррекция',
  446. ]);
  447. }
  448. public function test_correct_inventory_changes_status_to_shipped_when_zero(): void
  449. {
  450. // Arrange
  451. $user = User::factory()->create();
  452. $this->actingAs($user);
  453. $sparePart = SparePart::factory()->create();
  454. $order = SparePartOrder::factory()
  455. ->inStock()
  456. ->withQuantity(5)
  457. ->forSparePart($sparePart)
  458. ->create();
  459. // Act
  460. $this->service->correctInventory($order, 0, 'Списано всё');
  461. // Assert
  462. $order->refresh();
  463. $this->assertEquals(0, $order->available_qty);
  464. $this->assertEquals(SparePartOrder::STATUS_SHIPPED, $order->status);
  465. }
  466. public function test_correct_inventory_restores_status_to_in_stock_from_shipped(): void
  467. {
  468. // Arrange
  469. $user = User::factory()->create();
  470. $this->actingAs($user);
  471. $sparePart = SparePart::factory()->create();
  472. $order = SparePartOrder::factory()
  473. ->shipped()
  474. ->forSparePart($sparePart)
  475. ->create();
  476. // Act
  477. $this->service->correctInventory($order, 5, 'Найдено на складе');
  478. // Assert
  479. $order->refresh();
  480. $this->assertEquals(5, $order->available_qty);
  481. $this->assertEquals(SparePartOrder::STATUS_IN_STOCK, $order->status);
  482. }
  483. public function test_correct_inventory_throws_exception_for_negative_quantity(): void
  484. {
  485. // Arrange
  486. $sparePart = SparePart::factory()->create();
  487. $order = SparePartOrder::factory()
  488. ->inStock()
  489. ->withQuantity(5)
  490. ->forSparePart($sparePart)
  491. ->create();
  492. // Assert
  493. $this->expectException(\InvalidArgumentException::class);
  494. $this->expectExceptionMessage('Остаток не может быть отрицательным');
  495. // Act
  496. $this->service->correctInventory($order, -1, 'Отрицательный остаток');
  497. }
  498. public function test_correct_inventory_throws_exception_when_quantity_unchanged(): void
  499. {
  500. // Arrange
  501. $sparePart = SparePart::factory()->create();
  502. $order = SparePartOrder::factory()
  503. ->inStock()
  504. ->withQuantity(10)
  505. ->forSparePart($sparePart)
  506. ->create();
  507. // Assert
  508. $this->expectException(\InvalidArgumentException::class);
  509. $this->expectExceptionMessage('Количество не изменилось');
  510. // Act
  511. $this->service->correctInventory($order, 10, 'Без изменений');
  512. }
  513. }