diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 1b2ceb58..56a8c971 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-03-17 14:05:31 +> **자동 생성**: 2026-03-19 16:29:38 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -88,6 +88,17 @@ ### hometax_invoice_journals - **tenant()**: belongsTo → `tenants` - **invoice()**: belongsTo → `hometax_invoices` +### bending_data_rows +**모델**: `App\Models\BendingDataRow` + +- **bendingItem()**: belongsTo → `bending_items` + +### bending_items +**모델**: `App\Models\BendingItem` + +- **bendingData()**: hasMany → `bending_data` +- **files()**: hasMany → `files` + ### biddings **모델**: `App\Models\Bidding\Bidding` @@ -723,11 +734,6 @@ ### process_steps - **process()**: belongsTo → `processes` -### bending_item_mappings -**모델**: `App\Models\Production\BendingItemMapping` - -- **item()**: belongsTo → `items` - ### work_orders **모델**: `App\Models\Production\WorkOrder` diff --git a/app/Console/Commands/BendingCleanReimport.php b/app/Console/Commands/BendingCleanReimport.php new file mode 100644 index 00000000..875d6b01 --- /dev/null +++ b/app/Console/Commands/BendingCleanReimport.php @@ -0,0 +1,334 @@ +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; + } +} diff --git a/app/Console/Commands/BendingFillOptions.php b/app/Console/Commands/BendingFillOptions.php index c08e35bc..3d008ce5 100644 --- a/app/Console/Commands/BendingFillOptions.php +++ b/app/Console/Commands/BendingFillOptions.php @@ -204,6 +204,9 @@ private function resolveOptions(string $code, string $name, array $existing): ?a $new[$key] = $value; } } + if (empty($existing['item_name'])) { + $new['item_name'] = $name; + } return $new; } @@ -222,10 +225,54 @@ private function resolveOptions(string $code, string $name, array $existing): ?a // 한글 패턴별 추가 파싱 $this->parseKoreanPattern($code, $patternPrefix, $existing, $new); + // item_name 폴백: options에 없으면 items.name 사용 + if (empty($existing['item_name']) && empty($new['item_name'])) { + $new['item_name'] = $name; + } + return $new; } } + // 패턴 C: BD-LEGACY-NUM → chandj.bending에서 직접 조회 + if (preg_match('/^BD-LEGACY-(\d+)$/', $code, $m)) { + $chandjNum = (int) $m[1]; + $chandjRow = DB::connection('chandj')->table('bending') + ->where('num', $chandjNum) + ->first(); + + if ($chandjRow) { + $fields = [ + 'item_name' => $chandjRow->itemName ?? $chandjRow->item_name ?? null, + 'item_sep' => $chandjRow->item_sep ?? null, + 'item_bending' => $chandjRow->item_bending ?? null, + 'material' => $chandjRow->material ?? null, + 'item_spec' => $chandjRow->item_spec ?? null, + 'model_name' => $chandjRow->model_name ?? null, + 'model_UA' => $chandjRow->model_UA ?? null, + 'rail_width' => $chandjRow->rail_width ?? null, + 'search_keyword' => $chandjRow->search_keyword ?? null, + 'legacy_bending_num' => $chandjNum, + ]; + foreach ($fields as $key => $value) { + if (! empty($value) && empty($existing[$key])) { + $new[$key] = $value; + } + } + // item_name 폴백: chandj에도 없으면 items.name 사용 + if (empty($new['item_name']) && empty($existing['item_name'])) { + $new['item_name'] = $name; + } + } else { + // chandj에 없으면 items.name으로 폴백 + if (empty($existing['item_name'])) { + $new['item_name'] = $name; + } + } + + return $new; + } + return null; } diff --git a/app/Console/Commands/BendingFillSamItemIds.php b/app/Console/Commands/BendingFillSamItemIds.php new file mode 100644 index 00000000..b00a8c5f --- /dev/null +++ b/app/Console/Commands/BendingFillSamItemIds.php @@ -0,0 +1,101 @@ +option('tenant_id'); + $dryRun = $this->option('dry-run'); + + $this->info('=== sam_item_id 일괄 매핑 ==='); + $this->info('Mode: ' . ($dryRun ? 'DRY-RUN' : 'LIVE')); + + // 1. legacy_bending_num → SAM item ID 매핑 테이블 구축 + $bendingItems = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('item_category', 'BENDING') + ->whereNull('deleted_at') + ->get(); + + $legacyMap = []; + foreach ($bendingItems as $item) { + $legacyNum = $item->getOption('legacy_bending_num'); + if ($legacyNum !== null) { + $legacyMap[(string) $legacyNum] = $item->id; + } + } + $this->info("BENDING items: {$bendingItems->count()}건, legacy_bending_num 매핑: " . count($legacyMap) . '건'); + + // 2. 모델 items의 components 순회 + $models = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->whereIn('item_category', ['GUIDERAIL_MODEL', 'SHUTTERBOX_MODEL', 'BOTTOMBAR_MODEL']) + ->whereNull('deleted_at') + ->get(); + + $this->info("모델: {$models->count()}건"); + + $updated = 0; + $mapped = 0; + $notFound = 0; + + foreach ($models as $model) { + $components = $model->getOption('components', []); + if (empty($components)) { + continue; + } + + $changed = false; + foreach ($components as &$comp) { + // 이미 sam_item_id가 있으면 스킵 + if (! empty($comp['sam_item_id'])) { + continue; + } + + $legacyNum = $comp['legacy_bending_num'] ?? null; + if ($legacyNum === null) { + continue; + } + + $samId = $legacyMap[(string) $legacyNum] ?? null; + if ($samId) { + $comp['sam_item_id'] = $samId; + $changed = true; + $mapped++; + } else { + $notFound++; + $this->warn(" [{$model->id}] legacy_bending_num={$legacyNum} → SAM ID 없음 ({$comp['itemName']})"); + } + } + unset($comp); + + if ($changed && ! $dryRun) { + $model->setOption('components', $components); + $model->save(); + $updated++; + } elseif ($changed) { + $updated++; + } + } + + $this->info(''); + $this->info("결과: 모델 {$updated}건 업데이트, 컴포넌트 {$mapped}건 매핑, {$notFound}건 미매핑"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/BendingImportImages.php b/app/Console/Commands/BendingImportImages.php index 6626ce58..7f94c08e 100644 --- a/app/Console/Commands/BendingImportImages.php +++ b/app/Console/Commands/BendingImportImages.php @@ -2,168 +2,114 @@ namespace App\Console\Commands; +use App\Models\BendingItem; use App\Models\Commons\File; -use Illuminate\Console\Attributes\AsCommand; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; /** - * 레거시 5130 절곡 이미지 → SAM R2 + files 테이블 마이그레이션 + * 레거시 이미지 → R2 업로드 + bending_items 연결 * - * 소스: https://5130.codebridge-x.com/bending/img/{imgdata} - * 대상: R2 저장 + files 테이블 + items.options 업데이트 + * 실행: php artisan bending:import-images [--dry-run] [--tenant_id=287] */ -#[AsCommand(name: 'bending:import-images', description: '레거시 절곡 이미지 → R2 마이그레이션')] class BendingImportImages extends Command { protected $signature = 'bending:import-images - {--tenant_id=287 : Target tenant ID} - {--dry-run : 실제 저장 없이 미리보기} - {--source=https://5130.codebridge-x.com/bending/img : 이미지 소스 URL}'; + {--tenant_id=287 : 테넌트 ID} + {--dry-run : 미리보기} + {--legacy-path=/home/kkk/sam/5130/bending/img : 레거시 이미지 경로}'; - private int $uploaded = 0; - - private int $skipped = 0; - - private int $failed = 0; + protected $description = '레거시 절곡품 이미지 → R2 업로드 + bending_items 연결'; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); - $sourceBase = rtrim($this->option('source'), '/'); + $legacyPath = $this->option('legacy-path'); - $this->info('=== 레거시 절곡 이미지 → R2 마이그레이션 ==='); - $this->info('Source: '.$sourceBase); - $this->info('Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE')); - $this->newLine(); + $items = BendingItem::where('tenant_id', $tenantId) + ->whereNotNull('legacy_bending_id') + ->get(); - // 1. BENDING 아이템에서 legacy_bending_num이 있는 것 조회 - $items = DB::table('items') - ->where('tenant_id', $tenantId) - ->where('item_category', 'BENDING') - ->whereNull('deleted_at') - ->get(['id', 'code', 'options']); - - $this->info("BENDING 아이템: {$items->count()}건"); - - // legacy_bending_num → chandj imgdata 매핑 - $chandjImages = DB::connection('chandj')->table('bending') - ->whereNull('is_deleted') + $chandjMap = DB::connection('chandj')->table('bending') ->whereNotNull('imgdata') ->where('imgdata', '!=', '') ->pluck('imgdata', 'num'); - $this->info("chandj 이미지: {$chandjImages->count()}건"); - $this->newLine(); + $this->info("bending_items: {$items->count()}건 / chandj imgdata: {$chandjMap->count()}건"); - foreach ($items as $item) { - $opts = json_decode($item->options ?? '{}', true) ?: []; - $legacyNum = $opts['legacy_bending_num'] ?? null; + $uploaded = 0; + $skipped = 0; + $notFound = 0; + $errors = 0; - if (! $legacyNum || ! isset($chandjImages[$legacyNum])) { + foreach ($items as $bi) { + $imgFile = $chandjMap[$bi->legacy_bending_id] ?? null; + if (! $imgFile) { + $skipped++; continue; } - // 이미 파일이 연결되어 있으면 스킵 - $existingFile = File::where('tenant_id', $tenantId) - ->where('document_type', '1') - ->where('document_id', $item->id) + $filePath = "{$legacyPath}/{$imgFile}"; + if (! file_exists($filePath)) { + $this->warn(" ⚠️ 파일 없음: {$imgFile} (#{$bi->legacy_bending_id})"); + $notFound++; + continue; + } + + $existing = File::where('document_type', 'bending_item') + ->where('document_id', $bi->id) ->where('field_key', 'bending_diagram') ->whereNull('deleted_at') ->first(); - if ($existingFile) { - $this->skipped++; - + if ($existing) { + $skipped++; continue; } - $imgFilename = $chandjImages[$legacyNum]; - $imageUrl = "{$sourceBase}/{$imgFilename}"; - if ($dryRun) { - $this->line(" ✅ {$item->code} ← {$imgFilename}"); - $this->uploaded++; - + $this->line(" [DRY] #{$bi->legacy_bending_id} → {$bi->id} ({$bi->item_name}) ← {$imgFile}"); + $uploaded++; continue; } - // 이미지 다운로드 try { - $response = Http::withoutVerifying()->timeout(15)->get($imageUrl); + $extension = pathinfo($imgFile, PATHINFO_EXTENSION); + $storedName = bin2hex(random_bytes(8)) . '.' . $extension; + $directory = sprintf('%d/bending/%s/%s', $tenantId, date('Y'), date('m')); + $r2Path = $directory . '/' . $storedName; - if (! $response->successful()) { - $this->warn(" ❌ {$item->code}: HTTP {$response->status()} ({$imageUrl})"); - $this->failed++; + Storage::disk('r2')->put($r2Path, file_get_contents($filePath)); - continue; - } - - $imageContent = $response->body(); - $mimeType = $response->header('Content-Type', 'image/png'); - $extension = $this->getExtension($imgFilename, $mimeType); - - // R2 저장 - $storedName = bin2hex(random_bytes(8)).'.'.$extension; - $year = date('Y'); - $month = date('m'); - $directory = sprintf('%d/items/%s/%s', $tenantId, $year, $month); - $filePath = $directory.'/'.$storedName; - - Storage::disk('r2')->put($filePath, $imageContent); - - // files 테이블 저장 - $file = File::create([ + File::create([ 'tenant_id' => $tenantId, - 'display_name' => $imgFilename, + 'display_name' => $imgFile, 'stored_name' => $storedName, - 'file_path' => $filePath, - 'file_size' => strlen($imageContent), - 'mime_type' => $mimeType, + 'file_path' => $r2Path, + 'file_size' => filesize($filePath), + 'mime_type' => mime_content_type($filePath), 'file_type' => 'image', 'field_key' => 'bending_diagram', - 'document_id' => $item->id, - 'document_type' => '1', + 'document_id' => $bi->id, + 'document_type' => 'bending_item', 'is_temp' => false, 'uploaded_by' => 1, 'created_by' => 1, ]); - $this->line(" ✅ {$item->code} ← {$imgFilename} → file_id={$file->id}"); - $this->uploaded++; - } catch (\Exception $e) { - $this->error(" ❌ {$item->code}: {$e->getMessage()}"); - $this->failed++; + $this->line(" ✅ #{$bi->legacy_bending_id} → {$bi->id} ({$bi->item_name}) ← {$imgFile}"); + $uploaded++; + } catch (\Throwable $e) { + $this->error(" ❌ #{$bi->legacy_bending_id}: {$e->getMessage()}"); + $errors++; } } $this->newLine(); - $this->info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - $this->info("업로드: {$this->uploaded}건 | 스킵(이미 있음): {$this->skipped}건 | 실패: {$this->failed}건"); + $this->info("완료: 업로드 {$uploaded}, 스킵 {$skipped}, 파일없음 {$notFound}, 오류 {$errors}"); - if ($dryRun) { - $this->info('🔍 DRY-RUN 완료.'); - } - - return self::SUCCESS; - } - - private function getExtension(string $filename, string $mimeType): string - { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - if ($ext) { - return strtolower($ext); - } - - return match ($mimeType) { - 'image/jpeg' => 'jpg', - 'image/png' => 'png', - 'image/gif' => 'gif', - 'image/webp' => 'webp', - default => 'png', - }; + return 0; } } diff --git a/app/Console/Commands/BendingImportMissing.php b/app/Console/Commands/BendingImportMissing.php new file mode 100644 index 00000000..22b9cffc --- /dev/null +++ b/app/Console/Commands/BendingImportMissing.php @@ -0,0 +1,227 @@ +tenantId = (int) $this->option('tenant_id'); + $dryRun = $this->option('dry-run'); + + $existingNums = BendingItem::where('tenant_id', $this->tenantId) + ->whereNotNull('legacy_bending_id') + ->pluck('legacy_bending_id') + ->toArray(); + + $missing = DB::connection('chandj')->table('bending') + ->where(function ($q) { + $q->whereNull('is_deleted')->orWhere('is_deleted', 0); + }) + ->whereNotIn('num', $existingNums) + ->orderBy('num') + ->get(); + + $this->info("누락분: {$missing->count()}건 (이미 매핑: " . count($existingNums) . "건)"); + + if ($dryRun) { + $this->preview($missing); + return 0; + } + + $success = 0; + $bdCount = 0; + $errors = 0; + + DB::transaction(function () use ($missing, &$success, &$bdCount, &$errors) { + foreach ($missing as $row) { + try { + $bi = $this->importItem($row); + $bd = $this->importBendingData($bi, $row); + $bdCount += $bd; + $success++; + } catch (\Throwable $e) { + $this->error(" ❌ #{$row->num} {$row->itemName}: {$e->getMessage()}"); + $errors++; + } + } + }); + + $this->newLine(); + $this->info("완료: {$success}건 이관, {$bdCount}건 전개도, {$errors}건 오류"); + + return $errors > 0 ? 1 : 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) ?: []; + + $count = count($inputs); + for ($i = 0; $i < $count; $i++) { + $input = (float) ($inputs[$i] ?? 0); + $rate = (string) ($rates[$i] ?? ''); + $afterRate = ($rate !== '') ? $input + (float) $rate : $input; + + BendingDataRow::create([ + 'bending_item_id' => $bi->id, + 'sort_order' => $i + 1, + 'input' => $input, + 'rate' => $rate !== '' ? $rate : null, + 'after_rate' => $afterRate, + 'sum' => (float) ($sums[$i] ?? 0), + 'color' => (bool) ($colors[$i] ?? false), + 'a_angle' => (bool) ($angles[$i] ?? false), + ]); + } + + return $count; + } + + 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 === '가이드레일' && str_contains($sep, '철재') => 'R', + $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($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)) + ->whereNull('length_code') + ->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 preview($missing): void + { + $grouped = $missing->groupBy(fn ($r) => ($r->item_bending ?: '미분류') . '/' . ($r->item_sep ?: '미분류')); + $this->table(['분류', '건수'], $grouped->map(fn ($g, $k) => [$k, $g->count()])->values()); + + $this->newLine(); + $headers = ['num', 'itemName', 'item_sep', 'item_bending', 'material', 'has_bd']; + $rows = $missing->take(15)->map(fn ($r) => [ + $r->num, + mb_substr($r->itemName ?? '', 0, 25), + $r->item_sep ?? '-', + $r->item_bending ?? '-', + mb_substr($r->material ?? '-', 0, 12), + ! empty(json_decode($r->inputList ?? '[]', true)) ? '✅' : '❌', + ]); + $this->table($headers, $rows); + } + + 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; + } +} diff --git a/app/Console/Commands/BendingModelCopyImages.php b/app/Console/Commands/BendingModelCopyImages.php new file mode 100644 index 00000000..9e1250e4 --- /dev/null +++ b/app/Console/Commands/BendingModelCopyImages.php @@ -0,0 +1,145 @@ +option('tenant_id'); + $dryRun = $this->option('dry-run'); + + // bending_items의 legacy_bending_id → 이미지 파일 매핑 + $itemImageMap = []; + $items = BendingItem::where('tenant_id', $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(); + + if ($file) { + $itemImageMap[$bi->legacy_bending_id] = $file; + } + } + + $this->info("기초관리 이미지 매핑: " . count($itemImageMap) . "건"); + + $models = BendingModel::where('tenant_id', $tenantId) + ->whereNotNull('components') + ->get(); + + $copied = 0; + $skipped = 0; + $noSource = 0; + + foreach ($models as $model) { + $components = $model->components; + if (empty($components)) { + continue; + } + + $updated = false; + foreach ($components as $idx => &$comp) { + // 이미 image_file_id가 있으면 skip + if (! empty($comp['image_file_id'])) { + $skipped++; + continue; + } + + // source_num으로 기초관리 이미지 찾기 + $sourceNum = $comp['num'] ?? $comp['source_num'] ?? null; + if (! $sourceNum) { + $noSource++; + continue; + } + + $sourceFile = $itemImageMap[(int) $sourceNum] ?? null; + if (! $sourceFile || ! $sourceFile->file_path) { + $noSource++; + continue; + } + + if ($dryRun) { + $this->line(" [DRY] model#{$model->id} comp[{$idx}] ← bending#{$sourceNum} file#{$sourceFile->id}"); + $copied++; + continue; + } + + // R2에서 파일 복사 + try { + $newFile = $this->copyFile($sourceFile, $model->id, $tenantId); + $comp['image_file_id'] = $newFile->id; + $updated = true; + $copied++; + } catch (\Throwable $e) { + $this->warn(" ⚠️ 복사 실패: model#{$model->id} comp[{$idx}] — {$e->getMessage()}"); + } + } + unset($comp); + + if ($updated && ! $dryRun) { + $model->components = $components; + $model->save(); + } + } + + $this->newLine(); + $this->info("완료: 복사 {$copied}건, 스킵 {$skipped}건, 소스없음 {$noSource}건"); + + return 0; + } + + private function copyFile(File $source, int $modelId, int $tenantId): File + { + $extension = pathinfo($source->stored_name, PATHINFO_EXTENSION); + $storedName = bin2hex(random_bytes(8)) . '.' . $extension; + $directory = sprintf('%d/bending/model-parts/%s/%s', $tenantId, date('Y'), date('m')); + $newPath = $directory . '/' . $storedName; + + // R2 파일 복사 + $content = Storage::disk('r2')->get($source->file_path); + Storage::disk('r2')->put($newPath, $content); + + // 새 파일 레코드 생성 + return File::create([ + 'tenant_id' => $tenantId, + 'display_name' => $source->display_name, + 'stored_name' => $storedName, + 'file_path' => $newPath, + 'file_size' => $source->file_size, + 'mime_type' => $source->mime_type, + 'file_type' => 'image', + 'field_key' => 'component_image', + 'document_id' => $modelId, + 'document_type' => 'bending_model', + 'is_temp' => false, + 'uploaded_by' => 1, + 'created_by' => 1, + ]); + } +} diff --git a/app/Console/Commands/BendingModelImport.php b/app/Console/Commands/BendingModelImport.php new file mode 100644 index 00000000..ce22c57e --- /dev/null +++ b/app/Console/Commands/BendingModelImport.php @@ -0,0 +1,387 @@ +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; + } +} diff --git a/app/Console/Commands/MigrateBendingItemsToNewTable.php b/app/Console/Commands/MigrateBendingItemsToNewTable.php new file mode 100644 index 00000000..029b987e --- /dev/null +++ b/app/Console/Commands/MigrateBendingItemsToNewTable.php @@ -0,0 +1,199 @@ +option('tenant_id'); + $dryRun = $this->option('dry-run'); + $rollback = $this->option('rollback'); + + if ($rollback) { + return $this->rollback($tenantId); + } + + // 이미 이관된 데이터 확인 + $existingCount = BendingItem::where('tenant_id', $tenantId)->count(); + if ($existingCount > 0) { + $this->warn("이미 bending_items에 {$existingCount}건 존재합니다."); + if (! $this->confirm('기존 데이터 삭제 후 재이관하시겠습니까?')) { + return 0; + } + $this->rollback($tenantId); + } + + // items(BENDING) 조회 + $items = Item::where('item_category', 'BENDING') + ->where('tenant_id', $tenantId) + ->get(); + + $this->info("이관 대상: {$items->count()}건"); + + if ($dryRun) { + $this->previewItems($items); + return 0; + } + + $success = 0; + $errors = 0; + $bdCount = 0; + + DB::transaction(function () use ($items, $tenantId, &$success, &$errors, &$bdCount) { + foreach ($items as $item) { + try { + $bi = $this->migrateItem($item, $tenantId); + $bdRows = $this->migrateBendingData($bi, $item); + $bdCount += $bdRows; + $success++; + } catch (\Throwable $e) { + $this->error(" ❌ {$item->code}: {$e->getMessage()}"); + $errors++; + } + } + }); + + $this->newLine(); + $this->info("완료: {$success}건 이관, {$bdCount}건 전개도 행, {$errors}건 오류"); + + return $errors > 0 ? 1 : 0; + } + + private function migrateItem(Item $item, int $tenantId): BendingItem + { + $opts = $item->options ?? []; + + // item_name: options.item_name → name 폴백 + $itemName = $opts['item_name'] ?? null; + if (empty($itemName) || $itemName === 'null') { + $itemName = $item->name; + } + + $bi = BendingItem::create([ + 'tenant_id' => $tenantId, + 'code' => $item->code, + 'legacy_code' => $item->code, + 'legacy_bending_id' => $opts['legacy_bending_num'] ?? null, + // 정규 컬럼 (options에서 승격) + 'item_name' => $itemName, + 'item_sep' => $this->cleanNull($opts['item_sep'] ?? null), + 'item_bending' => $this->cleanNull($opts['item_bending'] ?? null), + 'material' => $this->cleanNull($opts['material'] ?? null), + 'item_spec' => $this->cleanNull($opts['item_spec'] ?? null), + 'model_name' => $this->cleanNull($opts['model_name'] ?? null), + 'model_UA' => $this->cleanNull($opts['model_UA'] ?? null), + 'rail_width' => $this->toDecimal($opts['rail_width'] ?? null), + 'exit_direction' => $this->cleanNull($opts['exit_direction'] ?? null), + 'box_width' => $this->toDecimal($opts['box_width'] ?? null), + 'box_height' => $this->toDecimal($opts['box_height'] ?? null), + 'front_bottom' => $this->toDecimal($opts['front_bottom_width'] ?? $opts['front_bottom'] ?? null), + 'inspection_door' => $this->cleanNull($opts['inspection_door'] ?? null), + // 비정형 속성 + 'options' => $this->buildMetaOptions($opts), + 'is_active' => $item->is_active, + 'created_by' => $item->created_by, + 'updated_by' => $item->updated_by, + ]); + + $this->line(" ✅ {$item->code} → bending_items#{$bi->id} ({$itemName})"); + + return $bi; + } + + private function migrateBendingData(BendingItem $bi, Item $item): int + { + $opts = $item->options ?? []; + $bendingData = $opts['bendingData'] ?? []; + + if (empty($bendingData) || ! is_array($bendingData)) { + return 0; + } + + // bending_items.bending_data JSON 컬럼에 저장 + $bi->update(['bending_data' => $bendingData]); + + return count($bendingData); + } + + private function rollback(int $tenantId): int + { + $biCount = BendingItem::where('tenant_id', $tenantId)->count(); + BendingItem::where('tenant_id', $tenantId)->forceDelete(); + $this->info("롤백 완료: bending_items {$biCount}건 삭제"); + + return 0; + } + + private function previewItems($items): void + { + $headers = ['code', 'name', 'item_name(opts)', 'item_sep', 'material', 'has_bd']; + $rows = $items->take(20)->map(function ($item) { + $opts = $item->options ?? []; + return [ + $item->code, + mb_substr($item->name, 0, 20), + mb_substr($opts['item_name'] ?? '(NULL)', 0, 20), + $opts['item_sep'] ?? '-', + $opts['material'] ?? '-', + ! empty($opts['bendingData']) ? '✅' : '❌', + ]; + }); + $this->table($headers, $rows); + + $nullNameCount = $items->filter(fn ($i) => empty(($i->options ?? [])['item_name']))->count(); + $hasBdCount = $items->filter(fn ($i) => ! empty(($i->options ?? [])['bendingData']))->count(); + $this->info("item_name NULL: {$nullNameCount}건 (name 필드로 폴백)"); + $this->info("bendingData 있음: {$hasBdCount}건"); + } + + private function cleanNull(?string $value): ?string + { + if ($value === null || $value === 'null' || $value === '') { + return null; + } + return $value; + } + + private function toDecimal(mixed $value): ?float + { + if ($value === null || $value === 'null' || $value === '') { + return null; + } + return (float) $value; + } + + /** + * options에 남길 비정형 속성만 추출 + */ + private function buildMetaOptions(array $opts): ?array + { + $metaKeys = ['search_keyword', 'registration_date', 'author', 'memo', 'parent_num', 'modified_by']; + $meta = []; + foreach ($metaKeys as $key) { + $val = $opts[$key] ?? null; + if ($val !== null && $val !== 'null' && $val !== '') { + $meta[$key] = $val; + } + } + return empty($meta) ? null : $meta; + } +} diff --git a/app/Http/Controllers/Api/V1/GuiderailModelController.php b/app/Http/Controllers/Api/V1/GuiderailModelController.php index a5cb2728..2c862057 100644 --- a/app/Http/Controllers/Api/V1/GuiderailModelController.php +++ b/app/Http/Controllers/Api/V1/GuiderailModelController.php @@ -29,7 +29,7 @@ public function index(Request $request): JsonResponse $this->ensureContext($request); return ApiResponse::handle(function () use ($request) { - $params = $request->only(['item_category', 'item_sep', 'model_UA', 'check_type', 'model_name', 'search', 'page', 'size']); + $params = $request->only(['item_category', 'item_sep', 'model_UA', 'check_type', 'model_name', 'exit_direction', 'search', 'page', 'size']); $paginator = $this->service->list($params); $paginator->getCollection()->transform(fn ($item) => (new GuiderailModelResource($item))->resolve()); diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index 71b319d0..6ff64942 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Item\ItemFileUploadRequest; use App\Models\Commons\File; +use App\Models\BendingItem; +use App\Models\BendingModel; use App\Models\Items\Item; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -53,12 +55,13 @@ public function index(int $id, Request $request) $fieldKey = $request->input('field_key'); // 품목 존재 확인 - $this->getItemById($id, $tenantId); + $owner = $this->getItemById($id, $tenantId); + $docType = $this->getDocumentType($owner); // 파일 조회 $query = File::query() ->where('tenant_id', $tenantId) - ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_type', $docType) ->where('document_id', $id); // 특정 field_key만 조회 @@ -94,7 +97,8 @@ public function upload(int $id, ItemFileUploadRequest $request) $existingFileId = $validated['file_id'] ?? null; // 품목 존재 확인 - $this->getItemById($id, $tenantId); + $owner = $this->getItemById($id, $tenantId); + $docType = $this->getDocumentType($owner); $replaced = false; @@ -102,7 +106,7 @@ public function upload(int $id, ItemFileUploadRequest $request) if ($existingFileId) { $existingFile = File::query() ->where('tenant_id', $tenantId) - ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_type', $docType) ->where('document_id', $id) ->where('id', $existingFileId) ->first(); @@ -142,7 +146,7 @@ public function upload(int $id, ItemFileUploadRequest $request) 'file_type' => $fileType, // 파일 형식 (image, document, excel, archive) 'field_key' => $fieldKey, // 비즈니스 용도 (drawing, certificate 등) 'document_id' => $id, - 'document_type' => self::ITEM_GROUP_ID, // group_id + 'document_type' => $docType, 'is_temp' => false, 'uploaded_by' => $userId, 'created_by' => $userId, @@ -175,12 +179,13 @@ public function delete(int $id, mixed $fileId, Request $request) $tenantId = app('tenant_id'); // 품목 존재 확인 - $this->getItemById($id, $tenantId); + $owner = $this->getItemById($id, $tenantId); + $docType = $this->getDocumentType($owner); // 파일 조회 $file = File::query() ->where('tenant_id', $tenantId) - ->where('document_type', self::ITEM_GROUP_ID) + ->where('document_type', $docType) ->where('document_id', $id) ->where('id', $fileId) ->first(); @@ -200,19 +205,51 @@ public function delete(int $id, mixed $fileId, Request $request) } /** - * ID로 품목 조회 (통합 items 테이블) + * ID로 품목 조회 (items → bending_items 폴백) */ - private function getItemById(int $id, int $tenantId): Item + private function getItemById(int $id, int $tenantId): Item|BendingItem|BendingModel { $item = Item::query() ->where('tenant_id', $tenantId) ->find($id); - if (! $item) { - throw new NotFoundHttpException(__('error.not_found')); + if ($item) { + return $item; } - return $item; + // bending_items 폴백 + $bendingItem = BendingItem::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if ($bendingItem) { + return $bendingItem; + } + + // bending_models 폴백 + $bendingModel = BendingModel::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if ($bendingModel) { + return $bendingModel; + } + + throw new NotFoundHttpException(__('error.not_found')); + } + + /** + * 품목 유형에 따른 document_type 반환 + */ + private function getDocumentType(Item|BendingItem|BendingModel $item): string + { + if ($item instanceof BendingItem) { + return 'bending_item'; + } + if ($item instanceof BendingModel) { + return 'bending_model'; + } + return self::ITEM_GROUP_ID; } /** diff --git a/app/Http/Requests/Api/V1/BendingItemIndexRequest.php b/app/Http/Requests/Api/V1/BendingItemIndexRequest.php index bb704091..d6806101 100644 --- a/app/Http/Requests/Api/V1/BendingItemIndexRequest.php +++ b/app/Http/Requests/Api/V1/BendingItemIndexRequest.php @@ -19,6 +19,7 @@ public function rules(): array 'material' => 'nullable|string', 'model_UA' => 'nullable|string|in:인정,비인정', 'model_name' => 'nullable|string', + 'legacy_bending_num' => 'nullable|integer', 'search' => 'nullable|string|max:100', 'page' => 'nullable|integer|min:1', 'size' => 'nullable|integer|min:1|max:200', diff --git a/app/Http/Requests/Api/V1/BendingItemStoreRequest.php b/app/Http/Requests/Api/V1/BendingItemStoreRequest.php index 1818f1bb..302a13e2 100644 --- a/app/Http/Requests/Api/V1/BendingItemStoreRequest.php +++ b/app/Http/Requests/Api/V1/BendingItemStoreRequest.php @@ -14,9 +14,10 @@ public function authorize(): bool public function rules(): array { return [ - 'code' => 'required|string|max:100|unique:items,code', - 'name' => 'required|string|max:200', - 'unit' => 'nullable|string|max:20', + 'code' => [ + 'required', 'string', 'max:50', + \Illuminate\Validation\Rule::unique('bending_items', 'code')->where('tenant_id', request()->header('X-TENANT-ID', app()->bound('tenant_id') ? app('tenant_id') : 1)), + ], 'item_name' => 'required|string|max:50', 'item_sep' => 'required|in:스크린,철재', 'item_bending' => 'required|string|max:50', diff --git a/app/Http/Resources/Api/V1/BendingItemResource.php b/app/Http/Resources/Api/V1/BendingItemResource.php index e2f78016..14ad47d4 100644 --- a/app/Http/Resources/Api/V1/BendingItemResource.php +++ b/app/Http/Resources/Api/V1/BendingItemResource.php @@ -12,61 +12,61 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'code' => $this->code, - 'name' => $this->name, - 'item_type' => $this->item_type, - 'item_category' => $this->item_category, - 'unit' => $this->unit, - 'is_active' => $this->is_active, - // options → 최상위로 노출 - 'item_name' => $this->getOption('item_name'), - 'item_sep' => $this->getOption('item_sep'), - 'item_bending' => $this->getOption('item_bending'), - 'item_spec' => $this->getOption('item_spec'), - 'material' => $this->getOption('material'), - 'model_name' => $this->getOption('model_name'), - 'model_UA' => $this->getOption('model_UA'), + 'legacy_code' => $this->legacy_code, + // 정규 컬럼 직접 참조 + 'item_name' => $this->item_name, + 'item_sep' => $this->item_sep, + 'item_bending' => $this->item_bending, + 'item_spec' => $this->item_spec, + 'material' => $this->material, + 'model_name' => $this->model_name, + 'model_UA' => $this->model_UA, + 'rail_width' => $this->rail_width ? (int) $this->rail_width : null, + // 케이스 전용 + 'exit_direction' => $this->exit_direction, + 'front_bottom' => $this->front_bottom ? (int) $this->front_bottom : null, + 'box_width' => $this->box_width ? (int) $this->box_width : null, + 'box_height' => $this->box_height ? (int) $this->box_height : null, + 'inspection_door' => $this->inspection_door, + // 원자재 길이 + 'length_code' => $this->length_code, + 'length_mm' => $this->length_mm, + // 전개도 (JSON 컬럼) + 'bendingData' => $this->bending_data, + // 비정형 속성 (options) 'search_keyword' => $this->getOption('search_keyword'), - 'rail_width' => $this->getOption('rail_width'), - 'registration_date' => $this->getOption('registration_date'), 'author' => $this->getOption('author'), 'memo' => $this->getOption('memo'), - // 케이스 전용 - 'exit_direction' => $this->getOption('exit_direction'), - 'front_bottom_width' => $this->getOption('front_bottom_width'), - 'box_width' => $this->getOption('box_width'), - 'box_height' => $this->getOption('box_height'), - // 전개도 - 'bendingData' => $this->getOption('bendingData'), - // PREFIX 관련 - 'prefix' => $this->getOption('prefix'), - 'length_code' => $this->getOption('length_code'), - 'length_mm' => $this->getOption('length_mm'), + 'registration_date' => $this->getOption('registration_date'), + // 이미지 + 'image_file_id' => $this->getImageFileId(), // 추적 - 'legacy_bending_num' => $this->getOption('legacy_bending_num'), + 'legacy_bending_id' => $this->legacy_bending_id, + 'legacy_bending_num' => $this->legacy_bending_id, // MNG2 호환 + 'modified_by' => $this->getOption('modified_by'), + // MNG2 호환 (items 기반 필드명) + 'name' => $this->item_name, + 'front_bottom_width' => $this->front_bottom ? (int) $this->front_bottom : null, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', // 계산값 - 'width_sum' => $this->getWidthSum(), - 'bend_count' => $this->getBendCount(), + 'width_sum' => $this->width_sum, + 'bend_count' => $this->bend_count, // 메타 + 'is_active' => $this->is_active, 'created_at' => $this->created_at?->format('Y-m-d H:i:s'), 'updated_at' => $this->updated_at?->format('Y-m-d H:i:s'), ]; } - private function getWidthSum(): ?int + private function getImageFileId(): ?int { - $data = $this->getOption('bendingData', []); - if (empty($data)) { - return null; - } - $last = end($data); + $file = $this->files() + ->where('field_key', 'bending_diagram') + ->orderByDesc('id') + ->first(); - return isset($last['sum']) ? (int) $last['sum'] : null; - } - - private function getBendCount(): int - { - $data = $this->getOption('bendingData', []); - - return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== '')); + return $file?->id; } } diff --git a/app/Http/Resources/Api/V1/GuiderailModelResource.php b/app/Http/Resources/Api/V1/GuiderailModelResource.php index 5e54912f..d30f404f 100644 --- a/app/Http/Resources/Api/V1/GuiderailModelResource.php +++ b/app/Http/Resources/Api/V1/GuiderailModelResource.php @@ -9,10 +9,9 @@ class GuiderailModelResource extends JsonResource { public function toArray(Request $request): array { - $components = $this->getOption('components', []); - $materialSummary = $this->getOption('material_summary'); + $components = $this->components ?? []; + $materialSummary = $this->material_summary; - // material_summary가 없으면 components에서 계산 if (empty($materialSummary) && ! empty($components)) { $materialSummary = $this->calcMaterialSummary($components); } @@ -21,29 +20,34 @@ public function toArray(Request $request): array 'id' => $this->id, 'code' => $this->code, 'name' => $this->name, - 'item_type' => $this->item_type, - 'item_category' => $this->item_category, 'is_active' => $this->is_active, - // 모델 속성 - 'model_name' => $this->getOption('model_name'), - 'check_type' => $this->getOption('check_type'), - 'rail_width' => $this->getOption('rail_width'), - 'rail_length' => $this->getOption('rail_length'), - 'finishing_type' => $this->getOption('finishing_type'), - 'item_sep' => $this->getOption('item_sep'), - 'model_UA' => $this->getOption('model_UA'), - 'search_keyword' => $this->getOption('search_keyword'), - 'author' => $this->getOption('author'), + // MNG2 호환 + 'item_type' => 'FG', + 'item_category' => $this->model_type, + // 모델 속성 (정규 컬럼) + 'model_name' => $this->model_name, + 'check_type' => $this->check_type, + 'rail_width' => $this->rail_width ? (int) $this->rail_width : null, + 'rail_length' => $this->rail_length ? (int) $this->rail_length : null, + 'finishing_type' => $this->finishing_type, + 'item_sep' => $this->item_sep, + 'model_UA' => $this->model_UA, + 'search_keyword' => $this->search_keyword, + 'author' => $this->author, 'memo' => $this->getOption('memo'), - 'registration_date' => $this->getOption('registration_date'), - // 케이스(SHUTTERBOX_MODEL) 전용 - 'exit_direction' => $this->getOption('exit_direction'), - 'front_bottom_width' => $this->getOption('front_bottom_width'), - 'box_width' => $this->getOption('box_width'), - 'box_height' => $this->getOption('box_height'), - // 하단마감재(BOTTOMBAR_MODEL) 전용 - 'bar_width' => $this->getOption('bar_width'), - 'bar_height' => $this->getOption('bar_height'), + 'registration_date' => $this->registration_date?->format('Y-m-d'), + // 케이스 전용 + 'exit_direction' => $this->exit_direction, + 'front_bottom_width' => $this->front_bottom_width ? (int) $this->front_bottom_width : null, + 'box_width' => $this->box_width ? (int) $this->box_width : null, + 'box_height' => $this->box_height ? (int) $this->box_height : null, + // 하단마감재 전용 + 'bar_width' => $this->bar_width ? (int) $this->bar_width : null, + 'bar_height' => $this->bar_height ? (int) $this->bar_height : null, + // 수정자 + 'modified_by' => $this->getOption('modified_by'), + // 이미지 + 'image_file_id' => $this->getImageFileId(), // 부품 조합 'components' => $components, 'material_summary' => $materialSummary, @@ -54,18 +58,36 @@ public function toArray(Request $request): array ]; } + private function getImageFileId(): ?int + { + $file = \App\Models\Commons\File::where('document_id', $this->id) + ->where('document_type', 'bending_model') + ->where('field_key', 'assembly_image') + ->whereNull('deleted_at') + ->orderByDesc('id') + ->first(); + + if (! $file) { + $file = $this->files() + ->where('field_key', 'bending_diagram') + ->orderByDesc('id') + ->first(); + } + + return $file?->id; + } + private function calcMaterialSummary(array $components): array { $summary = []; foreach ($components as $comp) { $material = $comp['material'] ?? null; - $widthSum = $comp['width_sum'] ?? 0; + $widthSum = $comp['widthsum'] ?? $comp['width_sum'] ?? 0; $qty = $comp['quantity'] ?? 1; if ($material && $widthSum) { $summary[$material] = ($summary[$material] ?? 0) + ($widthSum * $qty); } } - return $summary; } } diff --git a/app/Models/BendingItem.php b/app/Models/BendingItem.php new file mode 100644 index 00000000..06aab5d1 --- /dev/null +++ b/app/Models/BendingItem.php @@ -0,0 +1,136 @@ + 'array', + 'options' => 'array', + 'is_active' => 'boolean', + 'rail_width' => 'decimal:2', + 'box_width' => 'decimal:2', + 'box_height' => 'decimal:2', + 'front_bottom' => 'decimal:2', + ]; + + protected $hidden = ['deleted_at']; + + // ────────────────────────────────────────────────────────────── + // 관계 + // ────────────────────────────────────────────────────────────── + + public function files(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'bending_item'); + } + + // ────────────────────────────────────────────────────────────── + // options 헬퍼 + // ────────────────────────────────────────────────────────────── + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + + return $this; + } + + // ────────────────────────────────────────────────────────────── + // 계산 accessor + // ────────────────────────────────────────────────────────────── + + public function getWidthSumAttribute(): ?float + { + $data = $this->bending_data ?? []; + if (empty($data)) { + return null; + } + $last = end($data); + + return isset($last['sum']) ? (float) $last['sum'] : null; + } + + public function getBendCountAttribute(): int + { + $data = $this->bending_data ?? []; + + return count(array_filter($data, fn ($d) => ($d['rate'] ?? '') !== '')); + } + + // ────────────────────────────────────────────────────────────── + // LOT 코드 테이블 + // ────────────────────────────────────────────────────────────── + + public const PROD_CODES = [ + 'R' => '가이드레일(벽면형)', + 'S' => '가이드레일(측면형)', + 'C' => '케이스', + 'B' => '하단마감재(스크린)', + 'T' => '하단마감재(철재)', + 'L' => 'L-Bar', + 'G' => '연기차단재', + ]; + + public const SPEC_CODES = [ + 'R' => ['M' => '본체', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재'], + 'S' => ['M' => '본체디딤', 'T' => '본체(철재)', 'C' => 'C형', 'D' => 'D형', 'S' => 'SUS 마감재①', 'U' => 'SUS 마감재②'], + 'C' => ['F' => '전면부', 'P' => '점검구', 'L' => '린텔부', 'B' => '후면코너부'], + 'B' => ['S' => 'SUS', 'E' => 'EGI'], + 'T' => ['S' => 'SUS', 'E' => 'EGI'], + 'L' => ['A' => '스크린용'], + 'G' => ['I' => '화이바원단'], + ]; +} diff --git a/app/Models/BendingModel.php b/app/Models/BendingModel.php new file mode 100644 index 00000000..cd72abe8 --- /dev/null +++ b/app/Models/BendingModel.php @@ -0,0 +1,68 @@ + 'array', + 'material_summary' => 'array', + 'options' => 'array', + 'is_active' => 'boolean', + 'registration_date' => 'date', + 'rail_width' => 'decimal:2', + 'rail_length' => 'decimal:2', + 'front_bottom_width' => 'decimal:2', + 'box_width' => 'decimal:2', + 'box_height' => 'decimal:2', + 'bar_width' => 'decimal:2', + 'bar_height' => 'decimal:2', + ]; + + protected $hidden = ['deleted_at']; + + public function files(): HasMany + { + return $this->hasMany(File::class, 'document_id') + ->where('document_type', 'bending_model'); + } + + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + public function setOption(string $key, mixed $value): self + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + return $this; + } + + public const TYPE_GUIDERAIL = 'GUIDERAIL_MODEL'; + public const TYPE_SHUTTERBOX = 'SHUTTERBOX_MODEL'; + public const TYPE_BOTTOMBAR = 'BOTTOMBAR_MODEL'; +} diff --git a/app/Models/Production/BendingItemMapping.php b/app/Models/Production/BendingItemMapping.php deleted file mode 100644 index cb264c38..00000000 --- a/app/Models/Production/BendingItemMapping.php +++ /dev/null @@ -1,33 +0,0 @@ - 'boolean', - ]; - - public function item(): BelongsTo - { - return $this->belongsTo(Item::class); - } -} diff --git a/app/Services/BendingCodeService.php b/app/Services/BendingCodeService.php index 89758c12..4ae7e406 100644 --- a/app/Services/BendingCodeService.php +++ b/app/Services/BendingCodeService.php @@ -2,7 +2,8 @@ namespace App\Services; -use App\Models\Production\BendingItemMapping; +use App\Models\BendingItem; +use App\Models\Orders\Order; class BendingCodeService extends Service { @@ -127,28 +128,38 @@ public function getCodeMap(): array } /** - * 드롭다운 선택 조합 → items 테이블 품목 매핑 조회 + * 드롭다운 선택 조합 → bending_items 품목 매핑 조회 + * + * legacy_code 패턴: BD-{prod}{spec}-{length} (예: BD-CP-30) */ public function resolveItem(string $prodCode, string $specCode, string $lengthCode): ?array { - $mapping = BendingItemMapping::where('tenant_id', $this->tenantId()) - ->where('prod_code', $prodCode) - ->where('spec_code', $specCode) + // 1차: code + length_code로 조회 (신규 LOT 체계) + $item = BendingItem::where('tenant_id', $this->tenantId()) + ->where('code', 'like', "{$prodCode}{$specCode}%") ->where('length_code', $lengthCode) ->where('is_active', true) - ->with('item:id,code,name,attributes,unit') ->first(); - if (! $mapping || ! $mapping->item) { + // 2차: legacy_code 폴백 + if (! $item) { + $legacyCode = "BD-{$prodCode}{$specCode}-{$lengthCode}"; + $item = BendingItem::where('tenant_id', $this->tenantId()) + ->where('legacy_code', $legacyCode) + ->where('is_active', true) + ->first(); + } + + if (! $item) { return null; } return [ - 'item_id' => $mapping->item->id, - 'item_code' => $mapping->item->code, - 'item_name' => $mapping->item->name, - 'specification' => $mapping->item->specification, - 'unit' => $mapping->item->unit ?? 'EA', + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->item_name, + 'specification' => $item->item_spec, + 'unit' => 'EA', ]; } diff --git a/app/Services/BendingItemService.php b/app/Services/BendingItemService.php index 31653a41..92bf00ce 100644 --- a/app/Services/BendingItemService.php +++ b/app/Services/BendingItemService.php @@ -2,80 +2,102 @@ namespace App\Services; -use App\Models\Items\Item; +use App\Models\BendingItem; use Illuminate\Pagination\LengthAwarePaginator; class BendingItemService extends Service { public function list(array $params): LengthAwarePaginator { - return Item::where('item_category', 'BENDING') - ->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v)) - ->when($params['item_bending'] ?? null, fn ($q, $v) => $q->where('options->item_bending', $v)) - ->when($params['material'] ?? null, fn ($q, $v) => $q->where('options->material', 'like', "%{$v}%")) - ->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v)) - ->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v)) + return BendingItem::query() + ->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('item_sep', $v)) + ->when($params['item_bending'] ?? null, fn ($q, $v) => $q->where('item_bending', $v)) + ->when($params['material'] ?? null, fn ($q, $v) => $q->where('material', 'like', "%{$v}%")) + ->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('model_UA', $v)) + ->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('model_name', $v)) + ->when($params['legacy_bending_num'] ?? $params['legacy_bending_id'] ?? null, fn ($q, $v) => $q->where('legacy_bending_id', (int) $v)) ->when($params['search'] ?? null, fn ($q, $v) => $q->where( fn ($q2) => $q2 - ->where('name', 'like', "%{$v}%") + ->where('item_name', 'like', "%{$v}%") ->orWhere('code', 'like', "%{$v}%") - ->orWhere('options->search_keyword', 'like', "%{$v}%") - ->orWhere('options->item_spec', 'like', "%{$v}%") + ->orWhere('item_spec', 'like', "%{$v}%") + ->orWhere('legacy_code', 'like', "%{$v}%") )) - ->orderBy('code') + ->orderByDesc('id') ->paginate($params['size'] ?? 50); } public function filters(): array { - $items = Item::where('item_category', 'BENDING') - ->select('options') - ->get(); - return [ - 'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(), - 'item_bending' => $items->pluck('options.item_bending')->filter()->unique()->sort()->values(), - 'material' => $items->pluck('options.material')->filter()->unique()->sort()->values(), - 'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(), - 'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(), + 'item_sep' => BendingItem::whereNotNull('item_sep')->distinct()->pluck('item_sep')->sort()->values(), + 'item_bending' => BendingItem::whereNotNull('item_bending')->distinct()->pluck('item_bending')->sort()->values(), + 'material' => BendingItem::whereNotNull('material')->distinct()->pluck('material')->sort()->values(), + 'model_UA' => BendingItem::whereNotNull('model_UA')->distinct()->pluck('model_UA')->sort()->values(), + 'model_name' => BendingItem::whereNotNull('model_name')->distinct()->pluck('model_name')->sort()->values(), ]; } - public function find(int $id): Item + public function find(int $id): BendingItem { - return Item::where('item_category', 'BENDING')->findOrFail($id); + return BendingItem::findOrFail($id); } - public function create(array $data): Item + public function create(array $data): BendingItem { - $options = $this->buildOptions($data); - - return Item::create([ + return BendingItem::create([ 'tenant_id' => $this->tenantId(), - 'item_type' => 'PT', - 'item_category' => 'BENDING', 'code' => $data['code'], - 'name' => $data['name'], - 'unit' => $data['unit'] ?? 'EA', - 'options' => $options, + 'legacy_code' => $data['legacy_code'] ?? null, + 'legacy_bending_id' => $data['legacy_bending_id'] ?? null, + 'item_name' => $data['item_name'] ?? $data['name'] ?? '', + 'item_sep' => $data['item_sep'] ?? null, + 'item_bending' => $data['item_bending'] ?? null, + 'material' => $data['material'] ?? null, + 'item_spec' => $data['item_spec'] ?? null, + 'model_name' => $data['model_name'] ?? null, + 'model_UA' => $data['model_UA'] ?? null, + 'rail_width' => $data['rail_width'] ?? null, + 'exit_direction' => $data['exit_direction'] ?? null, + 'box_width' => $data['box_width'] ?? null, + 'box_height' => $data['box_height'] ?? null, + 'front_bottom' => $data['front_bottom'] ?? $data['front_bottom_width'] ?? null, + 'inspection_door' => $data['inspection_door'] ?? null, + 'length_code' => $data['length_code'] ?? null, + 'length_mm' => $data['length_mm'] ?? null, + 'bending_data' => $data['bendingData'] ?? null, + 'options' => $this->buildOptions($data), 'is_active' => true, 'created_by' => $this->apiUserId(), ]); } - public function update(int $id, array $data): Item + public function update(int $id, array $data): BendingItem { - $item = Item::where('item_category', 'BENDING')->findOrFail($id); + $item = BendingItem::findOrFail($id); - if (isset($data['code'])) { - $item->code = $data['code']; + $columns = [ + 'code', 'item_name', 'item_sep', 'item_bending', + 'material', 'item_spec', 'model_name', 'model_UA', + 'rail_width', 'exit_direction', 'box_width', 'box_height', + 'front_bottom', 'inspection_door', 'length_code', 'length_mm', + ]; + foreach ($columns as $col) { + if (array_key_exists($col, $data)) { + $item->{$col} = $data[$col]; + } } - if (isset($data['name'])) { - $item->name = $data['name']; + if (array_key_exists('front_bottom_width', $data) && ! array_key_exists('front_bottom', $data)) { + $item->front_bottom = $data['front_bottom_width']; } - $optionKeys = self::OPTION_KEYS; - foreach ($optionKeys as $key) { + // 전개도 (JSON 직접 저장) + if (array_key_exists('bendingData', $data)) { + $item->bending_data = $data['bendingData']; + } + + // 비정형 속성 + foreach (self::OPTION_KEYS as $key) { if (array_key_exists($key, $data)) { $item->setOption($key, $data[$key]); } @@ -89,14 +111,14 @@ public function update(int $id, array $data): Item public function delete(int $id): bool { - $item = Item::where('item_category', 'BENDING')->findOrFail($id); + $item = BendingItem::findOrFail($id); $item->deleted_by = $this->apiUserId(); $item->save(); return $item->delete(); } - private function buildOptions(array $data): array + private function buildOptions(array $data): ?array { $options = []; foreach (self::OPTION_KEYS as $key) { @@ -105,15 +127,11 @@ private function buildOptions(array $data): array } } - return $options; + return empty($options) ? null : $options; } private const OPTION_KEYS = [ - 'item_name', 'item_sep', 'item_bending', 'item_spec', - 'material', 'model_name', 'model_UA', 'search_keyword', - 'rail_width', 'registration_date', 'author', 'memo', - 'parent_num', 'exit_direction', 'front_bottom_width', - 'box_width', 'box_height', 'bendingData', - 'prefix', 'length_code', 'length_mm', + 'search_keyword', 'registration_date', 'author', 'memo', + 'parent_num', 'modified_by', ]; } diff --git a/app/Services/GuiderailModelService.php b/app/Services/GuiderailModelService.php index a8cb639c..ac8e31ed 100644 --- a/app/Services/GuiderailModelService.php +++ b/app/Services/GuiderailModelService.php @@ -2,8 +2,10 @@ namespace App\Services; -use App\Models\Items\Item; +use App\Models\BendingModel; +use App\Models\Commons\File; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\Storage; class GuiderailModelService extends Service { @@ -11,75 +13,109 @@ class GuiderailModelService extends Service public function list(array $params): LengthAwarePaginator { - return Item::whereIn('item_category', self::CATEGORIES) - ->when($params['item_category'] ?? null, fn ($q, $v) => $q->where('item_category', $v)) - ->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('options->item_sep', $v)) - ->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('options->model_UA', $v)) - ->when($params['check_type'] ?? null, fn ($q, $v) => $q->where('options->check_type', $v)) - ->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('options->model_name', $v)) + return BendingModel::whereIn('model_type', self::CATEGORIES) + ->when($params['item_category'] ?? null, fn ($q, $v) => $q->where('model_type', $v)) + ->when($params['item_sep'] ?? null, fn ($q, $v) => $q->where('item_sep', $v)) + ->when($params['model_UA'] ?? null, fn ($q, $v) => $q->where('model_UA', $v)) + ->when($params['check_type'] ?? null, fn ($q, $v) => $q->where('check_type', $v)) + ->when($params['model_name'] ?? null, fn ($q, $v) => $q->where('model_name', $v)) + ->when($params['exit_direction'] ?? null, fn ($q, $v) => $q->where('exit_direction', $v)) ->when($params['search'] ?? null, fn ($q, $v) => $q->where( fn ($q2) => $q2 ->where('name', 'like', "%{$v}%") ->orWhere('code', 'like', "%{$v}%") - ->orWhere('options->model_name', 'like', "%{$v}%") - ->orWhere('options->search_keyword', 'like', "%{$v}%") + ->orWhere('model_name', 'like', "%{$v}%") + ->orWhere('search_keyword', 'like', "%{$v}%") )) - ->orderBy('code') + ->orderByDesc('id') ->paginate($params['size'] ?? 50); } public function filters(): array { - $items = Item::whereIn('item_category', self::CATEGORIES)->select('options')->get(); - return [ - 'item_sep' => $items->pluck('options.item_sep')->filter()->unique()->sort()->values(), - 'model_UA' => $items->pluck('options.model_UA')->filter()->unique()->sort()->values(), - 'check_type' => $items->pluck('options.check_type')->filter()->unique()->sort()->values(), - 'model_name' => $items->pluck('options.model_name')->filter()->unique()->sort()->values(), - 'finishing_type' => $items->pluck('options.finishing_type')->filter()->unique()->sort()->values(), + 'item_sep' => BendingModel::whereIn('model_type', self::CATEGORIES)->whereNotNull('item_sep')->distinct()->pluck('item_sep')->sort()->values(), + 'model_UA' => BendingModel::whereIn('model_type', self::CATEGORIES)->whereNotNull('model_UA')->distinct()->pluck('model_UA')->sort()->values(), + 'check_type' => BendingModel::whereIn('model_type', self::CATEGORIES)->whereNotNull('check_type')->distinct()->pluck('check_type')->sort()->values(), + 'model_name' => BendingModel::whereIn('model_type', self::CATEGORIES)->whereNotNull('model_name')->distinct()->pluck('model_name')->sort()->values(), + 'finishing_type' => BendingModel::whereIn('model_type', self::CATEGORIES)->whereNotNull('finishing_type')->distinct()->pluck('finishing_type')->sort()->values(), ]; } - public function find(int $id): Item + public function find(int $id): BendingModel { - return Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id); + return BendingModel::whereIn('model_type', self::CATEGORIES)->findOrFail($id); } - public function create(array $data): Item + public function create(array $data): BendingModel { - $options = $this->buildOptions($data); + // component 이미지 복사 (기초관리 원본 → 독립 복사본) + if (! empty($data['components'])) { + $data['components'] = $this->copyComponentImages($data['components']); + } - return Item::create([ + return BendingModel::create([ 'tenant_id' => $this->tenantId(), - 'item_type' => 'FG', - 'item_category' => $data['item_category'] ?? 'GUIDERAIL_MODEL', + 'model_type' => $data['item_category'] ?? 'GUIDERAIL_MODEL', 'code' => $data['code'], 'name' => $data['name'], - 'unit' => 'SET', - 'options' => $options, + 'model_name' => $data['model_name'] ?? null, + 'model_UA' => $data['model_UA'] ?? null, + 'item_sep' => $data['item_sep'] ?? null, + 'finishing_type' => $data['finishing_type'] ?? null, + 'check_type' => $data['check_type'] ?? null, + 'rail_width' => $data['rail_width'] ?? null, + 'rail_length' => $data['rail_length'] ?? null, + 'exit_direction' => $data['exit_direction'] ?? null, + 'front_bottom_width' => $data['front_bottom_width'] ?? null, + 'box_width' => $data['box_width'] ?? null, + 'box_height' => $data['box_height'] ?? null, + 'bar_width' => $data['bar_width'] ?? null, + 'bar_height' => $data['bar_height'] ?? null, + 'components' => $data['components'] ?? null, + 'material_summary' => $data['material_summary'] ?? null, + 'search_keyword' => $data['search_keyword'] ?? null, + 'author' => $data['author'] ?? null, + 'memo' => $data['memo'] ?? null, + 'registration_date' => $data['registration_date'] ?? null, + 'options' => $this->buildOptions($data), 'is_active' => true, 'created_by' => $this->apiUserId(), ]); } - public function update(int $id, array $data): Item + public function update(int $id, array $data): BendingModel { - $item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id); + $item = BendingModel::whereIn('model_type', self::CATEGORIES)->findOrFail($id); - if (isset($data['code'])) { - $item->code = $data['code']; - } - if (isset($data['name'])) { - $item->name = $data['name']; - } + $columns = [ + 'code', 'name', 'model_name', 'model_UA', 'item_sep', 'finishing_type', + 'check_type', 'rail_width', 'rail_length', + 'exit_direction', 'front_bottom_width', 'box_width', 'box_height', + 'bar_width', 'bar_height', + 'components', 'material_summary', + 'search_keyword', 'author', 'registration_date', + ]; - foreach (self::OPTION_KEYS as $key) { - if (array_key_exists($key, $data)) { - $item->setOption($key, $data[$key]); + foreach ($columns as $col) { + if (array_key_exists($col, $data)) { + // components 저장 시 이미지 복사 + if ($col === 'components' && ! empty($data[$col])) { + $item->{$col} = $this->copyComponentImages($data[$col]); + } else { + $item->{$col} = $data[$col]; + } } } + // memo → options + if (array_key_exists('memo', $data)) { + $item->setOption('memo', $data['memo']); + } + if (array_key_exists('modified_by', $data)) { + $item->setOption('modified_by', $data['modified_by']); + } + $item->updated_by = $this->apiUserId(); $item->save(); @@ -88,33 +124,79 @@ public function update(int $id, array $data): Item public function delete(int $id): bool { - $item = Item::whereIn('item_category', self::CATEGORIES)->findOrFail($id); + $item = BendingModel::whereIn('model_type', self::CATEGORIES)->findOrFail($id); $item->deleted_by = $this->apiUserId(); $item->save(); return $item->delete(); } - private function buildOptions(array $data): array + /** + * component의 image_file_id가 bending_item 원본이면 복사본 생성 + */ + private function copyComponentImages(array $components): array { - $options = []; - foreach (self::OPTION_KEYS as $key) { - if (isset($data[$key])) { - $options[$key] = $data[$key]; + $tenantId = $this->tenantId(); + + foreach ($components as &$comp) { + $fileId = $comp['image_file_id'] ?? null; + if (! $fileId) { + continue; + } + + $source = File::find($fileId); + if (! $source || ! $source->file_path) { + continue; + } + + // 이미 component_image면 복사 불필요 (이미 독립 복사본) + if ($source->field_key === 'component_image') { + continue; + } + + // bending_item 원본이면 복사 + try { + $extension = pathinfo($source->stored_name, PATHINFO_EXTENSION); + $storedName = bin2hex(random_bytes(8)) . '.' . $extension; + $directory = sprintf('%d/bending/model-parts/%s/%s', $tenantId, date('Y'), date('m')); + $newPath = $directory . '/' . $storedName; + + Storage::disk('r2')->put($newPath, Storage::disk('r2')->get($source->file_path)); + + $newFile = File::create([ + 'tenant_id' => $tenantId, + 'display_name' => $source->display_name, + 'stored_name' => $storedName, + 'file_path' => $newPath, + 'file_size' => $source->file_size, + 'mime_type' => $source->mime_type, + 'file_type' => 'image', + 'field_key' => 'component_image', + 'document_id' => 0, + 'document_type' => 'bending_model', + 'is_temp' => false, + 'uploaded_by' => $this->apiUserId(), + 'created_by' => $this->apiUserId(), + ]); + + $comp['image_file_id'] = $newFile->id; + } catch (\Throwable $e) { + // 복사 실패 시 원본 ID 유지 } } + unset($comp); - return $options; + return $components; } - private const OPTION_KEYS = [ - 'model_name', 'check_type', 'rail_width', 'rail_length', - 'finishing_type', 'item_sep', 'model_UA', 'search_keyword', - 'author', 'memo', 'registration_date', - 'components', 'material_summary', - // 케이스(SHUTTERBOX_MODEL) 전용 - 'exit_direction', 'front_bottom_width', 'box_width', 'box_height', - // 하단마감재(BOTTOMBAR_MODEL) 전용 - 'bar_width', 'bar_height', - ]; + private function buildOptions(array $data): ?array + { + $opts = []; + foreach (['memo', 'modified_by'] as $key) { + if (! empty($data[$key])) { + $opts[$key] = $data[$key]; + } + } + return empty($opts) ? null : $opts; + } } diff --git a/app/Swagger/v1/BendingItemApi.php b/app/Swagger/v1/BendingItemApi.php index bb794e38..b3bd3ab7 100644 --- a/app/Swagger/v1/BendingItemApi.php +++ b/app/Swagger/v1/BendingItemApi.php @@ -3,47 +3,52 @@ namespace App\Swagger\v1; /** - * @OA\Tag(name="BendingItem", description="절곡품 기초관리") + * @OA\Tag(name="BendingItem", description="절곡품 기초관리 (bending_items 전용 테이블)") * * @OA\Schema( * schema="BendingItem", - * @OA\Property(property="id", type="integer", example=15862), - * @OA\Property(property="code", type="string", example="BD-BE-30"), - * @OA\Property(property="name", type="string", example="하단마감재(스크린) EGI 3000mm"), - * @OA\Property(property="item_type", type="string", example="PT"), - * @OA\Property(property="item_category", type="string", example="BENDING"), - * @OA\Property(property="unit", type="string", example="EA"), - * @OA\Property(property="is_active", type="boolean", example=true), - * @OA\Property(property="item_name", type="string", example="하단마감재"), + * @OA\Property(property="id", type="integer", example=431), + * @OA\Property(property="code", type="string", example="RS260319", description="LOT 코드: {제품Code}{종류Code}{YYMMDD}"), + * @OA\Property(property="legacy_code", type="string", nullable=true, example="BD-RS-30", description="이전 코드 (items 기반)"), + * @OA\Property(property="item_name", type="string", example="SUS마감재"), * @OA\Property(property="item_sep", type="string", enum={"스크린","철재"}), - * @OA\Property(property="item_bending", type="string", example="하단마감재"), - * @OA\Property(property="item_spec", type="string", nullable=true, example="60*40"), - * @OA\Property(property="material", type="string", example="EGI 1.55T"), + * @OA\Property(property="item_bending", type="string", example="가이드레일"), + * @OA\Property(property="item_spec", type="string", nullable=true, example="120*70"), + * @OA\Property(property="material", type="string", example="SUS 1.2T"), * @OA\Property(property="model_name", type="string", nullable=true, example="KSS01"), * @OA\Property(property="model_UA", type="string", nullable=true, enum={"인정","비인정"}), + * @OA\Property(property="rail_width", type="number", nullable=true), + * @OA\Property(property="exit_direction", type="string", nullable=true, description="출구방향 (케이스 전용)"), + * @OA\Property(property="front_bottom", type="number", nullable=true, description="전면밑 (케이스 전용)"), + * @OA\Property(property="box_width", type="number", nullable=true, description="박스폭 (케이스 전용)"), + * @OA\Property(property="box_height", type="number", nullable=true, description="박스높이 (케이스 전용)"), + * @OA\Property(property="inspection_door", type="string", nullable=true, description="점검구 (케이스 전용)"), + * @OA\Property(property="length_code", type="string", nullable=true, example="30", description="원자재 길이코드"), + * @OA\Property(property="length_mm", type="integer", nullable=true, example=3000, description="원자재 길이(mm)"), + * @OA\Property(property="bendingData", type="array", nullable=true, description="전개도 데이터 (bending_data 테이블)", @OA\Items( + * @OA\Property(property="no", type="integer", description="열 순서"), + * @OA\Property(property="input", type="number", description="입력 치수"), + * @OA\Property(property="rate", type="string", description="연신율: 빈값/'-1'(하향)/'1'(상향)"), + * @OA\Property(property="sum", type="number", description="누적 합계"), + * @OA\Property(property="color", type="boolean", description="음영 마킹"), + * @OA\Property(property="aAngle", type="boolean", description="A각 표시") + * )), * @OA\Property(property="search_keyword", type="string", nullable=true), - * @OA\Property(property="rail_width", type="integer", nullable=true), - * @OA\Property(property="registration_date", type="string", format="date", nullable=true), * @OA\Property(property="author", type="string", nullable=true), * @OA\Property(property="memo", type="string", nullable=true), - * @OA\Property(property="exit_direction", type="string", nullable=true), - * @OA\Property(property="front_bottom_width", type="integer", nullable=true), - * @OA\Property(property="box_width", type="integer", nullable=true), - * @OA\Property(property="box_height", type="integer", nullable=true), - * @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items( - * @OA\Property(property="no", type="integer"), - * @OA\Property(property="input", type="number"), - * @OA\Property(property="rate", type="string"), - * @OA\Property(property="sum", type="number"), - * @OA\Property(property="color", type="boolean"), - * @OA\Property(property="aAngle", type="boolean") - * )), - * @OA\Property(property="prefix", type="string", nullable=true, example="BE"), - * @OA\Property(property="length_code", type="string", nullable=true, example="30"), - * @OA\Property(property="length_mm", type="integer", nullable=true, example=3000), - * @OA\Property(property="legacy_bending_num", type="integer", nullable=true, description="레거시 chandj bending.num (운영 전 삭제 예정)"), - * @OA\Property(property="width_sum", type="integer", nullable=true, example=193), - * @OA\Property(property="bend_count", type="integer", example=5), + * @OA\Property(property="registration_date", type="string", format="date", nullable=true), + * @OA\Property(property="image_file_id", type="integer", nullable=true, description="절곡 도면 이미지 파일 ID"), + * @OA\Property(property="legacy_bending_id", type="integer", nullable=true, description="chandj.bending.num 참조"), + * @OA\Property(property="legacy_bending_num", type="integer", nullable=true, description="MNG2 호환 (=legacy_bending_id)"), + * @OA\Property(property="modified_by", type="string", nullable=true), + * @OA\Property(property="width_sum", type="number", nullable=true, example=203, description="폭합계 (전개도 마지막 sum)"), + * @OA\Property(property="bend_count", type="integer", example=5, description="절곡 횟수"), + * @OA\Property(property="is_active", type="boolean", example=true), + * @OA\Property(property="name", type="string", description="MNG2 호환 (=item_name)"), + * @OA\Property(property="front_bottom_width", type="number", nullable=true, description="MNG2 호환 (=front_bottom)"), + * @OA\Property(property="item_type", type="string", example="PT", description="MNG2 호환 (고정값)"), + * @OA\Property(property="item_category", type="string", example="BENDING", description="MNG2 호환 (고정값)"), + * @OA\Property(property="unit", type="string", example="EA", description="MNG2 호환 (고정값)"), * @OA\Property(property="created_at", type="string", format="date-time"), * @OA\Property(property="updated_at", type="string", format="date-time") * ) @@ -55,14 +60,22 @@ class BendingItemApi * path="/api/v1/bending-items", * tags={"BendingItem"}, * summary="절곡품 목록 조회", + * description="bending_items 테이블에서 검색. 정규 컬럼 인덱스로 빠른 검색 지원.", * @OA\Parameter(name="item_sep", in="query", required=false, @OA\Schema(type="string", enum={"스크린","철재"})), * @OA\Parameter(name="item_bending", in="query", required=false, @OA\Schema(type="string")), * @OA\Parameter(name="material", in="query", required=false, @OA\Schema(type="string")), * @OA\Parameter(name="model_UA", in="query", required=false, @OA\Schema(type="string", enum={"인정","비인정"})), - * @OA\Parameter(name="search", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="model_name", in="query", required=false, @OA\Schema(type="string")), + * @OA\Parameter(name="legacy_bending_num", in="query", required=false, @OA\Schema(type="integer"), description="chandj.bending.num으로 검색"), + * @OA\Parameter(name="search", in="query", required=false, @OA\Schema(type="string"), description="item_name, code, item_spec, legacy_code 통합 검색"), * @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer")), * @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", default=50)), - * @OA\Response(response=200, description="성공") + * @OA\Response(response=200, description="성공", @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BendingItem")), + * @OA\Property(property="total", type="integer") + * ) + * )) * ) */ public function index() {} @@ -72,7 +85,16 @@ public function index() {} * path="/api/v1/bending-items/filters", * tags={"BendingItem"}, * summary="절곡품 필터 옵션 조회", - * @OA\Response(response=200, description="성공") + * description="item_sep, item_bending, material, model_UA, model_name 고유값 목록", + * @OA\Response(response=200, description="성공", @OA\JsonContent( + * @OA\Property(property="data", type="object", + * @OA\Property(property="item_sep", type="array", @OA\Items(type="string")), + * @OA\Property(property="item_bending", type="array", @OA\Items(type="string")), + * @OA\Property(property="material", type="array", @OA\Items(type="string")), + * @OA\Property(property="model_UA", type="array", @OA\Items(type="string")), + * @OA\Property(property="model_name", type="array", @OA\Items(type="string")) + * ) + * )) * ) */ public function filters() {} @@ -82,8 +104,11 @@ public function filters() {} * path="/api/v1/bending-items/{id}", * tags={"BendingItem"}, * summary="절곡품 상세 조회", + * description="전개도 데이터(bendingData) 포함", * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), - * @OA\Response(response=200, description="성공") + * @OA\Response(response=200, description="성공", @OA\JsonContent( + * @OA\Property(property="data", ref="#/components/schemas/BendingItem") + * )) * ) */ public function show() {} @@ -94,14 +119,33 @@ public function show() {} * tags={"BendingItem"}, * summary="절곡품 등록", * @OA\RequestBody(@OA\JsonContent( - * required={"code","name","item_name","item_sep","item_bending","material"}, - * @OA\Property(property="code", type="string"), - * @OA\Property(property="name", type="string"), - * @OA\Property(property="item_name", type="string"), + * required={"code","item_name","item_sep","item_bending","material"}, + * @OA\Property(property="code", type="string", example="RM260319", description="LOT 코드"), + * @OA\Property(property="item_name", type="string", example="본체"), * @OA\Property(property="item_sep", type="string", enum={"스크린","철재"}), - * @OA\Property(property="item_bending", type="string"), - * @OA\Property(property="material", type="string"), - * @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items(type="object")) + * @OA\Property(property="item_bending", type="string", example="가이드레일"), + * @OA\Property(property="material", type="string", example="EGI 1.55T"), + * @OA\Property(property="item_spec", type="string", nullable=true, example="120*70"), + * @OA\Property(property="model_name", type="string", nullable=true, example="KSS01"), + * @OA\Property(property="model_UA", type="string", nullable=true, enum={"인정","비인정"}), + * @OA\Property(property="rail_width", type="number", nullable=true), + * @OA\Property(property="exit_direction", type="string", nullable=true), + * @OA\Property(property="front_bottom", type="number", nullable=true), + * @OA\Property(property="box_width", type="number", nullable=true), + * @OA\Property(property="box_height", type="number", nullable=true), + * @OA\Property(property="inspection_door", type="string", nullable=true), + * @OA\Property(property="length_code", type="string", nullable=true, example="30"), + * @OA\Property(property="length_mm", type="integer", nullable=true, example=3000), + * @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items( + * @OA\Property(property="no", type="integer"), + * @OA\Property(property="input", type="number"), + * @OA\Property(property="rate", type="string"), + * @OA\Property(property="sum", type="number"), + * @OA\Property(property="color", type="boolean"), + * @OA\Property(property="aAngle", type="boolean") + * )), + * @OA\Property(property="memo", type="string", nullable=true), + * @OA\Property(property="author", type="string", nullable=true) * )), * @OA\Response(response=200, description="성공") * ) @@ -116,9 +160,18 @@ public function store() {} * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), * @OA\RequestBody(@OA\JsonContent( * @OA\Property(property="code", type="string"), - * @OA\Property(property="name", type="string"), - * @OA\Property(property="memo", type="string"), - * @OA\Property(property="bendingData", type="array", nullable=true, @OA\Items(type="object")) + * @OA\Property(property="item_name", type="string"), + * @OA\Property(property="item_sep", type="string", enum={"스크린","철재"}), + * @OA\Property(property="item_bending", type="string"), + * @OA\Property(property="material", type="string"), + * @OA\Property(property="item_spec", type="string", nullable=true), + * @OA\Property(property="model_name", type="string", nullable=true), + * @OA\Property(property="model_UA", type="string", nullable=true), + * @OA\Property(property="rail_width", type="number", nullable=true), + * @OA\Property(property="length_code", type="string", nullable=true), + * @OA\Property(property="length_mm", type="integer", nullable=true), + * @OA\Property(property="bendingData", type="array", nullable=true, description="전체 교체", @OA\Items(type="object")), + * @OA\Property(property="memo", type="string", nullable=true) * )), * @OA\Response(response=200, description="성공") * ) @@ -130,6 +183,7 @@ public function update() {} * path="/api/v1/bending-items/{id}", * tags={"BendingItem"}, * summary="절곡품 삭제", + * description="bending_data(전개도)도 함께 삭제", * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), * @OA\Response(response=200, description="성공") * ) diff --git a/database/migrations/2026_03_19_100000_create_bending_items_table.php b/database/migrations/2026_03_19_100000_create_bending_items_table.php new file mode 100644 index 00000000..0aceb170 --- /dev/null +++ b/database/migrations/2026_03_19_100000_create_bending_items_table.php @@ -0,0 +1,82 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // 코드 체계 (LOT 코드 = 제품Code + 종류Code + YYMMDD) + $table->string('code', 50)->comment('LOT: {제품}{종류}{YYMMDD} 예: CP260319'); + $table->string('legacy_code', 50)->nullable()->comment('이전 BD-LEGACY-* / BD-{품명}-* 코드'); + $table->unsignedInteger('legacy_bending_id')->nullable()->comment('chandj.bending.id 참조'); + + // 기본 정보 (기존 options에서 정규 컬럼으로 승격) + $table->string('item_name', 100)->comment('품명'); + $table->string('item_sep', 20)->nullable()->comment('대분류: 스크린/철재'); + $table->string('item_bending', 50)->nullable()->comment('중분류: 가이드레일/케이스/하단마감재'); + $table->string('material', 50)->nullable()->comment('재질: SUS 1.2T / EGI 1.55T'); + $table->string('item_spec', 100)->nullable()->comment('규격: 120*70'); + $table->string('model_name', 50)->nullable()->comment('소속 모델: KSS01'); + $table->string('model_UA', 20)->nullable()->comment('인정여부: 인정/비인정'); + + // 절곡 전용 속성 + $table->decimal('rail_width', 10, 2)->nullable()->comment('레일폭'); + $table->string('exit_direction', 20)->nullable()->comment('출구방향 (케이스 전용)'); + $table->decimal('box_width', 10, 2)->nullable()->comment('박스폭 (케이스 전용)'); + $table->decimal('box_height', 10, 2)->nullable()->comment('박스높이 (케이스 전용)'); + $table->decimal('front_bottom', 10, 2)->nullable()->comment('전면밑 (케이스 전용)'); + $table->string('inspection_door', 20)->nullable()->comment('점검구 (케이스 전용)'); + + // 메타 (비정형 속성만 — 검색/필터 대상 아닌 것) + $table->json('options')->nullable()->comment('memo, author, search_keyword, modified_by 등'); + $table->boolean('is_active')->default(true)->comment('활성 상태'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('tenant_id', 'idx_bi_tenant'); + $table->index('item_name', 'idx_bi_item_name'); + $table->index('item_sep', 'idx_bi_item_sep'); + $table->index('item_bending', 'idx_bi_item_bending'); + $table->index('material', 'idx_bi_material'); + $table->index('model_name', 'idx_bi_model_name'); + $table->index('code', 'idx_bi_code'); + $table->index('legacy_code', 'idx_bi_legacy_code'); + $table->unique(['tenant_id', 'code', 'deleted_at'], 'uk_bi_tenant_code'); + + // 외래키 + $table->foreign('tenant_id')->references('id')->on('tenants'); + }); + + \DB::statement("ALTER TABLE bending_items COMMENT = '절곡 기초관리 마스터 (items 테이블에서 분리)'"); + } + + public function down(): void + { + Schema::dropIfExists('bending_items'); + } +}; diff --git a/database/migrations/2026_03_19_100001_create_bending_data_table.php b/database/migrations/2026_03_19_100001_create_bending_data_table.php new file mode 100644 index 00000000..fd858a4b --- /dev/null +++ b/database/migrations/2026_03_19_100001_create_bending_data_table.php @@ -0,0 +1,57 @@ +id(); + $table->unsignedBigInteger('bending_item_id')->comment('FK → bending_items.id'); + $table->smallInteger('sort_order')->unsigned()->comment('열 순서 (1,2,3,...)'); + + // 전개도 행 데이터 + $table->decimal('input', 10, 2)->default(0)->comment('입력 치수'); + $table->string('rate', 10)->nullable()->comment('연신율: ""(없음), "-1"(하향), "1"(상향)'); + $table->decimal('after_rate', 10, 2)->nullable()->comment('연신율 적용 후 값 (input + rate)'); + $table->decimal('sum', 10, 2)->nullable()->comment('누적 합계'); + $table->boolean('color')->default(false)->comment('음영 마킹 (파란/노란 배경)'); + $table->boolean('a_angle')->default(false)->comment('A각 표시'); + + $table->timestamps(); + + // 인덱스 + $table->index('bending_item_id', 'idx_bd_bending_item'); + $table->unique(['bending_item_id', 'sort_order'], 'uk_bd_item_order'); + + // 외래키 + $table->foreign('bending_item_id') + ->references('id') + ->on('bending_items') + ->onDelete('cascade'); + }); + + \DB::statement("ALTER TABLE bending_data COMMENT = '절곡 전개도 행 데이터 (bending_items 1:N)'"); + } + + public function down(): void + { + Schema::dropIfExists('bending_data'); + } +}; diff --git a/database/migrations/2026_03_19_100002_add_bending_data_column_and_drop_bending_data_table.php b/database/migrations/2026_03_19_100002_add_bending_data_column_and_drop_bending_data_table.php new file mode 100644 index 00000000..4bdb7b10 --- /dev/null +++ b/database/migrations/2026_03_19_100002_add_bending_data_column_and_drop_bending_data_table.php @@ -0,0 +1,77 @@ +json('bending_data')->nullable()->after('inspection_door') + ->comment('전개도 데이터 [{no, input, rate, sum, color, aAngle}, ...]'); + }); + + // 2. bending_data 테이블 → bending_items.bending_data JSON으로 이관 + $items = DB::table('bending_data') + ->select('bending_item_id') + ->distinct() + ->pluck('bending_item_id'); + + foreach ($items as $itemId) { + $rows = DB::table('bending_data') + ->where('bending_item_id', $itemId) + ->orderBy('sort_order') + ->get(); + + $json = $rows->map(fn ($r) => [ + 'no' => $r->sort_order, + 'input' => (float) $r->input, + 'rate' => $r->rate ?? '', + 'sum' => $r->sum !== null ? (float) $r->sum : null, + 'color' => (bool) $r->color, + 'aAngle' => (bool) $r->a_angle, + ])->values()->toArray(); + + DB::table('bending_items') + ->where('id', $itemId) + ->update(['bending_data' => json_encode($json)]); + } + + // 3. bending_data 테이블 DROP + Schema::dropIfExists('bending_data'); + } + + public function down(): void + { + // bending_data 테이블 재생성 + Schema::create('bending_data', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('bending_item_id'); + $table->smallInteger('sort_order')->unsigned(); + $table->decimal('input', 10, 2)->default(0); + $table->string('rate', 10)->nullable(); + $table->decimal('after_rate', 10, 2)->nullable(); + $table->decimal('sum', 10, 2)->nullable(); + $table->boolean('color')->default(false); + $table->boolean('a_angle')->default(false); + $table->timestamps(); + $table->index('bending_item_id', 'idx_bd_bending_item'); + $table->unique(['bending_item_id', 'sort_order'], 'uk_bd_item_order'); + $table->foreign('bending_item_id')->references('id')->on('bending_items')->onDelete('cascade'); + }); + + // bending_items.bending_data 컬럼 삭제 + Schema::table('bending_items', function (Blueprint $table) { + $table->dropColumn('bending_data'); + }); + } +}; diff --git a/database/migrations/2026_03_19_100003_recreate_bending_data_table_from_json.php b/database/migrations/2026_03_19_100003_recreate_bending_data_table_from_json.php new file mode 100644 index 00000000..c278eadc --- /dev/null +++ b/database/migrations/2026_03_19_100003_recreate_bending_data_table_from_json.php @@ -0,0 +1,106 @@ +id(); + $table->unsignedBigInteger('bending_item_id')->comment('FK → bending_items.id'); + $table->smallInteger('sort_order')->unsigned()->comment('열 순서 (1,2,3,...)'); + + $table->decimal('input', 10, 2)->default(0)->comment('입력 치수'); + $table->string('rate', 10)->nullable()->comment('연신율: ""(없음), "-1"(하향), "1"(상향)'); + $table->decimal('after_rate', 10, 2)->nullable()->comment('연신율 적용 후 값'); + $table->decimal('sum', 10, 2)->nullable()->comment('누적 합계'); + $table->boolean('color')->default(false)->comment('음영 마킹'); + $table->boolean('a_angle')->default(false)->comment('A각 표시'); + + $table->timestamps(); + + $table->index('bending_item_id', 'idx_bd_bending_item'); + $table->unique(['bending_item_id', 'sort_order'], 'uk_bd_item_order'); + $table->foreign('bending_item_id')->references('id')->on('bending_items')->onDelete('cascade'); + }); + + // 2. bending_items.bending_data JSON → bending_data rows 분해 + $items = DB::table('bending_items') + ->whereNotNull('bending_data') + ->select('id', 'bending_data') + ->get(); + + foreach ($items as $item) { + $rows = json_decode($item->bending_data, true); + if (empty($rows) || ! is_array($rows)) { + continue; + } + + foreach ($rows as $i => $row) { + $input = (float) ($row['input'] ?? 0); + $rate = $row['rate'] ?? ''; + $afterRate = ($rate !== '' && $rate !== null) ? $input + (float) $rate : $input; + + DB::table('bending_data')->insert([ + 'bending_item_id' => $item->id, + 'sort_order' => $row['no'] ?? ($i + 1), + 'input' => $input, + 'rate' => $rate !== '' ? $rate : null, + 'after_rate' => $afterRate, + 'sum' => $row['sum'] ?? null, + 'color' => (bool) ($row['color'] ?? false), + 'a_angle' => (bool) ($row['aAngle'] ?? $row['a_angle'] ?? false), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + // 3. bending_items.bending_data JSON 컬럼 삭제 + Schema::table('bending_items', function (Blueprint $table) { + $table->dropColumn('bending_data'); + }); + } + + public function down(): void + { + // bending_items에 bending_data JSON 컬럼 복원 + Schema::table('bending_items', function (Blueprint $table) { + $table->json('bending_data')->nullable()->after('inspection_door'); + }); + + // bending_data rows → JSON 복원 + $itemIds = DB::table('bending_data')->distinct()->pluck('bending_item_id'); + foreach ($itemIds as $itemId) { + $rows = DB::table('bending_data') + ->where('bending_item_id', $itemId) + ->orderBy('sort_order') + ->get(); + + $json = $rows->map(fn ($r) => [ + 'no' => $r->sort_order, + 'input' => (float) $r->input, + 'rate' => $r->rate ?? '', + 'sum' => $r->sum !== null ? (float) $r->sum : null, + 'color' => (bool) $r->color, + 'aAngle' => (bool) $r->a_angle, + ])->values()->toArray(); + + DB::table('bending_items')->where('id', $itemId)->update([ + 'bending_data' => json_encode($json), + ]); + } + + Schema::dropIfExists('bending_data'); + } +}; diff --git a/database/migrations/2026_03_19_100004_drop_bending_item_mappings_table.php b/database/migrations/2026_03_19_100004_drop_bending_item_mappings_table.php new file mode 100644 index 00000000..cad57129 --- /dev/null +++ b/database/migrations/2026_03_19_100004_drop_bending_item_mappings_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('prod_code', 2); + $table->string('spec_code', 2); + $table->string('length_code', 2); + $table->unsignedBigInteger('item_id'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->unique(['tenant_id', 'prod_code', 'spec_code', 'length_code'], 'bim_tenant_prod_spec_length_unique'); + $table->index(['tenant_id', 'is_active']); + }); + } +}; diff --git a/database/migrations/2026_03_19_100005_add_length_columns_to_bending_items.php b/database/migrations/2026_03_19_100005_add_length_columns_to_bending_items.php new file mode 100644 index 00000000..c37ef29a --- /dev/null +++ b/database/migrations/2026_03_19_100005_add_length_columns_to_bending_items.php @@ -0,0 +1,64 @@ +string('length_code', 5)->nullable()->after('inspection_door')->comment('원자재 길이코드: 24,30,35,40,42,43 등'); + $table->integer('length_mm')->nullable()->after('length_code')->comment('원자재 길이(mm): 2438,3000,3500...'); + }); + + // 2. items.options에서 length_code/length_mm 복사 + DB::statement(" + UPDATE bending_items bi + JOIN items i ON i.code = bi.legacy_code AND i.item_category = 'BENDING' AND i.tenant_id = bi.tenant_id + SET bi.length_code = JSON_UNQUOTE(JSON_EXTRACT(i.options, '$.length_code')), + bi.length_mm = CAST(JSON_UNQUOTE(JSON_EXTRACT(i.options, '$.length_mm')) AS UNSIGNED) + WHERE JSON_EXTRACT(i.options, '$.length_code') IS NOT NULL + "); + + // 3. legacy_code에서 length_code 추출 (BD-RS-30 → 30) + DB::statement(" + UPDATE bending_items + SET length_code = SUBSTRING(legacy_code, -2) + WHERE legacy_code REGEXP '^BD-[A-Z]{2}-[0-9]{2}$' + AND length_code IS NULL + "); + + // 4. code에서 -XX 접미사 제거 (RS260319-30 → RS260319) + DB::statement(" + UPDATE bending_items + SET code = SUBSTRING(code, 1, LENGTH(code) - 3) + WHERE code REGEXP '^[A-Z]{2}[0-9]{6}-[0-9]{2}$' + "); + + // 5. 유니크 인덱스 변경 + Schema::table('bending_items', function (Blueprint $table) { + $table->dropUnique('uk_bi_tenant_code'); + $table->unique(['tenant_id', 'code', 'length_code', 'deleted_at'], 'uk_bi_tenant_code_length'); + }); + } + + public function down(): void + { + Schema::table('bending_items', function (Blueprint $table) { + $table->dropUnique('uk_bi_tenant_code_length'); + $table->unique(['tenant_id', 'code', 'deleted_at'], 'uk_bi_tenant_code'); + $table->dropColumn(['length_code', 'length_mm']); + }); + } +}; diff --git a/database/migrations/2026_03_19_100006_create_bending_models_table.php b/database/migrations/2026_03_19_100006_create_bending_models_table.php new file mode 100644 index 00000000..72224ae9 --- /dev/null +++ b/database/migrations/2026_03_19_100006_create_bending_models_table.php @@ -0,0 +1,77 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('model_type', 30)->comment('GUIDERAIL_MODEL / SHUTTERBOX_MODEL / BOTTOMBAR_MODEL'); + $table->string('code', 100); + $table->string('name', 255); + $table->string('legacy_code', 100)->nullable(); + $table->unsignedInteger('legacy_num')->nullable()->comment('chandj 원본 num'); + + // 공통 + $table->string('model_name', 50)->nullable(); + $table->string('model_UA', 20)->nullable(); + $table->string('item_sep', 20)->nullable(); + $table->string('finishing_type', 20)->nullable(); + $table->string('author', 50)->nullable(); + $table->text('remark')->nullable(); + + // 가이드레일 + $table->string('check_type', 30)->nullable(); + $table->decimal('rail_width', 10, 2)->nullable(); + $table->decimal('rail_length', 10, 2)->nullable(); + + // 케이스 + $table->string('exit_direction', 20)->nullable(); + $table->decimal('front_bottom_width', 10, 2)->nullable(); + $table->decimal('box_width', 10, 2)->nullable(); + $table->decimal('box_height', 10, 2)->nullable(); + + // 하단마감재 + $table->decimal('bar_width', 10, 2)->nullable(); + $table->decimal('bar_height', 10, 2)->nullable(); + + // 부품 조합 + $table->json('components')->nullable(); + $table->json('material_summary')->nullable(); + + // 메타 + $table->string('search_keyword', 100)->nullable(); + $table->date('registration_date')->nullable(); + $table->json('options')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->unsignedBigInteger('deleted_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('tenant_id', 'idx_bm_tenant'); + $table->index('model_type', 'idx_bm_type'); + $table->index('model_name', 'idx_bm_model_name'); + $table->index('item_sep', 'idx_bm_item_sep'); + $table->unique(['tenant_id', 'code', 'deleted_at'], 'uk_bm_tenant_code'); + $table->foreign('tenant_id')->references('id')->on('tenants'); + }); + } + + public function down(): void + { + Schema::dropIfExists('bending_models'); + } +}; diff --git a/database/migrations/2026_03_19_100007_move_bending_data_back_to_json.php b/database/migrations/2026_03_19_100007_move_bending_data_back_to_json.php new file mode 100644 index 00000000..cfe2f62c --- /dev/null +++ b/database/migrations/2026_03_19_100007_move_bending_data_back_to_json.php @@ -0,0 +1,72 @@ +json('bending_data')->nullable()->after('inspection_door') + ->comment('전개도 [{no, input, rate, sum, color, aAngle}]'); + }); + + // 2. bending_data rows → JSON 변환 + $itemIds = DB::table('bending_data')->distinct()->pluck('bending_item_id'); + + foreach ($itemIds as $itemId) { + $rows = DB::table('bending_data') + ->where('bending_item_id', $itemId) + ->orderBy('sort_order') + ->get(); + + $json = $rows->map(fn ($r) => [ + 'no' => $r->sort_order, + 'input' => (float) $r->input, + 'rate' => $r->rate ?? '', + 'sum' => $r->sum !== null ? (float) $r->sum : null, + 'color' => (bool) $r->color, + 'aAngle' => (bool) $r->a_angle, + ])->values()->toArray(); + + DB::table('bending_items') + ->where('id', $itemId) + ->update(['bending_data' => json_encode($json)]); + } + + // 3. bending_data 테이블 DROP + Schema::dropIfExists('bending_data'); + } + + public function down(): void + { + Schema::create('bending_data', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('bending_item_id'); + $table->smallInteger('sort_order')->unsigned(); + $table->decimal('input', 10, 2)->default(0); + $table->string('rate', 10)->nullable(); + $table->decimal('after_rate', 10, 2)->nullable(); + $table->decimal('sum', 10, 2)->nullable(); + $table->boolean('color')->default(false); + $table->boolean('a_angle')->default(false); + $table->timestamps(); + $table->index('bending_item_id', 'idx_bd_bending_item'); + $table->unique(['bending_item_id', 'sort_order'], 'uk_bd_item_order'); + $table->foreign('bending_item_id')->references('id')->on('bending_items')->onDelete('cascade'); + }); + + Schema::table('bending_items', function (Blueprint $table) { + $table->dropColumn('bending_data'); + }); + } +}; diff --git a/storage/api-docs/api-docs-v1.json b/storage/api-docs/api-docs-v1.json old mode 100755 new mode 100644 index 602224c7..4b4683ab --- a/storage/api-docs/api-docs-v1.json +++ b/storage/api-docs/api-docs-v1.json @@ -9442,6 +9442,7 @@ "BendingItem" ], "summary": "절곡품 목록 조회", + "description": "bending_items 테이블에서 검색. 정규 컬럼 인덱스로 빠른 검색 지원.", "operationId": "c497d5bfebed3fb08cd4d5be9224c795", "parameters": [ { @@ -9484,9 +9485,27 @@ ] } }, + { + "name": "model_name", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "legacy_bending_num", + "in": "query", + "description": "chandj.bending.num으로 검색", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "search", "in": "query", + "description": "item_name, code, item_spec, legacy_code 통합 검색", "required": false, "schema": { "type": "string" @@ -9512,7 +9531,30 @@ ], "responses": { "200": { - "description": "성공" + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BendingItem" + } + }, + "total": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } } } }, @@ -9528,7 +9570,6 @@ "schema": { "required": [ "code", - "name", "item_name", "item_sep", "item_bending", @@ -9536,13 +9577,13 @@ ], "properties": { "code": { - "type": "string" - }, - "name": { - "type": "string" + "description": "LOT 코드", + "type": "string", + "example": "RM260319" }, "item_name": { - "type": "string" + "type": "string", + "example": "본체" }, "item_sep": { "type": "string", @@ -9552,17 +9593,99 @@ ] }, "item_bending": { - "type": "string" + "type": "string", + "example": "가이드레일" }, "material": { - "type": "string" + "type": "string", + "example": "EGI 1.55T" + }, + "item_spec": { + "type": "string", + "example": "120*70", + "nullable": true + }, + "model_name": { + "type": "string", + "example": "KSS01", + "nullable": true + }, + "model_UA": { + "type": "string", + "enum": [ + "인정", + "비인정" + ], + "nullable": true + }, + "rail_width": { + "type": "number", + "nullable": true + }, + "exit_direction": { + "type": "string", + "nullable": true + }, + "front_bottom": { + "type": "number", + "nullable": true + }, + "box_width": { + "type": "number", + "nullable": true + }, + "box_height": { + "type": "number", + "nullable": true + }, + "inspection_door": { + "type": "string", + "nullable": true + }, + "length_code": { + "type": "string", + "example": "30", + "nullable": true + }, + "length_mm": { + "type": "integer", + "example": 3000, + "nullable": true }, "bendingData": { "type": "array", "items": { + "properties": { + "no": { + "type": "integer" + }, + "input": { + "type": "number" + }, + "rate": { + "type": "string" + }, + "sum": { + "type": "number" + }, + "color": { + "type": "boolean" + }, + "aAngle": { + "type": "boolean" + } + }, "type": "object" }, "nullable": true + }, + "memo": { + "type": "string", + "nullable": true + }, + "author": { + "type": "string", + "nullable": true } }, "type": "object" @@ -9583,10 +9706,55 @@ "BendingItem" ], "summary": "절곡품 필터 옵션 조회", + "description": "item_sep, item_bending, material, model_UA, model_name 고유값 목록", "operationId": "f5dd325adc791e1b8cab40b9fa2fb77d", "responses": { "200": { - "description": "성공" + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "item_sep": { + "type": "array", + "items": { + "type": "string" + } + }, + "item_bending": { + "type": "array", + "items": { + "type": "string" + } + }, + "material": { + "type": "array", + "items": { + "type": "string" + } + }, + "model_UA": { + "type": "array", + "items": { + "type": "string" + } + }, + "model_name": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "type": "object" + } + }, + "type": "object" + } + } + } } } } @@ -9597,6 +9765,7 @@ "BendingItem" ], "summary": "절곡품 상세 조회", + "description": "전개도 데이터(bendingData) 포함", "operationId": "d364f4d4cf76bcce7167561b73216382", "parameters": [ { @@ -9610,7 +9779,19 @@ ], "responses": { "200": { - "description": "성공" + "description": "성공", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "$ref": "#/components/schemas/BendingItem" + } + }, + "type": "object" + } + } + } } } }, @@ -9638,18 +9819,57 @@ "code": { "type": "string" }, - "name": { + "item_name": { "type": "string" }, - "memo": { + "item_sep": { + "type": "string", + "enum": [ + "스크린", + "철재" + ] + }, + "item_bending": { "type": "string" }, + "material": { + "type": "string" + }, + "item_spec": { + "type": "string", + "nullable": true + }, + "model_name": { + "type": "string", + "nullable": true + }, + "model_UA": { + "type": "string", + "nullable": true + }, + "rail_width": { + "type": "number", + "nullable": true + }, + "length_code": { + "type": "string", + "nullable": true + }, + "length_mm": { + "type": "integer", + "nullable": true + }, "bendingData": { + "description": "전체 교체", "type": "array", "items": { "type": "object" }, "nullable": true + }, + "memo": { + "type": "string", + "nullable": true } }, "type": "object" @@ -9668,6 +9888,7 @@ "BendingItem" ], "summary": "절곡품 삭제", + "description": "bending_data(전개도)도 함께 삭제", "operationId": "46dcd93439505ae5ffb1dd2c8c1e5685", "parameters": [ { @@ -64680,35 +64901,22 @@ "properties": { "id": { "type": "integer", - "example": 15862 + "example": 431 }, "code": { + "description": "LOT 코드: {제품Code}{종류Code}{YYMMDD}", "type": "string", - "example": "BD-BE-30" + "example": "RS260319" }, - "name": { + "legacy_code": { + "description": "이전 코드 (items 기반)", "type": "string", - "example": "하단마감재(스크린) EGI 3000mm" - }, - "item_type": { - "type": "string", - "example": "PT" - }, - "item_category": { - "type": "string", - "example": "BENDING" - }, - "unit": { - "type": "string", - "example": "EA" - }, - "is_active": { - "type": "boolean", - "example": true + "example": "BD-RS-30", + "nullable": true }, "item_name": { "type": "string", - "example": "하단마감재" + "example": "SUS마감재" }, "item_sep": { "type": "string", @@ -64719,16 +64927,16 @@ }, "item_bending": { "type": "string", - "example": "하단마감재" + "example": "가이드레일" }, "item_spec": { "type": "string", - "example": "60*40", + "example": "120*70", "nullable": true }, "material": { "type": "string", - "example": "EGI 1.55T" + "example": "SUS 1.2T" }, "model_name": { "type": "string", @@ -64743,19 +64951,85 @@ ], "nullable": true }, + "rail_width": { + "type": "number", + "nullable": true + }, + "exit_direction": { + "description": "출구방향 (케이스 전용)", + "type": "string", + "nullable": true + }, + "front_bottom": { + "description": "전면밑 (케이스 전용)", + "type": "number", + "nullable": true + }, + "box_width": { + "description": "박스폭 (케이스 전용)", + "type": "number", + "nullable": true + }, + "box_height": { + "description": "박스높이 (케이스 전용)", + "type": "number", + "nullable": true + }, + "inspection_door": { + "description": "점검구 (케이스 전용)", + "type": "string", + "nullable": true + }, + "length_code": { + "description": "원자재 길이코드", + "type": "string", + "example": "30", + "nullable": true + }, + "length_mm": { + "description": "원자재 길이(mm)", + "type": "integer", + "example": 3000, + "nullable": true + }, + "bendingData": { + "description": "전개도 데이터 (bending_data 테이블)", + "type": "array", + "items": { + "properties": { + "no": { + "description": "열 순서", + "type": "integer" + }, + "input": { + "description": "입력 치수", + "type": "number" + }, + "rate": { + "description": "연신율: 빈값/'-1'(하향)/'1'(상향)", + "type": "string" + }, + "sum": { + "description": "누적 합계", + "type": "number" + }, + "color": { + "description": "음영 마킹", + "type": "boolean" + }, + "aAngle": { + "description": "A각 표시", + "type": "boolean" + } + }, + "type": "object" + }, + "nullable": true + }, "search_keyword": { "type": "string", "nullable": true }, - "rail_width": { - "type": "integer", - "nullable": true - }, - "registration_date": { - "type": "string", - "format": "date", - "nullable": true - }, "author": { "type": "string", "nullable": true @@ -64764,78 +65038,69 @@ "type": "string", "nullable": true }, - "exit_direction": { + "registration_date": { "type": "string", + "format": "date", "nullable": true }, - "front_bottom_width": { + "image_file_id": { + "description": "절곡 도면 이미지 파일 ID", "type": "integer", "nullable": true }, - "box_width": { + "legacy_bending_id": { + "description": "chandj.bending.num 참조", "type": "integer", "nullable": true }, - "box_height": { - "type": "integer", - "nullable": true - }, - "bendingData": { - "type": "array", - "items": { - "properties": { - "no": { - "type": "integer" - }, - "input": { - "type": "number" - }, - "rate": { - "type": "string" - }, - "sum": { - "type": "number" - }, - "color": { - "type": "boolean" - }, - "aAngle": { - "type": "boolean" - } - }, - "type": "object" - }, - "nullable": true - }, - "prefix": { - "type": "string", - "example": "BE", - "nullable": true - }, - "length_code": { - "type": "string", - "example": "30", - "nullable": true - }, - "length_mm": { - "type": "integer", - "example": 3000, - "nullable": true - }, "legacy_bending_num": { - "description": "레거시 chandj bending.num (운영 전 삭제 예정)", + "description": "MNG2 호환 (=legacy_bending_id)", "type": "integer", "nullable": true }, + "modified_by": { + "type": "string", + "nullable": true + }, "width_sum": { - "type": "integer", - "example": 193, + "description": "폭합계 (전개도 마지막 sum)", + "type": "number", + "example": 203, "nullable": true }, "bend_count": { + "description": "절곡 횟수", "type": "integer", "example": 5 }, + "is_active": { + "type": "boolean", + "example": true + }, + "name": { + "description": "MNG2 호환 (=item_name)", + "type": "string" + }, + "front_bottom_width": { + "description": "MNG2 호환 (=front_bottom)", + "type": "number", + "nullable": true + }, + "item_type": { + "description": "MNG2 호환 (고정값)", + "type": "string", + "example": "PT" + }, + "item_category": { + "description": "MNG2 호환 (고정값)", + "type": "string", + "example": "BENDING" + }, + "unit": { + "description": "MNG2 호환 (고정값)", + "type": "string", + "example": "EA" + }, "created_at": { "type": "string", "format": "date-time" @@ -94607,7 +94872,7 @@ }, { "name": "BendingItem", - "description": "절곡품 기초관리" + "description": "절곡품 기초관리 (bending_items 전용 테이블)" }, { "name": "Bidding", diff --git a/storage/api-docs/api-docs.json b/storage/api-docs/api-docs.json old mode 100755 new mode 100644