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:
71
app/Http/Controllers/Api/V1/StockController.php
Normal file
71
app/Http/Controllers/Api/V1/StockController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
147
app/Models/Tenants/Stock.php
Normal file
147
app/Models/Tenants/Stock.php
Normal 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();
|
||||
}
|
||||
}
|
||||
111
app/Models/Tenants/StockLot.php
Normal file
111
app/Models/Tenants/StockLot.php
Normal 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();
|
||||
}
|
||||
}
|
||||
137
app/Services/StockService.php
Normal file
137
app/Services/StockService.php
Normal 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
284
app/Swagger/v1/StockApi.php
Normal 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() {}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user