Files
sam-api/app/Console/Commands/Migrate5130Orders.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

788 lines
27 KiB
PHP

<?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']],
]
);
}
}