feat: 견적 단가를 items+item_details+prices 통합 구조로 전환
- EstimatePriceService 생성: items+item_details+prices JOIN 기반 단가 조회 - item_details.product_category/part_type/specification 컬럼 매핑 - items.attributes JSON으로 model_name/finishing_type 추가 차원 처리 - 세션 내 캐시로 중복 조회 방지 - MigrateBDModelsPrices 커맨드: 레거시 BDmodels + kd_price_tables → 85건 마이그레이션 - KyungdongFormulaHandler: KdPriceTable 의존 제거 → EstimatePriceService 사용 - FormulaEvaluatorService: W1 마진 140→160, 면적 공식 W1×(H1+550) 수정 - 가이드레일 H0+250, 케이스/L바/평철 W0+220 (레거시 일치) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
334
app/Services/Quote/EstimatePriceService.php
Normal file
334
app/Services/Quote/EstimatePriceService.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Products\Price;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 견적 단가 조회 서비스
|
||||
*
|
||||
* items + item_details + prices 테이블 기반 단가 조회
|
||||
* kd_price_tables 대체
|
||||
*/
|
||||
class EstimatePriceService
|
||||
{
|
||||
private int $tenantId;
|
||||
|
||||
/** @var array 단가 캐시 (세션 내 중복 조회 방지) */
|
||||
private array $cache = [];
|
||||
|
||||
public function __construct(int $tenantId)
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// BDmodels 단가 조회 (절곡품)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* BDmodels 단가 조회
|
||||
*
|
||||
* item_details 컬럼 매핑:
|
||||
* product_category = 'bdmodels'
|
||||
* part_type = seconditem (가이드레일, 케이스, 마구리, L-BAR, 하단마감재, 보강평철, 연기차단재)
|
||||
* specification = spec (120*70, 500*380 등)
|
||||
* items.attributes:
|
||||
* model_name = KSS01, KSS02 등
|
||||
* finishing_type = SUS, EGI
|
||||
*/
|
||||
public function getBDModelPrice(
|
||||
string $secondItem,
|
||||
?string $modelName = null,
|
||||
?string $finishingType = null,
|
||||
?string $spec = null
|
||||
): float {
|
||||
$cacheKey = "bdmodel:{$secondItem}:{$modelName}:{$finishingType}:{$spec}";
|
||||
|
||||
if (isset($this->cache[$cacheKey])) {
|
||||
return $this->cache[$cacheKey];
|
||||
}
|
||||
|
||||
$query = 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', 'bdmodels')
|
||||
->where('item_details.part_type', $secondItem);
|
||||
|
||||
if ($modelName) {
|
||||
$query->where('items.attributes->model_name', $modelName);
|
||||
} else {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('items.attributes->model_name')
|
||||
->orWhere('items.attributes->model_name', '');
|
||||
});
|
||||
}
|
||||
|
||||
if ($finishingType) {
|
||||
$query->where('items.attributes->finishing_type', $finishingType);
|
||||
}
|
||||
|
||||
if ($spec) {
|
||||
$query->where('item_details.specification', $spec);
|
||||
}
|
||||
|
||||
// 현재 유효한 단가
|
||||
$today = now()->toDateString();
|
||||
$query->where('prices.effective_from', '<=', $today)
|
||||
->where(function ($q) use ($today) {
|
||||
$q->whereNull('prices.effective_to')
|
||||
->orWhere('prices.effective_to', '>=', $today);
|
||||
})
|
||||
->whereNull('prices.deleted_at');
|
||||
|
||||
$price = (float) ($query->value('prices.sales_price') ?? 0);
|
||||
|
||||
$this->cache[$cacheKey] = $price;
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
/**
|
||||
* 케이스 단가
|
||||
*/
|
||||
public function getCasePrice(string $spec): float
|
||||
{
|
||||
return $this->getBDModelPrice('케이스', null, null, $spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가이드레일 단가
|
||||
*/
|
||||
public function getGuideRailPrice(string $modelName, string $finishingType, string $spec): float
|
||||
{
|
||||
return $this->getBDModelPrice('가이드레일', $modelName, $finishingType, $spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단마감재(하장바) 단가
|
||||
*/
|
||||
public function getBottomBarPrice(string $modelName, string $finishingType): float
|
||||
{
|
||||
return $this->getBDModelPrice('하단마감재', $modelName, $finishingType);
|
||||
}
|
||||
|
||||
/**
|
||||
* L-BAR 단가
|
||||
*/
|
||||
public function getLBarPrice(string $modelName): float
|
||||
{
|
||||
return $this->getBDModelPrice('L-BAR', $modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보강평철 단가
|
||||
*/
|
||||
public function getFlatBarPrice(): float
|
||||
{
|
||||
return $this->getBDModelPrice('보강평철');
|
||||
}
|
||||
|
||||
/**
|
||||
* 케이스 마구리 단가
|
||||
*/
|
||||
public function getCaseCapPrice(string $spec): float
|
||||
{
|
||||
return $this->getBDModelPrice('마구리', null, null, $spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* 케이스용 연기차단재 단가
|
||||
*/
|
||||
public function getCaseSmokeBlockPrice(): float
|
||||
{
|
||||
return $this->getBDModelPrice('케이스용 연기차단재');
|
||||
}
|
||||
|
||||
/**
|
||||
* 가이드레일용 연기차단재 단가
|
||||
*/
|
||||
public function getRailSmokeBlockPrice(): float
|
||||
{
|
||||
return $this->getBDModelPrice('가이드레일용 연기차단재');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 모터/제어기 단가
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 모터 단가
|
||||
*/
|
||||
public function getMotorPrice(string $motorCapacity): float
|
||||
{
|
||||
return $this->getEstimatePartPrice('motor', $motorCapacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어기 단가
|
||||
*/
|
||||
public function getControllerPrice(string $controllerType): float
|
||||
{
|
||||
return $this->getEstimatePartPrice('motor', $controllerType);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 부자재 단가
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 샤프트 단가
|
||||
*/
|
||||
public function getShaftPrice(string $size, float $length): float
|
||||
{
|
||||
$lengthStr = number_format($length, 1, '.', '');
|
||||
$cacheKey = "shaft:{$size}:{$lengthStr}";
|
||||
|
||||
if (isset($this->cache[$cacheKey])) {
|
||||
return $this->cache[$cacheKey];
|
||||
}
|
||||
|
||||
$price = $this->getEstimatePartPriceBySpec('shaft', $size, $lengthStr);
|
||||
$this->cache[$cacheKey] = $price;
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파이프 단가
|
||||
*/
|
||||
public function getPipePrice(string $thickness, int $length): float
|
||||
{
|
||||
return $this->getEstimatePartPriceBySpec('pipe', $thickness, (string) $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앵글 단가
|
||||
*/
|
||||
public function getAnglePrice(string $type, string $bracketSize, string $angleType): float
|
||||
{
|
||||
$cacheKey = "angle:{$type}:{$bracketSize}:{$angleType}";
|
||||
|
||||
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', 'angle')
|
||||
->where('item_details.part_type', $type)
|
||||
->where('item_details.specification', $bracketSize)
|
||||
->where('items.attributes->angle_type', $angleType)
|
||||
->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원자재 단가
|
||||
*/
|
||||
public function getRawMaterialPrice(string $materialName): float
|
||||
{
|
||||
return $this->getEstimatePartPrice('raw_material', $materialName);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 내부 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* product_category + part_type 기반 단가 조회
|
||||
*/
|
||||
private function getEstimatePartPrice(string $productCategory, string $partType): float
|
||||
{
|
||||
$cacheKey = "{$productCategory}:{$partType}";
|
||||
|
||||
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', $productCategory)
|
||||
->where('item_details.part_type', $partType)
|
||||
->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* product_category + spec1 + spec2 기반 단가 조회
|
||||
*/
|
||||
private function getEstimatePartPriceBySpec(string $productCategory, string $spec1, string $spec2): float
|
||||
{
|
||||
$cacheKey = "{$productCategory}:{$spec1}:{$spec2}";
|
||||
|
||||
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', $productCategory)
|
||||
->where('item_details.part_type', $spec1)
|
||||
->where('item_details.specification', $spec2)
|
||||
->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화
|
||||
*/
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->cache = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user