- 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>
640 lines
20 KiB
PHP
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++;
|
|
}
|
|
} |