From 57133541d056af5b0d6c583b6104e04978729f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=98=81=EB=B3=B4?= Date: Mon, 16 Mar 2026 20:49:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[bending]=20=EC=A0=88=EA=B3=A1=ED=92=88?= =?UTF-8?q?=20=EA=B8=B0=EC=B4=88=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BendingItemController: CRUD + filters 엔드포인트 (pagination 메타 보존) - BendingItemService: items 테이블 item_category=BENDING 필터 기반 - BendingItemResource: options → 최상위 필드 노출 + 계산값(width_sum, bend_count) - FormRequest: Index/Store/Update 유효성 검증 (unique:items,code 포함) - BendingFillOptions: BD-* prefix/분류 속성 자동 보강 커맨드 - BendingImportLegacy: chandj 레거시 전개도(bendingData) 임포트 커맨드 (125/170건 매칭) - ensureContext: Bearer 토큰 없이 X-TENANT-ID 헤더로 컨텍스트 설정 --- app/Console/Commands/BendingFillOptions.php | 353 +++++++++++++++++ app/Console/Commands/BendingImportLegacy.php | 357 ++++++++++++++++++ .../Api/V1/BendingItemController.php | 95 +++++ app/Http/Middleware/ApiKeyMiddleware.php | 2 + .../Api/V1/BendingItemIndexRequest.php | 27 ++ .../Api/V1/BendingItemStoreRequest.php | 47 +++ .../Api/V1/BendingItemUpdateRequest.php | 46 +++ .../Resources/Api/V1/BendingItemResource.php | 70 ++++ app/Models/Items/Item.php | 18 + app/Services/BendingItemService.php | 119 ++++++ routes/api/v1/production.php | 11 + 11 files changed, 1145 insertions(+) create mode 100644 app/Console/Commands/BendingFillOptions.php create mode 100644 app/Console/Commands/BendingImportLegacy.php create mode 100644 app/Http/Controllers/Api/V1/BendingItemController.php create mode 100644 app/Http/Requests/Api/V1/BendingItemIndexRequest.php create mode 100644 app/Http/Requests/Api/V1/BendingItemStoreRequest.php create mode 100644 app/Http/Requests/Api/V1/BendingItemUpdateRequest.php create mode 100644 app/Http/Resources/Api/V1/BendingItemResource.php create mode 100644 app/Services/BendingItemService.php diff --git a/app/Console/Commands/BendingFillOptions.php b/app/Console/Commands/BendingFillOptions.php new file mode 100644 index 00000000..c08e35bc --- /dev/null +++ b/app/Console/Commands/BendingFillOptions.php @@ -0,0 +1,353 @@ + ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'], + 'RE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'], + 'RM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'], + 'RC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'], + 'RD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'], + 'RT' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'], + // 가이드레일 (측면) + 'SS' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재', 'material' => 'SUS 1.2T'], + 'SE' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'EGI마감재', 'material' => 'EGI 1.55T'], + 'SM' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => '본체', 'material' => 'EGI 1.55T'], + 'SC' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'C형', 'material' => 'EGI 1.55T'], + 'SD' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'D형', 'material' => 'EGI 1.55T'], + 'ST' => ['item_sep' => '철재', 'item_bending' => '가이드레일', 'item_name' => '본체(철재)', 'material' => 'EGI 1.55T'], + 'SU' => ['item_sep' => '스크린', 'item_bending' => '가이드레일', 'item_name' => 'SUS마감재2', 'material' => 'SUS 1.2T'], + // 하단마감재 + 'BE' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'EGI 1.55T'], + 'BS' => ['item_sep' => '스크린', 'item_bending' => '하단마감재', 'item_name' => '하단마감재', 'material' => 'SUS 1.5T'], + 'TS' => ['item_sep' => '철재', 'item_bending' => '하단마감재', 'item_name' => '하단마감재(철재)', 'material' => 'SUS 1.2T'], + 'LA' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR', 'item_name' => 'L-Bar', 'material' => 'EGI 1.55T'], + 'HH' => ['item_sep' => '스크린', 'item_bending' => '보강평철', 'item_name' => '보강평철', 'material' => 'EGI 1.55T'], + // 셔터박스 + 'CF' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '전면부', 'material' => 'EGI 1.55T'], + 'CL' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '린텔부', 'material' => 'EGI 1.55T'], + 'CP' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '점검구', 'material' => 'EGI 1.55T'], + 'CB' => ['item_sep' => '스크린', 'item_bending' => '케이스', 'item_name' => '후면코너부', 'material' => 'EGI 1.55T'], + // 연기차단재 + 'GI' => ['item_sep' => '스크린', 'item_bending' => '연기차단재', 'item_name' => '연기차단재', 'material' => '화이바원단'], + // 공용 + 'XX' => ['item_sep' => '스크린', 'item_bending' => '공용', 'item_name' => '하부BASE/상부덮개/마구리', 'material' => 'EGI 1.55T'], + 'YY' => ['item_sep' => '스크린', 'item_bending' => '별도마감', 'item_name' => '별도SUS마감', 'material' => 'SUS 1.2T'], + ]; + + // 한글 패턴 → 분류 매핑 + private const KOREAN_PATTERN_META = [ + 'BD-가이드레일' => ['item_sep' => null, 'item_bending' => '가이드레일'], + 'BD-케이스' => ['item_sep' => null, 'item_bending' => '케이스'], + 'BD-마구리' => ['item_sep' => null, 'item_bending' => '마구리'], + 'BD-하단마감재' => ['item_sep' => null, 'item_bending' => '하단마감재'], + 'BD-L-BAR' => ['item_sep' => '스크린', 'item_bending' => 'L-BAR'], + 'BD-보강평철' => ['item_sep' => '스크린', 'item_bending' => '보강평철'], + ]; + + private const LENGTH_MAP = [ + '02' => 200, '12' => 1219, '24' => 2438, '30' => 3000, + '35' => 3500, '40' => 4000, '41' => 4150, '42' => 4200, + '43' => 4300, '53' => 3000, '54' => 4000, '83' => 3000, '84' => 4000, + ]; + + private array $stats = [ + 'total' => 0, + 'prefix_len_filled' => 0, + 'korean_filled' => 0, + 'already_complete' => 0, + 'unknown_pattern' => 0, + ]; + + public function handle(): int + { + $tenantId = (int) $this->option('tenant_id'); + $dryRun = $this->option('dry-run'); + + $this->info('=== BD-* 품목 options 보강 ==='); + $this->info('Tenant: '.$tenantId.' | Mode: '.($dryRun ? 'DRY-RUN' : 'LIVE')); + $this->newLine(); + + // BD-* 전체 품목 조회 + $items = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->select('id', 'code', 'name', 'options') + ->orderBy('code') + ->get(); + + $this->stats['total'] = $items->count(); + $this->info("BD-* 품목: {$items->count()}건"); + $this->newLine(); + + foreach ($items as $item) { + $options = json_decode($item->options ?? '{}', true) ?: []; + $code = $item->code; + $newOptions = $this->resolveOptions($code, $item->name, $options); + + if ($newOptions === null) { + $this->stats['unknown_pattern']++; + $this->warn(" ❓ 미인식 패턴: {$code}"); + + continue; + } + + // 변경 필요 여부 확인 + $merged = array_merge($options, $newOptions); + if ($merged == $options) { + $this->stats['already_complete']++; + + continue; + } + + if (! $dryRun) { + $encoded = json_encode($merged, JSON_UNESCAPED_UNICODE); + if ($encoded === false) { + $this->error(" ❌ JSON 인코딩 실패: {$code} — ".json_last_error_msg()); + + continue; + } + DB::table('items') + ->where('id', $item->id) + ->update([ + 'options' => $encoded, + 'updated_at' => now(), + ]); + } + + $pattern = $this->detectPattern($code); + if ($pattern === 'prefix_len') { + $this->stats['prefix_len_filled']++; + } else { + $this->stats['korean_filled']++; + } + + $this->line(" ✅ {$code}: +".implode(', ', array_keys($newOptions))); + } + + $this->showStats($dryRun); + + return self::SUCCESS; + } + + /** + * 코드에서 options 속성 추출 + */ + private function resolveOptions(string $code, string $name, array $existing): ?array + { + $new = []; + + // item_category 보장 + if (empty($existing['item_category'])) { + // item_category는 items 테이블 컬럼이므로 여기서는 skip + } + + // 패턴 A: BD-PREFIX-LEN (예: BD-RS-30) + if (preg_match('/^BD-([A-Z]{2})-(\d{2})$/', $code, $m)) { + $prefix = $m[1]; + $lengthCode = $m[2]; + + // prefix/length 기본값 + if (empty($existing['prefix'])) { + $new['prefix'] = $prefix; + } + if (empty($existing['length_code'])) { + $new['length_code'] = $lengthCode; + } + if (empty($existing['length_mm']) && isset(self::LENGTH_MAP[$lengthCode])) { + $new['length_mm'] = self::LENGTH_MAP[$lengthCode]; + } + + // PREFIX 기반 분류 속성 + $meta = self::PREFIX_META[$prefix] ?? null; + if ($meta) { + foreach ($meta as $key => $value) { + if (empty($existing[$key])) { + $new[$key] = $value; + } + } + } + + return $new; + } + + // 특수 코드 (패턴 미준수) + $specialCodes = [ + 'BD-가이드레일용 연기차단재' => ['item_bending' => '연기차단재'], + 'BD-케이스용 연기차단재' => ['item_bending' => '연기차단재'], + ]; + if (isset($specialCodes[$code])) { + foreach ($specialCodes[$code] as $key => $value) { + if (empty($existing[$key])) { + $new[$key] = $value; + } + } + + return $new; + } + + // 패턴 B~G: 한글 패턴 + foreach (self::KOREAN_PATTERN_META as $patternPrefix => $meta) { + // 정확한 접두사+구분자 매칭 (BD-케이스-xxx는 O, BD-케이스용xxx는 X) + if ($code === $patternPrefix || str_starts_with($code, $patternPrefix.'-')) { + // 분류 속성 + foreach ($meta as $key => $value) { + if ($value !== null && empty($existing[$key])) { + $new[$key] = $value; + } + } + + // 한글 패턴별 추가 파싱 + $this->parseKoreanPattern($code, $patternPrefix, $existing, $new); + + return $new; + } + } + + return null; + } + + /** + * 한글 패턴에서 모델/재질/규격 추출 + */ + private function parseKoreanPattern(string $code, string $patternPrefix, array $existing, array &$new): void + { + $suffix = substr($code, strlen($patternPrefix) + 1); // "-" 제거 + $parts = explode('-', $suffix); + + switch ($patternPrefix) { + case 'BD-가이드레일': + // BD-가이드레일-KSS01-SUS-120*70 + if (count($parts) >= 3) { + if (empty($existing['model_name'])) { + $new['model_name'] = $parts[0]; + } + if (empty($existing['material'])) { + $material = $parts[1]; + $new['material'] = str_contains($material, 'SUS') ? 'SUS 1.2T' : 'EGI 1.55T'; + } + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[2]; + } + // item_sep 추론 (KTE → 철재) + if (empty($existing['item_sep'])) { + $new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린'; + } + } + break; + + case 'BD-하단마감재': + // BD-하단마감재-KSS01-SUS-60*40 + if (count($parts) >= 3) { + if (empty($existing['model_name'])) { + $new['model_name'] = $parts[0]; + } + if (empty($existing['material'])) { + $material = $parts[1]; + $new['material'] = str_contains($material, 'SUS') ? 'SUS 1.5T' : 'EGI 1.55T'; + } + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[2]; + } + if (empty($existing['item_sep'])) { + $new['item_sep'] = str_starts_with($parts[0], 'KTE') ? '철재' : '스크린'; + } + } + break; + + case 'BD-케이스': + // BD-케이스-650*550 + if (count($parts) >= 1 && ! empty($parts[0])) { + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[0]; + } + // 케이스는 대부분 철재 + if (empty($existing['item_sep'])) { + $new['item_sep'] = '철재'; + } + } + break; + + case 'BD-마구리': + // BD-마구리-655*505 + if (count($parts) >= 1 && ! empty($parts[0])) { + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[0]; + } + if (empty($existing['item_sep'])) { + $new['item_sep'] = '철재'; + } + } + break; + + case 'BD-L-BAR': + // BD-L-BAR-KSS01-17*60 + if (count($parts) >= 2) { + if (empty($existing['model_name'])) { + $new['model_name'] = $parts[0]; + } + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[1]; + } + } + break; + + case 'BD-보강평철': + // BD-보강평철-50 + if (count($parts) >= 1 && ! empty($parts[0])) { + if (empty($existing['item_spec'])) { + $new['item_spec'] = $parts[0]; + } + } + break; + } + } + + private function detectPattern(string $code): string + { + return preg_match('/^BD-[A-Z]{2}-\d{2}$/', $code) ? 'prefix_len' : 'korean'; + } + + private function showStats(bool $dryRun): void + { + $this->newLine(); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info('📊 결과'.($dryRun ? ' (DRY-RUN)' : '')); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info(" 전체 BD-* 품목: {$this->stats['total']}건"); + $this->info(" PREFIX-LEN 업데이트: {$this->stats['prefix_len_filled']}건"); + $this->info(" 한글 패턴 업데이트: {$this->stats['korean_filled']}건"); + $this->info(" 이미 완료: {$this->stats['already_complete']}건"); + if ($this->stats['unknown_pattern'] > 0) { + $this->warn(" 미인식 패턴: {$this->stats['unknown_pattern']}건"); + } + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + + if ($dryRun) { + $this->newLine(); + $this->info('🔍 DRY-RUN 완료. --dry-run 제거하면 실제 반영됩니다.'); + } + } +} diff --git a/app/Console/Commands/BendingImportLegacy.php b/app/Console/Commands/BendingImportLegacy.php new file mode 100644 index 00000000..6a1b6aff --- /dev/null +++ b/app/Console/Commands/BendingImportLegacy.php @@ -0,0 +1,357 @@ + ['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' => ['same_as' => 'RD'], + '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 + if (preg_match('/^BD-케이스-(\d+)\*(\d+)$/', $code, $m)) { + $spec = $m[1].'*'.$m[2]; + + return $chandjRows->first(function ($r) use ($spec) { + return $r->item_bending === '케이스' + && (str_contains($r->itemName, $spec) || $r->item_spec === $spec); + }); + } + + // 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 제거하면 실제 반영됩니다.'); + } + } +} diff --git a/app/Http/Controllers/Api/V1/BendingItemController.php b/app/Http/Controllers/Api/V1/BendingItemController.php new file mode 100644 index 00000000..ec948b37 --- /dev/null +++ b/app/Http/Controllers/Api/V1/BendingItemController.php @@ -0,0 +1,95 @@ +bound('tenant_id') || ! app('tenant_id')) { + $tenantId = (int) ($request->header('X-TENANT-ID') ?: 287); + app()->instance('tenant_id', $tenantId); + } + if (! app()->bound('api_user') || ! app('api_user')) { + // mng에서 Bearer 토큰 없이 호출 시 시스템 사용자(1)로 설정 + app()->instance('api_user', 1); + } + } + + public function index(BendingItemIndexRequest $request): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle(function () use ($request) { + $paginator = $this->service->list($request->validated()); + $paginator->getCollection()->transform(fn ($item) => (new BendingItemResource($item))->resolve()); + + return $paginator; + }, __('message.fetched')); + } + + public function filters(Request $request): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => $this->service->filters(), + __('message.fetched') + ); + } + + public function show(Request $request, int $id): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => new BendingItemResource($this->service->find($id)), + __('message.fetched') + ); + } + + public function store(BendingItemStoreRequest $request): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => new BendingItemResource($this->service->create($request->validated())), + __('message.created') + ); + } + + public function update(BendingItemUpdateRequest $request, int $id): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => new BendingItemResource($this->service->update($id, $request->validated())), + __('message.updated') + ); + } + + public function destroy(Request $request, int $id): JsonResponse + { + $this->ensureContext($request); + + return ApiResponse::handle( + fn () => $this->service->delete($id), + __('message.deleted') + ); + } +} diff --git a/app/Http/Middleware/ApiKeyMiddleware.php b/app/Http/Middleware/ApiKeyMiddleware.php index 64717fd5..3c1ce956 100644 --- a/app/Http/Middleware/ApiKeyMiddleware.php +++ b/app/Http/Middleware/ApiKeyMiddleware.php @@ -125,6 +125,8 @@ public function handle(Request $request, Closure $next) 'api/v1/internal/exchange-token', // 내부 서버간 토큰 교환 (HMAC 인증 사용) 'api/v1/admin/fcm/*', // Admin FCM API (MNG에서 API Key만으로 접근) 'api/v1/app/*', // 앱 버전 확인/다운로드 (API Key만 필요) + 'api/v1/bending-items', // 절곡품 목록 (MNG에서 API Key만으로 접근) + 'api/v1/bending-items/*', // 절곡품 상세/필터 ]; // 현재 라우트 확인 (경로 또는 이름) diff --git a/app/Http/Requests/Api/V1/BendingItemIndexRequest.php b/app/Http/Requests/Api/V1/BendingItemIndexRequest.php new file mode 100644 index 00000000..bb704091 --- /dev/null +++ b/app/Http/Requests/Api/V1/BendingItemIndexRequest.php @@ -0,0 +1,27 @@ + 'nullable|string|in:스크린,철재', + 'item_bending' => 'nullable|string', + 'material' => 'nullable|string', + 'model_UA' => 'nullable|string|in:인정,비인정', + 'model_name' => 'nullable|string', + '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 new file mode 100644 index 00000000..1818f1bb --- /dev/null +++ b/app/Http/Requests/Api/V1/BendingItemStoreRequest.php @@ -0,0 +1,47 @@ + 'required|string|max:100|unique:items,code', + 'name' => 'required|string|max:200', + 'unit' => 'nullable|string|max:20', + 'item_name' => 'required|string|max:50', + 'item_sep' => 'required|in:스크린,철재', + 'item_bending' => 'required|string|max:50', + 'material' => 'required|string|max:50', + 'model_UA' => 'nullable|in:인정,비인정', + 'item_spec' => 'nullable|string|max:50', + 'model_name' => 'nullable|string|max:30', + 'search_keyword' => 'nullable|string|max:100', + 'rail_width' => 'nullable|integer', + 'memo' => 'nullable|string|max:500', + 'author' => 'nullable|string|max:50', + 'registration_date' => 'nullable|date', + // 케이스 전용 + 'exit_direction' => 'nullable|string|max:30', + 'front_bottom_width' => 'nullable|integer', + 'box_width' => 'nullable|integer', + 'box_height' => 'nullable|integer', + // 전개도 + 'bendingData' => 'nullable|array', + 'bendingData.*.no' => 'required|integer', + 'bendingData.*.input' => 'required|numeric', + 'bendingData.*.rate' => 'nullable|string', + 'bendingData.*.sum' => 'required|numeric', + 'bendingData.*.color' => 'required|boolean', + 'bendingData.*.aAngle' => 'required|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/V1/BendingItemUpdateRequest.php b/app/Http/Requests/Api/V1/BendingItemUpdateRequest.php new file mode 100644 index 00000000..9cd0975b --- /dev/null +++ b/app/Http/Requests/Api/V1/BendingItemUpdateRequest.php @@ -0,0 +1,46 @@ + 'sometimes|string|max:100', + 'name' => 'sometimes|string|max:200', + 'item_name' => 'sometimes|string|max:50', + 'item_sep' => 'sometimes|in:스크린,철재', + 'item_bending' => 'sometimes|string|max:50', + 'material' => 'sometimes|string|max:50', + 'model_UA' => 'nullable|in:인정,비인정', + 'item_spec' => 'nullable|string|max:50', + 'model_name' => 'nullable|string|max:30', + 'search_keyword' => 'nullable|string|max:100', + 'rail_width' => 'nullable|integer', + 'memo' => 'nullable|string|max:500', + 'author' => 'nullable|string|max:50', + 'registration_date' => 'nullable|date', + // 케이스 전용 + 'exit_direction' => 'nullable|string|max:30', + 'front_bottom_width' => 'nullable|integer', + 'box_width' => 'nullable|integer', + 'box_height' => 'nullable|integer', + // 전개도 + 'bendingData' => 'nullable|array', + 'bendingData.*.no' => 'required|integer', + 'bendingData.*.input' => 'required|numeric', + 'bendingData.*.rate' => 'nullable|string', + 'bendingData.*.sum' => 'required|numeric', + 'bendingData.*.color' => 'required|boolean', + 'bendingData.*.aAngle' => 'required|boolean', + ]; + } +} diff --git a/app/Http/Resources/Api/V1/BendingItemResource.php b/app/Http/Resources/Api/V1/BendingItemResource.php new file mode 100644 index 00000000..76336e58 --- /dev/null +++ b/app/Http/Resources/Api/V1/BendingItemResource.php @@ -0,0 +1,70 @@ + $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'), + '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'), + // 계산값 + 'width_sum' => $this->getWidthSum(), + 'bend_count' => $this->getBendCount(), + // 메타 + '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 + { + $data = $this->getOption('bendingData', []); + if (empty($data)) { + return null; + } + $last = end($data); + + 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'] ?? '') !== '')); + } +} diff --git a/app/Models/Items/Item.php b/app/Models/Items/Item.php index 97329f09..5a4e7b48 100644 --- a/app/Models/Items/Item.php +++ b/app/Models/Items/Item.php @@ -182,6 +182,24 @@ public function scopeActive($query) return $query->where('is_active', true); } + // ────────────────────────────────────────────────────────────── + // 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; + } + // ────────────────────────────────────────────────────────────── // 헬퍼 메서드 // ────────────────────────────────────────────────────────────── diff --git a/app/Services/BendingItemService.php b/app/Services/BendingItemService.php new file mode 100644 index 00000000..31653a41 --- /dev/null +++ b/app/Services/BendingItemService.php @@ -0,0 +1,119 @@ +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)) + ->when($params['search'] ?? null, fn ($q, $v) => $q->where( + fn ($q2) => $q2 + ->where('name', 'like', "%{$v}%") + ->orWhere('code', 'like', "%{$v}%") + ->orWhere('options->search_keyword', 'like', "%{$v}%") + ->orWhere('options->item_spec', 'like', "%{$v}%") + )) + ->orderBy('code') + ->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(), + ]; + } + + public function find(int $id): Item + { + return Item::where('item_category', 'BENDING')->findOrFail($id); + } + + public function create(array $data): Item + { + $options = $this->buildOptions($data); + + return Item::create([ + 'tenant_id' => $this->tenantId(), + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'code' => $data['code'], + 'name' => $data['name'], + 'unit' => $data['unit'] ?? 'EA', + 'options' => $options, + 'is_active' => true, + 'created_by' => $this->apiUserId(), + ]); + } + + public function update(int $id, array $data): Item + { + $item = Item::where('item_category', 'BENDING')->findOrFail($id); + + if (isset($data['code'])) { + $item->code = $data['code']; + } + if (isset($data['name'])) { + $item->name = $data['name']; + } + + $optionKeys = self::OPTION_KEYS; + foreach ($optionKeys as $key) { + if (array_key_exists($key, $data)) { + $item->setOption($key, $data[$key]); + } + } + + $item->updated_by = $this->apiUserId(); + $item->save(); + + return $item; + } + + public function delete(int $id): bool + { + $item = Item::where('item_category', 'BENDING')->findOrFail($id); + $item->deleted_by = $this->apiUserId(); + $item->save(); + + return $item->delete(); + } + + private function buildOptions(array $data): array + { + $options = []; + foreach (self::OPTION_KEYS as $key) { + if (isset($data[$key])) { + $options[$key] = $data[$key]; + } + } + + return $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', + ]; +} diff --git a/routes/api/v1/production.php b/routes/api/v1/production.php index 17cb3f5b..7cacdf41 100644 --- a/routes/api/v1/production.php +++ b/routes/api/v1/production.php @@ -9,6 +9,7 @@ * - 검사 관리 */ +use App\Http\Controllers\Api\V1\BendingItemController; use App\Http\Controllers\Api\V1\InspectionController; use App\Http\Controllers\Api\V1\ProductionOrderController; use App\Http\Controllers\Api\V1\WorkOrderController; @@ -123,6 +124,16 @@ Route::patch('/{id}/complete', [InspectionController::class, 'complete'])->whereNumber('id')->name('v1.inspections.complete'); // 완료 처리 }); +// Bending Item API (절곡품 기초관리) +Route::prefix('bending-items')->group(function () { + Route::get('', [BendingItemController::class, 'index'])->name('v1.bending-items.index'); + Route::get('/filters', [BendingItemController::class, 'filters'])->name('v1.bending-items.filters'); + Route::post('', [BendingItemController::class, 'store'])->name('v1.bending-items.store'); + Route::get('/{id}', [BendingItemController::class, 'show'])->whereNumber('id')->name('v1.bending-items.show'); + Route::put('/{id}', [BendingItemController::class, 'update'])->whereNumber('id')->name('v1.bending-items.update'); + Route::delete('/{id}', [BendingItemController::class, 'destroy'])->whereNumber('id')->name('v1.bending-items.destroy'); +}); + // Production Order API (생산지시 조회) Route::prefix('production-orders')->group(function () { Route::get('', [ProductionOrderController::class, 'index'])->name('v1.production-orders.index');