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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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' => '품목 유형은 필수입니다.',
|
||||
|
||||
@@ -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 전용)
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user