Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bmilab.backend.domain.leave.controller;

import com.bmilab.backend.domain.leave.dto.request.AdminUpdateLeaveRequest;
import com.bmilab.backend.domain.leave.dto.request.RejectLeaveRequest;
import com.bmilab.backend.domain.leave.dto.response.LeaveFindAllResponse;
import com.bmilab.backend.domain.leave.enums.LeaveStatus;
Expand Down Expand Up @@ -72,4 +73,26 @@ ResponseEntity<LeaveFindAllResponse> getLeaves(
@RequestParam(required = false) LeaveStatus status,
@PageableDefault(size = 10, sort = "applicatedAt", direction = Sort.Direction.DESC) @ParameterObject Pageable pageable
);

@Operation(summary = "승인된 휴가 수정", description = "승인된 휴가 정보를 수정하는 PATCH API")
@ApiResponses(
value = {
@ApiResponse(
responseCode = "200",
description = "휴가 수정 성공"
),
@ApiResponse(
responseCode = "400",
description = "승인된 휴가만 수정할 수 있습니다."
),
@ApiResponse(
responseCode = "404",
description = "휴가 정보를 찾을 수 없습니다."
)
}
)
ResponseEntity<Void> updateLeave(
@PathVariable long leaveId,
AdminUpdateLeaveRequest request
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bmilab.backend.domain.leave.controller;

import com.bmilab.backend.domain.leave.dto.request.AdminUpdateLeaveRequest;
import com.bmilab.backend.domain.leave.dto.request.RejectLeaveRequest;
import com.bmilab.backend.domain.leave.dto.response.LeaveFindAllResponse;
import com.bmilab.backend.domain.leave.enums.LeaveStatus;
Expand All @@ -15,6 +16,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand Down Expand Up @@ -57,4 +59,13 @@ public ResponseEntity<LeaveFindAllResponse> getLeaves(
) {
return ResponseEntity.ok(leaveService.getLeavesByAdmin(status, pageable));
}

@PatchMapping("/{leaveId}")
public ResponseEntity<Void> updateLeave(
@PathVariable long leaveId,
@RequestBody @Valid AdminUpdateLeaveRequest request
) {
leaveService.updateLeaveByAdmin(leaveId, request);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.bmilab.backend.domain.leave.dto.request;

import com.bmilab.backend.domain.leave.enums.LeaveType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat;

import java.time.LocalDate;

public record AdminUpdateLeaveRequest(
@Schema(description = "휴가 시작일")
@NotNull(message = "휴가 시작일은 필수입니다.")
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate startDate,

@Schema(description = "휴가 종료일")
@Nullable
@DateTimeFormat(pattern = "yyyy-MM-dd")
LocalDate endDate,

@Schema(description = "휴가 종류", example = "ANNUAL")
@NotNull(message = "휴가 종류는 필수입니다.")
LeaveType type,

@Schema(description = "휴가 사유", example = "가족 행사 참석")
@Nullable
String reason
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ public boolean isAnnualLeave() {
return type == LeaveType.ANNUAL;
}

public void update(LocalDate startDate, LocalDate endDate, LeaveType type, Double leaveCount, String reason) {
this.startDate = startDate;
this.endDate = endDate;
this.type = type;
this.leaveCount = leaveCount;
this.reason = reason;
}

public int countDaysInYearMonth(YearMonth yearMonth) {
if (endDate == null) {
return YearMonth.from(startDate).equals(yearMonth) ? 1 : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,11 @@ public void increaseAnnualLeaveCount(Double leaveIncrement) {
public void updateAnnualLeaveCount(Double annualLeaveCount) {
this.annualLeaveCount = annualLeaveCount;
}

public void restoreLeave(Double leaveCount, boolean isAnnualLeave) {
if (isAnnualLeave) {
annualLeaveCount += leaveCount;
}
usedLeaveCount -= leaveCount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
public enum LeaveErrorCode implements ErrorCode {
LEAVE_NOT_FOUND("휴가 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
LEAVE_ALREADY_DONE("이미 처리된 휴가입니다.", HttpStatus.BAD_REQUEST),
LEAVE_DUPLICATE("해당 날짜에 이미 신청된 휴가가 있습니다.", HttpStatus.CONFLICT),
LEAVE_NOT_APPROVED("승인된 휴가만 수정할 수 있습니다.", HttpStatus.BAD_REQUEST),
LEAVE_COUNT_REQUIRED("연가 수가 부족합니다.", HttpStatus.BAD_REQUEST),
USER_LEAVE_NOT_FOUND("사용자의 휴가 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
HOLIDAY_API_ERROR("공휴일 API 조회 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ public interface LeaveRepository extends JpaRepository<Leave, Long> {
Page<Leave> findAllByStatus(LeaveStatus status, Pageable pageable);

List<Leave> findAllByStatus(LeaveStatus status);

@Query("SELECT COUNT(l) > 0 FROM Leave l " +
"WHERE l.user.id = :userId " +
"AND l.status != 'REJECTED' " +
"AND ( " +
" (l.endDate IS NULL AND l.startDate BETWEEN :startDate AND :endDate) " +
" OR (l.endDate IS NOT NULL AND l.startDate <= :endDate AND l.endDate >= :startDate) " +
")")
boolean existsOverlappingLeave(Long userId, LocalDate startDate, LocalDate endDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ public class LeaveSchedulerService {
public void updateUserAnnualLeaves() {
YearMonth lastMonthWithYear = YearMonth.now().minusMonths(1);

if (lastMonthWithYear.getYear() == 2025) {
return;
}

int endOfMonth = lastMonthWithYear.lengthOfMonth();
int holidayCount = countHolidays(lastMonthWithYear);
int weekendCount = (int) countWeekends(lastMonthWithYear);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.bmilab.backend.domain.leave.service;

import com.bmilab.backend.domain.leave.dto.request.AdminUpdateLeaveRequest;
import com.bmilab.backend.domain.leave.dto.request.ApplyLeaveRequest;
import com.bmilab.backend.domain.leave.dto.request.RejectLeaveRequest;
import com.bmilab.backend.domain.leave.dto.response.LeaveFindAllResponse;
Expand Down Expand Up @@ -75,9 +76,12 @@ public UserLeaveResponse getLeavesByUser(Long userId, Pageable pageable) {
public void applyLeave(Long userId, ApplyLeaveRequest request) {
User user = userService.findUserById(userId);
LeaveType type = request.type();
double leaveCount = (request.endDate() == null) ? 1 : calculateLeaveCount(request.startDate(), request.endDate());
LocalDate startDate = request.startDate();
LocalDate endDate = request.endDate() != null ? request.endDate() : startDate;
double leaveCount = (request.endDate() == null) ? 1 : calculateLeaveCount(startDate, endDate);

validateLeaveTypeAccessPermission(user, type);
validateNoDuplicateLeave(userId, startDate, endDate);

if (type.isHalf()) {
leaveCount *= 0.5;
Expand Down Expand Up @@ -136,6 +140,45 @@ public void rejectLeave(Long processorId, long leaveId, RejectLeaveRequest reque
leave.reject(request.rejectReason(), processor, LocalDateTime.now());
}

@Transactional
public void updateLeaveByAdmin(long leaveId, AdminUpdateLeaveRequest request) {
Leave leave = leaveRepository.findById(leaveId)
.orElseThrow(() -> new ApiException(LeaveErrorCode.LEAVE_NOT_FOUND));

if (!leave.isApproved()) {
throw new ApiException(LeaveErrorCode.LEAVE_NOT_APPROVED);
}

User user = leave.getUser();
UserLeave userLeave = userLeaveRepository.findByUserId(user.getId())
.orElseThrow(() -> new ApiException(LeaveErrorCode.USER_LEAVE_NOT_FOUND));

// 기존 휴가 일수 복원
userLeave.restoreLeave(leave.getLeaveCount(), leave.isAnnualLeave());

// 새로운 휴가 일수 계산
LocalDate startDate = request.startDate();
LocalDate endDate = request.endDate() != null ? request.endDate() : startDate;
LeaveType newType = request.type();
double newLeaveCount = (request.endDate() == null) ? 1 : calculateLeaveCount(startDate, endDate);

if (newType.isHalf()) {
newLeaveCount *= 0.5;
}

// 새로운 휴가 일수 차감
if (newType == LeaveType.ANNUAL && userLeave.getAnnualLeaveCount() < newLeaveCount) {
// 복원한 것을 다시 되돌림
userLeave.useLeave(leave.getLeaveCount(), leave.isAnnualLeave());
throw new ApiException(LeaveErrorCode.LEAVE_COUNT_REQUIRED);
}

userLeave.useLeave(newLeaveCount, newType == LeaveType.ANNUAL);

// 휴가 정보 업데이트
leave.update(startDate, request.endDate(), newType, newLeaveCount, request.reason());
}

public int countDatesByUserIdWithMonth(long userId, YearMonth yearMonth) {
return leaveRepository.findAllByUserId(userId)
.stream()
Expand All @@ -149,4 +192,10 @@ private void validateLeaveTypeAccessPermission(User user, LeaveType type) {
throw new ApiException(LeaveErrorCode.ACCESS_DENIED);
}
}

private void validateNoDuplicateLeave(Long userId, LocalDate startDate, LocalDate endDate) {
if (leaveRepository.existsOverlappingLeave(userId, startDate, endDate)) {
throw new ApiException(LeaveErrorCode.LEAVE_DUPLICATE);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,22 @@ public class GetAllProjectsQueryResult {
private boolean isAccessible;

private boolean isPinned;

public ProjectStatus getEffectiveStatus() {
if (this.status == ProjectStatus.WAITING) {
return ProjectStatus.WAITING;
}

LocalDate today = LocalDate.now();

if (this.endDate != null && today.isAfter(this.endDate)) {
return ProjectStatus.COMPLETED;
}

if (this.startDate != null && !today.isBefore(this.startDate)) {
return ProjectStatus.IN_PROGRESS;
}

return ProjectStatus.PENDING;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public static ProjectDetail from(Project project, List<ProjectParticipant> parti
.category(ProjectCategorySummary.from(project.getCategory()))
.isPrivate(project.isPrivate())
.isAccessible(isAccessible)
.status(project.getStatus())
.status(project.getEffectiveStatus())
.irbId(project.getIrbId())
.drbId(project.getDrbId())
.piList(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public static ProjectSummary from(GetAllProjectsQueryResult queryResult) {
.toList()
)
.participantCount(queryResult.getParticipantCount().intValue())
.status(queryResult.getStatus())
.status(queryResult.getEffectiveStatus())
.isPrivate(queryResult.isPrivate())
.isAccessible(queryResult.isAccessible())
.isPinned(queryResult.isPinned())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public static UserProjectItem from(Project project) {
.builder()
.projectId(project.getId())
.title(project.getTitle())
.status(project.getStatus())
.status(project.getEffectiveStatus())
.category(project.getCategory() == null ? null : ProjectCategorySummary.from(project.getCategory()))
.startDate(project.getStartDate())
.endDate(project.getEndDate())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ public void complete(LocalDate endDate) {
this.status = ProjectStatus.COMPLETED;
}

public void updateStatus(ProjectStatus status) {
this.status = status;
}

public ProjectStatus getEffectiveStatus() {
if (this.status == ProjectStatus.WAITING) {
return ProjectStatus.WAITING;
}

LocalDate today = LocalDate.now();

if (this.endDate != null && today.isAfter(this.endDate)) {
return ProjectStatus.COMPLETED;
}

if (this.startDate != null && !today.isBefore(this.startDate)) {
return ProjectStatus.IN_PROGRESS;
}

return ProjectStatus.PENDING;
}

public List<String> getPIList() {
return List.of(pi.split(","));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import com.bmilab.backend.domain.project.repository.ProjectRepository;
import com.bmilab.backend.domain.report.dto.query.GetAllReportsQueryResult;
import com.bmilab.backend.domain.report.repository.ReportRepository;
import com.bmilab.backend.domain.user.entity.User;
import com.bmilab.backend.domain.user.entity.UserProjectCategory;
import com.bmilab.backend.domain.user.repository.UserProjectCategoryRepository;
import com.bmilab.backend.domain.user.repository.UserRepository;
import com.bmilab.backend.global.email.EmailSender;
import jakarta.mail.MessagingException;
Expand All @@ -25,6 +28,7 @@
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

@Slf4j
@Service
Expand All @@ -42,6 +46,7 @@ public class ReportSchedulerService {
private final ProjectRepository projectRepository;
private final UserRepository userRepository;
private final LeaveRepository leaveRepository;
private final UserProjectCategoryRepository userProjectCategoryRepository;

private static final DateTimeFormatter DATE_WITH_DAY_FORMATTER =
DateTimeFormatter.ofPattern("MM/dd E", Locale.KOREAN);
Expand Down Expand Up @@ -119,14 +124,14 @@ private String buildWeeklyLeavesMessage(List<Leave> leaves) {
StringBuilder sb = new StringBuilder();
for (Leave leave : leaves) {
String name = escMdV2(leave.getUser().getName());
String email = leave.getUser().getEmail();
String categoryDisplay = getCategoryDisplay(leave.getUser());
String leaveType = escMdV2(leave.getType().getDescription());
String period = formatLeavePeriod(leave);

sb.append("\\- ")
.append(name)
.append(" \\(")
.append("`").append(email).append("`")
.append(categoryDisplay)
.append("\\) \\- ")
.append(leaveType)
.append(" \\(")
Expand All @@ -137,6 +142,16 @@ private String buildWeeklyLeavesMessage(List<Leave> leaves) {
return sb.toString().trim();
}

private String getCategoryDisplay(User user) {
List<UserProjectCategory> categories = userProjectCategoryRepository.findAllByUser(user);
if (categories.isEmpty()) {
return "소속 없음";
}
return categories.stream()
.map(upc -> escMdV2(upc.getCategory().getName()))
.collect(Collectors.joining(", "));
}

private String formatLeavePeriod(Leave leave) {
String startDateStr = escMdV2(leave.getStartDate().format(DATE_WITH_DAY_FORMATTER));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public record CreatePaperRequest(
ProfessorRole professorRole,
@Schema(description = "대표 실적 여부", example = "true")
boolean isRepresentative,
@Schema(description = "연계 과제 ID (선택적)", example = "1")
Long taskId,
@Schema(description = "첨부 파일 ID 목록", example = "[\"a1b2c3d4-e5f6-7890-1234-567890abcdef\"]")
List<UUID> fileIds
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ public record UpdatePaperRequest(
ProfessorRole professorRole,
@Schema(description = "대표 실적 여부", example = "false")
boolean isRepresentative,
@Schema(description = "연계 과제 ID (선택적)", example = "1")
Long taskId,
@Schema(description = "첨부 파일 ID 목록", example = "[\"a1b2c3d4-e5f6-7890-1234-567890abcdef\"]")
List<UUID> fileIds
) {
Expand Down
Loading
Loading