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:
@@ -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}";
|
||||
}
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function migrateMotor(object $row, bool $dryRun): void
|
||||
/**
|
||||
* chandj.price_smokeban → 연기차단재
|
||||
*
|
||||
* col2: 용도 (레일용, 케이스용)
|
||||
* col11: 판매가
|
||||
*/
|
||||
private function migrateSmokeBan(bool $dryRun): void
|
||||
{
|
||||
$category = $row->category; // 150K, 300K, 매립형, 노출형 등
|
||||
$code = "EST-MOTOR-{$category}";
|
||||
$name = "모터/제어기 {$category}";
|
||||
$this->info('--- price_smokeban (연기차단재) ---');
|
||||
|
||||
$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
|
||||
$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;
|
||||
}
|
||||
|
||||
private function migrateShaft(object $row, bool $dryRun): void
|
||||
{
|
||||
$size = $row->spec1; // 인치
|
||||
$length = $row->spec2; // 길이
|
||||
$code = "EST-SHAFT-{$size}-{$length}";
|
||||
$name = "감기샤프트 {$size}인치 {$length}m";
|
||||
$items = json_decode($row->itemList, true);
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
$usage = trim($item['col2'] ?? '');
|
||||
$price = (int) str_replace(',', '', $item['col11'] ?? '0');
|
||||
|
||||
private function migratePipe(object $row, bool $dryRun): void
|
||||
{
|
||||
$thickness = $row->spec1;
|
||||
$length = $row->spec2;
|
||||
$code = "EST-PIPE-{$thickness}-{$length}";
|
||||
$name = "각파이프 {$thickness}T {$length}mm";
|
||||
if (empty($usage) || $price <= 0) {
|
||||
$this->skipped++;
|
||||
|
||||
$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
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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}";
|
||||
$code = "EST-SMOKE-{$usage}";
|
||||
$name = "연기차단재 {$usage}";
|
||||
|
||||
$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
|
||||
);
|
||||
$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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 품목 생성 (items + item_details + prices)
|
||||
* 견적 품목 생성 또는 가격 업데이트
|
||||
*/
|
||||
private function createEstimateItem(
|
||||
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} - 이미 존재");
|
||||
$this->skipped++;
|
||||
// 가격 업데이트
|
||||
$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}) = {$salesPrice}");
|
||||
// 신규 생성
|
||||
$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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원자재 단가
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
$items[] = [
|
||||
'category' => 'controller',
|
||||
'item_code' => 'KD-CTRL-'.strtoupper($controllerType),
|
||||
'item_name' => "제어기 {$controllerType}",
|
||||
'specification' => $controllerType,
|
||||
'unit' => 'EA',
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $controllerPrice,
|
||||
'total_price' => $controllerPrice * $quantity,
|
||||
];
|
||||
if ($controllerPrice > 0 && $controllerQty > 0) {
|
||||
$items[] = [
|
||||
'category' => 'controller',
|
||||
'item_code' => 'KD-CTRL-'.strtoupper($controllerType),
|
||||
'item_name' => "제어기 {$controllerType}",
|
||||
'specification' => $controllerType,
|
||||
'unit' => 'EA',
|
||||
'quantity' => $controllerQty * $quantity,
|
||||
'unit_price' => $controllerPrice,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user