'원자재', 'SM' => '부자재', 'CS' => '소모품', 'PT' => '부품', 'SF' => '반제품', ]; private TenantSettingService $tenantSettingService; public function __construct() { $this->tenantSettingService = app(TenantSettingService::class); } /** * 테넌트 설정에서 재고관리 품목유형 조회 */ private function getStockItemTypes(): array { return $this->tenantSettingService->getStockItemTypes(); } /** * 재고 목록 조회 (Item 메인 + Stock LEFT JOIN) */ public function index(array $params): LengthAwarePaginator { $tenantId = $this->tenantId(); $stockItemTypes = $this->getStockItemTypes(); // Item 테이블이 메인 (테넌트 설정 기반 품목유형) $query = Item::query() ->where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->with('stock'); // 검색어 필터 (Item 기준) if (! empty($params['search'])) { $search = $params['search']; $query->where(function ($q) use ($search) { $q->where('items.code', 'like', "%{$search}%") ->orWhere('items.name', 'like', "%{$search}%"); }); } // 품목유형 필터 (Item.item_type 기준: RM, SM, CS) if (! empty($params['item_type'])) { $query->where('items.item_type', strtoupper($params['item_type'])); } // 품목 카테고리 필터 (Item.item_category: BENDING, SCREEN, STEEL 등) if (! empty($params['item_category'])) { $query->where('items.item_category', strtoupper($params['item_category'])); } // 재고 상태 필터 (Stock.status) if (! empty($params['status'])) { $query->whereHas('stock', function ($q) use ($params) { $q->where('status', $params['status']); }); } // 위치 필터 (Stock.location) if (! empty($params['location'])) { $query->whereHas('stock', function ($q) use ($params) { $q->where('location', 'like', "%{$params['location']}%"); }); } // 날짜 범위 필터 (해당 기간에 입출고 이력이 있는 품목만) if (! empty($params['start_date']) || ! empty($params['end_date'])) { $query->whereHas('stock', function ($stockQuery) use ($params) { $stockQuery->whereHas('transactions', function ($txQuery) use ($params) { if (! empty($params['start_date'])) { $txQuery->whereDate('created_at', '>=', $params['start_date']); } if (! empty($params['end_date'])) { $txQuery->whereDate('created_at', '<=', $params['end_date']); } }); }); } // 정렬 $sortBy = $params['sort_by'] ?? 'code'; $sortDir = $params['sort_dir'] ?? 'asc'; // Item 테이블 기준 정렬 필드 매핑 $sortMapping = [ 'item_code' => 'items.code', 'item_name' => 'items.name', 'item_type' => 'items.item_type', 'code' => 'items.code', 'name' => 'items.name', ]; $sortColumn = $sortMapping[$sortBy] ?? 'items.code'; $query->orderBy($sortColumn, $sortDir); // 페이지네이션 $perPage = $params['per_page'] ?? 20; return $query->paginate($perPage); } /** * 재고 통계 조회 (Item 기준) */ public function stats(): array { $tenantId = $this->tenantId(); $stockItemTypes = $this->getStockItemTypes(); // 전체 자재 품목 수 (Item 기준) $totalItems = Item::where('tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->count(); // 재고 상태별 카운트 (Stock이 있는 Item 기준) $normalCount = Item::where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'normal'); }) ->count(); $lowCount = Item::where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'low'); }) ->count(); $outCount = Item::where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->whereHas('stock', function ($q) { $q->where('status', 'out'); }) ->count(); // 재고 정보가 없는 Item 수 $noStockCount = Item::where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->whereDoesntHave('stock') ->count(); return [ 'total_items' => $totalItems, 'normal_count' => $normalCount, 'low_count' => $lowCount, 'out_count' => $outCount, 'no_stock_count' => $noStockCount, ]; } /** * 재고 상세 조회 (Item 기준, LOT 포함) */ public function show(int $id): Item { $tenantId = $this->tenantId(); $stockItemTypes = $this->getStockItemTypes(); return Item::query() ->where('tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->with(['stock.lots' => function ($query) { $query->orderBy('fifo_order'); }]) ->findOrFail($id); } /** * 품목코드로 재고 조회 (Item 기준) */ public function findByItemCode(string $itemCode): ?Item { $tenantId = $this->tenantId(); $stockItemTypes = $this->getStockItemTypes(); return Item::query() ->where('tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->where('code', $itemCode) ->with('stock') ->first(); } /** * 품목유형별 통계 (Item.item_type 기준) */ public function statsByItemType(): array { $tenantId = $this->tenantId(); $stockItemTypes = $this->getStockItemTypes(); // Item 기준으로 통계 (테넌트 설정 기반 품목유형) $stats = Item::where('tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->selectRaw('item_type, COUNT(*) as count') ->groupBy('item_type') ->get() ->keyBy('item_type'); // 재고 수량 합계 (Stock이 있는 경우) $stockQtys = Item::where('items.tenant_id', $tenantId) ->byItemTypes($stockItemTypes) ->join('stocks', 'items.id', '=', 'stocks.item_id') ->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty') ->groupBy('items.item_type') ->get() ->keyBy('item_type'); $result = []; foreach ($stockItemTypes as $key) { $label = self::ITEM_TYPE_LABELS[$key] ?? $key; $itemData = $stats->get($key); $stockData = $stockQtys->get($key); $result[$key] = [ 'label' => $label, 'count' => $itemData?->count ?? 0, 'total_qty' => $stockData?->total_qty ?? 0, ]; } return $result; } // ===================================================== // 재고 변동 이벤트 메서드 (입고/생산/출하 연동) // ===================================================== /** * 입고 완료 시 재고 증가 * * @param Receiving $receiving 입고 완료된 Receiving 레코드 * @return StockLot 생성된 StockLot * * @throws \Exception item_id가 없는 경우 */ public function increaseFromReceiving(Receiving $receiving): StockLot { if (! $receiving->item_id) { throw new \Exception(__('error.stock.item_id_required')); } $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($receiving, $tenantId, $userId) { // 1. Stock 조회 또는 생성 $stock = $this->getOrCreateStock($receiving->item_id, $receiving); // 2. FIFO 순서 계산 $fifoOrder = $this->getNextFifoOrder($stock->id); // 3. StockLot 생성 $stockLot = new StockLot; $stockLot->tenant_id = $tenantId; $stockLot->stock_id = $stock->id; $stockLot->lot_no = $receiving->lot_no; $stockLot->fifo_order = $fifoOrder; $stockLot->receipt_date = $receiving->receiving_date; $stockLot->qty = $receiving->receiving_qty; $stockLot->reserved_qty = 0; $stockLot->available_qty = $receiving->receiving_qty; $stockLot->unit = $receiving->order_unit ?? 'EA'; $stockLot->supplier = $receiving->supplier; $stockLot->supplier_lot = $receiving->supplier_lot; $stockLot->po_number = $receiving->order_no; $stockLot->location = $receiving->receiving_location; $stockLot->status = 'available'; $stockLot->receiving_id = $receiving->id; $stockLot->created_by = $userId; $stockLot->updated_by = $userId; $stockLot->save(); // 4. Stock 정보 갱신 (LOT 기반) $stock->refreshFromLots(); // 5. 거래 이력 기록 $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_IN, qty: $receiving->receiving_qty, reason: StockTransaction::REASON_RECEIVING, referenceType: 'receiving', referenceId: $receiving->id, lotNo: $receiving->lot_no, stockLotId: $stockLot->id ); // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_increase', reason: 'receiving', referenceType: 'receiving', referenceId: $receiving->id, qtyChange: $receiving->receiving_qty, lotNo: $receiving->lot_no ); Log::info('Stock increased from receiving', [ 'receiving_id' => $receiving->id, 'item_id' => $receiving->item_id, 'stock_id' => $stock->id, 'stock_lot_id' => $stockLot->id, 'qty' => $receiving->receiving_qty, ]); return $stockLot; }); } /** * 생산 완료 시 완성품 재고 입고 * * increaseFromReceiving()을 기반으로 구현. * 선생산(수주 없는 작업지시) 완료 시 양품을 재고로 적재. * * @param WorkOrder $workOrder 선생산 작업지시 * @param WorkOrderItem $woItem 작업지시 품목 * @param float $goodQty 양품 수량 * @param string $lotNo LOT 번호 * @return StockLot 생성된 StockLot */ public function increaseFromProduction( WorkOrder $workOrder, WorkOrderItem $woItem, float $goodQty, string $lotNo ): StockLot { if (! $woItem->item_id) { throw new \Exception(__('error.stock.item_id_required')); } $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($workOrder, $woItem, $goodQty, $lotNo, $tenantId, $userId) { // 1. Stock 조회 또는 생성 $stock = $this->getOrCreateStock($woItem->item_id); // 2. FIFO 순서 계산 $fifoOrder = $this->getNextFifoOrder($stock->id); // 3. StockLot 생성 $stockLot = new StockLot; $stockLot->tenant_id = $tenantId; $stockLot->stock_id = $stock->id; $stockLot->lot_no = $lotNo; $stockLot->fifo_order = $fifoOrder; $stockLot->receipt_date = now()->toDateString(); $stockLot->qty = $goodQty; $stockLot->reserved_qty = 0; $stockLot->available_qty = $goodQty; $stockLot->unit = $woItem->unit ?? 'EA'; $stockLot->supplier = null; $stockLot->supplier_lot = null; $stockLot->po_number = null; $stockLot->location = null; $stockLot->status = 'available'; $stockLot->receiving_id = null; $stockLot->work_order_id = $workOrder->id; $stockLot->created_by = $userId; $stockLot->updated_by = $userId; $stockLot->save(); // 4. Stock 합계 갱신 $stock->refreshFromLots(); // 5. 거래 이력 기록 $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_IN, qty: $goodQty, reason: StockTransaction::REASON_PRODUCTION_OUTPUT, referenceType: 'work_order', referenceId: $workOrder->id, lotNo: $lotNo, stockLotId: $stockLot->id ); // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'production_in', reason: 'production_output', referenceType: 'work_order', referenceId: $workOrder->id, qtyChange: $goodQty, lotNo: $lotNo ); Log::info('Stock increased from production', [ 'work_order_id' => $workOrder->id, 'item_id' => $woItem->item_id, 'stock_id' => $stock->id, 'stock_lot_id' => $stockLot->id, 'qty' => $goodQty, 'lot_no' => $lotNo, ]); return $stockLot; }); } /** * 입고 수정 시 재고 조정 (차이만큼 증감) * * - completed→completed 수량변경: 차이만큼 조정 (50→60 = +10) * - completed→대기: 전량 차감 (newQty = 0) * * @param Receiving $receiving 입고 레코드 * @param float $newQty 새 수량 (상태가 completed가 아니면 0) */ public function adjustFromReceiving(Receiving $receiving, float $newQty): void { if (! $receiving->item_id) { return; } $tenantId = $this->tenantId(); $userId = $this->apiUserId(); DB::transaction(function () use ($receiving, $newQty, $tenantId, $userId) { // 1. 해당 입고로 생성된 StockLot 조회 $stockLot = StockLot::where('tenant_id', $tenantId) ->where('receiving_id', $receiving->id) ->first(); if (! $stockLot) { Log::warning('StockLot not found for receiving adjustment', [ 'receiving_id' => $receiving->id, ]); return; } $stock = Stock::where('id', $stockLot->stock_id) ->lockForUpdate() ->first(); if (! $stock) { return; } $oldQty = (float) $stockLot->qty; $diff = $newQty - $oldQty; // 차이가 없으면 스킵 if (abs($diff) < 0.001) { return; } // 2. StockLot 수량 조정 $stockLot->qty = $newQty; $stockLot->available_qty = max(0, $newQty - $stockLot->reserved_qty); $stockLot->updated_by = $userId; if ($newQty <= 0) { $stockLot->qty = 0; $stockLot->available_qty = 0; $stockLot->reserved_qty = 0; $stockLot->status = 'used'; } else { $stockLot->status = 'available'; } $stockLot->save(); // 3. Stock 정보 갱신 $stock->refreshFromLots(); // 4. 거래 이력 기록 $this->recordTransaction( stock: $stock, type: $diff > 0 ? StockTransaction::TYPE_IN : StockTransaction::TYPE_OUT, qty: $diff, reason: StockTransaction::REASON_RECEIVING, referenceType: 'receiving', referenceId: $receiving->id, lotNo: $receiving->lot_no, stockLotId: $stockLot->id ); // 5. 감사 로그 기록 $this->logStockChange( stock: $stock, action: $diff > 0 ? 'stock_increase' : 'stock_decrease', reason: 'receiving_adjustment', referenceType: 'receiving', referenceId: $receiving->id, qtyChange: $diff, lotNo: $receiving->lot_no ); Log::info('Stock adjusted from receiving modification', [ 'receiving_id' => $receiving->id, 'item_id' => $receiving->item_id, 'stock_id' => $stock->id, 'old_qty' => $oldQty, 'new_qty' => $newQty, 'diff' => $diff, ]); }); } /** * Stock 조회 또는 생성 * * @param int $itemId 품목 ID * @param Receiving|null $receiving 입고 정보 (새 Stock 생성 시 사용) */ public function getOrCreateStock(int $itemId, ?Receiving $receiving = null): Stock { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $item = Item::where('tenant_id', $tenantId) ->findOrFail($itemId); // 1차: item_id로 조회 (SoftDeletes 포함) $stock = Stock::withTrashed() ->where('tenant_id', $tenantId) ->where('item_id', $itemId) ->first(); // 2차: item_code로 조회 (unique key 기준, item_id가 다를 수 있음) if (! $stock) { $stock = Stock::withTrashed() ->where('tenant_id', $tenantId) ->where('item_code', $item->code) ->first(); // item_id가 변경된 경우 업데이트 if ($stock && $stock->item_id !== $itemId) { $stock->item_id = $itemId; } } if ($stock) { if ($stock->trashed()) { $stock->restore(); $stock->status = 'out'; } $stock->item_name = $item->name; $stock->updated_by = $userId; $stock->save(); return $stock; } // Stock이 없으면 새로 생성 $stock = new Stock; $stock->tenant_id = $tenantId; $stock->item_id = $itemId; $stock->item_code = $item->code; $stock->item_name = $item->name; $stock->item_type = $item->item_type; $stock->specification = $item->specification ?? $receiving?->specification; $stock->unit = $item->unit ?? $receiving?->order_unit ?? 'EA'; $stock->stock_qty = 0; $stock->safety_stock = 0; $stock->reserved_qty = 0; $stock->available_qty = 0; $stock->lot_count = 0; $stock->location = $receiving?->receiving_location; $stock->status = 'out'; $stock->created_by = $userId; $stock->updated_by = $userId; $stock->save(); Log::info('New Stock created', [ 'stock_id' => $stock->id, 'item_id' => $itemId, 'item_code' => $item->code, ]); return $stock; } /** * 다음 FIFO 순서 계산 * * @param int $stockId Stock ID * @return int 다음 FIFO 순서 */ public function getNextFifoOrder(int $stockId): int { $maxOrder = StockLot::where('stock_id', $stockId) ->max('fifo_order'); return ($maxOrder ?? 0) + 1; } /** * FIFO 기반 재고 차감 * * @param int $itemId 품목 ID * @param float $qty 차감할 수량 * @param string $reason 차감 사유 (work_order_input, shipment 등) * @param int $referenceId 참조 ID (작업지시 ID, 출하 ID 등) * @return array 차감된 LOT 정보 배열 * * @throws \Exception 재고 부족 시 */ public function decreaseFIFO(int $itemId, float $qty, string $reason, int $referenceId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($itemId, $qty, $reason, $referenceId, $tenantId, $userId) { // 1. Stock 조회 $stock = Stock::where('tenant_id', $tenantId) ->where('item_id', $itemId) ->lockForUpdate() ->first(); if (! $stock) { throw new \Exception(__('error.stock.not_found')); } // 2. 가용 재고 확인 if ($stock->available_qty < $qty) { throw new \Exception(__('error.stock.insufficient_qty')); } // 3. FIFO 순서로 LOT 조회 (가용 수량이 있는 LOT만) $lots = StockLot::where('stock_id', $stock->id) ->where('status', '!=', 'used') ->where('available_qty', '>', 0) ->orderBy('fifo_order') ->lockForUpdate() ->get(); if ($lots->isEmpty()) { throw new \Exception(__('error.stock.lot_not_available')); } // 4. FIFO 순서로 차감 $remainingQty = $qty; $deductedLots = []; foreach ($lots as $lot) { if ($remainingQty <= 0) { break; } $deductQty = min($lot->available_qty, $remainingQty); // LOT 수량 차감 $lot->qty -= $deductQty; $lot->available_qty -= $deductQty; $lot->updated_by = $userId; // LOT 상태 업데이트 if ($lot->qty <= 0) { $lot->status = 'used'; } $lot->save(); $deductedLots[] = [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'deducted_qty' => $deductQty, 'remaining_qty' => $lot->qty, ]; $remainingQty -= $deductQty; } // 5. Stock 정보 갱신 $oldStockQty = $stock->stock_qty; $stock->refreshFromLots(); $stock->last_issue_date = now(); $stock->save(); // 6. 거래 이력 기록 (LOT별) foreach ($deductedLots as $dl) { $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_OUT, qty: -$dl['deducted_qty'], reason: $reason, referenceType: $reason, referenceId: $referenceId, lotNo: $dl['lot_no'], stockLotId: $dl['lot_id'] ); } // 7. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_decrease', reason: $reason, referenceType: $reason, referenceId: $referenceId, qtyChange: -$qty, lotNo: implode(',', array_column($deductedLots, 'lot_no')) ); Log::info('Stock decreased (FIFO)', [ 'item_id' => $itemId, 'stock_id' => $stock->id, 'qty' => $qty, 'reason' => $reason, 'reference_id' => $referenceId, 'old_stock_qty' => $oldStockQty, 'new_stock_qty' => $stock->stock_qty, 'deducted_lots' => $deductedLots, ]); return $deductedLots; }); } /** * 특정 StockLot에서 재고 차감 * * 사용자가 선택한 특정 로트에서 지정 수량만큼 차감합니다. * * @param int $stockLotId 차감할 StockLot ID * @param float $qty 차감 수량 * @param string $reason 차감 사유 * @param int $referenceId 참조 ID * @return array 차감 결과 * * @throws \Exception 재고 부족 또는 로트 없음 */ public function decreaseFromLot(int $stockLotId, float $qty, string $reason, int $referenceId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($stockLotId, $qty, $reason, $referenceId, $tenantId, $userId) { // 1. StockLot 조회 $lot = StockLot::where('tenant_id', $tenantId) ->where('id', $stockLotId) ->lockForUpdate() ->first(); if (! $lot) { throw new \Exception(__('error.stock.lot_not_available')); } if ($lot->available_qty < $qty) { throw new \Exception(__('error.stock.insufficient_qty')); } // 2. Stock 조회 $stock = Stock::where('id', $lot->stock_id) ->lockForUpdate() ->first(); if (! $stock) { throw new \Exception(__('error.stock.not_found')); } $oldStockQty = $stock->stock_qty; // 3. LOT 수량 차감 $lot->qty -= $qty; $lot->available_qty -= $qty; $lot->updated_by = $userId; if ($lot->qty <= 0) { $lot->status = 'used'; } $lot->save(); // 4. Stock 정보 갱신 $stock->refreshFromLots(); $stock->last_issue_date = now(); $stock->save(); // 5. 거래 이력 기록 $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_OUT, qty: -$qty, reason: $reason, referenceType: $reason, referenceId: $referenceId, lotNo: $lot->lot_no, stockLotId: $lot->id ); // 6. 감사 로그 $this->logStockChange( stock: $stock, action: 'stock_decrease', reason: $reason, referenceType: $reason, referenceId: $referenceId, qtyChange: -$qty, lotNo: $lot->lot_no ); Log::info('Stock decreased from specific lot', [ 'stock_lot_id' => $stockLotId, 'lot_no' => $lot->lot_no, 'qty' => $qty, 'reason' => $reason, 'reference_id' => $referenceId, 'old_stock_qty' => $oldStockQty, 'new_stock_qty' => $stock->stock_qty, ]); return [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'deducted_qty' => $qty, 'remaining_qty' => $lot->qty, ]; }); } /** * 특정 LOT에 수량 복원 (투입 취소, 삭제 등) * decreaseFromLot의 역방향 */ public function increaseToLot(int $stockLotId, float $qty, string $reason, int $referenceId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($stockLotId, $qty, $reason, $referenceId, $tenantId, $userId) { $lot = StockLot::where('tenant_id', $tenantId) ->where('id', $stockLotId) ->lockForUpdate() ->first(); if (! $lot) { throw new \Exception(__('error.stock.lot_not_available')); } $stock = Stock::where('id', $lot->stock_id) ->lockForUpdate() ->first(); if (! $stock) { throw new \Exception(__('error.stock.not_found')); } $oldStockQty = $stock->stock_qty; // LOT 수량 복원 $lot->qty += $qty; $lot->available_qty += $qty; $lot->updated_by = $userId; if ($lot->status === 'used' && $lot->qty > 0) { $lot->status = 'available'; } $lot->save(); // Stock 정보 갱신 $stock->refreshFromLots(); // 거래 이력 기록 $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_IN, qty: $qty, reason: $reason, referenceType: $reason, referenceId: $referenceId, lotNo: $lot->lot_no, stockLotId: $lot->id ); // 감사 로그 $this->logStockChange( stock: $stock, action: 'stock_increase', reason: $reason, referenceType: $reason, referenceId: $referenceId, qtyChange: $qty, lotNo: $lot->lot_no ); Log::info('Stock increased to specific lot', [ 'stock_lot_id' => $stockLotId, 'lot_no' => $lot->lot_no, 'qty' => $qty, 'reason' => $reason, 'reference_id' => $referenceId, 'old_stock_qty' => $oldStockQty, 'new_stock_qty' => $stock->stock_qty, ]); return [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'restored_qty' => $qty, 'remaining_qty' => $lot->qty, ]; }); } /** * 품목별 가용 재고 조회 * * @param int $itemId 품목 ID * @return array|null 재고 정보 (stock_qty, available_qty, reserved_qty, lot_count) */ public function getAvailableStock(int $itemId): ?array { $tenantId = $this->tenantId(); $stock = Stock::where('tenant_id', $tenantId) ->where('item_id', $itemId) ->first(); if (! $stock) { return null; } return [ 'stock_id' => $stock->id, 'item_id' => $stock->item_id, 'stock_qty' => (float) $stock->stock_qty, 'available_qty' => (float) $stock->available_qty, 'reserved_qty' => (float) $stock->reserved_qty, 'lot_count' => $stock->lot_count, 'status' => $stock->status, ]; } // ===================================================== // 재고 예약 메서드 (견적/수주 연동) // ===================================================== /** * 재고 예약 (수주 확정 시) * * @param int $itemId 품목 ID * @param float $qty 예약할 수량 * @param int $orderId 수주 ID (참조용) * * @throws \Exception 재고 부족 시 */ public function reserve(int $itemId, float $qty, int $orderId): void { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); DB::transaction(function () use ($itemId, $qty, $orderId, $tenantId, $userId) { // 1. Stock 조회 (락 적용) $stock = Stock::where('tenant_id', $tenantId) ->where('item_id', $itemId) ->lockForUpdate() ->first(); if (! $stock) { // 재고가 없으면 예약만 기록 (재고 부족이지만 수주는 가능) Log::warning('Stock not found for reservation', [ 'item_id' => $itemId, 'order_id' => $orderId, 'qty' => $qty, ]); return; } // 2. 가용 재고 확인 (경고만 기록, 예약은 진행) if ($stock->available_qty < $qty) { Log::warning('Insufficient stock for reservation', [ 'item_id' => $itemId, 'order_id' => $orderId, 'requested_qty' => $qty, 'available_qty' => $stock->available_qty, ]); } // 3. FIFO 순서로 LOT 예약 처리 $lots = StockLot::where('stock_id', $stock->id) ->where('status', '!=', 'used') ->where('available_qty', '>', 0) ->orderBy('fifo_order') ->lockForUpdate() ->get(); $remainingQty = $qty; $reservedLots = []; foreach ($lots as $lot) { if ($remainingQty <= 0) { break; } $reserveQty = min($lot->available_qty, $remainingQty); // LOT 예약 수량 증가 $lot->reserved_qty += $reserveQty; $lot->available_qty -= $reserveQty; $lot->updated_by = $userId; // 상태 업데이트 (전량 예약 시) if ($lot->available_qty <= 0) { $lot->status = 'reserved'; } $lot->save(); $reservedLots[] = [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'reserved_qty' => $reserveQty, ]; $remainingQty -= $reserveQty; } // 4. Stock 정보 갱신 $stock->refreshFromLots(); // 5. 거래 이력 기록 (LOT별) foreach ($reservedLots as $rl) { $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_RESERVE, qty: $rl['reserved_qty'], reason: StockTransaction::REASON_ORDER_CONFIRM, referenceType: 'order', referenceId: $orderId, lotNo: $rl['lot_no'], stockLotId: $rl['lot_id'] ); } // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_reserve', reason: 'order_confirm', referenceType: 'order', referenceId: $orderId, qtyChange: $qty, lotNo: implode(',', array_column($reservedLots, 'lot_no')) ); Log::info('Stock reserved for order', [ 'order_id' => $orderId, 'item_id' => $itemId, 'qty' => $qty, 'reserved_lots' => $reservedLots, ]); }); } /** * 재고 예약 해제 (수주 취소 시) * * @param int $itemId 품목 ID * @param float $qty 해제할 수량 * @param int $orderId 수주 ID (참조용) */ public function releaseReservation(int $itemId, float $qty, int $orderId): void { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); DB::transaction(function () use ($itemId, $qty, $orderId, $tenantId, $userId) { // 1. Stock 조회 $stock = Stock::where('tenant_id', $tenantId) ->where('item_id', $itemId) ->lockForUpdate() ->first(); if (! $stock) { Log::warning('Stock not found for release reservation', [ 'item_id' => $itemId, 'order_id' => $orderId, ]); return; } // 2. 예약된 LOT 조회 (FIFO 역순으로 해제) $lots = StockLot::where('stock_id', $stock->id) ->where('reserved_qty', '>', 0) ->orderByDesc('fifo_order') ->lockForUpdate() ->get(); $remainingQty = $qty; $releasedLots = []; foreach ($lots as $lot) { if ($remainingQty <= 0) { break; } $releaseQty = min($lot->reserved_qty, $remainingQty); // LOT 예약 해제 $lot->reserved_qty -= $releaseQty; $lot->available_qty += $releaseQty; $lot->updated_by = $userId; // 상태 업데이트 if ($lot->qty > 0 && $lot->status === 'reserved') { $lot->status = 'available'; } $lot->save(); $releasedLots[] = [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'released_qty' => $releaseQty, ]; $remainingQty -= $releaseQty; } // 3. Stock 정보 갱신 $stock->refreshFromLots(); // 4. 거래 이력 기록 (LOT별) foreach ($releasedLots as $rl) { $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_RELEASE, qty: -$rl['released_qty'], reason: StockTransaction::REASON_ORDER_CANCEL, referenceType: 'order', referenceId: $orderId, lotNo: $rl['lot_no'], stockLotId: $rl['lot_id'] ); } // 5. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_release', reason: 'order_cancel', referenceType: 'order', referenceId: $orderId, qtyChange: -$qty, lotNo: implode(',', array_column($releasedLots, 'lot_no')) ); Log::info('Stock reservation released', [ 'order_id' => $orderId, 'item_id' => $itemId, 'qty' => $qty, 'released_lots' => $releasedLots, ]); }); } /** * 수주 품목들의 재고 예약 (일괄 처리) * * @param \Illuminate\Support\Collection $orderItems 수주 품목 목록 * @param int $orderId 수주 ID */ public function reserveForOrder($orderItems, int $orderId): void { foreach ($orderItems as $item) { if (! $item->item_id) { continue; } $this->reserve( itemId: $item->item_id, qty: (float) $item->quantity, orderId: $orderId ); } } /** * 수주 품목들의 재고 예약 해제 (일괄 처리) * * @param \Illuminate\Support\Collection $orderItems 수주 품목 목록 * @param int $orderId 수주 ID */ public function releaseReservationForOrder($orderItems, int $orderId): void { foreach ($orderItems as $item) { if (! $item->item_id) { continue; } $this->releaseReservation( itemId: $item->item_id, qty: (float) $item->quantity, orderId: $orderId ); } } // ===================================================== // 출하 연동 메서드 // ===================================================== /** * 출하 완료 시 재고 차감 * * @param int $itemId 품목 ID * @param float $qty 차감할 수량 * @param int $shipmentId 출하 ID * @param int|null $stockLotId 특정 LOT ID (지정 시 해당 LOT만 차감) * @return array 차감된 LOT 정보 */ public function decreaseForShipment(int $itemId, float $qty, int $shipmentId, ?int $stockLotId = null): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($itemId, $qty, $shipmentId, $stockLotId, $tenantId, $userId) { // 1. Stock 조회 $stock = Stock::where('tenant_id', $tenantId) ->where('item_id', $itemId) ->lockForUpdate() ->first(); if (! $stock) { throw new \Exception(__('error.stock.not_found')); } // 2. LOT 조회 (특정 LOT 또는 FIFO) if ($stockLotId) { $lots = StockLot::where('id', $stockLotId) ->where('stock_id', $stock->id) ->lockForUpdate() ->get(); } else { $lots = StockLot::where('stock_id', $stock->id) ->where('status', '!=', 'used') ->where('qty', '>', 0) ->orderBy('fifo_order') ->lockForUpdate() ->get(); } if ($lots->isEmpty()) { throw new \Exception(__('error.stock.lot_not_available')); } // 3. 차감 처리 $remainingQty = $qty; $deductedLots = []; foreach ($lots as $lot) { if ($remainingQty <= 0) { break; } $deductQty = min($lot->qty, $remainingQty); // LOT 수량 차감 $lot->qty -= $deductQty; // 예약 수량도 동시에 차감 (출하는 예약된 재고를 사용) if ($lot->reserved_qty > 0) { $reserveDeduct = min($lot->reserved_qty, $deductQty); $lot->reserved_qty -= $reserveDeduct; } else { // 예약 없이 출하되는 경우 available에서 차감 $lot->available_qty = max(0, $lot->available_qty - $deductQty); } $lot->updated_by = $userId; // LOT 상태 업데이트 if ($lot->qty <= 0) { $lot->status = 'used'; $lot->available_qty = 0; $lot->reserved_qty = 0; } $lot->save(); $deductedLots[] = [ 'lot_id' => $lot->id, 'lot_no' => $lot->lot_no, 'deducted_qty' => $deductQty, 'remaining_qty' => $lot->qty, ]; $remainingQty -= $deductQty; } // 4. Stock 정보 갱신 $stock->refreshFromLots(); $stock->last_issue_date = now(); $stock->save(); // 5. 거래 이력 기록 (LOT별) foreach ($deductedLots as $dl) { $this->recordTransaction( stock: $stock, type: StockTransaction::TYPE_OUT, qty: -$dl['deducted_qty'], reason: StockTransaction::REASON_SHIPMENT, referenceType: 'shipment', referenceId: $shipmentId, lotNo: $dl['lot_no'], stockLotId: $dl['lot_id'] ); } // 6. 감사 로그 기록 $this->logStockChange( stock: $stock, action: 'stock_decrease', reason: 'shipment', referenceType: 'shipment', referenceId: $shipmentId, qtyChange: -$qty, lotNo: implode(',', array_column($deductedLots, 'lot_no')) ); Log::info('Stock decreased for shipment', [ 'shipment_id' => $shipmentId, 'item_id' => $itemId, 'qty' => $qty, 'deducted_lots' => $deductedLots, ]); return $deductedLots; }); } /** * 재고 거래 이력 기록 * * @param Stock $stock 재고 * @param string $type 거래유형 (IN, OUT, RESERVE, RELEASE) * @param float $qty 변동 수량 (입고: 양수, 출고: 음수) * @param string $reason 사유 * @param string $referenceType 참조 유형 * @param int $referenceId 참조 ID * @param string|null $lotNo LOT번호 * @param int|null $stockLotId StockLot ID * @param string|null $remark 비고 */ private function recordTransaction( Stock $stock, string $type, float $qty, string $reason, string $referenceType, int $referenceId, ?string $lotNo = null, ?int $stockLotId = null, ?string $remark = null ): void { try { StockTransaction::create([ 'tenant_id' => $stock->tenant_id, 'stock_id' => $stock->id, 'stock_lot_id' => $stockLotId, 'type' => $type, 'qty' => $qty, 'balance_qty' => (float) $stock->stock_qty, 'reference_type' => $referenceType, 'reference_id' => $referenceId, 'lot_no' => $lotNo, 'reason' => $reason, 'remark' => $remark, 'item_code' => $stock->item_code, 'item_name' => $stock->item_name, 'created_by' => $this->apiUserId(), ]); } catch (\Exception $e) { // 거래 이력 기록 실패는 비즈니스 로직에 영향을 주지 않음 Log::warning('Failed to record stock transaction', [ 'stock_id' => $stock->id, 'type' => $type, 'qty' => $qty, 'error' => $e->getMessage(), ]); } } /** * 재고 변경 감사 로그 기록 */ private function logStockChange( Stock $stock, string $action, string $reason, string $referenceType, int $referenceId, float $qtyChange, ?string $lotNo = null ): void { try { \App\Models\Audit\AuditLog::create([ 'tenant_id' => $stock->tenant_id, 'target_type' => 'Stock', 'target_id' => $stock->id, 'action' => $action, 'before' => [ 'stock_qty' => (float) ($stock->getOriginal('stock_qty') ?? 0), ], 'after' => [ 'stock_qty' => (float) $stock->stock_qty, 'qty_change' => $qtyChange, 'reason' => $reason, 'reference_type' => $referenceType, 'reference_id' => $referenceId, 'lot_no' => $lotNo, ], 'actor_id' => $this->apiUserId(), 'ip' => request()->ip(), 'ua' => request()->userAgent(), 'created_at' => now(), ]); } catch (\Exception $e) { // 감사 로그 실패는 비즈니스 로직에 영향을 주지 않음 Log::warning('Failed to create audit log for stock change', [ 'stock_id' => $stock->id, 'action' => $action, 'error' => $e->getMessage(), ]); } } }