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:
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목별 가용 재고 조회
|
||||
*
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user