feat: [inventory] 원자재 입고 테스트 데이터 생성 커맨드 추가
- receiving:generate-test-data 커맨드 생성 - RM 품목 기반 Receiving/Stock/StockLot/IQC Inspection 일괄 생성 - SUS/EGI 품목별 실제적 공급업체(포스코/현대제철/동국제강 등) 매핑 - 수입검사항목 자동 구성 (외관/두께/치수/재질 검사) - --dry-run, --tenant, --date 옵션 지원
This commit is contained in:
438
app/Console/Commands/GenerateReceivingTestDataCommand.php
Normal file
438
app/Console/Commands/GenerateReceivingTestDataCommand.php
Normal file
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Qualitys\Inspection;
|
||||
use App\Models\Tenants\Receiving;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\StockLot;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 원자재 입고 테스트 데이터 생성 커맨드
|
||||
*
|
||||
* 서비스 준비 단계에서 절곡 데이터 정합성, 작업일지, 검사 테스트를 위한 데이터 생성
|
||||
*/
|
||||
class GenerateReceivingTestDataCommand extends Command
|
||||
{
|
||||
protected $signature = 'receiving:generate-test-data
|
||||
{--tenant=287 : 테넌트 ID}
|
||||
{--date=2026-03-21 : 입고일자 (YYYY-MM-DD)}
|
||||
{--dry-run : 실제 DB에 넣지 않고 미리보기만}';
|
||||
|
||||
protected $description = '원자재(RM) 품목 기반 입고 테스트 데이터 생성 (Receiving + Stock + StockLot + IQC Inspection)';
|
||||
|
||||
private const USER_ID = 33;
|
||||
|
||||
private const ITEM_TYPE_MAP = [
|
||||
'FG' => 'purchased_part',
|
||||
'PT' => 'bent_part',
|
||||
'SM' => 'sub_material',
|
||||
'RM' => 'raw_material',
|
||||
'CS' => 'consumable',
|
||||
];
|
||||
|
||||
/** 철강 관련 공급업체 (업체명 => 제조사) */
|
||||
private const STEEL_SUPPLIERS = [
|
||||
'(주)포스코' => '포스코',
|
||||
'현대제철(주)' => '현대제철',
|
||||
'동국제강(주)' => '동국제강',
|
||||
'(주)세아제강' => '세아제강',
|
||||
'한국철강(주)' => '한국철강',
|
||||
];
|
||||
|
||||
/** 원단/기타 공급업체 */
|
||||
private const FABRIC_SUPPLIERS = [
|
||||
'(주)대한스틸' => '대한스틸',
|
||||
'(주)한국소재' => '한국소재',
|
||||
'삼화산업(주)' => '삼화산업',
|
||||
];
|
||||
|
||||
/** 창고 위치 */
|
||||
private const LOCATIONS = ['A-01-01', 'A-01-02', 'A-02-01', 'B-01-01', 'B-02-01', 'C-01-01'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tenantId = (int) $this->option('tenant');
|
||||
$date = $this->option('date');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info('=== 원자재 입고 테스트 데이터 생성 ===');
|
||||
$this->info("테넌트: {$tenantId} | 일자: {$date}".($dryRun ? ' [DRY-RUN]' : ''));
|
||||
|
||||
// Step 1: RM 품목 조회
|
||||
$items = Item::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('item_type', 'RM')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
$this->error("테넌트 {$tenantId}에 원자재(RM) 품목이 없습니다.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("대상 품목: {$items->count()}건");
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->table(
|
||||
['코드', '품목명', '단위', '예상 공급업체', '예상 수량'],
|
||||
$items->map(fn ($item) => [
|
||||
$item->code,
|
||||
$item->name,
|
||||
$item->unit,
|
||||
$this->getSupplierForItem($item->name)[0],
|
||||
'10~100 (10단위)',
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 채번 시퀀스 초기화
|
||||
$datePrefix = date('Ymd', strtotime($date));
|
||||
$dateShort = date('ymd', strtotime($date));
|
||||
$receivingSeq = $this->getNextReceivingSeq($tenantId, $datePrefix);
|
||||
$lotSeq = $this->getNextLotSeq($tenantId, $dateShort);
|
||||
$inspectionSeq = $this->getNextInspectionSeq($tenantId, $dateShort);
|
||||
$orderSeq = 1;
|
||||
|
||||
$created = ['receiving' => 0, 'stock' => 0, 'stock_lot' => 0, 'inspection' => 0];
|
||||
|
||||
DB::transaction(function () use ($items, $tenantId, $date, $datePrefix, $dateShort, &$receivingSeq, &$lotSeq, &$inspectionSeq, &$orderSeq, &$created) {
|
||||
foreach ($items as $item) {
|
||||
$qty = rand(1, 10) * 10; // 10~100 (10단위)
|
||||
[$supplierName, $manufacturer] = $this->getSupplierForItem($item->name);
|
||||
$location = self::LOCATIONS[array_rand(self::LOCATIONS)];
|
||||
|
||||
// Step 2: Receiving 생성
|
||||
$receivingNumber = 'RV'.$datePrefix.str_pad($receivingSeq++, 4, '0', STR_PAD_LEFT);
|
||||
$lotNo = $dateShort.'-'.str_pad($lotSeq++, 2, '0', STR_PAD_LEFT);
|
||||
$orderNo = 'PO-'.$dateShort.'-'.str_pad($orderSeq++, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$receiving = Receiving::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'receiving_number' => $receivingNumber,
|
||||
'order_no' => $orderNo,
|
||||
'order_date' => $date,
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'supplier' => $supplierName,
|
||||
'order_qty' => $qty,
|
||||
'order_unit' => $item->unit ?: 'EA',
|
||||
'due_date' => $date,
|
||||
'receiving_qty' => $qty,
|
||||
'receiving_date' => $date,
|
||||
'lot_no' => $lotNo,
|
||||
'supplier_lot' => 'SUP-'.rand(1000, 9999),
|
||||
'receiving_location' => $location,
|
||||
'receiving_manager' => '관리자',
|
||||
'status' => 'completed',
|
||||
'remark' => '테스트 데이터 (서비스 준비)',
|
||||
'options' => [
|
||||
'manufacturer' => $manufacturer,
|
||||
'inspection_status' => '적',
|
||||
'inspection_date' => $date,
|
||||
'inspection_result' => '합격',
|
||||
],
|
||||
'created_by' => self::USER_ID,
|
||||
'updated_by' => self::USER_ID,
|
||||
]);
|
||||
$created['receiving']++;
|
||||
|
||||
// Step 3: Stock 생성/갱신 (item_id 또는 item_code로 조회)
|
||||
$stockType = self::ITEM_TYPE_MAP[$item->item_type] ?? 'raw_material';
|
||||
$stock = Stock::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where(function ($q) use ($item) {
|
||||
$q->where('item_id', $item->id)
|
||||
->orWhere('item_code', $item->code);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($stock) {
|
||||
$stock->stock_qty += $qty;
|
||||
$stock->available_qty += $qty;
|
||||
$stock->lot_count += 1;
|
||||
$stock->last_receipt_date = $date;
|
||||
$stock->status = 'normal';
|
||||
$stock->updated_by = self::USER_ID;
|
||||
$stock->save();
|
||||
} else {
|
||||
$stock = Stock::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'item_id' => $item->id,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
'item_type' => $stockType,
|
||||
'unit' => $item->unit ?: 'EA',
|
||||
'stock_qty' => $qty,
|
||||
'safety_stock' => 10,
|
||||
'reserved_qty' => 0,
|
||||
'available_qty' => $qty,
|
||||
'lot_count' => 1,
|
||||
'oldest_lot_date' => $date,
|
||||
'location' => $location,
|
||||
'status' => 'normal',
|
||||
'last_receipt_date' => $date,
|
||||
'created_by' => self::USER_ID,
|
||||
]);
|
||||
$created['stock']++;
|
||||
}
|
||||
|
||||
// Step 4: StockLot 생성
|
||||
$nextFifo = StockLot::withoutGlobalScopes()
|
||||
->where('stock_id', $stock->id)
|
||||
->max('fifo_order') ?? 0;
|
||||
|
||||
StockLot::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'stock_id' => $stock->id,
|
||||
'lot_no' => $lotNo,
|
||||
'fifo_order' => $nextFifo + 1,
|
||||
'receipt_date' => $date,
|
||||
'qty' => $qty,
|
||||
'reserved_qty' => 0,
|
||||
'available_qty' => $qty,
|
||||
'unit' => $item->unit ?: 'EA',
|
||||
'supplier' => $supplierName,
|
||||
'supplier_lot' => $receiving->supplier_lot,
|
||||
'po_number' => $orderNo,
|
||||
'location' => $location,
|
||||
'status' => 'available',
|
||||
'receiving_id' => $receiving->id,
|
||||
'created_by' => self::USER_ID,
|
||||
]);
|
||||
$created['stock_lot']++;
|
||||
|
||||
// Step 5: IQC 수입검사 생성
|
||||
$inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspectionSeq++, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
Inspection::withoutGlobalScopes()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'inspection_no' => $inspectionNo,
|
||||
'inspection_type' => 'IQC',
|
||||
'status' => 'completed',
|
||||
'result' => 'pass',
|
||||
'request_date' => $date,
|
||||
'inspection_date' => $date,
|
||||
'item_id' => $item->id,
|
||||
'lot_no' => $lotNo,
|
||||
'meta' => [
|
||||
'quantity' => $qty,
|
||||
'unit' => $item->unit ?: 'EA',
|
||||
'supplier_name' => $supplierName,
|
||||
'manufacturer_name' => $manufacturer,
|
||||
'item_code' => $item->code,
|
||||
'item_name' => $item->name,
|
||||
],
|
||||
'items' => $this->buildInspectionItems($item->name),
|
||||
'extra' => [
|
||||
'remarks' => '입고 시 수입검사 합격',
|
||||
'opinion' => '양호',
|
||||
],
|
||||
'created_by' => self::USER_ID,
|
||||
'updated_by' => self::USER_ID,
|
||||
]);
|
||||
$created['inspection']++;
|
||||
|
||||
$this->line(" [{$item->code}] {$item->name} → 수량:{$qty} | {$receivingNumber} | {$lotNo} | {$supplierName}");
|
||||
}
|
||||
});
|
||||
|
||||
$this->newLine();
|
||||
$this->info('=== 생성 완료 ===');
|
||||
$this->table(
|
||||
['항목', '건수'],
|
||||
[
|
||||
['Receiving (입고)', $created['receiving']],
|
||||
['Stock (재고, 신규)', $created['stock']],
|
||||
['StockLot (재고LOT)', $created['stock_lot']],
|
||||
['Inspection (수입검사)', $created['inspection']],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목명에 따른 공급업체/제조사 반환
|
||||
*
|
||||
* @return array [업체명, 제조사]
|
||||
*/
|
||||
private function getSupplierForItem(string $itemName): array
|
||||
{
|
||||
$suppliers = self::STEEL_SUPPLIERS;
|
||||
|
||||
// SUS 품목 → 현대제철 우선
|
||||
if (str_contains($itemName, 'SUS')) {
|
||||
$pool = [
|
||||
['현대제철(주)', '현대제철'],
|
||||
['동국제강(주)', '동국제강'],
|
||||
['(주)세아제강', '세아제강'],
|
||||
];
|
||||
|
||||
return $pool[array_rand($pool)];
|
||||
}
|
||||
|
||||
// EGI 품목 → 포스코 우선
|
||||
if (str_contains($itemName, 'EGI')) {
|
||||
$pool = [
|
||||
['(주)포스코', '포스코'],
|
||||
['한국철강(주)', '한국철강'],
|
||||
['동국제강(주)', '동국제강'],
|
||||
];
|
||||
|
||||
return $pool[array_rand($pool)];
|
||||
}
|
||||
|
||||
// 원단류
|
||||
$fabricPool = array_map(
|
||||
fn ($name, $mfr) => [$name, $mfr],
|
||||
array_keys(self::FABRIC_SUPPLIERS),
|
||||
array_values(self::FABRIC_SUPPLIERS)
|
||||
);
|
||||
|
||||
return $fabricPool[array_rand($fabricPool)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목에 맞는 검사항목 생성
|
||||
*/
|
||||
private function buildInspectionItems(string $itemName): array
|
||||
{
|
||||
$items = [
|
||||
[
|
||||
'name' => '외관검사',
|
||||
'type' => 'visual',
|
||||
'spec' => '표면 흠집, 녹, 이물질 없음',
|
||||
'unit' => '-',
|
||||
'result' => '합격',
|
||||
'measured_value' => '양호',
|
||||
'judgment' => 'pass',
|
||||
],
|
||||
];
|
||||
|
||||
// 철판류 (SUS/EGI) 전용 검사항목
|
||||
if (str_contains($itemName, 'SUS') || str_contains($itemName, 'EGI')) {
|
||||
// 두께 추출
|
||||
$thickness = $this->extractThickness($itemName);
|
||||
|
||||
$items[] = [
|
||||
'name' => '두께검사',
|
||||
'type' => 'measurement',
|
||||
'spec' => $thickness ? "{$thickness}mm ±0.05" : '규격 ±0.05mm',
|
||||
'unit' => 'mm',
|
||||
'result' => '적합',
|
||||
'measured_value' => $thickness ?: '-',
|
||||
'judgment' => 'pass',
|
||||
];
|
||||
$items[] = [
|
||||
'name' => '치수검사',
|
||||
'type' => 'measurement',
|
||||
'spec' => '가로/세로 규격 ±1mm',
|
||||
'unit' => 'mm',
|
||||
'result' => '적합',
|
||||
'measured_value' => '규격 이내',
|
||||
'judgment' => 'pass',
|
||||
];
|
||||
$items[] = [
|
||||
'name' => '재질확인',
|
||||
'type' => 'certificate',
|
||||
'spec' => str_contains($itemName, 'SUS') ? 'STS304' : 'SECC',
|
||||
'unit' => '-',
|
||||
'result' => '적합',
|
||||
'measured_value' => str_contains($itemName, 'SUS') ? 'STS304' : 'SECC',
|
||||
'judgment' => 'pass',
|
||||
];
|
||||
} else {
|
||||
// 원단류 기본 검사
|
||||
$items[] = [
|
||||
'name' => '규격검사',
|
||||
'type' => 'measurement',
|
||||
'spec' => '제품규격 확인',
|
||||
'unit' => '-',
|
||||
'result' => '적합',
|
||||
'measured_value' => '규격 이내',
|
||||
'judgment' => 'pass',
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목명에서 두께(mm) 추출
|
||||
*/
|
||||
private function extractThickness(string $itemName): ?string
|
||||
{
|
||||
// "EGI1.2*..." → 1.2, "SUS1.5*..." → 1.5, "EGI1.6T" → 1.6
|
||||
if (preg_match('/(SUS|EGI)\s*(\d+\.?\d*)/i', $itemName, $m)) {
|
||||
return $m[2];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 입고번호 시퀀스 조회
|
||||
*/
|
||||
private function getNextReceivingSeq(int $tenantId, string $datePrefix): int
|
||||
{
|
||||
$prefix = 'RV'.$datePrefix;
|
||||
$last = Receiving::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('receiving_number', 'like', $prefix.'%')
|
||||
->orderBy('receiving_number', 'desc')
|
||||
->first(['receiving_number']);
|
||||
|
||||
if ($last) {
|
||||
return ((int) substr($last->receiving_number, -4)) + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 LOT번호 시퀀스 조회
|
||||
*/
|
||||
private function getNextLotSeq(int $tenantId, string $dateShort): int
|
||||
{
|
||||
$last = Receiving::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('lot_no', 'like', $dateShort.'-%')
|
||||
->orderBy('lot_no', 'desc')
|
||||
->first(['lot_no']);
|
||||
|
||||
if ($last) {
|
||||
return ((int) substr($last->lot_no, -2)) + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 수입검사번호 시퀀스 조회
|
||||
*/
|
||||
private function getNextInspectionSeq(int $tenantId, string $dateShort): int
|
||||
{
|
||||
$prefix = 'IQC-'.$dateShort.'-';
|
||||
$last = Inspection::withoutGlobalScopes()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('inspection_no', 'like', $prefix.'%')
|
||||
->orderBy('inspection_no', 'desc')
|
||||
->first(['inspection_no']);
|
||||
|
||||
if ($last) {
|
||||
return ((int) substr($last->inspection_no, -4)) + 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user