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:
363
app/Console/Commands/MigrateBDModelsPrices.php
Normal file
363
app/Console/Commands/MigrateBDModelsPrices.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* BDmodels + kd_price_tables → items + item_details + prices 마이그레이션
|
||||
*
|
||||
* 레거시 chandj.BDmodels 데이터와 kd_price_tables 데이터를
|
||||
* items + item_details + prices 통합 구조로 마이그레이션
|
||||
*/
|
||||
class MigrateBDModelsPrices extends Command
|
||||
{
|
||||
protected $signature = 'kd:migrate-prices {--dry-run : 실제 DB 변경 없이 미리보기}';
|
||||
|
||||
protected $description = '경동 견적 단가를 items+item_details+prices로 마이그레이션';
|
||||
|
||||
private const TENANT_ID = 287;
|
||||
|
||||
private int $created = 0;
|
||||
|
||||
private int $skipped = 0;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== 경동 견적 단가 마이그레이션 ===');
|
||||
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
|
||||
$this->newLine();
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
// 1. 레거시 BDmodels (chandj DB)
|
||||
$this->migrateBDModels($dryRun);
|
||||
|
||||
// 2. kd_price_tables (motor, shaft, pipe, angle, raw_material)
|
||||
$this->migrateKdPriceTables($dryRun);
|
||||
|
||||
if ($dryRun) {
|
||||
DB::rollBack();
|
||||
$this->warn('[DRY RUN] 롤백 완료');
|
||||
} else {
|
||||
DB::commit();
|
||||
$this->info('커밋 완료');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("생성: {$this->created}건, 스킵: {$this->skipped}건");
|
||||
|
||||
return Command::SUCCESS;
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
$this->error("오류: {$e->getMessage()}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 chandj.BDmodels → items + item_details + prices
|
||||
*/
|
||||
private function migrateBDModels(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- BDmodels (레거시) ---');
|
||||
|
||||
// chandj DB에서 BDmodels 조회 (chandj connection 사용)
|
||||
$rows = DB::connection('chandj')->select("
|
||||
SELECT model_name, seconditem, finishing_type, spec, unitprice, description
|
||||
FROM BDmodels
|
||||
WHERE is_deleted = 0
|
||||
ORDER BY model_name, seconditem, finishing_type, spec
|
||||
");
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$modelName = trim($row->model_name ?? '');
|
||||
$secondItem = trim($row->seconditem ?? '');
|
||||
$finishingType = trim($row->finishing_type ?? '');
|
||||
$spec = trim($row->spec ?? '');
|
||||
$unitPrice = (float) str_replace(',', '', $row->unitprice ?? '0');
|
||||
|
||||
// finishing_type 정규화: 'SUS마감' → 'SUS', 'EGI마감' → 'EGI'
|
||||
$finishingType = str_replace('마감', '', $finishingType);
|
||||
|
||||
if (empty($secondItem) || $unitPrice <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 코드 생성
|
||||
$codeParts = ['BD', $secondItem];
|
||||
if ($modelName) {
|
||||
$codeParts[] = $modelName;
|
||||
}
|
||||
if ($finishingType) {
|
||||
$codeParts[] = $finishingType;
|
||||
}
|
||||
if ($spec) {
|
||||
$codeParts[] = $spec;
|
||||
}
|
||||
$code = implode('-', $codeParts);
|
||||
|
||||
// 이름 생성
|
||||
$nameParts = [$secondItem];
|
||||
if ($modelName) {
|
||||
$nameParts[] = $modelName;
|
||||
}
|
||||
if ($finishingType) {
|
||||
$nameParts[] = $finishingType;
|
||||
}
|
||||
if ($spec) {
|
||||
$nameParts[] = $spec;
|
||||
}
|
||||
$name = implode(' ', $nameParts);
|
||||
|
||||
$this->createEstimateItem(
|
||||
code: $code,
|
||||
name: $name,
|
||||
productCategory: 'bdmodels',
|
||||
partType: $secondItem,
|
||||
specification: $spec ?: null,
|
||||
attributes: array_filter([
|
||||
'model_name' => $modelName ?: null,
|
||||
'finishing_type' => $finishingType ?: null,
|
||||
'bdmodel_source' => 'BDmodels',
|
||||
'description' => $row->description ?: null,
|
||||
]),
|
||||
salesPrice: $unitPrice,
|
||||
note: 'BDmodels 마이그레이션',
|
||||
dryRun: $dryRun
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* kd_price_tables → items + item_details + prices
|
||||
*/
|
||||
private function migrateKdPriceTables(bool $dryRun): void
|
||||
{
|
||||
$this->info('--- kd_price_tables ---');
|
||||
|
||||
$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();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$tableType = $row->table_type;
|
||||
$unitPrice = (float) $row->unit_price;
|
||||
|
||||
if ($unitPrice <= 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateMotor(object $row, bool $dryRun): void
|
||||
{
|
||||
$category = $row->category; // 150K, 300K, 매립형, 노출형 등
|
||||
$code = "EST-MOTOR-{$category}";
|
||||
$name = "모터/제어기 {$category}";
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
private function migrateShaft(object $row, bool $dryRun): void
|
||||
{
|
||||
$size = $row->spec1; // 인치
|
||||
$length = $row->spec2; // 길이
|
||||
$code = "EST-SHAFT-{$size}-{$length}";
|
||||
$name = "감기샤프트 {$size}인치 {$length}m";
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
private function migratePipe(object $row, bool $dryRun): void
|
||||
{
|
||||
$thickness = $row->spec1;
|
||||
$length = $row->spec2;
|
||||
$code = "EST-PIPE-{$thickness}-{$length}";
|
||||
$name = "각파이프 {$thickness}T {$length}mm";
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
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}";
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 품목 생성 (items + item_details + prices)
|
||||
*/
|
||||
private function createEstimateItem(
|
||||
string $code,
|
||||
string $name,
|
||||
string $productCategory,
|
||||
string $partType,
|
||||
?string $specification,
|
||||
array $attributes,
|
||||
float $salesPrice,
|
||||
string $note,
|
||||
bool $dryRun
|
||||
): void {
|
||||
// 중복 체크 (code 기준)
|
||||
$existing = DB::table('items')
|
||||
->where('tenant_id', self::TENANT_ID)
|
||||
->where('code', $code)
|
||||
->whereNull('deleted_at')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$this->line(" [스킵] {$code} - 이미 존재");
|
||||
$this->skipped++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->line(" [생성] {$code} ({$name}) = {$salesPrice}");
|
||||
|
||||
if ($dryRun) {
|
||||
$this->created++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
// 1. items
|
||||
$itemId = DB::table('items')->insertGetId([
|
||||
'tenant_id' => self::TENANT_ID,
|
||||
'item_type' => 'PT',
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'unit' => 'EA',
|
||||
'attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE),
|
||||
'is_active' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 2. item_details
|
||||
DB::table('item_details')->insert([
|
||||
'item_id' => $itemId,
|
||||
'product_category' => $productCategory,
|
||||
'part_type' => $partType,
|
||||
'specification' => $specification,
|
||||
'item_name' => $name,
|
||||
'is_purchasable' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 3. prices
|
||||
DB::table('prices')->insert([
|
||||
'tenant_id' => self::TENANT_ID,
|
||||
'item_type_code' => 'PT',
|
||||
'item_id' => $itemId,
|
||||
'sales_price' => $salesPrice,
|
||||
'effective_from' => $now->toDateString(),
|
||||
'status' => 'active',
|
||||
'note' => $note,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->created++;
|
||||
}
|
||||
}
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
@@ -658,7 +658,7 @@ public function calculateBomWithDebug(
|
||||
$marginW = 110; // 철재 마진
|
||||
$marginH = 350;
|
||||
} else {
|
||||
$marginW = 140; // 스크린 기본 마진
|
||||
$marginW = 160; // 스크린 기본 마진
|
||||
$marginH = 350;
|
||||
}
|
||||
|
||||
@@ -1625,9 +1625,9 @@ private function calculateKyungdongBom(
|
||||
$handler = new KyungdongFormulaHandler;
|
||||
|
||||
// Step 3: 경동 전용 변수 계산
|
||||
$W1 = $W0 + 140;
|
||||
$W1 = $W0 + 160;
|
||||
$H1 = $H0 + 350;
|
||||
$area = ($W0 * ($H0 + 550)) / 1000000;
|
||||
$area = ($W1 * ($H1 + 550)) / 1000000;
|
||||
|
||||
// 중량 계산 (제품타입별)
|
||||
if ($productType === 'steel') {
|
||||
@@ -1665,8 +1665,8 @@ private function calculateKyungdongBom(
|
||||
[
|
||||
'var' => 'W1',
|
||||
'desc' => '제작 폭',
|
||||
'formula' => 'W0 + 140',
|
||||
'calculation' => "{$W0} + 140",
|
||||
'formula' => 'W0 + 160',
|
||||
'calculation' => "{$W0} + 160",
|
||||
'result' => $W1,
|
||||
'unit' => 'mm',
|
||||
],
|
||||
@@ -1681,8 +1681,8 @@ private function calculateKyungdongBom(
|
||||
[
|
||||
'var' => 'AREA',
|
||||
'desc' => '면적',
|
||||
'formula' => '(W0 × (H0 + 550)) / 1,000,000',
|
||||
'calculation' => "({$W0} × ({$H0} + 550)) / 1,000,000",
|
||||
'formula' => '(W1 × (H1 + 550)) / 1,000,000',
|
||||
'calculation' => "({$W1} × ({$H1} + 550)) / 1,000,000",
|
||||
'result' => round($area, 4),
|
||||
'unit' => '㎡',
|
||||
],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Services\Quote\Handlers;
|
||||
|
||||
use App\Models\Kyungdong\KdPriceTable;
|
||||
use App\Services\Quote\EstimatePriceService;
|
||||
|
||||
/**
|
||||
* 경동기업 전용 견적 계산 핸들러
|
||||
@@ -14,6 +14,13 @@ class KyungdongFormulaHandler
|
||||
{
|
||||
private const TENANT_ID = 287;
|
||||
|
||||
private EstimatePriceService $priceService;
|
||||
|
||||
public function __construct(?EstimatePriceService $priceService = null)
|
||||
{
|
||||
$this->priceService = $priceService ?? new EstimatePriceService(self::TENANT_ID);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 모터 용량 계산
|
||||
// =========================================================================
|
||||
@@ -222,9 +229,12 @@ private function getMotorCapacityByWeight(float $weight, ?string $bracketInch =
|
||||
*/
|
||||
public function calculateScreenPrice(float $width, float $height): array
|
||||
{
|
||||
// 면적 계산: W × (H + 550) / 1,000,000
|
||||
$calculateHeight = $height + 550;
|
||||
$area = ($width * $calculateHeight) / 1000000;
|
||||
// 면적 계산: W1 × (H1 + 550) / 1,000,000
|
||||
// W1 = W0 + 160, H1 = H0 + 350 (레거시 5130 공식)
|
||||
$W1 = $width + 160;
|
||||
$H1 = $height + 350;
|
||||
$calculateHeight = $H1 + 550;
|
||||
$area = ($W1 * $calculateHeight) / 1000000;
|
||||
|
||||
// 원자재 단가 조회 (실리카/스크린)
|
||||
$unitPrice = $this->getRawMaterialPrice('실리카');
|
||||
@@ -237,117 +247,55 @@ public function calculateScreenPrice(float $width, float $height): array
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 단가 조회 메서드 (KdPriceTable 사용)
|
||||
// 단가 조회 메서드 (EstimatePriceService 사용)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* BDmodels 테이블에서 단가 조회
|
||||
*
|
||||
* @param string $modelName 모델코드 (KSS01, KWS01 등)
|
||||
* @param string $secondItem 부품분류 (케이스, 가이드레일, 하단마감재, L-BAR 등)
|
||||
* @param string|null $finishingType 마감재질 (SUS, EGI)
|
||||
* @param string|null $spec 규격 (120*70, 650*550 등)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getBDModelPrice(
|
||||
string $modelName,
|
||||
string $secondItem,
|
||||
?string $finishingType = null,
|
||||
?string $spec = null
|
||||
): float {
|
||||
// BDmodels는 복잡한 구조이므로 items 테이블의 기존 데이터 활용
|
||||
// TODO: 필요시 kd_price_tables TYPE_BDMODELS 추가
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* price_* 테이블에서 단가 조회 (모터, 샤프트, 파이프, 앵글)
|
||||
*
|
||||
* @param string $tableName 테이블명 (motor, shaft, pipe, angle)
|
||||
* @param array $conditions 조회 조건
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getPriceFromTable(string $tableName, array $conditions): float
|
||||
{
|
||||
$query = KdPriceTable::where('table_type', $tableName)->active();
|
||||
|
||||
foreach ($conditions as $field => $value) {
|
||||
$query->where($field, $value);
|
||||
}
|
||||
|
||||
$record = $query->first();
|
||||
|
||||
return (float) ($record?->unit_price ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 원자재 단가 조회
|
||||
*
|
||||
* @param string $materialName 원자재명 (실리카, 스크린 등)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getRawMaterialPrice(string $materialName): float
|
||||
{
|
||||
return KdPriceTable::getRawMaterialPrice($materialName);
|
||||
return $this->priceService->getRawMaterialPrice($materialName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모터 단가 조회
|
||||
*
|
||||
* @param string $motorCapacity 모터 용량 (150K, 300K 등)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getMotorPrice(string $motorCapacity): float
|
||||
{
|
||||
return KdPriceTable::getMotorPrice($motorCapacity);
|
||||
return $this->priceService->getMotorPrice($motorCapacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어기 단가 조회
|
||||
*
|
||||
* @param string $controllerType 제어기 타입 (매립형, 노출형, 뒷박스)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getControllerPrice(string $controllerType): float
|
||||
{
|
||||
return KdPriceTable::getControllerPrice($controllerType);
|
||||
return $this->priceService->getControllerPrice($controllerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샤프트 단가 조회
|
||||
*
|
||||
* @param string $size 사이즈 (3, 4, 5인치)
|
||||
* @param float $length 길이 (m 단위)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getShaftPrice(string $size, float $length): float
|
||||
{
|
||||
return KdPriceTable::getShaftPrice($size, $length);
|
||||
return $this->priceService->getShaftPrice($size, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파이프 단가 조회
|
||||
*
|
||||
* @param string $thickness 두께 (1.4 등)
|
||||
* @param int $length 길이 (3000, 6000)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getPipePrice(string $thickness, int $length): float
|
||||
{
|
||||
return KdPriceTable::getPipePrice($thickness, $length);
|
||||
return $this->priceService->getPipePrice($thickness, $length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 앵글 단가 조회
|
||||
*
|
||||
* @param string $type 타입 (스크린용, 철재용)
|
||||
* @param string $bracketSize 브라켓크기 (530*320, 600*350, 690*390)
|
||||
* @param string $angleType 앵글타입 (앵글3T, 앵글4T)
|
||||
* @return float 단가
|
||||
*/
|
||||
public function getAnglePrice(string $type, string $bracketSize, string $angleType): float
|
||||
{
|
||||
return KdPriceTable::getAnglePrice($type, $bracketSize, $angleType);
|
||||
return $this->priceService->getAnglePrice($type, $bracketSize, $angleType);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -376,18 +324,18 @@ public function calculateSteelItems(array $params): array
|
||||
|
||||
// 절곡품 관련 파라미터
|
||||
$caseSpec = $params['case_spec'] ?? '500*380';
|
||||
$caseLength = (float) ($params['case_length'] ?? $width); // mm 단위
|
||||
$caseLength = (float) ($params['case_length'] ?? ($width + 220)); // mm 단위 (레거시: W0+220)
|
||||
$guideType = $params['guide_type'] ?? '벽면형'; // 벽면형, 측면형, 혼합형
|
||||
$guideSpec = $params['guide_spec'] ?? '120*70'; // 120*70, 120*100
|
||||
$guideLength = (float) ($params['guide_length'] ?? ($height + 550)) / 1000; // m 단위
|
||||
$bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위
|
||||
$lbarLength = (float) ($params['lbar_length'] ?? $width) / 1000; // m 단위
|
||||
$flatBarLength = (float) ($params['flatbar_length'] ?? $width) / 1000; // m 단위
|
||||
$guideLength = (float) ($params['guide_length'] ?? ($height + 250)) / 1000; // m 단위 (레거시: H0+250)
|
||||
$bottomBarLength = (float) ($params['bottombar_length'] ?? $width) / 1000; // m 단위 (레거시: W0)
|
||||
$lbarLength = (float) ($params['lbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220)
|
||||
$flatBarLength = (float) ($params['flatbar_length'] ?? ($width + 220)) / 1000; // m 단위 (레거시: W0+220)
|
||||
$weightPlateQty = (int) ($params['weight_plate_qty'] ?? 0); // 무게평철 수량
|
||||
$roundBarQty = (int) ($params['round_bar_qty'] ?? 0); // 환봉 수량
|
||||
|
||||
// 1. 케이스 (단가/1000 × 길이mm × 수량)
|
||||
$casePrice = KdPriceTable::getCasePrice($caseSpec);
|
||||
$casePrice = $this->priceService->getCasePrice($caseSpec);
|
||||
if ($casePrice > 0 && $caseLength > 0) {
|
||||
$totalPrice = ($casePrice / 1000) * $caseLength * $quantity;
|
||||
$items[] = [
|
||||
@@ -402,7 +350,7 @@ public function calculateSteelItems(array $params): array
|
||||
}
|
||||
|
||||
// 2. 케이스용 연기차단재 (단가 × 길이m × 수량)
|
||||
$caseSmokePrice = KdPriceTable::getCaseSmokeBlockPrice();
|
||||
$caseSmokePrice = $this->priceService->getCaseSmokeBlockPrice();
|
||||
if ($caseSmokePrice > 0 && $caseLength > 0) {
|
||||
$lengthM = $caseLength / 1000;
|
||||
$items[] = [
|
||||
@@ -417,7 +365,7 @@ public function calculateSteelItems(array $params): array
|
||||
}
|
||||
|
||||
// 3. 케이스 마구리 (단가 × 수량)
|
||||
$caseCapPrice = KdPriceTable::getCaseCapPrice($caseSpec);
|
||||
$caseCapPrice = $this->priceService->getCaseCapPrice($caseSpec);
|
||||
if ($caseCapPrice > 0) {
|
||||
$capQty = 2 * $quantity; // 좌우 2개
|
||||
$items[] = [
|
||||
@@ -436,7 +384,7 @@ public function calculateSteelItems(array $params): array
|
||||
$items = array_merge($items, $guideItems);
|
||||
|
||||
// 5. 레일용 연기차단재 (단가 × 길이m × 2 × 수량)
|
||||
$railSmokePrice = KdPriceTable::getRailSmokeBlockPrice();
|
||||
$railSmokePrice = $this->priceService->getRailSmokeBlockPrice();
|
||||
if ($railSmokePrice > 0 && $guideLength > 0) {
|
||||
$railSmokeQty = 2 * $quantity; // 좌우 2개
|
||||
$items[] = [
|
||||
@@ -451,7 +399,7 @@ public function calculateSteelItems(array $params): array
|
||||
}
|
||||
|
||||
// 6. 하장바 (단가 × 길이m × 수량)
|
||||
$bottomBarPrice = KdPriceTable::getBottomBarPrice($modelName, $finishingType);
|
||||
$bottomBarPrice = $this->priceService->getBottomBarPrice($modelName, $finishingType);
|
||||
if ($bottomBarPrice > 0 && $bottomBarLength > 0) {
|
||||
$items[] = [
|
||||
'category' => 'steel',
|
||||
@@ -465,7 +413,7 @@ public function calculateSteelItems(array $params): array
|
||||
}
|
||||
|
||||
// 7. L바 (단가 × 길이m × 수량)
|
||||
$lbarPrice = KdPriceTable::getLBarPrice($modelName);
|
||||
$lbarPrice = $this->priceService->getLBarPrice($modelName);
|
||||
if ($lbarPrice > 0 && $lbarLength > 0) {
|
||||
$items[] = [
|
||||
'category' => 'steel',
|
||||
@@ -479,7 +427,7 @@ public function calculateSteelItems(array $params): array
|
||||
}
|
||||
|
||||
// 8. 보강평철 (단가 × 길이m × 수량)
|
||||
$flatBarPrice = KdPriceTable::getFlatBarPrice();
|
||||
$flatBarPrice = $this->priceService->getFlatBarPrice();
|
||||
if ($flatBarPrice > 0 && $flatBarLength > 0) {
|
||||
$items[] = [
|
||||
'category' => 'steel',
|
||||
@@ -551,7 +499,7 @@ private function calculateGuideRails(
|
||||
switch ($guideType) {
|
||||
case '벽면형':
|
||||
// 120*70 × 2개
|
||||
$price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70');
|
||||
$price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70');
|
||||
if ($price > 0) {
|
||||
$guideQty = 2 * $quantity;
|
||||
$items[] = [
|
||||
@@ -568,7 +516,7 @@ private function calculateGuideRails(
|
||||
|
||||
case '측면형':
|
||||
// 120*100 × 2개
|
||||
$price = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100');
|
||||
$price = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100');
|
||||
if ($price > 0) {
|
||||
$guideQty = 2 * $quantity;
|
||||
$items[] = [
|
||||
@@ -585,8 +533,8 @@ private function calculateGuideRails(
|
||||
|
||||
case '혼합형':
|
||||
// 120*70 × 1개 + 120*100 × 1개
|
||||
$price70 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*70');
|
||||
$price100 = KdPriceTable::getGuideRailPrice($modelName, $finishingType, '120*100');
|
||||
$price70 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*70');
|
||||
$price100 = $this->priceService->getGuideRailPrice($modelName, $finishingType, '120*100');
|
||||
|
||||
if ($price70 > 0) {
|
||||
$items[] = [
|
||||
@@ -707,8 +655,10 @@ public function calculateDynamicItems(array $inputs): array
|
||||
$bracketInch = $inputs['bracket_inch'] ?? '5';
|
||||
$productType = $inputs['product_type'] ?? 'screen';
|
||||
|
||||
// 중량 계산 (5130 로직)
|
||||
$area = ($width * ($height + 550)) / 1000000;
|
||||
// 중량 계산 (5130 로직) - W1, H1 기반
|
||||
$W1 = $width + 160;
|
||||
$H1 = $height + 350;
|
||||
$area = ($W1 * ($H1 + 550)) / 1000000;
|
||||
$weight = $area * ($productType === 'steel' ? 25 : 2) + ($width / 1000) * 14.17;
|
||||
|
||||
// 모터 용량/브라켓 크기 계산
|
||||
|
||||
Reference in New Issue
Block a user