Files
sam-api/app/Console/Commands/Migrate5130BendingStock.php

631 lines
24 KiB
PHP
Raw Normal View History

<?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('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
}
}