Files
sam-api/app/Console/Commands/MigrateBDModelsPrices.php
권혁성 3793e95662 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>
2026-01-29 22:00:15 +09:00

640 lines
20 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* chandj 원본 가격 테이블 → items + item_details + prices 마이그레이션
*
* 레거시 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 변경 없이 미리보기}
{--fresh : 기존 EST-* 항목 삭제 후 재생성}';
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('=== 경동 견적 단가 마이그레이션 (chandj 원본) ===');
$this->info($dryRun ? '[DRY RUN] 실제 변경 없음' : '[LIVE] DB에 반영합니다');
$this->newLine();
DB::beginTransaction();
try {
// --fresh: 기존 EST-* 항목 삭제
if ($fresh) {
$this->cleanExistingEstItems($dryRun);
}
// 1. BDmodels (절곡품: 케이스, 가이드레일, 하단마감재, 마구리, 연기차단재, L바, 보강평철)
$this->migrateBDModels($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();
$this->warn('[DRY RUN] 롤백 완료');
} else {
DB::commit();
$this->info('커밋 완료');
}
$this->newLine();
$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;
}
}
/**
* 기존 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 (절곡품) ---');
$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->upsertEstimateItem(
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: 'chandj.BDmodels',
dryRun: $dryRun
);
}
}
/**
* chandj.price_motor → 모터 + 제어기
*
* col1: 전압 (220, 380, 제어기, 방화, 방범)
* col2: 용량/종류 (150K(S), 300K, 매립형, 노출형 등)
* col13: 판매가
*/
private function migrateMotors(bool $dryRun): void
{
$this->info('--- price_motor (모터/제어기) ---');
$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;
}
$items = json_decode($row->itemList, true);
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;
}
// 카테고리 분류
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}";
}
$this->upsertEstimateItem(
code: $code,
name: $displayName,
productCategory: $productCategory,
partType: $partType,
specification: null,
attributes: ['voltage' => $category, 'source' => 'price_motor'],
salesPrice: (float) $price,
note: 'chandj.price_motor',
dryRun: $dryRun
);
}
}
/**
* chandj.price_raw_materials → 원자재
*
* col1: 카테고리 (슬랫, 스크린)
* col2: 품명 (방화, 실리카, 화이바, 와이어 등)
* col13: 판매단가
*/
private function migrateRawMaterials(bool $dryRun): void
{
$this->info('--- price_raw_materials (원자재) ---');
$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: $inch,
specification: $lengthM,
attributes: ['source' => 'price_shaft'],
salesPrice: (float) $price,
note: 'chandj.price_shaft',
dryRun: $dryRun
);
}
}
/**
* chandj.price_pipe → 각파이프
*
* col4: 두께 (1.4, 2)
* col2: 길이 (3,000 / 6,000)
* col8: 판매가
*/
private function migratePipes(bool $dryRun): void
{
$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->upsertEstimateItem(
code: $code,
name: $name,
productCategory: 'pipe',
partType: $thickness,
specification: (string) $length,
attributes: ['spec' => $item['col3'] ?? '', 'source' => 'price_pipe'],
salesPrice: (float) $price,
note: 'chandj.price_pipe',
dryRun: $dryRun
);
}
}
/**
* 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 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,
string $partType,
?string $specification,
array $attributes,
float $salesPrice,
string $note,
bool $dryRun
): void {
$existing = DB::table('items')
->where('tenant_id', self::TENANT_ID)
->where('code', $code)
->whereNull('deleted_at')
->first();
if ($existing) {
// 가격 업데이트
$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} 가격: " . 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++;
return;
}
$now = now();
$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,
]);
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,
]);
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++;
}
}