diff --git a/app/Console/Commands/MigrateBDModelsPrices.php b/app/Console/Commands/MigrateBDModelsPrices.php index b1a41fb..60f021f 100644 --- a/app/Console/Commands/MigrateBDModelsPrices.php +++ b/app/Console/Commands/MigrateBDModelsPrices.php @@ -6,39 +6,67 @@ use Illuminate\Support\Facades\DB; /** - * BDmodels + kd_price_tables → items + item_details + prices 마이그레이션 + * chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션 * - * 레거시 chandj.BDmodels 데이터와 kd_price_tables 데이터를 + * 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials, + * price_shaft, price_pipe, price_angle, price_smokeban 데이터를 * items + item_details + prices 통합 구조로 마이그레이션 */ class MigrateBDModelsPrices extends Command { - protected $signature = 'kd:migrate-prices {--dry-run : 실제 DB 변경 없이 미리보기}'; + protected $signature = 'kd:migrate-prices + {--dry-run : 실제 DB 변경 없이 미리보기} + {--fresh : 기존 EST-* 항목 삭제 후 재생성}'; - protected $description = '경동 견적 단가를 items+item_details+prices로 마이그레이션'; + protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션'; private const TENANT_ID = 287; private int $created = 0; + private int $updated = 0; + private int $skipped = 0; + private int $deleted = 0; + public function handle(): int { $dryRun = $this->option('dry-run'); + $fresh = $this->option('fresh'); - $this->info('=== 경동 견적 단가 마이그레이션 ==='); + $this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ==='); $this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다'); $this->newLine(); DB::beginTransaction(); try { - // 1. 레거시 BDmodels (chandj DB) + // --fresh: 기존 EST-* 항목 삭제 + if ($fresh) { + $this->cleanExistingEstItems($dryRun); + } + + // 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철) $this->migrateBDModels($dryRun); - // 2. kd_price_tables (motor, shaft, pipe, angle, raw_material) - $this->migrateKdPriceTables($dryRun); + // 2. price_motor (모터 + 제어기) + $this->migrateMotors($dryRun); + + // 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등) + $this->migrateRawMaterials($dryRun); + + // 4. price_shaft (감기샤프트) + $this->migrateShafts($dryRun); + + // 5. price_pipe (각파이프) + $this->migratePipes($dryRun); + + // 6. price_angle (앵글) + $this->migrateAngles($dryRun); + + // 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완) + $this->migrateSmokeBan($dryRun); if ($dryRun) { DB::rollBack(); @@ -49,25 +77,49 @@ public function handle(): int } $this->newLine(); - $this->info("생성: {$this->created}건, 스킵: {$this->skipped}건"); + $this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}건"); return Command::SUCCESS; } catch (\Exception $e) { DB::rollBack(); $this->error("오류: {$e->getMessage()}"); + $this->error($e->getTraceAsString()); return Command::FAILURE; } } /** - * 레거시 chandj.BDmodels → items + item_details + prices + * 기존 EST-* 항목 삭제 (--fresh 옵션) + */ + private function cleanExistingEstItems(bool $dryRun): void + { + $this->info('--- 기존 EST-* 항목 삭제 ---'); + + $items = DB::table('items') + ->where('tenant_id', self::TENANT_ID) + ->where('code', 'LIKE', 'EST-%') + ->whereNull('deleted_at') + ->get(['id', 'code']); + + foreach ($items as $item) { + $this->line(" [삭제] {$item->code}"); + if (! $dryRun) { + DB::table('prices')->where('item_id', $item->id)->delete(); + DB::table('item_details')->where('item_id', $item->id)->delete(); + DB::table('items')->where('id', $item->id)->delete(); + } + $this->deleted++; + } + } + + /** + * chandj.BDmodels → items + item_details + prices */ private function migrateBDModels(bool $dryRun): void { - $this->info('--- BDmodels (레거시) ---'); + $this->info('--- BDmodels (절곡품) ---'); - // chandj DB에서 BDmodels 조회 (chandj connection 사용) $rows = DB::connection('chandj')->select(" SELECT model_name, seconditem, finishing_type, spec, unitprice, description FROM BDmodels @@ -91,7 +143,6 @@ private function migrateBDModels(bool $dryRun): void continue; } - // 코드 생성 $codeParts = ['BD', $secondItem]; if ($modelName) { $codeParts[] = $modelName; @@ -104,7 +155,6 @@ private function migrateBDModels(bool $dryRun): void } $code = implode('-', $codeParts); - // 이름 생성 $nameParts = [$secondItem]; if ($modelName) { $nameParts[] = $modelName; @@ -117,7 +167,7 @@ private function migrateBDModels(bool $dryRun): void } $name = implode(' ', $nameParts); - $this->createEstimateItem( + $this->upsertEstimateItem( code: $code, name: $name, productCategory: 'bdmodels', @@ -130,162 +180,357 @@ private function migrateBDModels(bool $dryRun): void 'description' => $row->description ?: null, ]), salesPrice: $unitPrice, - note: 'BDmodels 마이그레이션', + note: 'chandj.BDmodels', dryRun: $dryRun ); } } /** - * kd_price_tables → items + item_details + prices + * chandj.price_motor → 모터 + 제어기 + * + * col1: 전압 (220, 380, 제어기, 방화, 방범) + * col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등) + * col13: 판매가 */ - private function migrateKdPriceTables(bool $dryRun): void + private function migrateMotors(bool $dryRun): void { - $this->info('--- kd_price_tables ---'); + $this->info('--- price_motor (모터/제어기) ---'); - $rows = DB::table('kd_price_tables') - ->where('tenant_id', self::TENANT_ID) - ->where('is_active', true) - ->where('table_type', '!=', 'bdmodels') // BDmodels는 위에서 처리 - ->orderBy('table_type') - ->orderBy('item_code') - ->get(); + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } - foreach ($rows as $row) { - $tableType = $row->table_type; - $unitPrice = (float) $row->unit_price; + $items = json_decode($row->itemList, true); - if ($unitPrice <= 0) { + foreach ($items as $item) { + $category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범 + $name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등 + $price = (int) str_replace(',', '', $item['col13'] ?? '0'); + + if (empty($name) || $price <= 0) { $this->skipped++; continue; } - switch ($tableType) { - case 'motor': - $this->migrateMotor($row, $dryRun); - break; - case 'shaft': - $this->migrateShaft($row, $dryRun); - break; - case 'pipe': - $this->migratePipe($row, $dryRun); - break; - case 'angle': - $this->migrateAngle($row, $dryRun); - break; - case 'raw_material': - $this->migrateRawMaterial($row, $dryRun); - break; + // 카테고리 분류 + if (in_array($category, ['220', '380'])) { + $productCategory = 'motor'; + $code = "EST-MOTOR-{$category}V-{$name}"; + $displayName = "모터 {$name} ({$category}V)"; + $partType = $name; + } elseif ($category === '제어기') { + $productCategory = 'controller'; + $code = "EST-CTRL-{$name}"; + $displayName = "제어기 {$name}"; + $partType = $name; + } else { + // 방화, 방범 등 + $productCategory = 'controller'; + $code = "EST-CTRL-{$category}-{$name}"; + $displayName = "{$category} {$name}"; + $partType = "{$category} {$name}"; + } + + $this->upsertEstimateItem( + code: $code, + name: $displayName, + productCategory: $productCategory, + partType: $partType, + specification: null, + attributes: ['voltage' => $category, 'source' => 'price_motor'], + salesPrice: (float) $price, + note: 'chandj.price_motor', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_raw_materials → 원자재 + * + * col1: 카테고리 (슬랫, 스크린) + * col2: 품명 (방화, 실리카, 화이바, 와이어 등) + * col13: 판매단가 + */ + private function migrateRawMaterials(bool $dryRun): void + { + $this->info('--- price_raw_materials (원자재) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $category = trim($item['col1'] ?? ''); + $name = trim($item['col2'] ?? ''); + $price = (int) str_replace(',', '', $item['col13'] ?? '0'); + + if (empty($name) || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-RAW-{$category}-{$name}"; + $displayName = "{$category} {$name}"; + + $this->upsertEstimateItem( + code: $code, + name: $displayName, + productCategory: 'raw_material', + partType: $name, + specification: $category, + attributes: ['category' => $category, 'source' => 'price_raw_materials'], + salesPrice: (float) $price, + note: 'chandj.price_raw_materials', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_shaft → 감기샤프트 + * + * col4: 인치 (3, 4, 5, 6, 8, 10, 12) + * col10: 길이 (m) + * col19: 판매가 + */ + private function migrateShafts(bool $dryRun): void + { + $this->info('--- price_shaft (감기샤프트) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $inch = trim($item['col4'] ?? ''); + $lengthM = trim($item['col10'] ?? ''); + $price = (int) str_replace(',', '', $item['col19'] ?? '0'); + + if (empty($inch) || empty($lengthM) || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-SHAFT-{$inch}-{$lengthM}"; + $name = "감기샤프트 {$inch}인치 {$lengthM}m"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'shaft', + partType: $inch, + specification: $lengthM, + attributes: ['source' => 'price_shaft'], + salesPrice: (float) $price, + note: 'chandj.price_shaft', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_pipe → 각파이프 + * + * col4: 두께 (1.4, 2) + * col2: 길이 (3,000 / 6,000) + * col8: 판매가 + */ + private function migratePipes(bool $dryRun): void + { + $this->info('--- price_pipe (각파이프) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $thickness = trim($item['col4'] ?? ''); + $length = (int) str_replace(',', '', $item['col2'] ?? '0'); + $price = (int) str_replace(',', '', $item['col8'] ?? '0'); + + if (empty($thickness) || $length <= 0 || $price <= 0) { + $this->skipped++; + + continue; + } + + $code = "EST-PIPE-{$thickness}-{$length}"; + $name = "각파이프 {$thickness}T {$length}mm"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'pipe', + partType: $thickness, + specification: (string) $length, + attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'], + salesPrice: (float) $price, + note: 'chandj.price_pipe', + dryRun: $dryRun + ); + } + } + + /** + * chandj.price_angle → 앵글 (bracket + main 분리) + * + * bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등) + * - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가 + * + * main angle (부자재용): col2가 숫자 (4 등) + * - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가 + */ + private function migrateAngles(bool $dryRun): void + { + $this->info('--- price_angle (앵글) ---'); + + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" + ); + if (! $row) { + return; + } + + $items = json_decode($row->itemList, true); + + foreach ($items as $item) { + $col2 = trim($item['col2'] ?? ''); + $col3 = trim($item['col3'] ?? ''); + $col4 = trim($item['col4'] ?? ''); + $col10 = trim($item['col10'] ?? ''); + $price = (int) str_replace(',', '', $item['col19'] ?? '0'); + + if ($price <= 0) { + $this->skipped++; + + continue; + } + + // col2가 숫자이면 main angle, 텍스트이면 bracket angle + if (is_numeric($col2)) { + // Main angle (부자재용): col4=앵글3T, col10=2.5 + if (empty($col4) || empty($col10)) { + $this->skipped++; + + continue; + } + + $code = "EST-ANGLE-MAIN-{$col4}-{$col10}"; + $name = "앵글 {$col4} {$col10}m"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'angle_main', + partType: $col4, + specification: $col10, + attributes: ['source' => 'price_angle'], + salesPrice: (float) $price, + note: 'chandj.price_angle (main)', + dryRun: $dryRun + ); + } else { + // Bracket angle (모터 받침용): col2=스크린용, col3=380*180 + if (empty($col2)) { + $this->skipped++; + + continue; + } + + $code = "EST-ANGLE-BRACKET-{$col2}"; + $name = "모터받침 앵글 {$col2}"; + + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'angle_bracket', + partType: $col2, + specification: $col3 ?: null, + attributes: [ + 'angle_type' => $col4, + 'source' => 'price_angle', + ], + salesPrice: (float) $price, + note: 'chandj.price_angle (bracket)', + dryRun: $dryRun + ); } } } - private function migrateMotor(object $row, bool $dryRun): void + /** + * chandj.price_smokeban → 연기차단재 + * + * col2: 용도 (레일용, 케이스용) + * col11: 판매가 + */ + private function migrateSmokeBan(bool $dryRun): void { - $category = $row->category; // 150K, 300K, 매립형, 노출형 등 - $code = "EST-MOTOR-{$category}"; - $name = "모터/제어기 {$category}"; + $this->info('--- price_smokeban (연기차단재) ---'); - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'motor', - partType: $category, - specification: $row->spec2 ?? null, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables motor 마이그레이션', - dryRun: $dryRun + $row = DB::connection('chandj')->selectOne( + "SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1" ); - } + if (! $row) { + return; + } - private function migrateShaft(object $row, bool $dryRun): void - { - $size = $row->spec1; // 인치 - $length = $row->spec2; // 길이 - $code = "EST-SHAFT-{$size}-{$length}"; - $name = "감기샤프트 {$size}인치 {$length}m"; + $items = json_decode($row->itemList, true); - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'shaft', - partType: $size, - specification: $length, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables shaft 마이그레이션', - dryRun: $dryRun - ); - } + foreach ($items as $item) { + $usage = trim($item['col2'] ?? ''); + $price = (int) str_replace(',', '', $item['col11'] ?? '0'); - private function migratePipe(object $row, bool $dryRun): void - { - $thickness = $row->spec1; - $length = $row->spec2; - $code = "EST-PIPE-{$thickness}-{$length}"; - $name = "각파이프 {$thickness}T {$length}mm"; + if (empty($usage) || $price <= 0) { + $this->skipped++; - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'pipe', - partType: $thickness, - specification: $length, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables pipe 마이그레이션', - dryRun: $dryRun - ); - } + continue; + } - private function migrateAngle(object $row, bool $dryRun): void - { - $category = $row->category; // 스크린용, 철재용 - $bracketSize = $row->spec1; // 530*320, 600*350, 690*390 - $angleType = $row->spec2; // 앵글3T, 앵글4T - $code = "EST-ANGLE-{$category}-{$bracketSize}-{$angleType}"; - $name = "앵글 {$category} {$bracketSize} {$angleType}"; + $code = "EST-SMOKE-{$usage}"; + $name = "연기차단재 {$usage}"; - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'angle', - partType: $category, - specification: $bracketSize, - attributes: [ - 'angle_type' => $angleType, - 'price_unit' => $row->unit ?? 'EA', - ], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables angle 마이그레이션', - dryRun: $dryRun - ); - } - - private function migrateRawMaterial(object $row, bool $dryRun): void - { - $name = $row->item_name; - $code = 'EST-RAW-'.preg_replace('/[^A-Za-z0-9가-힣]/', '', $name); - - $this->createEstimateItem( - code: $code, - name: $name, - productCategory: 'raw_material', - partType: $name, - specification: $row->spec1 ?? null, - attributes: ['price_unit' => $row->unit ?? 'EA'], - salesPrice: (float) $row->unit_price, - note: 'kd_price_tables raw_material 마이그레이션', - dryRun: $dryRun - ); + $this->upsertEstimateItem( + code: $code, + name: $name, + productCategory: 'smokeban', + partType: $usage, + specification: null, + attributes: ['source' => 'price_smokeban'], + salesPrice: (float) $price, + note: 'chandj.price_smokeban', + dryRun: $dryRun + ); + } } /** - * 견적 품목 생성 (items + item_details + prices) + * 견적 품목 생성 또는 가격 업데이트 */ - private function createEstimateItem( + private function upsertEstimateItem( string $code, string $name, string $productCategory, @@ -296,7 +541,6 @@ private function createEstimateItem( string $note, bool $dryRun ): void { - // 중복 체크 (code 기준) $existing = DB::table('items') ->where('tenant_id', self::TENANT_ID) ->where('code', $code) @@ -304,13 +548,49 @@ private function createEstimateItem( ->first(); if ($existing) { - $this->line(" [스킵] {$code} - 이미 존재"); - $this->skipped++; + // 가격 업데이트 + $currentPrice = DB::table('prices') + ->where('item_id', $existing->id) + ->where('status', 'active') + ->orderByDesc('id') + ->value('sales_price'); + + if ((float) $currentPrice === $salesPrice) { + $this->skipped++; + + return; + } + + $this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . " → " . number_format($salesPrice)); + + if (! $dryRun) { + // 기존 가격 비활성화 + DB::table('prices') + ->where('item_id', $existing->id) + ->where('status', 'active') + ->update(['status' => 'inactive', 'updated_at' => now()]); + + // 새 가격 추가 + DB::table('prices')->insert([ + 'tenant_id' => self::TENANT_ID, + 'item_type_code' => 'PT', + 'item_id' => $existing->id, + 'sales_price' => $salesPrice, + 'effective_from' => now()->toDateString(), + 'status' => 'active', + 'note' => $note, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $this->updated++; return; } - $this->line(" [생성] {$code} ({$name}) = {$salesPrice}"); + // 신규 생성 + $this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice)); if ($dryRun) { $this->created++; @@ -320,7 +600,6 @@ private function createEstimateItem( $now = now(); - // 1. items $itemId = DB::table('items')->insertGetId([ 'tenant_id' => self::TENANT_ID, 'item_type' => 'PT', @@ -333,7 +612,6 @@ private function createEstimateItem( 'updated_at' => $now, ]); - // 2. item_details DB::table('item_details')->insert([ 'item_id' => $itemId, 'product_category' => $productCategory, @@ -345,7 +623,6 @@ private function createEstimateItem( 'updated_at' => $now, ]); - // 3. prices DB::table('prices')->insert([ 'tenant_id' => self::TENANT_ID, 'item_type_code' => 'PT', diff --git a/app/Services/Quote/EstimatePriceService.php b/app/Services/Quote/EstimatePriceService.php index f66f336..88ec0c7 100644 --- a/app/Services/Quote/EstimatePriceService.php +++ b/app/Services/Quote/EstimatePriceService.php @@ -163,10 +163,41 @@ public function getRailSmokeBlockPrice(): float /** * 모터 단가 + * + * chandj col2는 '150K(S)', '300K(S)', '300K' 등 다양한 형식 + * handler는 '150K', '300K' 등 단순 용량으로 호출 + * LIKE 매칭 + 380V 기본 전압 필터 적용 */ - public function getMotorPrice(string $motorCapacity): float + public function getMotorPrice(string $motorCapacity, string $voltage = '380'): float { - return $this->getEstimatePartPrice('motor', $motorCapacity); + $cacheKey = "motor:{$motorCapacity}:{$voltage}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $today = now()->toDateString(); + + $price = (float) (DB::table('items') + ->join('item_details', 'item_details.item_id', '=', 'items.id') + ->join('prices', 'prices.item_id', '=', 'items.id') + ->where('items.tenant_id', $this->tenantId) + ->where('items.is_active', true) + ->whereNull('items.deleted_at') + ->where('item_details.product_category', 'motor') + ->where('item_details.part_type', 'LIKE', "{$motorCapacity}%") + ->where('items.attributes->voltage', $voltage) + ->where('prices.effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('prices.effective_to') + ->orWhere('prices.effective_to', '>=', $today); + }) + ->whereNull('prices.deleted_at') + ->value('prices.sales_price') ?? 0); + + $this->cache[$cacheKey] = $price; + + return $price; } /** @@ -174,7 +205,7 @@ public function getMotorPrice(string $motorCapacity): float */ public function getControllerPrice(string $controllerType): float { - return $this->getEstimatePartPrice('motor', $controllerType); + return $this->getEstimatePartPrice('controller', $controllerType); } // ========================================================================= @@ -183,10 +214,14 @@ public function getControllerPrice(string $controllerType): float /** * 샤프트 단가 + * + * chandj col10은 '0.3', '3', '6' 등 혼재 포맷 + * 정수면 '6', 소수면 '0.3' 그대로 저장됨 */ public function getShaftPrice(string $size, float $length): float { - $lengthStr = number_format($length, 1, '.', ''); + // chandj 원본 포맷에 맞게 변환: 정수면 정수형, 소수면 소수형 + $lengthStr = ($length == (int) $length) ? (string) (int) $length : (string) $length; $cacheKey = "shaft:{$size}:{$lengthStr}"; if (isset($this->cache[$cacheKey])) { @@ -208,11 +243,14 @@ public function getPipePrice(string $thickness, int $length): float } /** - * 앵글 단가 + * 모터 받침용 앵글 단가 (bracket angle) + * + * 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색 + * chandj col2 값: '스크린용', '철제300K', '철제400K', '철제800K' */ - public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + public function getAnglePrice(string $searchOption): float { - $cacheKey = "angle:{$type}:{$bracketSize}:{$angleType}"; + $cacheKey = "angle_bracket:{$searchOption}"; if (isset($this->cache[$cacheKey])) { return $this->cache[$cacheKey]; @@ -226,10 +264,8 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy ->where('items.tenant_id', $this->tenantId) ->where('items.is_active', true) ->whereNull('items.deleted_at') - ->where('item_details.product_category', 'angle') - ->where('item_details.part_type', $type) - ->where('item_details.specification', $bracketSize) - ->where('items.attributes->angle_type', $angleType) + ->where('item_details.product_category', 'angle_bracket') + ->where('item_details.part_type', $searchOption) ->where('prices.effective_from', '<=', $today) ->where(function ($q) use ($today) { $q->whereNull('prices.effective_to') @@ -243,6 +279,25 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy return $price; } + /** + * 부자재용 앵글 단가 (main angle) + * + * 5130: calculateMainAngle(1, itemList, '앵글3T', '2.5') → col4+col10 검색 + */ + public function getMainAnglePrice(string $angleType, string $size): float + { + $cacheKey = "angle_main:{$angleType}:{$size}"; + + if (isset($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]; + } + + $price = $this->getEstimatePartPriceBySpec('angle_main', $angleType, $size); + $this->cache[$cacheKey] = $price; + + return $price; + } + /** * 원자재 단가 */ diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index e00f4e5..32aa24d 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -239,10 +239,13 @@ public function calculateScreenPrice(float $width, float $height): array // 원자재 단가 조회 (실리카/스크린) $unitPrice = $this->getRawMaterialPrice('실리카'); + // 5130 동일: round(area, 2) 후 단가 곱셈 + $roundedArea = round($area, 2); + return [ 'unit_price' => $unitPrice, - 'area' => round($area, 2), - 'total_price' => round($unitPrice * $area), + 'area' => $roundedArea, + 'total_price' => round($unitPrice * $roundedArea), ]; } @@ -291,11 +294,24 @@ public function getPipePrice(string $thickness, int $length): float } /** - * 앵글 단가 조회 + * 모터 받침용 앵글 단가 조회 + * + * @param string $searchOption 검색옵션 (스크린용, 철제300K 등) */ - public function getAnglePrice(string $type, string $bracketSize, string $angleType): float + public function getAnglePrice(string $searchOption): float { - return $this->priceService->getAnglePrice($type, $bracketSize, $angleType); + return $this->priceService->getAnglePrice($searchOption); + } + + /** + * 부자재용 앵글 단가 조회 + * + * @param string $angleType 앵글타입 (앵글3T, 앵글4T) + * @param string $size 길이 (2.5, 10) + */ + public function getMainAnglePrice(string $angleType, string $size): float + { + return $this->priceService->getMainAnglePrice($angleType, $size); } // ========================================================================= @@ -319,7 +335,7 @@ public function calculateSteelItems(array $params): array $width = (float) ($params['W0'] ?? 0); $height = (float) ($params['H0'] ?? 0); $quantity = (int) ($params['QTY'] ?? 1); - $modelName = $params['model_name'] ?? 'KSS01'; + $modelName = $params['model_name'] ?? $params['product_model'] ?? 'KSS01'; $finishingType = $params['finishing_type'] ?? 'SUS'; // 절곡품 관련 파라미터 @@ -365,13 +381,15 @@ public function calculateSteelItems(array $params): array } // 3. 케이스 마구리 (단가 × 수량) - $caseCapPrice = $this->priceService->getCaseCapPrice($caseSpec); + // 마구리 규격 = 케이스 규격 각 치수 + 5mm (레거시 updateCol45 공식) + $caseCapSpec = $this->convertToCaseCapSpec($caseSpec); + $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); if ($caseCapPrice > 0) { $capQty = 2 * $quantity; // 좌우 2개 $items[] = [ 'category' => 'steel', 'item_name' => '케이스 마구리', - 'specification' => $caseSpec, + 'specification' => $caseCapSpec, 'unit' => 'EA', 'quantity' => $capQty, 'unit_price' => $caseCapPrice, @@ -616,19 +634,49 @@ public function calculatePartItems(array $params): array ]; } - // 3. 앵글 - $angleType = $productType === 'steel' ? '철재용' : '스크린용'; - $angleSpec = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; - $anglePrice = $this->getAnglePrice($angleType, $bracketSize, $angleSpec); + // 3. 모터 받침용 앵글 (bracket angle) + // 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색, qty × $su × 4 + $motorCapacity = $params['MOTOR_CAPACITY'] ?? '300K'; + if ($productType === 'screen') { + $angleSearchOption = '스크린용'; + } else { + // 철재: bracketSize로 매핑 (530*320→철제300K, 600*350→철제400K, 690*390→철제800K) + $angleSearchOption = match ($bracketSize) { + '530*320' => '철제300K', + '600*350' => '철제400K', + '690*390' => '철제800K', + default => '철제300K', + }; + } + $anglePrice = $this->getAnglePrice($angleSearchOption); if ($anglePrice > 0) { + $angleQty = 4 * $quantity; // 5130: $su * 4 $items[] = [ 'category' => 'parts', - 'item_name' => "앵글 {$angleSpec}", - 'specification' => "{$angleType} {$bracketSize}", + 'item_name' => '모터 받침용 앵글', + 'specification' => $angleSearchOption, 'unit' => 'EA', - 'quantity' => 2 * $quantity, // 좌우 2개 + 'quantity' => $angleQty, 'unit_price' => $anglePrice, - 'total_price' => $anglePrice * 2 * $quantity, + 'total_price' => $anglePrice * $angleQty, + ]; + } + + // 4. 부자재 앵글 (main angle) + // 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71 + $mainAngleType = $bracketSize === '690*390' ? '앵글4T' : '앵글3T'; + $mainAngleSize = '2.5'; + $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71, default 2 (좌우) + $mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize); + if ($mainAnglePrice > 0 && $mainAngleQty > 0) { + $items[] = [ + 'category' => 'parts', + 'item_name' => "앵글 {$mainAngleType}", + 'specification' => "{$mainAngleSize}m", + 'unit' => 'EA', + 'quantity' => $mainAngleQty * $quantity, + 'unit_price' => $mainAnglePrice, + 'total_price' => $mainAnglePrice * $mainAngleQty * $quantity, ]; } @@ -670,6 +718,21 @@ public function calculateDynamicItems(array $inputs): array $inputs['MOTOR_CAPACITY'] = $motorCapacity; $inputs['BRACKET_SIZE'] = $bracketSize; + // 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원) + $inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000); + if ($inspectionFee > 0) { + $items[] = [ + 'category' => 'inspection', + 'item_code' => 'KD-INSPECTION', + 'item_name' => '검사비', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $quantity, + 'unit_price' => $inspectionFee, + 'total_price' => $inspectionFee * $quantity, + ]; + } + // 1. 주자재 (스크린) $screenResult = $this->calculateScreenPrice($width, $height); $items[] = [ @@ -696,19 +759,40 @@ public function calculateDynamicItems(array $inputs): array 'total_price' => $motorPrice * $quantity, ]; - // 3. 제어기 + // 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17) $controllerType = $inputs['controller_type'] ?? '매립형'; + $controllerQty = (int) ($inputs['controller_qty'] ?? 1); $controllerPrice = $this->getControllerPrice($controllerType); - $items[] = [ - 'category' => 'controller', - 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), - 'item_name' => "제어기 {$controllerType}", - 'specification' => $controllerType, - 'unit' => 'EA', - 'quantity' => $quantity, - 'unit_price' => $controllerPrice, - 'total_price' => $controllerPrice * $quantity, - ]; + if ($controllerPrice > 0 && $controllerQty > 0) { + $items[] = [ + 'category' => 'controller', + 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), + 'item_name' => "제어기 {$controllerType}", + 'specification' => $controllerType, + 'unit' => 'EA', + 'quantity' => $controllerQty * $quantity, + 'unit_price' => $controllerPrice, + 'total_price' => $controllerPrice * $controllerQty * $quantity, + ]; + } + + // 뒷박스 (5130: col17 수량) + $backboxQty = (int) ($inputs['backbox_qty'] ?? 1); + if ($backboxQty > 0) { + $backboxPrice = $this->getControllerPrice('뒷박스'); + if ($backboxPrice > 0) { + $items[] = [ + 'category' => 'controller', + 'item_code' => 'KD-CTRL-BACKBOX', + 'item_name' => '뒷박스', + 'specification' => '', + 'unit' => 'EA', + 'quantity' => $backboxQty * $quantity, + 'unit_price' => $backboxPrice, + 'total_price' => $backboxPrice * $backboxQty * $quantity, + ]; + } + } // 4. 절곡품 $steelItems = $this->calculateSteelItems($inputs); @@ -720,4 +804,24 @@ public function calculateDynamicItems(array $inputs): array return $items; } + + /** + * 케이스 규격 → 마구리 규격 변환 + * + * 레거시 updateCol45/Slat_updateCol46 공식: + * 마구리 규격 = (케이스 가로 + 5) × (케이스 세로 + 5) + * 예: 500*380 → 505*385 + */ + private function convertToCaseCapSpec(string $caseSpec): string + { + if (str_contains($caseSpec, '*')) { + $parts = explode('*', $caseSpec); + $width = (int) trim($parts[0]) + 5; + $height = (int) trim($parts[1]) + 5; + + return "{$width}*{$height}"; + } + + return $caseSpec; + } }