diff --git a/app/Http/Controllers/Api/V1/ReceivingController.php b/app/Http/Controllers/Api/V1/ReceivingController.php index 2bbfca17..cddb10bd 100644 --- a/app/Http/Controllers/Api/V1/ReceivingController.php +++ b/app/Http/Controllers/Api/V1/ReceivingController.php @@ -104,10 +104,13 @@ public function process(int $id, ProcessReceivingRequest $request) } /** - * [개발전용] 입고 강제 생성 (입고 + 재고 + 수입검사 한번에) + * [개발전용] 입고 강제 생성 (원자재 입고 + 재공품 재고 + 수입검사 한번에) * * POST /api/v1/dev/force-receiving * Body: { item_id: number, qty?: number } + * + * PT(재공품) 품목이면 → material 속성에서 대응 RM(원자재) 찾아 입고 생성 + * + 원래 PT 품목의 재고도 함께 생성 (자재투입 매칭용) */ public function forceCreate(Request $request) { @@ -147,97 +150,110 @@ public function forceCreate(Request $request) $lotNo = $dateShort.'-'.str_pad($lotSeq, 2, '0', STR_PAD_LEFT); $orderNo = 'PO-'.$dateShort.'-DEV'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT); - // 1. Receiving 생성 + // PT(재공품)이면 material 속성에서 RM(원자재) 찾기 + $rmItem = null; + $receivingItem = $item; // 기본: 원본 품목으로 입고 + $itemOptions = is_array($item->options) ? $item->options : (json_decode($item->options ?? '{}', true) ?: []); + $materialSpec = $itemOptions['material'] ?? null; + + if ($item->item_type === 'PT' && $materialSpec) { + // "EGI 1.55T" → type=EGI, thickness=1.55 + // "SUS 1.2T" → type=SUS, thickness=1.2 + $cleanMat = preg_replace('/T$/i', '', trim($materialSpec)); + if (preg_match('/^([A-Za-z]+)\s*([\d.]+)/', $cleanMat, $m)) { + $matType = $m[1]; + $thickness = (float) $m[2]; + + // 품목명에서 길이 추출: "... 2438mm" → 2438 + $length = null; + if (preg_match('/(\d{3,5})\s*mm/i', $item->name, $lm)) { + $length = (int) $lm[1]; + } + + // RM 품목 검색: 재질+두께 일치, 길이 일치 (있으면) + $rmQuery = Item::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('item_type', 'RM') + ->whereNull('deleted_at') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.material_type')) = ?", [$matType]); + + // 두께 정확 매칭 시도 → 근사 매칭 폴백 + $rmExact = (clone $rmQuery) + ->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) = ?", [$thickness]); + if ($length) { + $rmExact = $rmExact->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]); + } + $rmItem = $rmExact->first(); + + // 두께 근사 매칭 (±0.1) + if (! $rmItem) { + $rmApprox = (clone $rmQuery) + ->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness]); + if ($length) { + $rmApprox = $rmApprox->whereRaw("CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.length')) AS UNSIGNED) = ?", [$length]); + } + $rmItem = $rmApprox->first(); + } + + // 길이 무관 매칭 (두께만) + if (! $rmItem) { + $rmItem = (clone $rmQuery) + ->whereRaw("ABS(CAST(JSON_UNQUOTE(JSON_EXTRACT(options, '$.attributes.thickness')) AS DECIMAL(5,2)) - ?) <= 0.1", [$thickness]) + ->first(); + } + + if ($rmItem) { + $receivingItem = $rmItem; + } + } + } + + // 공급업체 결정 + $supplier = str_contains($receivingItem->name, 'SUS') ? '현대제철(주)' : '(주)포스코'; + + // 1. Receiving 생성 (RM 원자재 기준) $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' => '(주)테스트공급업체', + 'item_id' => $receivingItem->id, + 'item_code' => $receivingItem->code, + 'item_name' => $receivingItem->name, + 'supplier' => $supplier, 'order_qty' => $qty, - 'order_unit' => $item->unit ?: 'EA', + 'order_unit' => $receivingItem->unit ?: 'EA', 'due_date' => $date, 'receiving_qty' => $qty, 'receiving_date' => $date, 'lot_no' => $lotNo, - 'supplier_lot' => 'DEV-'.rand(1000, 9999), + 'supplier_lot' => 'SUP-'.rand(1000, 9999), 'receiving_location' => 'A-01-01', - 'receiving_manager' => '개발자', + 'receiving_manager' => '관리자', 'status' => 'completed', - 'remark' => '[개발전용] 강제 생성 입고', + 'remark' => '[개발전용] 강제 생성 입고'.($rmItem ? ' (원자재 '.$rmItem->code.' → 재공품 '.$item->code.')' : ''), 'options' => [ + 'manufacturer' => str_replace('(주)', '', $supplier), 'inspection_status' => '적', 'inspection_date' => $date, 'inspection_result' => '합격', 'force_created' => true, + 'original_pt_item_id' => $item->id, + 'original_pt_item_code' => $item->code, ], 'created_by' => $userId, 'updated_by' => $userId, ]); - // 2. Stock 생성/갱신 - $itemTypeMap = ['FG' => 'purchased_part', 'PT' => 'bent_part', 'SM' => 'sub_material', 'RM' => 'raw_material', 'CS' => 'consumable']; - $stockType = $itemTypeMap[$item->item_type] ?? 'raw_material'; - - $stock = Stock::withoutGlobalScopes() - ->where('tenant_id', $tenantId) - ->where('item_id', $item->id) - ->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 = $userId; - $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' => 'A-01-01', - 'status' => 'normal', - 'last_receipt_date' => $date, - 'created_by' => $userId, - ]); + // 2-A. 원자재(RM) Stock/StockLot 생성 (입고 이력용) + if ($rmItem && $rmItem->id !== $item->id) { + $this->createStockAndLot($rmItem, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier); } - // 3. StockLot 생성 - $nextFifo = (StockLot::withoutGlobalScopes()->where('stock_id', $stock->id)->max('fifo_order') ?? 0) + 1; - StockLot::withoutGlobalScopes()->create([ - 'tenant_id' => $tenantId, - 'stock_id' => $stock->id, - 'lot_no' => $lotNo, - 'fifo_order' => $nextFifo, - 'receipt_date' => $date, - 'qty' => $qty, - 'reserved_qty' => 0, - 'available_qty' => $qty, - 'unit' => $item->unit ?: 'EA', - 'supplier' => '(주)테스트공급업체', - 'supplier_lot' => $receiving->supplier_lot, - 'po_number' => $orderNo, - 'location' => 'A-01-01', - 'status' => 'available', - 'receiving_id' => $receiving->id, - 'created_by' => $userId, - ]); + // 2-B. 재공품(PT) Stock/StockLot 생성 (자재투입 매칭용) + $ptStock = $this->createStockAndLot($item, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier); - // 4. IQC 수입검사 생성 (합격) + // 3. IQC 수입검사 생성 (합격) $inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspSeq, 4, '0', STR_PAD_LEFT); Inspection::withoutGlobalScopes()->create([ 'tenant_id' => $tenantId, @@ -247,13 +263,14 @@ public function forceCreate(Request $request) 'result' => 'pass', 'request_date' => $date, 'inspection_date' => $date, - 'item_id' => $item->id, + 'item_id' => $receivingItem->id, 'lot_no' => $lotNo, 'meta' => [ 'quantity' => $qty, - 'unit' => $item->unit ?: 'EA', - 'item_code' => $item->code, - 'item_name' => $item->name, + 'unit' => $receivingItem->unit ?: 'EA', + 'supplier_name' => $supplier, + 'item_code' => $receivingItem->code, + 'item_name' => $receivingItem->name, 'force_created' => true, ], 'items' => [ @@ -268,13 +285,81 @@ public function forceCreate(Request $request) return [ 'receiving_number' => $receivingNumber, 'lot_no' => $lotNo, - 'item_code' => $item->code, - 'item_name' => $item->name, + 'rm_item_code' => $receivingItem->code, + 'rm_item_name' => $receivingItem->name, + 'pt_item_code' => $item->code, + 'pt_item_name' => $item->name, 'qty' => $qty, - 'available_qty' => $stock->available_qty, + 'available_qty' => $ptStock->available_qty, + 'matched_rm' => $rmItem ? true : false, ]; }); return ApiResponse::success($result, '입고 데이터가 강제 생성되었습니다.'); } + + /** + * Stock + StockLot 생성/갱신 헬퍼 + */ + private function createStockAndLot(Item $item, int $tenantId, int $qty, int $userId, string $date, string $lotNo, Receiving $receiving, string $orderNo, string $supplier): Stock + { + $itemTypeMap = ['FG' => 'purchased_part', 'PT' => 'bent_part', 'SM' => 'sub_material', 'RM' => 'raw_material', 'CS' => 'consumable']; + $stockType = $itemTypeMap[$item->item_type] ?? 'raw_material'; + + $stock = Stock::withoutGlobalScopes() + ->where('tenant_id', $tenantId) + ->where('item_id', $item->id) + ->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 = $userId; + $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' => 'A-01-01', + 'status' => 'normal', + 'last_receipt_date' => $date, + 'created_by' => $userId, + ]); + } + + $nextFifo = (StockLot::withoutGlobalScopes()->where('stock_id', $stock->id)->max('fifo_order') ?? 0) + 1; + StockLot::withoutGlobalScopes()->create([ + 'tenant_id' => $tenantId, + 'stock_id' => $stock->id, + 'lot_no' => $lotNo, + 'fifo_order' => $nextFifo, + 'receipt_date' => $date, + 'qty' => $qty, + 'reserved_qty' => 0, + 'available_qty' => $qty, + 'unit' => $item->unit ?: 'EA', + 'supplier' => $supplier, + 'supplier_lot' => $receiving->supplier_lot, + 'po_number' => $orderNo, + 'location' => 'A-01-01', + 'status' => 'available', + 'receiving_id' => $receiving->id, + 'created_by' => $userId, + ]); + + return $stock; + } }