Files
sam-api/app/Services/ItemsService.php
hskwon ddc4bb99a0 feat: 통합 품목 조회 API 및 가격 통합 시스템 구현
- 통합 품목 조회 API (materials + products UNION)
  - ItemsService, ItemsController, Swagger 문서 생성
  - 타입 필터링 (FG/PT/SM/RM/CS), 검색, 카테고리 지원
  - Collection merge 방식으로 UNION 쿼리 안정화

- 품목-가격 통합 조회
  - PricingService.getPriceByType() 추가 (SALE/PURCHASE 지원)
  - 단일 품목 조회 시 판매가/매입가 선택적 포함
  - 고객그룹 가격 우선순위 적용 및 시계열 조회

- 자재 타입 명시적 관리
  - materials.material_type 컬럼 추가 (SM/RM/CS)
  - 기존 데이터 344개 자동 변환 (RAW→RM, SUB→SM)
  - 인덱스 추가로 조회 성능 최적화

- DB 데이터 정규화
  - products.product_type: 760개 정규화 (PRODUCT→FG, PART/SUBASSEMBLY→PT)
  - 타입 코드 표준화로 API 일관성 확보

최종 데이터: 제품 760개(FG 297, PT 463), 자재 344개(SM 215, RM 129)
2025-11-11 11:30:17 +09:00

223 lines
7.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Materials\Material;
use App\Models\Products\Product;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ItemsService extends Service
{
/**
* 통합 품목 조회 (materials + products UNION)
*
* @param array $filters 필터 조건 (type, search, category_id, page, size)
* @param int $perPage 페이지당 항목 수
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getItems(array $filters = [], int $perPage = 20)
{
$tenantId = $this->tenantId();
// 필터 파라미터 추출
$types = $filters['type'] ?? ['FG', 'PT', 'SM', 'RM', 'CS'];
$search = trim($filters['search'] ?? $filters['q'] ?? '');
$categoryId = $filters['category_id'] ?? null;
// 타입을 배열로 변환 (문자열인 경우 쉼표로 분리)
if (is_string($types)) {
$types = array_map('trim', explode(',', $types));
}
// Product 타입 (FG, PT)
$productTypes = array_intersect(['FG', 'PT'], $types);
// Material 타입 (SM, RM, CS)
$materialTypes = array_intersect(['SM', 'RM', 'CS'], $types);
// Products 쿼리 (FG, PT 타입만)
$productsQuery = null;
if (! empty($productTypes)) {
$productsQuery = Product::query()
->where('tenant_id', $tenantId)
->whereIn('product_type', $productTypes)
->where('is_active', 1)
->select([
'id',
DB::raw("'PRODUCT' as item_type"),
'code',
'name',
'unit',
'category_id',
'product_type as type_code',
'created_at',
]);
// 검색 조건
if ($search !== '') {
$productsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('code', 'like', "%{$search}%");
});
}
// 카테고리 필터
if ($categoryId) {
$productsQuery->where('category_id', (int) $categoryId);
}
}
// Materials 쿼리 (SM, RM, CS 타입)
$materialsQuery = null;
if (! empty($materialTypes)) {
$materialsQuery = Material::query()
->where('tenant_id', $tenantId)
->whereIn('material_type', $materialTypes)
->select([
'id',
DB::raw("'MATERIAL' as item_type"),
'material_code as code',
'name',
'unit',
'category_id',
'material_type as type_code',
'created_at',
]);
// 검색 조건
if ($search !== '') {
$materialsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('item_name', 'like', "%{$search}%")
->orWhere('material_code', 'like', "%{$search}%")
->orWhere('search_tag', 'like', "%{$search}%");
});
}
// 카테고리 필터
if ($categoryId) {
$materialsQuery->where('category_id', (int) $categoryId);
}
}
// 각 쿼리 실행 후 merge (UNION 바인딩 문제 방지)
$items = collect([]);
if ($productsQuery) {
$products = $productsQuery->get();
$items = $items->merge($products);
}
if ($materialsQuery) {
$materials = $materialsQuery->get();
$items = $items->merge($materials);
}
// 정렬 (name 기준 오름차순)
$items = $items->sortBy('name')->values();
// 페이지네이션 처리
$page = request()->input('page', 1);
$offset = ($page - 1) * $perPage;
$total = $items->count();
$paginatedItems = $items->slice($offset, $perPage)->values();
return new \Illuminate\Pagination\LengthAwarePaginator(
$paginatedItems,
$total,
$perPage,
$page,
['path' => request()->url(), 'query' => request()->query()]
);
}
/**
* 단일 품목 조회
*
* @param string $itemType 'PRODUCT' | 'MATERIAL'
* @param int $id 품목 ID
* @param bool $includePrice 가격 정보 포함 여부
* @param int|null $clientId 고객 ID (가격 조회 시)
* @param string|null $priceDate 기준일 (가격 조회 시)
* @return array 품목 데이터 (item_type, prices 포함)
*/
public function getItem(
string $itemType,
int $id,
bool $includePrice = false,
?int $clientId = null,
?string $priceDate = null
): array {
$tenantId = $this->tenantId();
$itemType = strtoupper($itemType);
if ($itemType === 'PRODUCT') {
$product = Product::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->find($id);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $product->toArray();
$data['item_type'] = 'PRODUCT';
$data['type_code'] = $product->product_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate);
}
return $data;
} elseif ($itemType === 'MATERIAL') {
$material = Material::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $material) {
throw new NotFoundHttpException(__('error.not_found'));
}
$data = $material->toArray();
$data['item_type'] = 'MATERIAL';
$data['code'] = $material->material_code;
$data['type_code'] = $material->material_type;
// 가격 정보 추가
if ($includePrice) {
$data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate);
}
return $data;
} else {
throw new \InvalidArgumentException(__('error.invalid_item_type'));
}
}
/**
* 품목의 판매가/매입가 조회
*
* @param string $itemType 'PRODUCT' | 'MATERIAL'
* @param int $itemId 품목 ID
* @param int|null $clientId 고객 ID
* @param string|null $priceDate 기준일
* @return array ['sale' => array, 'purchase' => array]
*/
private function fetchPrices(string $itemType, int $itemId, ?int $clientId, ?string $priceDate): array
{
// PricingService DI가 없으므로 직접 생성
$pricingService = app(\App\Services\Pricing\PricingService::class);
$salePrice = $pricingService->getPriceByType($itemType, $itemId, 'SALE', $clientId, $priceDate);
$purchasePrice = $pricingService->getPriceByType($itemType, $itemId, 'PURCHASE', $clientId, $priceDate);
return [
'sale' => $salePrice,
'purchase' => $purchasePrice,
];
}
}