Files
sam-api/app/Console/Commands/Migrate5130PriceItems.php
권혁성 3d70092956 feat: 5130 레거시 마이그레이션 커맨드 및 관련 파일 추가
- Migrate5130Bom: 완제품 BOM 템플릿 마이그레이션 (61건)
- Migrate5130Orders: 주문 데이터 마이그레이션
- Migrate5130PriceItems: 품목 데이터 마이그레이션
- Verify5130Calculation: 견적 계산 검증 커맨드
- Legacy5130Calculator: 레거시 계산 헬퍼
- ContractFromBiddingRequest: 입찰→계약 전환 요청
- 마이그레이션: shipments.work_order_id, order_id_mappings 테이블
2026-01-20 19:03:16 +09:00

583 lines
22 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 5130 가격표 품목 → SAM items 마이그레이션
*
* 대상 테이블:
* - KDunitprice → items (SM, RM, CS)
* - price_raw_materials → items (RM)
* - price_bend → items (PT)
*/
#[AsCommand(name: 'migrate:5130-price-items', description: '5130 가격표(KDunitprice, price_raw_materials, price_bend) → SAM items 마이그레이션')]
class Migrate5130PriceItems extends Command
{
protected $signature = 'migrate:5130-price-items
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
{--step=all : 실행할 단계 (all|kdunitprice|raw_materials|bend)}
{--rollback : 마이그레이션 롤백}
{--limit=0 : 테스트용 레코드 수 제한 (0=전체)}';
// 5130 DB 연결 (chandj)
private string $sourceDb = 'chandj';
// SAM DB 연결
private string $targetDb = 'mysql';
// 통계
private array $stats = [
'kdunitprice' => ['total' => 0, 'migrated' => 0, 'skipped' => 0],
'raw_materials' => ['total' => 0, 'migrated' => 0, 'skipped' => 0],
'bend' => ['total' => 0, 'migrated' => 0, 'skipped' => 0],
];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$step = $this->option('step');
$rollback = $this->option('rollback');
$limit = (int) $this->option('limit');
$this->info('╔══════════════════════════════════════════════════════════════╗');
$this->info('║ 5130 가격표 → SAM items 마이그레이션 ║');
$this->info('╚══════════════════════════════════════════════════════════════╝');
$this->newLine();
$this->info("📌 Tenant ID: {$tenantId}");
$this->info('📌 Mode: '.($dryRun ? '🔍 DRY-RUN (시뮬레이션)' : '⚡ LIVE'));
$this->info("📌 Step: {$step}");
if ($limit > 0) {
$this->warn("📌 Limit: {$limit} records (테스트 모드)");
}
$this->newLine();
if ($rollback) {
return $this->rollbackMigration($tenantId, $dryRun);
}
$steps = $step === 'all'
? ['kdunitprice', 'raw_materials', 'bend']
: [$step];
foreach ($steps as $currentStep) {
$this->line('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(">>> Step: {$currentStep}");
$this->newLine();
match ($currentStep) {
'kdunitprice' => $this->migrateKDunitprice($tenantId, $dryRun, $limit),
'raw_materials' => $this->migratePriceRawMaterials($tenantId, $dryRun, $limit),
'bend' => $this->migratePriceBend($tenantId, $dryRun, $limit),
default => $this->error("Unknown step: {$currentStep}"),
};
$this->newLine();
}
$this->showSummary();
return self::SUCCESS;
}
/**
* KDunitprice → items (SM, RM, CS)
*/
private function migrateKDunitprice(int $tenantId, bool $dryRun, int $limit): void
{
$this->info('📦 Migrating KDunitprice → items (SM, RM, CS)...');
$query = DB::connection($this->sourceDb)->table('KDunitprice')
->whereNull('is_deleted')
->orWhere('is_deleted', 0);
if ($limit > 0) {
$query->limit($limit);
}
$items = $query->get();
$this->stats['kdunitprice']['total'] = $items->count();
$this->line("Found {$items->count()} records");
$bar = $this->output->createProgressBar($items->count());
$bar->start();
foreach ($items as $item) {
// 이미 존재하는지 확인 (code 기준)
$exists = DB::connection($this->targetDb)->table('items')
->where('tenant_id', $tenantId)
->where('code', $item->prodcode)
->whereNull('deleted_at')
->exists();
if ($exists) {
$this->stats['kdunitprice']['skipped']++;
$bar->advance();
continue;
}
// item_type 결정
$itemType = $this->determineItemType($item->item_div);
// 단가 파싱 (콤마 제거)
$unitPrice = $this->parseNumber($item->unitprice);
$itemData = [
'tenant_id' => $tenantId,
'item_type' => $itemType,
'item_category' => $item->item_div,
'code' => $item->prodcode,
'name' => $item->item_name ?: '(이름없음)',
'unit' => $item->unit ?: 'EA',
'attributes' => json_encode([
'spec' => $item->spec,
'unit_price' => $unitPrice,
'update_log' => $item->update_log,
'source' => '5130',
'source_table' => 'KDunitprice',
'source_id' => $item->num,
], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('items')->insert($itemData);
}
$this->stats['kdunitprice']['migrated']++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("✅ KDunitprice 완료: {$this->stats['kdunitprice']['migrated']} migrated, {$this->stats['kdunitprice']['skipped']} skipped");
}
/**
* price_raw_materials → items (RM)
* 최신 버전(registedate)만 마이그레이션
*/
private function migratePriceRawMaterials(int $tenantId, bool $dryRun, int $limit): void
{
$this->info('📦 Migrating price_raw_materials → items (RM)...');
// 최신 registedate 조회
$latestRecord = DB::connection($this->sourceDb)->table('price_raw_materials')
->whereNull('is_deleted')
->orWhere('is_deleted', 0)
->orderBy('registedate', 'desc')
->first();
if (! $latestRecord) {
$this->warn('No records found in price_raw_materials');
return;
}
$this->line("Latest version: {$latestRecord->registedate}");
$itemList = json_decode($latestRecord->itemList ?? '[]', true);
if (empty($itemList)) {
$this->warn('Empty itemList in latest record');
return;
}
$totalItems = count($itemList);
if ($limit > 0 && $limit < $totalItems) {
$itemList = array_slice($itemList, 0, $limit);
}
$this->stats['raw_materials']['total'] = count($itemList);
$this->line('Found '.count($itemList).' items in itemList');
$bar = $this->output->createProgressBar(count($itemList));
$bar->start();
$orderNo = 0;
foreach ($itemList as $row) {
$orderNo++;
// 코드 생성: col14가 있으면 사용, 없으면 자동생성
$code = ! empty($row['col14'])
? $row['col14']
: $this->generateCode('RM', $latestRecord->registedate, $orderNo);
// 이미 존재하는지 확인
$exists = DB::connection($this->targetDb)->table('items')
->where('tenant_id', $tenantId)
->where('code', $code)
->whereNull('deleted_at')
->exists();
if ($exists) {
$this->stats['raw_materials']['skipped']++;
$bar->advance();
continue;
}
// 품목명 생성
$name = trim(($row['col1'] ?? '').' '.($row['col2'] ?? '')) ?: '(이름없음)';
// item_category 결정
$itemCategory = $this->determineRawMaterialCategory($row['col1'] ?? '');
$itemData = [
'tenant_id' => $tenantId,
'item_type' => 'RM',
'item_category' => $itemCategory,
'code' => $code,
'name' => $name,
'unit' => 'kg',
'attributes' => json_encode([
'product_type' => $row['col1'] ?? null,
'sub_type' => $row['col2'] ?? null,
'length' => $this->parseNumber($row['col3'] ?? null),
'thickness' => $this->parseNumber($row['col4'] ?? null),
'specific_gravity' => $this->parseNumber($row['col5'] ?? null),
'area_per_unit' => $this->parseNumber($row['col6'] ?? null),
'weight_per_sqm' => $this->parseNumber($row['col7'] ?? null),
'purchase_price_per_kg' => $this->parseNumber($row['col8'] ?? null),
'price_per_sqm_with_loss' => $this->parseNumber($row['col9'] ?? null),
'processing_cost' => $this->parseNumber($row['col10'] ?? null),
'mimi' => $this->parseNumber($row['col11'] ?? null),
'total' => $this->parseNumber($row['col12'] ?? null),
'total_with_labor' => $this->parseNumber($row['col13'] ?? null),
'non_certified_code' => $row['col14'] ?? null,
'non_certified_price' => $this->parseNumber($row['col15'] ?? null),
'source' => '5130',
'source_table' => 'price_raw_materials',
'source_id' => $latestRecord->num,
'source_version' => $latestRecord->registedate,
'source_order' => $orderNo,
], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('items')->insert($itemData);
}
$this->stats['raw_materials']['migrated']++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("✅ price_raw_materials 완료: {$this->stats['raw_materials']['migrated']} migrated, {$this->stats['raw_materials']['skipped']} skipped");
}
/**
* price_bend → items (PT)
* 최신 버전(registedate)만 마이그레이션
*/
private function migratePriceBend(int $tenantId, bool $dryRun, int $limit): void
{
$this->info('📦 Migrating price_bend → items (PT)...');
// 최신 registedate 조회
$latestRecord = DB::connection($this->sourceDb)->table('price_bend')
->whereNull('is_deleted')
->orWhere('is_deleted', 0)
->orderBy('registedate', 'desc')
->first();
if (! $latestRecord) {
$this->warn('No records found in price_bend');
return;
}
$this->line("Latest version: {$latestRecord->registedate}");
$itemList = json_decode($latestRecord->itemList ?? '[]', true);
if (empty($itemList)) {
$this->warn('Empty itemList in latest record');
return;
}
$totalItems = count($itemList);
if ($limit > 0 && $limit < $totalItems) {
$itemList = array_slice($itemList, 0, $limit);
}
$this->stats['bend']['total'] = count($itemList);
$this->line('Found '.count($itemList).' items in itemList');
$bar = $this->output->createProgressBar(count($itemList));
$bar->start();
$orderNo = 0;
foreach ($itemList as $row) {
$orderNo++;
// 코드 자동생성
$code = $this->generateCode('PT', $latestRecord->registedate, $orderNo);
// 이미 존재하는지 확인
$exists = DB::connection($this->targetDb)->table('items')
->where('tenant_id', $tenantId)
->where('code', $code)
->whereNull('deleted_at')
->exists();
if ($exists) {
$this->stats['bend']['skipped']++;
$bar->advance();
continue;
}
// 품목명 생성
$name = trim(($row['col1'] ?? '').' '.($row['col2'] ?? '')) ?: '(이름없음)';
// item_category 결정 (STEEL/ALUMINUM 등)
$itemCategory = $this->determineBendCategory($row['col1'] ?? '');
$itemData = [
'tenant_id' => $tenantId,
'item_type' => 'PT',
'item_category' => $itemCategory,
'code' => $code,
'name' => $name,
'unit' => '㎡',
'attributes' => json_encode([
'material_type' => $row['col1'] ?? null,
'material_sub' => $row['col2'] ?? null,
'width' => $this->parseNumber($row['col3'] ?? null),
'length' => $this->parseNumber($row['col4'] ?? null),
'thickness' => $this->parseNumber($row['col5'] ?? null),
'specific_gravity' => $this->parseNumber($row['col6'] ?? null),
'area' => $this->parseNumber($row['col7'] ?? null),
'weight' => $this->parseNumber($row['col8'] ?? null),
'purchase_price_per_kg' => $this->parseNumber($row['col9'] ?? null),
'loss_premium_price' => $this->parseNumber($row['col10'] ?? null),
'material_cost' => $this->parseNumber($row['col11'] ?? null),
'selling_price' => $this->parseNumber($row['col12'] ?? null),
'processing_cost_per_sqm' => $this->parseNumber($row['col13'] ?? null),
'processing_cost' => $this->parseNumber($row['col14'] ?? null),
'total' => $this->parseNumber($row['col15'] ?? null),
'price_per_sqm' => $this->parseNumber($row['col16'] ?? null),
'selling_price_per_sqm' => $this->parseNumber($row['col17'] ?? null),
'price_per_kg' => $this->parseNumber($row['col18'] ?? null),
'source' => '5130',
'source_table' => 'price_bend',
'source_id' => $latestRecord->num,
'source_version' => $latestRecord->registedate,
'source_order' => $orderNo,
], JSON_UNESCAPED_UNICODE),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('items')->insert($itemData);
}
$this->stats['bend']['migrated']++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("✅ price_bend 완료: {$this->stats['bend']['migrated']} migrated, {$this->stats['bend']['skipped']} skipped");
}
/**
* item_type 결정 (KDunitprice용)
*
* 5130 item_div → SAM item_type 매핑:
* - [원재료] → RM (원자재)
* - [부재료] → SM (부자재)
* - [상품], [제품] → FG (완제품)
* - [반제품] → PT (부품)
* - [무형상품] → CS (소모품/서비스)
*
* 주의: 조건 순서가 중요함 (더 구체적인 조건을 먼저 체크)
* - '반제품'을 '제품'보다 먼저 체크
* - '무형상품'을 '상품'보다 먼저 체크
*/
private function determineItemType(?string $itemDiv): string
{
if (empty($itemDiv)) {
return 'SM';
}
$lower = mb_strtolower($itemDiv);
// 1. 부품 판별 (반제품) - '제품'보다 먼저!
if (str_contains($lower, '반제품')) {
return 'PT';
}
// 2. 소모품 판별 (무형상품) - '상품'보다 먼저!
if (str_contains($lower, '무형') || str_contains($lower, '소모품') || str_contains($lower, 'consumable') || str_contains($lower, '소모')) {
return 'CS';
}
// 3. 완제품 판별 (상품, 제품)
if (str_contains($lower, '상품') || str_contains($lower, '제품')) {
return 'FG';
}
// 4. 원자재 판별 (원재료)
if (str_contains($lower, '원자재') || str_contains($lower, '원재료') || str_contains($lower, 'raw') || str_contains($lower, '원료')) {
return 'RM';
}
// 5. 기본값: 부자재 (부재료 포함)
return 'SM';
}
/**
* 원자재 카테고리 결정
*/
private function determineRawMaterialCategory(?string $productType): string
{
if (empty($productType)) {
return 'GENERAL';
}
$lower = mb_strtolower($productType);
if (str_contains($lower, '슬랫') || str_contains($lower, 'slat')) {
return 'SLAT';
}
if (str_contains($lower, '알루미늄') || str_contains($lower, 'aluminum') || str_contains($lower, 'al')) {
return 'ALUMINUM';
}
if (str_contains($lower, '스틸') || str_contains($lower, 'steel')) {
return 'STEEL';
}
return 'GENERAL';
}
/**
* 절곡 카테고리 결정
*/
private function determineBendCategory(?string $materialType): string
{
if (empty($materialType)) {
return 'GENERAL';
}
$lower = mb_strtolower($materialType);
if (str_contains($lower, '스틸') || str_contains($lower, 'steel') || str_contains($lower, '철')) {
return 'STEEL';
}
if (str_contains($lower, '알루미늄') || str_contains($lower, 'aluminum') || str_contains($lower, 'al')) {
return 'ALUMINUM';
}
return 'BENDING';
}
/**
* 코드 생성
*/
private function generateCode(string $prefix, ?string $date, int $orderNo): string
{
$dateStr = $date ? str_replace('-', '', $date) : date('Ymd');
return sprintf('%s-%s-%03d', $prefix, $dateStr, $orderNo);
}
/**
* 숫자 파싱 (콤마, 공백 제거)
*/
private function parseNumber(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return (float) $value;
}
$cleaned = preg_replace('/[^\d.-]/', '', (string) $value);
return is_numeric($cleaned) ? (float) $cleaned : null;
}
/**
* 롤백
*/
private function rollbackMigration(int $tenantId, bool $dryRun): int
{
$this->warn('╔══════════════════════════════════════════════════════════════╗');
$this->warn('║ ⚠️ 롤백 모드 ║');
$this->warn('╚══════════════════════════════════════════════════════════════╝');
if (! $this->confirm('5130 가격표에서 마이그레이션된 모든 데이터를 삭제하시겠습니까?')) {
$this->info('롤백 취소됨');
return self::SUCCESS;
}
$tables = ['KDunitprice', 'price_raw_materials', 'price_bend'];
foreach ($tables as $table) {
$count = DB::connection($this->targetDb)->table('items')
->where('tenant_id', $tenantId)
->whereRaw("JSON_EXTRACT(attributes, '$.source_table') = ?", [$table])
->count();
if (! $dryRun) {
DB::connection($this->targetDb)->table('items')
->where('tenant_id', $tenantId)
->whereRaw("JSON_EXTRACT(attributes, '$.source_table') = ?", [$table])
->delete();
}
$this->line("Deleted {$count} items from {$table}");
}
$this->info('✅ 롤백 완료');
return self::SUCCESS;
}
/**
* 요약 출력
*/
private function showSummary(): void
{
$this->newLine();
$this->info('╔══════════════════════════════════════════════════════════════╗');
$this->info('║ 📊 마이그레이션 결과 요약 ║');
$this->info('╚══════════════════════════════════════════════════════════════╝');
$this->table(
['Source Table', 'Total', 'Migrated', 'Skipped'],
[
['KDunitprice', $this->stats['kdunitprice']['total'], $this->stats['kdunitprice']['migrated'], $this->stats['kdunitprice']['skipped']],
['price_raw_materials', $this->stats['raw_materials']['total'], $this->stats['raw_materials']['migrated'], $this->stats['raw_materials']['skipped']],
['price_bend', $this->stats['bend']['total'], $this->stats['bend']['migrated'], $this->stats['bend']['skipped']],
]
);
$totalMigrated = $this->stats['kdunitprice']['migrated']
+ $this->stats['raw_materials']['migrated']
+ $this->stats['bend']['migrated'];
$this->newLine();
$this->info("🎉 총 {$totalMigrated}개 품목 마이그레이션 완료!");
}
}