- BadDebt 컨트롤러/서비스 기능 확장 - StockService 재고 조회 로직 개선 - ProcessReceivingRequest 검증 규칙 수정 - Item, Order, CommonCode, Shipment 모델 업데이트 - TodayIssueObserverService 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
201 lines
5.8 KiB
PHP
201 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Items\Item;
|
|
use App\Models\Tenants\Stock;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
|
|
class StockService extends Service
|
|
{
|
|
/**
|
|
* Item 타입 → 재고관리 라벨 매핑
|
|
*/
|
|
public const ITEM_TYPE_LABELS = [
|
|
'RM' => '원자재',
|
|
'SM' => '부자재',
|
|
'CS' => '소모품',
|
|
];
|
|
|
|
/**
|
|
* 재고 목록 조회 (Item 메인 + Stock LEFT JOIN)
|
|
*/
|
|
public function index(array $params): LengthAwarePaginator
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// Item 테이블이 메인 (materials 타입만: SM, RM, CS)
|
|
$query = Item::query()
|
|
->where('items.tenant_id', $tenantId)
|
|
->materials() // SM, RM, CS만
|
|
->with('stock');
|
|
|
|
// 검색어 필터 (Item 기준)
|
|
if (! empty($params['search'])) {
|
|
$search = $params['search'];
|
|
$query->where(function ($q) use ($search) {
|
|
$q->where('items.code', 'like', "%{$search}%")
|
|
->orWhere('items.name', 'like', "%{$search}%");
|
|
});
|
|
}
|
|
|
|
// 품목유형 필터 (Item.item_type 기준: RM, SM, CS)
|
|
if (! empty($params['item_type'])) {
|
|
$query->where('items.item_type', strtoupper($params['item_type']));
|
|
}
|
|
|
|
// 재고 상태 필터 (Stock.status)
|
|
if (! empty($params['status'])) {
|
|
$query->whereHas('stock', function ($q) use ($params) {
|
|
$q->where('status', $params['status']);
|
|
});
|
|
}
|
|
|
|
// 위치 필터 (Stock.location)
|
|
if (! empty($params['location'])) {
|
|
$query->whereHas('stock', function ($q) use ($params) {
|
|
$q->where('location', 'like', "%{$params['location']}%");
|
|
});
|
|
}
|
|
|
|
// 정렬
|
|
$sortBy = $params['sort_by'] ?? 'code';
|
|
$sortDir = $params['sort_dir'] ?? 'asc';
|
|
|
|
// Item 테이블 기준 정렬 필드 매핑
|
|
$sortMapping = [
|
|
'item_code' => 'items.code',
|
|
'item_name' => 'items.name',
|
|
'item_type' => 'items.item_type',
|
|
'code' => 'items.code',
|
|
'name' => 'items.name',
|
|
];
|
|
|
|
$sortColumn = $sortMapping[$sortBy] ?? 'items.code';
|
|
$query->orderBy($sortColumn, $sortDir);
|
|
|
|
// 페이지네이션
|
|
$perPage = $params['per_page'] ?? 20;
|
|
|
|
return $query->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* 재고 통계 조회 (Item 기준)
|
|
*/
|
|
public function stats(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// 전체 자재 품목 수 (Item 기준)
|
|
$totalItems = Item::where('tenant_id', $tenantId)
|
|
->materials()
|
|
->count();
|
|
|
|
// 재고 상태별 카운트 (Stock이 있는 Item 기준)
|
|
$normalCount = Item::where('items.tenant_id', $tenantId)
|
|
->materials()
|
|
->whereHas('stock', function ($q) {
|
|
$q->where('status', 'normal');
|
|
})
|
|
->count();
|
|
|
|
$lowCount = Item::where('items.tenant_id', $tenantId)
|
|
->materials()
|
|
->whereHas('stock', function ($q) {
|
|
$q->where('status', 'low');
|
|
})
|
|
->count();
|
|
|
|
$outCount = Item::where('items.tenant_id', $tenantId)
|
|
->materials()
|
|
->whereHas('stock', function ($q) {
|
|
$q->where('status', 'out');
|
|
})
|
|
->count();
|
|
|
|
// 재고 정보가 없는 Item 수
|
|
$noStockCount = Item::where('items.tenant_id', $tenantId)
|
|
->materials()
|
|
->whereDoesntHave('stock')
|
|
->count();
|
|
|
|
return [
|
|
'total_items' => $totalItems,
|
|
'normal_count' => $normalCount,
|
|
'low_count' => $lowCount,
|
|
'out_count' => $outCount,
|
|
'no_stock_count' => $noStockCount,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 재고 상세 조회 (Item 기준, LOT 포함)
|
|
*/
|
|
public function show(int $id): Item
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return Item::query()
|
|
->where('tenant_id', $tenantId)
|
|
->materials()
|
|
->with(['stock.lots' => function ($query) {
|
|
$query->orderBy('fifo_order');
|
|
}])
|
|
->findOrFail($id);
|
|
}
|
|
|
|
/**
|
|
* 품목코드로 재고 조회 (Item 기준)
|
|
*/
|
|
public function findByItemCode(string $itemCode): ?Item
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
return Item::query()
|
|
->where('tenant_id', $tenantId)
|
|
->materials()
|
|
->where('code', $itemCode)
|
|
->with('stock')
|
|
->first();
|
|
}
|
|
|
|
/**
|
|
* 품목유형별 통계 (Item.item_type 기준)
|
|
*/
|
|
public function statsByItemType(): array
|
|
{
|
|
$tenantId = $this->tenantId();
|
|
|
|
// Item 기준으로 통계 (materials 타입만)
|
|
$stats = Item::where('tenant_id', $tenantId)
|
|
->materials()
|
|
->selectRaw('item_type, COUNT(*) as count')
|
|
->groupBy('item_type')
|
|
->get()
|
|
->keyBy('item_type');
|
|
|
|
// 재고 수량 합계 (Stock이 있는 경우)
|
|
$stockQtys = Item::where('items.tenant_id', $tenantId)
|
|
->materials()
|
|
->join('stocks', 'items.id', '=', 'stocks.item_id')
|
|
->selectRaw('items.item_type, SUM(stocks.stock_qty) as total_qty')
|
|
->groupBy('items.item_type')
|
|
->get()
|
|
->keyBy('item_type');
|
|
|
|
$result = [];
|
|
foreach (self::ITEM_TYPE_LABELS as $key => $label) {
|
|
$itemData = $stats->get($key);
|
|
$stockData = $stockQtys->get($key);
|
|
$result[$key] = [
|
|
'label' => $label,
|
|
'count' => $itemData?->count ?? 0,
|
|
'total_qty' => $stockData?->total_qty ?? 0,
|
|
];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|