['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'SUS 1.2T', 'item_spec' => '120*70'], 'RE' => ['item_bending' => '가이드레일', 'itemName_like' => '%마감%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'], 'RM' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'], 'RC' => ['item_bending' => '가이드레일', 'itemName_like' => '%C형%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'], 'RD' => ['item_bending' => '가이드레일', 'itemName_like' => '%벽면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*70'], 'RT' => ['item_bending' => '가이드레일', 'itemName_like' => '%본체%', 'material' => 'EGI 1.55T', 'item_spec' => '130*75'], // 가이드레일 (측면) — 벽면과 같은 전개도 'SS' => ['same_as' => 'RS'], 'SU' => ['same_as' => 'RS'], 'SM' => ['same_as' => 'RM'], 'SC' => ['same_as' => 'RC'], 'SD' => ['item_bending' => '가이드레일', 'itemName_like' => '%측면형-D%', 'material' => 'EGI 1.55T', 'item_spec' => '120*120'], 'ST' => ['same_as' => 'RT'], 'SE' => ['same_as' => 'RE'], // 하단마감재 'BS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '스크린'], 'BE' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%EGI%', 'item_sep' => '스크린'], 'TS' => ['item_bending' => '하단마감재', 'itemName_like' => '%하장바%', 'material_like' => '%SUS%', 'item_sep' => '철재'], 'LA' => ['item_bending' => 'L-BAR', 'itemName_like' => '%L-BAR%', 'material' => 'EGI 1.55T'], 'HH' => ['item_bending' => '하단마감재', 'itemName_like' => '%보강평철%', 'material_like' => '%EGI%'], // 케이스 — spec 없이 itemName으로 구분 'CF' => ['item_bending' => '케이스', 'itemName_like' => '%전면%', 'item_sep' => '스크린'], 'CL' => ['item_bending' => '케이스', 'itemName_like' => '%린텔%', 'item_sep' => '스크린'], 'CP' => ['item_bending' => '케이스', 'itemName_like' => '%점검%', 'item_sep' => '스크린'], 'CB' => ['item_bending' => '케이스', 'itemName_like' => '%후면%', 'item_sep' => '스크린'], // 연기차단재 'GI' => ['item_bending' => '연기차단재', 'itemName_like' => '%연기%'], // 공용 'XX' => null, // 여러 부품이 섞여 있어 자동 매핑 불가 'YY' => null, // 별도 마감 — 자동 매핑 불가 ]; private array $stats = [ 'total_sam' => 0, 'matched' => 0, 'updated' => 0, 'already_has' => 0, 'no_match' => 0, 'no_bending_data' => 0, ]; private array $unmatchedItems = []; public function handle(): int { $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $force = $this->option('force'); $this->info('=== 3단계: chandj 전개도 → SAM options 임포트 ==='); $this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE').($force ? ' (FORCE)' : '')); $this->newLine(); // 1. chandj bending 전체 로드 $chandjRows = DB::connection('chandj')->table('bending') ->whereNull('is_deleted') ->get(); $this->info("chandj bending 활성: {$chandjRows->count()}건"); // 2. SAM BENDING items 전체 로드 $samItems = DB::table('items') ->where('tenant_id', $tenantId) ->where('item_category', 'BENDING') ->whereNull('deleted_at') ->orderBy('code') ->get(['id', 'code', 'name', 'options']); $this->stats['total_sam'] = $samItems->count(); $this->info("SAM BENDING items: {$samItems->count()}건"); $this->newLine(); // 3. 매칭 + 임포트 foreach ($samItems as $item) { $options = json_decode($item->options ?? '{}', true) ?: []; // 이미 bendingData가 있으면 skip (--force 아닌 경우) if (! empty($options['bendingData']) && ! $force) { $this->stats['already_has']++; continue; } // chandj 매칭 $chandjRow = $this->findChandjMatch($item->code, $options, $chandjRows); if (! $chandjRow) { $this->stats['no_match']++; $this->unmatchedItems[] = $item->code; continue; } // bendingData 변환 $bendingData = $this->convertBendingData($chandjRow); if (empty($bendingData)) { $this->stats['no_bending_data']++; continue; } // options 업데이트 $updates = ['bendingData' => $bendingData]; // 추가 속성 (비어있으면 채우기) $optionalFields = [ 'memo' => $chandjRow->memo, 'author' => $chandjRow->author, 'search_keyword' => $chandjRow->search_keyword, 'registration_date' => $chandjRow->registration_date, 'model_UA' => $chandjRow->model_UA, 'exit_direction' => $chandjRow->exit_direction, 'front_bottom_width' => $chandjRow->front_bottom_width, 'rail_width' => $chandjRow->rail_width, 'box_width' => $chandjRow->box_width, 'box_height' => $chandjRow->box_height, 'item_spec' => $chandjRow->item_spec, 'legacy_bending_num' => $chandjRow->num, ]; foreach ($optionalFields as $key => $value) { if (! empty($value) && empty($options[$key])) { $updates[$key] = $value; } } $merged = array_merge($options, $updates); if (! $dryRun) { DB::table('items')->where('id', $item->id)->update([ 'options' => json_encode($merged, JSON_UNESCAPED_UNICODE), 'updated_at' => now(), ]); } $this->stats['matched']++; $this->stats['updated']++; $colCount = count($bendingData); $this->line(" ✅ {$item->code} ← chandj#{$chandjRow->num} (전개도 {$colCount}열, +".implode(',', array_keys($updates)).')'); } $this->showStats($dryRun); return self::SUCCESS; } /** * SAM item code → chandj bending 매칭 */ private function findChandjMatch(string $code, array $options, $chandjRows): ?object { // A) 한글 패턴 — code에서 속성 추출하여 매칭 if (! preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code)) { return $this->matchKoreanPattern($code, $chandjRows); } // B) PREFIX-LEN — PREFIX로 chandj 조건 결정 preg_match('/^BD-([A-Z]{2})-\d{2}$/', $code, $m); $prefix = $m[1]; $mapping = self::PREFIX_TO_CHANDJ[$prefix] ?? null; if (! $mapping) { return null; } // same_as 참조 if (isset($mapping['same_as'])) { $mapping = self::PREFIX_TO_CHANDJ[$mapping['same_as']] ?? null; if (! $mapping) { return null; } } return $this->queryChangj($chandjRows, $mapping); } /** * 한글 패턴 매칭 */ private function matchKoreanPattern(string $code, $chandjRows): ?object { // BD-가이드레일-KSS01-SUS-120*70 if (preg_match('/^BD-가이드레일-(\w+)-(\w+)-(.+)$/', $code, $m)) { $material = str_contains($m[2], 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T'; return $this->queryChangj($chandjRows, [ 'item_bending' => '가이드레일', 'material' => $material, 'item_spec' => $m[3], ]); } // BD-하단마감재-KSS01-SUS-60*40 if (preg_match('/^BD-하단마감재-(\w+)-(\w+)-(.+)$/', $code, $m)) { $material = str_contains($m[2], 'SUS') ? 'SUS' : 'EGI'; return $this->queryChangj($chandjRows, [ 'item_bending' => '하단마감재', 'material_like' => "%{$material}%", 'item_spec_like' => "%{$m[3]}%", ]); } // BD-케이스-650*550 → chandj에서 itemName에 "650*550" 포함된 전면판 매칭 if (preg_match('/^BD-케이스-(\d+)\*(\d+)$/', $code, $m)) { $spec = $m[1].'*'.$m[2]; return $chandjRows->first(function ($r) use ($spec) { return (str_contains($r->itemName, $spec) || $r->item_spec === $spec) && str_contains($r->itemName, '전면'); }); } // BD-마구리-655*505 if (preg_match('/^BD-마구리-(.+)$/', $code, $m)) { return $chandjRows->first(fn ($r) => $r->item_bending === '마구리' && $r->item_spec === $m[1]); } // BD-L-BAR-KSS01-17*60 if (preg_match('/^BD-L-BAR-\w+-(.+)$/', $code, $m)) { return $chandjRows->first(fn ($r) => $r->item_bending === 'L-BAR' && $r->item_spec === $m[1]); } // BD-보강평철-50 if (preg_match('/^BD-보강평철-(.+)$/', $code, $m)) { return $chandjRows->first(fn ($r) => str_contains($r->itemName, '보강평철') && $r->item_spec === $m[1]); } return null; } /** * chandj 컬렉션에서 조건으로 검색 */ private function queryChangj($rows, array $cond): ?object { return $rows->first(function ($r) use ($cond) { if (isset($cond['item_sep']) && $r->item_sep !== $cond['item_sep']) { return false; } if (isset($cond['item_bending']) && $r->item_bending !== $cond['item_bending']) { return false; } if (isset($cond['material']) && $r->material !== $cond['material']) { return false; } if (isset($cond['material_like']) && ! str_contains($r->material, str_replace('%', '', $cond['material_like']))) { return false; } if (isset($cond['itemName_like']) && ! str_contains($r->itemName, str_replace('%', '', $cond['itemName_like']))) { return false; } if (isset($cond['item_spec']) && $r->item_spec !== $cond['item_spec']) { return false; } if (isset($cond['item_spec_like']) && ! str_contains($r->item_spec ?? '', str_replace('%', '', $cond['item_spec_like']))) { return false; } return true; }); } /** * chandj bending row → bendingData JSON 배열 변환 * * 레거시: inputList=["10","11","110"], bendingrateList=["","-1",""], sumList=["10","21","131"], colorList=[false,false,true], AList=[false,false,false] * SAM: [{"no":1,"input":10,"rate":"","sum":10,"color":false,"aAngle":false}, ...] */ private function convertBendingData(object $row): array { $inputs = json_decode($row->inputList ?? '[]', true) ?: []; $rates = json_decode($row->bendingrateList ?? '[]', true) ?: []; $sums = json_decode($row->sumList ?? '[]', true) ?: []; $colors = json_decode($row->colorList ?? '[]', true) ?: []; $angles = json_decode($row->AList ?? '[]', true) ?: []; if (empty($inputs)) { return []; } $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), ]; } return $data; } private function showStats(bool $dryRun): void { $this->newLine(); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : '')); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); $this->info(" SAM BENDING 전체: {$this->stats['total_sam']}건"); $this->info(" 매칭 성공 (업데이트): {$this->stats['updated']}건"); $this->info(" 이미 bendingData 있음 (skip): {$this->stats['already_has']}건"); $this->info(" 매칭 실패: {$this->stats['no_match']}건"); if ($this->stats['no_bending_data'] > 0) { $this->warn(" 전개도 데이터 없음: {$this->stats['no_bending_data']}건"); } $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); if (! empty($this->unmatchedItems)) { $this->newLine(); $this->warn('⚠️ 매칭 실패 항목:'); foreach ($this->unmatchedItems as $code) { $this->line(" - {$code}"); } } if ($dryRun) { $this->newLine(); $this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.'); } } }