diff --git a/database/factories/StockFactory.php b/database/factories/StockFactory.php new file mode 100644 index 0000000..53b753e --- /dev/null +++ b/database/factories/StockFactory.php @@ -0,0 +1,31 @@ + + */ +class StockFactory extends Factory +{ + protected $model = Stock::class; + + public function definition(): array + { + return [ + 'item_code' => 'ITM-'.strtoupper(fake()->unique()->bothify('???###')), + 'item_name' => fake()->word().' 부품', + 'item_type' => 'raw_material', + 'unit' => 'EA', + 'stock_qty' => 0, + 'safety_stock' => 10, + 'reserved_qty' => 0, + 'available_qty' => 0, + 'lot_count' => 0, + 'status' => 'out', + 'location' => 'A-01', + ]; + } +} diff --git a/database/factories/StockLotFactory.php b/database/factories/StockLotFactory.php new file mode 100644 index 0000000..a4a5d00 --- /dev/null +++ b/database/factories/StockLotFactory.php @@ -0,0 +1,28 @@ + + */ +class StockLotFactory extends Factory +{ + protected $model = StockLot::class; + + public function definition(): array + { + return [ + 'lot_no' => 'LOT-'.fake()->unique()->numerify('######'), + 'fifo_order' => 1, + 'receipt_date' => now(), + 'qty' => fake()->numberBetween(10, 100), + 'reserved_qty' => 0, + 'available_qty' => 0, + 'unit' => 'EA', + 'status' => 'available', + ]; + } +} diff --git a/tests/Feature/Inventory/StockApiTest.php b/tests/Feature/Inventory/StockApiTest.php new file mode 100644 index 0000000..f0fc4d4 --- /dev/null +++ b/tests/Feature/Inventory/StockApiTest.php @@ -0,0 +1,272 @@ +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, + ]); + } +}