only([ 'search', 'status', 'start_date', 'end_date', 'sort_by', 'sort_dir', 'per_page', 'page', ]); $receivings = $this->service->index($params); return ApiResponse::success($receivings, __('message.fetched')); } /** * 입고 통계 */ public function stats() { $stats = $this->service->stats(); return ApiResponse::success($stats, __('message.fetched')); } /** * 입고 등록 */ public function store(StoreReceivingRequest $request) { $receiving = $this->service->store($request->validated()); return ApiResponse::success($receiving, __('message.created'), [], 201); } /** * 입고 상세 */ public function show(int $id) { $receiving = $this->service->show($id); return ApiResponse::success($receiving, __('message.fetched')); } /** * 입고 수정 */ public function update(int $id, UpdateReceivingRequest $request) { $receiving = $this->service->update($id, $request->validated()); return ApiResponse::success($receiving, __('message.updated')); } /** * 입고 삭제 */ public function destroy(int $id) { $this->service->destroy($id); return ApiResponse::success(null, __('message.deleted')); } /** * 입고처리 (상태 변경 + 입고 정보 입력) */ public function process(int $id, ProcessReceivingRequest $request) { $receiving = $this->service->process($id, $request->validated()); return ApiResponse::success($receiving, __('message.receiving.processed')); } /** * [개발전용] 입고 강제 생성 (원자재 입고 + 재공품 재고 + 수입검사 한번에) * * POST /api/v1/dev/force-receiving * Body: { item_id: number, qty?: number } * * PT(재공품) 품목이면 → material 속성에서 대응 RM(원자재) 찾아 입고 생성 * + 원래 PT 품목의 재고도 함께 생성 (자재투입 매칭용) */ public function forceCreate(Request $request) { $request->validate([ 'item_id' => 'required|integer', 'qty' => 'nullable|integer|min:1|max:10000', ]); $tenantId = app('tenant_id'); $itemId = $request->input('item_id'); $qty = $request->input('qty', 100); $userId = auth()->id() ?? 33; $date = now()->toDateString(); $item = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('id', $itemId) ->first(); if (! $item) { return ApiResponse::error(__('error.not_found'), 404); } $result = DB::transaction(function () use ($item, $tenantId, $qty, $userId, $date) { $datePrefix = date('Ymd', strtotime($date)); $dateShort = date('ymd', strtotime($date)); // 채번 $receivingSeq = (Receiving::withoutGlobalScopes()->where('tenant_id', $tenantId) ->where('receiving_number', 'LIKE', "RV{$datePrefix}%")->count()) + 1; $lotSeq = (StockLot::withoutGlobalScopes()->where('tenant_id', $tenantId) ->where('lot_no', 'LIKE', "{$dateShort}-%")->count()) + 1; $inspSeq = (Inspection::withoutGlobalScopes()->where('tenant_id', $tenantId) ->where('inspection_no', 'LIKE', "IQC-{$dateShort}-%")->count()) + 1; $receivingNumber = 'RV'.$datePrefix.str_pad($receivingSeq, 4, '0', STR_PAD_LEFT); $lotNo = $dateShort.'-'.str_pad($lotSeq, 2, '0', STR_PAD_LEFT); $orderNo = 'PO-'.$dateShort.'-DEV'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT); // 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' => $receivingItem->id, 'item_code' => $receivingItem->code, 'item_name' => $receivingItem->name, 'supplier' => $supplier, 'order_qty' => $qty, 'order_unit' => $receivingItem->unit ?: 'EA', 'due_date' => $date, 'receiving_qty' => $qty, 'receiving_date' => $date, 'lot_no' => $lotNo, 'supplier_lot' => 'SUP-'.rand(1000, 9999), 'receiving_location' => 'A-01-01', 'receiving_manager' => '관리자', 'status' => 'inspection_completed', '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-A. 원자재(RM) Stock/StockLot 생성 (입고 이력용) if ($rmItem && $rmItem->id !== $item->id) { $this->createStockAndLot($rmItem, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier); } // 2-B. 재공품(PT) Stock/StockLot 생성 (자재투입 매칭용) $ptStock = $this->createStockAndLot($item, $tenantId, $qty, $userId, $date, $lotNo, $receiving, $orderNo, $supplier); // 3. IQC 수입검사 생성 (합격) $inspectionNo = 'IQC-'.$dateShort.'-'.str_pad($inspSeq, 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' => $receivingItem->id, 'lot_no' => $lotNo, 'meta' => [ 'quantity' => $qty, 'unit' => $receivingItem->unit ?: 'EA', 'supplier_name' => $supplier, 'item_code' => $receivingItem->code, 'item_name' => $receivingItem->name, 'force_created' => true, ], 'items' => [ ['item' => '외관검사', 'standard' => '이상 없을 것', 'result' => '양호', 'judgment' => '적'], ['item' => '치수검사', 'standard' => '규격 일치', 'result' => '양호', 'judgment' => '적'], ], 'extra' => ['remarks' => '[개발전용] 자동 합격', 'opinion' => '양호'], 'created_by' => $userId, 'updated_by' => $userId, ]); return [ 'receiving_number' => $receivingNumber, 'lot_no' => $lotNo, 'rm_item_code' => $receivingItem->code, 'rm_item_name' => $receivingItem->name, 'pt_item_code' => $item->code, 'pt_item_name' => $item->name, 'qty' => $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; } }