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)
|
public function registerMaterialInput(Request $request, int $id)
|
||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($request, $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'));
|
}, __('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 자재별로 StockLot(입고 로트)를 FIFO 순서로 반환합니다.
|
||||||
* 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다.
|
* 로트번호는 입고관리(Receiving)에서 생성된 실제 로트번호입니다.
|
||||||
*
|
*
|
||||||
* @param int $workOrderId 작업지시 ID
|
* @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
|
public function getMaterials(int $workOrderId): array
|
||||||
{
|
{
|
||||||
@@ -1111,17 +1111,16 @@ public function getMaterials(int $workOrderId): array
|
|||||||
|
|
||||||
$materials = [];
|
$materials = [];
|
||||||
$rank = 1;
|
$rank = 1;
|
||||||
$stockService = app(StockService::class);
|
|
||||||
|
|
||||||
// 작업지시 품목들의 BOM에서 자재 추출
|
|
||||||
foreach ($workOrder->items as $woItem) {
|
foreach ($workOrder->items as $woItem) {
|
||||||
// item_id가 있으면 해당 Item의 BOM 조회
|
$materialItems = [];
|
||||||
|
|
||||||
|
// BOM이 있으면 자식 품목들을 자재로 사용
|
||||||
if ($woItem->item_id) {
|
if ($woItem->item_id) {
|
||||||
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
->find($woItem->item_id);
|
->find($woItem->item_id);
|
||||||
|
|
||||||
if ($item && ! empty($item->bom)) {
|
if ($item && ! empty($item->bom)) {
|
||||||
// BOM의 각 자재 처리
|
|
||||||
foreach ($item->bom as $bomItem) {
|
foreach ($item->bom as $bomItem) {
|
||||||
$childItemId = $bomItem['child_item_id'] ?? null;
|
$childItemId = $bomItem['child_item_id'] ?? null;
|
||||||
$bomQty = (float) ($bomItem['qty'] ?? 1);
|
$bomQty = (float) ($bomItem['qty'] ?? 1);
|
||||||
@@ -1130,7 +1129,6 @@ public function getMaterials(int $workOrderId): array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자재(자식 품목) 정보 조회
|
|
||||||
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||||
->find($childItemId);
|
->find($childItemId);
|
||||||
|
|
||||||
@@ -1138,69 +1136,106 @@ public function getMaterials(int $workOrderId): array
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필요 수량 계산 (BOM 수량 × 작업지시 수량)
|
$materialItems[] = [
|
||||||
$requiredQty = $bomQty * ($woItem->quantity ?? 1);
|
'item' => $childItem,
|
||||||
|
|
||||||
// 실제 재고 조회
|
|
||||||
$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',
|
|
||||||
'bom_qty' => $bomQty,
|
'bom_qty' => $bomQty,
|
||||||
'required_qty' => $requiredQty,
|
'required_qty' => $bomQty * ($woItem->quantity ?? 1),
|
||||||
'current_stock' => $stockInfo['stock_qty'] ?? 0,
|
'work_order_item_id' => $woItem->id,
|
||||||
'available_qty' => $stockInfo['available_qty'] ?? 0,
|
|
||||||
'reserved_qty' => $stockInfo['reserved_qty'] ?? 0,
|
|
||||||
'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= $requiredQty,
|
|
||||||
'fifo_rank' => $rank++,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback)
|
// BOM이 없으면 품목 자체를 자재로 사용
|
||||||
if (empty($materials) && $woItem->item_id) {
|
if (empty($materialItems) && $woItem->item_id && $woItem->item) {
|
||||||
$stockInfo = $stockService->getAvailableStock($woItem->item_id);
|
$materialItems[] = [
|
||||||
|
'item' => $woItem->item,
|
||||||
$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_qty' => 1,
|
'bom_qty' => 1,
|
||||||
'required_qty' => $woItem->quantity ?? 1,
|
'required_qty' => $woItem->quantity ?? 1,
|
||||||
'current_stock' => $stockInfo['stock_qty'] ?? 0,
|
'work_order_item_id' => $woItem->id,
|
||||||
'available_qty' => $stockInfo['available_qty'] ?? 0,
|
|
||||||
'reserved_qty' => $stockInfo['reserved_qty'] ?? 0,
|
|
||||||
'is_sufficient' => ($stockInfo['available_qty'] ?? 0) >= ($woItem->quantity ?? 1),
|
|
||||||
'fifo_rank' => $rank++,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 각 자재별로 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;
|
return $materials;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 자재 투입 등록 (재고 차감 포함)
|
* 자재 투입 등록 (로트 지정 차감)
|
||||||
*
|
*
|
||||||
* 작업지시에 자재 투입을 등록하고 재고를 차감합니다.
|
* 사용자가 선택한 로트별로 지정 수량을 차감합니다.
|
||||||
* FIFO 기반으로 가장 오래된 LOT부터 차감합니다.
|
|
||||||
*
|
*
|
||||||
* @param int $workOrderId 작업지시 ID
|
* @param int $workOrderId 작업지시 ID
|
||||||
* @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...]
|
* @param array $inputs 투입 목록 [['stock_lot_id' => int, 'qty' => float], ...]
|
||||||
* @return array 투입 결과
|
* @return array 투입 결과
|
||||||
*
|
*
|
||||||
* @throws \Exception 재고 부족 시
|
* @throws \Exception 재고 부족 시
|
||||||
*/
|
*/
|
||||||
public function registerMaterialInput(int $workOrderId, array $materials): array
|
public function registerMaterialInput(int $workOrderId, array $inputs): array
|
||||||
{
|
{
|
||||||
$tenantId = $this->tenantId();
|
$tenantId = $this->tenantId();
|
||||||
$userId = $this->apiUserId();
|
$userId = $this->apiUserId();
|
||||||
@@ -1210,37 +1245,32 @@ public function registerMaterialInput(int $workOrderId, array $materials): array
|
|||||||
throw new NotFoundHttpException(__('error.not_found'));
|
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);
|
$stockService = app(StockService::class);
|
||||||
$inputResults = [];
|
$inputResults = [];
|
||||||
|
|
||||||
foreach ($materials as $material) {
|
foreach ($inputs as $input) {
|
||||||
$itemId = $material['item_id'] ?? null;
|
$stockLotId = $input['stock_lot_id'] ?? null;
|
||||||
$qty = (float) ($material['qty'] ?? 0);
|
$qty = (float) ($input['qty'] ?? 0);
|
||||||
|
|
||||||
if (! $itemId || $qty <= 0) {
|
if (! $stockLotId || $qty <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIFO 기반 재고 차감
|
// 특정 로트에서 재고 차감
|
||||||
try {
|
$result = $stockService->decreaseFromLot(
|
||||||
$deductedLots = $stockService->decreaseFIFO(
|
stockLotId: $stockLotId,
|
||||||
itemId: $itemId,
|
qty: $qty,
|
||||||
qty: $qty,
|
reason: 'work_order_input',
|
||||||
reason: 'work_order_input',
|
referenceId: $workOrderId
|
||||||
referenceId: $workOrderId
|
);
|
||||||
);
|
|
||||||
|
|
||||||
$inputResults[] = [
|
$inputResults[] = [
|
||||||
'item_id' => $itemId,
|
'stock_lot_id' => $stockLotId,
|
||||||
'qty' => $qty,
|
'qty' => $qty,
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'deducted_lots' => $deductedLots,
|
'deducted_lot' => $result,
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
|
||||||
// 재고 부족 등의 오류는 전체 트랜잭션 롤백
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자재 투입 감사 로그
|
// 자재 투입 감사 로그
|
||||||
@@ -1251,7 +1281,7 @@ public function registerMaterialInput(int $workOrderId, array $materials): array
|
|||||||
'material_input',
|
'material_input',
|
||||||
null,
|
null,
|
||||||
[
|
[
|
||||||
'materials' => $materials,
|
'inputs' => $inputs,
|
||||||
'input_results' => $inputResults,
|
'input_results' => $inputResults,
|
||||||
'input_by' => $userId,
|
'input_by' => $userId,
|
||||||
'input_at' => now()->toDateTimeString(),
|
'input_at' => now()->toDateTimeString(),
|
||||||
|
|||||||
Reference in New Issue
Block a user