fix : 분류 API

This commit is contained in:
2025-08-22 19:30:05 +09:00
parent 4b6e43ee62
commit fe7d761bf6
6 changed files with 432 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\ClassificationService;
use App\Helpers\ApiResponse;
class ClassificationController extends Controller
{
public function __construct(private ClassificationService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(fn() => $this->service->index($request->all()), '분류 목록 조회');
}
public function show(string $id)
{
return ApiResponse::handle(fn() => $this->service->show((int)$id), '분류 단건 조회');
}
public function store(Request $request)
{
return ApiResponse::handle(fn() => $this->service->store($request->all()), '분류 생성');
}
public function update(string $id, Request $request)
{
return ApiResponse::handle(fn() => $this->service->update((int)$id, $request->all()), '분류 수정');
}
public function destroy(string $id)
{
return ApiResponse::handle(fn() => $this->service->destroy((int)$id), '분류 삭제');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Commons;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Classification extends Model
{
use SoftDeletes, ModelTrait, BelongsToTenant;
protected $fillable = [
'tenant_id',
'group',
'code',
'name',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
protected $hidden = ['deleted_at'];
// 스코프
public function scopeGroup($q, string $group)
{
return $q->where('group', $group);
}
public function scopeActive($q)
{
return $q->where('is_active', 1);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Services;
use App\Models\Commons\Classification;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ClassificationService extends Service
{
/** 목록(검색/페이징) */
public function index(array $params)
{
$tenantId = $this->tenantId();
$page = (int)($params['page'] ?? 1);
$size = (int)($params['size'] ?? 20);
$q = trim((string)($params['q'] ?? ''));
$group = $params['group'] ?? null;
$only = $params['only_active'] ?? null;
$query = Classification::query()->where('tenant_id', $tenantId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('code', 'like', "%{$q}%");
});
}
if (!is_null($group) && $group !== '') {
$query->where('group', $group);
}
if (!is_null($only)) {
$query->where('is_active', (int)!!$only);
}
$query->orderBy('group')->orderBy('code')->orderBy('id');
return $query->paginate($size, ['*'], 'page', $page);
}
/** 단건 */
public function show(int $id)
{
$tenantId = $this->tenantId();
$row = Classification::where('tenant_id', $tenantId)->find($id);
if (!$row) throw new NotFoundHttpException(__('error.not_found'));
return $row;
}
/** 생성 */
public function store(array $params)
{
$tenantId = $this->tenantId();
$uid = $this->apiUserId();
$v = Validator::make($params, [
'group' => 'required|string|max:50',
'code' => 'required|string|max:50',
'name' => 'required|string|max:100',
'is_active' => 'nullable|boolean',
]);
if ($v->fails()) throw new BadRequestHttpException($v->errors()->first());
// 그룹 내 코드 중복 체크(tenant 기준)
$data = $v->validated();
$exists = Classification::where('tenant_id', $tenantId)
->where('group', $data['group'])
->where('code', $data['code'])
->exists();
if ($exists) throw new BadRequestHttpException(__('validation.unique', ['attribute' => 'code']));
$data['tenant_id'] = $tenantId;
$data['is_active'] = (int)($data['is_active'] ?? 1);
return Classification::create($data);
}
/** 수정 */
public function update(int $id, array $params)
{
$tenantId = $this->tenantId();
$row = Classification::where('tenant_id', $tenantId)->find($id);
if (!$row) throw new NotFoundHttpException(__('error.not_found'));
$v = Validator::make($params, [
'group' => 'sometimes|required|string|max:50',
'code' => 'sometimes|required|string|max:50',
'name' => 'sometimes|required|string|max:100',
'is_active' => 'nullable|boolean',
]);
if ($v->fails()) throw new BadRequestHttpException($v->errors()->first());
$payload = $v->validated();
// group 또는 code 변경 시 유니크 검사
$newGroup = $payload['group'] ?? $row->group;
$newCode = $payload['code'] ?? $row->code;
$dupe = Classification::where('tenant_id', $tenantId)
->where('group', $newGroup)
->where('code', $newCode)
->where('id', '!=', $row->id)
->exists();
if ($dupe) throw new BadRequestHttpException(__('validation.unique', ['attribute' => 'code']));
$row->update($payload);
return $row->refresh();
}
/** 삭제(soft) */
public function destroy(int $id)
{
$tenantId = $this->tenantId();
$row = Classification::where('tenant_id', $tenantId)->find($id);
if (!$row) throw new NotFoundHttpException(__('error.not_found'));
$row->delete();
return 'success';
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Classification", description="분류 관리 (평면 구조 / 코드 테이블)")
*
* @OA\Schema(
* schema="Classification",
* type="object",
* required={"id","group","name"},
* @OA\Property(property="id", type="integer", example=11),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="group", type="string", example="product_type"),
* @OA\Property(property="code", type="string", nullable=true, example="OUTER"),
* @OA\Property(property="name", type="string", example="아우터"),
* @OA\Property(property="description", type="string", nullable=true, example=null),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="created_at", type="string", example="2025-08-22 10:00:00"),
* @OA\Property(property="updated_at", type="string", example="2025-08-22 10:10:00")
* )
*
* @OA\Schema(
* schema="ClassificationPagination",
* type="object",
* description="라라벨 LengthAwarePaginator 기본 구조",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/Classification")
* ),
* @OA\Property(property="first_page_url", type="string", example="/api/v1/classifications?page=1"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=3),
* @OA\Property(property="last_page_url", type="string", example="/api/v1/classifications?page=3"),
* @OA\Property(
* property="links",
* type="array",
* @OA\Items(type="object",
* @OA\Property(property="url", type="string", nullable=true, example=null),
* @OA\Property(property="label", type="string", example="&laquo; Previous"),
* @OA\Property(property="active", type="boolean", example=false)
* )
* ),
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/classifications?page=2"),
* @OA\Property(property="path", type="string", example="/api/v1/classifications"),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
* @OA\Property(property="to", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=60)
* )
*
* @OA\Schema(
* schema="ClassificationCreateRequest",
* type="object",
* required={"group","name"},
* @OA\Property(property="group", type="string", maxLength=50, example="product_type"),
* @OA\Property(property="code", type="string", nullable=true, maxLength=50, example="OUTER"),
* @OA\Property(property="name", type="string", maxLength=100, example="아우터"),
* @OA\Property(property="description", type="string", nullable=true, maxLength=255),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="sort_order", type="integer", example=0)
* )
*
* @OA\Schema(
* schema="ClassificationUpdateRequest",
* type="object",
* @OA\Property(property="group", type="string", maxLength=50),
* @OA\Property(property="code", type="string", nullable=true, maxLength=50),
* @OA\Property(property="name", type="string", maxLength=100),
* @OA\Property(property="description", type="string", nullable=true, maxLength=255),
* @OA\Property(property="is_active", type="boolean"),
* @OA\Property(property="sort_order", type="integer")
* )
*/
class ClassificationApi
{
/**
* @OA\Get(
* path="/api/v1/classifications",
* tags={"Classification"},
* summary="분류 목록",
* description="그룹/검색/활성여부 조건으로 분류 목록을 페이징 반환합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
* @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="size", in="query", @OA\Schema(type="integer", example=20)),
* @OA\Parameter(name="q", in="query", description="코드/이름 검색", @OA\Schema(type="string")),
* @OA\Parameter(name="group", in="query", description="분류 그룹 키", @OA\Schema(type="string")),
* @OA\Parameter(name="only_active", in="query", @OA\Schema(type="boolean")),
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ClassificationPagination"))
* })
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/classifications/{id}",
* tags={"Classification"},
* summary="분류 단건 조회",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Classification"))
* })
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/classifications",
* tags={"Classification"},
* summary="분류 생성",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ClassificationCreateRequest")),
* @OA\Response(
* response=200,
* description="생성 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Classification"))
* })
* ),
* @OA\Response(response=400, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/classifications/{id}",
* tags={"Classification"},
* summary="분류 수정",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/ClassificationUpdateRequest")),
* @OA\Response(
* response=200,
* description="수정 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Classification"))
* })
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/classifications/{id}",
* tags={"Classification"},
* summary="분류 삭제(soft)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('classifications', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('group', 50)->comment('분류 그룹 키 (예: product_type, payment_method)');
$table->string('code', 50)->comment('분류 코드 (그룹 내 고유)');
$table->string('name', 100)->comment('분류명');
$table->boolean('is_active')->default(true)->comment('활성여부(1=활성,0=비활성)');
$table->timestamps();
$table->softDeletes()->comment('소프트삭제 시각');
// 고유/검색 인덱스
$table->unique(['tenant_id', 'group', 'code'], 'uniq_tenant_group_code'); // code는 그룹 내 유니크
$table->index(['tenant_id', 'group', 'is_active'], 'idx_tenant_group_active');
// FK (모델링 단계에서 생성, 운영 전 필요 시 제거)
$table->foreign('tenant_id')
->references('id')->on('tenants')
->cascadeOnUpdate()
->restrictOnDelete();
});
// 테이블 주석
DB::statement("ALTER TABLE `classifications` COMMENT='분류 관리(코드 테이블; 평면 구조)';");
}
public function down(): void
{
Schema::dropIfExists('classifications');
}
};

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\V1\ClassificationController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\CommonController;
@@ -238,5 +239,14 @@
Route::delete('/{id}', [CategoryController::class, 'destroy'])->name('v1.categories.destroy'); // 삭제(soft)
});
// Product API
Route::prefix('classifications')->group(function () {
Route::get ('', [ClassificationController::class, 'index'])->name('v1.classifications.index'); // 목록
Route::post ('', [ClassificationController::class, 'store'])->name('v1.classifications.store'); // 생성
Route::get ('/{id}', [ClassificationController::class, 'show'])->whereNumber('id')->name('v1.classifications.show'); // 단건
Route::patch ('/{id}', [ClassificationController::class, 'update'])->whereNumber('id')->name('v1.classifications.update'); // 수정
Route::delete('/{id}', [ClassificationController::class, 'destroy'])->whereNumber('id')->name('v1.classifications.destroy'); // 삭제
});
});
});