From 0ea8c719d7178806e2cb15b206e43a50c5c78931 Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 1 Dec 2025 14:22:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[items]=20=ED=92=88=EB=AA=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=A1=B0=ED=9A=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복 코드 자동 증가 기능 추가 (P-001 → P-002, ABC → ABC-001) - soft delete 항목 조회 파라미터 추가 (include_deleted) - ValidationException 응답 포맷 수정 (공통 에러 형식) - batch delete 라우트 순서 수정 (/{id} 보다 /batch 먼저) - is_active 기본값 true 설정 --- app/Exceptions/Handler.php | 5 +- .../Controllers/Api/V1/ItemsController.php | 3 +- app/Http/Requests/Item/ItemStoreRequest.php | 3 +- app/Services/ItemsService.php | 79 ++++++++++++++++--- app/Swagger/v1/ItemsApi.php | 7 +- routes/api.php | 2 +- 6 files changed, 81 insertions(+), 18 deletions(-) diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index e12120c..3a94f0b 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -65,8 +65,9 @@ public function render($request, Throwable $exception) return response()->json([ 'success' => false, 'message' => '입력값 검증 실패', - 'data' => [ - 'errors' => $exception->errors(), + 'error' => [ + 'code' => 422, + 'details' => $exception->errors(), ], ], 422); } diff --git a/app/Http/Controllers/Api/V1/ItemsController.php b/app/Http/Controllers/Api/V1/ItemsController.php index 327be70..3a848e8 100644 --- a/app/Http/Controllers/Api/V1/ItemsController.php +++ b/app/Http/Controllers/Api/V1/ItemsController.php @@ -24,8 +24,9 @@ public function index(Request $request) return ApiResponse::handle(function () use ($request) { $filters = $request->only(['type', 'search', 'q', 'category_id']); $perPage = (int) ($request->input('size') ?? 20); + $includeDeleted = filter_var($request->input('include_deleted', false), FILTER_VALIDATE_BOOLEAN); - return $this->service->getItems($filters, $perPage); + return $this->service->getItems($filters, $perPage, $includeDeleted); }, __('message.fetched')); } diff --git a/app/Http/Requests/Item/ItemStoreRequest.php b/app/Http/Requests/Item/ItemStoreRequest.php index 4daeee8..abf7ae8 100644 --- a/app/Http/Requests/Item/ItemStoreRequest.php +++ b/app/Http/Requests/Item/ItemStoreRequest.php @@ -14,7 +14,7 @@ public function authorize(): bool public function rules(): array { return [ - // 필수 필드 + // 필수 필드 (중복 시 Service에서 자동 증가 처리) 'code' => 'required|string|max:50', 'name' => 'required|string|max:255', 'product_type' => 'required|string|in:FG,PT,SM,RM,CS', @@ -46,6 +46,7 @@ public function messages(): array return [ 'code.required' => '품목코드는 필수입니다.', 'code.max' => '품목코드는 50자 이내로 입력하세요.', + 'code.unique' => '이미 사용 중인 품목코드입니다.', 'name.required' => '품목명은 필수입니다.', 'name.max' => '품목명은 255자 이내로 입력하세요.', 'product_type.required' => '품목 유형은 필수입니다.', diff --git a/app/Services/ItemsService.php b/app/Services/ItemsService.php index 2d5032e..a83aa91 100644 --- a/app/Services/ItemsService.php +++ b/app/Services/ItemsService.php @@ -15,9 +15,10 @@ class ItemsService extends Service * * @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) + public function getItems(array $filters = [], int $perPage = 20, bool $includeDeleted = false) { $tenantId = $this->tenantId(); @@ -52,8 +53,14 @@ public function getItems(array $filters = [], int $perPage = 20) '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) { @@ -83,8 +90,14 @@ public function getItems(array $filters = [], int $perPage = 20) '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) { @@ -232,19 +245,13 @@ public function createItem(array $data): Product $tenantId = $this->tenantId(); $userId = $this->apiUserId(); - // 품목 코드 중복 체크 - $exists = Product::query() - ->where('tenant_id', $tenantId) - ->where('code', $data['code']) - ->exists(); - - if ($exists) { - throw new BadRequestHttpException(__('error.duplicate_code')); - } + // 품목 코드 중복 시 자동 증가 + $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; @@ -252,6 +259,58 @@ public function createItem(array $data): Product 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 전용) * diff --git a/app/Swagger/v1/ItemsApi.php b/app/Swagger/v1/ItemsApi.php index 6358f49..cdf5b93 100644 --- a/app/Swagger/v1/ItemsApi.php +++ b/app/Swagger/v1/ItemsApi.php @@ -33,7 +33,8 @@ * example={"color": "black", "weight": 5.5} * ), * @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="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)") * ) * * @OA\Schema( @@ -117,9 +118,9 @@ class ItemsApi * @OA\Parameter(ref="#/components/parameters/Page"), * @OA\Parameter(ref="#/components/parameters/Size"), * @OA\Parameter(name="search", in="query", @OA\Schema(type="string"), description="검색어 (code, name)", example="P-001"), - * @OA\Parameter(name="product_type", in="query", @OA\Schema(type="string"), description="품목 타입 필터 (FG,PT,SM,RM,CS)", example="FG"), + * @OA\Parameter(name="type", in="query", @OA\Schema(type="string"), description="품목 타입 필터 (FG,PT,SM,RM,CS 쉼표 구분)", example="FG,PT"), * @OA\Parameter(name="category_id", in="query", @OA\Schema(type="integer"), description="카테고리 ID 필터", example=1), - * @OA\Parameter(name="is_active", in="query", @OA\Schema(type="boolean"), description="활성 상태 필터", example=true), + * @OA\Parameter(name="include_deleted", in="query", @OA\Schema(type="boolean"), description="삭제된 항목 포함 여부", example=false), * * @OA\Response( * response=200, diff --git a/routes/api.php b/routes/api.php index 1c5a855..e763b56 100644 --- a/routes/api.php +++ b/routes/api.php @@ -352,8 +352,8 @@ Route::get('/code/{code}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'showByCode'])->name('v1.items.show_by_code'); // code 기반 조회 Route::get('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'show'])->name('v1.items.show'); // 단건 (item_type 파라미터 필수) Route::put('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'update'])->name('v1.items.update'); // 품목 수정 - Route::delete('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제 Route::delete('/batch', [\App\Http\Controllers\Api\V1\ItemsController::class, 'batchDestroy'])->name('v1.items.batch_destroy'); // 품목 일괄 삭제 + Route::delete('/{id}', [\App\Http\Controllers\Api\V1\ItemsController::class, 'destroy'])->name('v1.items.destroy'); // 품목 삭제 }); // Items BOM (ID-based BOM API)