feat: [items] 품목 생성/조회 개선

- 중복 코드 자동 증가 기능 추가 (P-001 → P-002, ABC → ABC-001)
- soft delete 항목 조회 파라미터 추가 (include_deleted)
- ValidationException 응답 포맷 수정 (공통 에러 형식)
- batch delete 라우트 순서 수정 (/{id} 보다 /batch 먼저)
- is_active 기본값 true 설정
This commit is contained in:
2025-12-01 14:22:50 +09:00
parent aa93c19da1
commit 0ea8c719d7
6 changed files with 81 additions and 18 deletions

View File

@@ -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);
}

View File

@@ -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'));
}

View File

@@ -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' => '품목 유형은 필수입니다.',

View File

@@ -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 전용)
*

View File

@@ -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,

View File

@@ -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)