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:
773
app/Console/Commands/Migrate5130BendingStock.php
Normal file
773
app/Console/Commands/Migrate5130BendingStock.php
Normal 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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user