From 5ec201b98591790dfdf22d7223a108cf9551b8a6 Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 26 Dec 2025 15:45:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20H-2=20=EC=9E=AC=EA=B3=A0=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Controllers/Api/V1/StockController.php | 71 +++++ app/Models/Tenants/Stock.php | 147 +++++++++ app/Models/Tenants/StockLot.php | 111 +++++++ app/Services/StockService.php | 137 +++++++++ app/Swagger/v1/StockApi.php | 284 ++++++++++++++++++ .../2025_12_26_132806_create_stocks_table.php | 74 +++++ ...5_12_26_132842_create_stock_lots_table.php | 76 +++++ 7 files changed, 900 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/StockController.php create mode 100644 app/Models/Tenants/Stock.php create mode 100644 app/Models/Tenants/StockLot.php create mode 100644 app/Services/StockService.php create mode 100644 app/Swagger/v1/StockApi.php create mode 100644 database/migrations/2025_12_26_132806_create_stocks_table.php create mode 100644 database/migrations/2025_12_26_132842_create_stock_lots_table.php diff --git a/app/Http/Controllers/Api/V1/StockController.php b/app/Http/Controllers/Api/V1/StockController.php new file mode 100644 index 0000000..f7cc586 --- /dev/null +++ b/app/Http/Controllers/Api/V1/StockController.php @@ -0,0 +1,71 @@ +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')); + } +} diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php new file mode 100644 index 0000000..1d1b8f0 --- /dev/null +++ b/app/Models/Tenants/Stock.php @@ -0,0 +1,147 @@ + '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(); + } +} diff --git a/app/Models/Tenants/StockLot.php b/app/Models/Tenants/StockLot.php new file mode 100644 index 0000000..f0c854e --- /dev/null +++ b/app/Models/Tenants/StockLot.php @@ -0,0 +1,111 @@ + '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(); + } +} diff --git a/app/Services/StockService.php b/app/Services/StockService.php new file mode 100644 index 0000000..ba01a5a --- /dev/null +++ b/app/Services/StockService.php @@ -0,0 +1,137 @@ +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; + } +} diff --git a/app/Swagger/v1/StockApi.php b/app/Swagger/v1/StockApi.php new file mode 100644 index 0000000..a71627a --- /dev/null +++ b/app/Swagger/v1/StockApi.php @@ -0,0 +1,284 @@ +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'); + } +}; diff --git a/database/migrations/2025_12_26_132842_create_stock_lots_table.php b/database/migrations/2025_12_26_132842_create_stock_lots_table.php new file mode 100644 index 0000000..3d20f37 --- /dev/null +++ b/database/migrations/2025_12_26_132842_create_stock_lots_table.php @@ -0,0 +1,76 @@ +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'); + } +};