test: [stock] 재고 API 및 FIFO 로직 테스트 13개 추가
- Stock API 엔드포인트 테스트 5개 (목록/통계/유형별통계/상세/인증) - FIFO 핵심 로직 테스트 4개 (입고/차감/LOT걸침/재고부족) - 예약/해제 테스트 2개 (수주확정→예약, 수주취소→해제) - 거래이력 기록 테스트 1개 - 재고 상태 자동 계산 테스트 1개 (normal/low/out) - Factory 2개 추가: StockFactory, StockLotFactory
This commit is contained in:
31
database/factories/StockFactory.php
Normal file
31
database/factories/StockFactory.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenants\Stock;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Stock>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
28
database/factories/StockLotFactory.php
Normal file
28
database/factories/StockLotFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Tenants\StockLot;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<StockLot>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
272
tests/Feature/Inventory/StockApiTest.php
Normal file
272
tests/Feature/Inventory/StockApiTest.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user