Files
sam-api/app/Services/StockService.php
권혁성 25e21ee6d7 feat(WEB): 절곡품 선생산→재고적재 Phase 2 - 품목 카테고리 필터 추가
- StockController: item_category 파라미터 수용
- StockService: items.item_category 기반 필터링 로직 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 04:19:47 +09:00

1415 lines
46 KiB
PHP

<?php
namespace App\Services;
use App\Models\Items\Item;
use App\Models\Production\WorkOrder;
use App\Models\Production\WorkOrderItem;
use App\Models\Tenants\Receiving;
use App\Models\Tenants\Stock;
use App\Models\Tenants\StockLot;
use App\Models\Tenants\StockTransaction;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class StockService extends Service
{
/**
* Item 타입 → 재고관리 라벨 매핑 (기본값)
*/
public const ITEM_TYPE_LABELS = [
'RM' => '원자재',
'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']}%");
});
}
// 정렬
$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();
$stock = Stock::where('tenant_id', $tenantId)
->where('item_id', $itemId)
->first();
if ($stock) {
return $stock;
}
// Stock이 없으면 새로 생성
$item = Item::where('tenant_id', $tenantId)
->findOrFail($itemId);
$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(),
]);
}
}
}