Files
sam-api/tests/Feature/Inventory/StockApiTest.php
김보곤 95bae11042 test: [stock] 재고 API 및 FIFO 로직 테스트 13개 추가
- Stock API 엔드포인트 테스트 5개 (목록/통계/유형별통계/상세/인증)
- FIFO 핵심 로직 테스트 4개 (입고/차감/LOT걸침/재고부족)
- 예약/해제 테스트 2개 (수주확정→예약, 수주취소→해제)
- 거래이력 기록 테스트 1개
- 재고 상태 자동 계산 테스트 1개 (normal/low/out)
- Factory 2개 추가: StockFactory, StockLotFactory
2026-03-14 14:42:22 +09:00

273 lines
8.6 KiB
PHP

<?php
namespace Tests\Feature\Inventory;
use App\Models\Tenants\Stock;
use App\Models\Tenants\StockLot;
use App\Models\Tenants\StockTransaction;
use App\Services\StockService;
use Tests\TestCase;
class StockApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}