feat(API): Position API 추가 (직급/직책 통합)

- Position 모델 생성 (type: rank | title)
- PositionService: CRUD + reorder 구현
- PositionController: REST API 엔드포인트
- Swagger 문서 작성 (PositionApi.php)
- 마이그레이션: positions 테이블 + common_codes 등록
- routes/api.php에 라우트 등록

Phase L-3 (직급관리), L-4 (직책관리) 백엔드 완료

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 11:18:17 +09:00
parent bdb7460bfa
commit 75576323fe
9 changed files with 665 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\PositionReorderRequest;
use App\Http\Requests\PositionRequest;
use App\Services\PositionService;
class PositionController extends Controller
{
public function __construct(private PositionService $service) {}
/**
* GET /v1/positions
*/
public function index(PositionRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->validated());
}, __('message.fetched'));
}
/**
* POST /v1/positions
*/
public function store(PositionRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
/**
* GET /v1/positions/{id}
*/
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
/**
* PUT /v1/positions/{id}
*/
public function update(int $id, PositionRequest $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
/**
* DELETE /v1/positions/{id}
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->destroy($id);
}, __('message.deleted'));
}
/**
* PUT /v1/positions/reorder
*/
public function reorder(PositionReorderRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->reorder($request->validated()['items']);
}, __('message.reordered'));
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PositionReorderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.id' => 'required|integer|exists:positions,id',
'items.*.sort_order' => 'required|integer|min:0',
];
}
public function messages(): array
{
return [
'items.required' => __('validation.required', ['attribute' => '항목']),
'items.*.id.required' => __('validation.required', ['attribute' => 'ID']),
'items.*.id.exists' => __('validation.exists', ['attribute' => 'ID']),
'items.*.sort_order.required' => __('validation.required', ['attribute' => '정렬순서']),
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests;
use App\Models\Tenants\Position;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PositionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$method = $this->method();
if ($method === 'GET') {
return [
'type' => ['nullable', Rule::in([Position::TYPE_RANK, Position::TYPE_TITLE])],
'is_active' => 'nullable|boolean',
'q' => 'nullable|string|max:100',
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
];
}
if ($method === 'POST') {
return [
'type' => ['required', Rule::in([Position::TYPE_RANK, Position::TYPE_TITLE])],
'name' => 'required|string|max:50',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
];
}
if (in_array($method, ['PUT', 'PATCH'])) {
return [
'name' => 'nullable|string|max:50',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
];
}
return [];
}
public function messages(): array
{
return [
'type.required' => __('validation.required', ['attribute' => '유형']),
'type.in' => __('validation.in', ['attribute' => '유형']),
'name.required' => __('validation.required', ['attribute' => '명칭']),
'name.max' => __('validation.max.string', ['attribute' => '명칭', 'max' => 50]),
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 직급/직책 통합 모델
*
* @property int $id
* @property int $tenant_id
* @property string $type rank(직급) | title(직책)
* @property string $name 명칭
* @property int $sort_order 정렬 순서
* @property bool $is_active 활성화 여부
*/
class Position extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'positions';
protected $fillable = [
'tenant_id',
'type',
'name',
'sort_order',
'is_active',
];
protected $casts = [
'tenant_id' => 'int',
'sort_order' => 'int',
'is_active' => 'bool',
];
protected $hidden = [
'deleted_at',
];
// =========================================================================
// 상수
// =========================================================================
public const TYPE_RANK = 'rank'; // 직급
public const TYPE_TITLE = 'title'; // 직책
// =========================================================================
// 스코프
// =========================================================================
public function scopeRanks($query)
{
return $query->where('type', self::TYPE_RANK);
}
public function scopeTitles($query)
{
return $query->where('type', self::TYPE_TITLE);
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order');
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Services;
use App\Models\Tenants\Position;
use Illuminate\Support\Facades\DB;
class PositionService extends Service
{
/**
* 목록 조회
*/
public function index(array $params)
{
$query = Position::query()
->when(isset($params['type']), fn ($q) => $q->where('type', $params['type']))
->when(isset($params['is_active']), fn ($q) => $q->where('is_active', (bool) $params['is_active']))
->when(! empty($params['q']), fn ($q) => $q->where('name', 'like', '%'.$params['q'].'%'))
->ordered();
if (isset($params['per_page'])) {
return $query->paginate((int) $params['per_page']);
}
return $query->get();
}
/**
* 단건 조회
*/
public function show(int $id)
{
$position = Position::find($id);
if (! $position) {
return ['error' => __('error.not_found'), 'code' => 404];
}
return $position;
}
/**
* 생성
*/
public function store(array $data)
{
$tenantId = $this->tenantId();
// 중복 체크
$exists = Position::where('tenant_id', $tenantId)
->where('type', $data['type'])
->where('name', $data['name'])
->exists();
if ($exists) {
return ['error' => __('error.duplicate'), 'code' => 409];
}
// 정렬 순서 자동 설정
if (! isset($data['sort_order'])) {
$maxOrder = Position::where('tenant_id', $tenantId)
->where('type', $data['type'])
->max('sort_order') ?? 0;
$data['sort_order'] = $maxOrder + 1;
}
$position = Position::create([
'tenant_id' => $tenantId,
'type' => $data['type'],
'name' => $data['name'],
'sort_order' => $data['sort_order'],
'is_active' => $data['is_active'] ?? true,
]);
return $position;
}
/**
* 수정
*/
public function update(int $id, array $data)
{
$position = Position::find($id);
if (! $position) {
return ['error' => __('error.not_found'), 'code' => 404];
}
// 이름 중복 체크 (자기 자신 제외)
if (isset($data['name'])) {
$exists = Position::where('tenant_id', $position->tenant_id)
->where('type', $position->type)
->where('name', $data['name'])
->where('id', '!=', $id)
->exists();
if ($exists) {
return ['error' => __('error.duplicate'), 'code' => 409];
}
}
$position->update([
'name' => $data['name'] ?? $position->name,
'sort_order' => $data['sort_order'] ?? $position->sort_order,
'is_active' => $data['is_active'] ?? $position->is_active,
]);
return $position->fresh();
}
/**
* 삭제
*/
public function destroy(int $id)
{
$position = Position::find($id);
if (! $position) {
return ['error' => __('error.not_found'), 'code' => 404];
}
$position->delete();
return ['id' => $id, 'deleted_at' => now()->toDateTimeString()];
}
/**
* 순서 변경 (벌크)
*/
public function reorder(array $items)
{
DB::transaction(function () use ($items) {
foreach ($items as $item) {
Position::where('id', $item['id'])
->update(['sort_order' => $item['sort_order']]);
}
});
return ['success' => true, 'updated' => count($items)];
}
}

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Position",
* description="직급/직책 통합 관리"
* )
*
* @OA\Schema(
* schema="Position",
* type="object",
* required={"id", "tenant_id", "type", "name", "sort_order", "is_active"},
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=287),
* @OA\Property(property="type", type="string", enum={"rank", "title"}, example="rank", description="유형: rank(직급), title(직책)"),
* @OA\Property(property="name", type="string", example="대리", description="명칭"),
* @OA\Property(property="sort_order", type="integer", example=2, description="정렬 순서"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="PositionCreateRequest",
* type="object",
* required={"type", "name"},
* @OA\Property(property="type", type="string", enum={"rank", "title"}, example="rank", description="유형: rank(직급), title(직책)"),
* @OA\Property(property="name", type="string", example="과장", description="명칭"),
* @OA\Property(property="sort_order", type="integer", example=3, description="정렬 순서 (생략시 자동)"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부")
* )
*
* @OA\Schema(
* schema="PositionUpdateRequest",
* type="object",
* @OA\Property(property="name", type="string", example="과장", description="명칭"),
* @OA\Property(property="sort_order", type="integer", example=3, description="정렬 순서"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부")
* )
*
* @OA\Schema(
* schema="PositionReorderRequest",
* type="object",
* required={"items"},
* @OA\Property(
* property="items",
* type="array",
* @OA\Items(
* type="object",
* required={"id", "sort_order"},
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=1)
* )
* )
* )
*/
class PositionApi
{
/**
* @OA\Get(
* path="/api/v1/positions",
* tags={"Position"},
* summary="직급/직책 목록 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* @OA\Parameter(name="type", in="query", description="유형 필터 (rank: 직급, title: 직책)", @OA\Schema(type="string", enum={"rank", "title"})),
* @OA\Parameter(name="is_active", in="query", description="활성화 필터", @OA\Schema(type="boolean")),
* @OA\Parameter(name="q", in="query", description="검색어 (명칭)", @OA\Schema(type="string")),
* @OA\Parameter(name="per_page", in="query", description="페이지당 개수 (생략시 전체)", @OA\Schema(type="integer")),
* @OA\Parameter(name="page", in="query", description="페이지 번호", @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Position"))
* )
* )
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/positions",
* tags={"Position"},
* summary="직급/직책 생성",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/PositionCreateRequest")
* ),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Position")
* )
* ),
* @OA\Response(response=409, description="중복 오류")
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/positions/{id}",
* tags={"Position"},
* summary="직급/직책 단건 조회",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Position")
* )
* ),
* @OA\Response(response=404, description="찾을 수 없음")
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/positions/{id}",
* tags={"Position"},
* 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/PositionUpdateRequest")
* ),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/Position")
* )
* ),
* @OA\Response(response=404, description="찾을 수 없음"),
* @OA\Response(response=409, description="중복 오류")
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/positions/{id}",
* tags={"Position"},
* summary="직급/직책 삭제",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="id", type="integer"),
* @OA\Property(property="deleted_at", type="string", format="date-time")
* )
* )
* ),
* @OA\Response(response=404, description="찾을 수 없음")
* )
*/
public function destroy() {}
/**
* @OA\Put(
* path="/api/v1/positions/reorder",
* tags={"Position"},
* summary="직급/직책 순서 변경 (벌크)",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/PositionReorderRequest")
* ),
* @OA\Response(
* response=200,
* description="성공",
* @OA\JsonContent(
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", type="object",
* @OA\Property(property="success", type="boolean"),
* @OA\Property(property="updated", type="integer")
* )
* )
* )
* )
*/
public function reorder() {}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('positions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->comment('테넌트 ID');
$table->string('type', 20)->comment('유형: rank(직급), title(직책)');
$table->string('name', 50)->comment('명칭');
$table->integer('sort_order')->default(0)->comment('정렬 순서');
$table->boolean('is_active')->default(true)->comment('활성화 여부');
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'type', 'sort_order']);
$table->unique(['tenant_id', 'type', 'name', 'deleted_at'], 'positions_tenant_type_name_unique');
});
}
public function down(): void
{
Schema::dropIfExists('positions');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$now = now();
// position_type 코드 그룹 (직급/직책 구분)
$positionTypes = [
['code' => 'rank', 'name' => '직급', 'sort_order' => 1],
['code' => 'title', 'name' => '직책', 'sort_order' => 2],
];
foreach ($positionTypes as $item) {
DB::table('common_codes')->updateOrInsert(
['code_group' => 'position_type', 'code' => $item['code']],
[
'code_group' => 'position_type',
'code' => $item['code'],
'name' => $item['name'],
'sort_order' => $item['sort_order'],
'is_active' => true,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
public function down(): void
{
DB::table('common_codes')->where('code_group', 'position_type')->delete();
}
};

View File

@@ -73,6 +73,7 @@
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PlanController;
use App\Http\Controllers\Api\V1\PopupController;
use App\Http\Controllers\Api\V1\PositionController;
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\PricingController;
use App\Http\Controllers\Api\V1\PurchaseController;
@@ -292,6 +293,16 @@
Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermissions'])->name('v1.departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지)
});
// Position API (직급/직책 통합 관리)
Route::prefix('positions')->group(function () {
Route::get('', [PositionController::class, 'index'])->name('v1.positions.index');
Route::post('', [PositionController::class, 'store'])->name('v1.positions.store');
Route::put('/reorder', [PositionController::class, 'reorder'])->name('v1.positions.reorder');
Route::get('/{id}', [PositionController::class, 'show'])->name('v1.positions.show');
Route::put('/{id}', [PositionController::class, 'update'])->name('v1.positions.update');
Route::delete('/{id}', [PositionController::class, 'destroy'])->name('v1.positions.destroy');
});
// Employee API (사원 관리)
Route::prefix('employees')->group(function () {
Route::get('', [EmployeeController::class, 'index'])->name('v1.employees.index');