|
|
@@ -0,0 +1,326 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace Tests\Feature;
|
|
|
+
|
|
|
+use App\Jobs\Import\ImportJob;
|
|
|
+use App\Models\CommonCatalogItem;
|
|
|
+use App\Models\Role;
|
|
|
+use App\Models\StockMovement;
|
|
|
+use App\Models\StockOrder;
|
|
|
+use App\Models\StockReservation;
|
|
|
+use App\Models\User;
|
|
|
+use App\Services\StockInventoryService;
|
|
|
+use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
+use Illuminate\Http\UploadedFile;
|
|
|
+use Illuminate\Support\Facades\Bus;
|
|
|
+use Illuminate\Support\Facades\Storage;
|
|
|
+use Tests\TestCase;
|
|
|
+
|
|
|
+class StockControllerTest extends TestCase
|
|
|
+{
|
|
|
+ use RefreshDatabase;
|
|
|
+
|
|
|
+ protected bool $seed = true;
|
|
|
+
|
|
|
+ private User $admin;
|
|
|
+
|
|
|
+ private User $manager;
|
|
|
+
|
|
|
+ private User $assistant;
|
|
|
+
|
|
|
+ private StockInventoryService $inventory;
|
|
|
+
|
|
|
+ protected function setUp(): void
|
|
|
+ {
|
|
|
+ parent::setUp();
|
|
|
+
|
|
|
+ $this->admin = User::factory()->create(['role' => Role::ADMIN]);
|
|
|
+ $this->manager = User::factory()->create(['role' => Role::MANAGER]);
|
|
|
+ $this->assistant = User::factory()->create(['role' => Role::ASSISTANT_HEAD]);
|
|
|
+ $this->inventory = app(StockInventoryService::class);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_guest_cannot_open_stock(): void
|
|
|
+ {
|
|
|
+ $this->get(route('stock.index'))->assertRedirect(route('login'));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_availability_is_year_independent_and_visible_to_regular_users(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create(['article' => 'STOCK-001']);
|
|
|
+ $this->createOrder($item, 'A-1', 4);
|
|
|
+
|
|
|
+ $this->actingAs($this->manager)
|
|
|
+ ->withSession(['year' => 2019])
|
|
|
+ ->get(route('stock.index'))
|
|
|
+ ->assertOk()
|
|
|
+ ->assertViewIs('stock.index')
|
|
|
+ ->assertSeeText('Склад')
|
|
|
+ ->assertDontSeeText('Склад наличие')
|
|
|
+ ->assertSeeText('STOCK-001');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_orders_are_available_only_to_admin_and_assistant_head(): void
|
|
|
+ {
|
|
|
+ $this->actingAs($this->manager)->get(route('stock.orders'))->assertForbidden();
|
|
|
+ $this->actingAs($this->admin)->get(route('stock.orders'))->assertOk();
|
|
|
+ $this->actingAs($this->assistant)->get(route('stock.orders'))->assertOk();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_admin_can_create_order_linked_to_common_catalog(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)->post(route('stock.orders.store'), [
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'order_number' => 'WH-100',
|
|
|
+ 'status' => StockOrder::STATUS_IN_STOCK,
|
|
|
+ 'ordered_quantity' => 8,
|
|
|
+ 'note' => 'Первая партия',
|
|
|
+ ])->assertRedirect();
|
|
|
+
|
|
|
+ $this->assertDatabaseHas('stock_orders', [
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'order_number' => 'WH-100',
|
|
|
+ 'available_quantity' => 8,
|
|
|
+ ]);
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_RECEIPT,
|
|
|
+ 'quantity' => 8,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_ordered_status_does_not_increase_physical_stock_until_receipt(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+ $order = $this->createOrder($item, 'WH-ORDERED', 9, StockOrder::STATUS_ORDERED);
|
|
|
+
|
|
|
+ $this->assertSame([
|
|
|
+ 'physical' => 0,
|
|
|
+ 'reserved' => 0,
|
|
|
+ 'available' => 0,
|
|
|
+ 'ordered' => 9,
|
|
|
+ ], $this->inventory->summary($item));
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'order_number' => $order->order_number,
|
|
|
+ 'status' => StockOrder::STATUS_IN_STOCK,
|
|
|
+ 'ordered_quantity' => 9,
|
|
|
+ 'note' => null,
|
|
|
+ ])->assertRedirect();
|
|
|
+
|
|
|
+ $this->assertSame(9, $this->inventory->summary($item)['available']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_editing_order_casts_form_foreign_key_before_creating_receipt_movement(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+ $order = $this->inventory->createOrder([
|
|
|
+ 'common_catalog_item_id' => (string) $item->id,
|
|
|
+ 'order_number' => 'WH-STRING-ID',
|
|
|
+ 'status' => StockOrder::STATUS_ORDERED,
|
|
|
+ 'ordered_quantity' => '6',
|
|
|
+ 'note' => null,
|
|
|
+ ], $this->admin);
|
|
|
+
|
|
|
+ $this->assertIsInt($order->common_catalog_item_id);
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
|
|
|
+ 'common_catalog_item_id' => (string) $item->id,
|
|
|
+ 'order_number' => $order->order_number,
|
|
|
+ 'status' => StockOrder::STATUS_IN_STOCK,
|
|
|
+ 'ordered_quantity' => '6',
|
|
|
+ 'note' => null,
|
|
|
+ ])->assertRedirect()->assertSessionHasNoErrors();
|
|
|
+
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'stock_order_id' => $order->id,
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_RECEIPT,
|
|
|
+ 'quantity' => 6,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_admin_can_set_all_ordered_positions_with_same_number_in_stock(): void
|
|
|
+ {
|
|
|
+ $firstItem = CommonCatalogItem::factory()->create();
|
|
|
+ $secondItem = CommonCatalogItem::factory()->create();
|
|
|
+ $alreadyReceivedItem = CommonCatalogItem::factory()->create();
|
|
|
+ $unrelatedItem = CommonCatalogItem::factory()->create();
|
|
|
+ $first = $this->createOrder($firstItem, 'WH-BULK', 2, StockOrder::STATUS_ORDERED);
|
|
|
+ $second = $this->createOrder($secondItem, 'WH-BULK', 3, StockOrder::STATUS_ORDERED);
|
|
|
+ $alreadyReceived = $this->createOrder($alreadyReceivedItem, 'WH-BULK', 4);
|
|
|
+ $unrelated = $this->createOrder($unrelatedItem, 'WH-OTHER', 5, StockOrder::STATUS_ORDERED);
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)
|
|
|
+ ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
|
|
|
+ ->assertRedirect()
|
|
|
+ ->assertSessionHas('success', 'Позиции заказа переведены в статус «На складе»: 2.');
|
|
|
+
|
|
|
+ $this->assertSame(StockOrder::STATUS_IN_STOCK, $first->refresh()->status);
|
|
|
+ $this->assertSame(2, $first->available_quantity);
|
|
|
+ $this->assertSame(StockOrder::STATUS_IN_STOCK, $second->refresh()->status);
|
|
|
+ $this->assertSame(3, $second->available_quantity);
|
|
|
+ $this->assertSame(StockOrder::STATUS_IN_STOCK, $alreadyReceived->refresh()->status);
|
|
|
+ $this->assertSame(StockOrder::STATUS_ORDERED, $unrelated->refresh()->status);
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'stock_order_id' => $first->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_RECEIPT,
|
|
|
+ 'quantity' => 2,
|
|
|
+ ]);
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'stock_order_id' => $second->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_RECEIPT,
|
|
|
+ 'quantity' => 3,
|
|
|
+ ]);
|
|
|
+ $this->assertSame(1, StockMovement::query()->where('stock_order_id', $alreadyReceived->id)->count());
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_bulk_receipt_button_lists_only_ordered_order_numbers(): void
|
|
|
+ {
|
|
|
+ $orderedItem = CommonCatalogItem::factory()->create();
|
|
|
+ $receivedItem = CommonCatalogItem::factory()->create();
|
|
|
+ $this->createOrder($orderedItem, 'WH-AVAILABLE', 2, StockOrder::STATUS_ORDERED);
|
|
|
+ $this->createOrder($receivedItem, 'WH-RECEIVED', 2);
|
|
|
+
|
|
|
+ $response = $this->actingAs($this->admin)
|
|
|
+ ->get(route('stock.orders'))
|
|
|
+ ->assertOk()
|
|
|
+ ->assertSeeText('Весь заказ на складе');
|
|
|
+
|
|
|
+ $response->assertViewHas('orderedOrderNumbers', fn ($numbers): bool => $numbers->all() === ['WH-AVAILABLE']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_manager_cannot_set_whole_stock_order_in_stock(): void
|
|
|
+ {
|
|
|
+ $this->actingAs($this->manager)
|
|
|
+ ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-BULK'])
|
|
|
+ ->assertForbidden();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_bulk_receipt_reports_unknown_order_number(): void
|
|
|
+ {
|
|
|
+ $this->actingAs($this->admin)
|
|
|
+ ->post(route('stock.orders.set-in-stock'), ['bulk_order_number' => 'WH-MISSING'])
|
|
|
+ ->assertSessionHasErrors([
|
|
|
+ 'bulk_order_number' => 'Заказ со статусом «Заказан» не найден',
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_reservation_is_all_or_nothing_and_cannot_exceed_available_stock(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+ $this->createOrder($item, 'WH-1', 5);
|
|
|
+
|
|
|
+ $this->actingAs($this->manager)->post(route('stock.reserve', $item), [
|
|
|
+ 'manager_id' => $this->manager->id,
|
|
|
+ 'quantity' => 6,
|
|
|
+ 'note' => 'Объект 15',
|
|
|
+ ])->assertSessionHasErrors(['quantity' => 'Такого кол-ва нет в наличии']);
|
|
|
+
|
|
|
+ $this->assertDatabaseCount('stock_reservations', 0);
|
|
|
+ $this->assertSame(5, $this->inventory->summary($item)['available']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_reservation_uses_fifo_orders_and_issue_and_cancel_keep_history(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+ $first = $this->createOrder($item, 'WH-1', 5);
|
|
|
+ $second = $this->createOrder($item, 'WH-2', 5);
|
|
|
+
|
|
|
+ $reservations = $this->inventory->reserve($item, $this->manager, 7, 'Объект 21', $this->admin);
|
|
|
+
|
|
|
+ $this->assertCount(2, $reservations);
|
|
|
+ $this->assertSame([$first->id, $second->id], $reservations->pluck('stock_order_id')->all());
|
|
|
+ $this->assertSame([5, 2], $reservations->pluck('quantity')->all());
|
|
|
+ $this->assertSame(3, $this->inventory->summary($item)['available']);
|
|
|
+
|
|
|
+ $this->inventory->issue($reservations[0], $this->admin);
|
|
|
+ $first->refresh();
|
|
|
+ $this->assertSame(StockOrder::STATUS_SHIPPED, $first->status);
|
|
|
+ $this->assertSame(StockReservation::STATUS_ISSUED, $reservations[0]->refresh()->status);
|
|
|
+ $this->assertSame(3, $this->inventory->summary($item)['available']);
|
|
|
+
|
|
|
+ $this->inventory->cancel($reservations[1], $this->admin, 'Заказ клиента отменён');
|
|
|
+ $this->assertSame(StockReservation::STATUS_CANCELLED, $reservations[1]->refresh()->status);
|
|
|
+ $this->assertSame(5, $this->inventory->summary($item)['available']);
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'stock_order_id' => $first->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_ISSUE,
|
|
|
+ 'quantity' => 5,
|
|
|
+ ]);
|
|
|
+ $this->assertDatabaseHas('stock_movements', [
|
|
|
+ 'stock_order_id' => $second->id,
|
|
|
+ 'movement_type' => StockMovement::TYPE_RESERVE_CANCEL,
|
|
|
+ 'quantity' => 2,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_order_with_active_reservation_cannot_be_manually_shipped(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create();
|
|
|
+ $order = $this->createOrder($item, 'WH-LOCKED', 4);
|
|
|
+ $this->inventory->reserve($item, $this->manager, 1, null, $this->admin);
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)->put(route('stock.orders.update', $order), [
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'order_number' => $order->order_number,
|
|
|
+ 'status' => StockOrder::STATUS_SHIPPED,
|
|
|
+ 'ordered_quantity' => 4,
|
|
|
+ 'note' => null,
|
|
|
+ ])->assertSessionHas('error', 'Нельзя изменить статус заказа с активной бронью');
|
|
|
+
|
|
|
+ $this->assertSame(StockOrder::STATUS_IN_STOCK, $order->refresh()->status);
|
|
|
+ $this->assertSame(3, $this->inventory->summary($item)['available']);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_stock_card_displays_orders_reservations_and_movement_history(): void
|
|
|
+ {
|
|
|
+ $item = CommonCatalogItem::factory()->create(['article' => 'CARD-001']);
|
|
|
+ $this->createOrder($item, 'CARD-ORDER', 3);
|
|
|
+ $this->inventory->reserve($item, $this->manager, 1, 'Бронь карточки', $this->admin);
|
|
|
+
|
|
|
+ $this->actingAs($this->manager)
|
|
|
+ ->get(route('stock.show', $item))
|
|
|
+ ->assertOk()
|
|
|
+ ->assertSeeText('CARD-001')
|
|
|
+ ->assertSeeText('CARD-ORDER')
|
|
|
+ ->assertSeeText('Бронь карточки')
|
|
|
+ ->assertSeeText('Бронирование');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_admin_can_queue_xlsx_import(): void
|
|
|
+ {
|
|
|
+ Bus::fake();
|
|
|
+ Storage::fake('upload');
|
|
|
+
|
|
|
+ $this->actingAs($this->admin)->post(route('stock.orders.import'), [
|
|
|
+ 'import_file' => UploadedFile::fake()->create('stock.xlsx', 20, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
|
|
|
+ ])->assertRedirect();
|
|
|
+
|
|
|
+ $this->assertDatabaseHas('imports', [
|
|
|
+ 'type' => 'stock_orders',
|
|
|
+ 'original_filename' => 'stock.xlsx',
|
|
|
+ ]);
|
|
|
+ Bus::assertDispatched(ImportJob::class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function createOrder(
|
|
|
+ CommonCatalogItem $item,
|
|
|
+ string $number,
|
|
|
+ int $quantity,
|
|
|
+ string $status = StockOrder::STATUS_IN_STOCK,
|
|
|
+ ): StockOrder {
|
|
|
+ return $this->inventory->createOrder([
|
|
|
+ 'common_catalog_item_id' => $item->id,
|
|
|
+ 'order_number' => $number,
|
|
|
+ 'status' => $status,
|
|
|
+ 'ordered_quantity' => $quantity,
|
|
|
+ 'note' => null,
|
|
|
+ ], $this->admin);
|
|
|
+ }
|
|
|
+}
|