'가이드레일(벽면)', 'S' => '가이드레일(측면)', 'G' => '연기차단재', 'B' => '하단마감재(스크린)', 'T' => '하단마감재(철재)', 'L' => 'L-Bar', 'C' => '케이스', ]; // 5130 spec 코드 → 한글명 private array $specNames = [ 'I' => '화이바원단', 'S' => 'SUS', 'U' => 'SUS2', 'E' => 'EGI', 'A' => '스크린용', 'D' => 'D형', 'C' => 'C형', 'M' => '본체', 'T' => '본체(철재)', 'B' => '후면코너부', 'L' => '린텔부', 'P' => '점검구', 'F' => '전면부', ]; // 5130 slength 코드 → 한글명 private array $slengthNames = [ '53' => 'W50×3000', '54' => 'W50×4000', '83' => 'W80×3000', '84' => 'W80×4000', '12' => '1219mm', '24' => '2438mm', '30' => '3000mm', '35' => '3500mm', '40' => '4000mm', '41' => '4150mm', '42' => '4200mm', '43' => '4300mm', ]; private array $stats = [ 'items_found' => 0, 'items_created_5130' => 0, 'items_category_updated' => 0, 'stocks_created' => 0, 'stocks_skipped' => 0, 'lots_created' => 0, 'transactions_created' => 0, ]; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $rollback = $this->option('rollback'); $minStock = (int) $this->option('min-stock'); $this->info('=== BD-* 절곡품 초기 재고 셋팅 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); $this->info("초기 재고: {$minStock}개/품목"); $this->newLine(); if ($rollback) { return $this->rollbackInitStock($tenantId, $dryRun); } // 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 $this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...'); $this->createLegacyItems($tenantId, $dryRun); $this->newLine(); // 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분) $this->info('📥 Step 1: BD-* 절곡품 품목 조회...'); $items = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('code', 'like', 'BD-%') ->whereNull('deleted_at') ->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options') ->orderBy('code') ->get(); $this->stats['items_found'] = $items->count(); $this->info(" - BD-* 품목: {$items->count()}건"); if ($items->isEmpty()) { $this->warn('BD-* 품목이 없습니다. 종료합니다.'); return self::SUCCESS; } // 2. item_category 미설정 품목 업데이트 $this->newLine(); $this->info('🏷️ Step 2: item_category 업데이트...'); $needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING'); if ($needsCategoryUpdate->isNotEmpty()) { $this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}건"); if (! $dryRun) { DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('code', 'like', 'BD-%') ->whereNull('deleted_at') ->where(function ($q) { $q->whereNull('item_category') ->orWhere('item_category', '!=', 'BENDING'); }) ->update(['item_category' => 'BENDING', 'updated_at' => now()]); } $this->stats['items_category_updated'] = $needsCategoryUpdate->count(); } else { $this->info(' - 모든 품목 BENDING 카테고리 설정 완료'); } // 3. 현재 재고 현황 표시 $this->newLine(); $this->info('📊 Step 3: 현재 재고 현황...'); $this->showCurrentStockStatus($tenantId, $items); // 4. 재고 셋팅 대상 확인 $this->newLine(); $this->info('📦 Step 4: 재고 셋팅 대상 확인...'); $itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock); if ($itemsNeedingStock->isEmpty()) { $this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요."); $this->showStats(); return self::SUCCESS; } $this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}건"); $this->table( ['코드', '품목명', '현재고', '목표', '추가수량'], $itemsNeedingStock->map(fn ($item) => [ $item->code, mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name, number_format($item->current_qty), number_format($minStock), number_format($item->supplement_qty), ])->toArray() ); if ($dryRun) { $this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count(); $this->stats['lots_created'] = $itemsNeedingStock->count(); $this->stats['transactions_created'] = $itemsNeedingStock->count(); $this->showStats(); $this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.'); return self::SUCCESS; } if (! $this->confirm('초기 재고를 셋팅하시겠습니까?')) { $this->info('취소되었습니다.'); return self::SUCCESS; } // 5. 실행 $this->newLine(); $this->info('🚀 Step 5: 초기 재고 셋팅 실행...'); DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) { $this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock); }); $this->newLine(); $this->showStats(); $this->info('✅ 초기 재고 셋팅 완료!'); return self::SUCCESS; } /** * 현재 재고 현황 표시 */ private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void { $itemIds = $items->pluck('id'); $stocks = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $itemIds) ->whereNull('deleted_at') ->get() ->keyBy('item_id'); $hasStock = 0; $noStock = 0; foreach ($items as $item) { $stock = $stocks->get($item->id); if ($stock && (float) $stock->stock_qty > 0) { $hasStock++; } else { $noStock++; } } $this->info(" - 재고 있음: {$hasStock}건"); $this->info(" - 재고 없음: {$noStock}건"); } /** * 재고 셋팅이 필요한 품목 목록 조회 */ private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection { $itemIds = $items->pluck('id'); $stocks = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $itemIds) ->whereNull('deleted_at') ->get() ->keyBy('item_id'); $result = collect(); foreach ($items as $item) { $stock = $stocks->get($item->id); $currentQty = $stock ? (float) $stock->stock_qty : 0; if ($currentQty >= $minStock) { $this->stats['stocks_skipped']++; continue; } $supplementQty = $minStock - $currentQty; $item->has_stock = (bool) $stock; $item->stock_id = $stock?->id; $item->current_qty = $currentQty; $item->supplement_qty = $supplementQty; $result->push($item); } return $result; } /** * 초기 재고 셋팅 실행 */ private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void { foreach ($items as $item) { $stockId = $item->stock_id; // Stock 레코드가 없으면 생성 if (! $item->has_stock) { $stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([ 'tenant_id' => $tenantId, 'item_id' => $item->id, 'item_code' => $item->code, 'item_name' => $item->name, 'item_type' => 'bent_part', 'unit' => $item->unit ?? 'EA', 'stock_qty' => 0, 'safety_stock' => 0, 'reserved_qty' => 0, 'available_qty' => 0, 'lot_count' => 0, 'status' => 'out', 'created_at' => now(), 'updated_at' => now(), ]); $this->stats['stocks_created']++; $this->line(" + Stock 생성: {$item->code}"); } // FIFO 순서 계산 $maxFifo = DB::connection($this->targetDb) ->table('stock_lots') ->where('stock_id', $stockId) ->max('fifo_order'); $nextFifo = ($maxFifo ?? 0) + 1; // LOT 번호 생성 $lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code); // 중복 체크 $existingLot = DB::connection($this->targetDb) ->table('stock_lots') ->where('tenant_id', $tenantId) ->where('stock_id', $stockId) ->where('lot_no', $lotNo) ->whereNull('deleted_at') ->first(); if ($existingLot) { $this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}"); continue; } $supplementQty = $item->supplement_qty; // StockLot 생성 $stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'lot_no' => $lotNo, 'fifo_order' => $nextFifo, 'receipt_date' => now()->toDateString(), 'qty' => $supplementQty, 'reserved_qty' => 0, 'available_qty' => $supplementQty, 'unit' => $item->unit ?? 'EA', 'status' => 'available', 'created_at' => now(), 'updated_at' => now(), ]); $this->stats['lots_created']++; // StockTransaction 생성 DB::connection($this->targetDb)->table('stock_transactions')->insert([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'stock_lot_id' => $stockLotId, 'type' => 'IN', 'qty' => $supplementQty, 'balance_qty' => 0, 'reference_type' => 'init_stock', 'reference_id' => 0, 'lot_no' => $lotNo, 'reason' => 'receiving', 'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})", 'item_code' => $item->code, 'item_name' => $item->name, 'created_at' => now(), ]); $this->stats['transactions_created']++; // Stock 집계 갱신 $this->refreshStockFromLots($stockId, $tenantId); $this->line(" ✅ {$item->code}: 0 → {$supplementQty} (+{$supplementQty})"); } } /** * Stock 집계 갱신 (LOT 기반) */ private function refreshStockFromLots(int $stockId, int $tenantId): void { $lotStats = DB::connection($this->targetDb) ->table('stock_lots') ->where('stock_id', $stockId) ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->selectRaw(' COALESCE(SUM(qty), 0) as total_qty, COALESCE(SUM(reserved_qty), 0) as total_reserved, COALESCE(SUM(available_qty), 0) as total_available, COUNT(*) as lot_count, MIN(receipt_date) as oldest_lot_date, MAX(receipt_date) as latest_receipt_date ') ->first(); $stockQty = (float) $lotStats->total_qty; DB::connection($this->targetDb) ->table('stocks') ->where('id', $stockId) ->update([ 'stock_qty' => $stockQty, 'reserved_qty' => (float) $lotStats->total_reserved, 'available_qty' => (float) $lotStats->total_available, 'lot_count' => (int) $lotStats->lot_count, 'oldest_lot_date' => $lotStats->oldest_lot_date, 'last_receipt_date' => $lotStats->latest_receipt_date, 'status' => $stockQty > 0 ? 'normal' : 'out', 'updated_at' => now(), ]); } /** * 롤백: init_stock 참조 데이터 삭제 */ private function rollbackInitStock(int $tenantId, bool $dryRun): int { $this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.'); // init_stock으로 생성된 트랜잭션 $txCount = DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', 'init_stock') ->count(); // init_stock 트랜잭션에 연결된 LOT $lotIds = DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', 'init_stock') ->whereNotNull('stock_lot_id') ->pluck('stock_lot_id') ->unique(); // 5130으로 생성된 아이템 $legacyItemCount = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->count(); $this->info(' 삭제 대상:'); $this->info(" - stock_transactions (reference_type=init_stock): {$txCount}건"); $this->info(" - stock_lots (연결 LOT): {$lotIds->count()}건"); $this->info(" - items (source=5130_migration): {$legacyItemCount}건"); if ($dryRun) { $this->info('DRY RUN - 실제 삭제 없음'); return self::SUCCESS; } if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) { return self::SUCCESS; } DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) { // 1. 트랜잭션 삭제 DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', 'init_stock') ->delete(); // 2. LOT에서 stock_id 목록 수집 (집계 갱신용) $affectedStockIds = collect(); if ($lotIds->isNotEmpty()) { $affectedStockIds = DB::connection($this->targetDb) ->table('stock_lots') ->whereIn('id', $lotIds) ->pluck('stock_id') ->unique(); // LOT 삭제 DB::connection($this->targetDb) ->table('stock_lots') ->whereIn('id', $lotIds) ->delete(); } // 3. 영향받은 Stock 집계 갱신 foreach ($affectedStockIds as $stockId) { $this->refreshStockFromLots($stockId, $tenantId); } // 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제 $migrationItemIds = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->pluck('id'); if ($migrationItemIds->isNotEmpty()) { $migrationStockIds = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $migrationItemIds) ->pluck('id'); if ($migrationStockIds->isNotEmpty()) { DB::connection($this->targetDb) ->table('stock_lots') ->whereIn('stock_id', $migrationStockIds) ->delete(); DB::connection($this->targetDb) ->table('stocks') ->whereIn('id', $migrationStockIds) ->delete(); } DB::connection($this->targetDb) ->table('items') ->whereIn('id', $migrationItemIds) ->delete(); } }); $this->info('✅ 롤백 완료'); return self::SUCCESS; } /** * 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 */ private function createLegacyItems(int $tenantId, bool $dryRun): void { // 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출 $lots = DB::connection($this->sourceDb) ->table('lot') ->where(function ($q) { $q->whereNull('is_deleted') ->orWhere('is_deleted', 0); }) ->whereNotNull('prod') ->where('prod', '!=', '') ->whereNotNull('surang') ->where('surang', '>', 0) ->select('prod', 'spec', 'slength') ->distinct() ->get(); // bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함) $workLogs = DB::connection($this->sourceDb) ->table('bending_work_log') ->where(function ($q) { $q->whereNull('is_deleted') ->orWhere('is_deleted', 0); }) ->whereNotNull('prod_code') ->where('prod_code', '!=', '') ->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength') ->distinct() ->get(); $allRecords = $lots->merge($workLogs); if ($allRecords->isEmpty()) { $this->info(' - 5130 데이터 없음'); return; } // 고유 제품 조합 추출 $uniqueProducts = []; foreach ($allRecords as $row) { $key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? ''); if (! isset($uniqueProducts[$key])) { $uniqueProducts[$key] = [ 'prod' => trim($row->prod), 'spec' => trim($row->spec ?? ''), 'slength' => trim($row->slength ?? ''), ]; } } $this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개'); $created = 0; $skipped = 0; foreach ($uniqueProducts as $data) { $itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}"; $prodName = $this->prodNames[$data['prod']] ?? $data['prod']; $specName = $this->specNames[$data['spec']] ?? $data['spec']; $slengthName = $this->slengthNames[$data['slength']] ?? $data['slength']; $itemName = implode(' ', array_filter([$prodName, $specName, $slengthName])); // 이미 존재하는지 확인 $existing = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('code', $itemCode) ->whereNull('deleted_at') ->first(); if ($existing) { $skipped++; continue; } if (! $dryRun) { DB::connection($this->targetDb)->table('items')->insert([ 'tenant_id' => $tenantId, 'code' => $itemCode, 'name' => $itemName, 'item_type' => 'PT', 'item_category' => 'BENDING', 'unit' => 'EA', 'options' => json_encode([ 'source' => '5130_migration', 'lot_managed' => true, 'consumption_method' => 'auto', 'production_source' => 'self_produced', 'input_tracking' => true, 'legacy_prod' => $data['prod'], 'legacy_spec' => $data['spec'], 'legacy_slength' => $data['slength'], ]), 'is_active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } $created++; } $this->stats['items_created_5130'] = $created; $this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}건"); } /** * 통계 출력 */ private function showStats(): void { $this->newLine(); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info('📊 실행 통계'); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}건"); $this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}건"); $this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}건"); $this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건"); $this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}건"); $this->info(" StockLot 생성: {$this->stats['lots_created']}건"); $this->info(" 입고 트랜잭션: {$this->stats['transactions_created']}건"); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); } }