feat: 5130 레거시 마이그레이션 커맨드 및 관련 파일 추가

- Migrate5130Bom: 완제품 BOM 템플릿 마이그레이션 (61건)
- Migrate5130Orders: 주문 데이터 마이그레이션
- Migrate5130PriceItems: 품목 데이터 마이그레이션
- Verify5130Calculation: 견적 계산 검증 커맨드
- Legacy5130Calculator: 레거시 계산 헬퍼
- ContractFromBiddingRequest: 입찰→계약 전환 요청
- 마이그레이션: shipments.work_order_id, order_id_mappings 테이블
This commit is contained in:
2026-01-20 19:03:16 +09:00
parent 7246ac003f
commit 3d70092956
8 changed files with 2469 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 5130 마이그레이션 완제품에 BOM 데이터 적용 커맨드
*
* 5130에서 마이그레이션된 완제품(FG) 중 BOM이 NULL인 항목에
* 기존 SAM BOM 템플릿을 적용합니다.
*
* Usage:
* php artisan migration:migrate-5130-bom --dry-run # 시뮬레이션
* php artisan migration:migrate-5130-bom # 실제 실행
* php artisan migration:migrate-5130-bom --code=S0001 # 특정 품목만
* php artisan migration:migrate-5130-bom --category=SCREEN # 특정 카테고리만
*/
class Migrate5130Bom extends Command
{
protected $signature = 'migration:migrate-5130-bom
{--dry-run : 실제 변경 없이 시뮬레이션}
{--code= : 특정 품목 코드만 처리}
{--category= : 특정 카테고리만 처리 (SCREEN|STEEL|BENDING)}
{--tenant-id=1 : 테넌트 ID}';
protected $description = '5130 마이그레이션 완제품에 BOM 템플릿 적용';
/**
* 카테고리별 BOM 템플릿 소스 (기존 SAM 완제품 코드)
*/
private array $templateSources = [
'SCREEN' => 'FG-SCR-001',
'STEEL' => 'FG-STL-001',
'BENDING' => 'FG-BND-001',
];
/**
* 로드된 BOM 템플릿 캐시
*/
private array $bomTemplates = [];
public function handle(): int
{
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(' 5130 → SAM BOM 마이그레이션');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->newLine();
$dryRun = $this->option('dry-run');
$code = $this->option('code');
$category = $this->option('category');
$tenantId = (int) $this->option('tenant-id');
if ($dryRun) {
$this->warn('🔍 DRY-RUN 모드: 실제 변경 없이 시뮬레이션합니다.');
$this->newLine();
}
// 1. BOM 템플릿 로드
$this->info('📥 Step 1: BOM 템플릿 로드');
if (! $this->loadBomTemplates($tenantId)) {
$this->error('BOM 템플릿 로드 실패');
return Command::FAILURE;
}
// 2. 대상 품목 조회
$this->info('📥 Step 2: 대상 품목 조회');
$items = $this->getTargetItems($tenantId, $code, $category);
if ($items->isEmpty()) {
$this->info('✅ 처리할 대상 품목이 없습니다.');
return Command::SUCCESS;
}
$this->info(" 대상 품목 수: {$items->count()}");
$this->newLine();
// 3. BOM 적용
$this->info('🔄 Step 3: BOM 적용');
$stats = [
'total' => $items->count(),
'success' => 0,
'skipped' => 0,
'failed' => 0,
];
$this->output->progressStart($items->count());
foreach ($items as $item) {
$result = $this->applyBomToItem($item, $dryRun);
if ($result === 'success') {
$stats['success']++;
} elseif ($result === 'skipped') {
$stats['skipped']++;
} else {
$stats['failed']++;
}
$this->output->progressAdvance();
}
$this->output->progressFinish();
$this->newLine();
// 4. 결과 출력
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 처리 결과');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->table(
['항목', '건수'],
[
['전체 대상', $stats['total']],
['✅ 성공', $stats['success']],
['⏭️ 스킵 (템플릿 없음)', $stats['skipped']],
['❌ 실패', $stats['failed']],
]
);
if ($dryRun) {
$this->newLine();
$this->warn('🔍 DRY-RUN 모드였습니다. 실제 적용하려면 --dry-run 옵션을 제거하세요.');
}
return Command::SUCCESS;
}
/**
* BOM 템플릿 로드
*/
private function loadBomTemplates(int $tenantId): bool
{
foreach ($this->templateSources as $category => $sourceCode) {
$sourceItem = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $sourceCode)
->first(['bom']);
if ($sourceItem && $sourceItem->bom) {
$bom = json_decode($sourceItem->bom, true);
if (is_array($bom) && count($bom) > 0) {
$this->bomTemplates[$category] = $sourceItem->bom;
$this->info("{$category}: {$sourceCode} 템플릿 로드됨 (" . count($bom) . "개 항목)");
} else {
$this->warn(" ⚠️ {$category}: {$sourceCode} BOM이 비어있음");
}
} else {
$this->warn(" ⚠️ {$category}: {$sourceCode} 템플릿 없음");
}
}
$this->newLine();
return count($this->bomTemplates) > 0;
}
/**
* 대상 품목 조회 (BOM이 NULL인 FG 완제품)
*/
private function getTargetItems(int $tenantId, ?string $code, ?string $category)
{
$query = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_type', 'FG')
->whereIn('item_category', array_keys($this->bomTemplates))
->where(function ($q) {
$q->whereNull('bom')
->orWhere('bom', '[]')
->orWhere('bom', 'null')
->orWhereRaw('JSON_LENGTH(bom) = 0');
});
if ($code) {
$query->where('code', $code);
}
if ($category) {
$query->where('item_category', strtoupper($category));
}
return $query->get(['id', 'code', 'name', 'item_category']);
}
/**
* 개별 품목에 BOM 적용
*/
private function applyBomToItem(object $item, bool $dryRun): string
{
$category = $item->item_category;
// 템플릿 확인
if (! isset($this->bomTemplates[$category])) {
if ($this->output->isVerbose()) {
$this->warn(" ⏭️ [{$item->code}] {$item->name} - {$category} 템플릿 없음");
}
return 'skipped';
}
$bomJson = $this->bomTemplates[$category];
if ($dryRun) {
if ($this->output->isVerbose()) {
$this->info(" 📋 [{$item->code}] {$item->name}{$category} 템플릿 적용 예정");
}
return 'success';
}
// 실제 업데이트
try {
DB::table('items')
->where('id', $item->id)
->update([
'bom' => $bomJson,
'updated_at' => now(),
]);
if ($this->output->isVerbose()) {
$this->info(" ✅ [{$item->code}] {$item->name} → BOM 적용 완료");
}
return 'success';
} catch (\Exception $e) {
$this->error(" ❌ [{$item->code}] 오류: " . $e->getMessage());
return 'failed';
}
}
}

