diff --git a/app/Models/Products/Price.php b/app/Models/Products/Price.php index b1fd4a8..9cfdbaf 100644 --- a/app/Models/Products/Price.php +++ b/app/Models/Products/Price.php @@ -16,6 +16,24 @@ class Price extends Model protected $table = 'prices'; + // ───────────────────────────────────────────────────────────── + // Constants + // ───────────────────────────────────────────────────────────── + + // 상태 + public const STATUS_DRAFT = 'draft'; + + public const STATUS_ACTIVE = 'active'; + + public const STATUS_INACTIVE = 'inactive'; + + public const STATUS_FINALIZED = 'finalized'; + + // 품목 유형 + public const ITEM_TYPE_PRODUCT = 'PRODUCT'; + + public const ITEM_TYPE_MATERIAL = 'MATERIAL'; + protected $fillable = [ 'tenant_id', 'item_type_code', @@ -218,4 +236,93 @@ public function toSnapshot(): array 'is_final' => $this->is_final, ]; } + + // ───────────────────────────────────────────────────────────── + // Static Query Methods (견적 산출용) + // ───────────────────────────────────────────────────────────── + + /** + * 특정 품목의 현재 유효 단가 조회 + * + * @param int $tenantId 테넌트 ID + * @param string $itemTypeCode 품목 유형 (PRODUCT/MATERIAL) + * @param int $itemId 품목 ID + * @param int|null $clientGroupId 고객 그룹 ID (NULL = 기본가) + */ + public static function getCurrentPrice( + int $tenantId, + string $itemTypeCode, + int $itemId, + ?int $clientGroupId = null + ): ?self { + $today = now()->toDateString(); + + $query = static::query() + ->where('tenant_id', $tenantId) + ->where('item_type_code', $itemTypeCode) + ->where('item_id', $itemId) + ->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_FINALIZED]) + ->where('effective_from', '<=', $today) + ->where(function ($q) use ($today) { + $q->whereNull('effective_to') + ->orWhere('effective_to', '>=', $today); + }); + + // 고객그룹 지정된 가격 우선, 없으면 기본가 + if ($clientGroupId) { + $groupPrice = (clone $query) + ->where('client_group_id', $clientGroupId) + ->orderByDesc('effective_from') + ->first(); + + if ($groupPrice) { + return $groupPrice; + } + } + + // 기본가 (client_group_id = NULL) + return $query + ->whereNull('client_group_id') + ->orderByDesc('effective_from') + ->first(); + } + + /** + * 품목 코드로 현재 유효 판매단가 조회 + * (quote_formula_items.item_code와 연동용) + * + * @param int $tenantId 테넌트 ID + * @param string $itemCode 품목 코드 + * @return float 판매단가 (없으면 0) + */ + public static function getSalesPriceByItemCode(int $tenantId, string $itemCode): float + { + // products 테이블에서 품목 코드로 검색 + $product = \Illuminate\Support\Facades\DB::table('products') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if ($product) { + $price = static::getCurrentPrice($tenantId, self::ITEM_TYPE_PRODUCT, $product->id); + + return (float) ($price?->sales_price ?? 0); + } + + // materials 테이블에서도 검색 + $material = \Illuminate\Support\Facades\DB::table('materials') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if ($material) { + $price = static::getCurrentPrice($tenantId, self::ITEM_TYPE_MATERIAL, $material->id); + + return (float) ($price?->sales_price ?? 0); + } + + return 0; + } } diff --git a/app/Services/Quote/QuoteCalculationService.php b/app/Services/Quote/QuoteCalculationService.php index 3e44935..e8e7e17 100644 --- a/app/Services/Quote/QuoteCalculationService.php +++ b/app/Services/Quote/QuoteCalculationService.php @@ -2,6 +2,7 @@ namespace App\Services\Quote; +use App\Models\Products\Price; use App\Models\Quote\Quote; use App\Services\Service; @@ -13,10 +14,39 @@ */ class QuoteCalculationService extends Service { + private ?int $tenantId = null; + public function __construct( private FormulaEvaluatorService $formulaEvaluator ) {} + /** + * 테넌트 ID 설정 + */ + public function setTenantId(int $tenantId): self + { + $this->tenantId = $tenantId; + + return $this; + } + + /** + * 품목 코드로 단가 조회 (prices 테이블 연동) + * + * @param string $itemCode 품목 코드 + * @param float $fallback 조회 실패 시 기본값 + */ + private function getUnitPrice(string $itemCode, float $fallback = 0): float + { + if (! $this->tenantId) { + return $fallback; + } + + $price = Price::getSalesPriceByItemCode($this->tenantId, $itemCode); + + return $price > 0 ? $price : $fallback; + } + /** * 견적 자동산출 실행 * @@ -209,6 +239,7 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a $items = []; // 1. 스크린 원단 + $fabricPrice = $this->getUnitPrice('SCR-FABRIC-001', 25000); $items[] = [ 'item_code' => 'SCR-FABRIC-001', 'item_name' => '스크린 원단', @@ -216,13 +247,14 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a 'unit' => 'm²', 'base_quantity' => 1, 'calculated_quantity' => $outputs['AREA'] * $qty, - 'unit_price' => 25000, - 'total_price' => $outputs['AREA'] * $qty * 25000, + 'unit_price' => $fabricPrice, + 'total_price' => $outputs['AREA'] * $qty * $fabricPrice, 'formula' => 'AREA * QTY', 'formula_category' => 'material', ]; // 2. 케이스 + $casePrice = $this->getUnitPrice('SCR-CASE-001', 85000); $items[] = [ 'item_code' => 'SCR-CASE-001', 'item_name' => '알루미늄 케이스', @@ -230,8 +262,8 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a 'unit' => 'EA', 'base_quantity' => 1, 'calculated_quantity' => $qty, - 'unit_price' => 85000, - 'total_price' => $qty * 85000, + 'unit_price' => $casePrice, + 'total_price' => $qty * $casePrice, 'formula' => 'QTY', 'formula_category' => 'material', ]; @@ -252,6 +284,7 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a ]; // 4. 브라켓 + $bracketPrice = $this->getUnitPrice('SCR-BRACKET-001', 15000); $items[] = [ 'item_code' => 'SCR-BRACKET-001', 'item_name' => '설치 브라켓', @@ -259,8 +292,8 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a 'unit' => 'SET', 'base_quantity' => 2, 'calculated_quantity' => 2 * $qty, - 'unit_price' => 15000, - 'total_price' => 2 * $qty * 15000, + 'unit_price' => $bracketPrice, + 'total_price' => 2 * $qty * $bracketPrice, 'formula' => '2 * QTY', 'formula_category' => 'material', ]; @@ -272,6 +305,7 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a ['min' => 10, 'max' => null, 'result' => 4], ], 2); + $laborPrice = $this->getUnitPrice('LAB-INSTALL-001', 50000); $items[] = [ 'item_code' => 'LAB-INSTALL-001', 'item_name' => '설치 인건비', @@ -279,8 +313,8 @@ private function generateScreenItems(array $inputs, array $outputs, int $qty): a 'unit' => 'HR', 'base_quantity' => $laborHours, 'calculated_quantity' => $laborHours * $qty, - 'unit_price' => 50000, - 'total_price' => $laborHours * $qty * 50000, + 'unit_price' => $laborPrice, + 'total_price' => $laborHours * $qty * $laborPrice, 'formula' => 'LABOR_HOURS * QTY', 'formula_category' => 'labor', ]; @@ -295,16 +329,18 @@ private function generateSteelItems(array $inputs, array $outputs, int $qty): ar { $items = []; - // 재질별 단가 - $materialPrice = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [ + // 재질별 품목코드 및 단가 조회 + $materialCode = 'STL-PLATE-'.strtoupper($inputs['MATERIAL']); + $fallbackMaterialPrice = $this->formulaEvaluator->evaluateMapping($inputs['MATERIAL'], [ ['source' => 'ss304', 'result' => 4500], ['source' => 'ss316', 'result' => 6500], ['source' => 'galvanized', 'result' => 3000], ], 4500); + $materialPrice = $this->getUnitPrice($materialCode, $fallbackMaterialPrice); // 1. 철판 $items[] = [ - 'item_code' => 'STL-PLATE-001', + 'item_code' => $materialCode, 'item_name' => '철판 ('.$inputs['MATERIAL'].')', 'specification' => sprintf('%.0f x %.0f x %.1f mm', $outputs['W1'], $outputs['H1'], $inputs['THICKNESS']), 'unit' => 'kg', @@ -318,6 +354,7 @@ private function generateSteelItems(array $inputs, array $outputs, int $qty): ar // 2. 용접 $weldLength = ($outputs['W1'] + $outputs['H1']) * 2 / 1000; // m + $weldPrice = $this->getUnitPrice('STL-WELD-001', 15000); $items[] = [ 'item_code' => 'STL-WELD-001', 'item_name' => '용접 ('.$inputs['WELDING'].')', @@ -325,21 +362,23 @@ private function generateSteelItems(array $inputs, array $outputs, int $qty): ar 'unit' => 'm', 'base_quantity' => $weldLength, 'calculated_quantity' => $weldLength * $qty, - 'unit_price' => 15000, - 'total_price' => $weldLength * $qty * 15000, + 'unit_price' => $weldPrice, + 'total_price' => $weldLength * $qty * $weldPrice, 'formula' => 'WELD_LENGTH * QTY', 'formula_category' => 'labor', ]; // 3. 표면처리 - $finishPrice = $this->formulaEvaluator->evaluateMapping($inputs['FINISH'], [ + $finishCode = 'STL-FINISH-'.strtoupper($inputs['FINISH']); + $fallbackFinishPrice = $this->formulaEvaluator->evaluateMapping($inputs['FINISH'], [ ['source' => 'hairline', 'result' => 8000], ['source' => 'mirror', 'result' => 15000], ['source' => 'matte', 'result' => 5000], ], 8000); + $finishPrice = $this->getUnitPrice($finishCode, $fallbackFinishPrice); $items[] = [ - 'item_code' => 'STL-FINISH-001', + 'item_code' => $finishCode, 'item_name' => '표면처리 ('.$inputs['FINISH'].')', 'specification' => sprintf('%.2f m²', $outputs['AREA'] * $qty), 'unit' => 'm²', @@ -352,6 +391,7 @@ private function generateSteelItems(array $inputs, array $outputs, int $qty): ar ]; // 4. 가공비 + $processPrice = $this->getUnitPrice('STL-PROCESS-001', 50000); $items[] = [ 'item_code' => 'STL-PROCESS-001', 'item_name' => '가공비', @@ -359,8 +399,8 @@ private function generateSteelItems(array $inputs, array $outputs, int $qty): ar 'unit' => 'EA', 'base_quantity' => 1, 'calculated_quantity' => $qty, - 'unit_price' => 50000, - 'total_price' => $qty * 50000, + 'unit_price' => $processPrice, + 'total_price' => $qty * $processPrice, 'formula' => 'QTY', 'formula_category' => 'labor', ]; @@ -400,17 +440,21 @@ private function calculateCosts(array $items): array } /** - * 모터 단가 조회 + * 모터 단가 조회 (prices 테이블 연동) */ - private function getMotorPrice(string $capacity): int + private function getMotorPrice(string $capacity): float { - return match ($capacity) { + // 용량별 품목코드 및 기본 단가 + $motorCode = 'SCR-MOTOR-'.$capacity; + $fallbackPrice = match ($capacity) { '50W' => 120000, '100W' => 150000, '200W' => 200000, '300W' => 280000, default => 150000, }; + + return $this->getUnitPrice($motorCode, $fallbackPrice); } /**