From 6318474b6f1b7b6e0c9fd0aed082ea3978f7cea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Feb 2026 05:06:28 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[=EC=9E=90=EC=9E=AC=ED=88=AC=EC=9E=85]?= =?UTF-8?q?=20=EC=9E=85=EA=B3=A0=20=EB=A1=9C=ED=8A=B8=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=9E=90=EC=9E=AC=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getMaterials(): 품목당 1행 → StockLot(입고 로트)당 1행으로 변경 - ITEM-{id} 가짜 로트번호 → Receiving에서 생성된 실제 lot_no 반환 - registerMaterialInput(): material_ids → stock_lot_id+qty 로트별 수량 차감 - StockService::decreaseFromLot() 신규 추가 (특정 로트 지정 차감) Co-Authored-By: Claude Opus 4.6 --- .../Api/V1/WorkOrderController.php | 4 +- app/Services/StockService.php | 101 ++++++++++ app/Services/WorkOrderService.php | 176 ++++++++++-------- 3 files changed, 206 insertions(+), 75 deletions(-) diff --git a/app/Http/Controllers/Api/V1/WorkOrderController.php b/app/Http/Controllers/Api/V1/WorkOrderController.php index b2f0d53..68b9489 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderController.php @@ -149,12 +149,12 @@ public function materials(int $id) } /** - * 자재 투입 등록 + * 자재 투입 등록 (로트별 수량 차감) */ public function registerMaterialInput(Request $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { - return $this->service->registerMaterialInput($id, $request->input('material_ids', [])); + return $this->service->registerMaterialInput($id, $request->input('inputs', [])); }, __('message.work_order.material_input_registered')); } diff --git a/app/Services/StockService.php b/app/Services/StockService.php index b337b09..c6032e6 100644 --- a/app/Services/StockService.php +++ b/app/Services/StockService.php @@ -602,6 +602,107 @@ public function decreaseFIFO(int $itemId, float $qty, string $reason, int $refer }); } + /** + * 특정 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, + ]; + }); + } + /** * 품목별 가용 재고 조회 * diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index f17d8f8..6ab3be8 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -1089,13 +1089,13 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status) } /** - * 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 실제 재고 연동) + * 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 로트별 재고) * - * 작업지시의 품목에 연결된 BOM 자재 목록과 실제 재고 정보를 반환합니다. - * 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다. + * 작업지시 품목의 BOM 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다. + * 로트번호는 입고관리(Receiving)에서 생성된 실제 로트번호입니다. * * @param int $workOrderId 작업지시 ID - * @return array 자재 목록 (item_id, material_code, material_name, unit, required_qty, current_stock, available_qty, fifo_rank) + * @return array 자재 목록 (로트 단위) */ public function getMaterials(int $workOrderId): array { @@ -1111,17 +1111,16 @@ public function getMaterials(int $workOrderId): array $materials = []; $rank = 1; - $stockService = app(StockService::class); - // 작업지시 품목들의 BOM에서 자재 추출 foreach ($workOrder->items as $woItem) { - // item_id가 있으면 해당 Item의 BOM 조회 + $materialItems = []; + + // BOM이 있으면 자식 품목들을 자재로 사용 if ($woItem->item_id) { $item = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($woItem->item_id); if ($item && ! empty($item->bom)) { - // BOM의 각 자재 처리 foreach ($item->bom as $bomItem) { $childItemId = $bomItem['child_item_id'] ?? null; $bomQty = (float) ($bomItem['qty'] ?? 1); @@ -1130,7 +1129,6 @@ public function getMaterials(int $workOrderId): array continue; } - // 자재(자식 품목) 정보 조회 $childItem = \App\Models\Items\Item::where('tenant_id', $tenantId) ->find($childItemId); @@ -1138,69 +1136,106 @@ public function getMaterials(int $workOrderId): array continue; } - // 필요 수량 계산 (BOM 수량 × 작업지시 수량) - $requiredQty = $bomQty * ($woItem->quantity ?? 1); - - // 실제 재고 조회 - $stockInfo = $stockService->getAvailableStock($childItemId); - - $materials[] = [ - 'item_id' => $childItemId, - 'work_order_item_id' => $woItem->id, - 'material_code' => $childItem->code, - 'material_name' => $childItem->name, - 'specification' => $childItem->specification, - 'unit' => $childItem->unit ?? 'EA', + $materialItems[] = [ + 'item' => $childItem, 'bom_qty' => $bomQty, - 'required_qty' => $requiredQty, - 'current_stock' => $stockInfo['stock_qty'] ?? 0, - 'available_qty' => $stockInfo['available_qty'] ?? 0, - 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, - 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= $requiredQty, - 'fifo_rank' => $rank++, + 'required_qty' => $bomQty * ($woItem->quantity ?? 1), + 'work_order_item_id' => $woItem->id, ]; } } } - // BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback) - if (empty($materials) && $woItem->item_id) { - $stockInfo = $stockService->getAvailableStock($woItem->item_id); - - $materials[] = [ - 'item_id' => $woItem->item_id, - 'work_order_item_id' => $woItem->id, - 'material_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null, - 'material_name' => $woItem->item_name, - 'specification' => $woItem->specification, - 'unit' => $woItem->unit ?? 'EA', + // BOM이 없으면 품목 자체를 자재로 사용 + if (empty($materialItems) && $woItem->item_id && $woItem->item) { + $materialItems[] = [ + 'item' => $woItem->item, 'bom_qty' => 1, 'required_qty' => $woItem->quantity ?? 1, - 'current_stock' => $stockInfo['stock_qty'] ?? 0, - 'available_qty' => $stockInfo['available_qty'] ?? 0, - 'reserved_qty' => $stockInfo['reserved_qty'] ?? 0, - 'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= ($woItem->quantity ?? 1), - 'fifo_rank' => $rank++, + 'work_order_item_id' => $woItem->id, ]; } + + // 각 자재별로 StockLot(입고 로트) 조회 + foreach ($materialItems as $matInfo) { + $materialItem = $matInfo['item']; + + // Stock 조회 + $stock = \App\Models\Tenants\Stock::where('tenant_id', $tenantId) + ->where('item_id', $materialItem->id) + ->first(); + + if ($stock) { + // 가용 로트를 FIFO 순서로 조회 + $lots = \App\Models\Tenants\StockLot::where('tenant_id', $tenantId) + ->where('stock_id', $stock->id) + ->where('status', 'available') + ->where('available_qty', '>', 0) + ->orderBy('fifo_order', 'asc') + ->get(); + + foreach ($lots as $lot) { + $materials[] = [ + 'stock_lot_id' => $lot->id, + 'item_id' => $materialItem->id, + 'work_order_item_id' => $matInfo['work_order_item_id'], + 'lot_no' => $lot->lot_no, + 'material_code' => $materialItem->code, + 'material_name' => $materialItem->name, + 'specification' => $materialItem->specification, + 'unit' => $lot->unit ?? $materialItem->unit ?? 'EA', + 'bom_qty' => $matInfo['bom_qty'], + 'required_qty' => $matInfo['required_qty'], + 'lot_qty' => (float) $lot->qty, + 'lot_available_qty' => (float) $lot->available_qty, + 'lot_reserved_qty' => (float) $lot->reserved_qty, + 'receipt_date' => $lot->receipt_date, + 'supplier' => $lot->supplier, + 'fifo_rank' => $rank++, + ]; + } + } + + // 가용 로트가 없는 경우 자재 정보만 반환 (재고 없음 표시) + $hasLots = collect($materials)->where('item_id', $materialItem->id)->isNotEmpty(); + if (! $hasLots) { + $materials[] = [ + 'stock_lot_id' => null, + 'item_id' => $materialItem->id, + 'work_order_item_id' => $matInfo['work_order_item_id'], + 'lot_no' => null, + 'material_code' => $materialItem->code, + 'material_name' => $materialItem->name, + 'specification' => $materialItem->specification, + 'unit' => $materialItem->unit ?? 'EA', + 'bom_qty' => $matInfo['bom_qty'], + 'required_qty' => $matInfo['required_qty'], + 'lot_qty' => 0, + 'lot_available_qty' => 0, + 'lot_reserved_qty' => 0, + 'receipt_date' => null, + 'supplier' => null, + 'fifo_rank' => $rank++, + ]; + } + } } return $materials; } /** - * 자재 투입 등록 (재고 차감 포함) + * 자재 투입 등록 (로트 지정 차감) * - * 작업지시에 자재 투입을 등록하고 재고를 차감합니다. - * FIFO 기반으로 가장 오래된 LOT부터 차감합니다. + * 사용자가 선택한 로트별로 지정 수량을 차감합니다. * * @param int $workOrderId 작업지시 ID - * @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...] + * @param array $inputs 투입 목록 [['stock_lot_id' => int, 'qty' => float], ...] * @return array 투입 결과 * * @throws \Exception 재고 부족 시 */ - public function registerMaterialInput(int $workOrderId, array $materials): array + public function registerMaterialInput(int $workOrderId, array $inputs): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); @@ -1210,37 +1245,32 @@ public function registerMaterialInput(int $workOrderId, array $materials): array throw new NotFoundHttpException(__('error.not_found')); } - return DB::transaction(function () use ($materials, $tenantId, $userId, $workOrderId) { + return DB::transaction(function () use ($inputs, $tenantId, $userId, $workOrderId) { $stockService = app(StockService::class); $inputResults = []; - foreach ($materials as $material) { - $itemId = $material['item_id'] ?? null; - $qty = (float) ($material['qty'] ?? 0); + foreach ($inputs as $input) { + $stockLotId = $input['stock_lot_id'] ?? null; + $qty = (float) ($input['qty'] ?? 0); - if (! $itemId || $qty <= 0) { + if (! $stockLotId || $qty <= 0) { continue; } - // FIFO 기반 재고 차감 - try { - $deductedLots = $stockService->decreaseFIFO( - itemId: $itemId, - qty: $qty, - reason: 'work_order_input', - referenceId: $workOrderId - ); + // 특정 로트에서 재고 차감 + $result = $stockService->decreaseFromLot( + stockLotId: $stockLotId, + qty: $qty, + reason: 'work_order_input', + referenceId: $workOrderId + ); - $inputResults[] = [ - 'item_id' => $itemId, - 'qty' => $qty, - 'status' => 'success', - 'deducted_lots' => $deductedLots, - ]; - } catch (\Exception $e) { - // 재고 부족 등의 오류는 전체 트랜잭션 롤백 - throw $e; - } + $inputResults[] = [ + 'stock_lot_id' => $stockLotId, + 'qty' => $qty, + 'status' => 'success', + 'deducted_lot' => $result, + ]; } // 자재 투입 감사 로그 @@ -1251,7 +1281,7 @@ public function registerMaterialInput(int $workOrderId, array $materials): array 'material_input', null, [ - 'materials' => $materials, + 'inputs' => $inputs, 'input_results' => $inputResults, 'input_by' => $userId, 'input_at' => now()->toDateTimeString(),