feat: H-2 재고 현황 API 구현

- StockController: 재고 조회 및 통계 API
- StockService: 재고 비즈니스 로직
- Stock, StockLot 모델: 재고/로트 관리
- Swagger 문서화
- stocks, stock_lots 테이블 마이그레이션

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-26 15:45:48 +09:00
parent 43ccd1e6e0
commit 5ec201b985
7 changed files with 900 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\StockService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class StockController extends Controller
{
public function __construct(
private readonly StockService $service
) {}
/**
* 재고 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->only([
'search',
'item_type',
'status',
'location',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$stocks = $this->service->index($params);
return ApiResponse::success($stocks, __('message.fetched'));
}
/**
* 재고 통계 조회
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 재고 상세 조회 (LOT 포함)
*/
public function show(int $id): JsonResponse
{
try {
$stock = $this->service->show($id);
return ApiResponse::success($stock, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.stock.not_found'), 404);
}
}
/**
* 품목유형별 통계 조회
*/
public function statsByItemType(): JsonResponse
{
$stats = $this->service->statsByItemType();
return ApiResponse::success($stats, __('message.fetched'));
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Stock extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'item_code',
'item_name',
'item_type',
'specification',
'unit',
'stock_qty',
'safety_stock',
'reserved_qty',
'available_qty',
'lot_count',
'oldest_lot_date',
'location',
'status',
'last_receipt_date',
'last_issue_date',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'stock_qty' => 'decimal:3',
'safety_stock' => 'decimal:3',
'reserved_qty' => 'decimal:3',
'available_qty' => 'decimal:3',
'lot_count' => 'integer',
'oldest_lot_date' => 'date',
'last_receipt_date' => 'date',
'last_issue_date' => 'date',
];
/**
* 품목 유형 목록
*/
public const ITEM_TYPES = [
'raw_material' => '원자재',
'bent_part' => '절곡부품',
'purchased_part' => '구매부품',
'sub_material' => '부자재',
'consumable' => '소모품',
];
/**
* 재고 상태 목록
*/
public const STATUSES = [
'normal' => '정상',
'low' => '부족',
'out' => '없음',
];
/**
* LOT 관계
*/
public function lots(): HasMany
{
return $this->hasMany(StockLot::class)->orderBy('fifo_order');
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
}
/**
* 품목유형 라벨
*/
public function getItemTypeLabelAttribute(): string
{
return self::ITEM_TYPES[$this->item_type] ?? $this->item_type;
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* 경과일 계산 (가장 오래된 LOT 기준)
*/
public function getDaysElapsedAttribute(): int
{
if (! $this->oldest_lot_date) {
return 0;
}
return $this->oldest_lot_date->diffInDays(now());
}
/**
* 재고 상태 계산
*/
public function calculateStatus(): string
{
if ($this->stock_qty <= 0) {
return 'out';
}
if ($this->stock_qty < $this->safety_stock) {
return 'low';
}
return 'normal';
}
/**
* 재고 정보 업데이트 (LOT 기반)
*/
public function refreshFromLots(): void
{
$lots = $this->lots()->where('status', '!=', 'used')->get();
$this->lot_count = $lots->count();
$this->stock_qty = $lots->sum('qty');
$this->reserved_qty = $lots->sum('reserved_qty');
$this->available_qty = $lots->sum('available_qty');
$oldestLot = $lots->sortBy('receipt_date')->first();
$this->oldest_lot_date = $oldestLot?->receipt_date;
$this->last_receipt_date = $lots->max('receipt_date');
$this->status = $this->calculateStatus();
$this->save();
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class StockLot extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'stock_id',
'lot_no',
'fifo_order',
'receipt_date',
'qty',
'reserved_qty',
'available_qty',
'unit',
'supplier',
'supplier_lot',
'po_number',
'location',
'status',
'receiving_id',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'fifo_order' => 'integer',
'receipt_date' => 'date',
'qty' => 'decimal:3',
'reserved_qty' => 'decimal:3',
'available_qty' => 'decimal:3',
'stock_id' => 'integer',
'receiving_id' => 'integer',
];
/**
* LOT 상태 목록
*/
public const STATUSES = [
'available' => '사용가능',
'reserved' => '예약됨',
'used' => '사용완료',
];
/**
* 재고 관계
*/
public function stock(): BelongsTo
{
return $this->belongsTo(Stock::class);
}
/**
* 입고 관계
*/
public function receiving(): BelongsTo
{
return $this->belongsTo(Receiving::class);
}
/**
* 생성자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(\App\Models\Members\User::class, 'created_by');
}
/**
* 상태 라벨
*/
public function getStatusLabelAttribute(): string
{
return self::STATUSES[$this->status] ?? $this->status;
}
/**
* 경과일
*/
public function getDaysElapsedAttribute(): int
{
return $this->receipt_date->diffInDays(now());
}
/**
* 가용 수량 업데이트
*/
public function updateAvailableQty(): void
{
$this->available_qty = $this->qty - $this->reserved_qty;
if ($this->available_qty <= 0 && $this->qty <= 0) {
$this->status = 'used';
} elseif ($this->reserved_qty > 0) {
$this->status = 'reserved';
} else {
$this->status = 'available';
}
$this->save();
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services;
use App\Models\Tenants\Stock;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class StockService extends Service
{
/**
* 재고 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Stock::query()
->where('tenant_id', $tenantId);
// 검색어 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('item_code', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%");
});
}
// 품목유형 필터
if (! empty($params['item_type'])) {
$query->where('item_type', $params['item_type']);
}
// 재고 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 위치 필터
if (! empty($params['location'])) {
$query->where('location', 'like', "%{$params['location']}%");
}
// 정렬
$sortBy = $params['sort_by'] ?? 'item_code';
$sortDir = $params['sort_dir'] ?? 'asc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 재고 통계 조회
*/
public function stats(): array
{
$tenantId = $this->tenantId();
$totalItems = Stock::where('tenant_id', $tenantId)->count();
$normalCount = Stock::where('tenant_id', $tenantId)
->where('status', 'normal')
->count();
$lowCount = Stock::where('tenant_id', $tenantId)
->where('status', 'low')
->count();
$outCount = Stock::where('tenant_id', $tenantId)
->where('status', 'out')
->count();
return [
'total_items' => $totalItems,
'normal_count' => $normalCount,
'low_count' => $lowCount,
'out_count' => $outCount,
];
}
/**
* 재고 상세 조회 (LOT 포함)
*/
public function show(int $id): Stock
{
$tenantId = $this->tenantId();
return Stock::query()
->where('tenant_id', $tenantId)
->with(['lots' => function ($query) {
$query->orderBy('fifo_order');
}])
->findOrFail($id);
}
/**
* 품목코드로 재고 조회
*/
public function findByItemCode(string $itemCode): ?Stock
{
$tenantId = $this->tenantId();
return Stock::query()
->where('tenant_id', $tenantId)
->where('item_code', $itemCode)
->first();
}
/**
* 품목유형별 통계
*/
public function statsByItemType(): array
{
$tenantId = $this->tenantId();
$stats = Stock::where('tenant_id', $tenantId)
->selectRaw('item_type, COUNT(*) as count, SUM(stock_qty) as total_qty')
->groupBy('item_type')
->get()
->keyBy('item_type');
$result = [];
foreach (Stock::ITEM_TYPES as $key => $label) {
$data = $stats->get($key);
$result[$key] = [
'label' => $label,
'count' => $data?->count ?? 0,
'total_qty' => $data?->total_qty ?? 0,
];
}
return $result;
}
}

284
app/Swagger/v1/StockApi.php Normal file
View File

@@ -0,0 +1,284 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Stocks", description="재고 현황")
*
* @OA\Schema(
* schema="Stock",
* type="object",
* description="재고 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="재고 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="item_code", type="string", example="ITEM-001", description="품목코드"),
* @OA\Property(property="item_name", type="string", example="원재료 A", description="품목명"),
* @OA\Property(property="item_type", type="string", enum={"raw_material","bent_part","purchased_part","sub_material","consumable"}, example="raw_material", description="품목유형"),
* @OA\Property(property="item_type_label", type="string", example="원자재", description="품목유형 라벨"),
* @OA\Property(property="specification", type="string", example="100x100mm", nullable=true, description="규격"),
* @OA\Property(property="unit", type="string", example="EA", description="단위"),
* @OA\Property(property="stock_qty", type="number", format="float", example=150.5, description="현재 재고량"),
* @OA\Property(property="safety_stock", type="number", format="float", example=50.0, description="안전 재고"),
* @OA\Property(property="reserved_qty", type="number", format="float", example=20.0, description="예약 수량"),
* @OA\Property(property="available_qty", type="number", format="float", example=130.5, description="가용 수량"),
* @OA\Property(property="lot_count", type="integer", example=3, description="LOT 개수"),
* @OA\Property(property="oldest_lot_date", type="string", format="date", example="2025-11-15", nullable=true, description="가장 오래된 LOT 입고일"),
* @OA\Property(property="days_elapsed", type="integer", example=41, description="경과일 (가장 오래된 LOT 기준)"),
* @OA\Property(property="location", type="string", example="A-01-01", nullable=true, description="위치"),
* @OA\Property(property="status", type="string", enum={"normal","low","out"}, example="normal", description="상태"),
* @OA\Property(property="status_label", type="string", example="정상", description="상태 라벨"),
* @OA\Property(property="last_receipt_date", type="string", format="date", example="2025-12-20", nullable=true, description="마지막 입고일"),
* @OA\Property(property="last_issue_date", type="string", format="date", example="2025-12-24", nullable=true, description="마지막 출고일"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="StockLot",
* type="object",
* description="재고 LOT 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="LOT ID"),
* @OA\Property(property="stock_id", type="integer", example=1, description="재고 ID"),
* @OA\Property(property="lot_no", type="string", example="251226-01", description="LOT번호"),
* @OA\Property(property="fifo_order", type="integer", example=1, description="FIFO 순서"),
* @OA\Property(property="receipt_date", type="string", format="date", example="2025-12-26", description="입고일"),
* @OA\Property(property="qty", type="number", format="float", example=50.0, description="수량"),
* @OA\Property(property="reserved_qty", type="number", format="float", example=10.0, description="예약 수량"),
* @OA\Property(property="available_qty", type="number", format="float", example=40.0, description="가용 수량"),
* @OA\Property(property="unit", type="string", example="EA", nullable=true, description="단위"),
* @OA\Property(property="supplier", type="string", example="(주)공급사", nullable=true, description="공급업체"),
* @OA\Property(property="supplier_lot", type="string", example="SUP-LOT-001", nullable=true, description="공급업체 LOT"),
* @OA\Property(property="po_number", type="string", example="PO-2025-001", nullable=true, description="발주번호"),
* @OA\Property(property="location", type="string", example="A-01-01", nullable=true, description="위치"),
* @OA\Property(property="status", type="string", enum={"available","reserved","used"}, example="available", description="상태"),
* @OA\Property(property="status_label", type="string", example="사용가능", description="상태 라벨"),
* @OA\Property(property="days_elapsed", type="integer", example=1, description="경과일"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="StockWithLots",
* type="object",
* description="재고 상세 정보 (LOT 포함)",
* allOf={
*
* @OA\Schema(ref="#/components/schemas/Stock"),
* @OA\Schema(
*
* @OA\Property(
* property="lots",
* type="array",
* description="LOT 목록 (FIFO 순서)",
*
* @OA\Items(ref="#/components/schemas/StockLot")
* )
* )
* }
* )
*
* @OA\Schema(
* schema="StockStats",
* type="object",
* description="재고 통계",
*
* @OA\Property(property="total_items", type="integer", example=150, description="전체 품목 수"),
* @OA\Property(property="normal_count", type="integer", example=120, description="정상 재고 품목 수"),
* @OA\Property(property="low_count", type="integer", example=20, description="부족 재고 품목 수"),
* @OA\Property(property="out_count", type="integer", example=10, description="재고 없음 품목 수")
* )
*
* @OA\Schema(
* schema="StockStatsByItemType",
* type="object",
* description="품목유형별 재고 통계",
*
* @OA\Property(
* property="raw_material",
* type="object",
*
* @OA\Property(property="label", type="string", example="원자재"),
* @OA\Property(property="count", type="integer", example=50),
* @OA\Property(property="total_qty", type="number", format="float", example=5000.0)
* ),
*
* @OA\Property(
* property="bent_part",
* type="object",
*
* @OA\Property(property="label", type="string", example="절곡부품"),
* @OA\Property(property="count", type="integer", example=30),
* @OA\Property(property="total_qty", type="number", format="float", example=2500.0)
* ),
*
* @OA\Property(
* property="purchased_part",
* type="object",
*
* @OA\Property(property="label", type="string", example="구매부품"),
* @OA\Property(property="count", type="integer", example=40),
* @OA\Property(property="total_qty", type="number", format="float", example=3000.0)
* ),
*
* @OA\Property(
* property="sub_material",
* type="object",
*
* @OA\Property(property="label", type="string", example="부자재"),
* @OA\Property(property="count", type="integer", example=20),
* @OA\Property(property="total_qty", type="number", format="float", example=1500.0)
* ),
*
* @OA\Property(
* property="consumable",
* type="object",
*
* @OA\Property(property="label", type="string", example="소모품"),
* @OA\Property(property="count", type="integer", example=10),
* @OA\Property(property="total_qty", type="number", format="float", example=500.0)
* )
* )
*/
class StockApi
{
/**
* @OA\Get(
* path="/api/v1/stocks",
* tags={"Stocks"},
* summary="재고 목록 조회",
* description="재고 현황 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="search", in="query", description="검색어 (품목코드, 품목명)", @OA\Schema(type="string")),
* @OA\Parameter(name="item_type", in="query", description="품목유형", @OA\Schema(type="string", enum={"raw_material","bent_part","purchased_part","sub_material","consumable"})),
* @OA\Parameter(name="status", in="query", description="재고상태", @OA\Schema(type="string", enum={"normal","low","out"})),
* @OA\Parameter(name="location", in="query", description="위치 (부분 일치)", @OA\Schema(type="string")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"item_code","item_name","stock_qty","oldest_lot_date"}, default="item_code")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="asc")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(
* property="data",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Stock")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=150)
* )
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/stocks/stats",
* tags={"Stocks"},
* summary="재고 통계 조회",
* description="전체 재고 현황 통계를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/StockStats")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function stats() {}
/**
* @OA\Get(
* path="/api/v1/stocks/stats-by-type",
* tags={"Stocks"},
* summary="품목유형별 재고 통계 조회",
* description="품목유형별 재고 현황 통계를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/StockStatsByItemType")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function statsByItemType() {}
/**
* @OA\Get(
* path="/api/v1/stocks/{id}",
* tags={"Stocks"},
* summary="재고 상세 조회",
* description="재고 상세 정보를 조회합니다. LOT 목록이 FIFO 순서로 포함됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="재고 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/StockWithLots")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="재고 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
}

View File

@@ -0,0 +1,74 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 재고 현황 테이블
*
* 품목별 재고 현황을 관리합니다.
* 입고/출고 시 트리거 또는 이벤트로 자동 업데이트됩니다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stocks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
// 품목 정보
$table->string('item_code', 50)->comment('품목코드');
$table->string('item_name', 200)->comment('품목명');
$table->string('item_type', 30)->default('raw_material')
->comment('품목유형: raw_material, bent_part, purchased_part, sub_material, consumable');
$table->string('specification', 200)->nullable()->comment('규격');
$table->string('unit', 20)->default('EA')->comment('단위');
// 재고 수량
$table->decimal('stock_qty', 15, 3)->default(0)->comment('현재 재고량');
$table->decimal('safety_stock', 15, 3)->default(0)->comment('안전 재고');
$table->decimal('reserved_qty', 15, 3)->default(0)->comment('예약 수량');
$table->decimal('available_qty', 15, 3)->default(0)->comment('가용 재고량');
// LOT 정보
$table->unsignedInteger('lot_count')->default(0)->comment('LOT 개수');
$table->date('oldest_lot_date')->nullable()->comment('가장 오래된 LOT 입고일');
// 위치 및 상태
$table->string('location', 50)->nullable()->comment('재고 위치');
$table->string('status', 20)->default('normal')
->comment('상태: normal(정상), low(부족), out(없음)');
// 최근 입고/출고 정보
$table->date('last_receipt_date')->nullable()->comment('최근 입고일');
$table->date('last_issue_date')->nullable()->comment('최근 출고일');
// 감사 정보
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->timestamps();
$table->softDeletes();
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
// 인덱스
$table->index('tenant_id');
$table->index('item_code');
$table->index('item_type');
$table->index('status');
$table->unique(['tenant_id', 'item_code']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stocks');
}
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 재고 LOT 테이블
*
* 재고의 LOT별 상세 정보를 관리합니다.
* FIFO 기반 출고 관리에 사용됩니다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stock_lots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('stock_id')->comment('재고 ID');
// LOT 정보
$table->string('lot_no', 50)->comment('LOT번호');
$table->unsignedInteger('fifo_order')->default(1)->comment('FIFO 순서');
$table->date('receipt_date')->comment('입고일');
// 수량 정보
$table->decimal('qty', 15, 3)->default(0)->comment('수량');
$table->decimal('reserved_qty', 15, 3)->default(0)->comment('예약 수량');
$table->decimal('available_qty', 15, 3)->default(0)->comment('가용 수량');
$table->string('unit', 20)->default('EA')->comment('단위');
// 공급업체 정보
$table->string('supplier', 100)->nullable()->comment('공급업체');
$table->string('supplier_lot', 50)->nullable()->comment('공급업체 LOT');
$table->string('po_number', 50)->nullable()->comment('발주번호');
// 위치 및 상태
$table->string('location', 50)->nullable()->comment('위치');
$table->string('status', 20)->default('available')
->comment('상태: available(사용가능), reserved(예약됨), used(사용완료)');
// 연결 정보
$table->unsignedBigInteger('receiving_id')->nullable()->comment('입고 ID');
// 감사 정보
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->timestamps();
$table->softDeletes();
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
// 인덱스
$table->index('tenant_id');
$table->index('stock_id');
$table->index('lot_no');
$table->index('status');
$table->index(['stock_id', 'fifo_order']);
$table->unique(['tenant_id', 'stock_id', 'lot_no']);
// 외래키
$table->foreign('stock_id')->references('id')->on('stocks')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stock_lots');
}
};