View File

@@ -0,0 +1,787 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'migrate:5130-orders', description: '5130 수주 데이터를 SAM orders/order_items 테이블로 마이그레이션')]
class Migrate5130Orders extends Command
{
protected $signature = 'migrate:5130-orders
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
{--step=all : 실행할 단계 (all|orders|items|extra)}
{--rollback : 마이그레이션 롤백 (source=5130 데이터 삭제)}
{--limit=0 : 처리할 최대 건수 (0=전체)}
{--offset=0 : 시작 위치}';
// 5130 DB 연결
private string $sourceDb = 'chandj';
// SAM DB 연결
private string $targetDb = 'mysql';
// 매핑 캐시 (source_num => order_id)
private array $orderMappings = [];
// 통계
private array $stats = [
'orders' => 0,
'order_items_screen' => 0,
'order_items_slat' => 0,
'order_items_motor' => 0,
'order_items_bend' => 0,
'order_items_etc' => 0,
'skipped' => 0,
'errors' => 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');
$offset = (int) $this->option('offset');
$this->info('=== 5130 → SAM 수주 마이그레이션 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
$this->info("Step: {$step}");
if ($limit > 0) {
$this->info("Limit: {$limit}, Offset: {$offset}");
}
$this->newLine();
if ($rollback) {
return $this->rollbackMigration($tenantId, $dryRun);
}
// 기존 매핑 로드
$this->loadExistingMappings();
$steps = $step === 'all'
? ['orders', 'items', 'extra']
: [$step];
foreach ($steps as $currentStep) {
$this->line(">>> Step: {$currentStep}");
match ($currentStep) {
'orders' => $this->migrateOrders($tenantId, $dryRun, $limit, $offset),
'items' => $this->migrateOrderItems($tenantId, $dryRun, $limit, $offset),
'extra' => $this->migrateExtraItems($tenantId, $dryRun, $limit, $offset),
default => $this->error("Unknown step: {$currentStep}"),
};
$this->newLine();
}
$this->info('=== 마이그레이션 완료 ===');
$this->showSummary();
return self::SUCCESS;
}
/**
* 기존 매핑 로드 (중복 방지)
*/
private function loadExistingMappings(): void
{
$mappings = DB::connection($this->targetDb)->table('order_id_mappings')->get();
foreach ($mappings as $mapping) {
$this->orderMappings[$mapping->source_num] = $mapping->order_id;
}
$this->line('Loaded '.count($this->orderMappings).' existing mappings');
}
/**
* output → orders 마이그레이션
*/
private function migrateOrders(int $tenantId, bool $dryRun, int $limit, int $offset): void
{
$this->info('Migrating output → orders...');
$query = DB::connection($this->sourceDb)->table('output')
->where('is_deleted', '!=', '1')
->orderBy('num');
if ($limit > 0) {
$query->skip($offset)->take($limit);
}
$outputs = $query->get();
$this->line("Found {$outputs->count()} orders");
$bar = $this->output->createProgressBar($outputs->count());
$bar->start();
foreach ($outputs as $output) {
// 이미 마이그레이션된 경우 스킵
if (isset($this->orderMappings[$output->num])) {
$this->stats['skipped']++;
$bar->advance();
continue;
}
try {
$orderData = $this->mapOutputToOrder($output, $tenantId);
if (! $dryRun) {
$orderId = DB::connection($this->targetDb)->table('orders')->insertGetId($orderData);
// 매핑 기록
DB::connection($this->targetDb)->table('order_id_mappings')->insert([
'source_num' => $output->num,
'order_id' => $orderId,
'created_at' => now(),
'updated_at' => now(),
]);
$this->orderMappings[$output->num] = $orderId;
}
$this->stats['orders']++;
} catch (\Exception $e) {
$this->stats['errors']++;
$this->newLine();
$this->error("Error migrating order {$output->num}: {$e->getMessage()}");
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Orders migration completed: {$this->stats['orders']} created");
}
/**
* output → order 데이터 매핑
*/
private function mapOutputToOrder(object $output, int $tenantId): array
{
// 상태 코드 매핑
$statusMap = [
'등록' => 'REGISTERED',
'진행' => 'IN_PROGRESS',
'출고' => 'SHIPPED',
'완료' => 'COMPLETED',
'취소' => 'CANCELLED',
];
$status = $statusMap[$output->regist_state ?? ''] ?? 'REGISTERED';
// 주문번호 생성 (5130 원본 번호 기반)
$orderNo = 'ORD-5130-'.str_pad($output->num, 6, '0', STR_PAD_LEFT);
// options JSON에 저장할 레거시 데이터
$options = [
'legacy_num' => $output->num,
'legacy_parent_num' => $output->parent_num ?? null,
'legacy_con_num' => $output->con_num ?? null,
'orderman' => $output->orderman ?? null,
'receiver' => $output->receiver ?? null,
'outputplace' => $output->outputplace ?? null,
'root' => $output->root ?? null,
'steel' => $output->steel ?? null,
'motor' => $output->motor ?? null,
'delivery' => $output->delivery ?? null,
'screen_state' => $output->screen_state ?? null,
'slat_state' => $output->slat_state ?? null,
'bend_state' => $output->bend_state ?? null,
'motor_state' => $output->motor_state ?? null,
'screen_su' => $output->screen_su ?? null,
'screen_m2' => $output->screen_m2 ?? null,
'slat_su' => $output->slat_su ?? null,
'slat_m2' => $output->slat_m2 ?? null,
'secondord' => $output->secondord ?? null,
'secondordman' => $output->secondordman ?? null,
'secondordmantel' => $output->secondordmantel ?? null,
'secondordnum' => $output->secondordnum ?? null,
'prodCode' => $output->prodCode ?? null,
'warrantyNum' => $output->warrantyNum ?? null,
'warranty' => $output->warranty ?? null,
'lotNum' => $output->lotNum ?? null,
'pjnum' => $output->pjnum ?? null,
'major_category' => $output->major_category ?? null,
'position' => $output->position ?? null,
'makeWidth' => $output->makeWidth ?? null,
'makeHeight' => $output->makeHeight ?? null,
'maguriWing' => $output->maguriWing ?? null,
'ACI' => [
'regDate' => $output->ACIregDate ?? null,
'askDate' => $output->ACIaskDate ?? null,
'doneDate' => $output->ACIdoneDate ?? null,
'memo' => $output->ACImemo ?? null,
'check' => $output->ACIcheck ?? null,
'groupCode' => $output->ACIgroupCode ?? null,
'groupName' => $output->ACIgroupName ?? null,
],
'devMode' => $output->devMode ?? null,
'displayText' => $output->displayText ?? null,
'slatcheck' => $output->slatcheck ?? null,
'partscheck' => $output->partscheck ?? null,
'requestBendingASSY' => $output->requestBendingASSY ?? null,
'source' => '5130',
];
return [
'tenant_id' => $tenantId,
'order_no' => $orderNo,
'order_type_code' => 'STANDARD',
'status_code' => $status,
'category_code' => $output->major_category ?? null,
'received_at' => $this->parseDate($output->outdate),
'client_id' => null, // client 매핑은 별도 처리 필요
'client_name' => null,
'client_contact' => $output->phone ?? null,
'site_name' => $output->outworkplace ?? null,
'supply_amount' => $this->parseNumber($output->estimateTotal ?? '0'),
'tax_amount' => 0,
'total_amount' => $this->parseNumber($output->estimateTotal ?? '0'),
'discount_rate' => 0,
'discount_amount' => 0,
'delivery_date' => $this->parseDate($output->indate),
'memo' => $output->comment ?? null,
'remarks' => $output->updatecomment ?? null,
'options' => json_encode($options),
'created_at' => now(),
'updated_at' => now(),
];
}
/**
* screenlist/slatlist → order_items 마이그레이션
* 이미 마이그레이션된 orders에 대해서만 처리
*/
private function migrateOrderItems(int $tenantId, bool $dryRun, int $limit, int $offset): void
{
$this->info('Migrating screenlist/slatlist → order_items...');
// 매핑된 source_num 목록으로 필터링
$sourceNums = array_keys($this->orderMappings);
if (empty($sourceNums)) {
$this->warn('No order mappings found. Run orders step first.');
return;
}
$query = DB::connection($this->sourceDb)->table('output')
->whereIn('num', $sourceNums)
->where(function ($q) {
$q->whereRaw('LENGTH(screenlist) > 2') // '{}' 보다 긴 것만
->orWhereRaw('LENGTH(slatlist) > 2');
})
->orderBy('num');
$outputs = $query->get();
$this->line("Found {$outputs->count()} orders with screen/slat items");
if ($outputs->isEmpty()) {
return;
}
$bar = $this->output->createProgressBar($outputs->count());
$bar->start();
foreach ($outputs as $output) {
$orderId = $this->orderMappings[$output->num] ?? null;
if (! $orderId) {
$bar->advance();
continue;
}
// 이미 order_items가 있는지 확인 (중복 방지)
$existingCount = DB::connection($this->targetDb)->table('order_items')
->where('order_id', $orderId)
->count();
if ($existingCount > 0) {
$bar->advance();
continue;
}
// screenlist 처리
if (! empty($output->screenlist) && strlen($output->screenlist) > 2) {
$this->processJsonItems($output->screenlist, $orderId, $tenantId, 'SCREEN', $dryRun);
}
// slatlist 처리
if (! empty($output->slatlist) && strlen($output->slatlist) > 2) {
$this->processJsonItems($output->slatlist, $orderId, $tenantId, 'SLAT', $dryRun);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Order items migration completed: screen={$this->stats['order_items_screen']}, slat={$this->stats['order_items_slat']}");
}
/**
* JSON 리스트를 order_items로 변환
*/
private function processJsonItems(string $json, int $orderId, int $tenantId, string $type, bool $dryRun): void
{
$items = json_decode($json, true);
if (! is_array($items)) {
return;
}
$serialNo = 0;
foreach ($items as $item) {
$serialNo++;
// 빈 항목 스킵
if (empty($item['cutwidth']) && empty($item['cutheight']) && empty($item['text1'])) {
continue;
}
$specification = '';
if (! empty($item['cutwidth']) || ! empty($item['cutheight'])) {
$specification = ($item['cutwidth'] ?? '').'x'.($item['cutheight'] ?? '');
}
$itemData = [
'tenant_id' => $tenantId,
'order_id' => $orderId,
'serial_no' => $serialNo,
'item_code' => $type.'-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT),
'item_name' => $type === 'SCREEN' ? '방충망' : '슬랫',
'specification' => $specification,
'floor_code' => $item['floors'] ?? null,
'symbol_code' => $item['text1'] ?? null,
'unit' => 'EA',
'quantity' => (int) ($item['number'] ?? 1),
'unit_price' => 0,
'supply_amount' => 0,
'tax_amount' => 0,
'total_amount' => 0,
'status_code' => 'PENDING',
'remarks' => $item['memo'] ?? null,
'note' => $item['text2'] ?? null,
'sort_order' => $serialNo,
'attributes' => json_encode([
'item_type' => $type,
'cutwidth' => $item['cutwidth'] ?? null,
'cutheight' => $item['cutheight'] ?? null,
'exititem' => $item['exititem'] ?? null,
'printside' => $item['printside'] ?? null,
'direction' => $item['direction'] ?? null,
'intervalnum' => $item['intervalnum'] ?? null,
'intervalnumsecond' => $item['intervalnumsecond'] ?? null,
'exitinterval' => $item['exitinterval'] ?? null,
'cover' => $item['cover'] ?? null,
'drawbottom1' => $item['drawbottom1'] ?? null,
'drawbottom2' => $item['drawbottom2'] ?? null,
'drawbottom3' => $item['drawbottom3'] ?? null,
'draw' => $item['draw'] ?? null,
'done_check' => $item['done_check'] ?? null,
'remain_check' => $item['remain_check'] ?? null,
'mid_check' => $item['mid_check'] ?? null,
'left_check' => $item['left_check'] ?? null,
'right_check' => $item['right_check'] ?? null,
// SLAT 전용
'hinge' => $item['hinge'] ?? null,
'hingenum' => $item['hingenum'] ?? null,
'hinge_direction' => $item['hinge_direction'] ?? null,
'source' => '5130',
]),
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_items')->insert($itemData);
}
if ($type === 'SCREEN') {
$this->stats['order_items_screen']++;
} else {
$this->stats['order_items_slat']++;
}
}
}
/**
* output_extra (motorList, bendList 등) → order_items 마이그레이션
*/
private function migrateExtraItems(int $tenantId, bool $dryRun, int $limit, int $offset): void
{
$this->info('Migrating output_extra → order_items (motor, bend, etc)...');
$query = DB::connection($this->sourceDb)->table('output_extra')
->orderBy('parent_num');
if ($limit > 0) {
$query->skip($offset)->take($limit);
}
$extras = $query->get();
$this->line("Found {$extras->count()} output_extra records");
$bar = $this->output->createProgressBar($extras->count());
$bar->start();
foreach ($extras as $extra) {
$orderId = $this->orderMappings[$extra->parent_num] ?? null;
if (! $orderId) {
$bar->advance();
continue;
}
// motorList 처리
if (! empty($extra->motorList) && $extra->motorList !== '{}') {
$this->processMotorList($extra->motorList, $orderId, $tenantId, $dryRun);
}
// bendList 처리
if (! empty($extra->bendList) && $extra->bendList !== '{}') {
$this->processBendList($extra->bendList, $orderId, $tenantId, $dryRun);
}
// etcList 처리
if (! empty($extra->etcList) && $extra->etcList !== '{}') {
$this->processEtcList($extra->etcList, $orderId, $tenantId, $dryRun);
}
// orders.options 업데이트 (금액 정보)
if (! $dryRun) {
$this->updateOrderOptions($orderId, $extra);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Extra items migration completed: motor={$this->stats['order_items_motor']}, bend={$this->stats['order_items_bend']}, etc={$this->stats['order_items_etc']}");
}
/**
* motorList → order_items (MOTOR)
*/
private function processMotorList(string $json, int $orderId, int $tenantId, bool $dryRun): void
{
$items = json_decode($json, true);
if (! is_array($items)) {
return;
}
$serialNo = $this->getNextSerialNo($orderId);
foreach ($items as $item) {
if (empty($item['col1'])) {
continue;
}
$itemData = [
'tenant_id' => $tenantId,
'order_id' => $orderId,
'serial_no' => $serialNo++,
'item_code' => 'MOTOR-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT),
'item_name' => $item['col1'] ?? '모터',
'specification' => $item['col2'] ?? null, // 용량
'unit' => 'EA',
'quantity' => (int) ($item['col5'] ?? 1),
'unit_price' => 0,
'supply_amount' => 0,
'tax_amount' => 0,
'total_amount' => 0,
'status_code' => 'PENDING',
'sort_order' => $serialNo,
'attributes' => json_encode([
'item_type' => 'MOTOR',
'dimension' => $item['col3'] ?? null, // 규격
'inch' => $item['col4'] ?? null,
'type' => $item['col6'] ?? null, // 형태
'option' => $item['col7'] ?? null,
'power' => $item['col8'] ?? null, // 전원
'source' => '5130',
]),
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_items')->insert($itemData);
}
$this->stats['order_items_motor']++;
}
}
/**
* bendList → order_items (BEND)
*/
private function processBendList(string $json, int $orderId, int $tenantId, bool $dryRun): void
{
$items = json_decode($json, true);
if (! is_array($items)) {
return;
}
$serialNo = $this->getNextSerialNo($orderId);
foreach ($items as $item) {
if (empty($item['col1'])) {
continue;
}
$itemData = [
'tenant_id' => $tenantId,
'order_id' => $orderId,
'serial_no' => $serialNo++,
'item_code' => 'BEND-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT),
'item_name' => $item['col1'] ?? '절곡',
'specification' => $item['col2'] ?? null, // 재질
'unit' => 'EA',
'quantity' => (int) ($item['col7'] ?? 1),
'unit_price' => 0,
'supply_amount' => 0,
'tax_amount' => 0,
'total_amount' => 0,
'status_code' => 'PENDING',
'remarks' => $item['col8'] ?? null, // 비고
'sort_order' => $serialNo,
'attributes' => json_encode([
'item_type' => 'BEND',
'length' => $item['col3'] ?? null,
'width' => $item['col5'] ?? null,
'drawing' => $item['col6'] ?? null, // 도면이미지
'source' => '5130',
]),
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_items')->insert($itemData);
}
$this->stats['order_items_bend']++;
}
}
/**
* etcList → order_items (ETC)
*/
private function processEtcList(string $json, int $orderId, int $tenantId, bool $dryRun): void
{
$items = json_decode($json, true);
if (! is_array($items)) {
return;
}
$serialNo = $this->getNextSerialNo($orderId);
foreach ($items as $item) {
if (empty($item['col1'])) {
continue;
}
$itemData = [
'tenant_id' => $tenantId,
'order_id' => $orderId,
'serial_no' => $serialNo++,
'item_code' => 'ETC-'.str_pad($serialNo, 3, '0', STR_PAD_LEFT),
'item_name' => $item['col1'] ?? '기타',
'specification' => $item['col2'] ?? null,
'unit' => 'EA',
'quantity' => (int) ($item['col3'] ?? 1),
'unit_price' => $this->parseNumber($item['col4'] ?? '0'),
'supply_amount' => $this->parseNumber($item['col5'] ?? '0'),
'tax_amount' => 0,
'total_amount' => $this->parseNumber($item['col5'] ?? '0'),
'status_code' => 'PENDING',
'sort_order' => $serialNo,
'attributes' => json_encode([
'item_type' => 'ETC',
'source' => '5130',
]),
'created_at' => now(),
'updated_at' => now(),
];
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_items')->insert($itemData);
}
$this->stats['order_items_etc']++;
}
}
/**
* order의 다음 serial_no 조회
*/
private function getNextSerialNo(int $orderId): int
{
$max = DB::connection($this->targetDb)->table('order_items')
->where('order_id', $orderId)
->max('serial_no');
return ($max ?? 0) + 1;
}
/**
* orders.options 업데이트 (output_extra 금액 정보)
*/
private function updateOrderOptions(int $orderId, object $extra): void
{
$order = DB::connection($this->targetDb)->table('orders')
->where('id', $orderId)
->first();
if (! $order) {
return;
}
$options = json_decode($order->options ?? '{}', true);
// output_extra 금액 정보 추가
$options['estimate_first'] = $this->parseNumber($extra->EstimateFirstSum ?? '0');
$options['estimate_update'] = $this->parseNumber($extra->EstimateUpdatetSum ?? '0');
$options['estimate_diff'] = $this->parseNumber($extra->EstimateDiffer ?? '0');
$options['estimate_quantity'] = $this->parseNumber($extra->estimateSurang ?? '0');
$options['inspection_fee'] = $this->parseNumber($extra->inspectionFee ?? '0');
// 금액 필드 업데이트
$supplyAmount = $this->parseNumber($extra->estimateTotal ?? '0');
$discountRate = $this->parseNumber($extra->EstimateDiscountRate ?? '0');
$discountAmount = $this->parseNumber($extra->EstimateDiscount ?? '0');
$totalAmount = $this->parseNumber($extra->EstimateFinalSum ?? '0');
DB::connection($this->targetDb)->table('orders')
->where('id', $orderId)
->update([
'supply_amount' => $supplyAmount ?: $order->supply_amount,
'discount_rate' => $discountRate,
'discount_amount' => $discountAmount,
'total_amount' => $totalAmount ?: $order->total_amount,
'options' => json_encode($options),
'updated_at' => now(),
]);
}
/**
* 롤백 (source=5130 데이터 삭제)
*/
private function rollbackMigration(int $tenantId, bool $dryRun): int
{
$this->warn('=== 롤백 모드 ===');
if (! $this->confirm('5130에서 마이그레이션된 모든 수주 데이터를 삭제하시겠습니까?')) {
$this->info('롤백 취소됨');
return self::SUCCESS;
}
// 1. order_items 삭제 (order_id 기준)
$orderIds = DB::connection($this->targetDb)->table('order_id_mappings')
->pluck('order_id')
->toArray();
if (! empty($orderIds)) {
$itemCount = DB::connection($this->targetDb)->table('order_items')
->whereIn('order_id', $orderIds)
->count();
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_items')
->whereIn('order_id', $orderIds)
->delete();
}
$this->line("Deleted {$itemCount} order_items");
// 2. orders 삭제
if (! $dryRun) {
DB::connection($this->targetDb)->table('orders')
->whereIn('id', $orderIds)
->delete();
}
$this->line('Deleted '.count($orderIds).' orders');
}
// 3. order_id_mappings 삭제
if (! $dryRun) {
DB::connection($this->targetDb)->table('order_id_mappings')->truncate();
}
$this->line('Cleared order_id_mappings');
$this->info('롤백 완료');
return self::SUCCESS;
}
/**
* 날짜 파싱
*/
private function parseDate(?string $value): ?string
{
if (empty($value)) {
return null;
}
try {
return date('Y-m-d H:i:s', strtotime($value));
} catch (\Exception $e) {
return null;
}
}
/**
* 숫자 파싱 (varchar → decimal)
*/
private function parseNumber(?string $value): float
{
if (empty($value)) {
return 0;
}
$cleaned = preg_replace('/[^\d.-]/', '', $value);
return is_numeric($cleaned) ? (float) $cleaned : 0;
}
/**
* 요약 출력
*/
private function showSummary(): void
{
$this->newLine();
$this->table(
['Category', 'Count'],
[
['Orders', $this->stats['orders']],
['Screen Items', $this->stats['order_items_screen']],
['Slat Items', $this->stats['order_items_slat']],
['Motor Items', $this->stats['order_items_motor']],
['Bend Items', $this->stats['order_items_bend']],
['Etc Items', $this->stats['order_items_etc']],
['Skipped', $this->stats['skipped']],
['Errors', $this->stats['errors']],
]
);
}
}

View File

@@ -0,0 +1,582 @@
<?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}개 품목 마이그레이션 완료!");
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Console\Commands;
use App\Helpers\Legacy5130Calculator;
use App\Services\Quote\FormulaEvaluatorService;
use Illuminate\Console\Command;
/**
* 5130 견적 계산 결과 검증 커맨드
*
* 5130 레거시 시스템의 계산 결과와 SAM의 계산 결과가 동일한지 검증합니다.
*
* Usage:
* php artisan migration:verify-5130-calculation
* php artisan migration:verify-5130-calculation --W0=3000 --H0=2500 --type=screen
*/
class Verify5130Calculation extends Command
{
protected $signature = 'migration:verify-5130-calculation
{--W0=2500 : 개구부 폭 (mm)}
{--H0=2000 : 개구부 높이 (mm)}
{--qty=1 : 수량}
{--type=screen : 제품 유형 (screen, steel)}
{--tenant-id=1 : 테넌트 ID}
{--finished-goods= : 완제품 코드 (BOM 계산 시)}
{--verbose-mode : 상세 출력 모드}';
protected $description = '5130 레거시 계산 결과와 SAM 계산 결과 비교 검증';
public function handle(FormulaEvaluatorService $formulaEvaluator): int
{
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(' 5130 → SAM 견적 계산 검증');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->newLine();
// 입력 파라미터
$W0 = (float) $this->option('W0');
$H0 = (float) $this->option('H0');
$qty = (int) $this->option('qty');
$productType = $this->option('type');
$tenantId = (int) $this->option('tenant-id');
$finishedGoodsCode = $this->option('finished-goods');
$verboseMode = $this->option('verbose-mode');
$this->info("📥 입력 파라미터:");
$this->table(
['항목', '값'],
[
['개구부 폭 (W0)', "{$W0} mm"],
['개구부 높이 (H0)', "{$H0} mm"],
['수량 (QTY)', $qty],
['제품 유형', $productType],
['테넌트 ID', $tenantId],
]
);
$this->newLine();
// 1. Legacy5130Calculator로 계산 (5130 호환 모드)
$this->info('🔄 Step 1: Legacy5130Calculator 계산 (5130 호환)');
$legacyResult = Legacy5130Calculator::calculateEstimate($W0, $H0, $qty, $productType);
$this->table(
['항목', '값'],
[
['제작 폭 (W1)', "{$legacyResult['calculated']['W1']} mm"],
['제작 높이 (H1)', "{$legacyResult['calculated']['H1']} mm"],
['면적 (M)', "{$legacyResult['calculated']['area_m2']}"],
['중량 (K)', "{$legacyResult['calculated']['weight_kg']} kg"],
['브라켓 인치', "{$legacyResult['calculated']['bracket_inch']} inch"],
['모터 용량', $legacyResult['motor']['capacity']],
['브라켓 사이즈', $legacyResult['motor']['bracket_dimensions']],
]
);
$this->newLine();
// 2. SAM FormulaEvaluatorService로 계산
$this->info('🔄 Step 2: SAM FormulaEvaluatorService 계산');
if ($finishedGoodsCode) {
// BOM 기반 계산
$samResult = $formulaEvaluator->calculateBomWithDebug(
$finishedGoodsCode,
['W0' => $W0, 'H0' => $H0, 'QTY' => $qty],
$tenantId
);
if (! $samResult['success']) {
$this->error("SAM 계산 실패: " . ($samResult['error'] ?? '알 수 없는 오류'));
return Command::FAILURE;
}
$samVariables = $samResult['variables'];
} else {
// 직접 계산 (변수만)
$samVariables = $this->calculateSamVariables($W0, $H0, $productType);
$samResult = ['success' => true, 'variables' => $samVariables];
}
$this->table(
['항목', '값'],
[
['제작 폭 (W1)', ($samVariables['W1'] ?? 'N/A').' mm'],
['제작 높이 (H1)', ($samVariables['H1'] ?? 'N/A').' mm'],
['면적 (M)', round($samVariables['M'] ?? 0, 4).' m²'],
['중량 (K)', round($samVariables['K'] ?? 0, 2).' kg'],
]
);
$this->newLine();
// 3. 결과 비교
$this->info('🔍 Step 3: 결과 비교');
$comparison = [
['W1 (제작 폭)', $legacyResult['calculated']['W1'], $samVariables['W1'] ?? 0],
['H1 (제작 높이)', $legacyResult['calculated']['H1'], $samVariables['H1'] ?? 0],
['M (면적)', $legacyResult['calculated']['area_m2'], round($samVariables['M'] ?? 0, 4)],
['K (중량)', $legacyResult['calculated']['weight_kg'], round($samVariables['K'] ?? 0, 2)],
];
$allMatch = true;
$comparisonTable = [];
foreach ($comparison as [$name, $legacy, $sam]) {
$diff = abs($legacy - $sam);
$percentDiff = $legacy != 0 ? ($diff / abs($legacy)) * 100 : 0;
$match = $percentDiff < 1; // 1% 이내 허용
if (! $match) {
$allMatch = false;
}
$comparisonTable[] = [
$name,
$legacy,
$sam,
round($diff, 4),
round($percentDiff, 2).'%',
$match ? '✅ 일치' : '❌ 불일치',
];
}
$this->table(
['항목', '5130 (Legacy)', 'SAM', '차이', '차이율', '결과'],
$comparisonTable
);
$this->newLine();
// 4. 최종 결과
if ($allMatch) {
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(' ✅ 검증 성공: 5130과 SAM 계산 결과가 일치합니다.');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
} else {
$this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->error(' ❌ 검증 실패: 5130과 SAM 계산 결과가 불일치합니다.');
$this->error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
// 상세 출력 모드
if ($verboseMode) {
$this->newLine();
$this->info('📊 상세 정보 (Legacy5130Calculator):');
$this->line(json_encode($legacyResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
if ($finishedGoodsCode && isset($samResult['debug_steps'])) {
$this->newLine();
$this->info('📊 상세 정보 (SAM Debug Steps):');
foreach ($samResult['debug_steps'] as $step) {
$this->line("Step {$step['step']}: {$step['name']}");
$this->line(json_encode($step['data'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
}
}
return $allMatch ? Command::SUCCESS : Command::FAILURE;
}
/**
* SAM 방식으로 변수 계산 (BOM 없이)
*/
private function calculateSamVariables(float $W0, float $H0, string $productType): array
{
// 마진값 결정
if (strtolower($productType) === 'steel') {
$marginW = 110;
$marginH = 350;
} else {
$marginW = 140;
$marginH = 350;
}
$W1 = $W0 + $marginW;
$H1 = $H0 + $marginH;
$M = ($W1 * $H1) / 1000000;
// 중량 계산
if (strtolower($productType) === 'steel') {
$K = $M * 25;
} else {
$K = $M * 2 + ($W0 / 1000) * 14.17;
}
return [
'W0' => $W0,
'H0' => $H0,
'W1' => $W1,
'H1' => $H1,
'W' => $W1,
'H' => $H1,
'M' => $M,
'K' => $K,
];
}
}

View File

@@ -0,0 +1,509 @@
<?php
namespace App\Helpers;
/**
* 5130 레거시 시스템 호환 계산기
*
* 5130 시스템의 견적 계산 로직을 SAM에서 동일하게 재현하기 위한 헬퍼 클래스입니다.
* 데이터 마이그레이션 후 계산 결과 동일성 검증에 사용됩니다.
*
* 주요 기능:
* - 절곡품 단가 계산 (getBendPlatePrice)
* - 모터 용량 계산 (calculateMotorSpec)
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{
// =========================================================================
// 두께 매핑 상수
// =========================================================================
/**
* EGI(아연도금) 두께 매핑
* 5130: 1.15 → 1.2, 1.55 → 1.6
*/
public const EGI_THICKNESS_MAP = [
1.15 => 1.2,
1.55 => 1.6,
];
/**
* SUS(스테인리스) 두께 매핑
* 5130: 1.15 → 1.2, 1.55 → 1.5
*/
public const SUS_THICKNESS_MAP = [
1.15 => 1.2,
1.55 => 1.5,
];
// =========================================================================
// 모터 용량 상수
// =========================================================================
/**
* 스크린 모터 용량 테이블
* [min_weight, max_weight, min_bracket_inch, max_bracket_inch] => capacity
*/
public const SCREEN_MOTOR_CAPACITY = [
// 150K: 중량 ≤20, 브라켓인치 ≤6
['weight_max' => 20, 'bracket_max' => 6, 'capacity' => '150K'],
// 200K: 중량 ≤35, 브라켓인치 ≤8
['weight_max' => 35, 'bracket_max' => 8, 'capacity' => '200K'],
// 300K: 중량 ≤50, 브라켓인치 ≤10
['weight_max' => 50, 'bracket_max' => 10, 'capacity' => '300K'],
// 400K: 중량 ≤70, 브라켓인치 ≤12
['weight_max' => 70, 'bracket_max' => 12, 'capacity' => '400K'],
// 500K: 중량 ≤90, 브라켓인치 ≤14
['weight_max' => 90, 'bracket_max' => 14, 'capacity' => '500K'],
// 600K: 그 이상
['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '600K'],
];
/**
* 철재 모터 용량 테이블
*/
public const STEEL_MOTOR_CAPACITY = [
// 300K: 중량 ≤40, 브라켓인치 ≤8
['weight_max' => 40, 'bracket_max' => 8, 'capacity' => '300K'],
// 400K: 중량 ≤60, 브라켓인치 ≤10
['weight_max' => 60, 'bracket_max' => 10, 'capacity' => '400K'],
// 500K: 중량 ≤80, 브라켓인치 ≤12
['weight_max' => 80, 'bracket_max' => 12, 'capacity' => '500K'],
// 600K: 중량 ≤100, 브라켓인치 ≤14
['weight_max' => 100, 'bracket_max' => 14, 'capacity' => '600K'],
// 800K: 중량 ≤150, 브라켓인치 ≤16
['weight_max' => 150, 'bracket_max' => 16, 'capacity' => '800K'],
// 1000K: 그 이상
['weight_max' => PHP_INT_MAX, 'bracket_max' => PHP_INT_MAX, 'capacity' => '1000K'],
];
/**
* 브라켓 사이즈 매핑 (용량 → 사이즈)
*/
public const BRACKET_SIZE_MAP = [
'150K' => ['width' => 450, 'height' => 280],
'200K' => ['width' => 480, 'height' => 300],
'300K' => ['width' => 530, 'height' => 320],
'400K' => ['width' => 530, 'height' => 320],
'500K' => ['width' => 600, 'height' => 350],
'600K' => ['width' => 600, 'height' => 350],
'800K' => ['width' => 690, 'height' => 390],
'1000K' => ['width' => 690, 'height' => 390],
];
// =========================================================================
// 두께 정규화
// =========================================================================
/**
* 두께 정규화 (5130 방식)
*
* @param string $material 재질 (EGI, SUS)
* @param float $thickness 입력 두께
* @return float 정규화된 두께
*/
public static function normalizeThickness(string $material, float $thickness): float
{
$material = strtoupper($material);
if ($material === 'EGI' && isset(self::EGI_THICKNESS_MAP[$thickness])) {
return self::EGI_THICKNESS_MAP[$thickness];
}
if ($material === 'SUS' && isset(self::SUS_THICKNESS_MAP[$thickness])) {
return self::SUS_THICKNESS_MAP[$thickness];
}
return $thickness;
}
// =========================================================================
// 면적 계산
// =========================================================================
/**
* 면적 계산 (mm² → m²)
*
* 5130 방식: (length × width) / 1,000,000
*
* @param float $length 길이 (mm)
* @param float $width 너비 (mm)
* @return float 면적 (m²)
*/
public static function calculateArea(float $length, float $width): float
{
return ($length * $width) / 1000000;
}
/**
* 면적 계산 (개구부 → 제작 사이즈)
*
* @param float $W0 개구부 폭 (mm)
* @param float $H0 개구부 높이 (mm)
* @param string $productType 제품 유형 (screen, steel)
* @return array ['W1' => 제작폭, 'H1' => 제작높이, 'area' => 면적(m²)]
*/
public static function calculateManufacturingSize(float $W0, float $H0, string $productType = 'screen'): array
{
$productType = strtolower($productType);
// 마진 값 결정
if ($productType === 'steel') {
$marginW = 110;
$marginH = 350;
} else {
// screen (기본값)
$marginW = 140;
$marginH = 350;
}
$W1 = $W0 + $marginW;
$H1 = $H0 + $marginH;
$area = self::calculateArea($W1, $H1);
return [
'W1' => $W1,
'H1' => $H1,
'area' => $area,
];
}
// =========================================================================
// 중량 계산
// =========================================================================
/**
* 중량 계산 (5130 방식)
*
* @param float $W0 개구부 폭 (mm)
* @param float $area 면적 (m²)
* @param string $productType 제품 유형 (screen, steel)
* @return float 중량 (kg)
*/
public static function calculateWeight(float $W0, float $area, string $productType = 'screen'): float
{
$productType = strtolower($productType);
if ($productType === 'steel') {
// 철재: 면적 × 25 kg/m²
return $area * 25;
}
// 스크린: (면적 × 2) + (폭(m) × 14.17)
return ($area * 2) + (($W0 / 1000) * 14.17);
}
// =========================================================================
// 절곡품 단가 계산
// =========================================================================
/**
* 절곡품 단가 계산 (5130 getBendPlatePrice 호환)
*
* @param string $material 재질 (EGI, SUS)
* @param float $thickness 두께 (mm)
* @param float $length 길이 (mm)
* @param float $width 너비 (mm)
* @param int $qty 수량
* @param float $unitPricePerM2 단위 면적당 단가 (원/m²)
* @return array ['area' => 면적, 'total' => 총액]
*/
public static function getBendPlatePrice(
string $material,
float $thickness,
float $length,
float $width,
int $qty,
float $unitPricePerM2
): array {
// 1. 두께 정규화
$normalizedThickness = self::normalizeThickness($material, $thickness);
// 2. 면적 계산 (mm² → m²)
$areaM2 = self::calculateArea($length, $width);
// 3. 총액 계산 (절삭 - Math.floor 호환)
$total = floor($unitPricePerM2 * $areaM2 * $qty);
return [
'material' => $material,
'original_thickness' => $thickness,
'normalized_thickness' => $normalizedThickness,
'length' => $length,
'width' => $width,
'area_m2' => $areaM2,
'qty' => $qty,
'unit_price_per_m2' => $unitPricePerM2,
'total' => $total,
];
}
// =========================================================================
// 모터 용량 계산
// =========================================================================
/**
* 모터 용량 계산 (5130 calculateMotorSpec 호환)
*
* @param float $weight 중량 (kg)
* @param float $bracketInch 브라켓 인치
* @param string $productType 제품 유형 (screen, steel)
* @return array ['capacity' => 용량, 'bracket_size' => 브라켓 사이즈]
*/
public static function calculateMotorSpec(float $weight, float $bracketInch, string $productType = 'screen'): array
{
$productType = strtolower($productType);
// 용량 테이블 선택
$capacityTable = ($productType === 'steel')
? self::STEEL_MOTOR_CAPACITY
: self::SCREEN_MOTOR_CAPACITY;
// 용량 결정 (경계값은 상위 용량 적용)
$capacity = null;
foreach ($capacityTable as $entry) {
if ($weight <= $entry['weight_max'] && $bracketInch <= $entry['bracket_max']) {
$capacity = $entry['capacity'];
break;
}
}
// 기본값 (마지막 항목)
if ($capacity === null) {
$lastEntry = end($capacityTable);
$capacity = $lastEntry['capacity'];
}
// 브라켓 사이즈 조회
$bracketSize = self::BRACKET_SIZE_MAP[$capacity] ?? ['width' => 530, 'height' => 320];
return [
'product_type' => $productType,
'weight' => $weight,
'bracket_inch' => $bracketInch,
'capacity' => $capacity,
'bracket_size' => $bracketSize,
'bracket_dimensions' => "{$bracketSize['width']}×{$bracketSize['height']}",
];
}
/**
* 품목 코드로 제품 유형 판별 (5130 방식)
*
* @param string $itemCode 품목 코드 (예: KS-001, ST-002)
* @return string 제품 유형 (screen, steel)
*/
public static function detectProductType(string $itemCode): string
{
$prefix = strtoupper(substr($itemCode, 0, 2));
// KS, KW로 시작하면 스크린
if (in_array($prefix, ['KS', 'KW'])) {
return 'screen';
}
// 그 외는 철재
return 'steel';
}
// =========================================================================
// 가이드레일/샤프트/파이프 계산
// =========================================================================
/**
* 가이드레일 수량 계산 (5130 calculateGuidrail 호환)
*
* @param float $height 높이 (mm)
* @param float $standardLength 기본 길이 (mm, 기본값 3490)
* @return int 가이드레일 수량
*/
public static function calculateGuiderailQty(float $height, float $standardLength = 3490): int
{
if ($standardLength <= 0) {
return 1;
}
return (int) ceil($height / $standardLength);
}
// =========================================================================
// 비인정 자재 단가 계산
// =========================================================================
/**
* 비인정 스크린 단가 계산
*
* @param float $width 너비 (mm)
* @param float $height 높이 (mm)
* @param int $qty 수량
* @param float $unitPricePerM2 단위 면적당 단가 (원/m²)
* @return array ['area' => 면적, 'total' => 총액]
*/
public static function calculateUnapprovedScreenPrice(
float $width,
float $height,
int $qty,
float $unitPricePerM2
): array {
$areaM2 = self::calculateArea($width, $height);
$total = floor($unitPricePerM2 * $areaM2 * $qty);
return [
'width' => $width,
'height' => $height,
'area_m2' => $areaM2,
'qty' => $qty,
'unit_price_per_m2' => $unitPricePerM2,
'total' => $total,
];
}
/**
* 철재 스라트 비인정 단가 계산
*
* @param string $type 유형 (방화셔터, 방범셔터, 단열셔터, 이중파이프, 조인트바)
* @param float $width 너비 (mm)
* @param float $height 높이 (mm)
* @param int $qty 수량
* @param float $unitPrice 단가
* @return array ['surang' => 수량, 'total' => 총액]
*/
public static function calculateUnapprovedSteelSlatPrice(
string $type,
float $width,
float $height,
int $qty,
float $unitPrice
): array {
// 면적 기준 유형
$areaBased = ['방화셔터', '방범셔터', '단열셔터', '이중파이프'];
if (in_array($type, $areaBased)) {
// 면적 × 수량
$areaM2 = self::calculateArea($width, $height);
$surang = $areaM2 * $qty;
} else {
// 수량 기준 (조인트바 등)
$surang = $qty;
}
$total = floor($unitPrice * $surang);
return [
'type' => $type,
'width' => $width,
'height' => $height,
'qty' => $qty,
'surang' => $surang,
'unit_price' => $unitPrice,
'total' => $total,
];
}
// =========================================================================
// 전체 견적 계산 (통합)
// =========================================================================
/**
* 전체 견적 계산 (5130 호환 모드)
*
* 5130의 계산 로직을 그대로 재현하여 동일한 결과를 반환합니다.
*
* @param float $W0 개구부 폭 (mm)
* @param float $H0 개구부 높이 (mm)
* @param int $qty 수량
* @param string $productType 제품 유형 (screen, steel)
* @return array 계산 결과
*/
public static function calculateEstimate(
float $W0,
float $H0,
int $qty,
string $productType = 'screen'
): array {
// 1. 제작 사이즈 계산
$size = self::calculateManufacturingSize($W0, $H0, $productType);
$W1 = $size['W1'];
$H1 = $size['H1'];
$area = $size['area'];
// 2. 중량 계산
$weight = self::calculateWeight($W0, $area, $productType);
// 3. 브라켓 인치 계산 (폭 기준, 25.4mm = 1inch)
$bracketInch = ceil($W1 / 25.4);
// 4. 모터 용량 계산
$motorSpec = self::calculateMotorSpec($weight, $bracketInch, $productType);
return [
'input' => [
'W0' => $W0,
'H0' => $H0,
'qty' => $qty,
'product_type' => $productType,
],
'calculated' => [
'W1' => $W1,
'H1' => $H1,
'area_m2' => round($area, 4),
'weight_kg' => round($weight, 2),
'bracket_inch' => $bracketInch,
],
'motor' => $motorSpec,
];
}
// =========================================================================
// 검증 유틸리티
// =========================================================================
/**
* 5130 계산 결과와 비교 검증
*
* @param array $samResult SAM 계산 결과
* @param array $legacy5130Result 5130 계산 결과
* @param float $tolerance 허용 오차 (기본 0.01 = 1%)
* @return array ['match' => bool, 'differences' => array]
*/
public static function validateAgainstLegacy(
array $samResult,
array $legacy5130Result,
float $tolerance = 0.01
): array {
$differences = [];
// 비교할 필드 목록
$compareFields = ['W1', 'H1', 'area_m2', 'weight_kg', 'total'];
foreach ($compareFields as $field) {
$samValue = $samResult[$field] ?? $samResult['calculated'][$field] ?? null;
$legacyValue = $legacy5130Result[$field] ?? null;
if ($samValue === null || $legacyValue === null) {
continue;
}
$diff = abs($samValue - $legacyValue);
$percentDiff = $legacyValue != 0 ? ($diff / abs($legacyValue)) : ($diff > 0 ? 1 : 0);
if ($percentDiff > $tolerance) {
$differences[$field] = [
'sam' => $samValue,
'legacy' => $legacyValue,
'diff' => $diff,
'percent_diff' => round($percentDiff * 100, 2).'%',
];
}
}
return [
'match' => empty($differences),
'differences' => $differences,
];
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests\Construction;
use App\Models\Construction\Contract;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ContractFromBiddingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
// 기본 정보 (입찰에서 가져오므로 optional)
'project_name' => 'nullable|string|max:255',
// 거래처 정보 (입찰에서 가져오므로 optional)
'partner_id' => 'nullable|integer',
'partner_name' => 'nullable|string|max:255',
// 담당자 정보
'contract_manager_id' => 'nullable|integer',
'contract_manager_name' => 'nullable|string|max:100',
'construction_pm_id' => 'nullable|integer',
'construction_pm_name' => 'nullable|string|max:100',
// 계약 상세 (입찰에서 가져오므로 optional)
'total_locations' => 'nullable|integer|min:0',
'contract_amount' => 'nullable|numeric|min:0',
'contract_start_date' => 'nullable|date',
'contract_end_date' => 'nullable|date|after_or_equal:contract_start_date',
// 상태 정보
'status' => [
'nullable',
Rule::in([Contract::STATUS_PENDING, Contract::STATUS_COMPLETED]),
],
'stage' => [
'nullable',
Rule::in([
Contract::STAGE_ESTIMATE_SELECTED,
Contract::STAGE_ESTIMATE_PROGRESS,
Contract::STAGE_DELIVERY,
Contract::STAGE_INSTALLATION,
Contract::STAGE_INSPECTION,
Contract::STAGE_OTHER,
]),
],
// 기타
'remarks' => 'nullable|string',
'is_active' => 'nullable|boolean',
];
}
public function messages(): array
{
return [
'contract_end_date.after_or_equal' => __('validation.after_or_equal', ['attribute' => '계약종료일', 'date' => '계약시작일']),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->foreignId('work_order_id')
->nullable()
->after('order_id')
->comment('작업지시 ID');
$table->index(['tenant_id', 'work_order_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('shipments', function (Blueprint $table) {
$table->dropIndex(['tenant_id', 'work_order_id']);
$table->dropColumn('work_order_id');
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
/**
* 5130 output.num → SAM orders.id 매핑 테이블
* 마이그레이션 추적 및 롤백을 위해 사용
*/
public function up(): void
{
Schema::create('order_id_mappings', function (Blueprint $table) {
$table->id();
$table->bigInteger('source_num')->unsigned()->comment('5130 output.num');
$table->bigInteger('order_id')->unsigned()->comment('SAM orders.id');
$table->timestamps();
$table->unique('source_num');
$table->index('order_id');
$table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('order_id_mappings');
}
};