fix: [자재투입] 입고 로트번호 기반으로 자재 목록 변경

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 05:06:28 +09:00
parent d4125dc4ad
commit 6318474b6f
3 changed files with 206 additions and 75 deletions

View File

@@ -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'));
}

View File

@@ -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,
];
});
}
/**
* 품목별 가용 재고 조회
*

View File

@@ -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(),