'가이드레일(벽면)', '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_updated' => 0, 'items_created' => 0, 'stocks_created' => 0, 'lots_created' => 0, 'transactions_in' => 0, 'transactions_out' => 0, 'skipped_lots' => 0, ]; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $rollback = $this->option('rollback'); $this->info('=== 5130 → SAM 절곡품 재고 마이그레이션 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); $this->newLine(); if ($rollback) { return $this->rollbackMigration($tenantId, $dryRun); } // 1. 5130 데이터 읽기 $this->info('📥 Step 1: 5130 레거시 데이터 읽기...'); $lots = $this->readLegacyLots(); $workLogs = $this->readLegacyWorkLogs(); $this->info(" - lot 레코드: {$lots->count()}건"); $this->info(" - bending_work_log 레코드: {$workLogs->count()}건"); $this->newLine(); if ($lots->isEmpty()) { $this->warn('5130 lot 데이터가 없습니다. 마이그레이션을 종료합니다.'); return self::SUCCESS; } // 2. 재고 요약 계산 $this->info('📊 Step 2: 재고 현황 계산...'); $stockSummary = $this->calculateStockSummary($lots, $workLogs); $this->showSummary($stockSummary); $this->newLine(); // 3. 기존 BD- 아이템 item_category 업데이트 현황 $this->info('🏷️ Step 3: 기존 BD- 아이템 카테고리 업데이트...'); $this->updateExistingBdItems($tenantId, $dryRun); $this->newLine(); if ($dryRun) { $this->showStats(); $this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.'); return self::SUCCESS; } if (! $this->confirm('마이그레이션을 진행하시겠습니까?')) { $this->info('취소되었습니다.'); return self::SUCCESS; } // 4. 실행 $this->info('🚀 Step 4: 마이그레이션 실행...'); DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lots, $workLogs, $stockSummary) { $this->executeMigration($tenantId, $lots, $workLogs, $stockSummary); }); $this->newLine(); $this->showStats(); $this->info('✅ 마이그레이션 완료!'); return self::SUCCESS; } /** * 5130 lot 테이블 데이터 읽기 */ private function readLegacyLots(): \Illuminate\Support\Collection { return DB::connection($this->sourceDb) ->table('lot') ->where('is_deleted', 0) ->whereNotNull('prod') ->where('prod', '!=', '') ->whereNotNull('surang') ->where('surang', '>', 0) ->select('num', 'reg_date', 'lot_number', 'prod', 'spec', 'slength', 'surang', 'rawLot', 'author', 'remark') ->orderBy('reg_date') ->orderBy('num') ->get(); } /** * 5130 bending_work_log 테이블 데이터 읽기 */ private function readLegacyWorkLogs(): \Illuminate\Support\Collection { return DB::connection($this->sourceDb) ->table('bending_work_log') ->where('is_deleted', 0) ->whereNotNull('prod_code') ->where('prod_code', '!=', '') ->where('quantity', '>', 0) ->select('id', 'work_date', 'work_order_no', 'prod_code', 'spec_code', 'slength_code', 'quantity', 'unit', 'work_type', 'worker', 'remark', 'created_at') ->orderBy('work_date') ->orderBy('id') ->get(); } /** * prod+spec+slength 기준 재고 요약 계산 */ private function calculateStockSummary(\Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs): array { $summary = []; // 입고(lot) 합산 foreach ($lots as $lot) { $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); if (! isset($summary[$key])) { $summary[$key] = [ 'prod' => $lot->prod, 'spec' => $lot->spec, 'slength' => $lot->slength, 'total_in' => 0, 'total_out' => 0, 'lot_count' => 0, ]; } $summary[$key]['total_in'] += (float) $lot->surang; $summary[$key]['lot_count']++; } // 출고(bending_work_log) 합산 foreach ($workLogs as $log) { $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); if (! isset($summary[$key])) { $summary[$key] = [ 'prod' => $log->prod_code, 'spec' => $log->spec_code, 'slength' => $log->slength_code, 'total_in' => 0, 'total_out' => 0, 'lot_count' => 0, ]; } $summary[$key]['total_out'] += (float) $log->quantity; } return $summary; } /** * 재고 요약 표시 */ private function showSummary(array $summary): void { $headers = ['코드', '품목명', '입고합계', '출고합계', '현재고', 'LOT수']; $rows = []; foreach ($summary as $key => $item) { $netStock = $item['total_in'] - $item['total_out']; $rows[] = [ $key, $this->makeItemName($item['prod'], $item['spec'], $item['slength']), number_format($item['total_in']), number_format($item['total_out']), number_format($netStock), $item['lot_count'], ]; } $this->table($headers, $rows); $this->info(' - 품목 종류: '.count($summary).'개'); } /** * 기존 BD- 아이템의 item_category를 BENDING으로 업데이트 */ private function updateExistingBdItems(int $tenantId, bool $dryRun): void { $count = 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'); }) ->count(); $this->info(" - BD- 아이템 중 item_category 미설정: {$count}건"); if ($count > 0 && ! $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->info(" ✅ {$count}건 업데이트 완료"); } $this->stats['items_updated'] = $count; } /** * 마이그레이션 실행 */ private function executeMigration(int $tenantId, \Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs, array $stockSummary): void { $itemMap = []; // productKey => item_id $stockMap = []; // productKey => stock_id $lotMap = []; // productKey => [stock_lot_ids] // 1. 각 제품 조합별 아이템 & 스톡 생성 $this->info(' 📦 아이템 & 재고 레코드 생성...'); foreach ($stockSummary as $key => $data) { $itemCode = $this->makeItemCode($data['prod'], $data['spec'], $data['slength']); $itemName = $this->makeItemName($data['prod'], $data['spec'], $data['slength']); // 아이템 조회 또는 생성 $item = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('code', $itemCode) ->whereNull('deleted_at') ->first(); if (! $item) { $itemId = DB::connection($this->targetDb)->table('items')->insertGetId([ 'tenant_id' => $tenantId, 'code' => $itemCode, 'name' => $itemName, 'item_type' => 'PT', 'item_category' => 'BENDING', 'unit' => 'EA', 'source' => '5130_migration', 'options' => json_encode([ '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(), ]); $this->stats['items_created']++; $this->line(" + 아이템 생성: {$itemCode} ({$itemName})"); } else { $itemId = $item->id; $this->line(" ✓ 기존 아이템 사용: {$itemCode}"); } $itemMap[$key] = $itemId; // Stock 레코드 조회 또는 생성 $stock = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->where('item_id', $itemId) ->whereNull('deleted_at') ->first(); if (! $stock) { $stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([ 'tenant_id' => $tenantId, 'item_id' => $itemId, 'item_code' => $itemCode, 'item_name' => $itemName, 'item_type' => 'bent_part', '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']++; } else { $stockId = $stock->id; } $stockMap[$key] = $stockId; } // 2. LOT 데이터 마이그레이션 (입고) $this->newLine(); $this->info(' 📋 LOT 데이터 마이그레이션 (입고)...'); $fifoCounters = []; // stockId => current fifo_order foreach ($lots as $lot) { $key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength); if (! isset($stockMap[$key])) { $this->stats['skipped_lots']++; continue; } $stockId = $stockMap[$key]; $itemCode = $this->makeItemCode($lot->prod, $lot->spec, $lot->slength); $itemName = $this->makeItemName($lot->prod, $lot->spec, $lot->slength); // FIFO 순서 계산 if (! isset($fifoCounters[$stockId])) { $existing = DB::connection($this->targetDb) ->table('stock_lots') ->where('stock_id', $stockId) ->max('fifo_order'); $fifoCounters[$stockId] = ($existing ?? 0); } $fifoCounters[$stockId]++; // LOT 번호 생성 (5130 lot_number 사용, 없으면 생성) $lotNo = $lot->lot_number; if (empty($lotNo)) { $regDate = $lot->reg_date ? Carbon::parse($lot->reg_date)->format('ymd') : Carbon::now()->format('ymd'); $lotNo = "5130-{$regDate}-{$lot->num}"; } // 중복 체크 $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->stats['skipped_lots']++; continue; } $qty = (float) $lot->surang; $receiptDate = $lot->reg_date ?? now()->toDateString(); $stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'lot_no' => $lotNo, 'fifo_order' => $fifoCounters[$stockId], 'receipt_date' => $receiptDate, 'qty' => $qty, 'reserved_qty' => 0, 'available_qty' => $qty, 'unit' => 'EA', 'supplier' => null, 'supplier_lot' => $lot->rawLot ?: null, 'location' => null, 'status' => 'available', 'receiving_id' => null, 'work_order_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); // 입고 트랜잭션 기록 // balance_qty는 나중에 refreshFromLots 후 업데이트되므로 임시로 qty 사용 DB::connection($this->targetDb)->table('stock_transactions')->insert([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'stock_lot_id' => $stockLotId, 'type' => 'IN', 'qty' => $qty, 'balance_qty' => 0, // 나중에 갱신 'reference_type' => 'migration', 'reference_id' => $lot->num, 'lot_no' => $lotNo, 'reason' => 'receiving', 'remark' => '5130 레거시 마이그레이션 (lot.num='.$lot->num.')', 'item_code' => $itemCode, 'item_name' => $itemName, 'created_at' => $receiptDate, ]); $this->stats['lots_created']++; $this->stats['transactions_in']++; if (! isset($lotMap[$key])) { $lotMap[$key] = []; } $lotMap[$key][] = $stockLotId; } $this->info(" ✅ LOT {$this->stats['lots_created']}건 생성"); // 3. 출고 로그 마이그레이션 (bending_work_log → stock_transactions OUT) $this->newLine(); $this->info(' 📋 출고 로그 마이그레이션...'); foreach ($workLogs as $log) { $key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code); if (! isset($stockMap[$key])) { continue; } $stockId = $stockMap[$key]; $itemCode = $this->makeItemCode($log->prod_code, $log->spec_code, $log->slength_code); $itemName = $this->makeItemName($log->prod_code, $log->spec_code, $log->slength_code); $qty = (float) $log->quantity; $workDate = $log->work_date ?? ($log->created_at ?? now()->toDateString()); DB::connection($this->targetDb)->table('stock_transactions')->insert([ 'tenant_id' => $tenantId, 'stock_id' => $stockId, 'stock_lot_id' => null, // 레거시 데이터는 특정 LOT 추적 불가 'type' => 'OUT', 'qty' => -$qty, 'balance_qty' => 0, // 나중에 갱신 'reference_type' => 'migration', 'reference_id' => $log->id, 'lot_no' => null, 'reason' => 'work_order_input', 'remark' => '5130 레거시 마이그레이션 (bending_work_log.id='.$log->id.', worker='.$log->worker.')', 'item_code' => $itemCode, 'item_name' => $itemName, 'created_at' => $workDate, ]); $this->stats['transactions_out']++; } $this->info(" ✅ 출고 트랜잭션 {$this->stats['transactions_out']}건 생성"); // 4. LOT 수량 조정 (출고분 차감) $this->newLine(); $this->info(' 🔄 LOT 수량 조정 (출고분 FIFO 차감)...'); foreach ($stockSummary as $key => $data) { if (! isset($stockMap[$key])) { continue; } $stockId = $stockMap[$key]; $totalOut = $data['total_out']; if ($totalOut <= 0) { continue; } // FIFO 순서로 LOT에서 차감 $remainingOut = $totalOut; $stockLots = DB::connection($this->targetDb) ->table('stock_lots') ->where('stock_id', $stockId) ->where('tenant_id', $tenantId) ->whereNull('deleted_at') ->orderBy('fifo_order') ->get(); foreach ($stockLots as $sl) { if ($remainingOut <= 0) { break; } $lotQty = (float) $sl->qty; $deduct = min($lotQty, $remainingOut); $newQty = $lotQty - $deduct; $remainingOut -= $deduct; $status = $newQty <= 0 ? 'used' : 'available'; DB::connection($this->targetDb) ->table('stock_lots') ->where('id', $sl->id) ->update([ 'qty' => max(0, $newQty), 'available_qty' => max(0, $newQty), 'status' => $status, 'updated_at' => now(), ]); } if ($remainingOut > 0) { $this->warn(" ⚠️ {$key}: 출고량이 입고량보다 {$remainingOut}만큼 초과 (데이터 불일치)"); } } // 5. Stock 집계 갱신 $this->newLine(); $this->info(' 📊 Stock 집계 갱신...'); foreach ($stockMap as $key => $stockId) { $this->refreshStockFromLots($stockId, $tenantId); } // 6. balance_qty 갱신 (트랜잭션별 잔량) $this->info(' 📊 트랜잭션 잔량(balance_qty) 갱신...'); foreach ($stockMap as $key => $stockId) { $stock = DB::connection($this->targetDb) ->table('stocks') ->where('id', $stockId) ->first(); if ($stock) { DB::connection($this->targetDb) ->table('stock_transactions') ->where('stock_id', $stockId) ->where('tenant_id', $tenantId) ->update(['balance_qty' => $stock->stock_qty]); } } $this->info(' ✅ 집계 갱신 완료'); } /** * 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; $reservedQty = (float) $lotStats->total_reserved; $availableQty = (float) $lotStats->total_available; $status = 'normal'; if ($stockQty <= 0) { $status = 'out'; } DB::connection($this->targetDb) ->table('stocks') ->where('id', $stockId) ->update([ 'stock_qty' => $stockQty, 'reserved_qty' => $reservedQty, 'available_qty' => $availableQty, 'lot_count' => (int) $lotStats->lot_count, 'oldest_lot_date' => $lotStats->oldest_lot_date, 'last_receipt_date' => $lotStats->latest_receipt_date, 'status' => $status, 'updated_at' => now(), ]); } /** * 롤백: migration 참조 데이터 삭제 */ private function rollbackMigration(int $tenantId, bool $dryRun): int { $this->warn('⚠️ 롤백: migration 소스 데이터를 삭제합니다.'); // 마이그레이션으로 생성된 트랜잭션 $txCount = DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', 'migration') ->count(); // 마이그레이션으로 생성된 아이템 (source = '5130_migration') $itemCount = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('source', '5130_migration') ->whereNull('deleted_at') ->count(); $this->info(' 삭제 대상:'); $this->info(" - stock_transactions (reference_type=migration): {$txCount}건"); $this->info(" - items (source=5130_migration): {$itemCount}건"); if ($dryRun) { $this->info('DRY RUN - 실제 삭제 없음'); return self::SUCCESS; } if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) { return self::SUCCESS; } DB::connection($this->targetDb)->transaction(function () use ($tenantId) { // 1. 트랜잭션 삭제 DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) ->where('reference_type', 'migration') ->delete(); // 2. migration으로 생성된 아이템의 stock_lots 삭제 $migrationItemIds = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) ->where('source', '5130_migration') ->whereNull('deleted_at') ->pluck('id'); if ($migrationItemIds->isNotEmpty()) { $stockIds = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $migrationItemIds) ->pluck('id'); if ($stockIds->isNotEmpty()) { DB::connection($this->targetDb) ->table('stock_lots') ->whereIn('stock_id', $stockIds) ->delete(); DB::connection($this->targetDb) ->table('stocks') ->whereIn('id', $stockIds) ->delete(); } // 3. 아이템 삭제 DB::connection($this->targetDb) ->table('items') ->whereIn('id', $migrationItemIds) ->delete(); } }); $this->info('✅ 롤백 완료'); return self::SUCCESS; } /** * 5130 코드 → 제품 키 생성 */ private function makeProductKey(string $prod, ?string $spec, ?string $slength): string { return trim($prod).'-'.trim($spec ?? '').'-'.trim($slength ?? ''); } /** * 5130 코드 → SAM 아이템 코드 생성 * 형식: BD-{PROD}{SPEC}-{SLENGTH} (예: BD-RS-40) */ private function makeItemCode(string $prod, ?string $spec, ?string $slength): string { $p = trim($prod); $s = trim($spec ?? ''); $l = trim($slength ?? ''); return "BD-{$p}{$s}-{$l}"; } /** * 5130 코드 → 사람이 읽을 수 있는 아이템명 생성 */ private function makeItemName(string $prod, ?string $spec, ?string $slength): string { $prodName = $this->prodNames[trim($prod)] ?? trim($prod); $specName = $this->specNames[trim($spec ?? '')] ?? trim($spec ?? ''); $slengthName = $this->slengthNames[trim($slength ?? '')] ?? trim($slength ?? ''); $parts = array_filter([$prodName, $specName, $slengthName]); return implode(' ', $parts); } /** * 통계 출력 */ private function showStats(): void { $this->newLine(); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info('📊 마이그레이션 통계'); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info(" 아이템 카테고리 업데이트: {$this->stats['items_updated']}건"); $this->info(" 아이템 신규 생성: {$this->stats['items_created']}건"); $this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건"); $this->info(" StockLot 생성: {$this->stats['lots_created']}건"); $this->info(" 입고 트랜잭션: {$this->stats['transactions_in']}건"); $this->info(" 출고 트랜잭션: {$this->stats['transactions_out']}건"); $this->info(" 스킵된 LOT: {$this->stats['skipped_lots']}건"); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); } }