Skip to content
40 changes: 37 additions & 3 deletions src/main/java/com/offway/core/leave/controller/LeaveApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;

/** 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다. */
/**
* 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다.
*
* <p>여기 엔드포인트는 전부 인증 게이트 뒤에 있다({@code anyRequest().authenticated()}, #122) — 그래서 어느
* 메서드든 401 이 도달 가능하고, 전수 문서화 대상이다. 한 메서드에만 적으면 나머지가 공개로 읽힌다.
*/
@Tag(name = "연차", description = "연차 기반 가용시간(LNT)·샌드위치 연휴·내 연차")
public interface LeaveApi {

Expand All @@ -22,6 +27,7 @@ public interface LeaveApi {
description = "총 연차·쓴 연차·남은 연차와 사용 내역. 아직 설정한 적이 없으면 총 0·내역 없음으로 답한다(404 아님).")
@ApiResponse(responseCode = "200", description = "조회 성공")
@ApiResponse(responseCode = "400", description = "X-Guest-Id 헤더 누락 · 헤더가 비었거나 64자 초과")
@ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody<MyLeaveResponse> myLeave(
@Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId);

Expand All @@ -32,25 +38,51 @@ ApiResponseBody<MyLeaveResponse> myLeave(
@ApiResponse(
responseCode = "400",
description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · totalDays 누락 · 0.5 단위가 아니거나 0~365 범위 밖")
@ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody<MyLeaveResponse> updateMyLeave(
@Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId,
UpdateMyLeaveRequest request);

@Operation(
summary = "연차 사용 내역 추가",
description = """
연차를 쓰거나(양수) 되돌린(음수) 내역을 남긴다.
연차를 쓴(양수) 또는 되돌린(음수) 내역을 남긴다. days 는 0.5 단위다.

**되돌릴 때는 음수 등록 대신 내역 삭제 API 를 쓴다.** 음수 등록은 같은 요청이 두 번 들어오면
그만큼 더 상쇄돼 장부가 틀어진다 — 취소가 아니라 새 기록이기 때문이다. 지금은 아직 받지만
거절할 예정이다(#276). 새로 붙이는 화면은 삭제 API 를 쓴다.

남은 연차가 부족해도 서버는 막지 않는다 — 프론트가 경고하고 사용자가 확인하면 진행한다(결정 #38).
그래서 남은 연차는 음수가 될 수 있다.""")
@ApiResponse(responseCode = "201", description = "추가 성공")
@ApiResponse(
responseCode = "400",
description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · usedOn·days 누락 또는 형식 오류 · days 가 0 이거나 0.5 단위가 아님")
description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · usedOn·days 누락 또는 형식 오류 · "
+ "days 가 0 이거나 0.5 단위가 아니거나 절댓값이 99 초과(LEAVE-010)")
@ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody<MyLeaveResponse> addLeaveUsage(
@Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId,
AddLeaveUsageRequest request);

@Operation(
summary = "연차 사용 내역 삭제",
description = """
사용 내역 한 건을 지우고 갱신된 내 연차 전체(총·쓴·남은 + 내역 목록)를 돌려준다 —
화면이 한 번의 왕복으로 다시 그린다.

코스 확정으로 기록된 내역(courseId 가 있는 것)은 여기서 지울 수 없다(409). 그 행은 차감량이자
확정 표식이라, 지우면 코스는 확정인데 연차는 안 깎인 상태가 남는다. 코스의 차감 취소로 되돌린다.

없는 내역과 남의 내역을 같은 404 로 답한다 — 번호를 넣어보며 존재 여부를 알아낼 수 없게 한다.""")
@ApiResponse(responseCode = "200", description = "삭제 성공")
@ApiResponse(responseCode = "400", description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · usageId 가 숫자가 아님")
@ApiResponse(responseCode = "401", description = "인증 필요")
@ApiResponse(responseCode = "404", description = "그 내역이 없거나 다른 소유자의 것")
@ApiResponse(responseCode = "409", description = "코스 확정으로 기록된 내역이라 연차 화면에서 지울 수 없음")
ApiResponseBody<MyLeaveResponse> deleteLeaveUsage(
@Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId,
@Parameter(description = "지울 사용 내역 ID", example = "42") long usageId);

@Operation(
summary = "가용 시간(LNT) 산출",
description = """
Expand All @@ -67,12 +99,14 @@ ApiResponseBody<MyLeaveResponse> addLeaveUsage(
description = "날짜 형식 오류 · 날짜와 기간스타일을 함께 보냄 또는 둘 다 없음 · 종료일이 시작일보다 앞섬 · "
+ "여행 구간이 2박 3일 초과 · 기간스타일에 기준일 누락 · WEEKEND 인데 브릿지 요일 누락 · "
+ "CONNECTED 인데 연차 일수 누락 또는 2~3 범위 밖")
@ApiResponse(responseCode = "401", description = "인증 필요")
@ApiResponse(responseCode = "502", description = "공휴일 정보(특일정보) 조회 실패")
ApiResponseBody<AvailableTimeResponse> availableTime(AvailableTimeRequest request);

@Operation(summary = "샌드위치 연휴 추천", description = "조회 기간 안에서 최소 연차로 최대 휴식이 되는 황금 연차를 효율 순으로 추천한다.")
@ApiResponse(responseCode = "200", description = "추천 성공 (없으면 빈 목록)")
@ApiResponse(responseCode = "400", description = "fromDate 누락·형식 오류 · 조회 개월 수가 1~12 범위 밖")
@ApiResponse(responseCode = "401", description = "인증 필요")
@ApiResponse(responseCode = "502", description = "공휴일 정보(특일정보) 조회 실패")
ApiResponseBody<SandwichResponse> sandwich(
@Parameter(description = "조회 시작일", example = "2026-05-01") LocalDate fromDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
import org.springframework.web.bind.annotation.RequestHeader;
Expand Down Expand Up @@ -59,6 +61,13 @@ public ApiResponseBody<MyLeaveResponse> addLeaveUsage(
MyLeaveResponse.from(myLeaveService.addUsage(guestId, request.toCommand())));
}

@Override
@DeleteMapping("/me/usages/{usageId}")
public ApiResponseBody<MyLeaveResponse> deleteLeaveUsage(
@RequestHeader(GUEST_HEADER) String guestId, @PathVariable long usageId) {
return ApiResponseBody.ok(MyLeaveResponse.from(myLeaveService.deleteUsage(guestId, usageId)));
}

@Override
@PostMapping("/available-time")
public ApiResponseBody<AvailableTimeResponse> availableTime(@Valid @RequestBody AvailableTimeRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@
* 연차 사용 내역 추가 요청.
*
* @param usedOn 연차를 쓴(또는 되돌린) 날 (필수)
* @param days 증감 (필수, 0.5 단위). 사용은 양수, <b>취소는 음수</b>
* @param days 증감 (필수, 0.5 단위). 사용은 양수, 취소는 음수 — <b>음수 거절은 #276 으로 미뤘다</b>(앱이 삭제
* API 로 갈아탄 뒤에 닫는다)
* @param reason 사유 (선택)
* @param courseId 이 내역을 만든 코스 (선택 — 수동 입력이면 생략)
*/
public record AddLeaveUsageRequest(
@Schema(description = "연차를 쓴 날", example = "2026-05-08", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull LocalDate usedOn,
@Schema(description = "증감 (사용 양수 · 취소 음수, 0.5 단위)", example = "1.0",
requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "증감 (사용 양수 · 취소 음수, 0.5 단위). 취소는 내역 삭제 API 를 쓰는 것이 낫다",
example = "1.0", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Double days,
@Schema(description = "사유 (선택)", example = "제주 여행") String reason,
@Schema(description = "코스 ID (선택)", example = "12") Long courseId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@
* "내 연차" 응답 — API 계약. 남은 연차는 서버가 계산해 내려준다(클라이언트가 빼지 않게).
*
* @param totalDays 총 연차
* @param usedDays 쓴 연차 (증감 합 — 취소가 있으면 줄어든다)
* @param remainingDays 남은 연차. <b>음수일 수 있다</b> — 초과 사용을 서버가 막지 않기 때문이다(결정 #38)
* @param usedDays 쓴 연차. <b>0 아래로 내려가지 않는다</b> — 상쇄 등록(음수 행)이 섞여 있으면 {@code usages}
* 의 합보다 클 수 있다(#265). 클라이언트는 목록을 더해 검산하지 말고 이 값을 쓴다
* @param remainingDays 남은 연차. <b>총 연차를 넘지 않고</b>(#265), 초과 사용 시 <b>음수일 수 있다</b>(결정 #38)
* @param usages 사용 내역 (최근 순)
*/
public record MyLeaveResponse(
@Schema(description = "총 연차", example = "15.0") double totalDays,
@Schema(description = "쓴 연차(증감 합)", example = "2.0") double usedDays,
@Schema(description = "남은 연차 (초과 사용 시 음수)", example = "13.0") double remainingDays,
@Schema(description = "쓴 연차 (0 이상 — 옛 음수 행이 있으면 목록 합과 다를 수 있다)", example = "2.0")
double usedDays,
@Schema(description = "남은 연차 (총 연차 이하 · 초과 사용 시 음수)", example = "13.0") double remainingDays,
List<Usage> usages) {

public static MyLeaveResponse from(MyLeave myLeave) {
Expand All @@ -29,16 +31,16 @@ public static MyLeaveResponse from(MyLeave myLeave) {
}

/**
* @param id 내역 ID
* @param usedOn 연차를 쓴(또는 되돌린)
* @param days 증감 — 사용은 양수, 취소는 음수
* @param id 내역 ID — 삭제({@code DELETE /me/usages/{id}})의 대상이다
* @param usedOn 연차를 쓴 날
* @param days 증감 — 사용은 양수, <b>상쇄 등록은 음수</b>. 그 음수 행도 삭제 대상이다(#265)
* @param reason 사유 (없으면 null)
* @param courseId 이 내역을 만든 코스 (수동 입력이면 null)
* @param courseId 이 내역을 만든 코스 (수동 입력이면 null). <b>값이 있으면 삭제할 수 없다</b> — 코스에서 차감을 취소한다
*/
public record Usage(
long id,
@Schema(example = "2026-05-08") LocalDate usedOn,
@Schema(description = "증감 (사용 양수 · 취소 음수)", example = "1.0") double days,
@Schema(description = "증감 (사용 양수 · 상쇄 등록 음수)", example = "1.0") double days,
@Schema(example = "제주 여행", nullable = true) String reason,
@Schema(nullable = true) Long courseId) {

Expand Down
15 changes: 11 additions & 4 deletions src/main/java/com/offway/core/leave/domain/LeaveDays.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ public static boolean isValidTotal(double days) {
}

/**
* 사용 내역의 증감으로 쓸 수 있는 값인가.
* 사용 내역의 증감으로 쓸 수 있는 값인가 — <b>0 은 막고 음수는 아직 받는다</b>.
*
* <p>음수를 허용한다 — 코스를 취소하면 쓴 연차를 되돌려야 하고, 그걸 <b>내역을 지워서</b> 하면 "언제 무엇이
* 취소됐는지" 가 사라진다. 다만 <b>0 은 막는다</b>: 아무것도 바꾸지 않는 내역은 기록이 아니라 소음이다.
* <p>0 은 아무것도 바꾸지 않아 기록이 아니라 소음이다.
*
* <p><b>음수를 계속 받는 것은 한시적이다</b>(#276 에서 닫는다). 취소는 이제 {@code DELETE /me/usages/{id}}
* 로 하는 것이 맞고, 상쇄 등록은 취소가 아니라 새 기록이라 같은 취소가 두 번 들어오면 사용 합이 음수로
* 내려간다. 그런데 <b>거절을 지금 켜면 앱이 깨진다</b> — 앱은 삭제 API 가 배포된 뒤에야 갈아탈 수 있어
* 순서가 백엔드 → 프론트로 고정이고, 그 사이 구간에서 취소가 400 을 받아 사용자가 취소를 아예 못 한다.
*
* <p>그동안 사용자가 보는 증상은 막혀 있다 — 사용 합이 음수로 내려가도 {@link LeaveSummary} 가 잘라서
* 잔여가 총 연차를 넘지 않는다(#265). 틀어진 장부는 삭제 API 로 정리한다.
*/
public static boolean isValidUsage(double days) {
return isValidUnit(days) && days != 0 && Math.abs(days) <= MAX_TOTAL;
return isValidUnit(days) && days != NONE && Math.abs(days) <= MAX_TOTAL;
}

/**
Expand Down
19 changes: 18 additions & 1 deletion src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,24 @@ public enum LeaveErrorCode implements ErrorCode {
"LEAVE-010", ErrorCategory.BAD_REQUEST, "연차 증감은 0.5일 단위여야 하고 0일은 기록할 수 없습니다."),

/** 소유 키 헤더가 비었거나 너무 김 — 헤더 자체가 없으면 프레임워크가 먼저 COMMON-400 으로 막는다. */
INVALID_OWNER_ID("LEAVE-011", ErrorCategory.BAD_REQUEST, "사용자 식별값이 올바르지 않습니다.");
INVALID_OWNER_ID("LEAVE-011", ErrorCategory.BAD_REQUEST, "사용자 식별값이 올바르지 않습니다."),

/** 지우려는 사용 내역이 없거나 남의 것 — 둘을 구분해 답하지 않는다(존재 여부를 흘리지 않는다). */
LEAVE_USAGE_NOT_FOUND("LEAVE-012", ErrorCategory.NOT_FOUND, "연차 사용 내역을 찾을 수 없습니다."),

/**
* 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다.
*
* <p><b>아직 아무도 던지지 않는다. 죽은 코드가 아니라 자리를 잡아둔 것이다</b> — #276 이 이 코드를 그대로
* 쓴다. #265 에서 거절까지 함께 넣었다가, 앱이 삭제 API 로 갈아타기 전에 배포되면 그 구간에서 취소가
* 끊긴다는 것이 드러나 거절만 떼어냈다. 번호는 append-only 라(재사용·재배치 금지) 되돌리면서 지우지 않았다.
*/
LEAVE_USAGE_REVERSAL_NOT_ALLOWED(
"LEAVE-013", ErrorCategory.BAD_REQUEST, "연차 사용은 0.5일 단위의 양수여야 합니다. 되돌리려면 해당 내역을 삭제해 주세요."),

/** 코스 확정으로 기록된 내역을 연차 화면에서 지우려 함 — 코스 쪽 차감 취소로만 되돌릴 수 있다. */
COURSE_LEAVE_USAGE_NOT_DELETABLE(
"LEAVE-014", ErrorCategory.CONFLICT, "코스 확정으로 기록된 연차입니다. 코스에서 차감을 취소해 주세요.");

private final String code;
private final ErrorCategory category;
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/offway/core/leave/domain/LeaveException.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,24 @@ public static LeaveException invalidLeaveUsageDays() {
public static LeaveException invalidOwnerId() {
return new LeaveException(LeaveErrorCode.INVALID_OWNER_ID);
}

/** 지우려는 사용 내역이 없거나 남의 것. */
public static LeaveException leaveUsageNotFound() {
return new LeaveException(LeaveErrorCode.LEAVE_USAGE_NOT_FOUND);
}

/**
* 사용 내역을 음수로 등록하려 함 — 취소는 삭제로 한다.
*
* <p><b>호출부는 #276 이 만든다</b>({@link LeaveErrorCode#LEAVE_USAGE_REVERSAL_NOT_ALLOWED} 와 함께 자리만
* 잡아둔 것이다). 거절을 지금 켜면 앱이 삭제 API 로 갈아타기 전 구간에서 취소가 끊긴다.
*/
public static LeaveException leaveUsageReversalNotAllowed() {
return new LeaveException(LeaveErrorCode.LEAVE_USAGE_REVERSAL_NOT_ALLOWED);
}

/** 코스 확정으로 기록된 내역을 연차 화면에서 지우려 함. */
public static LeaveException courseLeaveUsageNotDeletable() {
return new LeaveException(LeaveErrorCode.COURSE_LEAVE_USAGE_NOT_DELETABLE);
}
}
Loading