StockControllerTest.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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_availability_supports_column_filters_and_computed_sorting(): void
  51. {
  52. $smallerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-SMALL']);
  53. $largerItem = CommonCatalogItem::factory()->create(['article' => 'STOCK-LARGE']);
  54. $smallerOrder = $this->createOrder($smallerItem, 'SMALL-ORDER', 2);
  55. $this->createOrder($largerItem, 'LARGE-ORDER', 7);
  56. $smallerOrder->update(['note' => 'Примечание малой партии']);
  57. $this->inventory->reserve($largerItem, $this->manager, 2, null, $this->admin);
  58. $this->actingAs($this->manager)
  59. ->get(route('stock.index', [
  60. 'filters' => ['article' => 'STOCK-SMALL'],
  61. ]))
  62. ->assertOk()
  63. ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
  64. $this->actingAs($this->manager)
  65. ->get(route('stock.index', [
  66. 'filters' => ['physical_quantity_from' => 5],
  67. ]))
  68. ->assertOk()
  69. ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
  70. $this->actingAs($this->manager)
  71. ->get(route('stock.index', [
  72. 'filters' => ['latest_order_note' => 'Примечание малой партии'],
  73. ]))
  74. ->assertOk()
  75. ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-SMALL']);
  76. $this->actingAs($this->manager)
  77. ->get(route('stock.index', [
  78. 'filters' => ['reserved_quantity_from' => 1, 'available_stock_quantity_from' => 5],
  79. ]))
  80. ->assertOk()
  81. ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === ['STOCK-LARGE']);
  82. $this->actingAs($this->manager)
  83. ->get(route('stock.index', [
  84. 'sortBy' => 'physical_quantity',
  85. 'order' => 'desc',
  86. ]))
  87. ->assertOk()
  88. ->assertViewHas('items', fn ($items): bool => $items->pluck('article')->all() === [
  89. 'STOCK-LARGE',
  90. 'STOCK-SMALL',
  91. ]);
  92. }
  93. public function test_stock_column_filter_values_are_loaded_lazily(): void
  94. {
  95. $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-FILTER-VALUE']);
  96. $this->createOrder($item, 'FILTER-ORDER', 1);
  97. $this->actingAs($this->manager)
  98. ->getJson(route('getFilters', [
  99. 'table' => 'stock_availability',
  100. 'column' => 'article',
  101. ]))
  102. ->assertOk()
  103. ->assertJsonFragment(['STOCK-FILTER-VALUE']);
  104. $this->actingAs($this->manager)
  105. ->getJson(route('getFilters', [
  106. 'table' => 'stock_orders',
  107. 'column' => 'order_number',
  108. ]))
  109. ->assertForbidden();
  110. }
  111. public function test_orders_are_available_only_to_admin_and_assistant_head(): void
  112. {
  113. $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
  114. $this->actingAs($this->admin)->get(route('stock.orders'))->assertOk();
  115. $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
  116. }
  117. public function test_stock_orders_support_column_filters_and_related_sorting(): void
  118. {
  119. $firstItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-A']);
  120. $secondItem = CommonCatalogItem::factory()->create(['article' => 'ORDER-ARTICLE-B']);
  121. $firstOrder = $this->createOrder($firstItem, 'ORDER-A', 2, StockOrder::STATUS_ORDERED);
  122. $secondOrder = $this->createOrder($secondItem, 'ORDER-B', 5);
  123. $firstOrder->forceFill(['created_at' => '2026-01-10 10:00:00'])->save();
  124. $secondOrder->forceFill(['created_at' => '2026-02-10 10:00:00'])->save();
  125. $this->inventory->reserve($secondItem, $this->manager, 2, null, $this->admin);
  126. $this->actingAs($this->admin)
  127. ->get(route('stock.orders', [
  128. 'filters' => ['status_name' => 'Заказан'],
  129. ]))
  130. ->assertOk()
  131. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-A']);
  132. $this->actingAs($this->admin)
  133. ->get(route('stock.orders', [
  134. 'filters' => ['ordered_quantity_from' => 4],
  135. ]))
  136. ->assertOk()
  137. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
  138. $this->actingAs($this->admin)
  139. ->get(route('stock.orders', [
  140. 'filters' => ['created_at_from' => '2026-02-01', 'created_at_to' => '2026-02-28'],
  141. ]))
  142. ->assertOk()
  143. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
  144. $this->actingAs($this->admin)
  145. ->get(route('stock.orders', [
  146. 'filters' => ['created_at' => '2026-02-10'],
  147. ]))
  148. ->assertOk()
  149. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B'])
  150. ->assertSee('id="filter-list-created_at"', false)
  151. ->assertDontSee('id="range_from_created_at"', false);
  152. $this->actingAs($this->admin)
  153. ->get(route('stock.orders', [
  154. 'filters' => ['reserved_quantity_from' => 1, 'free_quantity_from' => 3],
  155. ]))
  156. ->assertOk()
  157. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === ['ORDER-B']);
  158. $this->actingAs($this->admin)
  159. ->get(route('stock.orders', [
  160. 'sortBy' => 'item_article',
  161. 'order' => 'desc',
  162. ]))
  163. ->assertOk()
  164. ->assertViewHas('orders', fn ($orders): bool => $orders->pluck('order_number')->all() === [
  165. 'ORDER-B',
  166. 'ORDER-A',
  167. ]);
  168. $this->actingAs($this->admin)
  169. ->getJson(route('getFilters', [
  170. 'table' => 'stock_orders',
  171. 'column' => 'status_name',
  172. ]))
  173. ->assertOk()
  174. ->assertExactJson(['Заказан', 'На складе']);
  175. $this->actingAs($this->admin)
  176. ->getJson(route('getFilters', [
  177. 'table' => 'stock_orders',
  178. 'column' => 'created_at',
  179. ]))
  180. ->assertOk()
  181. ->assertExactJson(['2026-01-10', '2026-02-10']);
  182. }
  183. public function test_admin_can_create_order_linked_to_common_catalog(): void
  184. {
  185. $item = CommonCatalogItem::factory()->create();
  186. $this->actingAs($this->admin)->post(route('stock.orders.store'), [
  187. 'common_catalog_item_id' => $item->id,
  188. 'order_number' => 'WH-100',
  189. 'status' => StockOrder::STATUS_IN_STOCK,
  190. 'ordered_quantity' => 8,
  191. 'note' => 'Первая партия',
  192. ])->assertRedirect();
  193. $this->assertDatabaseHas('stock_orders', [
  194. 'common_catalog_item_id' => $item->id,
  195. 'order_number' => 'WH-100',
  196. 'available_quantity' => 8,
  197. ]);
  198. $this->assertDatabaseHas('stock_movements', [
  199. 'common_catalog_item_id' => $item->id,
  200. 'movement_type' => StockMovement::TYPE_RECEIPT,
  201. 'quantity' => 8,
  202. ]);
  203. }
  204. public function test_ordered_status_does_not_increase_physical_stock_until_receipt(): void
  205. {
  206. $item = CommonCatalogItem::factory()->create();
  207. $order = $this->createOrder($item, 'WH-ORDERED', 9, StockOrder::STATUS_ORDERED);
  208. $this->assertSame([
  209. 'physical' => 0,
  210. 'reserved' => 0,
  211. 'available' => 0,
  212. 'ordered' => 9,
  213. ], $this->inventory->summary($item));
  214. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  215. 'common_catalog_item_id' => $item->id,
  216. 'order_number' => $order->order_number,
  217. 'status' => StockOrder::STATUS_IN_STOCK,
  218. 'ordered_quantity' => 9,
  219. 'note' => null,
  220. ])->assertRedirect();
  221. $this->assertSame(9, $this->inventory->summary($item)['available']);
  222. }
  223. public function test_editing_order_casts_form_foreign_key_before_creating_receipt_movement(): void
  224. {
  225. $item = CommonCatalogItem::factory()->create();
  226. $order = $this->inventory->createOrder([
  227. 'common_catalog_item_id' => (string) $item->id,
  228. 'order_number' => 'WH-STRING-ID',
  229. 'status' => StockOrder::STATUS_ORDERED,
  230. 'ordered_quantity' => '6',
  231. 'note' => null,
  232. ], $this->admin);
  233. $this->assertIsInt($order->common_catalog_item_id);
  234. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  235. 'common_catalog_item_id' => (string) $item->id,
  236. 'order_number' => $order->order_number,
  237. 'status' => StockOrder::STATUS_IN_STOCK,
  238. 'ordered_quantity' => '6',
  239. 'note' => null,
  240. ])->assertRedirect()->assertSessionHasNoErrors();
  241. $this->assertDatabaseHas('stock_movements', [
  242. 'stock_order_id' => $order->id,
  243. 'common_catalog_item_id' => $item->id,
  244. 'movement_type' => StockMovement::TYPE_RECEIPT,
  245. 'quantity' => 6,
  246. ]);
  247. }
  248. public function test_admin_can_set_all_ordered_positions_with_same_number_in_stock(): void
  249. {
  250. $firstItem = CommonCatalogItem::factory()->create();
  251. $secondItem = CommonCatalogItem::factory()->create();
  252. $alreadyReceivedItem = CommonCatalogItem::factory()->create();
  253. $unrelatedItem = CommonCatalogItem::factory()->create();
  254. $first = $this->createOrder($firstItem, 'WH-BULK', 2, StockOrder::STATUS_ORDERED);
  255. $second = $this->createOrder($secondItem, 'WH-BULK', 3, StockOrder::STATUS_ORDERED);
  256. $alreadyReceived = $this->createOrder($alreadyReceivedItem, 'WH-BULK', 4);
  257. $unrelated = $this->createOrder($unrelatedItem, 'WH-OTHER', 5, StockOrder::STATUS_ORDERED);
  258. $this->actingAs($this->admin)
  259. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
  260. ->assertRedirect()
  261. ->assertSessionHas('success', 'Позиции заказа переведены в статус «На складе»: 2.');
  262. $this->assertSame(StockOrder::STATUS_IN_STOCK, $first->refresh()->status);
  263. $this->assertSame(2, $first->available_quantity);
  264. $this->assertSame(StockOrder::STATUS_IN_STOCK, $second->refresh()->status);
  265. $this->assertSame(3, $second->available_quantity);
  266. $this->assertSame(StockOrder::STATUS_IN_STOCK, $alreadyReceived->refresh()->status);
  267. $this->assertSame(StockOrder::STATUS_ORDERED, $unrelated->refresh()->status);
  268. $this->assertDatabaseHas('stock_movements', [
  269. 'stock_order_id' => $first->id,
  270. 'movement_type' => StockMovement::TYPE_RECEIPT,
  271. 'quantity' => 2,
  272. ]);
  273. $this->assertDatabaseHas('stock_movements', [
  274. 'stock_order_id' => $second->id,
  275. 'movement_type' => StockMovement::TYPE_RECEIPT,
  276. 'quantity' => 3,
  277. ]);
  278. $this->assertSame(1, StockMovement::query()->where('stock_order_id', $alreadyReceived->id)->count());
  279. }
  280. public function test_bulk_receipt_button_lists_only_ordered_order_numbers(): void
  281. {
  282. $orderedItem = CommonCatalogItem::factory()->create();
  283. $receivedItem = CommonCatalogItem::factory()->create();
  284. $this->createOrder($orderedItem, 'WH-AVAILABLE', 2, StockOrder::STATUS_ORDERED);
  285. $this->createOrder($receivedItem, 'WH-RECEIVED', 2);
  286. $response = $this->actingAs($this->admin)
  287. ->get(route('stock.orders'))
  288. ->assertOk()
  289. ->assertSeeText('Весь заказ на складе');
  290. $response->assertViewHas('orderedOrderNumbers', fn ($numbers): bool => $numbers->all() === ['WH-AVAILABLE']);
  291. }
  292. public function test_manager_cannot_set_whole_stock_order_in_stock(): void
  293. {
  294. $this->actingAs($this->manager)
  295. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
  296. ->assertForbidden();
  297. }
  298. public function test_bulk_receipt_reports_unknown_order_number(): void
  299. {
  300. $this->actingAs($this->admin)
  301. ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-MISSING'])
  302. ->assertSessionHasErrors([
  303. 'bulk_order_number' => 'Заказ со статусом «Заказан» не найден',
  304. ]);
  305. }
  306. public function test_reservation_is_all_or_nothing_and_cannot_exceed_available_stock(): void
  307. {
  308. $item = CommonCatalogItem::factory()->create();
  309. $this->createOrder($item, 'WH-1', 5);
  310. $this->actingAs($this->manager)->post(route('stock.reserve', $item), [
  311. 'manager_id' => $this->manager->id,
  312. 'quantity' => 6,
  313. 'note' => 'Объект 15',
  314. ])->assertSessionHasErrors(['quantity' => 'Такого кол-ва нет в наличии']);
  315. $this->assertDatabaseCount('stock_reservations', 0);
  316. $this->assertSame(5, $this->inventory->summary($item)['available']);
  317. }
  318. public function test_reservation_uses_fifo_orders_and_issue_and_cancel_keep_history(): void
  319. {
  320. $item = CommonCatalogItem::factory()->create();
  321. $first = $this->createOrder($item, 'WH-1', 5);
  322. $second = $this->createOrder($item, 'WH-2', 5);
  323. $reservations = $this->inventory->reserve($item, $this->manager, 7, 'Объект 21', $this->admin);
  324. $this->assertCount(2, $reservations);
  325. $this->assertSame([$first->id, $second->id], $reservations->pluck('stock_order_id')->all());
  326. $this->assertSame([5, 2], $reservations->pluck('quantity')->all());
  327. $this->assertSame(3, $this->inventory->summary($item)['available']);
  328. $this->inventory->issue($reservations[0], $this->admin);
  329. $first->refresh();
  330. $this->assertSame(StockOrder::STATUS_SHIPPED, $first->status);
  331. $this->assertSame(StockReservation::STATUS_ISSUED, $reservations[0]->refresh()->status);
  332. $this->assertSame(3, $this->inventory->summary($item)['available']);
  333. $this->inventory->cancel($reservations[1], $this->admin, 'Заказ клиента отменён');
  334. $this->assertSame(StockReservation::STATUS_CANCELLED, $reservations[1]->refresh()->status);
  335. $this->assertSame(5, $this->inventory->summary($item)['available']);
  336. $this->assertDatabaseHas('stock_movements', [
  337. 'stock_order_id' => $first->id,
  338. 'movement_type' => StockMovement::TYPE_ISSUE,
  339. 'quantity' => 5,
  340. ]);
  341. $this->assertDatabaseHas('stock_movements', [
  342. 'stock_order_id' => $second->id,
  343. 'movement_type' => StockMovement::TYPE_RESERVE_CANCEL,
  344. 'quantity' => 2,
  345. ]);
  346. }
  347. public function test_order_with_active_reservation_cannot_be_manually_shipped(): void
  348. {
  349. $item = CommonCatalogItem::factory()->create();
  350. $order = $this->createOrder($item, 'WH-LOCKED', 4);
  351. $this->inventory->reserve($item, $this->manager, 1, null, $this->admin);
  352. $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
  353. 'common_catalog_item_id' => $item->id,
  354. 'order_number' => $order->order_number,
  355. 'status' => StockOrder::STATUS_SHIPPED,
  356. 'ordered_quantity' => 4,
  357. 'note' => null,
  358. ])->assertSessionHas('error', 'Нельзя изменить статус заказа с активной бронью');
  359. $this->assertSame(StockOrder::STATUS_IN_STOCK, $order->refresh()->status);
  360. $this->assertSame(3, $this->inventory->summary($item)['available']);
  361. }
  362. public function test_stock_card_displays_orders_reservations_and_movement_history(): void
  363. {
  364. $item = CommonCatalogItem::factory()->create(['article' => 'CARD-001']);
  365. $this->createOrder($item, 'CARD-ORDER', 3);
  366. $this->inventory->reserve($item, $this->manager, 1, 'Бронь карточки', $this->admin);
  367. $this->actingAs($this->manager)
  368. ->get(route('stock.show', $item))
  369. ->assertOk()
  370. ->assertSeeText('CARD-001')
  371. ->assertSeeText('CARD-ORDER')
  372. ->assertSeeText('Бронь карточки')
  373. ->assertSeeText('Бронирование');
  374. }
  375. public function test_admin_can_queue_xlsx_import(): void
  376. {
  377. Bus::fake();
  378. Storage::fake('upload');
  379. $this->actingAs($this->admin)->post(route('stock.orders.import'), [
  380. 'import_file' => UploadedFile::fake()->create('stock.xlsx', 20, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
  381. ])->assertRedirect();
  382. $this->assertDatabaseHas('imports', [
  383. 'type' => 'stock_orders',
  384. 'original_filename' => 'stock.xlsx',
  385. ]);
  386. Bus::assertDispatched(ImportJob::class);
  387. }
  388. private function createOrder(
  389. CommonCatalogItem $item,
  390. string $number,
  391. int $quantity,
  392. string $status = StockOrder::STATUS_IN_STOCK,
  393. ): StockOrder {
  394. return $this->inventory->createOrder([
  395. 'common_catalog_item_id' => $item->id,
  396. 'order_number' => $number,
  397. 'status' => $status,
  398. 'ordered_quantity' => $quantity,
  399. 'note' => null,
  400. ], $this->admin);
  401. }
  402. }