feat: 견적 산출 서비스 prices 테이블 연동

- Price 모델에 getCurrentPrice(), getSalesPriceByItemCode() 메서드 추가
- Price 모델에 STATUS_*, ITEM_TYPE_* 상수 추가
- QuoteCalculationService에 setTenantId(), getUnitPrice() 메서드 추가
- 스크린 품목 단가: 원단, 케이스, 브라켓, 인건비 prices 조회로 변경
- 철재 품목 단가: 철판, 용접, 표면처리, 가공비 prices 조회로 변경
- 모터 용량별 단가: 50W~300W prices 조회로 변경
- 모든 단가는 prices 조회 실패 시 기존 하드코딩 값을 fallback으로 사용
This commit is contained in:
2025-12-19 16:20:38 +09:00
parent 8f1292f7c4
commit 4d3085e705
2 changed files with 171 additions and 20 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
/**