diff --git a/app/Console/Commands/Migrate5130BendingStock.php b/app/Console/Commands/Migrate5130BendingStock.php index 9cd10ce..280c889 100644 --- a/app/Console/Commands/Migrate5130BendingStock.php +++ b/app/Console/Commands/Migrate5130BendingStock.php @@ -4,16 +4,16 @@ 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로 마이그레이션')] +#[AsCommand(name: 'migrate:5130-bending-stock', description: '5130 레거시 절곡품 코드 생성 + BD-* 전체 품목 초기 재고 셋팅')] class Migrate5130BendingStock extends Command { protected $signature = 'migrate:5130-bending-stock {--tenant_id=287 : Target tenant ID (default: 287 경동기업)} {--dry-run : 실제 저장 없이 시뮬레이션만 수행} - {--rollback : 마이그레이션 롤백 (migration 소스 데이터 삭제)}'; + {--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)} + {--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}'; private string $sourceDb = 'chandj'; @@ -21,57 +21,35 @@ class Migrate5130BendingStock extends Command // 5130 prod 코드 → 한글명 private array $prodNames = [ - 'R' => '가이드레일(벽면)', - 'S' => '가이드레일(측면)', - 'G' => '연기차단재', - 'B' => '하단마감재(스크린)', - 'T' => '하단마감재(철재)', - 'L' => 'L-Bar', - 'C' => '케이스', + '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' => '전면부', + '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', + '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, + 'items_found' => 0, + 'items_created_5130' => 0, + 'items_category_updated' => 0, 'stocks_created' => 0, + 'stocks_skipped' => 0, 'lots_created' => 0, - 'transactions_in' => 0, - 'transactions_out' => 0, - 'skipped_lots' => 0, + 'transactions_created' => 0, ]; public function handle(): int @@ -79,275 +57,214 @@ public function handle(): int $tenantId = (int) $this->option('tenant_id'); $dryRun = $this->option('dry-run'); $rollback = $this->option('rollback'); + $minStock = (int) $this->option('min-stock'); - $this->info('=== 5130 → SAM 절곡품 재고 마이그레이션 ==='); + $this->info('=== BD-* 절곡품 초기 재고 셋팅 ==='); $this->info("Tenant ID: {$tenantId}"); $this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); + $this->info("초기 재고: {$minStock}개/품목"); $this->newLine(); if ($rollback) { - return $this->rollbackMigration($tenantId, $dryRun); + return $this->rollbackInitStock($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()}건"); + // 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 + $this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...'); + $this->createLegacyItems($tenantId, $dryRun); $this->newLine(); - if ($lots->isEmpty()) { - $this->warn('5130 lot 데이터가 없습니다. 마이그레이션을 종료합니다.'); + // 1. 전체 BD-* 아이템 조회 (기존 58개 + 5130 생성분) + $this->info('📥 Step 1: BD-* 절곡품 품목 조회...'); + $items = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->select('id', 'code', 'name', 'item_type', 'item_category', 'unit', 'options') + ->orderBy('code') + ->get(); + + $this->stats['items_found'] = $items->count(); + $this->info(" - BD-* 품목: {$items->count()}건"); + + if ($items->isEmpty()) { + $this->warn('BD-* 품목이 없습니다. 종료합니다.'); return self::SUCCESS; } - // 2. 재고 요약 계산 - $this->info('📊 Step 2: 재고 현황 계산...'); - $stockSummary = $this->calculateStockSummary($lots, $workLogs); - $this->showSummary($stockSummary); + // 2. item_category 미설정 품목 업데이트 $this->newLine(); + $this->info('🏷️ Step 2: item_category 업데이트...'); + $needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING'); - // 3. 기존 BD- 아이템 item_category 업데이트 현황 - $this->info('🏷️ Step 3: 기존 BD- 아이템 카테고리 업데이트...'); - $this->updateExistingBdItems($tenantId, $dryRun); + if ($needsCategoryUpdate->isNotEmpty()) { + $this->info(" - item_category 미설정/불일치: {$needsCategoryUpdate->count()}건"); + if (! $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->stats['items_category_updated'] = $needsCategoryUpdate->count(); + } else { + $this->info(' - 모든 품목 BENDING 카테고리 설정 완료'); + } + + // 3. 현재 재고 현황 표시 $this->newLine(); + $this->info('📊 Step 3: 현재 재고 현황...'); + $this->showCurrentStockStatus($tenantId, $items); + + // 4. 재고 셋팅 대상 확인 + $this->newLine(); + $this->info('📦 Step 4: 재고 셋팅 대상 확인...'); + $itemsNeedingStock = $this->getItemsNeedingStock($tenantId, $items, $minStock); + + if ($itemsNeedingStock->isEmpty()) { + $this->info(" - 모든 품목이 이미 {$minStock}개 이상 재고 보유. 추가 작업 불필요."); + $this->showStats(); + + return self::SUCCESS; + } + + $this->info(" - 재고 셋팅 필요: {$itemsNeedingStock->count()}건"); + $this->table( + ['코드', '품목명', '현재고', '목표', '추가수량'], + $itemsNeedingStock->map(fn ($item) => [ + $item->code, + mb_strlen($item->name) > 30 ? mb_substr($item->name, 0, 30).'...' : $item->name, + number_format($item->current_qty), + number_format($minStock), + number_format($item->supplement_qty), + ])->toArray() + ); if ($dryRun) { + $this->stats['stocks_created'] = $itemsNeedingStock->filter(fn ($i) => ! $i->has_stock)->count(); + $this->stats['lots_created'] = $itemsNeedingStock->count(); + $this->stats['transactions_created'] = $itemsNeedingStock->count(); $this->showStats(); $this->info('🔍 DRY RUN 완료. 실제 실행은 --dry-run 플래그를 제거하세요.'); return self::SUCCESS; } - if (! $this->confirm('마이그레이션을 진행하시겠습니까?')) { + 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); + // 5. 실행 + $this->newLine(); + $this->info('🚀 Step 5: 초기 재고 셋팅 실행...'); + DB::connection($this->targetDb)->transaction(function () use ($tenantId, $itemsNeedingStock, $minStock) { + $this->executeStockSetup($tenantId, $itemsNeedingStock, $minStock); }); $this->newLine(); $this->showStats(); - $this->info('✅ 마이그레이션 완료!'); + $this->info('✅ 초기 재고 셋팅 완료!'); return self::SUCCESS; } /** - * 5130 lot 테이블 데이터 읽기 + * 현재 재고 현황 표시 */ - private function readLegacyLots(): \Illuminate\Support\Collection + private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void { - 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(); - } + $itemIds = $items->pluck('id'); - /** - * 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') + $stocks = DB::connection($this->targetDb) + ->table('stocks') ->where('tenant_id', $tenantId) - ->where('code', 'like', 'BD-%') + ->whereIn('item_id', $itemIds) ->whereNull('deleted_at') - ->where(function ($q) { - $q->whereNull('item_category') - ->orWhere('item_category', '!=', 'BENDING'); - }) - ->count(); + ->get() + ->keyBy('item_id'); - $this->info(" - BD- 아이템 중 item_category 미설정: {$count}건"); + $hasStock = 0; + $noStock = 0; - 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}건 업데이트 완료"); + foreach ($items as $item) { + $stock = $stocks->get($item->id); + if ($stock && (float) $stock->stock_qty > 0) { + $hasStock++; + } else { + $noStock++; + } } - $this->stats['items_updated'] = $count; + $this->info(" - 재고 있음: {$hasStock}건"); + $this->info(" - 재고 없음: {$noStock}건"); } /** - * 마이그레이션 실행 + * 재고 셋팅이 필요한 품목 목록 조회 */ - private function executeMigration(int $tenantId, \Illuminate\Support\Collection $lots, \Illuminate\Support\Collection $workLogs, array $stockSummary): void + private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection { - $itemMap = []; // productKey => item_id - $stockMap = []; // productKey => stock_id - $lotMap = []; // productKey => [stock_lot_ids] + $itemIds = $items->pluck('id'); - // 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']); + $stocks = DB::connection($this->targetDb) + ->table('stocks') + ->where('tenant_id', $tenantId) + ->whereIn('item_id', $itemIds) + ->whereNull('deleted_at') + ->get() + ->keyBy('item_id'); - // 아이템 조회 또는 생성 - $item = DB::connection($this->targetDb) - ->table('items') - ->where('tenant_id', $tenantId) - ->where('code', $itemCode) - ->whereNull('deleted_at') - ->first(); + $result = collect(); - 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}"); + foreach ($items as $item) { + $stock = $stocks->get($item->id); + $currentQty = $stock ? (float) $stock->stock_qty : 0; + + if ($currentQty >= $minStock) { + $this->stats['stocks_skipped']++; + + continue; } - $itemMap[$key] = $itemId; + $supplementQty = $minStock - $currentQty; - // Stock 레코드 조회 또는 생성 - $stock = DB::connection($this->targetDb) - ->table('stocks') - ->where('tenant_id', $tenantId) - ->where('item_id', $itemId) - ->whereNull('deleted_at') - ->first(); + $item->has_stock = (bool) $stock; + $item->stock_id = $stock?->id; + $item->current_qty = $currentQty; + $item->supplement_qty = $supplementQty; - if (! $stock) { + $result->push($item); + } + + return $result; + } + + /** + * 초기 재고 셋팅 실행 + */ + private function executeStockSetup(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): void + { + foreach ($items as $item) { + $stockId = $item->stock_id; + + // Stock 레코드가 없으면 생성 + if (! $item->has_stock) { $stockId = DB::connection($this->targetDb)->table('stocks')->insertGetId([ 'tenant_id' => $tenantId, - 'item_id' => $itemId, - 'item_code' => $itemCode, - 'item_name' => $itemName, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, 'item_type' => 'bent_part', - 'unit' => 'EA', + 'unit' => $item->unit ?? 'EA', 'stock_qty' => 0, 'safety_stock' => 0, 'reserved_qty' => 0, @@ -358,47 +275,18 @@ private function executeMigration(int $tenantId, \Illuminate\Support\Collection 'updated_at' => now(), ]); $this->stats['stocks_created']++; - } else { - $stockId = $stock->id; + $this->line(" + Stock 생성: {$item->code}"); } - $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]++; + $maxFifo = DB::connection($this->targetDb) + ->table('stock_lots') + ->where('stock_id', $stockId) + ->max('fifo_order'); + $nextFifo = ($maxFifo ?? 0) + 1; - // 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}"; - } + // LOT 번호 생성 + $lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code); // 중복 체크 $existingLot = DB::connection($this->targetDb) @@ -410,183 +298,54 @@ private function executeMigration(int $tenantId, \Illuminate\Support\Collection ->first(); if ($existingLot) { - $this->stats['skipped_lots']++; + $this->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}"); continue; } - $qty = (float) $lot->surang; - $receiptDate = $lot->reg_date ?? now()->toDateString(); + $supplementQty = $item->supplement_qty; + // StockLot 생성 $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, + 'fifo_order' => $nextFifo, + 'receipt_date' => now()->toDateString(), + 'qty' => $supplementQty, 'reserved_qty' => 0, - 'available_qty' => $qty, - 'unit' => 'EA', - 'supplier' => null, - 'supplier_lot' => $lot->rawLot ?: null, - 'location' => null, + 'available_qty' => $supplementQty, + 'unit' => $item->unit ?? 'EA', 'status' => 'available', - 'receiving_id' => null, - 'work_order_id' => null, 'created_at' => now(), 'updated_at' => now(), ]); + $this->stats['lots_created']++; - // 입고 트랜잭션 기록 - // balance_qty는 나중에 refreshFromLots 후 업데이트되므로 임시로 qty 사용 + // StockTransaction 생성 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, + 'qty' => $supplementQty, + 'balance_qty' => 0, + 'reference_type' => 'init_stock', + 'reference_id' => 0, 'lot_no' => $lotNo, 'reason' => 'receiving', - 'remark' => '5130 레거시 마이그레이션 (lot.num='.$lot->num.')', - 'item_code' => $itemCode, - 'item_name' => $itemName, - 'created_at' => $receiptDate, + 'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})", + 'item_code' => $item->code, + 'item_name' => $item->name, + 'created_at' => now(), ]); + $this->stats['transactions_created']++; - $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) { + // Stock 집계 갱신 $this->refreshStockFromLots($stockId, $tenantId); + + $this->line(" ✅ {$item->code}: 0 → {$supplementQty} (+{$supplementQty})"); } - - // 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(' ✅ 집계 갱신 완료'); } /** @@ -610,54 +369,57 @@ private function refreshStockFromLots(int $stockId, int $tenantId): void ->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, + 'reserved_qty' => (float) $lotStats->total_reserved, + 'available_qty' => (float) $lotStats->total_available, 'lot_count' => (int) $lotStats->lot_count, 'oldest_lot_date' => $lotStats->oldest_lot_date, 'last_receipt_date' => $lotStats->latest_receipt_date, - 'status' => $status, + 'status' => $stockQty > 0 ? 'normal' : 'out', 'updated_at' => now(), ]); } /** - * 롤백: migration 참조 데이터 삭제 + * 롤백: init_stock 참조 데이터 삭제 */ - private function rollbackMigration(int $tenantId, bool $dryRun): int + private function rollbackInitStock(int $tenantId, bool $dryRun): int { - $this->warn('⚠️ 롤백: migration 소스 데이터를 삭제합니다.'); + $this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.'); - // 마이그레이션으로 생성된 트랜잭션 + // init_stock으로 생성된 트랜잭션 $txCount = DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) - ->where('reference_type', 'migration') + ->where('reference_type', 'init_stock') ->count(); - // 마이그레이션으로 생성된 아이템 (source = '5130_migration') - $itemCount = DB::connection($this->targetDb) + // init_stock 트랜잭션에 연결된 LOT + $lotIds = DB::connection($this->targetDb) + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->where('reference_type', 'init_stock') + ->whereNotNull('stock_lot_id') + ->pluck('stock_lot_id') + ->unique(); + + // 5130으로 생성된 아이템 + $legacyItemCount = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) - ->where('source', '5130_migration') + ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->count(); $this->info(' 삭제 대상:'); - $this->info(" - stock_transactions (reference_type=migration): {$txCount}건"); - $this->info(" - items (source=5130_migration): {$itemCount}건"); + $this->info(" - stock_transactions (reference_type=init_stock): {$txCount}건"); + $this->info(" - stock_lots (연결 LOT): {$lotIds->count()}건"); + $this->info(" - items (source=5130_migration): {$legacyItemCount}건"); if ($dryRun) { $this->info('DRY RUN - 실제 삭제 없음'); @@ -669,42 +431,62 @@ private function rollbackMigration(int $tenantId, bool $dryRun): int return self::SUCCESS; } - DB::connection($this->targetDb)->transaction(function () use ($tenantId) { + DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) { // 1. 트랜잭션 삭제 DB::connection($this->targetDb) ->table('stock_transactions') ->where('tenant_id', $tenantId) - ->where('reference_type', 'migration') + ->where('reference_type', 'init_stock') ->delete(); - // 2. migration으로 생성된 아이템의 stock_lots 삭제 + // 2. LOT에서 stock_id 목록 수집 (집계 갱신용) + $affectedStockIds = collect(); + if ($lotIds->isNotEmpty()) { + $affectedStockIds = DB::connection($this->targetDb) + ->table('stock_lots') + ->whereIn('id', $lotIds) + ->pluck('stock_id') + ->unique(); + + // LOT 삭제 + DB::connection($this->targetDb) + ->table('stock_lots') + ->whereIn('id', $lotIds) + ->delete(); + } + + // 3. 영향받은 Stock 집계 갱신 + foreach ($affectedStockIds as $stockId) { + $this->refreshStockFromLots($stockId, $tenantId); + } + + // 4. 5130 migration으로 생성된 아이템 + 연결 stocks 삭제 $migrationItemIds = DB::connection($this->targetDb) ->table('items') ->where('tenant_id', $tenantId) - ->where('source', '5130_migration') + ->where('options->source', '5130_migration') ->whereNull('deleted_at') ->pluck('id'); if ($migrationItemIds->isNotEmpty()) { - $stockIds = DB::connection($this->targetDb) + $migrationStockIds = DB::connection($this->targetDb) ->table('stocks') ->where('tenant_id', $tenantId) ->whereIn('item_id', $migrationItemIds) ->pluck('id'); - if ($stockIds->isNotEmpty()) { + if ($migrationStockIds->isNotEmpty()) { DB::connection($this->targetDb) ->table('stock_lots') - ->whereIn('stock_id', $stockIds) + ->whereIn('stock_id', $migrationStockIds) ->delete(); DB::connection($this->targetDb) ->table('stocks') - ->whereIn('id', $stockIds) + ->whereIn('id', $migrationStockIds) ->delete(); } - // 3. 아이템 삭제 DB::connection($this->targetDb) ->table('items') ->whereIn('id', $migrationItemIds) @@ -718,38 +500,114 @@ private function rollbackMigration(int $tenantId, bool $dryRun): int } /** - * 5130 코드 → 제품 키 생성 + * 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성 */ - private function makeProductKey(string $prod, ?string $spec, ?string $slength): string + private function createLegacyItems(int $tenantId, bool $dryRun): void { - return trim($prod).'-'.trim($spec ?? '').'-'.trim($slength ?? ''); - } + // 5130 lot 테이블에서 고유 prod+spec+slength 조합 추출 + $lots = DB::connection($this->sourceDb) + ->table('lot') + ->where(function ($q) { + $q->whereNull('is_deleted') + ->orWhere('is_deleted', 0); + }) + ->whereNotNull('prod') + ->where('prod', '!=', '') + ->whereNotNull('surang') + ->where('surang', '>', 0) + ->select('prod', 'spec', 'slength') + ->distinct() + ->get(); - /** - * 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 ?? ''); + // bending_work_log 테이블에서도 추출 (lot에 없는 조합 포함) + $workLogs = DB::connection($this->sourceDb) + ->table('bending_work_log') + ->where(function ($q) { + $q->whereNull('is_deleted') + ->orWhere('is_deleted', 0); + }) + ->whereNotNull('prod_code') + ->where('prod_code', '!=', '') + ->select('prod_code as prod', 'spec_code as spec', 'slength_code as slength') + ->distinct() + ->get(); - return "BD-{$p}{$s}-{$l}"; - } + $allRecords = $lots->merge($workLogs); - /** - * 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 ?? ''); + if ($allRecords->isEmpty()) { + $this->info(' - 5130 데이터 없음'); - $parts = array_filter([$prodName, $specName, $slengthName]); + return; + } - return implode(' ', $parts); + // 고유 제품 조합 추출 + $uniqueProducts = []; + foreach ($allRecords as $row) { + $key = trim($row->prod).'-'.trim($row->spec ?? '').'-'.trim($row->slength ?? ''); + if (! isset($uniqueProducts[$key])) { + $uniqueProducts[$key] = [ + 'prod' => trim($row->prod), + 'spec' => trim($row->spec ?? ''), + 'slength' => trim($row->slength ?? ''), + ]; + } + } + + $this->info(" - 5130 고유 제품 조합: ".count($uniqueProducts).'개'); + + $created = 0; + $skipped = 0; + + foreach ($uniqueProducts as $data) { + $itemCode = "BD-{$data['prod']}{$data['spec']}-{$data['slength']}"; + $prodName = $this->prodNames[$data['prod']] ?? $data['prod']; + $specName = $this->specNames[$data['spec']] ?? $data['spec']; + $slengthName = $this->slengthNames[$data['slength']] ?? $data['slength']; + $itemName = implode(' ', array_filter([$prodName, $specName, $slengthName])); + + // 이미 존재하는지 확인 + $existing = DB::connection($this->targetDb) + ->table('items') + ->where('tenant_id', $tenantId) + ->where('code', $itemCode) + ->whereNull('deleted_at') + ->first(); + + if ($existing) { + $skipped++; + + continue; + } + + if (! $dryRun) { + DB::connection($this->targetDb)->table('items')->insert([ + 'tenant_id' => $tenantId, + 'code' => $itemCode, + 'name' => $itemName, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', + 'options' => json_encode([ + 'source' => '5130_migration', + '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(), + ]); + } + + $created++; + } + + $this->stats['items_created_5130'] = $created; + $this->info(" - 신규 생성: {$created}건, 기존 존재 (skip): {$skipped}건"); } /** @@ -759,15 +617,15 @@ private function showStats(): void { $this->newLine(); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - $this->info('📊 마이그레이션 통계'); + $this->info('📊 실행 통계'); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - $this->info(" 아이템 카테고리 업데이트: {$this->stats['items_updated']}건"); - $this->info(" 아이템 신규 생성: {$this->stats['items_created']}건"); + $this->info(" 5130 아이템 생성: {$this->stats['items_created_5130']}건"); + $this->info(" BD-* 품목 수 (전체): {$this->stats['items_found']}건"); + $this->info(" 카테고리 업데이트: {$this->stats['items_category_updated']}건"); $this->info(" Stock 레코드 생성: {$this->stats['stocks_created']}건"); + $this->info(" 기존 재고 충분 (skip): {$this->stats['stocks_skipped']}건"); $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(" 입고 트랜잭션: {$this->stats['transactions_created']}건"); $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); } -} +} \ No newline at end of file diff --git a/app/Console/Commands/ValidateBendingItems.php b/app/Console/Commands/ValidateBendingItems.php new file mode 100644 index 0000000..3274c9f --- /dev/null +++ b/app/Console/Commands/ValidateBendingItems.php @@ -0,0 +1,137 @@ + $guideRailCodes, // 벽면 SUS 마감재 + 'RM' => ['24', '30', '35', '40', '42', '43'], // 벽면 본체 (EGI) + 'RC' => ['24', '30', '35', '40', '42', '43'], // 벽면 C형 + 'RD' => ['24', '30', '35', '40', '42', '43'], // 벽면 D형 + 'RT' => ['30', '43'], // 벽면 본체 (철재) + + // 가이드레일 측면형 + 'SS' => ['30', '35', '40'], // 측면 SUS 마감재 + 'SM' => ['24', '30', '35', '40', '43'], // 측면 본체 (EGI) + 'SC' => ['24', '30', '35', '40', '43'], // 측면 C형 + 'SD' => ['24', '30', '35', '40', '43'], // 측면 D형 + 'ST' => ['43'], // 측면 본체 (철재) + 'SU' => ['30', '35', '40', '43'], // 측면 SUS (SUS2) + + // 하단마감재 + 'BE' => $bottomBarCodes, // EGI 마감 + 'BS' => ['24', '30', '35', '40', '43'], // SUS 마감 + 'TS' => ['43'], // 철재 SUS + 'LA' => $bottomBarCodes, // L-Bar + + // 셔터박스 + 'CF' => $shutterBoxCodes, // 전면부 + 'CL' => $shutterBoxCodes, // 린텔부 + 'CP' => $shutterBoxCodes, // 점검구 + 'CB' => $shutterBoxCodes, // 후면코너부 + + // 연기차단재 + 'GI' => ['53', '54', '83', '84', '30', '35', '40'], // W50/W80 + 일반 + + // 공용/기타 + 'XX' => ['12', '24', '30', '35', '40', '41', '43'], // 하부BASE/셔터 상부/마구리 + 'YY' => ['30', '35', '40', '43'], // 별도 SUS 마감 + 'HH' => ['30', '40'], // 보강평철 + ]; + } + + public function handle(): int + { + $tenantId = (int) $this->option('tenant_id'); + + $this->info("=== BD-* 절곡 세부품목 마스터 검증 (tenant: {$tenantId}) ==="); + $this->newLine(); + + // DB에서 전체 BD-* 품목 조회 + $existingItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->where('code', 'like', 'BD-%') + ->whereNull('deleted_at') + ->pluck('code') + ->toArray(); + + $existingSet = array_flip($existingItems); + + $this->info('현재 등록된 BD-* 품목: '.count($existingItems).'개'); + $this->newLine(); + + $prefixMap = $this->getPrefixLengthCodes(); + $totalExpected = 0; + $missing = []; + $found = 0; + + foreach ($prefixMap as $prefix => $codes) { + $prefixMissing = []; + foreach ($codes as $code) { + $itemCode = "BD-{$prefix}-{$code}"; + $totalExpected++; + + if (isset($existingSet[$itemCode])) { + $found++; + } else { + $prefixMissing[] = $itemCode; + $missing[] = $itemCode; + } + } + + $status = empty($prefixMissing) ? '✅' : '❌'; + $countStr = count($codes) - count($prefixMissing).'/'.count($codes); + $this->line(" {$status} BD-{$prefix}: {$countStr}"); + + if (! empty($prefixMissing)) { + foreach ($prefixMissing as $m) { + $this->line(" ⚠️ 누락: {$m}"); + } + } + } + + $this->newLine(); + $this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + $this->info("검증 결과: {$found}/{$totalExpected} 등록 완료"); + + if (empty($missing)) { + $this->info('✅ All items registered — 누락 0건'); + + return self::SUCCESS; + } + + $this->warn('❌ 누락 항목: '.count($missing).'건'); + $this->newLine(); + $this->table(['누락 품목코드'], array_map(fn ($m) => [$m], $missing)); + + return self::FAILURE; + } +} diff --git a/database/seeders/Kyungdong/BendingItemSeeder.php b/database/seeders/Kyungdong/BendingItemSeeder.php new file mode 100644 index 0000000..956738c --- /dev/null +++ b/database/seeders/Kyungdong/BendingItemSeeder.php @@ -0,0 +1,151 @@ + 1219, + '24' => 2438, + '30' => 3000, + '35' => 3500, + '40' => 4000, + '41' => 4150, + '42' => 4200, + '43' => 4300, + '53' => 3000, // 연기차단재50 전용 + '54' => 4000, // 연기차단재50 전용 + '83' => 3000, // 연기차단재80 전용 + '84' => 4000, // 연기차단재80 전용 + ]; + + /** + * 등록 대상 정의 + */ + private function getItemDefinitions(): array + { + return [ + // Phase 0.1 대상 + 'XX' => [ + 'name' => '하부BASE/셔터 상부/마구리', + 'lengthCodes' => ['12', '24', '30', '35', '40', '41', '43'], + ], + 'YY' => [ + 'name' => '별도SUS마감', + 'lengthCodes' => ['30', '35', '40', '43'], + ], + 'HH' => [ + 'name' => '보강평철', + 'lengthCodes' => ['30', '40'], + ], + // 추가 누락분 + 'RM' => [ + 'name' => '가이드레일(벽면) 본체', + 'lengthCodes' => ['42'], + ], + 'RC' => [ + 'name' => '가이드레일(벽면) C형', + 'lengthCodes' => ['42'], + ], + 'RD' => [ + 'name' => '가이드레일(벽면) D형', + 'lengthCodes' => ['42'], + ], + 'SM' => [ + 'name' => '가이드레일(측면) 본체', + 'lengthCodes' => ['24'], + ], + 'BS' => [ + 'name' => '하단마감재 SUS', + 'lengthCodes' => ['35', '43'], + ], + 'TS' => [ + 'name' => '하단마감재 철재SUS', + 'lengthCodes' => ['43'], + ], + 'GI' => [ + 'name' => '연기차단재', + 'lengthCodes' => ['54', '84'], + 'nameOverrides' => [ + '54' => '연기차단재 W50 4000mm', + '84' => '연기차단재 W80 4000mm', + ], + ], + ]; + } + + public function run(): void + { + $definitions = $this->getItemDefinitions(); + $created = 0; + $skipped = 0; + + foreach ($definitions as $prefix => $def) { + foreach ($def['lengthCodes'] as $lengthCode) { + $code = "BD-{$prefix}-{$lengthCode}"; + $lengthMm = self::LENGTH_MAP[$lengthCode]; + $name = ($def['nameOverrides'][$lengthCode] ?? null) + ?: "{$def['name']} {$lengthMm}mm"; + + $exists = DB::table('items') + ->where('tenant_id', $this->tenantId) + ->where('code', $code) + ->whereNull('deleted_at') + ->exists(); + + if ($exists) { + $this->command?->line(" ⏭️ skip (exists): {$code}"); + $skipped++; + + continue; + } + + DB::table('items')->insert([ + 'tenant_id' => $this->tenantId, + 'code' => $code, + 'name' => $name, + 'item_type' => 'PT', + 'item_category' => 'BENDING', + 'unit' => 'EA', + 'bom' => null, + 'attributes' => json_encode([]), + 'attributes_archive' => json_encode([]), + 'options' => json_encode([ + 'source' => 'bending_item_seeder', + 'lot_managed' => true, + 'consumption_method' => 'auto', + 'production_source' => 'self_produced', + 'input_tracking' => true, + 'prefix' => $prefix, + 'length_code' => $lengthCode, + 'length_mm' => $lengthMm, + ]), + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->command?->line(" ✅ created: {$code} ({$name})"); + $created++; + } + } + + $this->command?->info("BD-* 누락 품목 등록 완료: 생성 {$created}건, 스킵 {$skipped}건"); + } +}