tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $legacyImgPath = $this->option('legacy-img-path'); // 1. 현재 상태 $biCount = BendingItem::where('tenant_id', $this->tenantId)->count(); $bdCount = BendingItem::where('tenant_id', $this->tenantId) ->whereNotNull('bending_data')->count(); $fileCount = File::where('field_key', 'bending_diagram') ->where(function ($q) { $q->where('document_type', 'bending_item') ->orWhere('document_type', '1'); })->count(); $this->info("현재: bending_items={$biCount}, bending_data={$bdCount}, files={$fileCount}"); // chandj 유효 건수 $chandjRows = DB::connection('chandj')->table('bending') ->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', 0); }) ->orderBy('num') ->get(); $this->info("chandj 이관 대상: {$chandjRows->count()}건"); if ($dryRun) { $this->preview($chandjRows); return 0; } if (! $this->confirm("기존 데이터 전체 삭제 후 chandj에서 재이관합니다. 계속?")) { return 0; } DB::transaction(function () use ($chandjRows) { // 2. 기존 파일 DB 레코드만 삭제 (R2 파일은 유지) $this->deleteFileRecords(); // 3. 기존 데이터 삭제 BendingItem::where('tenant_id', $this->tenantId)->forceDelete(); $this->info("기존 데이터 삭제 완료"); // 4. chandj에서 직접 이관 $success = 0; $bdTotal = 0; foreach ($chandjRows as $row) { try { $bi = $this->importItem($row); $bd = $this->importBendingData($bi, $row); $bdTotal += $bd; $success++; } catch (\Throwable $e) { $this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}"); } } $this->newLine(); $this->info("이관 완료: {$success}/{$chandjRows->count()}건, 전개도 {$bdTotal}행"); }); // 5. 이미지 이관 $this->importImages($legacyImgPath); // 6. 최종 검증 $this->verify(); return 0; } private function importItem(object $row): BendingItem { $code = $this->generateCode($row); $bi = BendingItem::create([ 'tenant_id' => $this->tenantId, 'code' => $code, 'legacy_code' => "CHANDJ-{$row->num}", 'legacy_bending_id' => $row->num, 'item_name' => $row->itemName ?: "부품#{$row->num}", 'item_sep' => $this->clean($row->item_sep), 'item_bending' => $this->clean($row->item_bending), 'material' => $this->clean($row->material), 'item_spec' => $this->clean($row->item_spec), 'model_name' => $this->clean($row->model_name ?? null), 'model_UA' => $this->clean($row->model_UA ?? null), 'rail_width' => $this->toNum($row->rail_width ?? null), 'exit_direction' => $this->clean($row->exit_direction ?? null), 'box_width' => $this->toNum($row->box_width ?? null), 'box_height' => $this->toNum($row->box_height ?? null), 'front_bottom' => $this->toNum($row->front_bottom_width ?? null), 'options' => $this->buildOptions($row), 'is_active' => true, 'created_by' => 1, ]); $this->line(" ✅ #{$row->num} → {$bi->id} ({$row->itemName}) [{$code}]"); return $bi; } private function importBendingData(BendingItem $bi, object $row): int { $inputs = json_decode($row->inputList ?? '[]', true) ?: []; if (empty($inputs)) { return 0; } $rates = json_decode($row->bendingrateList ?? '[]', true) ?: []; $sums = json_decode($row->sumList ?? '[]', true) ?: []; $colors = json_decode($row->colorList ?? '[]', true) ?: []; $angles = json_decode($row->AList ?? '[]', true) ?: []; $data = []; $count = count($inputs); for ($i = 0; $i < $count; $i++) { $data[] = [ 'no' => $i + 1, 'input' => (float) ($inputs[$i] ?? 0), 'rate' => (string) ($rates[$i] ?? ''), 'sum' => (float) ($sums[$i] ?? 0), 'color' => (bool) ($colors[$i] ?? false), 'aAngle' => (bool) ($angles[$i] ?? false), ]; } $bi->update(['bending_data' => $data]); return $count; } private function deleteFileRecords(): void { $count = File::where('field_key', 'bending_diagram') ->where('document_type', 'bending_item') ->forceDelete(); $this->info("파일 레코드 삭제: {$count}건 (R2 파일은 유지)"); } private function importImages(string $legacyImgPath): void { $chandjMap = DB::connection('chandj')->table('bending') ->whereNotNull('imgdata') ->where('imgdata', '!=', '') ->pluck('imgdata', 'num'); $items = BendingItem::where('tenant_id', $this->tenantId) ->whereNotNull('legacy_bending_id') ->get(); $uploaded = 0; $notFound = 0; foreach ($items as $bi) { $imgFile = $chandjMap[$bi->legacy_bending_id] ?? null; if (! $imgFile) { continue; } $filePath = "{$legacyImgPath}/{$imgFile}"; if (! file_exists($filePath)) { $notFound++; continue; } try { $extension = pathinfo($imgFile, PATHINFO_EXTENSION); $storedName = bin2hex(random_bytes(8)) . '.' . $extension; $directory = sprintf('%d/bending/%s/%s', $this->tenantId, date('Y'), date('m')); $r2Path = $directory . '/' . $storedName; Storage::disk('r2')->put($r2Path, file_get_contents($filePath)); File::create([ 'tenant_id' => $this->tenantId, 'display_name' => $imgFile, 'stored_name' => $storedName, 'file_path' => $r2Path, 'file_size' => filesize($filePath), 'mime_type' => mime_content_type($filePath), 'file_type' => 'image', 'field_key' => 'bending_diagram', 'document_id' => $bi->id, 'document_type' => 'bending_item', 'is_temp' => false, 'uploaded_by' => 1, 'created_by' => 1, ]); $uploaded++; } catch (\Throwable $e) { $this->warn(" ⚠️ 이미지 업로드 실패: #{$bi->legacy_bending_id} — {$e->getMessage()}"); } } $this->info("이미지 업로드: {$uploaded}건" . ($notFound > 0 ? " (파일없음 {$notFound}건)" : '')); } private function generateCode(object $row): string { $bending = $row->item_bending ?? ''; $sep = $row->item_sep ?? ''; $material = $row->material ?? ''; $name = $row->itemName ?? ''; $prodCode = match (true) { $bending === '케이스' => 'C', $bending === '하단마감재' && str_contains($sep, '철재') => 'T', $bending === '하단마감재' => 'B', $bending === '가이드레일' => 'R', $bending === '마구리' => 'X', $bending === 'L-BAR' => 'L', $bending === '연기차단재' => 'G', default => 'Z', }; $specCode = match (true) { str_contains($name, '전면') => 'F', str_contains($name, '린텔') => 'L', str_contains($name, '점검') => 'P', str_contains($name, '후면') => 'B', str_contains($name, '상부') || str_contains($name, '덮개') => 'X', str_contains($name, '본체') => 'M', str_contains($name, 'C형') || str_contains($name, '-C') => 'C', str_contains($name, 'D형') || str_contains($name, '-D') => 'D', str_contains($name, '마감') && str_contains($material, 'SUS') => 'S', str_contains($name, '하장바') && str_contains($material, 'SUS') => 'S', str_contains($name, '하장바') && str_contains($material, 'EGI') => 'E', str_contains($name, '보강') => 'H', str_contains($name, '절단') => 'T', str_contains($name, '비인정') => 'N', str_contains($name, '밑면') => 'P', str_contains($material, 'SUS') => 'S', str_contains($material, 'EGI') => 'E', default => 'Z', }; $date = $row->registration_date ?? now()->format('Y-m-d'); $dateCode = date('ymd', strtotime($date)); $base = "{$prodCode}{$specCode}{$dateCode}"; // 중복 방지 일련번호 $seq = 1; while (BendingItem::where('tenant_id', $this->tenantId) ->where('code', $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT)) ->exists()) { $seq++; } return $seq === 1 ? $base : "{$base}-" . str_pad($seq, 2, '0', STR_PAD_LEFT); } private function buildOptions(object $row): ?array { $opts = []; if (! empty($row->memo)) $opts['memo'] = $row->memo; if (! empty($row->author)) $opts['author'] = $row->author; if (! empty($row->search_keyword)) $opts['search_keyword'] = $row->search_keyword; if (! empty($row->registration_date)) $opts['registration_date'] = (string) $row->registration_date; return empty($opts) ? null : $opts; } private function verify(): void { $bi = BendingItem::where('tenant_id', $this->tenantId)->count(); $bd = BendingItem::where('tenant_id', $this->tenantId) ->whereNotNull('bending_data')->count(); $mapped = BendingItem::where('tenant_id', $this->tenantId) ->whereNotNull('legacy_bending_id') ->distinct('legacy_bending_id') ->count('legacy_bending_id'); $files = File::where('field_key', 'bending_diagram')->count(); $this->newLine(); $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); $this->info("📊 최종 결과"); $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); $this->info(" bending_items: {$bi}건"); $this->info(" bending_data: {$bd}행"); $this->info(" chandj 매핑: {$mapped}건"); $this->info(" 파일: {$files}건 (이미지 재업로드 필요)"); $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); } private function preview($rows): void { $grouped = $rows->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류')); $this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values()); } private function clean(?string $v): ?string { return ($v === null || $v === '' || $v === 'null') ? null : trim($v); } private function toNum(mixed $v): ?float { return ($v === null || $v === '' || $v === 'null') ? null : (float) $v; } }