Files
sam-api/app/Services/ItemsService.php
hskwon 0ea8c719d7 feat: [items] 품목 생성/조회 개선
- 중복 코드 자동 증가 기능 추가 (P-001 → P-002, ABC → ABC-001)
- soft delete 항목 조회 파라미터 추가 (include_deleted)
- ValidationException 응답 포맷 수정 (공통 에러 형식)
- batch delete 라우트 순서 수정 (/{id} 보다 /batch 먼저)
- is_active 기본값 true 설정
2025-12-01 14:22:50 +09:00

423 lines
13 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\BadRequestHttpException;
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 페이지당 항목 수
* @param bool $includeDeleted soft delete 포함 여부
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function getItems(array $filters = [], int $perPage = 20, bool $includeDeleted = false)
{
$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',
'deleted_at',
]);
// soft delete 포함
if ($includeDeleted) {
$productsQuery->withTrashed();
}
// 검색 조건
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',
'deleted_at',
]);
// soft delete 포함
if ($includeDeleted) {
$materialsQuery->withTrashed();
}
// 검색 조건
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,
];
}
/**
* 품목 생성 (Product 전용)
*
* @param array $data 검증된 데이터
*/
public function createItem(array $data): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// 품목 코드 중복 시 자동 증가
$data['code'] = $this->resolveUniqueCode($data['code'], $tenantId);
$payload = $data;
$payload['tenant_id'] = $tenantId;
$payload['created_by'] = $userId;
$payload['is_active'] = $payload['is_active'] ?? true;
$payload['is_sellable'] = $payload['is_sellable'] ?? true;
$payload['is_purchasable'] = $payload['is_purchasable'] ?? false;
$payload['is_producible'] = $payload['is_producible'] ?? false;
return Product::create($payload);
}
/**
* 중복되지 않는 고유 코드 생성
*
* - 중복 없으면 원본 반환
* - 마지막이 숫자면 숫자 증가 (P-001 → P-002)
* - 마지막이 문자면 -001 추가 (ABC → ABC-001)
*/
private function resolveUniqueCode(string $code, int $tenantId): string
{
// 삭제된 항목 포함해서 중복 체크
$exists = Product::withTrashed()
->where('tenant_id', $tenantId)
->where('code', $code)
->exists();
if (! $exists) {
return $code;
}
// 마지막이 숫자인지 확인 (예: P-001, ITEM123)
if (preg_match('/^(.+?)(\d+)$/', $code, $matches)) {
$prefix = $matches[1];
$number = (int) $matches[2];
$digits = strlen($matches[2]);
// 숫자 증가하며 고유 코드 찾기
do {
$number++;
$newCode = $prefix . str_pad($number, $digits, '0', STR_PAD_LEFT);
$exists = Product::withTrashed()
->where('tenant_id', $tenantId)
->where('code', $newCode)
->exists();
} while ($exists);
return $newCode;
}
// 마지막이 문자면 -001 추가
$suffix = 1;
do {
$newCode = $code . '-' . str_pad($suffix, 3, '0', STR_PAD_LEFT);
$exists = Product::withTrashed()
->where('tenant_id', $tenantId)
->where('code', $newCode)
->exists();
$suffix++;
} while ($exists);
return $newCode;
}
/**
* 품목 수정 (Product 전용)
*
* @param int $id 품목 ID
* @param array $data 검증된 데이터
*/
public function updateItem(int $id, array $data): Product
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$product = Product::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 코드 변경 시 중복 체크
if (isset($data['code']) && $data['code'] !== $product->code) {
$exists = Product::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->where('id', '!=', $product->id)
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.duplicate_code'));
}
}
$data['updated_by'] = $userId;
$product->update($data);
return $product->refresh();
}
/**
* 품목 삭제 (Product 전용, Soft Delete)
*
* @param int $id 품목 ID
*/
public function deleteItem(int $id): void
{
$tenantId = $this->tenantId();
$product = Product::query()
->where('tenant_id', $tenantId)
->find($id);
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
$product->delete();
}
/**
* 품목 일괄 삭제 (Product 전용, Soft Delete)
*
* @param array $ids 품목 ID 배열
*/
public function batchDeleteItems(array $ids): void
{
$tenantId = $this->tenantId();
$products = Product::query()
->where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
if ($products->isEmpty()) {
throw new NotFoundHttpException(__('error.not_found'));
}
foreach ($products as $product) {
$product->delete();
}
}
/**
* 품목 상세 조회 (code 기반, BOM 포함 옵션)
*
* @param string $code 품목 코드
* @param bool $includeBom BOM 포함 여부
*/
public function getItemByCode(string $code, bool $includeBom = false): Product
{
$tenantId = $this->tenantId();
$query = Product::query()
->with('category:id,name')
->where('tenant_id', $tenantId)
->where('code', $code);
if ($includeBom) {
$query->with('componentLines.childProduct:id,code,name,unit');
}
$product = $query->first();
if (! $product) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $product;
}
}