diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 7780437..a32b24f 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -7,57 +7,73 @@ use App\Http\Requests\Item\ItemBatchDeleteRequest; use App\Http\Requests\Item\ItemStoreRequest; use App\Http\Requests\Item\ItemUpdateRequest; -use App\Services\ItemsService; +use App\Services\ItemService; use Illuminate\Http\Request; class ItemsController extends Controller { - public function __construct(private ItemsService $service) {} + public function __construct(private ItemService $service) {} /** - * 통합 품목 목록 조회 (materials + products) + * 통합 품목 목록 조회 (items 테이블) * * GET /api/v1/items */ public function index(Request $request) { return ApiResponse::handle(function () use ($request) { - $filters = $request->only(['type', 'search', 'q', 'category_id', 'is_active']); - $perPage = (int) ($request->input('size') ?? 20); - $includeDeleted = filter_var($request->input('include_deleted', false), FILTER_VALIDATE_BOOLEAN); + $params = [ + 'size' => $request->input('size', 20), + 'q' => $request->input('q') ?? $request->input('search'), + 'category_id' => $request->input('category_id'), + 'item_type' => $request->input('type') ?? $request->input('item_type'), + 'active' => $request->input('is_active') ?? $request->input('active'), + ]; - return $this->service->getItems($filters, $perPage, $includeDeleted); + return $this->service->index($params); }, __('message.fetched')); } /** - * 단일 품목 조회 + * 단일 품목 조회 (동적 테이블 라우팅) * - * GET /api/v1/items/{id}?item_type=FG|PT|SM|RM|CS&include_price=true&client_id=1&price_date=2025-01-10 + * GET /api/v1/items/{id}?item_type=FG&include_price=true&client_id=1&price_date=2025-01-10 + * + * @param string item_type 품목 유형 (필수 - 동적 테이블 라우팅) */ public function show(Request $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { - $itemType = strtoupper($request->input('item_type', 'FG')); + // item_type 필수 (동적 테이블 라우팅에 사용) + $itemType = strtoupper($request->input('item_type', '')); $includePrice = filter_var($request->input('include_price', false), FILTER_VALIDATE_BOOLEAN); - $clientId = $request->input('client_id') ? (int) $request->input('client_id') : null; - $priceDate = $request->input('price_date'); - return $this->service->getItem($id, $itemType, $includePrice, $clientId, $priceDate); + if ($includePrice) { + $clientId = $request->input('client_id') ? (int) $request->input('client_id') : null; + $priceDate = $request->input('price_date'); + + return $this->service->showWithPrice($id, $itemType, $clientId, $priceDate); + } + + return $this->service->show($id, $itemType); }, __('message.fetched')); } /** - * 품목 상세 조회 (code 기반, BOM 포함 옵션) + * 품목 상세 조회 (동적 테이블 라우팅, code 기반, BOM 포함 옵션) * - * GET /api/v1/items/code/{code}?include_bom=true + * GET /api/v1/items/code/{code}?item_type=FG&include_bom=true + * + * @param string item_type 품목 유형 (필수 - 동적 테이블 라우팅) */ public function showByCode(Request $request, string $code) { return ApiResponse::handle(function () use ($request, $code) { + // item_type 필수 (동적 테이블 라우팅에 사용) + $itemType = strtoupper($request->input('item_type', '')); $includeBom = filter_var($request->input('include_bom', false), FILTER_VALIDATE_BOOLEAN); - return $this->service->getItemByCode($code, $includeBom); + return $this->service->showByCode($code, $itemType, $includeBom); }, __('message.item.fetched')); } @@ -69,7 +85,7 @@ public function showByCode(Request $request, string $code) public function store(ItemStoreRequest $request) { return ApiResponse::handle(function () use ($request) { - return $this->service->createItem($request->all()); + return $this->service->store($request->all()); }, __('message.item.created')); } @@ -81,38 +97,44 @@ public function store(ItemStoreRequest $request) public function update(int $id, ItemUpdateRequest $request) { return ApiResponse::handle(function () use ($id, $request) { - return $this->service->updateItem($id, $request->all()); + return $this->service->update($id, $request->all()); }, __('message.item.updated')); } /** - * 품목 삭제 (Soft Delete) + * 품목 삭제 (동적 테이블 라우팅, Soft Delete) * - * DELETE /api/v1/items/{id}?item_type=FG|PT|SM|RM|CS + * DELETE /api/v1/items/{id}?item_type=FG + * + * @param string item_type 품목 유형 (필수 - 동적 테이블 라우팅) */ public function destroy(Request $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { - $itemType = strtoupper($request->input('item_type', 'FG')); - $this->service->deleteItem($id, $itemType); + // item_type 필수 (동적 테이블 라우팅에 사용) + $itemType = strtoupper($request->input('item_type', '')); + $this->service->destroy($id, $itemType); return 'success'; }, __('message.item.deleted')); } /** - * 품목 일괄 삭제 (Soft Delete) + * 품목 일괄 삭제 (동적 테이블 라우팅, Soft Delete) * - * DELETE /api/v1/items/batch + * DELETE /api/v1/items/batch?item_type=FG + * + * @param string item_type 품목 유형 (필수 - 동적 테이블 라우팅) */ public function batchDestroy(ItemBatchDeleteRequest $request) { return ApiResponse::handle(function () use ($request) { $validated = $request->validated(); - $itemType = strtoupper($validated['item_type'] ?? 'FG'); - $this->service->batchDeleteItems($validated['ids'], $itemType); + // item_type 필수 (동적 테이블 라우팅에 사용) + $itemType = strtoupper($validated['item_type'] ?? ''); + $deletedCount = $this->service->batchDestroy($validated['ids'], $itemType); - return 'success'; + return ['deleted_count' => $deletedCount]; }, __('message.item.batch_deleted')); } } diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php index 29a8038..93e707d 100644 --- a/app/Services/ItemService.php +++ b/app/Services/ItemService.php @@ -2,17 +2,81 @@ namespace App\Services; -use App\Constants\SystemFields; use App\Models\Commons\Category; use App\Models\ItemMaster\ItemField; -use App\Models\Items\Item; +use App\Models\ItemMaster\ItemPage; use App\Models\Items\ItemDetail; -use App\Models\Products\CommonCode; 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()); + } + /** * items 테이블의 고정 컬럼 목록 조회 (SystemFields + ItemField 기반) */ @@ -162,21 +226,26 @@ protected function fetchCategoryTree(?int $parentId = null) } /** - * 목록/검색 + * 목록/검색 (동적 테이블 라우팅) + * + * @param array $params 검색 파라미터 (item_type 필수) */ public function index(array $params): LengthAwarePaginator { - $tenantId = $this->tenantId(); - $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; $active = $params['active'] ?? null; - $query = Item::query() - ->with(['category:id,name', 'details']) - ->where('tenant_id', $tenantId); + // item_type 필수 검증 + if (! $itemType) { + throw new BadRequestHttpException(__('error.item_type_required')); + } + + // 동적 테이블 라우팅 + $query = $this->newQuery($itemType) + ->with(['category:id,name', 'details']); // 검색어 if ($q !== '') { @@ -192,11 +261,6 @@ public function index(array $params): LengthAwarePaginator $query->where('category_id', (int) $categoryId); } - // item_type 필터 - if ($itemType) { - $query->where('item_type', strtoupper($itemType)); - } - // 활성 상태 if ($active !== null && $active !== '') { $query->where('is_active', (bool) $active); @@ -220,13 +284,25 @@ public function index(array $params): LengthAwarePaginator } /** - * 생성 + * 생성 (동적 테이블 라우팅) + * + * @param array $data 생성 데이터 (item_type 필수) */ - public function store(array $data): Item + 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)) { @@ -239,8 +315,8 @@ public function store(array $data): Item $data['options'] = $this->normalizeOptions($data['options']); } - // tenant별 code 유니크 체크 - $dup = Item::query() + // tenant별 code 유니크 체크 (동적 테이블) + $dup = $modelClass::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->exists(); @@ -248,10 +324,10 @@ public function store(array $data): Item throw new BadRequestHttpException(__('error.duplicate_key')); } - // items 테이블 데이터 + // 테이블 데이터 준비 $itemData = [ 'tenant_id' => $tenantId, - 'item_type' => strtoupper($data['item_type']), + 'item_type' => strtoupper($itemType), 'code' => $data['code'], 'name' => $data['name'], 'unit' => $data['unit'] ?? null, @@ -264,27 +340,37 @@ public function store(array $data): Item 'created_by' => $userId, ]; - $item = Item::create($itemData); + $item = $modelClass::create($itemData); - // item_details 테이블 데이터 - $detailData = $this->extractDetailData($data); - $detailData['item_id'] = $item->id; - ItemDetail::create($detailData); + // 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->load('details'); + return $item; } /** - * 단건 조회 + * 단건 조회 (동적 테이블 라우팅) + * + * @param int $id 품목 ID + * @param string $itemType 품목 유형 (필수) */ - public function show(int $id): Item + public function show(int $id, string $itemType): Model { - $tenantId = $this->tenantId(); + // 동적 테이블 라우팅 + $modelInfo = $this->getModelInfoByItemType($itemType); + $query = $this->newQuery($itemType); - $item = Item::query() - ->with(['category:id,name', 'details']) - ->where('tenant_id', $tenantId) - ->find($id); + // items 테이블인 경우 관계 로드 + if ($modelInfo['source_table'] === 'items') { + $query->with(['category:id,name', 'details']); + } + + $item = $query->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); @@ -294,14 +380,27 @@ public function show(int $id): Item } /** - * 수정 + * 수정 (동적 테이블 라우팅) + * + * @param int $id 품목 ID + * @param array $data 수정 데이터 (item_type 필수) */ - public function update(int $id, array $data): Item + public function update(int $id, array $data): Model { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $item = Item::query()->where('tenant_id', $tenantId)->find($id); + // 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')); } @@ -318,9 +417,9 @@ public function update(int $id, array $data): Item $data['options'] = $this->normalizeOptions($data['options']); } - // code 변경 시 중복 체크 + // code 변경 시 중복 체크 (동적 테이블) if (isset($data['code']) && $data['code'] !== $item->code) { - $dup = Item::query() + $dup = $modelClass::query() ->where('tenant_id', $tenantId) ->where('code', $data['code']) ->exists(); @@ -329,7 +428,7 @@ public function update(int $id, array $data): Item } } - // items 테이블 업데이트 + // 테이블 업데이트 $itemData = array_intersect_key($data, array_flip([ 'item_type', 'code', 'name', 'unit', 'category_id', 'bom', 'attributes', 'options', 'description', 'is_active', @@ -342,44 +441,60 @@ public function update(int $id, array $data): Item $item->update($itemData); - // item_details 테이블 업데이트 - $detailData = $this->extractDetailData($data); - if (! empty($detailData)) { - $item->details()->updateOrCreate( - ['item_id' => $item->id], - $detailData - ); + // 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->load('details')->refresh(); + return $item->refresh(); } /** - * 삭제 (soft delete) + * 삭제 (동적 테이블 라우팅, soft delete) + * + * @param int $id 품목 ID + * @param string $itemType 품목 유형 (필수) */ - public function destroy(int $id): void + public function destroy(int $id, string $itemType): void { - $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); - $item = Item::query()->where('tenant_id', $tenantId)->find($id); + // 동적 테이블 라우팅 + $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) { - $tenantId = $this->tenantId(); $q = trim((string) ($params['q'] ?? '')); $limit = (int) ($params['limit'] ?? 20); $itemType = $params['item_type'] ?? null; - $query = Item::query()->where('tenant_id', $tenantId); + // item_type 필수 검증 + if (! $itemType) { + throw new BadRequestHttpException(__('error.item_type_required')); + } + + // 동적 테이블 라우팅 + $query = $this->newQuery($itemType); if ($q !== '') { $query->where(function ($w) use ($q) { @@ -388,24 +503,23 @@ public function search(array $params) }); } - if ($itemType) { - $query->where('item_type', strtoupper($itemType)); - } - 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): array + public function toggle(int $id, string $itemType): array { - $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - $item = Item::query()->where('tenant_id', $tenantId)->find($id); + // 동적 테이블 라우팅 + $item = $this->newQuery($itemType)->find($id); if (! $item) { throw new BadRequestHttpException(__('error.not_found')); } @@ -417,6 +531,97 @@ public function toggle(int $id): array 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 데이터 추출 */ @@ -437,4 +642,4 @@ private function extractDetailData(array $data): array return array_intersect_key($data, array_flip($detailFields)); } -} \ No newline at end of file +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 8e2d554..e72e729 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -111,6 +111,9 @@ 'field_not_found' => '필드를 찾을 수 없습니다.', 'field_key_reserved' => '":field_key"은(는) 시스템 예약어로 사용할 수 없습니다.', 'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.', + 'item_type_required' => '품목 유형(item_type)은 필수입니다.', + 'invalid_item_type' => '유효하지 않은 품목 유형입니다.', + 'invalid_source_table' => '품목 유형에 대한 소스 테이블이 설정되지 않았습니다.', // 품목 관리 관련 'item' => [