feat(WEB): 5130 레거시 절곡품 재고 마이그레이션 커맨드 추가

- php artisan migrate:5130-bending-stock 커맨드 생성
- 5130 lot 테이블 → SAM stocks + stock_lots 마이그레이션
- 5130 bending_work_log → SAM stock_transactions(OUT) 마이그레이션
- prod+spec+slength 3코드 → BD-{PROD}{SPEC}-{SLENGTH} 아이템 코드 매핑
- --dry-run 시뮬레이션, --rollback 롤백 지원
- 기존 BD- 아이템 item_category='BENDING' 자동 업데이트
- FIFO 기반 LOT 수량 차감 및 Stock 집계 갱신

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:19:03 +09:00
parent 4f777d8cf9
commit 9c88138de8

View File

@@ -0,0 +1,773 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 재고(lot, bending_work_log)를 SAM stocks/stock_lots/stock_transactions로 마이그레이션')]
class Migrate5130BendingStock extends Command
{
protected $signature = 'migrate:5130-bending-stock
{--tenant_id=287 : Target tenant ID (default: 287 경동기업)}
{--dry-run : 실제 저장 없이 시뮬레이션만 수행}
{--rollback : 마이그레이션 롤백 (migration 소스 데이터 삭제)}';
private string $sourceDb = 'chandj';
private string $targetDb = 'mysql';
// 5130 prod 코드 → 한글명
private array $prodNames = [
'R' => '가이드레일(벽면)',
'S' => '가이드레일(측면)',
'G' => '연기차단재',
'B' => '하단마감재(스크린)',
'T' => '하단마감재(철재)',
'L' => 'L-Bar',
'C' => '케이스',
];
// 5130 spec 코드 → 한글명
private array $specNames = [
'I' => '화이바원단',
'S' => 'SUS',
'U' => 'SUS2',
'E' => 'EGI',
'A' => '스크린용',
'D' => 'D형',
'C' => 'C형',
'M' => '본체',
'T' => '본체(철재)',
'B' => '후면코너부',
'L' => '린텔부',
'P' => '점검구',
'F' => '전면부',
];
// 5130 slength 코드 → 한글명
private array $slengthNames = [
'53' => 'W50×3000',
'54' => 'W50×4000',
'83' => 'W80×3000',
'84' => 'W80×4000',
'12' => '1219mm',
'24' => '2438mm',
'30' => '3000mm',
'35' => '3500mm',
'40' => '4000mm',
'41' => '4150mm',
'42' => '4200mm',
'43' => '4300mm',
];
// 통계
private array $stats = [
'items_updated' => 0,
'items_created' => 0,
'stocks_created' => 0,
'lots_created' => 0,
'transactions_in' => 0,
'transactions_out' => 0,
'skipped_lots' => 0,
];
public function handle(): int
{
$tenantId = (int) $this->option('tenant_id');
$dryRun = $this->option('dry-run');
$rollback = $this->option('rollback');
$this->info('=== 5130 → SAM 절곡품 재고 마이그레이션 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
$this->newLine();
if ($rollback) {
return $this->rollbackMigration($tenantId, $dryRun);
}
// 1. 5130 데이터 읽기
$this->info('📥 Step 1: 5130 레거시 데이터 읽기...');
$lots = $this->readLegacyLots();
$workLogs = $this->readLegacyWorkLogs();
$this->info(" - lot 레코드: {$lots->count()}");
$this->info(" - bending_work_log 레코드: {$workLogs->count()}");
$this->newLine();
if ($lots->isEmpty()) {
$this->warn('5130 lot 데이터가 없습니다. 마이그레이션을 종료합니다.');
return self::SUCCESS;
}
// 2. 재고 요약 계산
$this->info('📊 Step 2: 재고 현황 계산...');
$stockSummary = $this->calculateStockSummary($lots, $workLogs);
$this->showSummary($stockSummary);
$this->newLine();
// 3. 기존 BD- 아이템 item_category 업데이트 현황
$this->info('🏷️ Step 3: 기존 BD- 아이템 카테고리 업데이트...');
$this->updateExistingBdItems($tenantId, $dryRun);
$this->newLine();
if ($dryRun) {
$this->showStats();
$this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.');
return self::SUCCESS;
}
if (! $this->confirm('마이그레이션을 진행하시겠습니까?')) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
// 4. 실행
$this->info('🚀 Step 4: 마이그레이션 실행...');
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lots, $workLogs, $stockSummary) {
$this->executeMigration($tenantId, $lots, $workLogs, $stockSummary);
});
$this->newLine();
$this->showStats();
$this->info('✅ 마이그레이션 완료!');
return self::SUCCESS;
}
/**
* 5130 lot 테이블 데이터 읽기
*/
private function readLegacyLots(): \Illuminate\Support\Collection
{
return DB::connection($this->sourceDb)
->table('lot')
->where('is_deleted', 0)
->whereNotNull('prod')
->where('prod', '!=', '')
->whereNotNull('surang')
->where('surang', '>', 0)
->select('num', 'reg_date', 'lot_number', 'prod', 'spec', 'slength', 'surang', 'rawLot', 'author', 'remark')
->orderBy('reg_date')
->orderBy('num')
->get();
}
/**
* 5130 bending_work_log 테이블 데이터 읽기
*/
private function readLegacyWorkLogs(): \Illuminate\Support\Collection
{
return DB::connection($this->sourceDb)
->table('bending_work_log')
->where('is_deleted', 0)
->whereNotNull('prod_code')
->where('prod_code', '!=', '')
->where('quantity', '>', 0)
->select('id', 'work_date', 'work_order_no', 'prod_code', 'spec_code', 'slength_code', 'quantity', 'unit', 'work_type', 'worker', 'remark', 'created_at')
->orderBy('work_date')
->orderBy('id')
->get();
}
/**
* prod+spec+slength 기준 재고 요약 계산
*/
private function calculateStockSummary(\Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs): array
{
$summary = [];
// 입고(lot) 합산
foreach ($lots as $lot) {
$key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength);
if (! isset($summary[$key])) {
$summary[$key] = [
'prod' => $lot->prod,
'spec' => $lot->spec,
'slength' => $lot->slength,
'total_in' => 0,
'total_out' => 0,
'lot_count' => 0,
];
}
$summary[$key]['total_in'] += (float) $lot->surang;
$summary[$key]['lot_count']++;
}
// 출고(bending_work_log) 합산
foreach ($workLogs as $log) {
$key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code);
if (! isset($summary[$key])) {
$summary[$key] = [
'prod' => $log->prod_code,
'spec' => $log->spec_code,
'slength' => $log->slength_code,
'total_in' => 0,
'total_out' => 0,
'lot_count' => 0,
];
}
$summary[$key]['total_out'] += (float) $log->quantity;
}
return $summary;
}
/**
* 재고 요약 표시
*/
private function showSummary(array $summary): void
{
$headers = ['코드', '품목명', '입고합계', '출고합계', '현재고', 'LOT수'];
$rows = [];
foreach ($summary as $key => $item) {
$netStock = $item['total_in'] - $item['total_out'];
$rows[] = [
$key,
$this->makeItemName($item['prod'], $item['spec'], $item['slength']),
number_format($item['total_in']),
number_format($item['total_out']),
number_format($netStock),
$item['lot_count'],
];
}
$this->table($headers, $rows);
$this->info(' - 품목 종류: '.count($summary).'개');
}
/**
* 기존 BD- 아이템의 item_category를 BENDING으로 업데이트
*/
private function updateExistingBdItems(int $tenantId, bool $dryRun): void
{
$count = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->where(function ($q) {
$q->whereNull('item_category')
->orWhere('item_category', '!=', 'BENDING');
})
->count();
$this->info(" - BD- 아이템 중 item_category 미설정: {$count}");
if ($count > 0 && ! $dryRun) {
DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', 'like', 'BD-%')
->whereNull('deleted_at')
->where(function ($q) {
$q->whereNull('item_category')
->orWhere('item_category', '!=', 'BENDING');
})
->update(['item_category' => 'BENDING', 'updated_at' => now()]);
$this->info("{$count}건 업데이트 완료");
}
$this->stats['items_updated'] = $count;
}
/**
* 마이그레이션 실행
*/
private function executeMigration(int $tenantId, \Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs, array $stockSummary): void
{
$itemMap = []; // productKey => item_id
$stockMap = []; // productKey => stock_id
$lotMap = []; // productKey => [stock_lot_ids]
// 1. 각 제품 조합별 아이템 & 스톡 생성
$this->info(' 📦 아이템 & 재고 레코드 생성...');
foreach ($stockSummary as $key => $data) {
$itemCode = $this->makeItemCode($data['prod'], $data['spec'], $data['slength']);
$itemName = $this->makeItemName($data['prod'], $data['spec'], $data['slength']);
// 아이템 조회 또는 생성
$item = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('code', $itemCode)
->whereNull('deleted_at')
->first();
if (! $item) {
$itemId = DB::connection($this->targetDb)->table('items')->insertGetId([
'tenant_id' => $tenantId,
'code' => $itemCode,
'name' => $itemName,
'item_type' => 'PT',
'item_category' => 'BENDING',
'unit' => 'EA',
'source' => '5130_migration',
'options' => json_encode([
'lot_managed' => true,
'consumption_method' => 'auto',
'production_source' => 'self_produced',
'input_tracking' => true,
'legacy_prod' => $data['prod'],
'legacy_spec' => $data['spec'],
'legacy_slength' => $data['slength'],
]),
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['items_created']++;
$this->line(" + 아이템 생성: {$itemCode} ({$itemName})");
} else {
$itemId = $item->id;
$this->line(" ✓ 기존 아이템 사용: {$itemCode}");
}
$itemMap[$key] = $itemId;
// Stock 레코드 조회 또는 생성
$stock = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->where('item_id', $itemId)
->whereNull('deleted_at')
->first();
if (! $stock) {
$stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([
'tenant_id' => $tenantId,
'item_id' => $itemId,
'item_code' => $itemCode,
'item_name' => $itemName,
'item_type' => 'bent_part',
'unit' => 'EA',
'stock_qty' => 0,
'safety_stock' => 0,
'reserved_qty' => 0,
'available_qty' => 0,
'lot_count' => 0,
'status' => 'out',
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['stocks_created']++;
} else {
$stockId = $stock->id;
}
$stockMap[$key] = $stockId;
}
// 2. LOT 데이터 마이그레이션 (입고)
$this->newLine();
$this->info(' 📋 LOT 데이터 마이그레이션 (입고)...');
$fifoCounters = []; // stockId => current fifo_order
foreach ($lots as $lot) {
$key = $this->makeProductKey($lot->prod, $lot->spec, $lot->slength);
if (! isset($stockMap[$key])) {
$this->stats['skipped_lots']++;
continue;
}
$stockId = $stockMap[$key];
$itemCode = $this->makeItemCode($lot->prod, $lot->spec, $lot->slength);
$itemName = $this->makeItemName($lot->prod, $lot->spec, $lot->slength);
// FIFO 순서 계산
if (! isset($fifoCounters[$stockId])) {
$existing = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->max('fifo_order');
$fifoCounters[$stockId] = ($existing ?? 0);
}
$fifoCounters[$stockId]++;
// LOT 번호 생성 (5130 lot_number 사용, 없으면 생성)
$lotNo = $lot->lot_number;
if (empty($lotNo)) {
$regDate = $lot->reg_date ? Carbon::parse($lot->reg_date)->format('ymd') : Carbon::now()->format('ymd');
$lotNo = "5130-{$regDate}-{$lot->num}";
}
// 중복 체크
$existingLot = DB::connection($this->targetDb)
->table('stock_lots')
->where('tenant_id', $tenantId)
->where('stock_id', $stockId)
->where('lot_no', $lotNo)
->whereNull('deleted_at')
->first();
if ($existingLot) {
$this->stats['skipped_lots']++;
continue;
}
$qty = (float) $lot->surang;
$receiptDate = $lot->reg_date ?? now()->toDateString();
$stockLotId = DB::connection($this->targetDb)->table('stock_lots')->insertGetId([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'lot_no' => $lotNo,
'fifo_order' => $fifoCounters[$stockId],
'receipt_date' => $receiptDate,
'qty' => $qty,
'reserved_qty' => 0,
'available_qty' => $qty,
'unit' => 'EA',
'supplier' => null,
'supplier_lot' => $lot->rawLot ?: null,
'location' => null,
'status' => 'available',
'receiving_id' => null,
'work_order_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
// 입고 트랜잭션 기록
// balance_qty는 나중에 refreshFromLots 후 업데이트되므로 임시로 qty 사용
DB::connection($this->targetDb)->table('stock_transactions')->insert([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'stock_lot_id' => $stockLotId,
'type' => 'IN',
'qty' => $qty,
'balance_qty' => 0, // 나중에 갱신
'reference_type' => 'migration',
'reference_id' => $lot->num,
'lot_no' => $lotNo,
'reason' => 'receiving',
'remark' => '5130 레거시 마이그레이션 (lot.num='.$lot->num.')',
'item_code' => $itemCode,
'item_name' => $itemName,
'created_at' => $receiptDate,
]);
$this->stats['lots_created']++;
$this->stats['transactions_in']++;
if (! isset($lotMap[$key])) {
$lotMap[$key] = [];
}
$lotMap[$key][] = $stockLotId;
}
$this->info(" ✅ LOT {$this->stats['lots_created']}건 생성");
// 3. 출고 로그 마이그레이션 (bending_work_log → stock_transactions OUT)
$this->newLine();
$this->info(' 📋 출고 로그 마이그레이션...');
foreach ($workLogs as $log) {
$key = $this->makeProductKey($log->prod_code, $log->spec_code, $log->slength_code);
if (! isset($stockMap[$key])) {
continue;
}
$stockId = $stockMap[$key];
$itemCode = $this->makeItemCode($log->prod_code, $log->spec_code, $log->slength_code);
$itemName = $this->makeItemName($log->prod_code, $log->spec_code, $log->slength_code);
$qty = (float) $log->quantity;
$workDate = $log->work_date ?? ($log->created_at ?? now()->toDateString());
DB::connection($this->targetDb)->table('stock_transactions')->insert([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'stock_lot_id' => null, // 레거시 데이터는 특정 LOT 추적 불가
'type' => 'OUT',
'qty' => -$qty,
'balance_qty' => 0, // 나중에 갱신
'reference_type' => 'migration',
'reference_id' => $log->id,
'lot_no' => null,
'reason' => 'work_order_input',
'remark' => '5130 레거시 마이그레이션 (bending_work_log.id='.$log->id.', worker='.$log->worker.')',
'item_code' => $itemCode,
'item_name' => $itemName,
'created_at' => $workDate,
]);
$this->stats['transactions_out']++;
}
$this->info(" ✅ 출고 트랜잭션 {$this->stats['transactions_out']}건 생성");
// 4. LOT 수량 조정 (출고분 차감)
$this->newLine();
$this->info(' 🔄 LOT 수량 조정 (출고분 FIFO 차감)...');
foreach ($stockSummary as $key => $data) {
if (! isset($stockMap[$key])) {
continue;
}
$stockId = $stockMap[$key];
$totalOut = $data['total_out'];
if ($totalOut <= 0) {
continue;
}
// FIFO 순서로 LOT에서 차감
$remainingOut = $totalOut;
$stockLots = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->orderBy('fifo_order')
->get();
foreach ($stockLots as $sl) {
if ($remainingOut <= 0) {
break;
}
$lotQty = (float) $sl->qty;
$deduct = min($lotQty, $remainingOut);
$newQty = $lotQty - $deduct;
$remainingOut -= $deduct;
$status = $newQty <= 0 ? 'used' : 'available';
DB::connection($this->targetDb)
->table('stock_lots')
->where('id', $sl->id)
->update([
'qty' => max(0, $newQty),
'available_qty' => max(0, $newQty),
'status' => $status,
'updated_at' => now(),
]);
}
if ($remainingOut > 0) {
$this->warn(" ⚠️ {$key}: 출고량이 입고량보다 {$remainingOut}만큼 초과 (데이터 불일치)");
}
}
// 5. Stock 집계 갱신
$this->newLine();
$this->info(' 📊 Stock 집계 갱신...');
foreach ($stockMap as $key => $stockId) {
$this->refreshStockFromLots($stockId, $tenantId);
}
// 6. balance_qty 갱신 (트랜잭션별 잔량)
$this->info(' 📊 트랜잭션 잔량(balance_qty) 갱신...');
foreach ($stockMap as $key => $stockId) {
$stock = DB::connection($this->targetDb)
->table('stocks')
->where('id', $stockId)
->first();
if ($stock) {
DB::connection($this->targetDb)
->table('stock_transactions')
->where('stock_id', $stockId)
->where('tenant_id', $tenantId)
->update(['balance_qty' => $stock->stock_qty]);
}
}
$this->info(' ✅ 집계 갱신 완료');
}
/**
* Stock 집계 갱신 (LOT 기반)
*/
private function refreshStockFromLots(int $stockId, int $tenantId): void
{
$lotStats = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->selectRaw('
COALESCE(SUM(qty), 0) as total_qty,
COALESCE(SUM(reserved_qty), 0) as total_reserved,
COALESCE(SUM(available_qty), 0) as total_available,
COUNT(*) as lot_count,
MIN(receipt_date) as oldest_lot_date,
MAX(receipt_date) as latest_receipt_date
')
->first();
$stockQty = (float) $lotStats->total_qty;
$reservedQty = (float) $lotStats->total_reserved;
$availableQty = (float) $lotStats->total_available;
$status = 'normal';
if ($stockQty <= 0) {
$status = 'out';
}
DB::connection($this->targetDb)
->table('stocks')
->where('id', $stockId)
->update([
'stock_qty' => $stockQty,
'reserved_qty' => $reservedQty,
'available_qty' => $availableQty,
'lot_count' => (int) $lotStats->lot_count,
'oldest_lot_date' => $lotStats->oldest_lot_date,
'last_receipt_date' => $lotStats->latest_receipt_date,
'status' => $status,
'updated_at' => now(),
]);
}
/**
* 롤백: migration 참조 데이터 삭제
*/
private function rollbackMigration(int $tenantId, bool $dryRun): int
{
$this->warn('⚠️ 롤백: migration 소스 데이터를 삭제합니다.');
// 마이그레이션으로 생성된 트랜잭션
$txCount = DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'migration')
->count();
// 마이그레이션으로 생성된 아이템 (source = '5130_migration')
$itemCount = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('source', '5130_migration')
->whereNull('deleted_at')
->count();
$this->info(' 삭제 대상:');
$this->info(" - stock_transactions (reference_type=migration): {$txCount}");
$this->info(" - items (source=5130_migration): {$itemCount}");
if ($dryRun) {
$this->info('DRY RUN - 실제 삭제 없음');
return self::SUCCESS;
}
if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) {
return self::SUCCESS;
}
DB::connection($this->targetDb)->transaction(function () use ($tenantId) {
// 1. 트랜잭션 삭제
DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'migration')
->delete();
// 2. migration으로 생성된 아이템의 stock_lots 삭제
$migrationItemIds = DB::connection($this->targetDb)
->table('items')
->where('tenant_id', $tenantId)
->where('source', '5130_migration')
->whereNull('deleted_at')
->pluck('id');
if ($migrationItemIds->isNotEmpty()) {
$stockIds = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $migrationItemIds)
->pluck('id');
if ($stockIds->isNotEmpty()) {
DB::connection($this->targetDb)
->table('stock_lots')
->whereIn('stock_id', $stockIds)
->delete();
DB::connection($this->targetDb)
->table('stocks')
->whereIn('id', $stockIds)
->delete();
}
// 3. 아이템 삭제
DB::connection($this->targetDb)
->table('items')
->whereIn('id', $migrationItemIds)
->delete();
}
});
$this->info('✅ 롤백 완료');
return self::SUCCESS;
}
/**
* 5130 코드 → 제품 키 생성
*/
private function makeProductKey(string $prod, ?string $spec, ?string $slength): string
{
return trim($prod).'-'.trim($spec ?? '').'-'.trim($slength ?? '');
}
/**
* 5130 코드 → SAM 아이템 코드 생성
* 형식: BD-{PROD}{SPEC}-{SLENGTH} (예: BD-RS-40)
*/
private function makeItemCode(string $prod, ?string $spec, ?string $slength): string
{
$p = trim($prod);
$s = trim($spec ?? '');
$l = trim($slength ?? '');
return "BD-{$p}{$s}-{$l}";
}
/**
* 5130 코드 → 사람이 읽을 수 있는 아이템명 생성
*/
private function makeItemName(string $prod, ?string $spec, ?string $slength): string
{
$prodName = $this->prodNames[trim($prod)] ?? trim($prod);
$specName = $this->specNames[trim($spec ?? '')] ?? trim($spec ?? '');
$slengthName = $this->slengthNames[trim($slength ?? '')] ?? trim($slength ?? '');
$parts = array_filter([$prodName, $specName, $slengthName]);
return implode(' ', $parts);
}
/**
* 통계 출력
*/
private function showStats(): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 마이그레이션 통계');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info(" 아이템 카테고리 업데이트: {$this->stats['items_updated']}");
$this->info(" 아이템 신규 생성: {$this->stats['items_created']}");
$this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}");
$this->info(" StockLot 생성: {$this->stats['lots_created']}");
$this->info(" 입고 트랜잭션: {$this->stats['transactions_in']}");
$this->info(" 출고 트랜잭션: {$this->stats['transactions_out']}");
$this->info(" 스킵된 LOT: {$this->stats['skipped_lots']}");
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
}