StockControllerTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. <?php
  2. declare(strict_types=1);
  3. namespace Tests\Feature;
  4. use App\Jobs\Import\ImportJob;
  5. use App\Models\CommonCatalogItem;
  6. use App\Models\Role;
  7. use App\Models\StockMovement;
  8. use App\Models\StockOrder;
  9. use App\Models\StockReservation;
  10. use App\Models\User;
  11. use App\Services\StockInventoryService;
  12. use Illuminate\Foundation\Testing\RefreshDatabase;
  13. use Illuminate\Http\UploadedFile;
  14. use Illuminate\Support\Facades\Bus;
  15. use Illuminate\Support\Facades\Storage;
  16. use Tests\TestCase;
  17. class StockControllerTest extends TestCase
  18. {
  19. use RefreshDatabase;
  20. protected bool $seed = true;
  21. private User $admin;
  22. private User $manager;
  23. private User $assistant;
  24. private StockInventoryService $inventory;
  25. protected function setUp(): void
  26. {
  27. parent::setUp();
  28. $this->admin = User::factory()->create(['role' => Role::ADMIN]);
  29. $this->manager = User::factory()->create(['role' => Role::MANAGER]);
  30. $this->assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
  31. $this->inventory = app(StockInventoryService::class);
  32. }
  33. public function test_guest_cannot_open_stock(): void
  34. {
  35. $this->get(route('stock.index'))->assertRedirect(route('login'));
  36. }
  37. public function test_availability_is_year_independent_and_visible_to_regular_users(): void
  38. {
  39. $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-001']);
  40. $this->createOrder($item, 'A-1', 4);
  41. $this->actingAs($this->manager)
  42. ->withSession(['year' => 2019])
  43. ->get(route('stock.index'))
  44. ->assertOk()
  45. ->assertViewIs('stock.index')
  46. ->assertSeeText('Склад')
  47. ->assertDontSeeText('Склад наличие')
  48. ->assertSeeText('STOCK-001');
  49. }
  50. public function test_orders_are_available_only_to_admin_and_assistant_head(): void
  51. {
  52. $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
  53. $this->actingAs($this->admin)->get(route('stock.orders'))->assertOk();
  54. $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
  55. }
  56. public function test_admin_can_create_order_linked_to_common_catalog(): void
  57. {
  58. $item = CommonCatalogItem::factory()->create();
  59. $this->actingAs($this->admin)->post(route('stock.orders.store'), [
  60. 'common_catalog_item_id' => $item->id,
  61. 'order_number' => 'WH-100',
  62. 'status' => StockOrder::STATUS_IN_STOCK,
  63. 'ordered_quantity' => 8,
  64. 'note' => 'Первая партия',
  65. ])->assertRedirect();
  66. $this->assertDatabaseHas('stock_orders', [
  67. 'common_catalog_item_id' => $item->id,
  68. 'order_number' => 'WH-100',
  69. 'available_quantity' => 8,
  70. ]);
  71. $this->assertDatabaseHas('stock_movements', [
  72. 'common_catalog_item_id' => $item->id,
  73. 'movement_type' => StockMovement::TYPE_RECEIPT,
  74. 'quantity' => 8,
  75. ]);
  76. }
  77. public function test_ordered_status_does_not_increase_physical_stock_until_receipt(): void
  78. {
  79. $item = CommonCatalogItem::factory()->create();
  80. $order = $this->createOrder($item, 'WH-ORDERED', 9, StockOrder::STATUS_ORDERED);
  81. $this->assertSame([
  82. 'physical' => 0,
  83. 'reserved' => 0,
  84. 'available' => 0,
  85. 'ordered' => 9,
  86. ], $this->inventory->summary($item));
  87. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  88. 'common_catalog_item_id' => $item->id,
  89. 'order_number' => $order->order_number,
  90. 'status' => StockOrder::STATUS_IN_STOCK,
  91. 'ordered_quantity' => 9,
  92. 'note' => null,
  93. ])->assertRedirect();
  94. $this->assertSame(9, $this->inventory->summary($item)['available']);
  95. }
  96. public function test_editing_order_casts_form_foreign_key_before_creating_receipt_movement(): void
  97. {
  98. $item = CommonCatalogItem::factory()->create();
  99. $order = $this->inventory->createOrder([
  100. 'common_catalog_item_id' => (string) $item->id,
  101. 'order_number' => 'WH-STRING-ID',
  102. 'status' => StockOrder::STATUS_ORDERED,
  103. 'ordered_quantity' => '6',
  104. 'note' => null,
  105. ], $this->admin);
  106. $this->assertIsInt($order->common_catalog_item_id);
  107. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  108. 'common_catalog_item_id' => (string) $item->id,
  109. 'order_number' => $order->order_number,
  110. 'status' => StockOrder::STATUS_IN_STOCK,
  111. 'ordered_quantity' => '6',
  112. 'note' => null,
  113. ])->assertRedirect()->assertSessionHasNoErrors();
  114. $this->assertDatabaseHas('stock_movements', [
  115. 'stock_order_id' => $order->id,
  116. 'common_catalog_item_id' => $item->id,
  117. 'movement_type' => StockMovement::TYPE_RECEIPT,
  118. 'quantity' => 6,
  119. ]);
  120. }
  121. public function test_admin_can_set_all_ordered_positions_with_same_number_in_stock(): void
  122. {
  123. $firstItem = CommonCatalogItem::factory()->create();
  124. $secondItem = CommonCatalogItem::factory()->create();
  125. $alreadyReceivedItem = CommonCatalogItem::factory()->create();
  126. $unrelatedItem = CommonCatalogItem::factory()->create();
  127. $first = $this->createOrder($firstItem, 'WH-BULK', 2, StockOrder::STATUS_ORDERED);
  128. $second = $this->createOrder($secondItem, 'WH-BULK', 3, StockOrder::STATUS_ORDERED);
  129. $alreadyReceived = $this->createOrder($alreadyReceivedItem, 'WH-BULK', 4);
  130. $unrelated = $this->createOrder($unrelatedItem, 'WH-OTHER', 5, StockOrder::STATUS_ORDERED);
  131. $this->actingAs($this->admin)
  132. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
  133. ->assertRedirect()
  134. ->assertSessionHas('success', 'Позиции заказа переведены в статус «На складе»: 2.');
  135. $this->assertSame(StockOrder::STATUS_IN_STOCK, $first->refresh()->status);
  136. $this->assertSame(2, $first->available_quantity);
  137. $this->assertSame(StockOrder::STATUS_IN_STOCK, $second->refresh()->status);
  138. $this->assertSame(3, $second->available_quantity);
  139. $this->assertSame(StockOrder::STATUS_IN_STOCK, $alreadyReceived->refresh()->status);
  140. $this->assertSame(StockOrder::STATUS_ORDERED, $unrelated->refresh()->status);
  141. $this->assertDatabaseHas('stock_movements', [
  142. 'stock_order_id' => $first->id,
  143. 'movement_type' => StockMovement::TYPE_RECEIPT,
  144. 'quantity' => 2,
  145. ]);
  146. $this->assertDatabaseHas('stock_movements', [
  147. 'stock_order_id' => $second->id,
  148. 'movement_type' => StockMovement::TYPE_RECEIPT,
  149. 'quantity' => 3,
  150. ]);
  151. $this->assertSame(1, StockMovement::query()->where('stock_order_id', $alreadyReceived->id)->count());
  152. }
  153. public function test_bulk_receipt_button_lists_only_ordered_order_numbers(): void
  154. {
  155. $orderedItem = CommonCatalogItem::factory()->create();
  156. $receivedItem = CommonCatalogItem::factory()->create();
  157. $this->createOrder($orderedItem, 'WH-AVAILABLE', 2, StockOrder::STATUS_ORDERED);
  158. $this->createOrder($receivedItem, 'WH-RECEIVED', 2);
  159. $response = $this->actingAs($this->admin)
  160. ->get(route('stock.orders'))
  161. ->assertOk()
  162. ->assertSeeText('Весь заказ на складе');
  163. $response->assertViewHas('orderedOrderNumbers', fn ($numbers): bool => $numbers->all() === ['WH-AVAILABLE']);
  164. }
  165. public function test_manager_cannot_set_whole_stock_order_in_stock(): void
  166. {
  167. $this->actingAs($this->manager)
  168. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
  169. ->assertForbidden();
  170. }
  171. public function test_bulk_receipt_reports_unknown_order_number(): void
  172. {
  173. $this->actingAs($this->admin)
  174. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-MISSING'])
  175. ->assertSessionHasErrors([
  176. 'bulk_order_number' => 'Заказ со статусом «Заказан» не найден',
  177. ]);
  178. }
  179. public function test_reservation_is_all_or_nothing_and_cannot_exceed_available_stock(): void
  180. {
  181. $item = CommonCatalogItem::factory()->create();
  182. $this->createOrder($item, 'WH-1', 5);
  183. $this->actingAs($this->manager)->post(route('stock.reserve', $item), [
  184. 'manager_id' => $this->manager->id,
  185. 'quantity' => 6,
  186. 'note' => 'Объект 15',
  187. ])->assertSessionHasErrors(['quantity' => 'Такого кол-ва нет в наличии']);
  188. $this->assertDatabaseCount('stock_reservations', 0);
  189. $this->assertSame(5, $this->inventory->summary($item)['available']);
  190. }
  191. public function test_reservation_uses_fifo_orders_and_issue_and_cancel_keep_history(): void
  192. {
  193. $item = CommonCatalogItem::factory()->create();
  194. $first = $this->createOrder($item, 'WH-1', 5);
  195. $second = $this->createOrder($item, 'WH-2', 5);
  196. $reservations = $this->inventory->reserve($item, $this->manager, 7, 'Объект 21', $this->admin);
  197. $this->assertCount(2, $reservations);
  198. $this->assertSame([$first->id, $second->id], $reservations->pluck('stock_order_id')->all());
  199. $this->assertSame([5, 2], $reservations->pluck('quantity')->all());
  200. $this->assertSame(3, $this->inventory->summary($item)['available']);
  201. $this->inventory->issue($reservations[0], $this->admin);
  202. $first->refresh();
  203. $this->assertSame(StockOrder::STATUS_SHIPPED, $first->status);
  204. $this->assertSame(StockReservation::STATUS_ISSUED, $reservations[0]->refresh()->status);
  205. $this->assertSame(3, $this->inventory->summary($item)['available']);
  206. $this->inventory->cancel($reservations[1], $this->admin, 'Заказ клиента отменён');
  207. $this->assertSame(StockReservation::STATUS_CANCELLED, $reservations[1]->refresh()->status);
  208. $this->assertSame(5, $this->inventory->summary($item)['available']);
  209. $this->assertDatabaseHas('stock_movements', [
  210. 'stock_order_id' => $first->id,
  211. 'movement_type' => StockMovement::TYPE_ISSUE,
  212. 'quantity' => 5,
  213. ]);
  214. $this->assertDatabaseHas('stock_movements', [
  215. 'stock_order_id' => $second->id,
  216. 'movement_type' => StockMovement::TYPE_RESERVE_CANCEL,
  217. 'quantity' => 2,
  218. ]);
  219. }
  220. public function test_order_with_active_reservation_cannot_be_manually_shipped(): void
  221. {
  222. $item = CommonCatalogItem::factory()->create();
  223. $order = $this->createOrder($item, 'WH-LOCKED', 4);
  224. $this->inventory->reserve($item, $this->manager, 1, null, $this->admin);
  225. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  226. 'common_catalog_item_id' => $item->id,
  227. 'order_number' => $order->order_number,
  228. 'status' => StockOrder::STATUS_SHIPPED,
  229. 'ordered_quantity' => 4,
  230. 'note' => null,
  231. ])->assertSessionHas('error', 'Нельзя изменить статус заказа с активной бронью');
  232. $this->assertSame(StockOrder::STATUS_IN_STOCK, $order->refresh()->status);
  233. $this->assertSame(3, $this->inventory->summary($item)['available']);
  234. }
  235. public function test_stock_card_displays_orders_reservations_and_movement_history(): void
  236. {
  237. $item = CommonCatalogItem::factory()->create(['article' => 'CARD-001']);
  238. $this->createOrder($item, 'CARD-ORDER', 3);
  239. $this->inventory->reserve($item, $this->manager, 1, 'Бронь карточки', $this->admin);
  240. $this->actingAs($this->manager)
  241. ->get(route('stock.show', $item))
  242. ->assertOk()
  243. ->assertSeeText('CARD-001')
  244. ->assertSeeText('CARD-ORDER')
  245. ->assertSeeText('Бронь карточки')
  246. ->assertSeeText('Бронирование');
  247. }
  248. public function test_admin_can_queue_xlsx_import(): void
  249. {
  250. Bus::fake();
  251. Storage::fake('upload');
  252. $this->actingAs($this->admin)->post(route('stock.orders.import'), [
  253. 'import_file' => UploadedFile::fake()->create('stock.xlsx', 20, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
  254. ])->assertRedirect();
  255. $this->assertDatabaseHas('imports', [
  256. 'type' => 'stock_orders',
  257. 'original_filename' => 'stock.xlsx',
  258. ]);
  259. Bus::assertDispatched(ImportJob::class);
  260. }
  261. private function createOrder(
  262. CommonCatalogItem $item,
  263. string $number,
  264. int $quantity,
  265. string $status = StockOrder::STATUS_IN_STOCK,
  266. ): StockOrder {
  267. return $this->inventory->createOrder([
  268. 'common_catalog_item_id' => $item->id,
  269. 'order_number' => $number,
  270. 'status' => $status,
  271. 'ordered_quantity' => $quantity,
  272. 'note' => null,
  273. ], $this->admin);
  274. }
  275. }