feat: 5130 레거시 마이그레이션 커맨드 및 관련 파일 추가
- Migrate5130Bom: 완제품 BOM 템플릿 마이그레이션 (61건) - Migrate5130Orders: 주문 데이터 마이그레이션 - Migrate5130PriceItems: 품목 데이터 마이그레이션 - Verify5130Calculation: 견적 계산 검증 커맨드 - Legacy5130Calculator: 레거시 계산 헬퍼 - ContractFromBiddingRequest: 입찰→계약 전환 요청 - 마이그레이션: shipments.work_order_id, order_id_mappings 테이블
This commit is contained in:
235
app/Console/Commands/Migrate5130Bom.php
Normal file
235
app/Console/Commands/Migrate5130Bom.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
787
app/Console/Commands/Migrate5130Orders.php
Normal file
787
app/Console/Commands/Migrate5130Orders.php
Normal 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']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
582
app/Console/Commands/Migrate5130PriceItems.php
Normal file
582
app/Console/Commands/Migrate5130PriceItems.php
Normal 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}개 품목 마이그레이션 완료!");
|
||||||
|
}
|
||||||
|
}
|
||||||
217
app/Console/Commands/Verify5130Calculation.php
Normal file
217
app/Console/Commands/Verify5130Calculation.php
Normal 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']} m²"],
|
||||||
|
['중량 (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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
509
app/Helpers/Legacy5130Calculator.php
Normal file
509
app/Helpers/Legacy5130Calculator.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' => '계약시작일']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user