Files
sam-api/app/Console/Commands/Migrate5130BendingStock.php
권혁성 855e806e42 refactor: 절곡 재고 마이그레이션 커맨드 리팩토링 및 검증/시더 추가
- Migrate5130BendingStock: BD-* 품목 초기 재고 셋팅으로 목적 변경, --min-stock 옵션 추가
- ValidateBendingItems: BD-* 품목 존재 여부 검증 커맨드 신규
- BendingItemSeeder: 경동 절곡 품목 시딩 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00

631 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[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 : 실제 저장 없이 시뮬레이션만 수행}
{--min-stock=100 : 품목별 초기 재고 수량 (기본: 100)}
{--rollback : 초기 재고 셋팅 롤백 (init_stock 소스 데이터 삭제)}';
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_found' => 0,
'items_created_5130' => 0,
'items_category_updated' => 0,
'stocks_created' => 0,
'stocks_skipped' => 0,
'lots_created' => 0,
'transactions_created' => 0,
];
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('=== BD-* 절곡품 초기 재고 셋팅 ===');
$this->info("Tenant ID: {$tenantId}");
$this->info('Mode: '.($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE'));
$this->info("초기 재고: {$minStock}개/품목");
$this->newLine();
if ($rollback) {
return $this->rollbackInitStock($tenantId, $dryRun);
}
// 0. 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
$this->info('📥 Step 0: 5130 레거시 코드 → BD 아이템 생성...');
$this->createLegacyItems($tenantId, $dryRun);
$this->newLine();
// 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. item_category 미설정 품목 업데이트
$this->newLine();
$this->info('🏷️ Step 2: item_category 업데이트...');
$needsCategoryUpdate = $items->filter(fn ($item) => $item->item_category !== 'BENDING');
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('초기 재고를 셋팅하시겠습니까?')) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
// 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('✅ 초기 재고 셋팅 완료!');
return self::SUCCESS;
}
/**
* 현재 재고 현황 표시
*/
private function showCurrentStockStatus(int $tenantId, \Illuminate\Support\Collection $items): void
{
$itemIds = $items->pluck('id');
$stocks = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $itemIds)
->whereNull('deleted_at')
->get()
->keyBy('item_id');
$hasStock = 0;
$noStock = 0;
foreach ($items as $item) {
$stock = $stocks->get($item->id);
if ($stock && (float) $stock->stock_qty > 0) {
$hasStock++;
} else {
$noStock++;
}
}
$this->info(" - 재고 있음: {$hasStock}");
$this->info(" - 재고 없음: {$noStock}");
}
/**
* 재고 셋팅이 필요한 품목 목록 조회
*/
private function getItemsNeedingStock(int $tenantId, \Illuminate\Support\Collection $items, int $minStock): \Illuminate\Support\Collection
{
$itemIds = $items->pluck('id');
$stocks = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $itemIds)
->whereNull('deleted_at')
->get()
->keyBy('item_id');
$result = collect();
foreach ($items as $item) {
$stock = $stocks->get($item->id);
$currentQty = $stock ? (float) $stock->stock_qty : 0;
if ($currentQty >= $minStock) {
$this->stats['stocks_skipped']++;
continue;
}
$supplementQty = $minStock - $currentQty;
$item->has_stock = (bool) $stock;
$item->stock_id = $stock?->id;
$item->current_qty = $currentQty;
$item->supplement_qty = $supplementQty;
$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' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => 'bent_part',
'unit' => $item->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']++;
$this->line(" + Stock 생성: {$item->code}");
}
// FIFO 순서 계산
$maxFifo = DB::connection($this->targetDb)
->table('stock_lots')
->where('stock_id', $stockId)
->max('fifo_order');
$nextFifo = ($maxFifo ?? 0) + 1;
// LOT 번호 생성
$lotNo = 'INIT-'.now()->format('ymd').'-'.str_replace(['-', ' ', '*'], ['', '', 'x'], $item->code);
// 중복 체크
$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->warn(" ⚠️ 이미 LOT 존재 (skip): {$lotNo}");
continue;
}
$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' => $nextFifo,
'receipt_date' => now()->toDateString(),
'qty' => $supplementQty,
'reserved_qty' => 0,
'available_qty' => $supplementQty,
'unit' => $item->unit ?? 'EA',
'status' => 'available',
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['lots_created']++;
// StockTransaction 생성
DB::connection($this->targetDb)->table('stock_transactions')->insert([
'tenant_id' => $tenantId,
'stock_id' => $stockId,
'stock_lot_id' => $stockLotId,
'type' => 'IN',
'qty' => $supplementQty,
'balance_qty' => 0,
'reference_type' => 'init_stock',
'reference_id' => 0,
'lot_no' => $lotNo,
'reason' => 'receiving',
'remark' => "절곡품 초기 재고 셋팅 (min-stock={$minStock})",
'item_code' => $item->code,
'item_name' => $item->name,
'created_at' => now(),
]);
$this->stats['transactions_created']++;
// Stock 집계 갱신
$this->refreshStockFromLots($stockId, $tenantId);
$this->line("{$item->code}: 0 → {$supplementQty} (+{$supplementQty})");
}
}
/**
* 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;
DB::connection($this->targetDb)
->table('stocks')
->where('id', $stockId)
->update([
'stock_qty' => $stockQty,
'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' => $stockQty > 0 ? 'normal' : 'out',
'updated_at' => now(),
]);
}
/**
* 롤백: init_stock 참조 데이터 삭제
*/
private function rollbackInitStock(int $tenantId, bool $dryRun): int
{
$this->warn('⚠️ 롤백: 초기 재고 셋팅 데이터를 삭제합니다.');
// init_stock으로 생성된 트랜잭션
$txCount = DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'init_stock')
->count();
// 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('options->source', '5130_migration')
->whereNull('deleted_at')
->count();
$this->info(' 삭제 대상:');
$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 - 실제 삭제 없음');
return self::SUCCESS;
}
if (! $this->confirm('정말 롤백하시겠습니까? 되돌릴 수 없습니다.')) {
return self::SUCCESS;
}
DB::connection($this->targetDb)->transaction(function () use ($tenantId, $lotIds) {
// 1. 트랜잭션 삭제
DB::connection($this->targetDb)
->table('stock_transactions')
->where('tenant_id', $tenantId)
->where('reference_type', 'init_stock')
->delete();
// 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('options->source', '5130_migration')
->whereNull('deleted_at')
->pluck('id');
if ($migrationItemIds->isNotEmpty()) {
$migrationStockIds = DB::connection($this->targetDb)
->table('stocks')
->where('tenant_id', $tenantId)
->whereIn('item_id', $migrationItemIds)
->pluck('id');
if ($migrationStockIds->isNotEmpty()) {
DB::connection($this->targetDb)
->table('stock_lots')
->whereIn('stock_id', $migrationStockIds)
->delete();
DB::connection($this->targetDb)
->table('stocks')
->whereIn('id', $migrationStockIds)
->delete();
}
DB::connection($this->targetDb)
->table('items')
->whereIn('id', $migrationItemIds)
->delete();
}
});
$this->info('✅ 롤백 완료');
return self::SUCCESS;
}
/**
* 5130 레거시 데이터에서 BD-{PROD}{SPEC}-{SLENGTH} 아이템 생성
*/
private function createLegacyItems(int $tenantId, bool $dryRun): void
{
// 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();
// 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();
$allRecords = $lots->merge($workLogs);
if ($allRecords->isEmpty()) {
$this->info(' - 5130 데이터 없음');
return;
}
// 고유 제품 조합 추출
$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}");
}
/**
* 통계 출력
*/
private function showStats(): void
{
$this->newLine();
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->info('📊 실행 통계');
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$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_created']}");
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
}