2025-12-26 15:45:48 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
use App\Models\Items\Item;
|
2026-01-26 20:29:22 +09:00
|
|
|
use App\Models\Tenants\Receiving;
|
2025-12-26 15:45:48 +09:00
|
|
|
use App\Models\Tenants\Stock;
|
2026-01-26 20:29:22 +09:00
|
|
|
use App\Models\Tenants\StockLot;
|
2026-01-29 15:05:03 +09:00
|
|
|
use App\Models\Tenants\StockTransaction;
|
2025-12-26 15:45:48 +09:00
|
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
2026-01-26 20:29:22 +09:00
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
2025-12-26 15:45:48 +09:00
|
|
|
|
|
|
|
|
class StockService extends Service
|
|
|
|
|
{
|
|
|
|
|
/**
|
2026-01-26 20:29:22 +09:00
|
|
|
* Item 타입 → 재고관리 라벨 매핑 (기본값)
|
2026-01-23 21:32:23 +09:00
|
|
|
*/
|
|
|
|
|
public const ITEM_TYPE_LABELS = [
|
|
|
|
|
'RM' => '원자재',
|
|
|
|
|
'SM' => '부자재',
|
|
|
|
|
'CS' => '소모품',
|
2026-01-26 20:29:22 +09:00
|
|
|
'PT' => '부품',
|
|
|
|
|
'SF' => '반제품',
|
2026-01-23 21:32:23 +09:00
|
|
|
];
|
|
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
private TenantSettingService $tenantSettingService;
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this->tenantSettingService = app(TenantSettingService::class);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 테넌트 설정에서 재고관리 품목유형 조회
|
|
|
|
|
*/
|
|
|
|
|
private function getStockItemTypes(): array
|
|
|
|
|
{
|
|
|
|
|
return $this->tenantSettingService->getStockItemTypes();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
/**
|
|
|
|
|
* 재고 목록 조회 (Item 메인 + Stock LEFT JOIN)
|
2025-12-26 15:45:48 +09:00
|
|
|
*/
|
|
|
|
|
public function index(array $params): LengthAwarePaginator
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-26 20:29:22 +09:00
|
|
|
$stockItemTypes = $this->getStockItemTypes();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
// Item 테이블이 메인 (테넌트 설정 기반 품목유형)
|
2026-01-23 21:32:23 +09:00
|
|
|
$query = Item::query()
|
|
|
|
|
->where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->with('stock');
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 검색어 필터 (Item 기준)
|
2025-12-26 15:45:48 +09:00
|
|
|
if (! empty($params['search'])) {
|
|
|
|
|
$search = $params['search'];
|
|
|
|
|
$query->where(function ($q) use ($search) {
|
2026-01-23 21:32:23 +09:00
|
|
|
$q->where('items.code', 'like', "%{$search}%")
|
|
|
|
|
->orWhere('items.name', 'like', "%{$search}%");
|
2025-12-26 15:45:48 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 품목유형 필터 (Item.item_type 기준: RM, SM, CS)
|
2025-12-26 15:45:48 +09:00
|
|
|
if (! empty($params['item_type'])) {
|
2026-01-23 21:32:23 +09:00
|
|
|
$query->where('items.item_type', strtoupper($params['item_type']));
|
2025-12-26 15:45:48 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 재고 상태 필터 (Stock.status)
|
2025-12-26 15:45:48 +09:00
|
|
|
if (! empty($params['status'])) {
|
2026-01-23 21:32:23 +09:00
|
|
|
$query->whereHas('stock', function ($q) use ($params) {
|
|
|
|
|
$q->where('status', $params['status']);
|
|
|
|
|
});
|
2025-12-26 15:45:48 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 위치 필터 (Stock.location)
|
2025-12-26 15:45:48 +09:00
|
|
|
if (! empty($params['location'])) {
|
2026-01-23 21:32:23 +09:00
|
|
|
$query->whereHas('stock', function ($q) use ($params) {
|
|
|
|
|
$q->where('location', 'like', "%{$params['location']}%");
|
|
|
|
|
});
|
2025-12-26 15:45:48 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 정렬
|
2026-01-23 21:32:23 +09:00
|
|
|
$sortBy = $params['sort_by'] ?? 'code';
|
2025-12-26 15:45:48 +09:00
|
|
|
$sortDir = $params['sort_dir'] ?? 'asc';
|
2026-01-23 21:32:23 +09:00
|
|
|
|
|
|
|
|
// 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);
|
2025-12-26 15:45:48 +09:00
|
|
|
|
|
|
|
|
// 페이지네이션
|
|
|
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
|
|
|
|
|
|
return $query->paginate($perPage);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-23 21:32:23 +09:00
|
|
|
* 재고 통계 조회 (Item 기준)
|
2025-12-26 15:45:48 +09:00
|
|
|
*/
|
|
|
|
|
public function stats(): array
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-26 20:29:22 +09:00
|
|
|
$stockItemTypes = $this->getStockItemTypes();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 전체 자재 품목 수 (Item 기준)
|
|
|
|
|
$totalItems = Item::where('tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->count();
|
|
|
|
|
|
|
|
|
|
// 재고 상태별 카운트 (Stock이 있는 Item 기준)
|
|
|
|
|
$normalCount = Item::where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->whereHas('stock', function ($q) {
|
|
|
|
|
$q->where('status', 'normal');
|
|
|
|
|
})
|
|
|
|
|
->count();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
$lowCount = Item::where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->whereHas('stock', function ($q) {
|
|
|
|
|
$q->where('status', 'low');
|
|
|
|
|
})
|
2025-12-26 15:45:48 +09:00
|
|
|
->count();
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
$outCount = Item::where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->whereHas('stock', function ($q) {
|
|
|
|
|
$q->where('status', 'out');
|
|
|
|
|
})
|
2025-12-26 15:45:48 +09:00
|
|
|
->count();
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 재고 정보가 없는 Item 수
|
|
|
|
|
$noStockCount = Item::where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->whereDoesntHave('stock')
|
2025-12-26 15:45:48 +09:00
|
|
|
->count();
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'total_items' => $totalItems,
|
|
|
|
|
'normal_count' => $normalCount,
|
|
|
|
|
'low_count' => $lowCount,
|
|
|
|
|
'out_count' => $outCount,
|
2026-01-23 21:32:23 +09:00
|
|
|
'no_stock_count' => $noStockCount,
|
2025-12-26 15:45:48 +09:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-23 21:32:23 +09:00
|
|
|
* 재고 상세 조회 (Item 기준, LOT 포함)
|
2025-12-26 15:45:48 +09:00
|
|
|
*/
|
2026-01-23 21:32:23 +09:00
|
|
|
public function show(int $id): Item
|
2025-12-26 15:45:48 +09:00
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-26 20:29:22 +09:00
|
|
|
$stockItemTypes = $this->getStockItemTypes();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
return Item::query()
|
2025-12-26 15:45:48 +09:00
|
|
|
->where('tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->with(['stock.lots' => function ($query) {
|
2025-12-26 15:45:48 +09:00
|
|
|
$query->orderBy('fifo_order');
|
|
|
|
|
}])
|
|
|
|
|
->findOrFail($id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-23 21:32:23 +09:00
|
|
|
* 품목코드로 재고 조회 (Item 기준)
|
2025-12-26 15:45:48 +09:00
|
|
|
*/
|
2026-01-23 21:32:23 +09:00
|
|
|
public function findByItemCode(string $itemCode): ?Item
|
2025-12-26 15:45:48 +09:00
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-26 20:29:22 +09:00
|
|
|
$stockItemTypes = $this->getStockItemTypes();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
return Item::query()
|
2025-12-26 15:45:48 +09:00
|
|
|
->where('tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->where('code', $itemCode)
|
|
|
|
|
->with('stock')
|
2025-12-26 15:45:48 +09:00
|
|
|
->first();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-23 21:32:23 +09:00
|
|
|
* 품목유형별 통계 (Item.item_type 기준)
|
2025-12-26 15:45:48 +09:00
|
|
|
*/
|
|
|
|
|
public function statsByItemType(): array
|
|
|
|
|
{
|
|
|
|
|
$tenantId = $this->tenantId();
|
2026-01-26 20:29:22 +09:00
|
|
|
$stockItemTypes = $this->getStockItemTypes();
|
2025-12-26 15:45:48 +09:00
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
// Item 기준으로 통계 (테넌트 설정 기반 품목유형)
|
2026-01-23 21:32:23 +09:00
|
|
|
$stats = Item::where('tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->selectRaw('item_type, COUNT(*) as count')
|
2025-12-26 15:45:48 +09:00
|
|
|
->groupBy('item_type')
|
|
|
|
|
->get()
|
|
|
|
|
->keyBy('item_type');
|
|
|
|
|
|
2026-01-23 21:32:23 +09:00
|
|
|
// 재고 수량 합계 (Stock이 있는 경우)
|
|
|
|
|
$stockQtys = Item::where('items.tenant_id', $tenantId)
|
2026-01-26 20:29:22 +09:00
|
|
|
->byItemTypes($stockItemTypes)
|
2026-01-23 21:32:23 +09:00
|
|
|
->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');
|
|
|
|
|
|
2025-12-26 15:45:48 +09:00
|
|
|
$result = [];
|
2026-01-26 20:29:22 +09:00
|
|
|
foreach ($stockItemTypes as $key) {
|
|
|
|
|
$label = self::ITEM_TYPE_LABELS[$key] ?? $key;
|
2026-01-23 21:32:23 +09:00
|
|
|
$itemData = $stats->get($key);
|
|
|
|
|
$stockData = $stockQtys->get($key);
|
2025-12-26 15:45:48 +09:00
|
|
|
$result[$key] = [
|
|
|
|
|
'label' => $label,
|
2026-01-23 21:32:23 +09:00
|
|
|
'count' => $itemData?->count ?? 0,
|
|
|
|
|
'total_qty' => $stockData?->total_qty ?? 0,
|
2025-12-26 15:45:48 +09:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
2026-01-26 20:29:22 +09:00
|
|
|
|
|
|
|
|
// =====================================================
|
|
|
|
|
// 재고 변동 이벤트 메서드 (입고/생산/출하 연동)
|
|
|
|
|
// =====================================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 입고 완료 시 재고 증가
|
|
|
|
|
*
|
|
|
|
|
* @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();
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
// 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. 감사 로그 기록
|
2026-01-26 20:29:22 +09:00
|
|
|
$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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 03:27:07 +09:00
|
|
|
/**
|
|
|
|
|
* 입고 수정 시 재고 조정 (차이만큼 증감)
|
|
|
|
|
*
|
|
|
|
|
* - 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,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
// 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. 감사 로그 기록
|
2026-01-26 20:29:22 +09:00
|
|
|
$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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 05:06:28 +09:00
|
|
|
/**
|
|
|
|
|
* 특정 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,
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 03:41:35 +09:00
|
|
|
/**
|
|
|
|
|
* 특정 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,
|
|
|
|
|
];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
/**
|
|
|
|
|
* 품목별 가용 재고 조회
|
|
|
|
|
*
|
|
|
|
|
* @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();
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
// 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. 감사 로그 기록
|
2026-01-26 20:29:22 +09:00
|
|
|
$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();
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
// 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. 감사 로그 기록
|
2026-01-26 20:29:22 +09:00
|
|
|
$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();
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
// 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. 감사 로그 기록
|
2026-01-26 20:29:22 +09:00
|
|
|
$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;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 15:05:03 +09:00
|
|
|
/**
|
|
|
|
|
* 재고 거래 이력 기록
|
|
|
|
|
*
|
|
|
|
|
* @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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-26 20:29:22 +09:00
|
|
|
/**
|
|
|
|
|
* 재고 변경 감사 로그 기록
|
|
|
|
|
*/
|
|
|
|
|
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(),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 15:45:48 +09:00
|
|
|
}
|