fix: 견적 단가 chandj 원본 소스 전환 및 5130 계산 일치 보정

- MigrateBDModelsPrices: chandj 원본 테이블(price_motor, price_angle 등)에서 직접 마이그레이션
- EstimatePriceService: 모터 LIKE 매칭, 제어기 카테고리 분리, 앵글 bracket/main 분리, 샤프트 포맷 정규화
- KyungdongFormulaHandler:
  - 검사비 항목 추가 (기본 50,000원)
  - 뒷박스 항목 추가 (제어기 섹션)
  - 부자재 앵글3T 항목 추가 (calculatePartItems)
  - 면적 소수점 2자리 반올림 후 곱셈 (5130 동일)
  - model_name에 product_model fallback 추가 (KSS02 단가 정확 조회)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 22:00:15 +09:00
parent 4d8dac1091
commit 3793e95662
3 changed files with 619 additions and 183 deletions

View File

@@ -6,39 +6,67 @@
use Illuminate\Support\Facades\DB;
/**
* BDmodels + kd_price_tables → items + item_details + prices 마이그레이션
* chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션
*
* 레거시 chandj.BDmodels 데이터와 kd_price_tables 데이터를
* 레거시 chandj DB의 BDmodels, price_motor, price_raw_materials,
* price_shaft, price_pipe, price_angle, price_smokeban 데이터를
* items + item_details + prices 통합 구조로 마이그레이션
*/
class MigrateBDModelsPrices extends Command
{
protected $signature = 'kd:migrate-prices {--dry-run : 실제 DB 변경 없이 미리보기}';
protected $signature = 'kd:migrate-prices
{--dry-run : 실제 DB 변경 없이 미리보기}
{--fresh : 기존 EST-* 항목 삭제 후 재생성}';
protected $description = '경동 견적 단가를 items+item_details+prices로 마이그레이션';
protected $description = '경동 견적 단가를 chandj 원본에서 items+item_details+prices로 마이그레이션';
private const TENANT_ID = 287;
private int $created = 0;
private int $updated = 0;
private int $skipped = 0;
private int $deleted = 0;
public function handle(): int
{
$dryRun = $this->option('dry-run');
$fresh = $this->option('fresh');
$this->info('=== 경동 견적 단가 마이그레이션 ===');
$this->info('=== 경동 견적 단가 마이그레이션 (chandj 원본) ===');
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
$this->newLine();
DB::beginTransaction();
try {
// 1. 레거시 BDmodels (chandj DB)
// --fresh: 기존 EST-* 항목 삭제
if ($fresh) {
$this->cleanExistingEstItems($dryRun);
}
// 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철)
$this->migrateBDModels($dryRun);
// 2. kd_price_tables (motor, shaft, pipe, angle, raw_material)
$this->migrateKdPriceTables($dryRun);
// 2. price_motor (모터 + 제어기)
$this->migrateMotors($dryRun);
// 3. price_raw_materials (원자재: 실리카, 화이바, 와이어 등)
$this->migrateRawMaterials($dryRun);
// 4. price_shaft (감기샤프트)
$this->migrateShafts($dryRun);
// 5. price_pipe (각파이프)
$this->migratePipes($dryRun);
// 6. price_angle (앵글)
$this->migrateAngles($dryRun);
// 7. price_smokeban (연기차단재 - BDmodels에 없는 경우 보완)
$this->migrateSmokeBan($dryRun);
if ($dryRun) {
DB::rollBack();
@@ -49,25 +77,49 @@ public function handle(): int
}
$this->newLine();
$this->info("생성: {$this->created}건, 스킵: {$this->skipped}");
$this->info("생성: {$this->created}건, 업데이트: {$this->updated}건, 스킵: {$this->skipped}건, 삭제: {$this->deleted}");
return Command::SUCCESS;
} catch (\Exception $e) {
DB::rollBack();
$this->error("오류: {$e->getMessage()}");
$this->error($e->getTraceAsString());
return Command::FAILURE;
}
}
/**
* 레거시 chandj.BDmodels → items + item_details + prices
* 기존 EST-* 항목 삭제 (--fresh 옵션)
*/
private function cleanExistingEstItems(bool $dryRun): void
{
$this->info('--- 기존 EST-* 항목 삭제 ---');
$items = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', 'LIKE', 'EST-%')
->whereNull('deleted_at')
->get(['id', 'code']);
foreach ($items as $item) {
$this->line(" [삭제] {$item->code}");
if (! $dryRun) {
DB::table('prices')->where('item_id', $item->id)->delete();
DB::table('item_details')->where('item_id', $item->id)->delete();
DB::table('items')->where('id', $item->id)->delete();
}
$this->deleted++;
}
}
/**
* chandj.BDmodels → items + item_details + prices
*/
private function migrateBDModels(bool $dryRun): void
{
$this->info('--- BDmodels (레거시) ---');
$this->info('--- BDmodels (절곡품) ---');
// chandj DB에서 BDmodels 조회 (chandj connection 사용)
$rows = DB::connection('chandj')->select("
SELECT model_name, seconditem, finishing_type, spec, unitprice, description
FROM BDmodels
@@ -91,7 +143,6 @@ private function migrateBDModels(bool $dryRun): void
continue;
}
// 코드 생성
$codeParts = ['BD', $secondItem];
if ($modelName) {
$codeParts[] = $modelName;
@@ -104,7 +155,6 @@ private function migrateBDModels(bool $dryRun): void
}
$code = implode('-', $codeParts);
// 이름 생성
$nameParts = [$secondItem];
if ($modelName) {
$nameParts[] = $modelName;
@@ -117,7 +167,7 @@ private function migrateBDModels(bool $dryRun): void
}
$name = implode(' ', $nameParts);
$this->createEstimateItem(
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'bdmodels',
@@ -130,162 +180,357 @@ private function migrateBDModels(bool $dryRun): void
'description' => $row->description ?: null,
]),
salesPrice: $unitPrice,
note: 'BDmodels 마이그레이션',
note: 'chandj.BDmodels',
dryRun: $dryRun
);
}
}
/**
* kd_price_tables → items + item_details + prices
* chandj.price_motor → 모터 + 제어기
*
* col1: 전압 (220, 380, 제어기, 방화, 방범)
* col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등)
* col13: 판매가
*/
private function migrateKdPriceTables(bool $dryRun): void
private function migrateMotors(bool $dryRun): void
{
$this->info('--- kd_price_tables ---');
$this->info('--- price_motor (모터/제어기) ---');
$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();
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_motor WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
foreach ($rows as $row) {
$tableType = $row->table_type;
$unitPrice = (float) $row->unit_price;
$items = json_decode($row->itemList, true);
if ($unitPrice <= 0) {
foreach ($items as $item) {
$category = trim($item['col1'] ?? ''); // 220, 380, 제어기, 방화, 방범
$name = trim($item['col2'] ?? ''); // 150K(S), 매립형 등
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 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;
}
}
// 카테고리 분류
if (in_array($category, ['220', '380'])) {
$productCategory = 'motor';
$code = "EST-MOTOR-{$category}V-{$name}";
$displayName = "모터 {$name} ({$category}V)";
$partType = $name;
} elseif ($category === '제어기') {
$productCategory = 'controller';
$code = "EST-CTRL-{$name}";
$displayName = "제어기 {$name}";
$partType = $name;
} else {
// 방화, 방범 등
$productCategory = 'controller';
$code = "EST-CTRL-{$category}-{$name}";
$displayName = "{$category} {$name}";
$partType = "{$category} {$name}";
}
private function migrateMotor(object $row, bool $dryRun): void
{
$category = $row->category; // 150K, 300K, 매립형, 노출형 등
$code = "EST-MOTOR-{$category}";
$name = "모터/제어기 {$category}";
$this->createEstimateItem(
$this->upsertEstimateItem(
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 마이그레이션',
name: $displayName,
productCategory: $productCategory,
partType: $partType,
specification: null,
attributes: ['voltage' => $category, 'source' => 'price_motor'],
salesPrice: (float) $price,
note: 'chandj.price_motor',
dryRun: $dryRun
);
}
}
private function migrateShaft(object $row, bool $dryRun): void
/**
* chandj.price_raw_materials → 원자재
*
* col1: 카테고리 (슬랫, 스크린)
* col2: 품명 (방화, 실리카, 화이바, 와이어 등)
* col13: 판매단가
*/
private function migrateRawMaterials(bool $dryRun): void
{
$size = $row->spec1; // 인치
$length = $row->spec2; // 길이
$code = "EST-SHAFT-{$size}-{$length}";
$name = "감기샤프트 {$size}인치 {$length}m";
$this->info('--- price_raw_materials (원자재) ---');
$this->createEstimateItem(
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_raw_materials WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY registedate DESC LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$category = trim($item['col1'] ?? '');
$name = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col13'] ?? '0');
if (empty($name) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-RAW-{$category}-{$name}";
$displayName = "{$category} {$name}";
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: 'raw_material',
partType: $name,
specification: $category,
attributes: ['category' => $category, 'source' => 'price_raw_materials'],
salesPrice: (float) $price,
note: 'chandj.price_raw_materials',
dryRun: $dryRun
);
}
}
/**
* chandj.price_shaft → 감기샤프트
*
* col4: 인치 (3, 4, 5, 6, 8, 10, 12)
* col10: 길이 (m)
* col19: 판매가
*/
private function migrateShafts(bool $dryRun): void
{
$this->info('--- price_shaft (감기샤프트) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_shaft WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$inch = trim($item['col4'] ?? '');
$lengthM = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if (empty($inch) || empty($lengthM) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SHAFT-{$inch}-{$lengthM}";
$name = "감기샤프트 {$inch}인치 {$lengthM}m";
$this->upsertEstimateItem(
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 마이그레이션',
partType: $inch,
specification: $lengthM,
attributes: ['source' => 'price_shaft'],
salesPrice: (float) $price,
note: 'chandj.price_shaft',
dryRun: $dryRun
);
}
}
private function migratePipe(object $row, bool $dryRun): void
/**
* chandj.price_pipe → 각파이프
*
* col4: 두께 (1.4, 2)
* col2: 길이 (3,000 / 6,000)
* col8: 판매가
*/
private function migratePipes(bool $dryRun): void
{
$thickness = $row->spec1;
$length = $row->spec2;
$this->info('--- price_pipe (각파이프) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_pipe WHERE (is_deleted IS NULL OR is_deleted = 0 OR is_deleted = '') ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$thickness = trim($item['col4'] ?? '');
$length = (int) str_replace(',', '', $item['col2'] ?? '0');
$price = (int) str_replace(',', '', $item['col8'] ?? '0');
if (empty($thickness) || $length <= 0 || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-PIPE-{$thickness}-{$length}";
$name = "각파이프 {$thickness}T {$length}mm";
$this->createEstimateItem(
$this->upsertEstimateItem(
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 마이그레이션',
specification: (string) $length,
attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'],
salesPrice: (float) $price,
note: 'chandj.price_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)
* chandj.price_angle → 앵글 (bracket + main 분리)
*
* bracket angle (모터 받침용): col2가 텍스트 (스크린용, 철제300K 등)
* - col2: 검색옵션, col3: 브라켓크기, col4: 앵글타입, col19: 판매가
*
* main angle (부자재용): col2가 숫자 (4 등)
* - col4: 종류 (앵글3T, 앵글4T), col10: 길이 (2.5, 10), col19: 판매가
*/
private function createEstimateItem(
private function migrateAngles(bool $dryRun): void
{
$this->info('--- price_angle (앵글) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_angle WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$col2 = trim($item['col2'] ?? '');
$col3 = trim($item['col3'] ?? '');
$col4 = trim($item['col4'] ?? '');
$col10 = trim($item['col10'] ?? '');
$price = (int) str_replace(',', '', $item['col19'] ?? '0');
if ($price <= 0) {
$this->skipped++;
continue;
}
// col2가 숫자이면 main angle, 텍스트이면 bracket angle
if (is_numeric($col2)) {
// Main angle (부자재용): col4=앵글3T, col10=2.5
if (empty($col4) || empty($col10)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-MAIN-{$col4}-{$col10}";
$name = "앵글 {$col4} {$col10}m";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_main',
partType: $col4,
specification: $col10,
attributes: ['source' => 'price_angle'],
salesPrice: (float) $price,
note: 'chandj.price_angle (main)',
dryRun: $dryRun
);
} else {
// Bracket angle (모터 받침용): col2=스크린용, col3=380*180
if (empty($col2)) {
$this->skipped++;
continue;
}
$code = "EST-ANGLE-BRACKET-{$col2}";
$name = "모터받침 앵글 {$col2}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'angle_bracket',
partType: $col2,
specification: $col3 ?: null,
attributes: [
'angle_type' => $col4,
'source' => 'price_angle',
],
salesPrice: (float) $price,
note: 'chandj.price_angle (bracket)',
dryRun: $dryRun
);
}
}
}
/**
* chandj.price_smokeban → 연기차단재
*
* col2: 용도 (레일용, 케이스용)
* col11: 판매가
*/
private function migrateSmokeBan(bool $dryRun): void
{
$this->info('--- price_smokeban (연기차단재) ---');
$row = DB::connection('chandj')->selectOne(
"SELECT itemList FROM price_smokeban WHERE is_deleted IS NULL OR is_deleted = 0 ORDER BY NUM LIMIT 1"
);
if (! $row) {
return;
}
$items = json_decode($row->itemList, true);
foreach ($items as $item) {
$usage = trim($item['col2'] ?? '');
$price = (int) str_replace(',', '', $item['col11'] ?? '0');
if (empty($usage) || $price <= 0) {
$this->skipped++;
continue;
}
$code = "EST-SMOKE-{$usage}";
$name = "연기차단재 {$usage}";
$this->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'smokeban',
partType: $usage,
specification: null,
attributes: ['source' => 'price_smokeban'],
salesPrice: (float) $price,
note: 'chandj.price_smokeban',
dryRun: $dryRun
);
}
}
/**
* 견적 품목 생성 또는 가격 업데이트
*/
private function upsertEstimateItem(
string $code,
string $name,
string $productCategory,
@@ -296,7 +541,6 @@ private function createEstimateItem(
string $note,
bool $dryRun
): void {
// 중복 체크 (code 기준)
$existing = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', $code)
@@ -304,13 +548,49 @@ private function createEstimateItem(
->first();
if ($existing) {
$this->line(" [스킵] {$code} - 이미 존재");
// 가격 업데이트
$currentPrice = DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->orderByDesc('id')
->value('sales_price');
if ((float) $currentPrice === $salesPrice) {
$this->skipped++;
return;
}
$this->line(" [생성] {$code} ({$name}) = {$salesPrice}");
$this->line(" [업데이트] {$code} 가격: " . number_format($currentPrice ?? 0) . "" . number_format($salesPrice));
if (! $dryRun) {
// 기존 가격 비활성화
DB::table('prices')
->where('item_id', $existing->id)
->where('status', 'active')
->update(['status' => 'inactive', 'updated_at' => now()]);
// 새 가격 추가
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',
'item_id' => $existing->id,
'sales_price' => $salesPrice,
'effective_from' => now()->toDateString(),
'status' => 'active',
'note' => $note,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->updated++;
return;
}
// 신규 생성
$this->line(" [생성] {$code} ({$name}) = " . number_format($salesPrice));
if ($dryRun) {
$this->created++;
@@ -320,7 +600,6 @@ private function createEstimateItem(
$now = now();
// 1. items
$itemId = DB::table('items')->insertGetId([
'tenant_id' => self::TENANT_ID,
'item_type' => 'PT',
@@ -333,7 +612,6 @@ private function createEstimateItem(
'updated_at' => $now,
]);
// 2. item_details
DB::table('item_details')->insert([
'item_id' => $itemId,
'product_category' => $productCategory,
@@ -345,7 +623,6 @@ private function createEstimateItem(
'updated_at' => $now,
]);
// 3. prices
DB::table('prices')->insert([
'tenant_id' => self::TENANT_ID,
'item_type_code' => 'PT',

View File

@@ -163,10 +163,41 @@ public function getRailSmokeBlockPrice(): float
/**
* 모터 단가
*
* chandj col2는 '150K(S)', '300K(S)', '300K' 등 다양한 형식
* handler는 '150K', '300K' 등 단순 용량으로 호출
* LIKE 매칭 + 380V 기본 전압 필터 적용
*/
public function getMotorPrice(string $motorCapacity): float
public function getMotorPrice(string $motorCapacity, string $voltage = '380'): float
{
return $this->getEstimatePartPrice('motor', $motorCapacity);
$cacheKey = "motor:{$motorCapacity}:{$voltage}";
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', 'motor')
->where('item_details.part_type', 'LIKE', "{$motorCapacity}%")
->where('items.attributes->voltage', $voltage)
->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;
}
/**
@@ -174,7 +205,7 @@ public function getMotorPrice(string $motorCapacity): float
*/
public function getControllerPrice(string $controllerType): float
{
return $this->getEstimatePartPrice('motor', $controllerType);
return $this->getEstimatePartPrice('controller', $controllerType);
}
// =========================================================================
@@ -183,10 +214,14 @@ public function getControllerPrice(string $controllerType): float
/**
* 샤프트 단가
*
* chandj col10은 '0.3', '3', '6' 등 혼재 포맷
* 정수면 '6', 소수면 '0.3' 그대로 저장됨
*/
public function getShaftPrice(string $size, float $length): float
{
$lengthStr = number_format($length, 1, '.', '');
// chandj 원본 포맷에 맞게 변환: 정수면 정수형, 소수면 소수형
$lengthStr = ($length == (int) $length) ? (string) (int) $length : (string) $length;
$cacheKey = "shaft:{$size}:{$lengthStr}";
if (isset($this->cache[$cacheKey])) {
@@ -208,11 +243,14 @@ public function getPipePrice(string $thickness, int $length): float
}
/**
* 앵글 단가
* 모터 받침용 앵글 단가 (bracket angle)
*
* 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색
* chandj col2 값: '스크린용', '철제300K', '철제400K', '철제800K'
*/
public function getAnglePrice(string $type, string $bracketSize, string $angleType): float
public function getAnglePrice(string $searchOption): float
{
$cacheKey = "angle:{$type}:{$bracketSize}:{$angleType}";
$cacheKey = "angle_bracket:{$searchOption}";
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
@@ -226,10 +264,8 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy
->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('item_details.product_category', 'angle_bracket')
->where('item_details.part_type', $searchOption)
->where('prices.effective_from', '<=', $today)
->where(function ($q) use ($today) {
$q->whereNull('prices.effective_to')
@@ -243,6 +279,25 @@ public function getAnglePrice(string $type, string $bracketSize, string $angleTy
return $price;
}
/**
* 부자재용 앵글 단가 (main angle)
*
* 5130: calculateMainAngle(1, itemList, '앵글3T', '2.5') → col4+col10 검색
*/
public function getMainAnglePrice(string $angleType, string $size): float
{
$cacheKey = "angle_main:{$angleType}:{$size}";
if (isset($this->cache[$cacheKey])) {
return $this->cache[$cacheKey];
}
$price = $this->getEstimatePartPriceBySpec('angle_main', $angleType, $size);
$this->cache[$cacheKey] = $price;
return $price;
}
/**
* 원자재 단가
*/

View File

@@ -239,10 +239,13 @@ public function calculateScreenPrice(float $width, float $height): array
// 원자재 단가 조회 (실리카/스크린)
$unitPrice = $this->getRawMaterialPrice('실리카');
// 5130 동일: round(area, 2) 후 단가 곱셈
$roundedArea = round($area, 2);
return [
'unit_price' => $unitPrice,
'area' => round($area, 2),
'total_price' => round($unitPrice * $area),
'area' => $roundedArea,
'total_price' => round($unitPrice * $roundedArea),
];
}
@@ -291,11 +294,24 @@ public function getPipePrice(string $thickness, int $length): float
}
/**
* 앵글 단가 조회
* 모터 받침용 앵글 단가 조회
*
* @param string $searchOption 검색옵션 (스크린용, 철제300K 등)
*/
public function getAnglePrice(string $type, string $bracketSize, string $angleType): float
public function getAnglePrice(string $searchOption): float
{
return $this->priceService->getAnglePrice($type, $bracketSize, $angleType);
return $this->priceService->getAnglePrice($searchOption);
}
/**
* 부자재용 앵글 단가 조회
*
* @param string $angleType 앵글타입 (앵글3T, 앵글4T)
* @param string $size 길이 (2.5, 10)
*/
public function getMainAnglePrice(string $angleType, string $size): float
{
return $this->priceService->getMainAnglePrice($angleType, $size);
}
// =========================================================================
@@ -319,7 +335,7 @@ public function calculateSteelItems(array $params): array
$width = (float) ($params['W0'] ?? 0);
$height = (float) ($params['H0'] ?? 0);
$quantity = (int) ($params['QTY'] ?? 1);
$modelName = $params['model_name'] ?? 'KSS01';
$modelName = $params['model_name'] ?? $params['product_model'] ?? 'KSS01';
$finishingType = $params['finishing_type'] ?? 'SUS';
// 절곡품 관련 파라미터
@@ -365,13 +381,15 @@ public function calculateSteelItems(array $params): array
}
// 3. 케이스 마구리 (단가 × 수량)
$caseCapPrice = $this->priceService->getCaseCapPrice($caseSpec);
// 마구리 규격 = 케이스 규격 각 치수 + 5mm (레거시 updateCol45 공식)
$caseCapSpec = $this->convertToCaseCapSpec($caseSpec);
$caseCapPrice = $this->priceService->getCaseCapPrice($caseCapSpec);
if ($caseCapPrice > 0) {
$capQty = 2 * $quantity; // 좌우 2개
$items[] = [
'category' => 'steel',
'item_name' => '케이스 마구리',
'specification' => $caseSpec,
'specification' => $caseCapSpec,
'unit' => 'EA',
'quantity' => $capQty,
'unit_price' => $caseCapPrice,
@@ -616,19 +634,49 @@ public function calculatePartItems(array $params): array
];
}
// 3. 앵글
$angleType = $productType === 'steel' ? '철재용' : '스크린용';
$angleSpec = $bracketSize === '690*390' ? '앵글4T' : '앵글3T';
$anglePrice = $this->getAnglePrice($angleType, $bracketSize, $angleSpec);
// 3. 모터 받침용 앵글 (bracket angle)
// 5130: calculateAngle(qty, itemList, '스크린용') → col2 검색, qty × $su × 4
$motorCapacity = $params['MOTOR_CAPACITY'] ?? '300K';
if ($productType === 'screen') {
$angleSearchOption = '스크린용';
} else {
// 철재: bracketSize로 매핑 (530*320→철제300K, 600*350→철제400K, 690*390→철제800K)
$angleSearchOption = match ($bracketSize) {
'530*320' => '철제300K',
'600*350' => '철제400K',
'690*390' => '철제800K',
default => '철제300K',
};
}
$anglePrice = $this->getAnglePrice($angleSearchOption);
if ($anglePrice > 0) {
$angleQty = 4 * $quantity; // 5130: $su * 4
$items[] = [
'category' => 'parts',
'item_name' => "앵글 {$angleSpec}",
'specification' => "{$angleType} {$bracketSize}",
'item_name' => '모터 받침용 앵글',
'specification' => $angleSearchOption,
'unit' => 'EA',
'quantity' => 2 * $quantity, // 좌우 2개
'quantity' => $angleQty,
'unit_price' => $anglePrice,
'total_price' => $anglePrice * 2 * $quantity,
'total_price' => $anglePrice * $angleQty,
];
}
// 4. 부자재 앵글 (main angle)
// 5130: calculateMainAngle(1, $itemList, '앵글3T', '2.5') × col71
$mainAngleType = $bracketSize === '690*390' ? '앵글4T' : '앵글3T';
$mainAngleSize = '2.5';
$mainAngleQty = (int) ($params['main_angle_qty'] ?? 2); // col71, default 2 (좌우)
$mainAnglePrice = $this->getMainAnglePrice($mainAngleType, $mainAngleSize);
if ($mainAnglePrice > 0 && $mainAngleQty > 0) {
$items[] = [
'category' => 'parts',
'item_name' => "앵글 {$mainAngleType}",
'specification' => "{$mainAngleSize}m",
'unit' => 'EA',
'quantity' => $mainAngleQty * $quantity,
'unit_price' => $mainAnglePrice,
'total_price' => $mainAnglePrice * $mainAngleQty * $quantity,
];
}
@@ -670,6 +718,21 @@ public function calculateDynamicItems(array $inputs): array
$inputs['MOTOR_CAPACITY'] = $motorCapacity;
$inputs['BRACKET_SIZE'] = $bracketSize;
// 0. 검사비 (5130: inspectionFee × col14, 기본 50,000원)
$inspectionFee = (int) ($inputs['inspection_fee'] ?? 50000);
if ($inspectionFee > 0) {
$items[] = [
'category' => 'inspection',
'item_code' => 'KD-INSPECTION',
'item_name' => '검사비',
'specification' => '',
'unit' => 'EA',
'quantity' => $quantity,
'unit_price' => $inspectionFee,
'total_price' => $inspectionFee * $quantity,
];
}
// 1. 주자재 (스크린)
$screenResult = $this->calculateScreenPrice($width, $height);
$items[] = [
@@ -696,19 +759,40 @@ public function calculateDynamicItems(array $inputs): array
'total_price' => $motorPrice * $quantity,
];
// 3. 제어기
// 3. 제어기 (5130: 매립형×col15 + 노출형×col16 + 뒷박스×col17)
$controllerType = $inputs['controller_type'] ?? '매립형';
$controllerQty = (int) ($inputs['controller_qty'] ?? 1);
$controllerPrice = $this->getControllerPrice($controllerType);
if ($controllerPrice > 0 && $controllerQty > 0) {
$items[] = [
'category' => 'controller',
'item_code' => 'KD-CTRL-'.strtoupper($controllerType),
'item_name' => "제어기 {$controllerType}",
'specification' => $controllerType,
'unit' => 'EA',
'quantity' => $quantity,
'quantity' => $controllerQty * $quantity,
'unit_price' => $controllerPrice,
'total_price' => $controllerPrice * $quantity,
'total_price' => $controllerPrice * $controllerQty * $quantity,
];
}
// 뒷박스 (5130: col17 수량)
$backboxQty = (int) ($inputs['backbox_qty'] ?? 1);
if ($backboxQty > 0) {
$backboxPrice = $this->getControllerPrice('뒷박스');
if ($backboxPrice > 0) {
$items[] = [
'category' => 'controller',
'item_code' => 'KD-CTRL-BACKBOX',
'item_name' => '뒷박스',
'specification' => '',
'unit' => 'EA',
'quantity' => $backboxQty * $quantity,
'unit_price' => $backboxPrice,
'total_price' => $backboxPrice * $backboxQty * $quantity,
];
}
}
// 4. 절곡품
$steelItems = $this->calculateSteelItems($inputs);
@@ -720,4 +804,24 @@ public function calculateDynamicItems(array $inputs): array
return $items;
}
/**
* 케이스 규격 → 마구리 규격 변환
*
* 레거시 updateCol45/Slat_updateCol46 공식:
* 마구리 규격 = (케이스 가로 + 5) × (케이스 세로 + 5)
* 예: 500*380 → 505*385
*/
private function convertToCaseCapSpec(string $caseSpec): string
{
if (str_contains($caseSpec, '*')) {
$parts = explode('*', $caseSpec);
$width = (int) trim($parts[0]) + 5;
$height = (int) trim($parts[1]) + 5;
return "{$width}*{$height}";
}
return $caseSpec;
}
}