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

439 lines
16 KiB
PHP
Raw Normal View History

<?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;
}
}