tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $this->legacyPath = $this->option('legacy-path'); // 기초관리 이미지 매핑 + 모델 JSON 이미지 로드 $this->buildItemImageMap(); $this->loadModelImageJsons(); // 기존 데이터 삭제 (assembly_image 파일 매핑 보존) $existing = BendingModel::where('tenant_id', $this->tenantId)->count(); $oldFileMap = []; if ($existing > 0 && ! $dryRun) { $oldFileMap = $this->buildOldFileMap(); // component_image 삭제 (재생성할 거니까) File::where('document_type', 'bending_model') ->where('field_key', 'component_image') ->whereNull('deleted_at') ->forceDelete(); BendingModel::where('tenant_id', $this->tenantId)->forceDelete(); $this->info("기존 bending_models {$existing}건 삭제 (component_image 재생성)"); } $total = 0; // 1. guiderail $guiderails = DB::connection('chandj')->table('guiderail') ->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); }) ->orderBy('num')->get(); $this->info("\n=== 가이드레일: {$guiderails->count()}건 ==="); foreach ($guiderails as $row) { if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; } $this->importModel($row, BendingModel::TYPE_GUIDERAIL, "GR-{$row->num}", $this->buildGuiderailData($row)); $total++; } // 2. shutterbox $shutterboxes = DB::connection('chandj')->table('shutterbox') ->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); }) ->orderBy('num')->get(); $this->info("\n=== 케이스: {$shutterboxes->count()}건 ==="); foreach ($shutterboxes as $row) { if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->exit_direction}"); continue; } $this->importModel($row, BendingModel::TYPE_SHUTTERBOX, "SB-{$row->num}", $this->buildShutterboxData($row)); $total++; } // 3. bottombar $bottombars = DB::connection('chandj')->table('bottombar') ->where(function ($q) { $q->whereNull('is_deleted')->orWhere('is_deleted', ''); }) ->orderBy('num')->get(); $this->info("\n=== 하단마감재: {$bottombars->count()}건 ==="); foreach ($bottombars as $row) { if ($dryRun) { $this->line(" [DRY] #{$row->num} {$row->model_name}"); continue; } $this->importModel($row, BendingModel::TYPE_BOTTOMBAR, "BB-{$row->num}", $this->buildBottombarData($row)); $total++; } // assembly_image 파일 매핑 업데이트 if (! $dryRun && ! empty($oldFileMap)) { $this->remapAssemblyImages($oldFileMap); } // 최종 결과 $this->newLine(); $final = BendingModel::where('tenant_id', $this->tenantId)->count(); $assemblyFiles = File::where('document_type', 'bending_model')->where('field_key', 'assembly_image')->whereNull('deleted_at')->count(); $compFiles = File::where('document_type', 'bending_model')->where('field_key', 'component_image')->whereNull('deleted_at')->count(); $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); $this->info("모델: {$final}건 / 조립도: {$assemblyFiles}건 / 부품이미지: {$compFiles}건"); $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); return 0; } private function importModel(object $row, string $type, string $code, array $data): void { $components = json_decode($row->bending_components ?? '[]', true) ?: []; $materialSummary = json_decode($row->material_summary ?? '{}', true) ?: []; // component별 이미지 복사 $components = $this->copyComponentImages($components); $bm = BendingModel::create(array_merge($data, [ 'tenant_id' => $this->tenantId, 'model_type' => $type, 'code' => $code, 'legacy_num' => $row->num, 'components' => $components, 'material_summary' => $materialSummary, 'registration_date' => $row->registration_date ?? null, 'author' => $this->clean($row->author ?? null), 'remark' => $this->clean($row->remark ?? null), 'search_keyword' => $this->clean($row->search_keyword ?? null), 'is_active' => true, 'created_by' => 1, ])); // assembly_image 업로드 (JSON 파일에서) $this->uploadAssemblyImage($bm, $type, $data); $compCount = count($components); $imgCount = collect($components)->whereNotNull('image_file_id')->count(); $hasAssembly = File::where('document_type', 'bending_model')->where('document_id', $bm->id)->where('field_key', 'assembly_image')->exists(); $this->line(" ✅ #{$row->num} → {$bm->id} ({$data['name']}) [부품:{$compCount} 이미지:{$imgCount} 조립도:" . ($hasAssembly ? 'Y' : 'N') . ']'); } private function copyComponentImages(array $components): array { foreach ($components as &$comp) { $sourceNum = $comp['num'] ?? $comp['source_num'] ?? null; if (! $sourceNum) { continue; } // sam_item_id 매핑 (원본수정 링크용) $samItemId = $this->itemIdMap[(int) $sourceNum] ?? null; if ($samItemId) { $comp['sam_item_id'] = $samItemId; } $sourceFile = $this->itemImageMap[(int) $sourceNum] ?? null; if (! $sourceFile || ! $sourceFile->file_path) { continue; } try { $extension = pathinfo($sourceFile->stored_name, PATHINFO_EXTENSION); $storedName = bin2hex(random_bytes(8)) . '.' . $extension; $directory = sprintf('%d/bending/model-parts/%s/%s', $this->tenantId, date('Y'), date('m')); $newPath = $directory . '/' . $storedName; $content = Storage::disk('r2')->get($sourceFile->file_path); Storage::disk('r2')->put($newPath, $content); $newFile = File::create([ 'tenant_id' => $this->tenantId, 'display_name' => $sourceFile->display_name, 'stored_name' => $storedName, 'file_path' => $newPath, 'file_size' => $sourceFile->file_size, 'mime_type' => $sourceFile->mime_type, 'file_type' => 'image', 'field_key' => 'component_image', 'document_id' => 0, // 모델 생성 전이므로 임시, 나중에 update 불필요 (component JSON에 ID 저장) 'document_type' => 'bending_model', 'is_temp' => false, 'uploaded_by' => 1, 'created_by' => 1, ]); $comp['image_file_id'] = $newFile->id; } catch (\Throwable $e) { // 복사 실패 시 무시 } } unset($comp); return $components; } private function buildItemImageMap(): void { $items = BendingItem::where('tenant_id', $this->tenantId) ->whereNotNull('legacy_bending_id') ->get(); foreach ($items as $bi) { $file = File::where('document_type', 'bending_item') ->where('document_id', $bi->id) ->where('field_key', 'bending_diagram') ->whereNull('deleted_at') ->first(); $this->itemIdMap[$bi->legacy_bending_id] = $bi->id; if ($file) { $this->itemImageMap[$bi->legacy_bending_id] = $file; } } $this->info("기초관리 매핑: " . count($this->itemIdMap) . "건 (이미지: " . count($this->itemImageMap) . "건)"); } private function buildOldFileMap(): array { return File::where('document_type', 'bending_model') ->where('field_key', 'assembly_image') ->whereNull('deleted_at') ->get() ->mapWithKeys(function ($file) { $bm = BendingModel::find($file->document_id); return $bm ? [$bm->legacy_num => $file->document_id] : []; })->toArray(); } private function remapAssemblyImages(array $oldFileMap): void { $remapped = 0; $newModels = BendingModel::where('tenant_id', $this->tenantId)->get()->keyBy('legacy_num'); foreach ($oldFileMap as $legacyNum => $oldDocId) { $newBm = $newModels[$legacyNum] ?? null; if ($newBm && $oldDocId !== $newBm->id) { File::where('document_type', 'bending_model') ->where('field_key', 'assembly_image') ->where('document_id', $oldDocId) ->whereNull('deleted_at') ->update(['document_id' => $newBm->id]); $remapped++; } } $this->info("조립도 매핑 업데이트: {$remapped}건"); } private function loadModelImageJsons(): void { $jsonFiles = [ 'guiderail' => $this->legacyPath . '/guiderail/guiderail.json', 'shutterbox' => $this->legacyPath . '/shutterbox/shutterbox.json', 'bottombar' => $this->legacyPath . '/bottombar/bottombar.json', ]; foreach ($jsonFiles as $type => $path) { if (! file_exists($path)) { continue; } $items = json_decode(file_get_contents($path), true) ?: []; foreach ($items as $item) { $key = $this->makeImageKey($type, $item); if ($key && ! empty($item['image'])) { $this->modelImageMap[$key] = $item['image']; } } } $this->info("모델 이미지 매핑: " . count($this->modelImageMap) . "건"); } private function makeImageKey(string $type, array $item): ?string { if ($type === 'guiderail') { return "GR:{$item['model_name']}:{$item['check_type']}:{$item['finishing_type']}"; } if ($type === 'shutterbox') { return "SB:{$item['exit_direction']}:{$item['box_width']}x{$item['box_height']}"; } if ($type === 'bottombar') { return "BB:{$item['model_name']}:{$item['finishing_type']}"; } return null; } private function uploadAssemblyImage(BendingModel $bm, string $type, array $data): void { $key = match ($type) { BendingModel::TYPE_GUIDERAIL => "GR:{$data['model_name']}:{$data['check_type']}:{$data['finishing_type']}", BendingModel::TYPE_SHUTTERBOX => "SB:{$data['exit_direction']}:" . intval($data['box_width'] ?? 0) . 'x' . intval($data['box_height'] ?? 0), BendingModel::TYPE_BOTTOMBAR => "BB:{$data['model_name']}:{$data['finishing_type']}", default => null, }; if (! $key) return; $imagePath = $this->modelImageMap[$key] ?? null; if (! $imagePath) return; // /bottombar/images/xxx.png → legacy-path/bottombar/images/xxx.png $localPath = $this->legacyPath . $imagePath; if (! file_exists($localPath)) return; try { $extension = pathinfo($localPath, PATHINFO_EXTENSION); $storedName = bin2hex(random_bytes(8)) . '.' . $extension; $directory = sprintf('%d/bending/models/%s/%s', $this->tenantId, date('Y'), date('m')); $r2Path = $directory . '/' . $storedName; Storage::disk('r2')->put($r2Path, file_get_contents($localPath)); File::create([ 'tenant_id' => $this->tenantId, 'display_name' => basename($imagePath), 'stored_name' => $storedName, 'file_path' => $r2Path, 'file_size' => filesize($localPath), 'mime_type' => mime_content_type($localPath), 'file_type' => 'image', 'field_key' => 'assembly_image', 'document_id' => $bm->id, 'document_type' => 'bending_model', 'is_temp' => false, 'uploaded_by' => 1, 'created_by' => 1, ]); } catch (\Throwable $e) { $this->warn(" ⚠️ 조립도 업로드 실패: {$bm->name} — {$e->getMessage()}"); } } // ── 모델별 데이터 빌드 ── private function buildGuiderailData(object $row): array { return [ 'name' => trim("{$row->model_name} {$row->firstitem} {$row->check_type} {$row->finishing_type}"), 'model_name' => $this->clean($row->model_name), 'model_UA' => $this->clean($row->model_UA), 'item_sep' => $this->clean($row->firstitem), 'finishing_type' => $this->clean($row->finishing_type), 'check_type' => $this->clean($row->check_type), 'rail_width' => $this->toNum($row->rail_width), 'rail_length' => $this->toNum($row->rail_length), ]; } private function buildShutterboxData(object $row): array { return [ 'name' => trim("케이스 {$row->exit_direction} {$row->box_width}x{$row->box_height}"), 'exit_direction' => $this->clean($row->exit_direction), 'front_bottom_width' => $this->toNum($row->front_bottom_width ?? null), 'rail_width' => $this->toNum($row->rail_width ?? null), 'box_width' => $this->toNum($row->box_width), 'box_height' => $this->toNum($row->box_height), ]; } private function buildBottombarData(object $row): array { return [ 'name' => trim("{$row->model_name} {$row->firstitem} {$row->finishing_type} {$row->bar_width}x{$row->bar_height}"), 'model_name' => $this->clean($row->model_name), 'model_UA' => $this->clean($row->model_UA), 'item_sep' => $this->clean($row->firstitem), 'finishing_type' => $this->clean($row->finishing_type), 'bar_width' => $this->toNum($row->bar_width), 'bar_height' => $this->toNum($row->bar_height), ]; } 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; } }