diff --git a/src/main/java/com/offway/core/leave/controller/LeaveApi.java b/src/main/java/com/offway/core/leave/controller/LeaveApi.java index e0969580..a32fa6df 100644 --- a/src/main/java/com/offway/core/leave/controller/LeaveApi.java +++ b/src/main/java/com/offway/core/leave/controller/LeaveApi.java @@ -13,7 +13,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDate; -/** 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다. */ +/** + * 연차·가용시간 API 문서 계약. 매핑·검증 어노테이션은 구현체({@link LeaveController})가 소유한다. + * + *

여기 엔드포인트는 전부 인증 게이트 뒤에 있다({@code anyRequest().authenticated()}, #122) — 그래서 어느 + * 메서드든 401 이 도달 가능하고, 전수 문서화 대상이다. 한 메서드에만 적으면 나머지가 공개로 읽힌다. + */ @Tag(name = "연차", description = "연차 기반 가용시간(LNT)·샌드위치 연휴·내 연차") public interface LeaveApi { @@ -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 myLeave( @Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId); @@ -32,6 +38,7 @@ ApiResponseBody myLeave( @ApiResponse( responseCode = "400", description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · totalDays 누락 · 0.5 단위가 아니거나 0~365 범위 밖") + @ApiResponse(responseCode = "401", description = "인증 필요") ApiResponseBody updateMyLeave( @Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId, UpdateMyLeaveRequest request); @@ -39,18 +46,43 @@ ApiResponseBody updateMyLeave( @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 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 deleteLeaveUsage( + @Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId, + @Parameter(description = "지울 사용 내역 ID", example = "42") long usageId); + @Operation( summary = "가용 시간(LNT) 산출", description = """ @@ -67,12 +99,14 @@ ApiResponseBody addLeaveUsage( description = "날짜 형식 오류 · 날짜와 기간스타일을 함께 보냄 또는 둘 다 없음 · 종료일이 시작일보다 앞섬 · " + "여행 구간이 2박 3일 초과 · 기간스타일에 기준일 누락 · WEEKEND 인데 브릿지 요일 누락 · " + "CONNECTED 인데 연차 일수 누락 또는 2~3 범위 밖") + @ApiResponse(responseCode = "401", description = "인증 필요") @ApiResponse(responseCode = "502", description = "공휴일 정보(특일정보) 조회 실패") ApiResponseBody 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 sandwich( @Parameter(description = "조회 시작일", example = "2026-05-01") LocalDate fromDate, diff --git a/src/main/java/com/offway/core/leave/controller/LeaveController.java b/src/main/java/com/offway/core/leave/controller/LeaveController.java index 334a4607..63bcd0a2 100644 --- a/src/main/java/com/offway/core/leave/controller/LeaveController.java +++ b/src/main/java/com/offway/core/leave/controller/LeaveController.java @@ -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; @@ -59,6 +61,13 @@ public ApiResponseBody addLeaveUsage( MyLeaveResponse.from(myLeaveService.addUsage(guestId, request.toCommand()))); } + @Override + @DeleteMapping("/me/usages/{usageId}") + public ApiResponseBody deleteLeaveUsage( + @RequestHeader(GUEST_HEADER) String guestId, @PathVariable long usageId) { + return ApiResponseBody.ok(MyLeaveResponse.from(myLeaveService.deleteUsage(guestId, usageId))); + } + @Override @PostMapping("/available-time") public ApiResponseBody availableTime(@Valid @RequestBody AvailableTimeRequest request) { diff --git a/src/main/java/com/offway/core/leave/controller/dto/AddLeaveUsageRequest.java b/src/main/java/com/offway/core/leave/controller/dto/AddLeaveUsageRequest.java index 96620129..d5c449b9 100644 --- a/src/main/java/com/offway/core/leave/controller/dto/AddLeaveUsageRequest.java +++ b/src/main/java/com/offway/core/leave/controller/dto/AddLeaveUsageRequest.java @@ -11,15 +11,16 @@ * 연차 사용 내역 추가 요청. * * @param usedOn 연차를 쓴(또는 되돌린) 날 (필수) - * @param days 증감 (필수, 0.5 단위). 사용은 양수, 취소는 음수 + * @param days 증감 (필수, 0.5 단위). 사용은 양수, 취소는 음수 — 음수 거절은 #276 으로 미뤘다(앱이 삭제 + * 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) { diff --git a/src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java b/src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java index 8cab5f41..3ff832dc 100644 --- a/src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java +++ b/src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java @@ -10,14 +10,16 @@ * "내 연차" 응답 — API 계약. 남은 연차는 서버가 계산해 내려준다(클라이언트가 빼지 않게). * * @param totalDays 총 연차 - * @param usedDays 쓴 연차 (증감 합 — 취소가 있으면 줄어든다) - * @param remainingDays 남은 연차. 음수일 수 있다 — 초과 사용을 서버가 막지 않기 때문이다(결정 #38) + * @param usedDays 쓴 연차. 0 아래로 내려가지 않는다 — 상쇄 등록(음수 행)이 섞여 있으면 {@code usages} + * 의 합보다 클 수 있다(#265). 클라이언트는 목록을 더해 검산하지 말고 이 값을 쓴다 + * @param remainingDays 남은 연차. 총 연차를 넘지 않고(#265), 초과 사용 시 음수일 수 있다(결정 #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 usages) { public static MyLeaveResponse from(MyLeave myLeave) { @@ -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 증감 — 사용은 양수, 상쇄 등록은 음수. 그 음수 행도 삭제 대상이다(#265) * @param reason 사유 (없으면 null) - * @param courseId 이 내역을 만든 코스 (수동 입력이면 null) + * @param courseId 이 내역을 만든 코스 (수동 입력이면 null). 값이 있으면 삭제할 수 없다 — 코스에서 차감을 취소한다 */ 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) { diff --git a/src/main/java/com/offway/core/leave/domain/LeaveDays.java b/src/main/java/com/offway/core/leave/domain/LeaveDays.java index 552f96d6..d9542b2e 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveDays.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveDays.java @@ -52,13 +52,20 @@ public static boolean isValidTotal(double days) { } /** - * 사용 내역의 증감으로 쓸 수 있는 값인가. + * 사용 내역의 증감으로 쓸 수 있는 값인가 — 0 은 막고 음수는 아직 받는다. * - *

음수를 허용한다 — 코스를 취소하면 쓴 연차를 되돌려야 하고, 그걸 내역을 지워서 하면 "언제 무엇이 - * 취소됐는지" 가 사라진다. 다만 0 은 막는다: 아무것도 바꾸지 않는 내역은 기록이 아니라 소음이다. + *

0 은 아무것도 바꾸지 않아 기록이 아니라 소음이다. + * + *

음수를 계속 받는 것은 한시적이다(#276 에서 닫는다). 취소는 이제 {@code DELETE /me/usages/{id}} + * 로 하는 것이 맞고, 상쇄 등록은 취소가 아니라 새 기록이라 같은 취소가 두 번 들어오면 사용 합이 음수로 + * 내려간다. 그런데 거절을 지금 켜면 앱이 깨진다 — 앱은 삭제 API 가 배포된 뒤에야 갈아탈 수 있어 + * 순서가 백엔드 → 프론트로 고정이고, 그 사이 구간에서 취소가 400 을 받아 사용자가 취소를 아예 못 한다. + * + *

그동안 사용자가 보는 증상은 막혀 있다 — 사용 합이 음수로 내려가도 {@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; } /** diff --git a/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java b/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java index 5c3b8a91..dbb8f34d 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java @@ -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, "연차 사용 내역을 찾을 수 없습니다."), + + /** + * 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다. + * + *

아직 아무도 던지지 않는다. 죽은 코드가 아니라 자리를 잡아둔 것이다 — #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; diff --git a/src/main/java/com/offway/core/leave/domain/LeaveException.java b/src/main/java/com/offway/core/leave/domain/LeaveException.java index cd8d93e7..c2aba809 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveException.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveException.java @@ -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); + } + + /** + * 사용 내역을 음수로 등록하려 함 — 취소는 삭제로 한다. + * + *

호출부는 #276 이 만든다({@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); + } } diff --git a/src/main/java/com/offway/core/leave/domain/LeaveSummary.java b/src/main/java/com/offway/core/leave/domain/LeaveSummary.java index 5b28c1e7..2c47eda3 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveSummary.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveSummary.java @@ -6,16 +6,62 @@ *

남은 값을 따로 저장하면 사용 내역과 어긋날 수 있다(차감은 됐는데 남은 값 갱신이 실패하는 식). 내역을 정본으로 두면 * 언제든 다시 계산해 맞출 수 있다. * + *

이 값객체가 "잔여는 총 연차를 넘지 않는다" 를 보장한다(#265). 예전엔 원장 합을 그대로 빼서 + * 내려줬는데, 합이 음수로 내려가면 잔여가 총보다 커졌다 — 총 15일에 2일 쓴 사람이 취소를 두 번 보내면 + * 잔여가 17이 됐고, 그건 재시도 한 번으로 없던 연차가 생겼다는 뜻이다. 사용 합을 만들어내는 경로가 + * 지금은 하나뿐이지만(수동 등록), 여기서 보장하면 어느 경로로 들어오든 잔여가 총을 못 넘는다. + * * @param totalDays 총 연차 - * @param usedDays 사용 내역의 증감 합 (취소가 있으면 음수가 섞여 줄어든다) + * @param usedDays 쓴 연차 — 0 아래로 내려가지 않는다. "쓴 연차가 -2일" 은 뜻이 없다 + * @param ledgerDays 자른 적 없는 원장 합. {@link #usedDays} 와 다르면 데이터가 이미 어긋나 있다는 신호다 */ -public record LeaveSummary(double totalDays, double usedDays) { +public record LeaveSummary(double totalDays, double usedDays, double ledgerDays) { + + /** 아무것도 쓰지 않은 상태. 사용 합의 하한이기도 하다. */ + private static final double NOTHING_USED = 0; + + /** + * 불변식 — {@link #of} 가 이미 자르므로 여기 닿는 위반은 버그다(500). 그래도 두는 이유는 누가 만들든 + * 스스로 유효함을 보장하는 최후의 보루이기 때문이다. + */ + public LeaveSummary { + if (usedDays < NOTHING_USED) { + throw new IllegalArgumentException("쓴 연차는 음수일 수 없습니다: " + usedDays); + } + } + + /** + * 원장 합에서 현황을 계산한다 — 조립이 아니라 계산이라 팩토리다. + * + *

음수 원장 합은 0 으로 본다. 그렇게 만든 데이터가 이미 남아 있을 수 있어(삭제 API 가 없던 시절의 + * 상쇄 등록) 거절하면 조회 자체가 500 이 되는데, 사용자는 화면을 못 여는 것으로 그 사실을 알게 된다. + * 자른 사실은 {@link #isLedgerNegative()} 로 드러내 호출자가 로그를 남긴다 — 조용히 넘어가지 않는다. + * + * @param ledgerDays 사용 내역의 증감 합 + */ + public static LeaveSummary of(double totalDays, double ledgerDays) { + return new LeaveSummary(totalDays, Math.max(NOTHING_USED, ledgerDays), ledgerDays); + } /** - * 남은 연차. 음수가 될 수 있다. 남은 연차가 부족해도 서버는 막지 않기 때문이다(결정 #38) — 프론트가 경고하고 + * 남은 연차. 총 연차를 넘지 않고(#265), 음수는 될 수 있다. + * + *

음수를 허용하는 이유는 남은 연차가 부족해도 서버가 막지 않기 때문이다(결정 #38) — 프론트가 경고하고 * 사용자가 확인하면 진행한다. 여기서 0 으로 깎으면 "얼마나 초과했는지" 를 화면이 알 수 없다. + * + *

위쪽을 막고 아래쪽을 여는 것이 비대칭으로 보이지만, 두 방향은 뜻이 다르다. 초과 사용은 사용자가 + * 확인하고 만든 사실이고, 총을 넘는 잔여는 있을 수 없는 값이다. */ public double remainingDays() { return totalDays - usedDays; } + + /** + * 원장 합이 음수라 잘렸는가 — 상쇄 등록이 남긴 데이터의 흔적이다. + * + *

참이면 그 소유자의 내역에 음수 행이 섞여 있다. 사용자는 이제 그 행을 삭제해 정리할 수 있다. + */ + public boolean isLedgerNegative() { + return ledgerDays < NOTHING_USED; + } } diff --git a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java index f7b51a40..59470f8a 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java @@ -15,8 +15,12 @@ /** * 연차 사용 내역 한 건. 이 내역의 합이 "쓴 연차" 이고, 총 연차에서 빼면 남은 연차가 된다. * - *

증감이다 — 코스를 취소하면 음수 내역을 하나 더 쌓아 되돌린다. 기존 행을 지우면 "언제 무엇이 취소됐는지" 가 - * 사라진다. + *

되돌리는 길은 행 삭제다(#265·#113). 예전엔 음수 내역을 하나 더 쌓아 상쇄했는데, 같은 취소가 두 번 + * 들어오면 합이 음수로 내려가 잔여가 총 연차를 넘었다. 수동 내역은 {@code DELETE /me/usages/{id}} 로, + * 코스 차감은 코스의 차감 취소로 지운다. + * + *

다만 음수 등록을 막는 것은 #276 으로 미뤘다 — 앱이 삭제 API 로 갈아탄 뒤에 닫아야 그 사이 구간에서 + * 취소가 끊기지 않는다. 그때까지는 상쇄 등록이 계속 들어올 수 있고, 잔여가 총을 넘는 증상만 clamp 가 막는다. * *

{@code courseId} 는 raw ID 다(도메인 경계를 넘으므로 연관관계를 두지 않는다 — persistence-convention). * 코스 확정 차감(#91)이 이 값으로 중복 차감을 막는다 — 같은 코스로 이미 쌓인 내역이 있으면 건너뛴다. @@ -37,11 +41,16 @@ public class LeaveUsage { @Column(name = "guest_id", nullable = false, length = LeaveBalance.MAX_OWNER_ID_LENGTH) private String guestId; - /** 연차를 쓴(또는 되돌린) 날. */ + /** 연차를 쓴 날. */ @Column(name = "used_on", nullable = false) private LocalDate usedOn; - /** 증감(0.5 단위). 사용은 양수, 취소는 음수. */ + /** + * 증감(0.5 단위). 사용은 양수, 취소는 음수. 코스 차감만 0 을 허용한다(#212). + * + *

음수 행은 상쇄 등록이다 — 취소를 표현할 길이 그것뿐이던 시절의 것이 쌓여 있고, 앱이 삭제 + * API 로 갈아탈 때까지 계속 들어올 수 있다(#276 에서 닫는다). 사용자는 그 행을 삭제로 정리할 수 있다. + */ @Column(name = "days", nullable = false) private double days; @@ -96,6 +105,27 @@ public boolean isHalfDayStart() { return Boolean.TRUE.equals(halfDayStart); } + /** 사용자가 직접 남긴 내역인가 — 코스 확정으로 생긴 행과 규칙이 다르다. */ + public boolean isManual() { + return courseId == null; + } + + /** + * 사용자가 손으로 지울 수 있는 내역인지 확인한다(#265) — 코스 확정 내역은 거절한다(409). + * + *

그 행은 차감량이자 확정 표식이다. 연차 화면에서 지우면 코스는 확정인데 연차는 안 깎인 상태가 + * 남고, 코스 삭제·날짜 변경이 그 행을 전제로 도는 것도 함께 어긋난다. 되돌리는 길은 이미 있다 — + * 코스의 차감 취소가 코스와 연차를 한 덩어리로 되돌린다(#113). + * + *

404 로 감추지 않는다. 자기 내역이 화면에 보이는데 "없다" 고 답하면 사용자는 버그로 읽는다. + * 지울 수 없는 이유를 알려줘야 코스 화면으로 갈 수 있다. + */ + public void requireManuallyDeletable() { + if (!isManual()) { + throw LeaveException.courseLeaveUsageNotDeletable(); + } + } + /** * 코스의 여행 날짜가 바뀌어 차감을 다시 잡는다(#170) — 쓴 날과 일수를 함께 옮긴다. * diff --git a/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java b/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java index f98adb14..9a1f8964 100644 --- a/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java +++ b/src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java @@ -21,6 +21,9 @@ public interface LeaveUsageJpaRepository extends JpaRepository Optional findByGuestIdAndCourseId(String guestId, Long courseId); + /** 소유자를 조건에 함께 건다 — 남의 내역은 애초에 읽히지 않는다(#265). */ + Optional findByIdAndGuestId(Long id, String guestId); + /** 코스 ID 만 뽑는다 — 목록 화면은 "차감했는가" 만 알면 되므로 내역 전체를 끌어올 이유가 없다. */ @Query("SELECT u.courseId FROM LeaveUsage u WHERE u.guestId = :guestId AND u.courseId IS NOT NULL") Set findDeductedCourseIds(@Param("guestId") String guestId); diff --git a/src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java b/src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java index 98c44424..29e5d33e 100644 --- a/src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java +++ b/src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java @@ -38,11 +38,22 @@ public interface LeaveUsageRepository { * 코스 차감 내역을 지운다(차감 취소). * *

음수 행을 덧붙이지 않는 이유 — {@code uk_leave_usage_guest_course} 가 코스당 한 행을 강제한다(#91). - * 음수 누적은 수동 내역 전용이다. + * 수동 내역도 같은 규칙을 따른다 — 되돌리기는 삭제다(#265). * * @return 지운 행 수 (없었으면 0) */ int deleteByGuestIdAndCourseId(String guestId, Long courseId); + /** + * 내 내역 한 건 — 삭제하려고 읽는다(#265). + * + *

소유자를 조건에 함께 건다. id 로만 읽고 나중에 소유자를 비교하면, 그 비교를 빠뜨린 코드 + * 한 줄이 곧 남의 내역을 지우는 길이 된다. + */ + Optional findByIdAndGuestId(Long id, String guestId); + + /** 수동 내역 한 건을 지운다(#265). 코스 차감 내역인지의 판단은 도메인이 소유한다. */ + void delete(LeaveUsage usage); + LeaveUsage save(LeaveUsage usage); } diff --git a/src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java b/src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java index 732b4130..3af42347 100644 --- a/src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java +++ b/src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java @@ -44,6 +44,16 @@ public int deleteByGuestIdAndCourseId(String guestId, Long courseId) { return jpaRepository.deleteByGuestIdAndCourseId(guestId, courseId); } + @Override + public Optional findByIdAndGuestId(Long id, String guestId) { + return jpaRepository.findByIdAndGuestId(id, guestId); + } + + @Override + public void delete(LeaveUsage usage) { + jpaRepository.delete(usage); + } + @Override public LeaveUsage save(LeaveUsage usage) { return jpaRepository.save(usage); diff --git a/src/main/java/com/offway/core/leave/service/MyLeaveService.java b/src/main/java/com/offway/core/leave/service/MyLeaveService.java index 1173350a..b49dc230 100644 --- a/src/main/java/com/offway/core/leave/service/MyLeaveService.java +++ b/src/main/java/com/offway/core/leave/service/MyLeaveService.java @@ -100,6 +100,31 @@ public MyLeave addUsage(String guestId, AddLeaveUsage command) { return after; } + /** + * 사용 내역 한 건을 지운다(#265) — 화면의 내역 삭제. + * + *

멱등하지 않다. 없는 내역이면 404 로 답한다. 취소 액션(코스 차감 취소)과 달리 사용자가 목록에서 + * 고른 한 행을 가리키므로, 조용히 200 을 주면 화면이 지운 줄 알고 다시 그린다. + * + *

없는 id 와 남의 id 를 같은 404 로 답한다 — 코스 조회와 같은 규칙이다. 나눠 답하면 id 를 넣어보며 + * "이 번호는 있다" 를 알아낼 수 있다. + * + *

코스 확정 내역은 지우지 못한다. 그 판단은 {@link LeaveUsage#requireManuallyDeletable()} 이 소유한다. + */ + @Transactional + public MyLeave deleteUsage(String guestId, long usageId) { + String owner = requireOwner(guestId); + LeaveUsage usage = usageRepository + .findByIdAndGuestId(usageId, owner) + .orElseThrow(LeaveException::leaveUsageNotFound); + usage.requireManuallyDeletable(); + usageRepository.delete(usage); + MyLeave after = myLeave(owner); + log.info("연차 사용내역 삭제 usageId={} days={} 남은={}", + usageId, usage.getDays(), after.summary().remainingDays()); + return after; + } + /** 홈 배지가 쓰는 남은 연차. 설정한 적이 없으면 {@code null} — 0 과 구분해야 화면이 "미설정" 을 보여줄 수 있다. */ @Transactional(readOnly = true) public Double remainingDaysOrNull(String guestId) { @@ -107,8 +132,7 @@ public Double remainingDaysOrNull(String guestId) { return null; } return balanceRepository.findByGuestId(guestId) - .map(balance -> new LeaveSummary(balance.getTotalDays(), usageRepository.sumDaysByGuestId(guestId)) - .remainingDays()) + .map(balance -> summaryOf(guestId, balance.getTotalDays()).remainingDays()) .orElse(null); } @@ -200,7 +224,24 @@ private LeaveSummary summaryOf(String guestId) { double total = balanceRepository.findByGuestId(guestId) .map(LeaveBalance::getTotalDays) .orElse(UNSET_TOTAL_DAYS); - return new LeaveSummary(total, usageRepository.sumDaysByGuestId(guestId)); + return summaryOf(guestId, total); + } + + /** + * 원장 합을 읽어 현황을 만든다 — 자르는 곳이 하나면 알리는 곳도 하나여야 한다. + * + *

총 연차를 이미 손에 든 호출자(홈 배지)도 여기를 지난다. 그쪽이 {@link LeaveSummary#of} 를 직접 부르면 + * 같은 clamp 가 로그 없이 일어나, 화면 하나는 경고를 남기고 다른 하나는 조용히 넘어간다 — 그리고 홈 배지가 + * 더 자주 불린다. + */ + private LeaveSummary summaryOf(String guestId, double totalDays) { + LeaveSummary summary = LeaveSummary.of(totalDays, usageRepository.sumDaysByGuestId(guestId)); + if (summary.isLedgerNegative()) { + // 잘라서 내려주고 끝내면 아무도 모른다 — 이 소유자의 내역에 상쇄 등록(음수 days)이 남아 있다는 + // 뜻이다(#265). 소유 키는 사용자 입력이라 로그에 싣지 않는다. + log.warn("사용 내역 합이 음수라 0 으로 봅니다 ledger={} — 옛 상쇄 등록이 남아 있습니다", summary.ledgerDays()); + } + return summary; } diff --git a/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java b/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java index dcf77980..629b311f 100644 --- a/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java +++ b/src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java @@ -6,7 +6,8 @@ * 연차 사용 내역 추가 커맨드 — 서비스 내부용. * * @param usedOn 연차를 쓴(또는 되돌린) 날 - * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수 + * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수 — 되돌리는 것은 등록이 아니라 삭제가 맞고, 음수 거절은 + * 앱이 갈아탄 뒤로 미뤘다(#276) * @param reason 사유 (선택) * @param courseId 이 내역을 만든 코스 (수동 입력이면 null) * @param halfDayStart 첫날 반차 여부. 코스 차감에서만 뜻이 있고, 날짜를 고칠 때 차감량을 다시 계산하는 입력이라 남긴다(#170) diff --git a/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java b/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java index 85262646..173a6c96 100644 --- a/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java +++ b/src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java @@ -1,6 +1,7 @@ package com.offway.core.itinerary.controller; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -313,4 +314,34 @@ private org.springframework.test.web.servlet.ResultActions deduct(String guest, .andExpect(jsonPath("$.data.usedDays").value(2.0)) .andExpect(jsonPath("$.data.usages.length()").value(1)); } + + /** + * 코스 확정으로 생긴 내역은 연차 화면에서 지울 수 없다(#265) — 409 로 끊고 코스 쪽으로 안내한다. + * + *

지우게 두면 코스는 확정인데 연차는 안 깎인 상태가 남고, 그 행을 전제로 도는 코스 삭제·날짜 변경도 + * 함께 어긋난다. 404 로 감추지도 않는다 — 자기 내역이 화면에 보이는데 "없다" 고 답하면 버그로 읽힌다. + */ + @Test + void 코스_확정_내역은_연차_화면에서_지울_수_없다() throws Exception { + holidays(Set.of()); + String guest = uniqueGuest(); + setTotalLeave(guest, 15.0); + long courseId = saveCourse(guest, TWO_DAY_COURSE); + String deducted = deduct(guest, courseId, "{}") + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + long usageId = ((Number) JsonPath.read(deducted, "$.data.usages[0].id")).longValue(); + + mockMvc.perform(delete(LEAVES + "/usages/{id}", usageId).header("X-Guest-Id", guest)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.code").value("LEAVE-014")) + .andExpect(jsonPath("$.detail").value("코스 확정으로 기록된 연차입니다. 코스에서 차감을 취소해 주세요.")) + .andExpect(jsonPath("$.data").doesNotExist()); + + // 차감은 그대로 남는다. + mockMvc.perform(get(LEAVES).header("X-Guest-Id", guest)) + .andExpect(jsonPath("$.data.usedDays").value(2.0)) + .andExpect(jsonPath("$.data.usages.length()").value(1)); + } } diff --git a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java index 5516b0fc..0793f933 100644 --- a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java +++ b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java @@ -5,12 +5,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.jayway.jsonpath.JsonPath; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -25,6 +27,7 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; /** * "내 연차" 통합 테스트. @@ -44,6 +47,17 @@ class MyLeaveIntegrationTest { @Autowired private MockMvc mockMvc; + private ResultActions addUsage(String guest, String body) throws Exception { + return mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } + + /** 방금 만든 내역의 ID — 삭제 대상이다. 이 소유자에게 내역이 하나뿐인 시나리오에서만 쓴다. */ + private static long onlyUsageId(String responseBody) { + return ((Number) JsonPath.read(responseBody, "$.data.usages[0].id")).longValue(); + } + @Test void 설정한_적_없으면_총0_내역없음으로_내려준다() throws Exception { // 없는 소유자를 404 로 돌려주면 클라이언트가 "처음 쓰는 사람" 을 예외로 다뤄야 한다. @@ -75,28 +89,160 @@ class MyLeaveIntegrationTest { } @Test - void 사용내역을_쌓으면_남은_연차가_줄고_취소하면_되돌아온다() throws Exception { + void 사용내역을_쌓으면_남은_연차가_줄고_지우면_되돌아온다() throws Exception { String guest = "leave-usage"; mockMvc.perform(patch(URL).header(GUEST_HEADER, guest) .contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 10}")) .andExpect(status().isOk()); - mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"usedOn\": \"2026-05-08\", \"days\": 3, \"reason\": \"제주 여행\"}")) + String created = addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 3, \"reason\": \"제주 여행\"}") .andExpect(status().isCreated()) .andExpect(jsonPath("$.status").value(201)) .andExpect(jsonPath("$.data.usedDays").value(3.0)) - .andExpect(jsonPath("$.data.remainingDays").value(7.0)); + .andExpect(jsonPath("$.data.remainingDays").value(7.0)) + .andReturn() + .getResponse() + .getContentAsString(); + long usageId = onlyUsageId(created); + + // 취소는 음수 상쇄가 아니라 그 행을 지우는 것이다(#265) — 응답은 갱신된 내 연차 전체다. + mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.totalDays").value(10.0)) + .andExpect(jsonPath("$.data.usedDays").value(0.0)) + .andExpect(jsonPath("$.data.remainingDays").value(10.0)) + .andExpect(jsonPath("$.data.usages.length()").value(0)); + } - // 취소는 행을 지우지 않고 음수 내역을 하나 더 쌓는다 — 언제 무엇이 취소됐는지가 남는다. - mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest) + /** + * 이 PR 의 존재 이유 — 같은 취소가 두 번 들어와도 잔여가 총 연차를 넘지 않는다. + * + *

예전에는 취소를 음수 등록으로 흉내냈고, 재시도·중복 탭으로 두 번 들어오면 총 15일인 사람의 잔여가 + * 17 이 됐다. 지금은 두 번째 삭제가 404 로 끊기고 잔여는 총 그대로다. + */ + @Test + void 취소를_두_번_보내도_잔여가_총_연차를_넘지_않는다() throws Exception { + String guest = "leave-double-cancel"; + mockMvc.perform(patch(URL).header(GUEST_HEADER, guest) + .contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 15}")) + .andExpect(status().isOk()); + String created = addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 2}") + .andExpect(jsonPath("$.data.remainingDays").value(13.0)) + .andReturn() + .getResponse() + .getContentAsString(); + long usageId = onlyUsageId(created); + + mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.remainingDays").value(15.0)); + + mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, guest)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("LEAVE-012")); + + mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.usedDays").value(0.0)) + .andExpect(jsonPath("$.data.remainingDays").value(15.0)); + } + + @Test + void 음수_등록은_아직_받는다() throws Exception { + // 거절(400 LEAVE-013)은 #276 으로 미뤘다 — 앱이 삭제 API 로 갈아타기 전에 켜면 그 구간 동안 + // 취소가 400 을 받아 사용자가 취소를 아예 못 한다. 지금 앱이 쓰는 경로라 열려 있어야 한다. + mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, "leave-reversal") .contentType(MediaType.APPLICATION_JSON) .content("{\"usedOn\": \"2026-05-09\", \"days\": -1, \"reason\": \"하루 취소\"}")) .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.usages[0].days").value(-1.0)); + } + + @Test + void 없는_내역을_지우면_404_LEAVE_012() throws Exception { + mockMvc.perform(delete(USAGES_URL + "/{id}", 987654321L).header(GUEST_HEADER, "leave-missing")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.code").value("LEAVE-012")) + .andExpect(jsonPath("$.detail").value("연차 사용 내역을 찾을 수 없습니다.")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void 남의_내역은_지울_수_없고_없는_것과_같은_404다() throws Exception { + // 403 으로 나눠 답하면 id 를 넣어보며 "이 번호는 있다" 를 알아낼 수 있다 — 코스 조회와 같은 규칙이다. + String created = addUsage("leave-owner-x", "{\"usedOn\": \"2026-05-08\", \"days\": 1}") + .andExpect(status().isCreated()) + .andReturn() + .getResponse() + .getContentAsString(); + long usageId = onlyUsageId(created); + + mockMvc.perform(delete(USAGES_URL + "/{id}", usageId).header(GUEST_HEADER, "leave-owner-y")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("LEAVE-012")); + + // 주인의 내역은 그대로 남아 있다. + mockMvc.perform(get(URL).header(GUEST_HEADER, "leave-owner-x")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.usages.length()").value(1)); + } + + @Test + void 삭제도_빈_소유_키는_400_LEAVE_011() throws Exception { + mockMvc.perform(delete(USAGES_URL + "/{id}", 1L).header(GUEST_HEADER, " ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("LEAVE-011")); + } + + /** + * #265 의 재현 시나리오 그 자체 — 지금 앱이 하는 그대로(상쇄 등록) 취소를 두 번 보낸 장부다. + * + *

넷을 한 번에 잠근다. ① 상쇄 등록은 아직 통과한다(#276 전이라 앱 경로가 열려 있어야 한다) + * ② 원장 합이 -2 로 내려가도 잔여가 17 이 아니라 15 — clamp 가 단위 테스트가 아니라 실응답에서 돈다 + * ③ 그래도 음수 행은 목록에 그대로 보인다 — 마이그레이션으로 지우지 않기로 한 결정의 실제 모습이다 + * ④ 사용자가 그 행을 지울 수 있고, 지우면 clamp 가 가리고 있던 값과 장부가 같아진다. + * + *

#276 이 음수 등록을 닫으면 이 시나리오의 준비 단계는 API 로 만들 수 없다 — 그때 남아 있는 옛 행을 + * 재현하려면 도메인을 우회해 직접 적재해야 한다(하이드레이션은 생성자를 거치지 않는다). + */ + @Test + void 상쇄_등록이_쌓여도_잔여가_총을_넘지_않고_지워서_정리할_수_있다() throws Exception { + String guest = "leave-reversal-ledger"; + mockMvc.perform(patch(URL).header(GUEST_HEADER, guest) + .contentType(MediaType.APPLICATION_JSON).content("{\"totalDays\": 15}")) + .andExpect(status().isOk()); + addUsage(guest, "{\"usedOn\": \"2026-05-08\", \"days\": 2}").andExpect(status().isCreated()); + // 같은 취소가 두 번 — 재시도·중복 탭이면 실제로 이렇게 들어온다. 원장 합이 -2 가 된다. + addUsage(guest, "{\"usedOn\": \"2026-05-09\", \"days\": -2}").andExpect(status().isCreated()); + addUsage(guest, "{\"usedOn\": \"2026-05-09\", \"days\": -2}").andExpect(status().isCreated()); + + String found = mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.usedDays").value(0.0)) + .andExpect(jsonPath("$.data.remainingDays").value(15.0)) // 17 이 아니다 + .andExpect(jsonPath("$.data.usages.length()").value(3)) + .andReturn() + .getResponse() + .getContentAsString(); + List negativeIds = JsonPath.read(found, "$.data.usages[?(@.days < 0)].id"); + assertEquals(2, negativeIds.size(), "음수 행은 감춰지지 않고 목록에 그대로 나간다"); + + for (Integer id : negativeIds) { + mockMvc.perform(delete(USAGES_URL + "/{id}", id.longValue()).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()); + } + + // 정리하고 나면 clamp 가 가리고 있던 값과 실제 장부가 같아진다. + mockMvc.perform(get(URL).header(GUEST_HEADER, guest)) + .andExpect(status().isOk()) .andExpect(jsonPath("$.data.usedDays").value(2.0)) - .andExpect(jsonPath("$.data.remainingDays").value(8.0)) - .andExpect(jsonPath("$.data.usages.length()").value(2)); + .andExpect(jsonPath("$.data.remainingDays").value(13.0)) + .andExpect(jsonPath("$.data.usages.length()").value(1)); } @Test diff --git a/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java b/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java index ea5a3c42..b04c8565 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java @@ -29,7 +29,8 @@ class LeaveDaysTest { @ParameterizedTest @ValueSource(doubles = {0.5, 1, 3, 99, -0.5, -3, -99}) void 사용_증감은_음수도_허용하고_상한은_총_연차와_같다(double days) { - // 코스를 취소하면 음수 내역으로 되돌린다 — 행을 지우면 취소 이력이 사라진다. + // 음수(상쇄 등록)를 아직 받는다 — 거절은 앱이 삭제 API 로 갈아탄 뒤로 미뤘다(#276). + // 먼저 켜면 앱의 취소가 400 을 받아 그 구간 동안 사용자가 취소를 못 한다. // ±99 를 함께 둔 이유: 사용 증감도 MAX_TOTAL 을 쓰는데, 경계가 없으면 상한이 예전 365 로 // 남아 있어도 이 테스트가 통과한다(#142 가 99 로 좁힌 계약을 못 지킨다). assertTrue(LeaveDays.isValidUsage(days)); diff --git a/src/test/java/com/offway/core/leave/domain/LeaveSummaryTest.java b/src/test/java/com/offway/core/leave/domain/LeaveSummaryTest.java index bb04a80d..b2c3ddff 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveSummaryTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveSummaryTest.java @@ -1,31 +1,91 @@ package com.offway.core.leave.domain; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; -/** 남은 연차 파생 단위 테스트. */ +/** + * 남은 연차 파생 단위 테스트. + * + *

핵심은 잔여가 총 연차를 넘지 않는다는 것이다(#265). 위쪽만 막고 아래쪽(초과 사용)은 열어두므로 + * 두 방향의 경계를 함께 본다. + */ class LeaveSummaryTest { @Test void 남은_연차는_총_연차에서_사용_합을_뺀_값이다() { - assertEquals(13.0, new LeaveSummary(15.0, 2.0).remainingDays()); + assertEquals(13.0, LeaveSummary.of(15.0, 2.0).remainingDays()); } @Test - void 취소가_섞이면_사용_합이_줄어_남은_연차가_늘어난다() { - // 3일 쓰고 1일 취소(-1) → 사용 합 2 - assertEquals(13.0, new LeaveSummary(15.0, 3.0 - 1.0).remainingDays()); + void 반차가_섞여도_0점5_단위로_떨어진다() { + assertEquals(12.5, LeaveSummary.of(15.0, 2.5).remainingDays()); } @Test void 초과_사용하면_남은_연차가_음수다() { // 서버가 초과를 막지 않으므로(결정 #38) 0 으로 깎지 않는다 — 얼마나 초과했는지를 화면이 알아야 한다. - assertEquals(-2.0, new LeaveSummary(3.0, 5.0).remainingDays()); + assertEquals(-2.0, LeaveSummary.of(3.0, 5.0).remainingDays()); + } + + @ParameterizedTest + @CsvSource({ + "15, 0, 15", // 아무것도 안 씀 — 잔여 = 총 + "15, 15, 0", // 딱 맞게 씀 — 잔여 0 + "15, 15.5, -0.5", // 반차만큼 초과 + "0, 0, 0" // 총 연차 미설정 + }) + void 사용_합이_0_이상이면_그대로_빼서_잔여를_낸다(double total, double ledger, double expected) { + assertEquals(expected, LeaveSummary.of(total, ledger).remainingDays()); + } + + /** + * 이 PR 의 존재 이유 — 상쇄 등록이 겹쳐 원장 합이 음수로 내려가도 잔여가 총을 넘지 않는다. + * + *

총 15일에 2일 쓴 사람이 취소를 두 번 보내면 원장 합이 -2 가 됐고, 예전에는 잔여 17 이 나갔다. + * 재시도·중복 탭만으로 없던 연차가 생긴 것이다. + */ + @ParameterizedTest + @ValueSource(doubles = {-0.5, -1, -2, -99}) + void 사용_합이_음수여도_잔여가_총_연차를_넘지_않는다(double ledger) { + LeaveSummary summary = LeaveSummary.of(15.0, ledger); + + assertEquals(15.0, summary.remainingDays(), "잔여는 총 연차가 상한이다"); + assertEquals(0.0, summary.usedDays(), "쓴 연차가 음수라는 말은 뜻이 없다"); } @Test - void 반차가_섞여도_0점5_단위로_떨어진다() { - assertEquals(12.5, new LeaveSummary(15.0, 2.5).remainingDays()); + void 취소_두_번이_잔여_17을_만들지_않는다() { + // 프론트가 재현한 시나리오 그대로 — 2일 사용(+2) 뒤 같은 취소가 두 번(-2, -2) 들어온 원장. + double ledger = 2.0 - 2.0 - 2.0; + + assertEquals(15.0, LeaveSummary.of(15.0, ledger).remainingDays()); + } + + @Test + void 사용_합이_음수면_잘렸다는_신호를_남긴다() { + // 조용히 자르고 끝내면 상쇄 등록이 남긴 데이터를 아무도 모른다 — 호출자가 이 값으로 warn 을 남긴다. + LeaveSummary clamped = LeaveSummary.of(15.0, -2.0); + + assertTrue(clamped.isLedgerNegative()); + assertEquals(-2.0, clamped.ledgerDays(), "원장 합 원본은 그대로 들고 있다"); + } + + @ParameterizedTest + @ValueSource(doubles = {0, 0.5, 2}) + void 사용_합이_0_이상이면_자르지_않는다(double ledger) { + assertFalse(LeaveSummary.of(15.0, ledger).isLedgerNegative()); + } + + @Test + void 쓴_연차가_음수인_현황은_만들_수_없다() { + // 팩토리가 이미 자르므로 여기 닿는 값은 버그다 — 계약 예외가 아니라 불변식이다. + assertThrows(IllegalArgumentException.class, () -> new LeaveSummary(15.0, -1.0, -1.0)); } } diff --git a/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java b/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java index 8ab556b9..91a18b2b 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java @@ -1,5 +1,6 @@ package com.offway.core.leave.domain; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -7,6 +8,8 @@ import java.time.LocalDate; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * 연차 사용 내역 한 건. 코스 날짜가 바뀌면 이 행이 따라 옮겨진다(#170). @@ -105,4 +108,34 @@ class LeaveUsageTest { // 코스 차감의 취소는 음수 누적이 아니라 행 삭제다(#113). assertThrows(LeaveException.class, () -> LeaveUsage.forCourse("guest-1", WHEN, -1.0, "코스 확정", 7L, false)); } + + @ParameterizedTest + @ValueSource(doubles = {-0.5, -1, -2, -99}) + void 수동_내역은_아직_음수를_받는다(double days) { + // 되돌리기는 삭제가 맞지만, 거절을 지금 켜면 앱이 갈아타기 전 구간에서 취소가 끊긴다 — + // #276 으로 미뤘다. 미뤄도 잔여가 총을 넘는 증상은 LeaveSummary 의 clamp 가 막는다. + LeaveUsage usage = LeaveUsage.manual("guest-1", WHEN, days, "취소"); + + assertEquals(days, usage.getDays()); + assertTrue(usage.isManual(), "상쇄 등록도 수동 내역이라 사용자가 삭제로 정리할 수 있다"); + } + + @Test + void 수동_내역은_손으로_지울_수_있다() { + LeaveUsage usage = LeaveUsage.manual("guest-1", WHEN, 1.0, "개인 사유"); + + assertTrue(usage.isManual()); + assertDoesNotThrow(usage::requireManuallyDeletable); + } + + @Test + void 코스_확정_내역은_연차_화면에서_지울_수_없다() { + // 그 행은 차감량이자 확정 표식이다 — 지우면 코스는 확정인데 연차는 안 깎인 상태가 남는다. + LeaveUsage usage = LeaveUsage.forCourse("guest-1", WHEN, 2.0, "코스 확정", 7L, false); + + LeaveException thrown = assertThrows(LeaveException.class, usage::requireManuallyDeletable); + + assertEquals(LeaveErrorCode.COURSE_LEAVE_USAGE_NOT_DELETABLE, thrown.errorCode()); + assertFalse(usage.isManual()); + } }