Files
sam-api/app/Services/ItemService.php
hskwon 23fd59dc88 refactor: group_id 네이밍 통일 (code_group='group', code='1')
- 마이그레이션: code_group='item_group' → 'group', code='ITEM' → '1'
- ItemService: group_id로 code 조회 후 parent_id 매칭
- API: /api/v1/items?group_id=1 → 품목 그룹 전체 조회
2025-12-15 14:59:07 +09:00

748 lines
23 KiB
PHP

<?php
namespace App\Services;
use App\Models\Commons\Category;
use App\Models\ItemMaster\ItemField;
use App\Models\ItemMaster\ItemPage;
use App\Models\Items\ItemDetail;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class ItemService extends Service
{
/**
* item_type 캐시 (동일 요청 내 중복 조회 방지)
*/
private array $modelCache = [];
/**
* item_type으로 해당하는 Model 클래스와 메타정보 조회
*
* @param string $itemType 품목 유형 (FG, PT, SM, RM, CS 등)
* @return array{model: string, page: ItemPage, source_table: string}
*/
private function getModelInfoByItemType(string $itemType): array
{
$itemType = strtoupper($itemType);
$tenantId = $this->tenantId();
$cacheKey = "{$tenantId}_{$itemType}";
if (isset($this->modelCache[$cacheKey])) {
return $this->modelCache[$cacheKey];
}
$page = ItemPage::where('tenant_id', $tenantId)
->where('item_type', $itemType)
->where('is_active', true)
->first();
if (! $page) {
throw new BadRequestHttpException(__('error.invalid_item_type'));
}
$modelClass = $page->getTargetModelClass();
if (! $modelClass) {
throw new BadRequestHttpException(__('error.invalid_source_table'));
}
$this->modelCache[$cacheKey] = [
'model' => $modelClass,
'page' => $page,
'source_table' => $page->source_table,
];
return $this->modelCache[$cacheKey];
}
/**
* item_type으로 Model 인스턴스 생성
*/
private function newModelInstance(string $itemType): Model
{
$info = $this->getModelInfoByItemType($itemType);
return new $info['model'];
}
/**
* item_type으로 Query Builder 생성
*/
private function newQuery(string $itemType)
{
$info = $this->getModelInfoByItemType($itemType);
$modelClass = $info['model'];
return $modelClass::query()
->where('tenant_id', $this->tenantId())
->where('item_type', strtoupper($itemType));
}
/**
* group_id로 해당 그룹의 item_type 목록 조회
*
* @param int $groupId 그룹 코드 (code_group='group', code='1' → group_id=1)
* @return array item_type 코드 배열 ['FG', 'PT', 'SM', 'RM', 'CS']
*/
private function getItemTypesByGroupId(int $groupId): array
{
// 1. group_id로 그룹 레코드 찾기 (code_group='group', code=group_id)
$group = \DB::table('common_codes')
->where('code_group', 'group')
->where('code', (string) $groupId)
->where('is_active', true)
->first();
if (! $group) {
return [];
}
// 2. 해당 그룹의 id를 parent_id로 가진 item_type 조회
return \DB::table('common_codes')
->where('code_group', 'item_type')
->where('parent_id', $group->id)
->where('is_active', true)
->pluck('code')
->toArray();
}
/**
* 여러 item_type으로 Query Builder 생성
*/
private function newQueryForTypes(array $itemTypes)
{
return \App\Models\Items\Item::query()
->where('tenant_id', $this->tenantId())
->whereIn('item_type', array_map('strtoupper', $itemTypes));
}
/**
* items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반)
*/
private function getKnownFields(): array
{
$tenantId = $this->tenantId();
// 1. 기본 고정 필드
$baseFields = [
'id', 'tenant_id', 'item_type', 'code', 'name', 'unit', 'category_id',
'bom', 'attributes', 'attributes_archive', 'options', 'description',
'is_active', 'created_by', 'updated_by', 'deleted_by',
'created_at', 'updated_at', 'deleted_at',
];
// 2. ItemField에서 storage_type='column'인 필드의 field_key 조회
$columnFields = ItemField::where('tenant_id', $tenantId)
->where('source_table', 'items')
->where('storage_type', 'column')
->whereNotNull('field_key')
->pluck('field_key')
->toArray();
// 3. API 전용 필드
$apiFields = ['type_code'];
return array_unique(array_merge($baseFields, $columnFields, $apiFields));
}
/**
* 정의된 필드 외의 동적 필드를 options로 추출
*/
private function extractDynamicOptions(array $params): array
{
$knownFields = $this->getKnownFields();
$dynamicOptions = [];
foreach ($params as $key => $value) {
if (! in_array($key, $knownFields) && $value !== null && $value !== '') {
$dynamicOptions[$key] = $value;
}
}
return $dynamicOptions;
}
/**
* 기존 options 배열과 동적 필드를 병합
*/
private function mergeOptionsWithDynamic($existingOptions, array $dynamicOptions): array
{
if (! is_array($existingOptions) || empty($existingOptions)) {
return $dynamicOptions;
}
$isAssoc = array_keys($existingOptions) !== range(0, count($existingOptions) - 1);
if ($isAssoc) {
return array_merge($existingOptions, $dynamicOptions);
}
foreach ($dynamicOptions as $key => $value) {
$existingOptions[] = ['label' => $key, 'value' => $value];
}
return $existingOptions;
}
/**
* options 입력을 [{label, value, unit}] 형태로 정규화
*/
private function normalizeOptions(?array $in): ?array
{
if (! $in) {
return null;
}
$isAssoc = array_keys($in) !== range(0, count($in) - 1);
if ($isAssoc) {
$out = [];
foreach ($in as $k => $v) {
$label = trim((string) $k);
$value = is_scalar($v) ? trim((string) $v) : json_encode($v, JSON_UNESCAPED_UNICODE);
if ($label !== '' || $value !== '') {
$out[] = ['label' => $label, 'value' => $value, 'unit' => ''];
}
}
return $out ?: null;
}
$out = [];
foreach ($in as $a) {
if (! is_array($a)) {
continue;
}
$label = trim((string) ($a['label'] ?? ''));
$value = trim((string) ($a['value'] ?? ''));
$unit = trim((string) ($a['unit'] ?? ''));
if ($label === '' && $value === '') {
continue;
}
$out[] = ['label' => $label, 'value' => $value, 'unit' => $unit];
}
return $out ?: null;
}
/**
* BOM 검증 (순환 참조 방지)
*
* @param array|null $bom BOM 데이터
* @param int|null $itemId 현재 품목 ID (수정 시)
*/
private function validateBom(?array $bom, ?int $itemId = null): void
{
if (empty($bom)) {
return;
}
foreach ($bom as $entry) {
$childItemId = $entry['child_item_id'] ?? null;
// 자기 자신 참조 방지
if ($itemId && $childItemId == $itemId) {
throw new BadRequestHttpException(__('error.item.self_reference_bom'));
}
}
}
/**
* 카테고리 트리 전체 조회
*/
public function getCategory($request)
{
$parentId = $request->parentId ?? null;
return $this->fetchCategoryTree($parentId);
}
/**
* 내부 재귀 함수 (하위 카테고리 트리 구조로 구성)
*/
protected function fetchCategoryTree(?int $parentId = null)
{
$tenantId = $this->tenantId();
$query = Category::query()
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(
is_null($parentId),
fn ($q) => $q->whereNull('parent_id'),
fn ($q) => $q->where('parent_id', $parentId)
)
->where('is_active', 1)
->orderBy('sort_order');
$categories = $query->get();
foreach ($categories as $category) {
$children = $this->fetchCategoryTree($category->id);
$category->setRelation('children', $children);
}
return $categories;
}
/**
* 목록/검색 (동적 테이블 라우팅)
*
* @param array $params 검색 파라미터 (item_type 또는 group_id 필수)
*/
public function index(array $params): LengthAwarePaginator
{
$size = (int) ($params['size'] ?? $params['per_page'] ?? 20);
$q = trim((string) ($params['q'] ?? $params['search'] ?? ''));
$categoryId = $params['category_id'] ?? null;
$itemType = $params['item_type'] ?? null;
$groupId = $params['group_id'] ?? null;
$active = $params['active'] ?? null;
// item_type 또는 group_id 필수 검증
if (! $itemType && ! $groupId) {
throw new BadRequestHttpException(__('error.item_type_or_group_required'));
}
// group_id로 조회 시 해당 그룹의 모든 item_type 조회
if ($groupId && ! $itemType) {
$itemTypes = $this->getItemTypesByGroupId((int) $groupId);
if (empty($itemTypes)) {
throw new BadRequestHttpException(__('error.invalid_group_id'));
}
$query = $this->newQueryForTypes($itemTypes)
->with(['category:id,name', 'details']);
} else {
// 단일 item_type 조회
$query = $this->newQuery($itemType)
->with(['category:id,name', 'details']);
}
// 검색어
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
// 카테고리
if ($categoryId) {
$query->where('category_id', (int) $categoryId);
}
// 활성 상태
if ($active !== null && $active !== '') {
$query->where('is_active', (bool) $active);
}
$paginator = $query->orderBy('id')->paginate($size);
// 날짜 형식 변환
$paginator->setCollection(
$paginator->getCollection()->transform(function ($item) {
$arr = $item->toArray();
$arr['created_at'] = $item->created_at
? $item->created_at->format('Y-m-d')
: null;
return $arr;
})
);
return $paginator;
}
/**
* 생성 (동적 테이블 라우팅)
*
* @param array $data 생성 데이터 (item_type 필수)
*/
public function store(array $data): Model
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// item_type 필수 검증
$itemType = $data['item_type'] ?? null;
if (! $itemType) {
throw new BadRequestHttpException(__('error.item_type_required'));
}
// 동적 모델 정보 조회
$modelInfo = $this->getModelInfoByItemType($itemType);
$modelClass = $modelInfo['model'];
// 동적 필드를 options에 병합
$dynamicOptions = $this->extractDynamicOptions($data);
if (! empty($dynamicOptions)) {
$existingOptions = $data['options'] ?? [];
$data['options'] = $this->mergeOptionsWithDynamic($existingOptions, $dynamicOptions);
}
// options 정규화
if (isset($data['options'])) {
$data['options'] = $this->normalizeOptions($data['options']);
}
// tenant별 code 유니크 체크 (동적 테이블)
$dup = $modelClass::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
// 테이블 데이터 준비
$itemData = [
'tenant_id' => $tenantId,
'item_type' => strtoupper($itemType),
'code' => $data['code'],
'name' => $data['name'],
'unit' => $data['unit'] ?? null,
'category_id' => $data['category_id'] ?? null,
'bom' => $data['bom'] ?? null,
'attributes' => $data['attributes'] ?? null,
'options' => $data['options'] ?? null,
'description' => $data['description'] ?? null,
'is_active' => $data['is_active'] ?? true,
'created_by' => $userId,
];
$item = $modelClass::create($itemData);
// item_details 테이블 데이터 (items 테이블인 경우에만)
if ($modelInfo['source_table'] === 'items') {
$detailData = $this->extractDetailData($data);
$detailData['item_id'] = $item->id;
ItemDetail::create($detailData);
$item->load('details');
}
return $item;
}
/**
* 단건 조회 (동적 테이블 라우팅)
*
* @param int $id 품목 ID
* @param string $itemType 품목 유형 (필수)
*/
public function show(int $id, string $itemType): Model
{
// 동적 테이블 라우팅
$modelInfo = $this->getModelInfoByItemType($itemType);
$query = $this->newQuery($itemType);
// items 테이블인 경우 관계 로드
if ($modelInfo['source_table'] === 'items') {
$query->with(['category:id,name', 'details']);
}
$item = $query->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
return $item;
}
/**
* 수정 (동적 테이블 라우팅)
*
* @param int $id 품목 ID
* @param array $data 수정 데이터 (item_type 필수)
*/
public function update(int $id, array $data): Model
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
// item_type 필수 검증
$itemType = $data['item_type'] ?? null;
if (! $itemType) {
throw new BadRequestHttpException(__('error.item_type_required'));
}
// 동적 모델 정보 조회
$modelInfo = $this->getModelInfoByItemType($itemType);
$modelClass = $modelInfo['model'];
$item = $this->newQuery($itemType)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
// 동적 필드를 options에 병합 (기존 item의 options 기반)
$dynamicOptions = $this->extractDynamicOptions($data);
// 기존 options를 배열 형태로 변환 (label → value 맵)
$existingOptionsMap = [];
if (is_array($item->options)) {
foreach ($item->options as $opt) {
if (isset($opt['label'])) {
$existingOptionsMap[$opt['label']] = $opt['value'] ?? '';
}
}
}
// 새 동적 필드와 기존 options 병합
if (! empty($dynamicOptions)) {
// 새 동적 필드로 기존 값 덮어쓰기
foreach ($dynamicOptions as $key => $value) {
$existingOptionsMap[$key] = $value;
}
}
// 명시적으로 전달된 options 처리
if (isset($data['options']) && is_array($data['options'])) {
// 배열 형태의 options 병합
foreach ($data['options'] as $opt) {
if (isset($opt['label'])) {
$existingOptionsMap[$opt['label']] = $opt['value'] ?? '';
}
}
}
// 최종 options 설정 (병합된 맵이 비어있지 않으면)
if (! empty($existingOptionsMap)) {
$data['options'] = $this->normalizeOptions($existingOptionsMap);
}
// code 변경 시 중복 체크 (동적 테이블)
if (isset($data['code']) && $data['code'] !== $item->code) {
$dup = $modelClass::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw new BadRequestHttpException(__('error.duplicate_key'));
}
}
// BOM 검증 (순환 참조 방지)
if (isset($data['bom'])) {
$this->validateBom($data['bom'], $id);
}
// 테이블 업데이트
$itemData = array_intersect_key($data, array_flip([
'item_type', 'code', 'name', 'unit', 'category_id',
'bom', 'attributes', 'options', 'description', 'is_active',
]));
$itemData['updated_by'] = $userId;
if (isset($itemData['item_type'])) {
$itemData['item_type'] = strtoupper($itemData['item_type']);
}
$item->update($itemData);
// item_details 테이블 업데이트 (items 테이블인 경우에만)
if ($modelInfo['source_table'] === 'items') {
$detailData = $this->extractDetailData($data);
if (! empty($detailData)) {
$item->details()->updateOrCreate(
['item_id' => $item->id],
$detailData
);
}
$item->load('details');
}
return $item->refresh();
}
/**
* 삭제 (동적 테이블 라우팅, soft delete)
*
* @param int $id 품목 ID
* @param string $itemType 품목 유형 (필수)
*/
public function destroy(int $id, string $itemType): void
{
$userId = $this->apiUserId();
// 동적 테이블 라우팅
$item = $this->newQuery($itemType)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
$item->deleted_by = $userId;
$item->save();
$item->delete();
}
/**
* 간편 검색 (동적 테이블 라우팅, 모달/드롭다운)
*
* @param array $params 검색 파라미터 (item_type 필수)
*/
public function search(array $params)
{
$q = trim((string) ($params['q'] ?? ''));
$limit = (int) ($params['limit'] ?? 20);
$itemType = $params['item_type'] ?? null;
// item_type 필수 검증
if (! $itemType) {
throw new BadRequestHttpException(__('error.item_type_required'));
}
// 동적 테이블 라우팅
$query = $this->newQuery($itemType);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%");
});
}
return $query->orderBy('name')
->limit($limit)
->get(['id', 'code', 'name', 'item_type', 'category_id']);
}
/**
* 활성/비활성 토글 (동적 테이블 라우팅)
*
* @param int $id 품목 ID
* @param string $itemType 품목 유형 (필수)
*/
public function toggle(int $id, string $itemType): array
{
$userId = $this->apiUserId();
// 동적 테이블 라우팅
$item = $this->newQuery($itemType)->find($id);
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
$item->is_active = ! $item->is_active;
$item->updated_by = $userId;
$item->save();
return ['id' => $item->id, 'is_active' => $item->is_active];
}
/**
* code 기반 품목 조회 (동적 테이블 라우팅, BOM 포함 옵션)
*
* @param string $code 품목 코드
* @param string $itemType 품목 유형 (필수)
* @param bool $includeBom BOM 포함 여부
*/
public function showByCode(string $code, string $itemType, bool $includeBom = false): Model
{
// 동적 테이블 라우팅
$modelInfo = $this->getModelInfoByItemType($itemType);
$query = $this->newQuery($itemType)->where('code', $code);
// items 테이블인 경우 관계 로드
if ($modelInfo['source_table'] === 'items') {
$query->with(['category:id,name', 'details']);
}
$item = $query->first();
if (! $item) {
throw new BadRequestHttpException(__('error.not_found'));
}
// BOM 포함 시 child items 로드 (items 테이블인 경우에만)
if ($includeBom && ! empty($item->bom) && method_exists($item, 'loadBomChildren')) {
$item->loadBomChildren();
}
return $item;
}
/**
* 일괄 삭제 (동적 테이블 라우팅, soft delete)
*
* @param array $ids 품목 ID 배열
* @param string $itemType 품목 유형 (필수)
*/
public function batchDestroy(array $ids, string $itemType): int
{
$userId = $this->apiUserId();
// 동적 테이블 라우팅
$items = $this->newQuery($itemType)
->whereIn('id', $ids)
->get();
if ($items->isEmpty()) {
throw new BadRequestHttpException(__('error.not_found'));
}
$deletedCount = 0;
foreach ($items as $item) {
$item->deleted_by = $userId;
$item->save();
$item->delete();
$deletedCount++;
}
return $deletedCount;
}
/**
* 가격 정보 포함 조회 (동적 테이블 라우팅)
*
* @param int $id 품목 ID
* @param string $itemType 품목 유형 (필수)
* @param int|null $clientId 거래처 ID
* @param string|null $priceDate 가격 기준일
*/
public function showWithPrice(int $id, string $itemType, ?int $clientId = null, ?string $priceDate = null): array
{
$item = $this->show($id, $itemType);
$data = $item->toArray();
// PricingService로 가격 조회
try {
$pricingService = app(\App\Services\Pricing\PricingService::class);
$itemTypeCode = in_array(strtoupper($itemType), ['FG', 'PT']) ? 'PRODUCT' : 'MATERIAL';
$data['prices'] = [
'sale' => $pricingService->getPriceByType($itemTypeCode, $id, 'SALE', $clientId, $priceDate),
'purchase' => $pricingService->getPriceByType($itemTypeCode, $id, 'PURCHASE', $clientId, $priceDate),
];
} catch (\Exception $e) {
$data['prices'] = ['sale' => null, 'purchase' => null];
}
return $data;
}
/**
* item_details 데이터 추출
*/
private function extractDetailData(array $data): array
{
$detailFields = [
// Products 전용
'is_sellable', 'is_purchasable', 'is_producible',
'safety_stock', 'lead_time', 'is_variable_size',
'product_category', 'part_type',
'bending_diagram', 'bending_details',
'specification_file', 'specification_file_name',
'certification_file', 'certification_file_name',
'certification_number', 'certification_start_date', 'certification_end_date',
// Materials 전용
'is_inspection', 'item_name', 'specification', 'search_tag', 'remarks',
];
return array_intersect_key($data, array_flip($detailFields));
}
}