diff --git a/app/Console/Commands/Migrate5130BendingStock.php b/app/Console/Commands/Migrate5130BendingStock.php new file mode 100644 index 0000000..9cd10ce --- /dev/null +++ b/app/Console/Commands/Migrate5130BendingStock.php @@ -0,0 +1,773 @@ + '가이드레일(벽면)', + '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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + } +}