From 01d9ccaf5778a67cab47105d62cc8683829dec03 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 24 Dec 2025 19:39:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9C=B4=EA=B0=80=20=EB=B6=80=EC=97=AC?= =?UTF-8?q?=ED=98=84=ED=99=A9=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leave_grants 테이블 마이그레이션 추가 - LeaveGrant 모델 생성 (annual/monthly/reward/condolence/other 유형) - LeaveService에 getGrants, storeGrant, destroyGrant 메서드 추가 - LeaveController에 grants, storeGrant, destroyGrant 엔드포인트 추가 - GET/POST/DELETE /api/v1/leaves/grants 라우트 추가 - 연차/월차 부여 시 LeaveBalance total_days 자동 갱신 --- .../Controllers/Api/V1/LeaveController.php | 33 ++++ app/Models/Tenants/LeaveGrant.php | 144 ++++++++++++++++++ app/Services/LeaveService.php | 139 +++++++++++++++++ ...12_24_193042_create_leave_grants_table.php | 35 +++++ lang/ko/message.php | 9 ++ routes/api.php | 3 + 6 files changed, 363 insertions(+) create mode 100644 app/Models/Tenants/LeaveGrant.php create mode 100644 database/migrations/2025_12_24_193042_create_leave_grants_table.php diff --git a/app/Http/Controllers/Api/V1/LeaveController.php b/app/Http/Controllers/Api/V1/LeaveController.php index 0a9b40f..da813d0 100644 --- a/app/Http/Controllers/Api/V1/LeaveController.php +++ b/app/Http/Controllers/Api/V1/LeaveController.php @@ -154,4 +154,37 @@ public function setBalance(BalanceRequest $request): JsonResponse ); }, __('message.updated')); } + + /** + * 휴가 부여 이력 목록 + * GET /v1/leaves/grants + */ + public function grants(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->getGrants($request->all()); + }, __('message.fetched')); + } + + /** + * 휴가 부여 + * POST /v1/leaves/grants + */ + public function storeGrant(Request $request): JsonResponse + { + return ApiResponse::handle(function () use ($request) { + return $this->service->storeGrant($request->all()); + }, __('message.leave.granted')); + } + + /** + * 휴가 부여 삭제 + * DELETE /v1/leaves/grants/{id} + */ + public function destroyGrant(int $id): JsonResponse + { + return ApiResponse::handle(function () use ($id) { + return $this->service->destroyGrant($id); + }, __('message.deleted')); + } } diff --git a/app/Models/Tenants/LeaveGrant.php b/app/Models/Tenants/LeaveGrant.php new file mode 100644 index 0000000..25abff0 --- /dev/null +++ b/app/Models/Tenants/LeaveGrant.php @@ -0,0 +1,144 @@ + 'date', + 'grant_days' => 'decimal:1', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'grant_type', + 'grant_date', + 'grant_days', + 'reason', + 'created_by', + 'updated_by', + 'deleted_by', + ]; + + // ========================================================================= + // 상수 정의 + // ========================================================================= + + public const TYPE_ANNUAL = 'annual'; // 연차 + + public const TYPE_MONTHLY = 'monthly'; // 월차 + + public const TYPE_REWARD = 'reward'; // 포상휴가 + + public const TYPE_CONDOLENCE = 'condolence'; // 경조사 + + public const TYPE_OTHER = 'other'; // 기타 + + public const GRANT_TYPES = [ + self::TYPE_ANNUAL, + self::TYPE_MONTHLY, + self::TYPE_REWARD, + self::TYPE_CONDOLENCE, + self::TYPE_OTHER, + ]; + + // ========================================================================= + // 관계 정의 + // ========================================================================= + + /** + * 부여 대상자 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 부여자 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + // ========================================================================= + // 스코프 + // ========================================================================= + + /** + * 특정 유형 + */ + public function scopeOfType($query, string $type) + { + return $query->where('grant_type', $type); + } + + /** + * 특정 사용자 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * 특정 연도 + */ + public function scopeForYear($query, int $year) + { + return $query->whereYear('grant_date', $year); + } + + /** + * 날짜 범위 + */ + public function scopeBetweenDates($query, string $startDate, string $endDate) + { + return $query->whereBetween('grant_date', [$startDate, $endDate]); + } + + // ========================================================================= + // 헬퍼 메서드 + // ========================================================================= + + /** + * 부여 유형 라벨 + */ + public function getGrantTypeLabelAttribute(): string + { + return match ($this->grant_type) { + self::TYPE_ANNUAL => '연차', + self::TYPE_MONTHLY => '월차', + self::TYPE_REWARD => '포상휴가', + self::TYPE_CONDOLENCE => '경조사', + self::TYPE_OTHER => '기타', + default => $this->grant_type, + }; + } +} diff --git a/app/Services/LeaveService.php b/app/Services/LeaveService.php index 458054b..86119c7 100644 --- a/app/Services/LeaveService.php +++ b/app/Services/LeaveService.php @@ -4,6 +4,7 @@ use App\Models\Tenants\Leave; use App\Models\Tenants\LeaveBalance; +use App\Models\Tenants\LeaveGrant; use App\Models\Tenants\TenantUserProfile; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -443,4 +444,142 @@ private function getOrCreateBalance(int $tenantId, int $userId, int $year): Leav return $balance; } + + // ========================================================================= + // 휴가 부여 관련 메서드 + // ========================================================================= + + /** + * 휴가 부여 이력 목록 조회 + */ + public function getGrants(array $params): LengthAwarePaginator + { + $tenantId = $this->tenantId(); + + $query = LeaveGrant::query() + ->where('tenant_id', $tenantId) + ->with([ + 'user:id,name,email', + 'user.tenantProfile' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId) + ->with('department:id,name'); + }, + 'creator:id,name', + ]); + + // 사용자 필터 + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + + // 부여 유형 필터 + if (! empty($params['grant_type'])) { + $query->where('grant_type', $params['grant_type']); + } + + // 날짜 범위 필터 + if (! empty($params['date_from'])) { + $query->where('grant_date', '>=', $params['date_from']); + } + if (! empty($params['date_to'])) { + $query->where('grant_date', '<=', $params['date_to']); + } + + // 연도 필터 + if (! empty($params['year'])) { + $query->whereYear('grant_date', $params['year']); + } + + // 부서 필터 + if (! empty($params['department_id'])) { + $query->whereHas('user.tenantProfile', function ($q) use ($params, $tenantId) { + $q->where('tenant_id', $tenantId) + ->where('department_id', $params['department_id']); + }); + } + + // 검색 (사용자명) + if (! empty($params['search'])) { + $query->whereHas('user', function ($q) use ($params) { + $q->where('name', 'like', '%'.$params['search'].'%'); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'grant_date'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 휴가 부여 + */ + public function storeGrant(array $data): LeaveGrant + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($data, $tenantId, $userId) { + $grant = LeaveGrant::create([ + 'tenant_id' => $tenantId, + 'user_id' => $data['user_id'], + 'grant_type' => $data['grant_type'], + 'grant_date' => $data['grant_date'], + 'grant_days' => $data['grant_days'], + 'reason' => $data['reason'] ?? null, + 'created_by' => $userId, + ]); + + // 연차/월차인 경우 LeaveBalance의 total_days에 추가 + if (in_array($data['grant_type'], [LeaveGrant::TYPE_ANNUAL, LeaveGrant::TYPE_MONTHLY])) { + $year = \Carbon\Carbon::parse($data['grant_date'])->year; + $balance = $this->getOrCreateBalance($tenantId, $data['user_id'], $year); + $balance->total_days += $data['grant_days']; + $balance->save(); + } + + return $grant->fresh(['user:id,name,email', 'creator:id,name']); + }); + } + + /** + * 휴가 부여 삭제 (soft delete) + */ + public function destroyGrant(int $id): bool + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + return DB::transaction(function () use ($id, $tenantId, $userId) { + $grant = LeaveGrant::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + // 연차/월차인 경우 LeaveBalance의 total_days에서 차감 + if (in_array($grant->grant_type, [LeaveGrant::TYPE_ANNUAL, LeaveGrant::TYPE_MONTHLY])) { + $year = \Carbon\Carbon::parse($grant->grant_date)->year; + $balance = LeaveBalance::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $grant->user_id) + ->where('year', $year) + ->first(); + + if ($balance) { + $balance->total_days = max(0, $balance->total_days - $grant->grant_days); + $balance->save(); + } + } + + $grant->deleted_by = $userId; + $grant->save(); + $grant->delete(); + + return true; + }); + } } diff --git a/database/migrations/2025_12_24_193042_create_leave_grants_table.php b/database/migrations/2025_12_24_193042_create_leave_grants_table.php new file mode 100644 index 0000000..3b1f7f7 --- /dev/null +++ b/database/migrations/2025_12_24_193042_create_leave_grants_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('부여 대상자 ID'); + $table->string('grant_type', 20)->comment('부여유형: annual/monthly/reward/condolence/other'); + $table->date('grant_date')->comment('부여일'); + $table->decimal('grant_days', 4, 1)->comment('부여일수'); + $table->text('reason')->nullable()->comment('부여 사유'); + $table->unsignedBigInteger('created_by')->nullable()->comment('부여자 ID'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['tenant_id', 'user_id'], 'idx_tenant_user'); + $table->index('grant_date', 'idx_grant_date'); + $table->index('grant_type', 'idx_grant_type'); + }); + } + + public function down(): void + { + Schema::dropIfExists('leave_grants'); + } +}; diff --git a/lang/ko/message.php b/lang/ko/message.php index fdac75a..c0c4e0b 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -382,4 +382,13 @@ 'token_toggled' => '토큰 상태가 변경되었습니다.', 'token_deleted' => '토큰이 삭제되었습니다.', ], + + // 휴가 관리 + 'leave' => [ + 'created' => '휴가 신청이 완료되었습니다.', + 'approved' => '휴가가 승인되었습니다.', + 'rejected' => '휴가가 반려되었습니다.', + 'cancelled' => '휴가가 취소되었습니다.', + 'granted' => '휴가가 부여되었습니다.', + ], ]; diff --git a/routes/api.php b/routes/api.php index 62eef4a..3d7dc3e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -308,6 +308,9 @@ Route::get('/balance', [LeaveController::class, 'balance'])->name('v1.leaves.balance'); Route::get('/balance/{userId}', [LeaveController::class, 'userBalance'])->name('v1.leaves.userBalance'); Route::put('/balance', [LeaveController::class, 'setBalance'])->name('v1.leaves.setBalance'); + Route::get('/grants', [LeaveController::class, 'grants'])->name('v1.leaves.grants'); + Route::post('/grants', [LeaveController::class, 'storeGrant'])->name('v1.leaves.grants.store'); + Route::delete('/grants/{id}', [LeaveController::class, 'destroyGrant'])->name('v1.leaves.grants.destroy'); Route::get('/{id}', [LeaveController::class, 'show'])->name('v1.leaves.show'); Route::patch('/{id}', [LeaveController::class, 'update'])->name('v1.leaves.update'); Route::delete('/{id}', [LeaveController::class, 'destroy'])->name('v1.leaves.destroy');