feat: I-8 휴가 정책 관리 API 구현

- LeavePolicyController: 휴가 정책 CRUD API
- LeavePolicyService: 정책 관리 로직
- LeavePolicy 모델: 다중 테넌트 지원
- FormRequest 검증 클래스
- Swagger 문서화
- leave_policies 테이블 마이그레이션

🤖 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-26 15:48:06 +09:00
parent ef3c2ce15f
commit 5c0f92d74a
6 changed files with 435 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\LeavePolicy\UpdateRequest;
use App\Services\LeavePolicyService;
use Illuminate\Http\JsonResponse;
/**
* 휴가 정책 관리 컨트롤러
*/
class LeavePolicyController extends Controller
{
public function __construct(
protected LeavePolicyService $service
) {}
/**
* 휴가 정책 조회
* GET /v1/leave-policy
*/
public function show(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->show();
}, __('message.fetched'));
}
/**
* 휴가 정책 업데이트
* PUT /v1/leave-policy
*/
public function update(UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->update($request->validated());
}, __('message.updated'));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\LeavePolicy;
use App\Models\Tenants\LeavePolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'standard_type' => ['sometimes', Rule::in(LeavePolicy::STANDARD_TYPES)],
'fiscal_start_month' => ['sometimes', 'integer', 'min:1', 'max:12'],
'fiscal_start_day' => ['sometimes', 'integer', 'min:1', 'max:31'],
'default_annual_leave' => ['sometimes', 'integer', 'min:0', 'max:100'],
'additional_leave_per_year' => ['sometimes', 'integer', 'min:0', 'max:10'],
'max_annual_leave' => ['sometimes', 'integer', 'min:0', 'max:100'],
'carry_over_enabled' => ['sometimes', 'boolean'],
'carry_over_max_days' => ['sometimes', 'integer', 'min:0', 'max:100'],
'carry_over_expiry_months' => ['sometimes', 'integer', 'min:0', 'max:24'],
];
}
public function messages(): array
{
return [
'standard_type.in' => __('validation.in', ['attribute' => '기준 유형']),
'fiscal_start_month.min' => __('validation.min.numeric', ['attribute' => '기준월', 'min' => 1]),
'fiscal_start_month.max' => __('validation.max.numeric', ['attribute' => '기준월', 'max' => 12]),
'fiscal_start_day.min' => __('validation.min.numeric', ['attribute' => '기준일', 'min' => 1]),
'fiscal_start_day.max' => __('validation.max.numeric', ['attribute' => '기준일', 'max' => 31]),
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* 휴가 정책 모델
*
* @property int $id
* @property int $tenant_id
* @property string $standard_type
* @property int $fiscal_start_month
* @property int $fiscal_start_day
* @property int $default_annual_leave
* @property int $additional_leave_per_year
* @property int $max_annual_leave
* @property bool $carry_over_enabled
* @property int $carry_over_max_days
* @property int $carry_over_expiry_months
* @property int|null $created_by
* @property int|null $updated_by
*/
class LeavePolicy extends Model
{
use BelongsToTenant;
protected $table = 'leave_policies';
protected $fillable = [
'tenant_id',
'standard_type',
'fiscal_start_month',
'fiscal_start_day',
'default_annual_leave',
'additional_leave_per_year',
'max_annual_leave',
'carry_over_enabled',
'carry_over_max_days',
'carry_over_expiry_months',
'created_by',
'updated_by',
];
protected $casts = [
'fiscal_start_month' => 'integer',
'fiscal_start_day' => 'integer',
'default_annual_leave' => 'integer',
'additional_leave_per_year' => 'integer',
'max_annual_leave' => 'integer',
'carry_over_enabled' => 'boolean',
'carry_over_max_days' => 'integer',
'carry_over_expiry_months' => 'integer',
];
// =========================================================================
// 상수 정의
// =========================================================================
public const TYPE_FISCAL = 'fiscal';
public const TYPE_HIRE = 'hire';
public const STANDARD_TYPES = [
self::TYPE_FISCAL,
self::TYPE_HIRE,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 테넌트
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 기준 유형 라벨
*/
public function getStandardTypeLabelAttribute(): string
{
return match ($this->standard_type) {
self::TYPE_FISCAL => '회계연도',
self::TYPE_HIRE => '입사일',
default => $this->standard_type,
};
}
/**
* 기준일 문자열
*/
public function getFiscalStartDateStringAttribute(): string
{
return "{$this->fiscal_start_month}{$this->fiscal_start_day}";
}
/**
* 기본 설정 생성
*/
public static function createDefault(int $tenantId, ?int $userId = null): self
{
return self::create([
'tenant_id' => $tenantId,
'standard_type' => self::TYPE_FISCAL,
'fiscal_start_month' => 1,
'fiscal_start_day' => 1,
'default_annual_leave' => 15,
'additional_leave_per_year' => 1,
'max_annual_leave' => 25,
'carry_over_enabled' => true,
'carry_over_max_days' => 10,
'carry_over_expiry_months' => 3,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Tenants\LeavePolicy;
class LeavePolicyService extends Service
{
/**
* 휴가 정책 조회
* 없으면 기본값으로 생성
*/
public function show(): LeavePolicy
{
$tenantId = $this->tenantId();
$policy = LeavePolicy::where('tenant_id', $tenantId)->first();
if (! $policy) {
$policy = LeavePolicy::createDefault($tenantId, $this->apiUserId());
}
return $policy;
}
/**
* 휴가 정책 업데이트
* 없으면 생성
*/
public function update(array $data): LeavePolicy
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$policy = LeavePolicy::where('tenant_id', $tenantId)->first();
if ($policy) {
$policy->fill([
'standard_type' => $data['standard_type'] ?? $policy->standard_type,
'fiscal_start_month' => $data['fiscal_start_month'] ?? $policy->fiscal_start_month,
'fiscal_start_day' => $data['fiscal_start_day'] ?? $policy->fiscal_start_day,
'default_annual_leave' => $data['default_annual_leave'] ?? $policy->default_annual_leave,
'additional_leave_per_year' => $data['additional_leave_per_year'] ?? $policy->additional_leave_per_year,
'max_annual_leave' => $data['max_annual_leave'] ?? $policy->max_annual_leave,
'carry_over_enabled' => $data['carry_over_enabled'] ?? $policy->carry_over_enabled,
'carry_over_max_days' => $data['carry_over_max_days'] ?? $policy->carry_over_max_days,
'carry_over_expiry_months' => $data['carry_over_expiry_months'] ?? $policy->carry_over_expiry_months,
'updated_by' => $userId,
]);
$policy->save();
} else {
$policy = LeavePolicy::create([
'tenant_id' => $tenantId,
'standard_type' => $data['standard_type'] ?? LeavePolicy::TYPE_FISCAL,
'fiscal_start_month' => $data['fiscal_start_month'] ?? 1,
'fiscal_start_day' => $data['fiscal_start_day'] ?? 1,
'default_annual_leave' => $data['default_annual_leave'] ?? 15,
'additional_leave_per_year' => $data['additional_leave_per_year'] ?? 1,
'max_annual_leave' => $data['max_annual_leave'] ?? 25,
'carry_over_enabled' => $data['carry_over_enabled'] ?? true,
'carry_over_max_days' => $data['carry_over_max_days'] ?? 10,
'carry_over_expiry_months' => $data['carry_over_expiry_months'] ?? 3,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
return $policy->fresh();
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="LeavePolicy",
* description="휴가 정책 관리 API - 테넌트별 휴가 기준 설정"
* )
*/
/**
* @OA\Schema(
* schema="LeavePolicy",
* description="휴가 정책",
*
* @OA\Property(property="id", type="integer", description="ID", example=1),
* @OA\Property(property="tenant_id", type="integer", description="테넌트 ID", example=1),
* @OA\Property(property="standard_type", type="string", enum={"fiscal", "hire"}, description="기준 유형", example="fiscal"),
* @OA\Property(property="fiscal_start_month", type="integer", description="회계연도 시작 월", example=1),
* @OA\Property(property="fiscal_start_day", type="integer", description="회계연도 시작 일", example=1),
* @OA\Property(property="default_annual_leave", type="integer", description="기본 연차 일수", example=15),
* @OA\Property(property="additional_leave_per_year", type="integer", description="근속년수당 추가 연차", example=1),
* @OA\Property(property="max_annual_leave", type="integer", description="최대 연차 일수", example=25),
* @OA\Property(property="carry_over_enabled", type="boolean", description="이월 허용 여부", example=true),
* @OA\Property(property="carry_over_max_days", type="integer", description="최대 이월 일수", example=10),
* @OA\Property(property="carry_over_expiry_months", type="integer", description="이월 연차 소멸 기간 (개월)", example=3),
* @OA\Property(property="created_at", type="string", format="date-time", description="생성일시"),
* @OA\Property(property="updated_at", type="string", format="date-time", description="수정일시")
* )
*
* @OA\Schema(
* schema="LeavePolicyUpdateRequest",
* description="휴가 정책 수정 요청",
*
* @OA\Property(property="standard_type", type="string", enum={"fiscal", "hire"}, description="기준 유형", example="fiscal"),
* @OA\Property(property="fiscal_start_month", type="integer", minimum=1, maximum=12, description="회계연도 시작 월", example=1),
* @OA\Property(property="fiscal_start_day", type="integer", minimum=1, maximum=31, description="회계연도 시작 일", example=1),
* @OA\Property(property="default_annual_leave", type="integer", minimum=0, maximum=100, description="기본 연차 일수", example=15),
* @OA\Property(property="additional_leave_per_year", type="integer", minimum=0, maximum=10, description="근속년수당 추가 연차", example=1),
* @OA\Property(property="max_annual_leave", type="integer", minimum=0, maximum=100, description="최대 연차 일수", example=25),
* @OA\Property(property="carry_over_enabled", type="boolean", description="이월 허용 여부", example=true),
* @OA\Property(property="carry_over_max_days", type="integer", minimum=0, maximum=100, description="최대 이월 일수", example=10),
* @OA\Property(property="carry_over_expiry_months", type="integer", minimum=0, maximum=24, description="이월 연차 소멸 기간 (개월)", example=3)
* )
*/
class LeavePolicyApi
{
/**
* @OA\Get(
* path="/api/v1/leave-policy",
* operationId="getLeavePolicy",
* tags={"LeavePolicy"},
* summary="휴가 정책 조회",
* description="현재 테넌트의 휴가 정책을 조회합니다. 설정이 없으면 기본값으로 생성됩니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/LeavePolicy")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음")
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/leave-policy",
* operationId="updateLeavePolicy",
* tags={"LeavePolicy"},
* summary="휴가 정책 수정",
* description="현재 테넌트의 휴가 정책을 수정합니다. 부분 업데이트가 가능합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/LeavePolicyUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="정보가 수정되었습니다."),
* @OA\Property(property="data", ref="#/components/schemas/LeavePolicy")
* )
* ),
*
* @OA\Response(response=401, description="인증 실패"),
* @OA\Response(response=403, description="권한 없음"),
* @OA\Response(response=422, description="유효성 검사 실패")
* )
*/
public function update() {}
}

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('leave_policies', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->unique()->constrained()->onDelete('cascade')->comment('테넌트 ID');
// 기준 설정
$table->enum('standard_type', ['fiscal', 'hire'])->default('fiscal')->comment('기준 유형: fiscal=회계연도, hire=입사일');
$table->tinyInteger('fiscal_start_month')->default(1)->comment('회계연도 시작 월 (1-12)');
$table->tinyInteger('fiscal_start_day')->default(1)->comment('회계연도 시작 일 (1-31)');
// 연차 설정
$table->tinyInteger('default_annual_leave')->default(15)->comment('기본 연차 일수');
$table->tinyInteger('additional_leave_per_year')->default(1)->comment('근속년수당 추가 연차');
$table->tinyInteger('max_annual_leave')->default(25)->comment('최대 연차 일수');
// 이월 설정
$table->boolean('carry_over_enabled')->default(true)->comment('이월 허용 여부');
$table->tinyInteger('carry_over_max_days')->default(10)->comment('최대 이월 일수');
$table->tinyInteger('carry_over_expiry_months')->default(3)->comment('이월 연차 소멸 기간 (개월)');
// 감사 필드
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('leave_policies');
}
};