setUpAuthenticatedUser(); } // ==================== API 엔드포인트 ==================== public function test_재고_목록_조회(): void { $this->createStockWithLot(50); $response = $this->api('get', '/api/v1/stocks'); $this->assertApiPaginated($response); } public function test_재고_통계_조회(): void { // 정상 재고 $this->createStockWithLot(50, 10); // 부족 재고 $this->createStockWithLot(5, 10); // 빈 재고 $this->createStock(0, 10); $response = $this->api('get', '/api/v1/stocks/stats'); $response->assertStatus(200) ->assertJsonStructure(['success', 'data']); } public function test_품목유형별_통계_조회(): void { $this->createStockWithLot(30, 10, 'raw_material'); $this->createStockWithLot(20, 5, 'purchased_part'); $response = $this->api('get', '/api/v1/stocks/stats-by-type'); $response->assertStatus(200) ->assertJsonStructure(['success', 'data']); } public function test_재고_상세_존재하지_않는_품목_404(): void { // Stock API는 Item 기준 조회 → 존재하지 않는 Item ID는 404 $response = $this->api('get', '/api/v1/stocks/999999'); $response->assertStatus(404); } public function test_미인증_요청시_401(): void { $response = $this->withHeaders([ 'X-API-KEY' => $this->apiKey, 'Accept' => 'application/json', ])->getJson('/api/v1/stocks'); $response->assertStatus(401); } // ==================== FIFO 핵심 로직 ==================== public function test_FIFO_입고시_LOT_생성_및_순서_할당(): void { $stock = $this->createStock(0, 10); // LOT 1 생성 (fifo_order = 1) $lot1 = $this->addLot($stock, 30, 1); // LOT 2 생성 (fifo_order = 2) $lot2 = $this->addLot($stock, 20, 2); $stock->refresh(); $stock->refreshFromLots(); $this->assertEquals(50, $stock->stock_qty); $this->assertEquals(50, $stock->available_qty); $this->assertEquals(2, $stock->lot_count); $this->assertEquals('normal', $stock->status); } public function test_FIFO_차감시_오래된_LOT부터_감소(): void { $stock = $this->createStock(0, 10); $lot1 = $this->addLot($stock, 30, 1); // 먼저 입고 $lot2 = $this->addLot($stock, 20, 2); // 나중 입고 $stock->refreshFromLots(); // FIFO 차감: 25개 요청 → LOT1에서 25개 차감 $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); $result = $service->decreaseFIFO($stock->item_id, 25, 'shipment', 1); $lot1->refresh(); $lot2->refresh(); // LOT1: 30 → 5 (25 차감) $this->assertEquals(5, $lot1->qty); // LOT2: 변화 없음 $this->assertEquals(20, $lot2->qty); } public function test_FIFO_차감시_LOT_걸침_처리(): void { $stock = $this->createStock(0, 10); $lot1 = $this->addLot($stock, 10, 1); $lot2 = $this->addLot($stock, 20, 2); $stock->refreshFromLots(); // 15개 차감 → LOT1(10) 전부 + LOT2(5) 일부 $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); $result = $service->decreaseFIFO($stock->item_id, 15, 'shipment', 2); $lot1->refresh(); $lot2->refresh(); $this->assertEquals(0, $lot1->qty); $this->assertEquals(15, $lot2->qty); } public function test_재고_부족시_예외_발생(): void { $stock = $this->createStockWithLot(10); $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); $this->expectException(\Exception::class); $service->decreaseFIFO($stock->item_id, 999, 'shipment', 3); } // ==================== 예약/해제 ==================== public function test_수주_확정시_재고_예약(): void { $stock = $this->createStock(0, 10); $lot1 = $this->addLot($stock, 50, 1); $stock->refreshFromLots(); $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); $service->reserve($stock->item_id, 20, 100); // orderId=100 $lot1->refresh(); $stock->refresh(); $this->assertEquals(20, $lot1->reserved_qty); $this->assertEquals(30, $lot1->available_qty); $this->assertEquals(20, $stock->reserved_qty); } public function test_수주_취소시_예약_해제(): void { $stock = $this->createStock(0, 10); $lot1 = $this->addLot($stock, 50, 1); $stock->refreshFromLots(); $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); // 예약 $service->reserve($stock->item_id, 20, 100); // 해제 $service->releaseReservation($stock->item_id, 20, 100); $lot1->refresh(); $stock->refresh(); $this->assertEquals(0, $lot1->reserved_qty); $this->assertEquals(50, $lot1->available_qty); $this->assertEquals(0, $stock->reserved_qty); } // ==================== 거래 이력 ==================== public function test_차감시_거래이력_기록(): void { $stock = $this->createStockWithLot(50); $service = app(StockService::class); $service->setContext($this->tenant->id, $this->user->id); $service->decreaseFIFO($stock->item_id, 10, 'shipment', 5); $transaction = StockTransaction::where('stock_id', $stock->id) ->where('type', StockTransaction::TYPE_OUT) ->first(); $this->assertNotNull($transaction); $this->assertEquals(-10, $transaction->qty); $this->assertEquals('shipment', $transaction->reason); } // ==================== Stock 상태 자동 계산 ==================== public function test_재고_상태_자동_계산(): void { // 정상: stock_qty > safety_stock $stock = $this->createStockWithLot(50, 10); $this->assertEquals('normal', $stock->calculateStatus()); // 부족: 0 < stock_qty <= safety_stock $stock2 = $this->createStockWithLot(5, 10); $this->assertEquals('low', $stock2->calculateStatus()); // 없음: stock_qty = 0 $stock3 = $this->createStock(0, 10); $this->assertEquals('out', $stock3->calculateStatus()); } // ==================== 헬퍼 ==================== private function createStock(int $qty, int $safetyStock = 10, string $itemType = 'raw_material'): Stock { return Stock::create([ 'tenant_id' => $this->tenant->id, 'item_id' => rand(100000, 999999), 'item_code' => 'ITM-'.uniqid(), 'item_name' => '테스트 품목', 'item_type' => $itemType, 'unit' => 'EA', 'stock_qty' => $qty, 'safety_stock' => $safetyStock, 'reserved_qty' => 0, 'available_qty' => $qty, 'lot_count' => 0, 'status' => $qty > $safetyStock ? 'normal' : ($qty > 0 ? 'low' : 'out'), 'location' => 'A-01', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); } private function createStockWithLot(int $qty, int $safetyStock = 10, string $itemType = 'raw_material'): Stock { $stock = $this->createStock($qty, $safetyStock, $itemType); $this->addLot($stock, $qty, 1); $stock->refreshFromLots(); return $stock; } private function addLot(Stock $stock, int $qty, int $fifoOrder): StockLot { return StockLot::create([ 'tenant_id' => $this->tenant->id, 'stock_id' => $stock->id, 'lot_no' => 'LOT-'.uniqid(), 'fifo_order' => $fifoOrder, 'receipt_date' => now(), 'qty' => $qty, 'reserved_qty' => 0, 'available_qty' => $qty, 'unit' => 'EA', 'status' => 'available', 'created_by' => $this->user->id, 'updated_by' => $this->user->id, ]); } }