From 192610e7fadd94a1adb268bfdc07d264b0306c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 21 Mar 2026 17:09:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[inventory]=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=9E=85=EA=B3=A0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=9D=EC=84=B1=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - receiving:generate-test-data 커맨드 생성 - RM 품목 기반 Receiving/Stock/StockLot/IQC Inspection 일괄 생성 - SUS/EGI 품목별 실제적 공급업체(포스코/현대제철/동국제강 등) 매핑 - 수입검사항목 자동 구성 (외관/두께/치수/재질 검사) - --dry-run, --tenant, --date 옵션 지원 --- .../GenerateReceivingTestDataCommand.php | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 app/Console/Commands/GenerateReceivingTestDataCommand.php diff --git a/app/Console/Commands/GenerateReceivingTestDataCommand.php b/app/Console/Commands/GenerateReceivingTestDataCommand.php new file mode 100644 index 00000000..21e11a8d --- /dev/null +++ b/app/Console/Commands/GenerateReceivingTestDataCommand.php @@ -0,0 +1,438 @@ + '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; + } +}