From a639e0fa60b042849ba6f8effef360c09c701d3f Mon Sep 17 00:00:00 2001 From: kent Date: Mon, 29 Dec 2025 15:31:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(SAM/API):=20Stock=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=97=90=20Item=20=EA=B4=80=EA=B3=84=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=9C=EB=8D=94=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stock 모델에 item_id 필드 및 item() 관계 추가 - StockService에 item eager loading 적용 - stocks 테이블 item_id 마이그레이션 추가 - StockReceivingSeeder 더미 데이터 시더 생성 - Process 모델 Traits 경로 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Models/Process.php | 4 +- app/Models/Tenants/Stock.php | 9 + app/Services/StockService.php | 5 +- ..._29_150420_add_item_id_to_stocks_table.php | 31 +++ database/seeders/StockReceivingSeeder.php | 229 ++++++++++++++++++ 5 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2025_12_29_150420_add_item_id_to_stocks_table.php create mode 100644 database/seeders/StockReceivingSeeder.php diff --git a/app/Models/Process.php b/app/Models/Process.php index 47fd3fc..679b5e1 100644 --- a/app/Models/Process.php +++ b/app/Models/Process.php @@ -2,8 +2,8 @@ namespace App\Models; -use App\Models\Traits\BelongsToTenant; -use App\Models\Traits\ModelTrait; +use App\Traits\BelongsToTenant; +use App\Traits\ModelTrait; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; diff --git a/app/Models/Tenants/Stock.php b/app/Models/Tenants/Stock.php index 1d1b8f0..15ea604 100644 --- a/app/Models/Tenants/Stock.php +++ b/app/Models/Tenants/Stock.php @@ -14,6 +14,7 @@ class Stock extends Model protected $fillable = [ 'tenant_id', + 'item_id', 'item_code', 'item_name', 'item_type', @@ -65,6 +66,14 @@ class Stock extends Model 'out' => '없음', ]; + /** + * 품목 관계 + */ + public function item(): BelongsTo + { + return $this->belongsTo(\App\Models\Items\Item::class); + } + /** * LOT 관계 */ diff --git a/app/Services/StockService.php b/app/Services/StockService.php index ba01a5a..086c34b 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -15,7 +15,8 @@ public function index(array $params): LengthAwarePaginator $tenantId = $this->tenantId(); $query = Stock::query() - ->where('tenant_id', $tenantId); + ->where('tenant_id', $tenantId) + ->with('item'); // 검색어 필터 if (! empty($params['search'])) { @@ -90,7 +91,7 @@ public function show(int $id): Stock return Stock::query() ->where('tenant_id', $tenantId) - ->with(['lots' => function ($query) { + ->with(['item', 'lots' => function ($query) { $query->orderBy('fifo_order'); }]) ->findOrFail($id); diff --git a/database/migrations/2025_12_29_150420_add_item_id_to_stocks_table.php b/database/migrations/2025_12_29_150420_add_item_id_to_stocks_table.php new file mode 100644 index 0000000..d97b34a --- /dev/null +++ b/database/migrations/2025_12_29_150420_add_item_id_to_stocks_table.php @@ -0,0 +1,31 @@ +foreignId('item_id')->nullable()->after('tenant_id') + ->comment('품목 ID (items.id 참조)'); + $table->index('item_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('stocks', function (Blueprint $table) { + $table->dropIndex(['item_id']); + $table->dropColumn('item_id'); + }); + } +}; diff --git a/database/seeders/StockReceivingSeeder.php b/database/seeders/StockReceivingSeeder.php new file mode 100644 index 0000000..cc1218b --- /dev/null +++ b/database/seeders/StockReceivingSeeder.php @@ -0,0 +1,229 @@ + 'purchased_part', // 완제품 + 'PT' => 'bent_part', // 부품 + 'SM' => 'sub_material', // 부자재 + 'RM' => 'raw_material', // 원자재 + 'CS' => 'consumable', // 소모품 + ]; + + public function run(): void + { + $this->command->info('Stock & Receiving Seeder 시작 (Tenant: '.self::TENANT_ID.')'); + + // Tenant 287의 품목 조회 + $items = Item::withoutGlobalScopes() + ->where('tenant_id', self::TENANT_ID) + ->get(); + + if ($items->isEmpty()) { + $this->command->warn('Tenant '.self::TENANT_ID.'에 품목이 없습니다.'); + + return; + } + + $this->command->info('품목 수: '.$items->count()); + + // 각 품목에 대해 재고 및 입고 데이터 생성 + foreach ($items as $item) { + $this->createStockData($item); + $this->createReceivingData($item); + } + + $this->command->info('Stock & Receiving Seeder 완료'); + } + + /** + * 재고 데이터 생성 + */ + private function createStockData(Item $item): void + { + $stockType = self::ITEM_TYPE_MAP[$item->item_type] ?? 'raw_material'; + + // 랜덤 재고 수량 + $stockQty = rand(50, 500); + $safetyStock = rand(10, 50); + $reservedQty = rand(0, min(20, $stockQty)); + $availableQty = $stockQty - $reservedQty; + + // 재고 상태 결정 + $status = 'normal'; + if ($stockQty <= 0) { + $status = 'out'; + } elseif ($stockQty < $safetyStock) { + $status = 'low'; + } + + // 재고 레코드 생성 + $stock = Stock::withoutGlobalScopes()->create([ + 'tenant_id' => self::TENANT_ID, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $stockType, + 'specification' => $item->attributes['specification'] ?? null, + 'unit' => $item->unit, + 'stock_qty' => $stockQty, + 'safety_stock' => $safetyStock, + 'reserved_qty' => $reservedQty, + 'available_qty' => $availableQty, + 'lot_count' => rand(1, 3), + 'oldest_lot_date' => now()->subDays(rand(30, 180)), + 'location' => $this->randomLocation(), + 'status' => $status, + 'last_receipt_date' => now()->subDays(rand(1, 30)), + 'last_issue_date' => now()->subDays(rand(1, 14)), + 'created_by' => self::USER_ID, + ]); + + // LOT 데이터 생성 (1~3개) + $lotCount = rand(1, 3); + $remainingQty = $stockQty; + + for ($i = 0; $i < $lotCount && $remainingQty > 0; $i++) { + $lotQty = $i === $lotCount - 1 ? $remainingQty : rand(10, $remainingQty); + $remainingQty -= $lotQty; + + StockLot::withoutGlobalScopes()->create([ + 'tenant_id' => self::TENANT_ID, + 'stock_id' => $stock->id, + 'lot_no' => 'LOT'.date('Ymd').'-'.str_pad($i + 1, 3, '0', STR_PAD_LEFT), + 'supplier_lot' => 'SUP-'.rand(1000, 9999), + 'qty' => $lotQty, + 'reserved_qty' => 0, + 'available_qty' => $lotQty, + 'receipt_date' => now()->subDays(rand(1, 60)), + 'fifo_order' => $i + 1, + 'location' => $stock->location, + 'status' => 'available', + 'created_by' => self::USER_ID, + ]); + } + + $this->command->info(" - Stock 생성: {$item->code} ({$stockQty} {$item->unit})"); + } + + /** + * 입고 데이터 생성 + */ + private function createReceivingData(Item $item): void + { + // 각 품목당 2~4건의 입고 데이터 생성 + $count = rand(2, 4); + + for ($i = 0; $i < $count; $i++) { + $orderQty = rand(50, 200); + $status = $this->randomReceivingStatus(); + $receivingQty = $status === 'completed' ? $orderQty : ($status === 'receiving_pending' ? rand(1, $orderQty) : null); + + // 고유번호 생성을 위해 uniqid 사용 + $uniqueId = substr(uniqid(), -6); + Receiving::withoutGlobalScopes()->create([ + 'tenant_id' => self::TENANT_ID, + 'receiving_number' => 'RCV-'.date('Ymd').'-'.$uniqueId, + 'order_no' => 'PO-'.date('Ymd').'-'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT), + 'order_date' => now()->subDays(rand(10, 60)), + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'specification' => $item->attributes['specification'] ?? null, + 'supplier' => $this->randomSupplier(), + 'order_qty' => $orderQty, + 'order_unit' => $item->unit, + 'due_date' => now()->addDays(rand(-5, 14)), + 'receiving_qty' => $receivingQty, + 'receiving_date' => $status === 'completed' ? now()->subDays(rand(1, 10)) : null, + 'lot_no' => $status === 'completed' ? 'LOT'.date('Ymd').'-'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT) : null, + 'supplier_lot' => $status === 'completed' ? 'SUP-'.rand(1000, 9999) : null, + 'receiving_location' => $status === 'completed' ? $this->randomLocation() : null, + 'receiving_manager' => $status === 'completed' ? '홍킬동' : null, + 'status' => $status, + 'remark' => $this->randomRemark(), + 'created_by' => self::USER_ID, + ]); + } + + $this->command->info(" - Receiving 생성: {$item->code} ({$count}건)"); + } + + /** + * 랜덤 창고 위치 + */ + private function randomLocation(): string + { + $locations = ['A-01-01', 'A-01-02', 'A-02-01', 'B-01-01', 'B-02-01', 'C-01-01']; + + return $locations[array_rand($locations)]; + } + + /** + * 랜덤 공급업체 + */ + private function randomSupplier(): string + { + $suppliers = ['(주)대한철강', '삼성부품', 'LG전자', '현대산업', '한국소재', '동아물산']; + + return $suppliers[array_rand($suppliers)]; + } + + /** + * 랜덤 입고 상태 + */ + private function randomReceivingStatus(): string + { + $statuses = ['order_completed', 'shipping', 'inspection_pending', 'receiving_pending', 'completed']; + $weights = [15, 15, 15, 15, 40]; // completed가 더 많이 + + $rand = rand(1, array_sum($weights)); + $cumulative = 0; + + foreach ($statuses as $i => $status) { + $cumulative += $weights[$i]; + if ($rand <= $cumulative) { + return $status; + } + } + + return 'completed'; + } + + /** + * 랜덤 비고 + */ + private function randomRemark(): ?string + { + $remarks = [ + null, + '긴급 발주', + '정기 입고', + '추가 발주', + '품질 검사 필요', + '납기 지연 주의', + ]; + + return $remarks[array_rand($remarks)]; + } +} \ No newline at end of file