diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index f9d82fc..e9931c6 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -31,6 +31,7 @@ public function index(Request $request) 'group_id' => $request->input('group_id'), 'active' => $request->input('is_active') ?? $request->input('active'), 'has_bom' => $request->input('has_bom'), + 'exclude_process_id' => $request->input('exclude_process_id'), ]; return $this->service->index($params); diff --git a/app/Models/Quote/Quote.php b/app/Models/Quote/Quote.php index 7953a7d..c8bde74 100644 --- a/app/Models/Quote/Quote.php +++ b/app/Models/Quote/Quote.php @@ -331,10 +331,12 @@ public function scopeSearch($query, ?string $keyword) /** * 수정 가능 여부 확인 + * - 모든 상태에서 수정 가능 (finalized, converted 포함) + * - 수주 전환된 견적 수정 시 연결된 수주도 함께 동기화됨 */ public function isEditable(): bool { - return ! in_array($this->status, [self::STATUS_FINALIZED, self::STATUS_CONVERTED]); + return true; } /** diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index a9b54e0..e85fb53 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Orders\Order; +use App\Models\Orders\OrderHistory; use App\Models\Production\WorkOrder; use App\Models\Quote\Quote; use App\Models\Tenants\Sale; @@ -470,19 +471,45 @@ public function createFromQuote(int $quoteId, array $data = []) $order->save(); + // calculation_inputs에서 제품 정보 추출 (floor, code) + // 단일 제품인 경우 모든 BOM 품목에 동일한 floor_code/symbol_code 적용 + $calculationInputs = $quote->calculation_inputs ?? []; + $productItems = $calculationInputs['items'] ?? []; + // 견적 품목을 수주 품목으로 변환 foreach ($quote->items as $index => $quoteItem) { // floor_code/symbol_code 추출: - // 1순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01) - // 2순위: NULL + // 1순위: calculation_inputs.items[].floor, code (제품 정보) + // 2순위: quoteItem->note에서 파싱 (형식: "4F DS-01" → floor=4F, symbol=DS-01) + // 3순위: NULL $floorCode = null; $symbolCode = null; - $note = trim($quoteItem->note ?? ''); - if ($note !== '') { - $parts = preg_split('/\s+/', $note, 2); - $floorCode = $parts[0] ?? null; - $symbolCode = $parts[1] ?? null; + // formula_source에서 제품 인덱스 추출 시도 (예: "product_0" → 0) + $productIndex = 0; + $formulaSource = $quoteItem->formula_source ?? ''; + if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { + $productIndex = (int) $matches[1]; + } + + // calculation_inputs에서 floor/code 가져오기 + if (isset($productItems[$productIndex])) { + $floorCode = $productItems[$productIndex]['floor'] ?? null; + $symbolCode = $productItems[$productIndex]['code'] ?? null; + } elseif (count($productItems) === 1) { + // 단일 제품인 경우 첫 번째 제품 사용 + $floorCode = $productItems[0]['floor'] ?? null; + $symbolCode = $productItems[0]['code'] ?? null; + } + + // calculation_inputs에서 못 찾은 경우 note에서 파싱 시도 + if (empty($floorCode) && empty($symbolCode)) { + $note = trim($quoteItem->note ?? ''); + if ($note !== '') { + $parts = preg_split('/\s+/', $note, 2); + $floorCode = $parts[0] ?? null; + $symbolCode = $parts[1] ?? null; + } } $order->items()->create([ @@ -520,6 +547,149 @@ public function createFromQuote(int $quoteId, array $data = []) }); } + /** + * 견적 변경사항을 수주에 동기화 + * + * 견적이 수정되면 연결된 수주도 함께 업데이트하고 히스토리를 생성합니다. + * + * @param Quote $quote 수정된 견적 + * @param int $revision 견적 수정 차수 + * @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우) + */ + public function syncFromQuote(Quote $quote, int $revision): ?Order + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + // 연결된 수주 확인 + $order = Order::where('tenant_id', $tenantId) + ->where('quote_id', $quote->id) + ->first(); + + if (! $order) { + return null; + } + + // 생산 진행 이상의 상태면 동기화 불가 (DRAFT, CONFIRMED, IN_PROGRESS만 허용) + $allowedStatuses = [ + Order::STATUS_DRAFT, + Order::STATUS_CONFIRMED, + Order::STATUS_IN_PROGRESS, + ]; + if (! in_array($order->status_code, $allowedStatuses)) { + throw new BadRequestHttpException(__('error.order.cannot_sync_after_production')); + } + + return DB::transaction(function () use ($order, $quote, $tenantId, $userId, $revision) { + // 변경 전 데이터 스냅샷 (히스토리용) + $beforeData = [ + 'site_name' => $order->site_name, + 'client_name' => $order->client_name, + 'total_amount' => $order->total_amount, + 'items_count' => $order->items()->count(), + ]; + + // 수주 기본 정보 업데이트 + $order->update([ + 'site_name' => $quote->site_name, + 'client_id' => $quote->client_id, + 'client_name' => $quote->client_name, + 'discount_rate' => $quote->discount_rate ?? 0, + 'discount_amount' => $quote->discount_amount ?? 0, + 'total_amount' => $quote->total_amount, + 'updated_by' => $userId, + ]); + + // 기존 품목 삭제 후 새로 생성 + $order->items()->delete(); + + // calculation_inputs에서 제품 정보 추출 + $calculationInputs = $quote->calculation_inputs ?? []; + $productItems = $calculationInputs['items'] ?? []; + + // 견적 품목을 수주 품목으로 변환 + foreach ($quote->items as $index => $quoteItem) { + $floorCode = null; + $symbolCode = null; + + // formula_source에서 제품 인덱스 추출 + $productIndex = 0; + $formulaSource = $quoteItem->formula_source ?? ''; + if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { + $productIndex = (int) $matches[1]; + } + + // calculation_inputs에서 floor/code 가져오기 + if (isset($productItems[$productIndex])) { + $floorCode = $productItems[$productIndex]['floor'] ?? null; + $symbolCode = $productItems[$productIndex]['code'] ?? null; + } elseif (count($productItems) === 1) { + $floorCode = $productItems[0]['floor'] ?? null; + $symbolCode = $productItems[0]['code'] ?? null; + } + + // note에서 파싱 시도 + if (empty($floorCode) && empty($symbolCode)) { + $note = trim($quoteItem->note ?? ''); + if ($note !== '') { + $parts = preg_split('/\s+/', $note, 2); + $floorCode = $parts[0] ?? null; + $symbolCode = $parts[1] ?? null; + } + } + + $order->items()->create([ + 'tenant_id' => $tenantId, + 'serial_no' => $index + 1, + 'item_id' => $quoteItem->item_id, + 'item_code' => $quoteItem->item_code, + 'item_name' => $quoteItem->item_name, + 'specification' => $quoteItem->specification, + 'floor_code' => $floorCode, + 'symbol_code' => $symbolCode, + 'quantity' => $quoteItem->calculated_quantity, + 'unit' => $quoteItem->unit, + 'unit_price' => $quoteItem->unit_price, + 'supply_amount' => $quoteItem->total_price, + 'tax_amount' => round($quoteItem->total_price * 0.1, 2), + 'total_amount' => round($quoteItem->total_price * 1.1, 2), + 'note' => $quoteItem->formula_category, + 'sort_order' => $index, + ]); + } + + // 합계 재계산 + $order->refresh(); + $order->recalculateTotals()->save(); + + // 변경 후 데이터 스냅샷 + $afterData = [ + 'site_name' => $order->site_name, + 'client_name' => $order->client_name, + 'total_amount' => $order->total_amount, + 'items_count' => $order->items()->count(), + ]; + + // 히스토리 생성 + OrderHistory::create([ + 'tenant_id' => $tenantId, + 'order_id' => $order->id, + 'history_type' => 'quote_updated', + 'content' => json_encode([ + 'message' => "견적 {$revision}차 수정으로 수주 정보가 업데이트되었습니다.", + 'quote_id' => $quote->id, + 'quote_number' => $quote->quote_number, + 'revision' => $revision, + 'before' => $beforeData, + 'after' => $afterData, + ], JSON_UNESCAPED_UNICODE), + 'created_by' => $userId, + ]); + + return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); + }); + } + /** * 생산지시 생성 (공정별 작업지시 다중 생성) */ diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 0e3098f..0c6c8c1 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1770,6 +1770,7 @@ private function calculateKyungdongBom( ]; $calculatedItems[] = [ + 'item_id' => $item['item_id'] ?? null, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'], 'item_category' => $item['category'], diff --git a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php index 2239b6d..48a98de 100644 --- a/app/Services/Quote/Handlers/KyungdongFormulaHandler.php +++ b/app/Services/Quote/Handlers/KyungdongFormulaHandler.php @@ -21,6 +21,105 @@ public function __construct(?EstimatePriceService $priceService = null) $this->priceService = $priceService ?? new EstimatePriceService(self::TENANT_ID); } + // ========================================================================= + // 아이템 매핑 헬퍼 메서드 및 상수 + // ========================================================================= + + /** + * 고정 매핑 아이템 코드 → ID 테이블 + * items master에서 조회한 고정 값들 + */ + private const FIXED_ITEM_MAPPINGS = [ + // 연기차단재 + 'EST-SMOKE-케이스용' => 14912, + 'EST-SMOKE-레일용' => 14911, + // 하장바 + '00035' => 14158, // SUS + '00036' => 14159, // EGI + // 보강평철 + 'BD-보강평철-50' => 14790, + // 무게평철12T (평철12T와 동일) + '00021' => 14147, + // 환봉 (파이별) + '90201' => 14407, // 30파이 + '90202' => 14408, // 35파이 + '90203' => 14409, // 45파이 + '90204' => 14410, // 50파이 + // 조인트바 + '800361' => 14733, + // 검사비 + 'EST-INSPECTION' => 14913, + // 제어기 + 'EST-CTRL-노출형' => 14861, + 'EST-CTRL-매립형' => 14862, + 'EST-CTRL-뒷박스' => 14863, + // 파이프 + 'EST-PIPE-1.4-3000' => 14900, + 'EST-PIPE-1.4-6000' => 14901, + // 모터받침 앵글 + 'EST-ANGLE-BRACKET-스크린용' => 14907, + 'EST-ANGLE-BRACKET-철제300K' => 14908, + 'EST-ANGLE-BRACKET-철제400K' => 14909, + 'EST-ANGLE-BRACKET-철제800K' => 14910, + ]; + + /** + * items master에서 코드로 아이템 ID 조회 (캐싱 적용) + * + * @param string $code 아이템 코드 + * @return int|null 아이템 ID (없으면 null) + */ + private function lookupItemId(string $code): ?int + { + // 1. 고정 매핑 먼저 확인 + if (isset(self::FIXED_ITEM_MAPPINGS[$code])) { + return self::FIXED_ITEM_MAPPINGS[$code]; + } + + // 2. DB에서 동적 조회 (BD-*, EST-* 패턴) + static $cache = []; + if (isset($cache[$code])) { + return $cache[$code]; + } + + $item = \App\Models\Items\Item::where('tenant_id', self::TENANT_ID) + ->where('code', $code) + ->first(['id']); + + $cache[$code] = $item?->id; + + return $cache[$code]; + } + + /** + * 아이템 배열에 item_code/item_id 매핑 추가 + * + * @param array $item 아이템 배열 + * @param string $code 아이템 코드 + * @return array 매핑이 추가된 아이템 배열 + */ + private function withItemMapping(array $item, string $code): array + { + return array_merge($item, [ + 'item_code' => $code, + 'item_id' => $this->lookupItemId($code), + ]); + } + + /** + * 모터 용량에 따른 기본 전압 결정 + * 800K 이상은 380V, 그 외는 220V + * + * @param string $motorCapacity 모터 용량 (예: '300K', '800K') + * @return string 전압 (220V 또는 380V) + */ + private function getMotorVoltage(string $motorCapacity): string + { + $capacity = (int) str_replace(['K', '(S)'], '', $motorCapacity); + + return $capacity >= 800 ? '380V' : '220V'; + } + // ========================================================================= // 모터 용량 계산 // ========================================================================= @@ -366,15 +465,6 @@ public function getMainAnglePrice(string $angleType, string $size): float // 절곡품 계산 (10종) // ========================================================================= - /** - * 절곡품 항목 계산 (10종) - * - * 케이스, 케이스용 연기차단재, 케이스 마구리, 가이드레일, - * 레일용 연기차단재, 하장바, L바, 보강평철, 무게평철12T, 환봉 - * - * @param array $params 입력 파라미터 - * @return array 절곡품 항목 배열 - */ public function calculateSteelItems(array $params): array { $items = []; @@ -421,7 +511,8 @@ public function calculateSteelItems(array $params): array if ($casePrice > 0 && $caseLength > 0) { // 5130: round($shutter_price * $total_length * 1000) * $su → 단건 반올림 후 × QTY $perUnitPrice = round(($casePrice / 1000) * $caseLength); - $items[] = [ + $itemCode = "BD-케이스-{$caseSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '케이스', 'specification' => "{$caseSpec} {$caseLength}mm", @@ -429,7 +520,7 @@ public function calculateSteelItems(array $params): array 'quantity' => $caseLength / 1000 * $quantity, 'unit_price' => $casePrice, 'total_price' => $perUnitPrice * $quantity, - ]; + ], $itemCode); } // 2. 케이스용 연기차단재 - 5130: round(단가 × 길이m) × QTY @@ -437,7 +528,7 @@ public function calculateSteelItems(array $params): array if ($caseSmokePrice > 0 && $caseLength > 0) { $lengthM = $caseLength / 1000; $perUnitSmoke = round($caseSmokePrice * $lengthM); - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '케이스용 연기차단재', 'specification' => "{$lengthM}m", @@ -445,7 +536,7 @@ public function calculateSteelItems(array $params): array 'quantity' => $lengthM * $quantity, 'unit_price' => $caseSmokePrice, 'total_price' => $perUnitSmoke * $quantity, - ]; + ], 'EST-SMOKE-케이스용'); } // 3. 케이스 마구리 - 5130: round(단가 × QTY) @@ -453,7 +544,8 @@ public function calculateSteelItems(array $params): array $caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec); if ($caseCapPrice > 0) { $capQty = $quantity; - $items[] = [ + $itemCode = "BD-마구리-{$caseCapSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '케이스 마구리', 'specification' => $caseCapSpec, @@ -461,7 +553,7 @@ public function calculateSteelItems(array $params): array 'quantity' => $capQty, 'unit_price' => $caseCapPrice, 'total_price' => round($caseCapPrice * $capQty), - ]; + ], $itemCode); } // 4. 가이드레일 (단가 × 길이m × 수량) - 타입별 처리 @@ -474,7 +566,7 @@ public function calculateSteelItems(array $params): array $railSmokeMultiplier = ($productType === 'slat') ? 1 : 2; $railSmokeQty = $railSmokeMultiplier * $quantity; $perUnitRailSmoke = round($railSmokePrice * $guideLength); - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '레일용 연기차단재', 'specification' => ($railSmokeMultiplier > 1) ? "{$guideLength}m × 2" : "{$guideLength}m", @@ -482,13 +574,15 @@ public function calculateSteelItems(array $params): array 'quantity' => $guideLength * $railSmokeQty, 'unit_price' => $railSmokePrice, 'total_price' => $perUnitRailSmoke * $railSmokeQty, - ]; + ], 'EST-SMOKE-레일용'); } // 6. 하장바 (단가 × 길이m × 수량) $bottomBarPrice = $this->priceService->getBottomBarPrice($modelName, $finishingType); if ($bottomBarPrice > 0 && $bottomBarLength > 0) { - $items[] = [ + // 하장바 코드: SUS→00035, EGI→00036 + $bottomBarCode = ($finishingType === 'EGI') ? '00036' : '00035'; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '하장바', 'specification' => "{$modelName} {$finishingType} {$bottomBarLength}m", @@ -496,13 +590,17 @@ public function calculateSteelItems(array $params): array 'quantity' => $bottomBarLength * $quantity, 'unit_price' => $bottomBarPrice, 'total_price' => round($bottomBarPrice * $bottomBarLength * $quantity), - ]; + ], $bottomBarCode); } // 7. L바 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 $lbarPrice = ($productType !== 'slat') ? $this->priceService->getLBarPrice($modelName) : 0; if ($lbarPrice > 0 && $lbarLength > 0) { - $items[] = [ + // L바 코드: BD-L-BAR-{모델}-{규격} (예: BD-L-BAR-KSS01-17*60) + // L바 규격은 모델별로 다르지만 대부분 17*60 또는 17*100 + $lbarSpec = (str_contains($modelName, 'KDSS') || str_contains($modelName, 'KQT')) ? '17*100' : '17*60'; + $itemCode = "BD-L-BAR-{$modelName}-{$lbarSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => 'L바', 'specification' => "{$modelName} {$lbarLength}m", @@ -510,13 +608,13 @@ public function calculateSteelItems(array $params): array 'quantity' => $lbarLength * $quantity, 'unit_price' => $lbarPrice, 'total_price' => round($lbarPrice * $lbarLength * $quantity), - ]; + ], $itemCode); } // 8. 보강평철 (단가 × 길이m × 수량) - 스크린 전용, 슬랫 미사용 $flatBarPrice = ($productType !== 'slat') ? $this->priceService->getFlatBarPrice() : 0; if ($flatBarPrice > 0 && $flatBarLength > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '보강평철', 'specification' => "{$flatBarLength}m", @@ -524,13 +622,13 @@ public function calculateSteelItems(array $params): array 'quantity' => $flatBarLength * $quantity, 'unit_price' => $flatBarPrice, 'total_price' => round($flatBarPrice * $flatBarLength * $quantity), - ]; + ], 'BD-보강평철-50'); } // 9. 무게평철12T (고정 12,000원 × 수량) if ($weightPlateQty > 0) { $weightPlatePrice = 12000; - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '무게평철12T', 'specification' => '12T', @@ -538,21 +636,29 @@ public function calculateSteelItems(array $params): array 'quantity' => $weightPlateQty * $quantity, 'unit_price' => $weightPlatePrice, 'total_price' => $weightPlatePrice * $weightPlateQty * $quantity, - ]; + ], '00021'); } // 10. 환봉 (고정 2,000원 × 수량) - 스크린 전용, 슬랫 미사용 if ($roundBarQty > 0 && $productType !== 'slat') { $roundBarPrice = 2000; - $items[] = [ + // 환봉 코드: 파이 규격에 따라 분기 (기본 30파이) + $roundBarPhi = (int) ($params['round_bar_phi'] ?? 30); + $roundBarCode = match ($roundBarPhi) { + 35 => '90202', + 45 => '90203', + 50 => '90204', + default => '90201', // 30파이 + }; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '환봉', - 'specification' => '', + 'specification' => "{$roundBarPhi}파이", 'unit' => 'EA', 'quantity' => $roundBarQty, 'unit_price' => $roundBarPrice, 'total_price' => $roundBarPrice * $roundBarQty, - ]; + ], $roundBarCode); } return $items; @@ -611,7 +717,8 @@ private function calculateGuideRails( if ($price > 0) { $setPrice = $price * 2; // 5130: 2개 세트 가격 $perSetTotal = round($setPrice * $guideLength); - $items[] = [ + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '가이드레일', 'specification' => "{$modelName} {$finishingType} {$wallSpec} {$guideLength}m × 2", @@ -619,7 +726,7 @@ private function calculateGuideRails( 'quantity' => $guideLength * 2 * $quantity, 'unit_price' => $price, 'total_price' => $perSetTotal * $quantity, - ]; + ], $itemCode); } break; @@ -628,7 +735,8 @@ private function calculateGuideRails( if ($price > 0) { $setPrice = $price * 2; // 5130: 2개 세트 가격 $perSetTotal = round($setPrice * $guideLength); - $items[] = [ + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$sideSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '가이드레일', 'specification' => "{$modelName} {$finishingType} {$sideSpec} {$guideLength}m × 2", @@ -636,7 +744,7 @@ private function calculateGuideRails( 'quantity' => $guideLength * 2 * $quantity, 'unit_price' => $price, 'total_price' => $perSetTotal * $quantity, - ]; + ], $itemCode); } break; @@ -649,7 +757,9 @@ private function calculateGuideRails( if ($setPrice > 0) { $perSetTotal = round($setPrice * $guideLength); $spec = "{$modelName} {$finishingType} {$wallSpec}/{$sideSpec} {$guideLength}m"; - $items[] = [ + // 혼합형은 벽면형 코드 사용 (주 가이드레일) + $itemCode = "BD-가이드레일-{$modelName}-{$finishingType}-{$wallSpec}"; + $items[] = $this->withItemMapping([ 'category' => 'steel', 'item_name' => '가이드레일', 'specification' => $spec, @@ -657,7 +767,7 @@ private function calculateGuideRails( 'quantity' => $guideLength * 2 * $quantity, 'unit_price' => $setPrice, 'total_price' => $perSetTotal * $quantity, - ]; + ], $itemCode); } break; } @@ -685,12 +795,6 @@ private function normalizeGuideType(string $type): string // 부자재 계산 (3종) // ========================================================================= - /** - * 부자재 항목 계산 - * - * @param array $params 입력 파라미터 - * @return array 부자재 항목 배열 - */ public function calculatePartItems(array $params): array { $items = []; @@ -708,7 +812,8 @@ public function calculatePartItems(array $params): array $shaftLength = $this->mapShaftToFixedProduct($shaftSize, $shaftLengthMm); $shaftPrice = $shaftLength > 0 ? $this->getShaftPrice($shaftSize, $shaftLength) : 0; if ($shaftPrice > 0) { - $items[] = [ + $itemCode = "EST-SHAFT-{$shaftSize}-{$shaftLength}"; + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => "감기샤프트 {$shaftSize}인치", 'specification' => "{$shaftLength}m", @@ -716,7 +821,7 @@ public function calculatePartItems(array $params): array 'quantity' => $quantity, 'unit_price' => $shaftPrice, 'total_price' => $shaftPrice * $quantity, - ]; + ], $itemCode); } // 2. 각파이프 (5130: col67 = col37 + 3000 × col66, col68/col69 자동계산) @@ -758,7 +863,7 @@ public function calculatePartItems(array $params): array if ($pipe3000Qty > 0) { $pipe3000Price = $this->getPipePrice($pipeThickness, 3000); if ($pipe3000Price > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => '각파이프', 'specification' => "{$pipeThickness}T 3000mm", @@ -766,13 +871,13 @@ public function calculatePartItems(array $params): array 'quantity' => $pipe3000Qty, 'unit_price' => $pipe3000Price, 'total_price' => $pipe3000Price * $pipe3000Qty, - ]; + ], 'EST-PIPE-1.4-3000'); } } if ($pipe6000Qty > 0) { $pipe6000Price = $this->getPipePrice($pipeThickness, 6000); if ($pipe6000Price > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => '각파이프', 'specification' => "{$pipeThickness}T 6000mm", @@ -780,7 +885,7 @@ public function calculatePartItems(array $params): array 'quantity' => $pipe6000Qty, 'unit_price' => $pipe6000Price, 'total_price' => $pipe6000Price * $pipe6000Qty, - ]; + ], 'EST-PIPE-1.4-6000'); } } @@ -803,7 +908,8 @@ public function calculatePartItems(array $params): array $anglePrice = $bracketAngleEnabled ? $this->getAnglePrice($angleSearchOption) : 0; if ($anglePrice > 0) { $angleQty = 4 * $quantity; // 5130: $su * 4 - $items[] = [ + $itemCode = "EST-ANGLE-BRACKET-{$angleSearchOption}"; + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => '모터 받침용 앵글', 'specification' => $angleSearchOption, @@ -811,7 +917,7 @@ public function calculatePartItems(array $params): array 'quantity' => $angleQty, 'unit_price' => $anglePrice, 'total_price' => $anglePrice * $angleQty, - ]; + ], $itemCode); } // 4. 부자재 앵글 (main angle) @@ -822,7 +928,9 @@ public function calculatePartItems(array $params): array $mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71/col77, default 2 (좌우) $mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize); if ($mainAnglePrice > 0 && $mainAngleQty > 0) { - $items[] = [ + // 앵글 코드: EST-ANGLE-MAIN-{타입}-{길이} (예: EST-ANGLE-MAIN-앵글3T-2.5) + $itemCode = "EST-ANGLE-MAIN-{$mainAngleType}-{$mainAngleSize}"; + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => "앵글 {$mainAngleType}", 'specification' => "{$mainAngleSize}m", @@ -830,7 +938,7 @@ public function calculatePartItems(array $params): array 'quantity' => $mainAngleQty, 'unit_price' => $mainAnglePrice, 'total_price' => $mainAnglePrice * $mainAngleQty, - ]; + ], $itemCode); } // 5. 조인트바 (슬랫 전용, 5130: price × col76, QTY 미적용) @@ -839,7 +947,7 @@ public function calculatePartItems(array $params): array if ($jointBarQty > 0) { $jointBarPrice = $this->getRawMaterialPrice('조인트바'); if ($jointBarPrice > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'parts', 'item_name' => '조인트바', 'specification' => '', @@ -847,7 +955,7 @@ public function calculatePartItems(array $params): array 'quantity' => $jointBarQty, 'unit_price' => $jointBarPrice, 'total_price' => round($jointBarPrice * $jointBarQty), - ]; + ], '800361'); } } } @@ -859,12 +967,6 @@ public function calculatePartItems(array $params): array // 전체 동적 항목 계산 // ========================================================================= - /** - * 동적 항목 전체 계산 - * - * @param array $inputs 입력 파라미터 - * @return array 계산된 항목 배열 - */ public function calculateDynamicItems(array $inputs): array { $items = []; @@ -904,51 +1006,57 @@ public function calculateDynamicItems(array $inputs): array // 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원) $inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000); if ($inspectionFee > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'inspection', - 'item_code' => 'KD-INSPECTION', 'item_name' => '검사비', 'specification' => '', 'unit' => 'EA', 'quantity' => $quantity, 'unit_price' => $inspectionFee, 'total_price' => $inspectionFee * $quantity, - ]; + ], 'EST-INSPECTION'); } // 1. 주자재 (스크린 또는 슬랫) if ($productType === 'slat') { $materialResult = $this->calculateSlatPrice($width, $height); $materialName = '주자재(슬랫)'; - $materialCode = 'KD-SLAT'; + // 슬랫 타입에 따른 코드 (기본: 방화) + $slatType = $inputs['slat_type'] ?? '방화'; + $materialCode = "EST-RAW-슬랫-{$slatType}"; } else { $materialResult = $this->calculateScreenPrice($width, $height); $materialName = '주자재(스크린)'; - $materialCode = 'KD-SCREEN'; + // 스크린 타입에 따른 코드 (기본: 실리카) + $screenType = $inputs['screen_type'] ?? '실리카'; + $materialCode = "EST-RAW-스크린-{$screenType}"; } - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'material', - 'item_code' => $materialCode, 'item_name' => $materialName, 'specification' => "면적 {$materialResult['area']}㎡", 'unit' => '㎡', 'quantity' => $materialResult['area'] * $quantity, 'unit_price' => $materialResult['unit_price'], 'total_price' => $materialResult['total_price'] * $quantity, - ]; + ], $materialCode); // 2. 모터 $motorPrice = $this->getMotorPrice($motorCapacity); - $items[] = [ + // 모터 전압 (기본: 220V, 대용량은 380V) + $motorVoltage = $inputs['motor_voltage'] ?? $this->getMotorVoltage($motorCapacity); + // 모터 코드: 150K는 150K(S)만 존재 + $motorCapacityCode = ($motorCapacity === '150K') ? '150K(S)' : $motorCapacity; + $motorCode = "EST-MOTOR-{$motorVoltage}-{$motorCapacityCode}"; + $items[] = $this->withItemMapping([ 'category' => 'motor', - 'item_code' => "KD-MOTOR-{$motorCapacity}", 'item_name' => "모터 {$motorCapacity}", 'specification' => $motorCapacity, 'unit' => 'EA', 'quantity' => $quantity, 'unit_price' => $motorPrice, 'total_price' => $motorPrice * $quantity, - ]; + ], $motorCode); // 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17) // 5130: 제어기 = price_매립 × col15 + price_노출 × col16 + price_뒷박스 × col17 @@ -957,16 +1065,16 @@ public function calculateDynamicItems(array $inputs): array $controllerQty = (int) ($inputs['controller_qty'] ?? 1); $controllerPrice = $this->getControllerPrice($controllerType); if ($controllerPrice > 0 && $controllerQty > 0) { - $items[] = [ + $ctrlCode = "EST-CTRL-{$controllerType}"; + $items[] = $this->withItemMapping([ 'category' => 'controller', - 'item_code' => 'KD-CTRL-'.strtoupper($controllerType), 'item_name' => "제어기 {$controllerType}", 'specification' => $controllerType, 'unit' => 'EA', 'quantity' => $controllerQty, 'unit_price' => $controllerPrice, 'total_price' => $controllerPrice * $controllerQty, - ]; + ], $ctrlCode); } // 뒷박스 (5130: col17 수량, QTY와 무관) @@ -974,16 +1082,15 @@ public function calculateDynamicItems(array $inputs): array if ($backboxQty > 0) { $backboxPrice = $this->getControllerPrice('뒷박스'); if ($backboxPrice > 0) { - $items[] = [ + $items[] = $this->withItemMapping([ 'category' => 'controller', - 'item_code' => 'KD-CTRL-BACKBOX', 'item_name' => '뒷박스', 'specification' => '', 'unit' => 'EA', 'quantity' => $backboxQty, 'unit_price' => $backboxPrice, 'total_price' => $backboxPrice * $backboxQty, - ]; + ], 'EST-CTRL-뒷박스'); } } diff --git a/app/Services/Quote/QuoteService.php b/app/Services/Quote/QuoteService.php index 66d6cf1..f0d076a 100644 --- a/app/Services/Quote/QuoteService.php +++ b/app/Services/Quote/QuoteService.php @@ -10,6 +10,7 @@ use App\Models\Quote\QuoteItem; use App\Models\Quote\QuoteRevision; use App\Models\Tenants\SiteBriefing; +use App\Services\OrderService; use App\Services\Service; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -21,7 +22,8 @@ class QuoteService extends Service { public function __construct( private QuoteNumberService $numberService, - private QuoteCalculationService $calculationService + private QuoteCalculationService $calculationService, + private OrderService $orderService ) {} /** @@ -439,7 +441,24 @@ public function update(int $id, array $data): Quote $this->createItems($quote, $data['items'], $tenantId); } - return $quote->refresh()->load(['items', 'revisions', 'client']); + $quote->refresh()->load(['items', 'revisions', 'client']); + + // 연결된 수주가 있으면 동기화 + if ($quote->order_id) { + try { + $this->orderService->setContext($tenantId, $userId); + $this->orderService->syncFromQuote($quote, $quote->current_revision); + } catch (\Exception $e) { + // 수주 동기화 실패는 로그만 남기고 견적 수정은 성공 처리 + Log::warning('Failed to sync order from quote', [ + 'quote_id' => $quote->id, + 'order_id' => $quote->order_id, + 'error' => $e->getMessage(), + ]); + } + } + + return $quote; }); } diff --git a/lang/ko/error.php b/lang/ko/error.php index 52bd9f7..43740a1 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -405,6 +405,7 @@ 'production_order_already_exists' => '이미 생산지시가 존재합니다.', 'cannot_revert_completed' => '완료된 수주의 생산지시는 되돌릴 수 없습니다.', 'cannot_revert_not_confirmed' => '수주확정 상태에서만 되돌리기가 가능합니다.', + 'cannot_sync_after_production' => '생산지시 이후의 수주는 견적에서 자동 동기화할 수 없습니다.', ], // 견적 관련