From cde89b2fb3e686e8c2f7031c16a6a85caa8159b5 Mon Sep 17 00:00:00 2001 From: hskwon Date: Tue, 9 Dec 2025 21:51:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Items=20API=20item=5Ftype=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=86=B5=ED=95=A9=20=EC=A1=B0=ED=9A=8C/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemTypeHelper를 활용한 item_type(FG/PT/SM/RM/CS) → source_table 매핑 - getItem: item_type 파라미터로 products/materials 테이블 자동 결정 - deleteItem: item_type 필수 파라미터 추가 - batchDeleteItems: item_type별 일괄 삭제 지원 - 목록 조회 시 attributes 플랫 전개 - Swagger 문서 업데이트 --- .../Controllers/Api/V1/ItemsController.php | 19 +- .../Requests/Item/ItemBatchDeleteRequest.php | 3 + app/Http/Requests/Item/ItemStoreRequest.php | 9 + app/Http/Requests/Item/ItemUpdateRequest.php | 14 + app/Services/ItemsService.php | 315 +++++++++++++++--- app/Swagger/v1/ItemsApi.php | 96 ++++-- routes/api.php | 4 +- 7 files changed, 367 insertions(+), 93 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 3a848e8..e9b7c3f 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -33,17 +33,17 @@ public function index(Request $request) /** * 단일 품목 조회 * - * GET /api/v1/items/{id}?item_type=PRODUCT|MATERIAL&include_price=true&client_id=1&price_date=2025-01-10 + * GET /api/v1/items/{id}?item_type=FG|PT|SM|RM|CS&include_price=true&client_id=1&price_date=2025-01-10 */ public function show(Request $request, int $id) { return ApiResponse::handle(function () use ($request, $id) { - $itemType = strtoupper($request->input('item_type', 'PRODUCT')); + $itemType = strtoupper($request->input('item_type', 'FG')); $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($itemType, $id, $includePrice, $clientId, $priceDate); + return $this->service->getItem($id, $itemType, $includePrice, $clientId, $priceDate); }, __('message.fetched')); } @@ -88,12 +88,13 @@ public function update(int $id, ItemUpdateRequest $request) /** * 품목 삭제 (Soft Delete) * - * DELETE /api/v1/items/{id} + * DELETE /api/v1/items/{id}?item_type=FG|PT|SM|RM|CS */ - public function destroy(int $id) + public function destroy(Request $request, int $id) { - return ApiResponse::handle(function () use ($id) { - $this->service->deleteItem($id); + return ApiResponse::handle(function () use ($request, $id) { + $itemType = strtoupper($request->input('item_type', 'FG')); + $this->service->deleteItem($id, $itemType); return 'success'; }, __('message.item.deleted')); @@ -107,7 +108,9 @@ public function destroy(int $id) public function batchDestroy(ItemBatchDeleteRequest $request) { return ApiResponse::handle(function () use ($request) { - $this->service->batchDeleteItems($request->validated()['ids']); + $validated = $request->validated(); + $itemType = strtoupper($validated['item_type'] ?? 'FG'); + $this->service->batchDeleteItems($validated['ids'], $itemType); return 'success'; }, __('message.item.batch_deleted')); diff --git a/app/Http/Requests/Item/ItemBatchDeleteRequest.php b/app/Http/Requests/Item/ItemBatchDeleteRequest.php index b76316d..8f64d28 100644 --- a/app/Http/Requests/Item/ItemBatchDeleteRequest.php +++ b/app/Http/Requests/Item/ItemBatchDeleteRequest.php @@ -14,6 +14,7 @@ public function authorize(): bool public function rules(): array { return [ + 'item_type' => 'required|string|in:FG,PT,SM,RM,CS', 'ids' => 'required|array|min:1', 'ids.*' => 'required|integer|min:1', ]; @@ -22,6 +23,8 @@ public function rules(): array public function messages(): array { return [ + 'item_type.required' => '품목 유형은 필수입니다.', + 'item_type.in' => '품목 유형은 FG, PT, SM, RM, CS 중 하나여야 합니다.', 'ids.required' => '삭제할 품목 ID 목록은 필수입니다.', 'ids.array' => '품목 ID 목록은 배열이어야 합니다.', 'ids.min' => '삭제할 품목을 하나 이상 선택하세요.', diff --git a/app/Http/Requests/Item/ItemStoreRequest.php b/app/Http/Requests/Item/ItemStoreRequest.php index abf7ae8..5a6ecd3 100644 --- a/app/Http/Requests/Item/ItemStoreRequest.php +++ b/app/Http/Requests/Item/ItemStoreRequest.php @@ -38,6 +38,15 @@ public function rules(): array // 동적 필드 (JSON) 'attributes' => 'nullable|array', + + // Material 전용 필드 + 'material_code' => 'nullable|string|max:50', + 'item_name' => 'nullable|string|max:255', + 'specification' => 'nullable|string|max:255', + 'is_inspection' => 'nullable|string|in:Y,N', + 'search_tag' => 'nullable|string|max:255', + 'remarks' => 'nullable|string', + 'options' => 'nullable|array', ]; } diff --git a/app/Http/Requests/Item/ItemUpdateRequest.php b/app/Http/Requests/Item/ItemUpdateRequest.php index 3fbbdee..f462884 100644 --- a/app/Http/Requests/Item/ItemUpdateRequest.php +++ b/app/Http/Requests/Item/ItemUpdateRequest.php @@ -14,6 +14,9 @@ public function authorize(): bool public function rules(): array { return [ + // 품목 유형 (필수 - 테이블 분기용) + 'item_type' => 'required|string|in:FG,PT,SM,RM,CS', + // 선택 필드 (모두 sometimes) 'code' => 'sometimes|string|max:50', 'name' => 'sometimes|string|max:255', @@ -36,12 +39,23 @@ public function rules(): array // 동적 필드 (JSON) 'attributes' => 'sometimes|array', + + // Material 전용 필드 + 'material_code' => 'sometimes|string|max:50', + 'item_name' => 'nullable|string|max:255', + 'specification' => 'nullable|string|max:255', + 'is_inspection' => 'nullable|string|in:Y,N', + 'search_tag' => 'nullable|string|max:255', + 'remarks' => 'nullable|string', + 'options' => 'nullable|array', ]; } public function messages(): array { return [ + 'item_type.required' => '품목 유형은 필수입니다.', + 'item_type.in' => '품목 유형은 FG, PT, SM, RM, CS 중 하나여야 합니다.', 'code.max' => '품목코드는 50자 이내로 입력하세요.', 'name.max' => '품목명은 255자 이내로 입력하세요.', 'product_type.in' => '품목 유형은 FG, PT, SM, RM, CS 중 하나여야 합니다.', diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index b98ed25..dd0f51c 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Helpers\ItemTypeHelper; use App\Models\Materials\Material; use App\Models\Products\Product; use Illuminate\Support\Facades\DB; @@ -46,12 +47,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe ->where('is_active', 1) ->select([ 'id', - DB::raw("'PRODUCT' as item_type"), + 'product_type as item_type', 'code', 'name', + DB::raw('NULL as specification'), 'unit', 'category_id', 'product_type as type_code', + 'attributes', 'created_at', 'deleted_at', ]); @@ -83,12 +86,14 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe ->whereIn('material_type', $materialTypes) ->select([ 'id', - DB::raw("'MATERIAL' as item_type"), + 'material_type as item_type', 'material_code as code', 'name', + 'specification', 'unit', 'category_id', 'material_type as type_code', + 'attributes', 'created_at', 'deleted_at', ]); @@ -127,9 +132,21 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe $items = $items->merge($materials); } - // 정렬 (name 기준 오름차순) + // 정렬 (created_at 기준 내림차순) $items = $items->sortByDesc('created_at')->values(); + // attributes 플랫 전개 + $items = $items->map(function ($item) { + $data = $item->toArray(); + $attributes = $data['attributes'] ?? []; + if (is_string($attributes)) { + $attributes = json_decode($attributes, true) ?? []; + } + unset($data['attributes']); + + return array_merge($data, $attributes); + }); + // 페이지네이션 처리 $page = request()->input('page', 1); $offset = ($page - 1) * $perPage; @@ -149,46 +166,27 @@ public function getItems(array $filters = [], int $perPage = 20, bool $includeDe /** * 단일 품목 조회 * - * @param string $itemType 'PRODUCT' | 'MATERIAL' * @param int $id 품목 ID + * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) * @param bool $includePrice 가격 정보 포함 여부 * @param int|null $clientId 고객 ID (가격 조회 시) * @param string|null $priceDate 기준일 (가격 조회 시) * @return array 품목 데이터 (item_type, prices 포함) */ public function getItem( - string $itemType, int $id, + string $itemType = 'FG', 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') { + // item_type으로 source_table 결정 + if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { $material = Material::query() + ->with('category:id,name') ->where('tenant_id', $tenantId) ->find($id); @@ -196,20 +194,53 @@ public function getItem( throw new NotFoundHttpException(__('error.not_found')); } - $data = $material->toArray(); - $data['item_type'] = 'MATERIAL'; + $data = $this->flattenAttributes($material->toArray()); + $data['item_type'] = $itemType; $data['code'] = $material->material_code; $data['type_code'] = $material->material_type; // 가격 정보 추가 if ($includePrice) { - $data['prices'] = $this->fetchPrices($itemType, $id, $clientId, $priceDate); + $data['prices'] = $this->fetchPrices('MATERIAL', $id, $clientId, $priceDate); } return $data; - } else { - throw new \InvalidArgumentException(__('error.invalid_item_type')); } + + // Product (FG, PT) + $product = Product::query() + ->with('category:id,name') + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $product) { + throw new NotFoundHttpException(__('error.not_found')); + } + + $data = $this->flattenAttributes($product->toArray()); + $data['item_type'] = $itemType; + $data['type_code'] = $product->product_type; + + // 가격 정보 추가 + if ($includePrice) { + $data['prices'] = $this->fetchPrices('PRODUCT', $id, $clientId, $priceDate); + } + + return $data; + } + + /** + * attributes JSON 필드를 최상위로 플랫 전개 + */ + private function flattenAttributes(array $data): array + { + $attributes = $data['attributes'] ?? []; + if (is_string($attributes)) { + $attributes = json_decode($attributes, true) ?? []; + } + unset($data['attributes']); + + return array_merge($data, $attributes); } /** @@ -406,12 +437,27 @@ private function resolveUniqueCode(string $code, int $tenantId): string } /** - * 품목 수정 (Product 전용) + * 품목 수정 (Product/Material 통합) * * @param int $id 품목 ID - * @param array $data 검증된 데이터 + * @param array $data 검증된 데이터 (item_type 필수) */ - public function updateItem(int $id, array $data): Product + public function updateItem(int $id, array $data): Product|Material + { + $tenantId = $this->tenantId(); + $itemType = strtoupper($data['item_type'] ?? 'FG'); + + if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { + return $this->updateMaterial($id, $data); + } + + return $this->updateProduct($id, $data); + } + + /** + * Product 수정 (FG, PT) + */ + private function updateProduct(int $id, array $data): Product { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); @@ -437,6 +483,8 @@ public function updateItem(int $id, array $data): Product } } + // item_type은 DB 필드가 아니므로 제거 + unset($data['item_type']); $data['updated_by'] = $userId; $product->update($data); @@ -444,14 +492,71 @@ public function updateItem(int $id, array $data): Product } /** - * 품목 삭제 (Product 전용, Soft Delete) + * Material 수정 (SM, RM, CS) + */ + private function updateMaterial(int $id, array $data): Material + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $material = Material::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $material) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // 코드 변경 시 중복 체크 + $newCode = $data['material_code'] ?? $data['code'] ?? null; + if ($newCode && $newCode !== $material->material_code) { + $exists = Material::query() + ->where('tenant_id', $tenantId) + ->where('material_code', $newCode) + ->where('id', '!=', $material->id) + ->exists(); + + if ($exists) { + throw new BadRequestHttpException(__('error.duplicate_code')); + } + $data['material_code'] = $newCode; + } + + // item_type, code는 DB 필드가 아니므로 제거 + unset($data['item_type'], $data['code']); + $data['updated_by'] = $userId; + $material->update($data); + + return $material->refresh(); + } + + /** + * 품목 삭제 (Product/Material 통합, Soft Delete) * * - 이미 삭제된 품목은 404 반환 * - 다른 BOM의 구성품으로 사용 중이면 삭제 불가 * * @param int $id 품목 ID + * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) */ - public function deleteItem(int $id): void + public function deleteItem(int $id, string $itemType = 'FG'): void + { + $tenantId = $this->tenantId(); + $itemType = strtoupper($itemType); + + if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { + $this->deleteMaterial($id); + + return; + } + + $this->deleteProduct($id); + } + + /** + * Product 삭제 (FG, PT) + */ + private function deleteProduct(int $id): void { $tenantId = $this->tenantId(); @@ -472,6 +577,30 @@ public function deleteItem(int $id): void $product->delete(); } + /** + * Material 삭제 (SM, RM, CS) + */ + private function deleteMaterial(int $id): void + { + $tenantId = $this->tenantId(); + + $material = Material::query() + ->where('tenant_id', $tenantId) + ->find($id); + + if (! $material) { + throw new NotFoundHttpException(__('error.item.not_found')); + } + + // BOM 구성품으로 사용 중인지 체크 + $usageCount = $this->checkMaterialUsageInBom($tenantId, $id); + if ($usageCount > 0) { + throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => $usageCount])); + } + + $material->delete(); + } + /** * Product가 다른 BOM의 구성품으로 사용 중인지 체크 * @@ -489,13 +618,47 @@ private function checkProductUsageInBom(int $tenantId, int $productId): int } /** - * 품목 일괄 삭제 (Product 전용, Soft Delete) + * Material이 다른 BOM의 구성품으로 사용 중인지 체크 + * + * @param int $tenantId 테넌트 ID + * @param int $materialId 자재 ID + * @return int 사용 건수 + */ + private function checkMaterialUsageInBom(int $tenantId, int $materialId): int + { + return \App\Models\Products\ProductComponent::query() + ->where('tenant_id', $tenantId) + ->where('ref_type', 'MATERIAL') + ->where('ref_id', $materialId) + ->count(); + } + + /** + * 품목 일괄 삭제 (Product/Material 통합, Soft Delete) * * - 다른 BOM의 구성품으로 사용 중인 품목은 삭제 불가 * * @param array $ids 품목 ID 배열 + * @param string $itemType 품목 유형 코드 (FG/PT/SM/RM/CS) */ - public function batchDeleteItems(array $ids): void + public function batchDeleteItems(array $ids, string $itemType = 'FG'): void + { + $tenantId = $this->tenantId(); + $itemType = strtoupper($itemType); + + if (ItemTypeHelper::isMaterial($itemType, $tenantId)) { + $this->batchDeleteMaterials($ids); + + return; + } + + $this->batchDeleteProducts($ids); + } + + /** + * Product 일괄 삭제 (FG, PT) + */ + private function batchDeleteProducts(array $ids): void { $tenantId = $this->tenantId(); @@ -527,15 +690,53 @@ public function batchDeleteItems(array $ids): void } /** - * 품목 상세 조회 (code 기반, BOM 포함 옵션) - * - * @param string $code 품목 코드 - * @param bool $includeBom BOM 포함 여부 + * Material 일괄 삭제 (SM, RM, CS) */ - public function getItemByCode(string $code, bool $includeBom = false): Product + private function batchDeleteMaterials(array $ids): void { $tenantId = $this->tenantId(); + $materials = Material::query() + ->where('tenant_id', $tenantId) + ->whereIn('id', $ids) + ->get(); + + if ($materials->isEmpty()) { + throw new NotFoundHttpException(__('error.item.not_found')); + } + + // BOM 구성품으로 사용 중인 자재 체크 + $inUseIds = []; + foreach ($materials as $material) { + $usageCount = $this->checkMaterialUsageInBom($tenantId, $material->id); + if ($usageCount > 0) { + $inUseIds[] = $material->id; + } + } + + if (! empty($inUseIds)) { + throw new BadRequestHttpException(__('error.item.in_use_as_bom_component', ['count' => count($inUseIds)])); + } + + foreach ($materials as $material) { + $material->delete(); + } + } + + /** + * 품목 상세 조회 (code 기반, BOM 포함 옵션) + * + * Product 먼저 조회 → 없으면 Material 조회 + * + * @param string $code 품목 코드 + * @param bool $includeBom BOM 포함 여부 + * @return array 품목 데이터 (attributes 플랫 전개) + */ + public function getItemByCode(string $code, bool $includeBom = false): array + { + $tenantId = $this->tenantId(); + + // 1. Product에서 먼저 조회 $query = Product::query() ->with('category:id,name') ->where('tenant_id', $tenantId) @@ -547,10 +748,30 @@ public function getItemByCode(string $code, bool $includeBom = false): Product $product = $query->first(); - if (! $product) { - throw new NotFoundHttpException(__('error.not_found')); + if ($product) { + $data = $this->flattenAttributes($product->toArray()); + $data['item_type'] = $product->product_type; + $data['type_code'] = $product->product_type; + + return $data; } - return $product; + // 2. Material에서 조회 + $material = Material::query() + ->with('category:id,name') + ->where('tenant_id', $tenantId) + ->where('material_code', $code) + ->first(); + + if ($material) { + $data = $this->flattenAttributes($material->toArray()); + $data['item_type'] = $material->material_type; + $data['code'] = $material->material_code; + $data['type_code'] = $material->material_type; + + return $data; + } + + throw new NotFoundHttpException(__('error.not_found')); } } diff --git a/app/Swagger/v1/ItemsApi.php b/app/Swagger/v1/ItemsApi.php index e1705ea..2879d4c 100644 --- a/app/Swagger/v1/ItemsApi.php +++ b/app/Swagger/v1/ItemsApi.php @@ -3,19 +3,21 @@ namespace App\Swagger\v1; /** - * @OA\Tag(name="Items", description="품목 관리 (Product CRUD)") + * @OA\Tag(name="Items", description="품목 관리 (Product + Material CRUD)") * * @OA\Schema( * schema="Item", * type="object", - * required={"id","code","name","product_type","unit"}, + * required={"id","code","name","item_type","unit"}, * * @OA\Property(property="id", type="integer", example=1), * @OA\Property(property="code", type="string", example="P-001"), * @OA\Property(property="name", type="string", example="스크린 제품 A"), - * @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"), + * @OA\Property(property="item_type", type="string", example="FG", description="품목 유형 (FG|PT|SM|RM|CS)"), + * @OA\Property(property="type_code", type="string", example="FG", description="품목 유형 코드"), * @OA\Property(property="unit", type="string", example="EA"), * @OA\Property(property="category_id", type="integer", nullable=true, example=1), + * @OA\Property(property="specification", type="string", nullable=true, example="1.2T x 1219 x 2438", description="규격 (Material 전용)"), * @OA\Property(property="description", type="string", nullable=true, example="제품 설명"), * @OA\Property(property="is_sellable", type="boolean", example=true), * @OA\Property(property="is_purchasable", type="boolean", example=false), @@ -25,13 +27,10 @@ * @OA\Property(property="is_variable_size", type="boolean", example=false), * @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"), * @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"), - * @OA\Property( - * property="attributes", - * type="object", - * nullable=true, - * description="동적 속성 (JSON)", - * example={"color": "black", "weight": 5.5} - * ), + * @OA\Property(property="item_name", type="string", nullable=true, example="철판", description="품명 (Material 전용)"), + * @OA\Property(property="is_inspection", type="string", nullable=true, example="Y", description="검수 여부 (Material 전용)"), + * @OA\Property(property="search_tag", type="string", nullable=true, example="철판,원자재,1.2T", description="검색 태그 (Material 전용)"), + * @OA\Property(property="remarks", type="string", nullable=true, example="비고", description="비고 (Material 전용)"), * @OA\Property(property="created_at", type="string", example="2025-11-14 10:00:00"), * @OA\Property(property="updated_at", type="string", example="2025-11-14 10:10:00"), * @OA\Property(property="deleted_at", type="string", nullable=true, example=null, description="삭제일시 (soft delete)") @@ -44,7 +43,7 @@ * * @OA\Property(property="code", type="string", maxLength=50, example="P-001"), * @OA\Property(property="name", type="string", maxLength=255, example="스크린 제품 A"), - * @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"), + * @OA\Property(property="product_type", type="string", example="FG", description="품목 유형 (FG|PT|SM|RM|CS)"), * @OA\Property(property="unit", type="string", maxLength=20, example="EA"), * @OA\Property(property="category_id", type="integer", nullable=true, example=1), * @OA\Property(property="description", type="string", nullable=true, example="제품 설명"), @@ -56,21 +55,25 @@ * @OA\Property(property="is_variable_size", type="boolean", nullable=true, example=false), * @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"), * @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"), - * @OA\Property( - * property="attributes", - * type="object", - * nullable=true, - * description="동적 속성 (JSON)" - * ) + * @OA\Property(property="attributes", type="object", nullable=true, description="동적 속성 (JSON)"), + * @OA\Property(property="material_code", type="string", nullable=true, maxLength=50, example="M-001", description="Material 코드 (Material 전용)"), + * @OA\Property(property="item_name", type="string", nullable=true, maxLength=255, example="철판", description="품명 (Material 전용)"), + * @OA\Property(property="specification", type="string", nullable=true, maxLength=255, example="1.2T x 1219 x 2438", description="규격 (Material 전용)"), + * @OA\Property(property="is_inspection", type="string", nullable=true, example="Y", description="검수 여부 Y|N (Material 전용)"), + * @OA\Property(property="search_tag", type="string", nullable=true, maxLength=255, example="철판,원자재", description="검색 태그 (Material 전용)"), + * @OA\Property(property="remarks", type="string", nullable=true, example="비고", description="비고 (Material 전용)"), + * @OA\Property(property="options", type="object", nullable=true, description="옵션 (Material 전용)") * ) * * @OA\Schema( * schema="ItemUpdateRequest", * type="object", + * required={"item_type"}, * + * @OA\Property(property="item_type", type="string", example="FG", description="품목 유형 (필수, FG|PT|SM|RM|CS)"), * @OA\Property(property="code", type="string", maxLength=50, example="P-001"), * @OA\Property(property="name", type="string", maxLength=255, example="스크린 제품 A"), - * @OA\Property(property="product_type", type="string", example="FG", description="FG,PT,SM,RM,CS"), + * @OA\Property(property="product_type", type="string", example="FG", description="FG|PT|SM|RM|CS"), * @OA\Property(property="unit", type="string", maxLength=20, example="EA"), * @OA\Property(property="category_id", type="integer", nullable=true, example=1), * @OA\Property(property="description", type="string", nullable=true, example="제품 설명"), @@ -82,19 +85,22 @@ * @OA\Property(property="is_variable_size", type="boolean", nullable=true, example=false), * @OA\Property(property="product_category", type="string", nullable=true, example="SCREEN"), * @OA\Property(property="part_type", type="string", nullable=true, example="ASSEMBLY"), - * @OA\Property( - * property="attributes", - * type="object", - * nullable=true, - * description="동적 속성 (JSON)" - * ) + * @OA\Property(property="attributes", type="object", nullable=true, description="동적 속성 (JSON)"), + * @OA\Property(property="material_code", type="string", nullable=true, maxLength=50, example="M-001", description="Material 코드 (Material 전용)"), + * @OA\Property(property="item_name", type="string", nullable=true, maxLength=255, example="철판", description="품명 (Material 전용)"), + * @OA\Property(property="specification", type="string", nullable=true, maxLength=255, example="1.2T x 1219 x 2438", description="규격 (Material 전용)"), + * @OA\Property(property="is_inspection", type="string", nullable=true, example="Y", description="검수 여부 Y|N (Material 전용)"), + * @OA\Property(property="search_tag", type="string", nullable=true, maxLength=255, example="철판,원자재", description="검색 태그 (Material 전용)"), + * @OA\Property(property="remarks", type="string", nullable=true, example="비고", description="비고 (Material 전용)"), + * @OA\Property(property="options", type="object", nullable=true, description="옵션 (Material 전용)") * ) * * @OA\Schema( * schema="ItemBatchDeleteRequest", * type="object", - * required={"ids"}, + * required={"item_type","ids"}, * + * @OA\Property(property="item_type", type="string", example="FG", description="품목 유형 (필수, FG|PT|SM|RM|CS)"), * @OA\Property( * property="ids", * type="array", @@ -112,7 +118,7 @@ class ItemsApi * path="/api/v1/items", * tags={"Items"}, * summary="품목 목록 조회 (통합)", - * description="Product + Material 통합 조회, 페이징 지원", + * description="Product + Material 통합 조회, 페이징 지원. attributes 필드는 플랫 전개됩니다.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter(ref="#/components/parameters/Page"), @@ -130,7 +136,7 @@ class ItemsApi * type="object", * * @OA\Property(property="success", type="boolean", example=true), - * @OA\Property(property="message", type="string", example="품목 목록을 조회했습니다."), + * @OA\Property(property="message", type="string", example="조회되었습니다."), * @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Item")), * @OA\Property(property="meta", ref="#/components/schemas/PaginationMeta") * ) @@ -147,6 +153,7 @@ public function index() {} * path="/api/v1/items", * tags={"Items"}, * summary="품목 생성", + * description="product_type에 따라 Product(FG,PT) 또는 Material(SM,RM,CS) 테이블에 저장됩니다.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\RequestBody( @@ -176,10 +183,11 @@ public function store() {} * path="/api/v1/items/code/{code}", * tags={"Items"}, * summary="품목 코드로 상세 조회", + * description="Product 먼저 조회 후, 없으면 Material에서 조회합니다.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter(name="code", in="path", required=true, @OA\Schema(type="string"), example="P-001"), - * @OA\Parameter(name="include_bom", in="query", @OA\Schema(type="boolean"), example=false), + * @OA\Parameter(name="include_bom", in="query", @OA\Schema(type="boolean"), example=false, description="BOM 포함 여부 (Product만 해당)"), * * @OA\Response( * response=200, @@ -192,7 +200,9 @@ public function store() {} * @OA\Property(property="message", type="string", example="품목을 조회했습니다."), * @OA\Property(property="data", ref="#/components/schemas/Item") * ) - * ) + * ), + * + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function showByCode() {} @@ -202,11 +212,11 @@ public function showByCode() {} * path="/api/v1/items/{id}", * tags={"Items"}, * summary="품목 ID로 상세 조회", - * description="품목 ID로 상세 정보를 조회합니다. item_type 파라미터 필수.", + * description="item_type 파라미터로 조회할 테이블을 지정합니다. (FG,PT→products / SM,RM,CS→materials)", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1, description="품목 ID"), - * @OA\Parameter(name="item_type", in="query", required=true, @OA\Schema(type="string", enum={"PRODUCT", "MATERIAL"}), example="PRODUCT", description="품목 유형"), + * @OA\Parameter(name="item_type", in="query", required=false, @OA\Schema(type="string", enum={"FG", "PT", "SM", "RM", "CS"}, default="FG"), example="FG", description="품목 유형 (기본값: FG)"), * @OA\Parameter(name="include_price", in="query", @OA\Schema(type="boolean"), example=false, description="단가 정보 포함 여부"), * @OA\Parameter(name="client_id", in="query", @OA\Schema(type="integer"), example=1, description="거래처 ID (단가 조회 시)"), * @OA\Parameter(name="price_date", in="query", @OA\Schema(type="string", format="date"), example="2025-01-10", description="단가 기준일"), @@ -222,7 +232,9 @@ public function showByCode() {} * @OA\Property(property="message", type="string", example="조회되었습니다."), * @OA\Property(property="data", ref="#/components/schemas/Item") * ) - * ) + * ), + * + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function show() {} @@ -232,6 +244,7 @@ public function show() {} * path="/api/v1/items/{id}", * tags={"Items"}, * summary="품목 수정", + * description="item_type 필드 필수. Product(FG,PT) 또는 Material(SM,RM,CS)을 자동 분기하여 수정합니다.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1, description="품목 ID"), @@ -253,7 +266,10 @@ public function show() {} * @OA\Property(property="message", type="string", example="품목이 수정되었습니다."), * @OA\Property(property="data", ref="#/components/schemas/Item") * ) - * ) + * ), + * + * @OA\Response(response=400, description="코드 중복", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function update() {} @@ -263,9 +279,11 @@ public function update() {} * path="/api/v1/items/{id}", * tags={"Items"}, * summary="품목 삭제", + * description="item_type 파라미터로 삭제할 테이블을 지정합니다. BOM 구성품으로 사용 중인 경우 삭제 불가.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1, description="품목 ID"), + * @OA\Parameter(name="item_type", in="query", required=false, @OA\Schema(type="string", enum={"FG", "PT", "SM", "RM", "CS"}, default="FG"), example="FG", description="품목 유형 (기본값: FG)"), * * @OA\Response( * response=200, @@ -278,7 +296,10 @@ public function update() {} * @OA\Property(property="message", type="string", example="품목이 삭제되었습니다."), * @OA\Property(property="data", type="string", example="success") * ) - * ) + * ), + * + * @OA\Response(response=400, description="BOM 사용 중", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function destroy() {} @@ -288,7 +309,7 @@ public function destroy() {} * path="/api/v1/items/batch", * tags={"Items"}, * summary="품목 일괄 삭제", - * description="여러 품목을 한 번에 삭제합니다.", + * description="item_type 필드 필수. 여러 품목을 한 번에 삭제합니다. BOM 구성품으로 사용 중인 경우 삭제 불가.", * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\RequestBody( @@ -308,7 +329,10 @@ public function destroy() {} * @OA\Property(property="message", type="string", example="품목이 일괄 삭제되었습니다."), * @OA\Property(property="data", type="string", example="success") * ) - * ) + * ), + * + * @OA\Response(response=400, description="BOM 사용 중", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")), + * @OA\Response(response=404, description="품목 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")) * ) */ public function batchDestroy() {} diff --git a/routes/api.php b/routes/api.php index e17b625..b246de4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\Admin\GlobalMenuController; use App\Http\Controllers\Api\V1\AdminController; use App\Http\Controllers\Api\V1\ApiController; +use App\Http\Controllers\Api\V1\AttendanceController; use App\Http\Controllers\Api\V1\BoardController; use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryFieldController; @@ -13,13 +14,12 @@ use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CommonController; use App\Http\Controllers\Api\V1\DepartmentController; -use App\Http\Controllers\Api\V1\EmployeeController; -use App\Http\Controllers\Api\V1\AttendanceController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; use App\Http\Controllers\Api\V1\Design\BomCalculationController; use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController; use App\Http\Controllers\Api\V1\Design\DesignModelController; use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController; +use App\Http\Controllers\Api\V1\EmployeeController; use App\Http\Controllers\Api\V1\EstimateController; use App\Http\Controllers\Api\V1\FileStorageController; use App\Http\Controllers\Api\V1\FolderController;