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:
2026-01-29 19:30:46 +09:00
parent e882d33de1
commit 6c9735581d
4 changed files with 745 additions and 98 deletions

View 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++;
}
}