feat:테넌트설정 API 및 다수 서비스 개선
- TenantSetting CRUD API 추가 - Calendar, Entertainment, VAT 서비스 개선 - 5130 BOM 계산 로직 수정 - quote_items에 item_type 컬럼 추가 - tenant_settings 테이블 마이그레이션 - Swagger 문서 업데이트
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
use App\Models\Production\WorkOrder;
|
||||
use App\Models\Tenants\Leave;
|
||||
use App\Models\Tenants\Schedule;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
@@ -260,4 +259,4 @@ private function getGeneralSchedules(
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\ExpenseAccount;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -258,4 +257,4 @@ private function generateCheckPoints(
|
||||
|
||||
return $checkPoints;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,7 +432,7 @@ private function getMonthlyTrend(int $tenantId, ?string $transactionType = null)
|
||||
$date = now()->subMonths($i);
|
||||
$months[] = [
|
||||
'month' => $date->format('Y-m'),
|
||||
'label' => $date->format('n') . '월',
|
||||
'label' => $date->format('n').'월',
|
||||
'start' => $date->startOfMonth()->toDateString(),
|
||||
'end' => $date->endOfMonth()->toDateString(),
|
||||
];
|
||||
|
||||
@@ -201,11 +201,31 @@ public function update(int $id, array $data)
|
||||
|
||||
// 품목 교체 (있는 경우)
|
||||
if ($items !== null) {
|
||||
// 기존 품목의 floor_code/symbol_code 매핑 저장 (item_name + specification → floor_code/symbol_code)
|
||||
$existingMappings = [];
|
||||
foreach ($order->items as $existingItem) {
|
||||
$key = ($existingItem->item_name ?? '').'|'.($existingItem->specification ?? '');
|
||||
$existingMappings[$key] = [
|
||||
'floor_code' => $existingItem->floor_code,
|
||||
'symbol_code' => $existingItem->symbol_code,
|
||||
];
|
||||
}
|
||||
|
||||
$order->items()->delete();
|
||||
foreach ($items as $index => $item) {
|
||||
$item['tenant_id'] = $tenantId;
|
||||
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번
|
||||
$item['sort_order'] = $index;
|
||||
|
||||
// floor_code/symbol_code 보존: 프론트엔드에서 전달되지 않으면 기존 값 사용
|
||||
if (empty($item['floor_code']) || empty($item['symbol_code'])) {
|
||||
$key = ($item['item_name'] ?? '').'|'.($item['specification'] ?? '');
|
||||
if (isset($existingMappings[$key])) {
|
||||
$item['floor_code'] = $item['floor_code'] ?? $existingMappings[$key]['floor_code'];
|
||||
$item['symbol_code'] = $item['symbol_code'] ?? $existingMappings[$key]['symbol_code'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->calculateItemAmounts($item);
|
||||
$order->items()->create($item);
|
||||
}
|
||||
@@ -277,6 +297,7 @@ public function updateStatus(int $id, string $status)
|
||||
|
||||
return DB::transaction(function () use ($order, $status, $userId) {
|
||||
$createdSale = null;
|
||||
$previousStatus = $order->status_code;
|
||||
|
||||
// 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우)
|
||||
if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) {
|
||||
@@ -284,6 +305,18 @@ public function updateStatus(int $id, string $status)
|
||||
$order->sale_id = $createdSale->id;
|
||||
}
|
||||
|
||||
// 🆕 수주확정 시 재고 예약
|
||||
if ($status === Order::STATUS_CONFIRMED && $previousStatus !== Order::STATUS_CONFIRMED) {
|
||||
$order->load('items');
|
||||
app(StockService::class)->reserveForOrder($order->items, $order->id);
|
||||
}
|
||||
|
||||
// 🆕 수주취소 시 재고 예약 해제
|
||||
if ($status === Order::STATUS_CANCELLED && $previousStatus === Order::STATUS_CONFIRMED) {
|
||||
$order->load('items');
|
||||
app(StockService::class)->releaseReservationForOrder($order->items, $order->id);
|
||||
}
|
||||
|
||||
$order->status_code = $status;
|
||||
$order->updated_by = $userId;
|
||||
$order->save();
|
||||
@@ -437,14 +470,45 @@ public function createFromQuote(int $quoteId, array $data = [])
|
||||
|
||||
$order->save();
|
||||
|
||||
// calculation_inputs에서 제품-부품 매핑 정보 추출
|
||||
$calculationInputs = $quote->calculation_inputs ?? [];
|
||||
$calcInputItems = $calculationInputs['items'] ?? [];
|
||||
|
||||
// 견적 품목을 수주 품목으로 변환
|
||||
foreach ($quote->items as $index => $quoteItem) {
|
||||
// calculation_inputs.items에서 해당 품목의 floor/code 정보 찾기
|
||||
// 1. item_index로 매칭 시도
|
||||
// 2. 없으면 배열 인덱스로 fallback
|
||||
$floorCode = null;
|
||||
$symbolCode = null;
|
||||
|
||||
$itemIndex = $quoteItem->item_index ?? null;
|
||||
if ($itemIndex !== null) {
|
||||
// item_index로 매칭
|
||||
foreach ($calcInputItems as $calcItem) {
|
||||
if (($calcItem['index'] ?? null) === $itemIndex) {
|
||||
$floorCode = $calcItem['floor'] ?? null;
|
||||
$symbolCode = $calcItem['code'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// item_index로 못 찾으면 배열 인덱스로 fallback
|
||||
if ($floorCode === null && $symbolCode === null && isset($calcInputItems[$index])) {
|
||||
$floorCode = $calcInputItems[$index]['floor'] ?? null;
|
||||
$symbolCode = $calcInputItems[$index]['code'] ?? null;
|
||||
}
|
||||
|
||||
$order->items()->create([
|
||||
'tenant_id' => $tenantId,
|
||||
'serial_no' => $index + 1, // 1부터 시작하는 순번
|
||||
'item_id' => $quoteItem->item_id,
|
||||
'item_code' => $quoteItem->item_code,
|
||||
'item_name' => $quoteItem->item_name,
|
||||
'specification' => $quoteItem->specification,
|
||||
'floor_code' => $floorCode,
|
||||
'symbol_code' => $symbolCode,
|
||||
'quantity' => $quoteItem->calculated_quantity,
|
||||
'unit' => $quoteItem->unit,
|
||||
'unit_price' => $quoteItem->unit_price,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Quote;
|
||||
|
||||
use App\Models\Bidding\Bidding;
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Orders\Order;
|
||||
use App\Models\Orders\OrderItem;
|
||||
use App\Models\Quote\Quote;
|
||||
@@ -180,6 +181,7 @@ private function calculateBomMaterials(Quote $quote): array
|
||||
$allMaterials[] = [
|
||||
'item_index' => $index,
|
||||
'finished_goods_code' => $finishedGoodsCode,
|
||||
'item_id' => $material['item_id'] ?? null,
|
||||
'item_code' => $material['item_code'] ?? '',
|
||||
'item_name' => $material['item_name'] ?? '',
|
||||
'item_type' => $material['item_type'] ?? '',
|
||||
@@ -575,10 +577,17 @@ private function createItems(Quote $quote, array $items, int $tenantId): void
|
||||
$unitPrice = (float) ($item['unit_price'] ?? 0);
|
||||
$totalPrice = $quantity * $unitPrice;
|
||||
|
||||
// item_type: 전달된 값 또는 items 테이블에서 조회
|
||||
$itemType = $item['item_type'] ?? null;
|
||||
if ($itemType === null && isset($item['item_id'])) {
|
||||
$itemType = Item::where('id', $item['item_id'])->value('item_type');
|
||||
}
|
||||
|
||||
QuoteItem::create([
|
||||
'quote_id' => $quote->id,
|
||||
'tenant_id' => $tenantId,
|
||||
'item_id' => $item['item_id'] ?? null,
|
||||
'item_type' => $itemType,
|
||||
'item_code' => $item['item_code'] ?? '',
|
||||
'item_name' => $item['item_name'] ?? '',
|
||||
'specification' => $item['specification'] ?? null,
|
||||
|
||||
@@ -225,7 +225,7 @@ public function destroy(int $id): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* 입고처리 (상태 변경 + 입고 정보 입력)
|
||||
* 입고처리 (상태 변경 + 입고 정보 입력 + 재고 연동)
|
||||
*/
|
||||
public function process(int $id, array $data): Receiving
|
||||
{
|
||||
@@ -255,6 +255,11 @@ public function process(int $id, array $data): Receiving
|
||||
$receiving->updated_by = $userId;
|
||||
$receiving->save();
|
||||
|
||||
// 🆕 재고 연동: Stock + StockLot 생성/갱신
|
||||
if ($receiving->item_id) {
|
||||
app(StockService::class)->increaseFromReceiving($receiving);
|
||||
}
|
||||
|
||||
return $receiving->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,14 +329,65 @@ public function updateStatus(int $id, string $status, ?array $additionalData = n
|
||||
$updateData['confirmed_arrival'] = $additionalData['confirmed_arrival'];
|
||||
}
|
||||
|
||||
$previousStatus = $shipment->status;
|
||||
$shipment->update($updateData);
|
||||
|
||||
// 🆕 출하완료 시 재고 차감 (FIFO)
|
||||
if ($status === 'completed' && $previousStatus !== 'completed') {
|
||||
$this->decreaseStockForShipment($shipment);
|
||||
}
|
||||
|
||||
// 연결된 수주(Order) 상태 동기화
|
||||
$this->syncOrderStatus($shipment, $tenantId);
|
||||
|
||||
return $shipment->load('items');
|
||||
}
|
||||
|
||||
/**
|
||||
* 출하 완료 시 재고 차감
|
||||
*/
|
||||
private function decreaseStockForShipment(Shipment $shipment): void
|
||||
{
|
||||
$stockService = app(StockService::class);
|
||||
|
||||
// 출하 품목 조회
|
||||
$items = $shipment->items;
|
||||
|
||||
foreach ($items as $item) {
|
||||
// item_id가 없는 경우 item_code로 품목 조회 시도
|
||||
$itemId = $item->item_id;
|
||||
|
||||
if (! $itemId && $item->item_code) {
|
||||
$tenantId = $this->tenantId();
|
||||
$foundItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->where('code', $item->item_code)
|
||||
->first();
|
||||
$itemId = $foundItem?->id;
|
||||
}
|
||||
|
||||
if (! $itemId || ! $item->quantity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$stockService->decreaseForShipment(
|
||||
itemId: $itemId,
|
||||
qty: (float) $item->quantity,
|
||||
shipmentId: $shipment->id,
|
||||
stockLotId: $item->stock_lot_id
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
// 재고 부족 등의 에러는 로그만 기록하고 계속 진행
|
||||
\Illuminate\Support\Facades\Log::warning('Failed to decrease stock for shipment item', [
|
||||
'shipment_id' => $shipment->id,
|
||||
'item_code' => $item->item_code,
|
||||
'quantity' => $item->quantity,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 출하 상태 변경 시 연결된 수주(Order) 상태 동기화
|
||||
*
|
||||
@@ -411,6 +462,7 @@ protected function syncItems(Shipment $shipment, array $items, int $tenantId): v
|
||||
'tenant_id' => $tenantId,
|
||||
'shipment_id' => $shipment->id,
|
||||
'seq' => $item['seq'] ?? $seq,
|
||||
'item_id' => $item['item_id'] ?? null,
|
||||
'item_code' => $item['item_code'] ?? null,
|
||||
'item_name' => $item['item_name'],
|
||||
'floor_unit' => $item['floor_unit'] ?? null,
|
||||
|
||||
@@ -3,31 +3,53 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Items\Item;
|
||||
use App\Models\Tenants\Receiving;
|
||||
use App\Models\Tenants\Stock;
|
||||
use App\Models\Tenants\StockLot;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class StockService extends Service
|
||||
{
|
||||
/**
|
||||
* Item 타입 → 재고관리 라벨 매핑
|
||||
* 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 테이블이 메인 (materials 타입만: SM, RM, CS)
|
||||
// Item 테이블이 메인 (테넌트 설정 기반 품목유형)
|
||||
$query = Item::query()
|
||||
->where('items.tenant_id', $tenantId)
|
||||
->materials() // SM, RM, CS만
|
||||
->byItemTypes($stockItemTypes)
|
||||
->with('stock');
|
||||
|
||||
// 검색어 필터 (Item 기준)
|
||||
@@ -86,29 +108,30 @@ public function index(array $params): LengthAwarePaginator
|
||||
public function stats(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$stockItemTypes = $this->getStockItemTypes();
|
||||
|
||||
// 전체 자재 품목 수 (Item 기준)
|
||||
$totalItems = Item::where('tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->count();
|
||||
|
||||
// 재고 상태별 카운트 (Stock이 있는 Item 기준)
|
||||
$normalCount = Item::where('items.tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->whereHas('stock', function ($q) {
|
||||
$q->where('status', 'normal');
|
||||
})
|
||||
->count();
|
||||
|
||||
$lowCount = Item::where('items.tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->whereHas('stock', function ($q) {
|
||||
$q->where('status', 'low');
|
||||
})
|
||||
->count();
|
||||
|
||||
$outCount = Item::where('items.tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->whereHas('stock', function ($q) {
|
||||
$q->where('status', 'out');
|
||||
})
|
||||
@@ -116,7 +139,7 @@ public function stats(): array
|
||||
|
||||
// 재고 정보가 없는 Item 수
|
||||
$noStockCount = Item::where('items.tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->whereDoesntHave('stock')
|
||||
->count();
|
||||
|
||||
@@ -135,10 +158,11 @@ public function stats(): array
|
||||
public function show(int $id): Item
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$stockItemTypes = $this->getStockItemTypes();
|
||||
|
||||
return Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->with(['stock.lots' => function ($query) {
|
||||
$query->orderBy('fifo_order');
|
||||
}])
|
||||
@@ -151,10 +175,11 @@ public function show(int $id): Item
|
||||
public function findByItemCode(string $itemCode): ?Item
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$stockItemTypes = $this->getStockItemTypes();
|
||||
|
||||
return Item::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->where('code', $itemCode)
|
||||
->with('stock')
|
||||
->first();
|
||||
@@ -166,10 +191,11 @@ public function findByItemCode(string $itemCode): ?Item
|
||||
public function statsByItemType(): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$stockItemTypes = $this->getStockItemTypes();
|
||||
|
||||
// Item 기준으로 통계 (materials 타입만)
|
||||
// Item 기준으로 통계 (테넌트 설정 기반 품목유형)
|
||||
$stats = Item::where('tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->selectRaw('item_type, COUNT(*) as count')
|
||||
->groupBy('item_type')
|
||||
->get()
|
||||
@@ -177,7 +203,7 @@ public function statsByItemType(): array
|
||||
|
||||
// 재고 수량 합계 (Stock이 있는 경우)
|
||||
$stockQtys = Item::where('items.tenant_id', $tenantId)
|
||||
->materials()
|
||||
->byItemTypes($stockItemTypes)
|
||||
->join('stocks', 'items.id', '=', 'stocks.item_id')
|
||||
->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty')
|
||||
->groupBy('items.item_type')
|
||||
@@ -185,7 +211,8 @@ public function statsByItemType(): array
|
||||
->keyBy('item_type');
|
||||
|
||||
$result = [];
|
||||
foreach (self::ITEM_TYPE_LABELS as $key => $label) {
|
||||
foreach ($stockItemTypes as $key) {
|
||||
$label = self::ITEM_TYPE_LABELS[$key] ?? $key;
|
||||
$itemData = $stats->get($key);
|
||||
$stockData = $stockQtys->get($key);
|
||||
$result[$key] = [
|
||||
@@ -197,4 +224,684 @@ public function statsByItemType(): array
|
||||
|
||||
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->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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 감사 로그 기록
|
||||
$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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목별 가용 재고 조회
|
||||
*
|
||||
* @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. 감사 로그 기록
|
||||
$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. 감사 로그 기록
|
||||
$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. 감사 로그 기록
|
||||
$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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고 변경 감사 로그 기록
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
205
app/Services/TenantSettingService.php
Normal file
205
app/Services/TenantSettingService.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Tenants\TenantSetting;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* 테넌트 설정 서비스
|
||||
*
|
||||
* 테넌트별 설정값 관리
|
||||
* - 재고관리 품목유형
|
||||
* - 안전재고 기본값
|
||||
* - 알림 설정 등
|
||||
*/
|
||||
class TenantSettingService extends Service
|
||||
{
|
||||
/**
|
||||
* 설정 그룹 상수
|
||||
*/
|
||||
public const GROUP_STOCK = 'stock';
|
||||
|
||||
public const GROUP_ORDER = 'order';
|
||||
|
||||
public const GROUP_PRODUCTION = 'production';
|
||||
|
||||
public const GROUP_NOTIFICATION = 'notification';
|
||||
|
||||
/**
|
||||
* 설정 키 상수
|
||||
*/
|
||||
public const KEY_STOCK_ITEM_TYPES = 'stock_item_types';
|
||||
|
||||
public const KEY_DEFAULT_SAFETY_STOCK = 'default_safety_stock';
|
||||
|
||||
public const KEY_LOW_STOCK_ALERT = 'low_stock_alert';
|
||||
|
||||
/**
|
||||
* 기본 설정값
|
||||
*/
|
||||
private array $defaults = [
|
||||
self::GROUP_STOCK => [
|
||||
self::KEY_STOCK_ITEM_TYPES => ['RM', 'SM', 'CS', 'PT', 'SF'],
|
||||
self::KEY_DEFAULT_SAFETY_STOCK => 10,
|
||||
self::KEY_LOW_STOCK_ALERT => true,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* 특정 그룹의 모든 설정 조회
|
||||
*/
|
||||
public function getByGroup(string $group): Collection
|
||||
{
|
||||
return TenantSetting::where('tenant_id', $this->tenantId())
|
||||
->group($group)
|
||||
->get()
|
||||
->mapWithKeys(function ($setting) {
|
||||
return [$setting->setting_key => $setting->setting_value];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 설정 조회 (그룹별 구조화)
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
$settings = TenantSetting::where('tenant_id', $this->tenantId())
|
||||
->get()
|
||||
->groupBy('setting_group')
|
||||
->map(function ($group) {
|
||||
return $group->mapWithKeys(function ($setting) {
|
||||
return [$setting->setting_key => [
|
||||
'value' => $setting->setting_value,
|
||||
'description' => $setting->description,
|
||||
'updated_at' => $setting->updated_at?->toISOString(),
|
||||
]];
|
||||
});
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정값 조회 (기본값 포함)
|
||||
*/
|
||||
public function get(string $group, string $key, $default = null)
|
||||
{
|
||||
$value = TenantSetting::getValue($this->tenantId(), $group, $key);
|
||||
|
||||
if ($value !== null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 기본값에서 찾기
|
||||
if ($default === null && isset($this->defaults[$group][$key])) {
|
||||
return $this->defaults[$group][$key];
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정값 저장
|
||||
*/
|
||||
public function set(string $group, string $key, $value, ?string $description = null): TenantSetting
|
||||
{
|
||||
return TenantSetting::setValue(
|
||||
$this->tenantId(),
|
||||
$group,
|
||||
$key,
|
||||
$value,
|
||||
$description,
|
||||
$this->apiUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 설정값 일괄 저장
|
||||
*/
|
||||
public function setMany(string $group, array $settings): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($settings as $key => $data) {
|
||||
$value = is_array($data) && isset($data['value']) ? $data['value'] : $data;
|
||||
$description = is_array($data) && isset($data['description']) ? $data['description'] : null;
|
||||
|
||||
$results[$key] = $this->set($group, $key, $value, $description);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 삭제
|
||||
*/
|
||||
public function delete(string $group, string $key): bool
|
||||
{
|
||||
return TenantSetting::where('tenant_id', $this->tenantId())
|
||||
->where('setting_group', $group)
|
||||
->where('setting_key', $key)
|
||||
->delete() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고관리 품목유형 조회
|
||||
*/
|
||||
public function getStockItemTypes(): array
|
||||
{
|
||||
return $this->get(self::GROUP_STOCK, self::KEY_STOCK_ITEM_TYPES, ['RM', 'SM', 'CS', 'PT', 'SF']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 안전재고 조회
|
||||
*/
|
||||
public function getDefaultSafetyStock(): int
|
||||
{
|
||||
return (int) $this->get(self::GROUP_STOCK, self::KEY_DEFAULT_SAFETY_STOCK, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 재고부족 알림 활성화 여부
|
||||
*/
|
||||
public function isLowStockAlertEnabled(): bool
|
||||
{
|
||||
return (bool) $this->get(self::GROUP_STOCK, self::KEY_LOW_STOCK_ALERT, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 설정으로 초기화
|
||||
*/
|
||||
public function initializeDefaults(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->defaults as $group => $settings) {
|
||||
foreach ($settings as $key => $value) {
|
||||
// 기존 설정이 없을 때만 생성
|
||||
$existing = TenantSetting::getValue($this->tenantId(), $group, $key);
|
||||
if ($existing === null) {
|
||||
$results["{$group}.{$key}"] = $this->set($group, $key, $value, $this->getDefaultDescription($group, $key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 설명 가져오기
|
||||
*/
|
||||
private function getDefaultDescription(string $group, string $key): string
|
||||
{
|
||||
$descriptions = [
|
||||
self::GROUP_STOCK => [
|
||||
self::KEY_STOCK_ITEM_TYPES => '재고관리 대상 품목유형 (FG 완제품 제외)',
|
||||
self::KEY_DEFAULT_SAFETY_STOCK => '안전재고 기본값',
|
||||
self::KEY_LOW_STOCK_ALERT => '재고부족 알림 활성화',
|
||||
],
|
||||
];
|
||||
|
||||
return $descriptions[$group][$key] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\Tenants\TaxInvoice;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* 부가세 현황 서비스
|
||||
@@ -232,7 +231,7 @@ private function getPeriodLabel(int $year, string $periodType, int $period): str
|
||||
{
|
||||
return match ($periodType) {
|
||||
'quarter' => "{$year}년 {$period}기 예정신고",
|
||||
'half' => "{$year}년 " . ($period === 1 ? '상반기' : '하반기') . ' 확정신고',
|
||||
'half' => "{$year}년 ".($period === 1 ? '상반기' : '하반기').' 확정신고',
|
||||
'year' => "{$year}년 연간",
|
||||
default => "{$year}년 {$period}기",
|
||||
};
|
||||
@@ -258,4 +257,4 @@ private function getPreviousPeriod(int $year, string $periodType, int $period):
|
||||
: ['year' => $year, 'period' => $period - 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,21 +1025,20 @@ public function updateItemStatus(int $workOrderId, int $itemId, string $status)
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업지시에 필요한 자재 목록 조회 (BOM 기반)
|
||||
* 작업지시에 필요한 자재 목록 조회 (BOM 기반 + 실제 재고 연동)
|
||||
*
|
||||
* 작업지시의 품목에 연결된 BOM 자재 목록을 반환합니다.
|
||||
* 현재는 품목 정보 기반으로 Mock 데이터를 반환하며,
|
||||
* 향후 자재 관리 기능 구현 시 실제 데이터로 연동됩니다.
|
||||
* 작업지시의 품목에 연결된 BOM 자재 목록과 실제 재고 정보를 반환합니다.
|
||||
* 품목의 BOM 정보를 기반으로 필요 자재를 추출하고, 각 자재의 실제 재고를 조회합니다.
|
||||
*
|
||||
* @param int $workOrderId 작업지시 ID
|
||||
* @return array 자재 목록 (id, material_code, material_name, unit, current_stock, fifo_rank)
|
||||
* @return array 자재 목록 (item_id, material_code, material_name, unit, required_qty, current_stock, available_qty, fifo_rank)
|
||||
*/
|
||||
public function getMaterials(int $workOrderId): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
|
||||
$workOrder = WorkOrder::where('tenant_id', $tenantId)
|
||||
->with(['items.item', 'salesOrder.items'])
|
||||
->with(['items.item'])
|
||||
->find($workOrderId);
|
||||
|
||||
if (! $workOrder) {
|
||||
@@ -1048,29 +1047,75 @@ public function getMaterials(int $workOrderId): array
|
||||
|
||||
$materials = [];
|
||||
$rank = 1;
|
||||
$stockService = app(StockService::class);
|
||||
|
||||
// 1. WorkOrder 자체 items가 있으면 사용
|
||||
if ($workOrder->items && $workOrder->items->count() > 0) {
|
||||
foreach ($workOrder->items as $item) {
|
||||
$materials[] = [
|
||||
'id' => $item->id,
|
||||
'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "MAT-{$item->id}",
|
||||
'material_name' => $item->item_name ?? '자재 '.$item->id,
|
||||
'unit' => $item->unit ?? 'EA',
|
||||
'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요
|
||||
'fifo_rank' => $rank++,
|
||||
];
|
||||
// 작업지시 품목들의 BOM에서 자재 추출
|
||||
foreach ($workOrder->items as $woItem) {
|
||||
// item_id가 있으면 해당 Item의 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);
|
||||
|
||||
if (! $childItemId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 자재(자식 품목) 정보 조회
|
||||
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
|
||||
->find($childItemId);
|
||||
|
||||
if (! $childItem) {
|
||||
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',
|
||||
'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++,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. WorkOrder items가 없으면 SalesOrder items 사용
|
||||
elseif ($workOrder->salesOrder && $workOrder->salesOrder->items && $workOrder->salesOrder->items->count() > 0) {
|
||||
foreach ($workOrder->salesOrder->items as $item) {
|
||||
|
||||
// BOM이 없는 경우, 품목 자체를 자재로 취급 (Fallback)
|
||||
if (empty($materials) && $woItem->item_id) {
|
||||
$stockInfo = $stockService->getAvailableStock($woItem->item_id);
|
||||
|
||||
$materials[] = [
|
||||
'id' => $item->id,
|
||||
'material_code' => $item->item_id ? "MAT-{$item->item_id}" : "SO-{$item->id}",
|
||||
'material_name' => $item->item_name ?? '자재 '.$item->id,
|
||||
'unit' => $item->unit ?? 'EA',
|
||||
'current_stock' => 100, // Mock: 실제 재고 데이터 연동 필요
|
||||
'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,
|
||||
'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++,
|
||||
];
|
||||
}
|
||||
@@ -1080,16 +1125,18 @@ public function getMaterials(int $workOrderId): array
|
||||
}
|
||||
|
||||
/**
|
||||
* 자재 투입 등록
|
||||
* 자재 투입 등록 (재고 차감 포함)
|
||||
*
|
||||
* 작업지시에 자재 투입을 등록합니다.
|
||||
* 현재는 감사 로그만 기록하며, 향후 재고 차감 로직 추가 필요.
|
||||
* 작업지시에 자재 투입을 등록하고 재고를 차감합니다.
|
||||
* FIFO 기반으로 가장 오래된 LOT부터 차감합니다.
|
||||
*
|
||||
* @param int $workOrderId 작업지시 ID
|
||||
* @param array $materialIds 투입할 자재 ID 목록
|
||||
* @param array $materials 투입할 자재 목록 [['item_id' => int, 'qty' => float], ...]
|
||||
* @return array 투입 결과
|
||||
*
|
||||
* @throws \Exception 재고 부족 시
|
||||
*/
|
||||
public function registerMaterialInput(int $workOrderId, array $materialIds): array
|
||||
public function registerMaterialInput(int $workOrderId, array $materials): array
|
||||
{
|
||||
$tenantId = $this->tenantId();
|
||||
$userId = $this->apiUserId();
|
||||
@@ -1099,25 +1146,61 @@ public function registerMaterialInput(int $workOrderId, array $materialIds): arr
|
||||
throw new NotFoundHttpException(__('error.not_found'));
|
||||
}
|
||||
|
||||
// 자재 투입 감사 로그
|
||||
$this->auditLogger->log(
|
||||
$tenantId,
|
||||
self::AUDIT_TARGET,
|
||||
$workOrderId,
|
||||
'material_input',
|
||||
null,
|
||||
[
|
||||
'material_ids' => $materialIds,
|
||||
'input_by' => $userId,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
]
|
||||
);
|
||||
return DB::transaction(function () use ($materials, $tenantId, $userId, $workOrderId) {
|
||||
$stockService = app(StockService::class);
|
||||
$inputResults = [];
|
||||
|
||||
return [
|
||||
'work_order_id' => $workOrderId,
|
||||
'material_count' => count($materialIds),
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
];
|
||||
foreach ($materials as $material) {
|
||||
$itemId = $material['item_id'] ?? null;
|
||||
$qty = (float) ($material['qty'] ?? 0);
|
||||
|
||||
if (! $itemId || $qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FIFO 기반 재고 차감
|
||||
try {
|
||||
$deductedLots = $stockService->decreaseFIFO(
|
||||
itemId: $itemId,
|
||||
qty: $qty,
|
||||
reason: 'work_order_input',
|
||||
referenceId: $workOrderId
|
||||
);
|
||||
|
||||
$inputResults[] = [
|
||||
'item_id' => $itemId,
|
||||
'qty' => $qty,
|
||||
'status' => 'success',
|
||||
'deducted_lots' => $deductedLots,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
// 재고 부족 등의 오류는 전체 트랜잭션 롤백
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 투입 감사 로그
|
||||
$this->auditLogger->log(
|
||||
$tenantId,
|
||||
self::AUDIT_TARGET,
|
||||
$workOrderId,
|
||||
'material_input',
|
||||
null,
|
||||
[
|
||||
'materials' => $materials,
|
||||
'input_results' => $inputResults,
|
||||
'input_by' => $userId,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
]
|
||||
);
|
||||
|
||||
return [
|
||||
'work_order_id' => $workOrderId,
|
||||
'material_count' => count($inputResults),
|
||||
'input_results' => $inputResults,
|
||||
'input_at' => now()->toDateTimeString(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user