From 7da401aaf8ad4b91f852bbb0c04bcf47a49f7ab9 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:50:37 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=97=B0=EC=B0=A8=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=82=B4=EC=97=AD=20=EC=82=AD=EC=A0=9C=20API=20?= =?UTF-8?q?=C2=B7=20=EC=9E=94=EC=97=AC=EA=B0=80=20=EC=B4=9D=20=EC=97=B0?= =?UTF-8?q?=EC=B0=A8=EB=A5=BC=20=EB=84=98=EC=A7=80=20=EB=AA=BB=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/v1/leaves/me/usages/{usageId} 를 열고, 응답으로 갱신된 내 연차 전체를 내려준다. 화면이 삭제 후 목록·잔여를 한 번의 왕복으로 다시 그린다 - 삭제가 없어 프론트가 음수 days 를 등록해 상쇄하고 있었는데, 같은 취소가 두 번 들어오면 사용 합이 음수로 내려가 잔여가 총 연차를 넘었다(15일 중 2일 쓰고 취소 2회 → 잔여 17) - 그 불변식을 LeaveSummary 가 갖게 했다. 서비스에 if 를 두면 그 서비스를 안 거치는 경로가 생기는 순간 다시 새므로, 사용 합을 0 아래로 못 내려가게 하는 팩토리로 값객체가 보장한다. 자른 사실은 isLedgerNegative 로 드러내 호출자가 warn 을 남긴다(조용한 실패 금지) - 음수 등록 자체를 막는다(LEAVE-013). 삭제가 생기면 상쇄 등록은 필요 없고, 그 등록은 재시도·중복 탭에 상한이 없어 장부가 그만큼 틀어진다. 0.5 단위 위반(LEAVE-010)과 코드를 가른 이유는 화면이 "삭제로 취소하세요" 를 안내해야 하기 때문 - 이미 쌓인 음수 행은 마이그레이션으로 건드리지 않는다. 남의 장부를 서버가 지우는 셈이고, 이제 사용자가 그 행을 직접 지울 수 있다. 그때까지는 위 clamp 가 화면을 정상으로 지킨다 - 코스 확정 내역(courseId 있음)은 삭제를 409(LEAVE-014)로 막는다. 그 행은 차감량이자 확정 표식이라 지우면 "코스는 확정인데 연차는 안 깎인" 상태가 남고, 코스 삭제·날짜 변경도 그 행을 전제로 돈다. 404 로 감추지 않는 이유는 자기 내역이 화면에 보이기 때문 — 이유를 알려줘야 코스 화면으로 갈 수 있다 - 없는 내역과 남의 내역은 같은 404(LEAVE-012). 나눠 답하면 번호를 넣어보며 존재 여부를 알아낼 수 있어 코스 조회와 같은 규칙을 따른다. 조회 자체도 소유자를 쿼리 조건에 함께 건다 - 수정(PATCH)은 이번에 넣지 않는다. 삭제 + 재등록으로 같은 결과를 낼 수 있고 프론트도 급하지 않다고 했다. days 수정은 코스 차감량과 어긋날 수 있어 계약을 따로 정해야 한다 (#267) --- .../core/leave/controller/LeaveApi.java | 27 +++++++++- .../leave/controller/LeaveController.java | 9 ++++ .../controller/dto/AddLeaveUsageRequest.java | 15 ++++-- .../leave/controller/dto/MyLeaveResponse.java | 18 +++---- .../offway/core/leave/domain/LeaveDays.java | 25 +++++++-- .../core/leave/domain/LeaveErrorCode.java | 13 ++++- .../core/leave/domain/LeaveException.java | 15 ++++++ .../core/leave/domain/LeaveSummary.java | 52 +++++++++++++++++-- .../offway/core/leave/domain/LeaveUsage.java | 31 ++++++++++- .../repository/LeaveUsageJpaRepository.java | 3 ++ .../repository/LeaveUsageRepository.java | 13 ++++- .../repository/LeaveUsageRepositoryImpl.java | 10 ++++ .../core/leave/service/MyLeaveService.java | 35 ++++++++++++- .../core/leave/service/dto/AddLeaveUsage.java | 4 +- 14 files changed, 240 insertions(+), 30 deletions(-) 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..666dd1cf 100644 --- a/src/main/java/com/offway/core/leave/controller/LeaveApi.java +++ b/src/main/java/com/offway/core/leave/controller/LeaveApi.java @@ -39,18 +39,41 @@ ApiResponseBody updateMyLeave( @Operation( summary = "연차 사용 내역 추가", description = """ - 연차를 쓰거나(양수) 되돌린(음수) 내역을 남긴다. + 연차를 쓴 내역을 남긴다. days 는 0.5 단위 양수다. + + 되돌릴 때는 음수를 등록하지 않는다 — 내역 삭제 API 를 쓴다. 음수 등록은 같은 요청이 두 번 + 들어오면 그만큼 더 상쇄돼 남은 연차가 총 연차를 넘었다(LEAVE-013 으로 거절한다). 남은 연차가 부족해도 서버는 막지 않는다 — 프론트가 경고하고 사용자가 확인하면 진행한다(결정 #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) · days 가 음수(LEAVE-013)") 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 = """ 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..3aaf6bda 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 @@ -10,22 +10,29 @@ /** * 연차 사용 내역 추가 요청. * - * @param usedOn 연차를 쓴(또는 되돌린) 날 (필수) - * @param days 증감 (필수, 0.5 단위). 사용은 양수, 취소는 음수 + * @param usedOn 연차를 쓴 날 (필수) + * @param days 쓴 일수 (필수, 0.5 단위 양수). 되돌리려면 등록이 아니라 삭제다(#265) * @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", + @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) { - /** 값 계약을 검증하고 커맨드로 바꾼다. */ + /** + * 값 계약을 검증하고 커맨드로 바꾼다. + * + *

음수를 먼저 가른다 — 사유가 다르면 코드도 달라야 화면이 "삭제로 취소하세요" 를 안내할 수 있다(#265). + */ public AddLeaveUsage toCommand() { + if (LeaveDays.isReversal(days)) { + throw LeaveException.leaveUsageReversalNotAllowed(); + } if (!LeaveDays.isValidUsage(days)) { throw LeaveException.invalidLeaveUsageDays(); } 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..fda8710e 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,14 @@ * "내 연차" 응답 — API 계약. 남은 연차는 서버가 계산해 내려준다(클라이언트가 빼지 않게). * * @param totalDays 총 연차 - * @param usedDays 쓴 연차 (증감 합 — 취소가 있으면 줄어든다) - * @param remainingDays 남은 연차. 음수일 수 있다 — 초과 사용을 서버가 막지 않기 때문이다(결정 #38) + * @param usedDays 쓴 연차 (내역 합). 0 아래로 내려가지 않는다 + * @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 +29,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 쓴 일수. 새 내역은 양수지만, 삭제 API 가 없던 시절의 상쇄 등록은 음수로 남아 있다(#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..29e32698 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,30 @@ public static boolean isValidTotal(double days) { } /** - * 사용 내역의 증감으로 쓸 수 있는 값인가. + * 사용 내역으로 쓸 수 있는 값인가 — 양수만. 0 도 음수도 받지 않는다. * - *

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

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

음수는 예전엔 받았다(#265 에서 닫았다). 삭제 API 가 없던 시절 화면이 취소를 표현할 방법이 + * 음수 등록뿐이었기 때문이다. 그런데 그 등록은 아무 상한이 없어, 같은 취소를 두 번 보내면 사용 합이 + * 음수로 내려가고 잔여가 총 연차를 넘었다 — 재시도 한 번에 없던 연차가 생긴 것이다. 취소는 + * 이제 {@code DELETE /me/usages/{id}} 로 한다. 상쇄 등록은 취소가 아니라 새 기록이라, 실수로 두 번 + * 보내면 장부가 그만큼 틀어진다. + * + * @see #isReversal(double) */ public static boolean isValidUsage(double days) { - return isValidUnit(days) && days != 0 && Math.abs(days) <= MAX_TOTAL; + return isValidUnit(days) && days > NONE && days <= MAX_TOTAL; + } + + /** + * 되돌리려고 넣는 음수 등록인가 — 받지 않는 값이지만 0.5 단위 위반과는 사유가 다르다(#265). + * + *

사유를 갈라야 화면이 "삭제로 취소하세요" 를 안내할 수 있다. 같은 400 으로 뭉뚱그리면 사용자는 + * 자기가 숫자를 잘못 넣은 줄 안다. + */ + public static boolean isReversal(double days) { + return days < NONE; } /** 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..2a73f315 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,18 @@ 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, "연차 사용 내역을 찾을 수 없습니다."), + + /** 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다(#265). */ + 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..a6b29e21 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,19 @@ 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); + } + + /** 사용 내역을 음수로 등록하려 함 — 취소는 삭제로 한다. */ + 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..1466bfbb 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,9 @@ /** * 연차 사용 내역 한 건. 이 내역의 합이 "쓴 연차" 이고, 총 연차에서 빼면 남은 연차가 된다. * - *

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

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

{@code courseId} 는 raw ID 다(도메인 경계를 넘으므로 연관관계를 두지 않는다 — persistence-convention). * 코스 확정 차감(#91)이 이 값으로 중복 차감을 막는다 — 같은 코스로 이미 쌓인 내역이 있으면 건너뛴다. @@ -96,6 +97,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) — 쓴 날과 일수를 함께 옮긴다. * @@ -122,6 +144,11 @@ public void moveTo(LocalDate usedOn, double days) { * 직접 부르면 DTO 를 거치지 않고 들어온다. 그때 불변식 예외를 던지면 클라이언트 계약 위반이 500 으로 나간다. */ private static double requireDays(double days, Long courseId) { + // 음수 수동 등록은 사유를 갈라 답한다(#265) — "0.5 단위가 아님" 과 뭉뚱그리면 화면이 "삭제로 + // 취소하세요" 를 안내할 수 없다. 코스 차감은 애초에 음수가 들어올 길이 없어 갈라도 소득이 없다. + if (courseId == null && LeaveDays.isReversal(days)) { + throw LeaveException.leaveUsageReversalNotAllowed(); + } // 코스 차감은 0 을 허용한다(#212). 주말·공휴일뿐인 구간이면 깎을 연차가 없는데, 그것도 확정이다 — // 그 행이 차감량이자 확정 표식이기 때문이다. 수동 내역은 순수 증감 장부라 0 이 그대로 소음이다. boolean valid = courseId == null 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..e47c7ebf 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,7 +132,7 @@ public Double remainingDaysOrNull(String guestId) { return null; } return balanceRepository.findByGuestId(guestId) - .map(balance -> new LeaveSummary(balance.getTotalDays(), usageRepository.sumDaysByGuestId(guestId)) + .map(balance -> LeaveSummary.of(balance.getTotalDays(), usageRepository.sumDaysByGuestId(guestId)) .remainingDays()) .orElse(null); } @@ -200,7 +225,13 @@ private LeaveSummary summaryOf(String guestId) { double total = balanceRepository.findByGuestId(guestId) .map(LeaveBalance::getTotalDays) .orElse(UNSET_TOTAL_DAYS); - return new LeaveSummary(total, usageRepository.sumDaysByGuestId(guestId)); + LeaveSummary summary = LeaveSummary.of(total, 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..8c8985b5 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 @@ -5,8 +5,8 @@ /** * 연차 사용 내역 추가 커맨드 — 서비스 내부용. * - * @param usedOn 연차를 쓴(또는 되돌린) 날 - * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수 + * @param usedOn 연차를 쓴 날 + * @param days 쓴 일수(0.5 단위 양수). 되돌리는 것은 등록이 아니라 삭제다(#265) * @param reason 사유 (선택) * @param courseId 이 내역을 만든 코스 (수동 입력이면 null) * @param halfDayStart 첫날 반차 여부. 코스 차감에서만 뜻이 있고, 날짜를 고칠 때 차감량을 다시 계산하는 입력이라 남긴다(#170) From 38fa59fdef33f538bf8894d6da01885c6fd5b549 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 01:50:52 +0900 Subject: [PATCH 2/6] =?UTF-8?q?test:=20=EB=82=B4=EC=97=AD=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=EC=9E=94=EC=97=AC=20=EC=83=81=ED=95=9C=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "취소 2회 → 잔여 17" 을 두 층에서 잠근다. 단위로는 원장 합이 음수여도 잔여가 총을 넘지 않는 것, 통합으로는 같은 내역을 두 번 지우면 두 번째가 404 이고 잔여가 총 그대로인 것 - LeaveSummary 는 경계값을 표로 망라했다 — 잔여 = 총(사용 0), 잔여 0(딱 맞게 씀), 초과 사용(음수 유지), 음수 원장(-0.5 ~ -99). 위쪽만 막고 아래쪽은 여는 비대칭이 핵심이라 두 방향을 함께 둔다 - 음수 등록 400 은 code 뿐 아니라 detail 까지 단언한다. 이 문구가 "삭제로 취소하세요" 를 사용자에게 전하는 유일한 통로라, 바뀌면 화면 안내가 조용히 사라진다 - 코스 확정 내역 삭제 409 는 코스 통합 테스트에 둔다. 차감 내역을 실제로 만들어야 하는데 그 준비가 이미 그쪽에 있고, 거절 뒤에도 차감이 남아 있는지까지 확인한다 - 기존 "취소하면 되돌아온다" 테스트는 음수 등록으로 검증하고 있어 삭제 기반으로 옮겼다 --- .../CourseLeaveDeductionIntegrationTest.java | 31 +++++ .../controller/MyLeaveIntegrationTest.java | 120 ++++++++++++++++-- .../core/leave/domain/LeaveDaysTest.java | 26 +++- .../core/leave/domain/LeaveSummaryTest.java | 76 +++++++++-- .../core/leave/domain/LeaveUsageTest.java | 33 +++++ 5 files changed, 262 insertions(+), 24 deletions(-) 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..73f9a9c1 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,114 @@ 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 음수_등록은_400_LEAVE_013_으로_거절하고_삭제를_안내한다() throws Exception { + // 상쇄 등록이 잔여를 총보다 크게 만들던 자리다. 0.5 단위 위반(LEAVE-010)과 코드를 가른다. + mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, "leave-reversal") .contentType(MediaType.APPLICATION_JSON) .content("{\"usedOn\": \"2026-05-09\", \"days\": -1, \"reason\": \"하루 취소\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.code").value("LEAVE-013")) + .andExpect(jsonPath("$.detail").value("연차 사용은 0.5일 단위의 양수여야 합니다. 되돌리려면 해당 내역을 삭제해 주세요.")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @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()) - .andExpect(jsonPath("$.data.usedDays").value(2.0)) - .andExpect(jsonPath("$.data.remainingDays").value(8.0)) - .andExpect(jsonPath("$.data.usages.length()").value(2)); + .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")); } @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..52c9c2cf 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java @@ -27,22 +27,36 @@ class LeaveDaysTest { } @ParameterizedTest - @ValueSource(doubles = {0.5, 1, 3, 99, -0.5, -3, -99}) - void 사용_증감은_음수도_허용하고_상한은_총_연차와_같다(double days) { - // 코스를 취소하면 음수 내역으로 되돌린다 — 행을 지우면 취소 이력이 사라진다. - // ±99 를 함께 둔 이유: 사용 증감도 MAX_TOTAL 을 쓰는데, 경계가 없으면 상한이 예전 365 로 + @ValueSource(doubles = {0.5, 1, 3, 99}) + void 사용_일수는_0점5_단위_양수다(double days) { + // 99 를 함께 둔 이유: 사용 일수도 MAX_TOTAL 을 쓰는데, 경계가 없으면 상한이 예전 365 로 // 남아 있어도 이 테스트가 통과한다(#142 가 99 로 좁힌 계약을 못 지킨다). assertTrue(LeaveDays.isValidUsage(days)); } @ParameterizedTest - @ValueSource(doubles = {0, 0.3, -0.2, 99.5, -99.5, 400}) - void 사용_증감이_0이거나_0점5_단위가_아니거나_상한_밖이면_거부한다(double days) { + @ValueSource(doubles = {0, 0.3, -0.2, 99.5, -99.5, 400, -0.5, -1, -99}) + void 사용_일수가_0이거나_음수거나_0점5_단위가_아니거나_상한_밖이면_거부한다(double days) { // 0 은 아무것도 바꾸지 않는 기록이라 소음이다. + // 음수는 예전엔 취소를 뜻해 받았는데, 같은 취소가 두 번 들어오면 잔여가 총을 넘었다(#265). // ±99.5 는 상한 바로 바깥 — 이게 없으면 상한이 365 여도 400 만 걸려 통과한다. assertFalse(LeaveDays.isValidUsage(days)); } + @ParameterizedTest + @ValueSource(doubles = {-0.5, -1, -2, -99, -0.3}) + void 음수는_상쇄_등록으로_가른다(double days) { + // 사유가 갈려야 화면이 "삭제로 취소하세요" 를 안내한다 — 0.5 단위 위반과 같은 코드로 답하면 + // 사용자는 자기가 숫자를 잘못 넣은 줄 안다. + assertTrue(LeaveDays.isReversal(days)); + } + + @ParameterizedTest + @ValueSource(doubles = {0, 0.5, 1, 99, 0.3}) + void 영_이상은_상쇄_등록이_아니다(double days) { + assertFalse(LeaveDays.isReversal(days)); + } + @ParameterizedTest @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) void 유한하지_않은_값은_거부한다(double 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..6038edc4 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) { + // 상쇄 등록이 잔여를 총 연차보다 크게 만들던 자리다(#265). 되돌리기는 이제 삭제다. + LeaveException thrown = + assertThrows(LeaveException.class, () -> LeaveUsage.manual("guest-1", WHEN, days, "취소")); + + assertEquals(LeaveErrorCode.LEAVE_USAGE_REVERSAL_NOT_ALLOWED, thrown.errorCode(), + "0.5 단위 위반과 사유가 달라야 화면이 '삭제로 취소하세요' 를 안내한다"); + } + + @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()); + } } From ce09c106a3703860c78c113d1c346c5808518ea3 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:02:40 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=ED=99=88=20=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A9=20clamp=20=EA=B0=80=20=EA=B2=BD=EA=B3=A0=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20=EC=9D=BC=EC=96=B4=EB=82=98=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현황을 만드는 경로가 둘인데(내 연차 조회·홈 배지) warn 이 한쪽에만 있었다. remainingDaysOrNull 이 LeaveSummary.of 를 직접 불러, 원장 합이 음수인 소유자가 홈만 열면 clamp 가 조용히 일어났다. 더 자주 불리는 쪽이 그쪽이라 방향이 반대였다. - 두 경로를 private summaryOf(guestId, totalDays) 하나로 모아 자르는 곳과 알리는 곳을 일치시켰다. 홈 배지는 총 연차를 이미 손에 들고 있어 조회를 한 번 아끼는데, 그 최적화가 로그를 건너뛰는 이유가 되지 않게 총 연차를 인자로 받는 오버로드를 뒀다. --- .../core/leave/service/MyLeaveService.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 e47c7ebf..b49dc230 100644 --- a/src/main/java/com/offway/core/leave/service/MyLeaveService.java +++ b/src/main/java/com/offway/core/leave/service/MyLeaveService.java @@ -132,8 +132,7 @@ public Double remainingDaysOrNull(String guestId) { return null; } return balanceRepository.findByGuestId(guestId) - .map(balance -> LeaveSummary.of(balance.getTotalDays(), usageRepository.sumDaysByGuestId(guestId)) - .remainingDays()) + .map(balance -> summaryOf(guestId, balance.getTotalDays()).remainingDays()) .orElse(null); } @@ -225,7 +224,18 @@ private LeaveSummary summaryOf(String guestId) { double total = balanceRepository.findByGuestId(guestId) .map(LeaveBalance::getTotalDays) .orElse(UNSET_TOTAL_DAYS); - LeaveSummary summary = LeaveSummary.of(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). 소유 키는 사용자 입력이라 로그에 싣지 않는다. From 55684001c306121053d5e25c2e428f596313a78a Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:02:52 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20=EC=97=B0=EC=B0=A8=20API=20?= =?UTF-8?q?=EC=9D=98=20401=20=EC=9D=84=20=EC=A0=84=EC=88=98=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=ED=95=98=EA=B3=A0=20=EB=82=A1=EC=95=84?= =?UTF-8?q?=EC=A7=84=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 삭제 엔드포인트에만 401 이 붙어 있어, 나머지 다섯 개가 공개로 읽혔다. SecurityConfig 는 anyRequest().authenticated() 라 여섯 개 모두 401 도달 가능하다. 한 곳만 적으면 없는 쪽이 permitAll 이라는 뜻이 되므로 전부에 붙이고, 인터페이스 주석에 근거(#122 인증 게이트)를 남겼다. - LeaveUsage 의 usedOn·days 필드 주석이 "증감 · 취소는 음수" 로 남아 있었다. 클래스 주석만 고치고 필드를 놓친 자리다. 하이드레이션이 생성자를 거치지 않아 옛 음수 행은 그대로 읽힌다는 사실도 함께 적었다. - MyLeaveResponse.usedDays 를 "내역 합" 이라 설명하던 것을 고쳤다. 이제 0 으로 잘리므로 옛 음수 행이 있으면 usages 를 더한 값과 다르다 — 클라이언트가 목록으로 검산하면 서버와 어긋난다. --- .../com/offway/core/leave/controller/LeaveApi.java | 12 +++++++++++- .../core/leave/controller/dto/MyLeaveResponse.java | 6 ++++-- .../com/offway/core/leave/domain/LeaveUsage.java | 9 +++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) 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 666dd1cf..0329b211 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); @@ -51,6 +58,7 @@ ApiResponseBody updateMyLeave( responseCode = "400", description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · usedOn·days 누락 또는 형식 오류 · " + "days 가 0 이거나 0.5 단위가 아니거나 99 초과(LEAVE-010) · days 가 음수(LEAVE-013)") + @ApiResponse(responseCode = "401", description = "인증 필요") ApiResponseBody addLeaveUsage( @Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId, AddLeaveUsageRequest request); @@ -90,12 +98,14 @@ ApiResponseBody deleteLeaveUsage( 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/dto/MyLeaveResponse.java b/src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java index fda8710e..1123a0e9 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,13 +10,15 @@ * "내 연차" 응답 — API 계약. 남은 연차는 서버가 계산해 내려준다(클라이언트가 빼지 않게). * * @param totalDays 총 연차 - * @param usedDays 쓴 연차 (내역 합). 0 아래로 내려가지 않는다 + * @param usedDays 쓴 연차. 0 아래로 내려가지 않는다 — 옛 상쇄 등록(음수 행)이 남아 있으면 {@code usages} + * 의 합보다 클 수 있다(#265). 클라이언트는 목록을 더해 검산하지 말고 이 값을 쓴다 * @param remainingDays 남은 연차. 총 연차를 넘지 않고(#265), 초과 사용 시 음수일 수 있다(결정 #38) * @param usages 사용 내역 (최근 순) */ public record MyLeaveResponse( @Schema(description = "총 연차", example = "15.0") double totalDays, - @Schema(description = "쓴 연차 (내역 합, 0 이상)", example = "2.0") double usedDays, + @Schema(description = "쓴 연차 (0 이상 — 옛 음수 행이 있으면 목록 합과 다를 수 있다)", example = "2.0") + double usedDays, @Schema(description = "남은 연차 (총 연차 이하 · 초과 사용 시 음수)", example = "13.0") double remainingDays, List usages) { 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 1466bfbb..d96947d3 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java @@ -38,11 +38,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 가 없던 시절의 상쇄 등록이다(#265). 새로 들어오는 것은 + * {@link #requireDays} 가 막지만, 이미 적재된 행은 그대로 읽힌다(하이드레이션은 생성자를 거치지 않는다). + */ @Column(name = "days", nullable = false) private double days; From 89d22aed615f48b95a9f256931187738b3593acb Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:03:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EC=9D=B4=EB=AF=B8=20=EC=8C=93?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=8C=EC=88=98=20=ED=96=89=EC=9D=B4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EB=B3=B4=EC=9D=B4=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EA=B0=80=20=EC=A7=80=EC=9B=8C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EB=90=98=EB=8A=94=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "마이그레이션으로 지우지 않고 사용자가 직접 지운다" 는 이 PR 의 결정이 테스트로 잠기지 않아, 그 행들이 실제로 어떻게 보이는지 아무도 확인하지 않은 상태였다. - API 로는 더 이상 음수를 넣을 수 없으므로 JdbcTemplate 으로 도메인을 우회해 심는다. 옛 데이터의 실제 모습이 그렇다 — 하이드레이션은 생성자를 거치지 않는다. - 한 시나리오로 셋을 잠근다. 원장 -2 여도 잔여가 17 이 아니라 15 인 것(clamp 가 단위 테스트가 아니라 실데이터에서 도는 것), 그래도 음수 행이 목록에 그대로 나가는 것(감추지 않기로 한 사양), 지우고 나면 usedDays 2·잔여 13 으로 장부가 맞아떨어지는 것. - clamp 를 제거해 돌려 이 테스트가 실제로 깨지는 것을 확인했다. --- .../controller/MyLeaveIntegrationTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 73f9a9c1..f1efc78d 100644 --- a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java +++ b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java @@ -13,6 +13,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.jayway.jsonpath.JsonPath; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -25,6 +26,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -47,6 +49,10 @@ class MyLeaveIntegrationTest { @Autowired private MockMvc mockMvc; + /** 도메인이 막는 값을 옛 데이터로 심을 때만 쓴다 — 그 밖의 준비는 전부 API 로 한다. */ + @Autowired + private JdbcTemplate jdbcTemplate; + private ResultActions addUsage(String guest, String body) throws Exception { return mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest) .contentType(MediaType.APPLICATION_JSON) @@ -199,6 +205,58 @@ private static long onlyUsageId(String responseBody) { .andExpect(jsonPath("$.code").value("LEAVE-011")); } + /** + * 이 PR 이 손대지 않기로 한 이미 쌓인 음수 행이 실제로 어떻게 보이고 어떻게 정리되는가(#265). + * + *

세 가지를 한 번에 잠근다. ① 원장 합이 음수여도 잔여가 총을 넘지 않는다(clamp 가 실데이터에서 돈다) + * ② 그래도 목록에는 음수 행이 그대로 보인다 — 마이그레이션으로 지우지 않기로 했으므로 이건 사양이다 + * ③ 그 행을 사용자가 직접 지울 수 있고, 지우면 장부가 실제로 맞아떨어진다. + * + *

API 로는 더 이상 음수를 넣을 수 없으므로 도메인을 우회해 직접 적재한다 — 그게 옛 데이터의 실제 모습이다 + * (하이드레이션은 생성자를 거치지 않는다). + */ + @Test + void 이미_쌓인_음수_행은_목록에_보이고_사용자가_지워_정리할_수_있다() throws Exception { + String guest = "leave-legacy-negative"; + 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()); + // 삭제 API 가 없던 시절의 상쇄 등록 — 같은 취소가 두 번 들어와 원장 합이 -2 가 된 그 장부다. + insertLegacyReversal(guest, -2.0); + insertLegacyReversal(guest, -2.0); + + 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(13.0)) + .andExpect(jsonPath("$.data.usages.length()").value(1)); + } + + /** 도메인을 우회해 음수 행을 심는다 — 이제 팩토리가 막으므로 옛 데이터는 이 길로만 재현된다. */ + private void insertLegacyReversal(String guest, double days) { + jdbcTemplate.update( + "INSERT INTO leave_usage (guest_id, used_on, days, reason) VALUES (?, ?, ?, ?)", + guest, LocalDate.of(2026, 5, 9), days, "하루 취소"); + } + @Test void 반차는_0점5로_센다() throws Exception { String guest = "leave-half"; From 063f4c5222b3656664965182fbfaae2c2cf35731 Mon Sep 17 00:00:00 2001 From: sevin98 Date: Fri, 14 Aug 2026 02:21:15 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=9D=8C=EC=88=98=20days=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B1=B0=EC=A0=88=EC=9D=84=20=EA=B1=B7?= =?UTF-8?q?=EC=96=B4=EB=82=B8=EB=8B=A4=20(=EC=95=B1=EC=9D=B4=20=EA=B0=88?= =?UTF-8?q?=EC=95=84=ED=83=84=20=EB=92=A4=EB=A1=9C=20=EB=AF=B8=EB=A3=B8=20?= =?UTF-8?q?=E2=80=94=20#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 거절을 이 PR 에 함께 넣으면 배포 순서에 창(window)이 생긴다. 프론트는 삭제 API 가 배포된 뒤에야 갈아탈 수 있어 순서가 백엔드 → 프론트로 고정인데, 그 사이 앱의 "취소" 는 400 LEAVE-013 을 받는다. 지금 앱은 그 코드를 모르고 삭제도 안 붙였으므로 그 구간 동안 사용자가 취소를 아예 못 한다. 리뷰에서 드러나 거절만 #276 으로 떼어냈다. - 미뤄도 이 PR 의 본질은 남는다. 사용자 눈에 보이는 "잔여가 총을 넘는" 증상은 LeaveSummary 의 clamp 가 막고, 틀어진 장부는 새 삭제 API 로 정리된다. 남는 것은 원장에 음수 행이 더 쌓이는 것뿐인데 그건 clamp 로 가려지고 삭제로 정리된다. - 되돌린 범위: isValidUsage 를 음수 허용으로 복구, isReversal 제거, 요청 DTO·도메인의 throw 두 곳 제거, 관련 문서·테스트. 삭제 API·clamp·409·404·warn 일원화는 그대로 둔다. - LEAVE-013 엔트리와 LeaveException 팩토리는 지우지 않았다. 에러코드 번호가 append-only 라 재사용·재배치가 금지이고, #276 이 그 코드를 그대로 쓴다. 지금 아무도 던지지 않아 죽은 코드로 보이므로 양쪽 javadoc 에 "#276 이 호출부를 만든다" 를 박았다. - 거절 테스트는 지우지 않고 뒤집었다. 음수 등록이 다시 201 로 통과하는 것을 단언한다 — 되돌리다 반쯤 남기는 것을 막는 자리다. 재현 시나리오 테스트도 JdbcTemplate 우회 대신 실제 앱 경로(상쇄 등록 두 번)로 바꿔, 원장 -2 에서 잔여가 17 이 아니라 15 인 것을 본다. --- .../core/leave/controller/LeaveApi.java | 9 ++-- .../controller/dto/AddLeaveUsageRequest.java | 18 +++---- .../leave/controller/dto/MyLeaveResponse.java | 6 +-- .../offway/core/leave/domain/LeaveDays.java | 26 +++------- .../core/leave/domain/LeaveErrorCode.java | 8 ++- .../core/leave/domain/LeaveException.java | 7 ++- .../offway/core/leave/domain/LeaveUsage.java | 14 +++--- .../core/leave/service/dto/AddLeaveUsage.java | 5 +- .../controller/MyLeaveIntegrationTest.java | 50 +++++++------------ .../core/leave/domain/LeaveDaysTest.java | 27 +++------- .../core/leave/domain/LeaveUsageTest.java | 12 ++--- 11 files changed, 76 insertions(+), 106 deletions(-) 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 0329b211..a32fa6df 100644 --- a/src/main/java/com/offway/core/leave/controller/LeaveApi.java +++ b/src/main/java/com/offway/core/leave/controller/LeaveApi.java @@ -46,10 +46,11 @@ ApiResponseBody updateMyLeave( @Operation( summary = "연차 사용 내역 추가", description = """ - 연차를 쓴 내역을 남긴다. days 는 0.5 단위 양수다. + 연차를 쓴(양수) 또는 되돌린(음수) 내역을 남긴다. days 는 0.5 단위다. - 되돌릴 때는 음수를 등록하지 않는다 — 내역 삭제 API 를 쓴다. 음수 등록은 같은 요청이 두 번 - 들어오면 그만큼 더 상쇄돼 남은 연차가 총 연차를 넘었다(LEAVE-013 으로 거절한다). + **되돌릴 때는 음수 등록 대신 내역 삭제 API 를 쓴다.** 음수 등록은 같은 요청이 두 번 들어오면 + 그만큼 더 상쇄돼 장부가 틀어진다 — 취소가 아니라 새 기록이기 때문이다. 지금은 아직 받지만 + 거절할 예정이다(#276). 새로 붙이는 화면은 삭제 API 를 쓴다. 남은 연차가 부족해도 서버는 막지 않는다 — 프론트가 경고하고 사용자가 확인하면 진행한다(결정 #38). 그래서 남은 연차는 음수가 될 수 있다.""") @@ -57,7 +58,7 @@ ApiResponseBody updateMyLeave( @ApiResponse( responseCode = "400", description = "X-Guest-Id 헤더 누락·빈 값·64자 초과 · usedOn·days 누락 또는 형식 오류 · " - + "days 가 0 이거나 0.5 단위가 아니거나 99 초과(LEAVE-010) · days 가 음수(LEAVE-013)") + + "days 가 0 이거나 0.5 단위가 아니거나 절댓값이 99 초과(LEAVE-010)") @ApiResponse(responseCode = "401", description = "인증 필요") ApiResponseBody addLeaveUsage( @Parameter(description = "소유 키 헤더", example = "guest-abc123") String guestId, 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 3aaf6bda..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 @@ -10,29 +10,23 @@ /** * 연차 사용 내역 추가 요청. * - * @param usedOn 연차를 쓴 날 (필수) - * @param days 쓴 일수 (필수, 0.5 단위 양수). 되돌리려면 등록이 아니라 삭제다(#265) + * @param usedOn 연차를 쓴(또는 되돌린) 날 (필수) + * @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 단위 양수). 되돌리려면 내역 삭제 API 를 쓴다", 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) { - /** - * 값 계약을 검증하고 커맨드로 바꾼다. - * - *

음수를 먼저 가른다 — 사유가 다르면 코드도 달라야 화면이 "삭제로 취소하세요" 를 안내할 수 있다(#265). - */ + /** 값 계약을 검증하고 커맨드로 바꾼다. */ public AddLeaveUsage toCommand() { - if (LeaveDays.isReversal(days)) { - throw LeaveException.leaveUsageReversalNotAllowed(); - } if (!LeaveDays.isValidUsage(days)) { throw LeaveException.invalidLeaveUsageDays(); } 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 1123a0e9..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,7 +10,7 @@ * "내 연차" 응답 — API 계약. 남은 연차는 서버가 계산해 내려준다(클라이언트가 빼지 않게). * * @param totalDays 총 연차 - * @param usedDays 쓴 연차. 0 아래로 내려가지 않는다 — 옛 상쇄 등록(음수 행)이 남아 있으면 {@code usages} + * @param usedDays 쓴 연차. 0 아래로 내려가지 않는다 — 상쇄 등록(음수 행)이 섞여 있으면 {@code usages} * 의 합보다 클 수 있다(#265). 클라이언트는 목록을 더해 검산하지 말고 이 값을 쓴다 * @param remainingDays 남은 연차. 총 연차를 넘지 않고(#265), 초과 사용 시 음수일 수 있다(결정 #38) * @param usages 사용 내역 (최근 순) @@ -33,14 +33,14 @@ public static MyLeaveResponse from(MyLeave myLeave) { /** * @param id 내역 ID — 삭제({@code DELETE /me/usages/{id}})의 대상이다 * @param usedOn 연차를 쓴 날 - * @param days 쓴 일수. 새 내역은 양수지만, 삭제 API 가 없던 시절의 상쇄 등록은 음수로 남아 있다(#265) + * @param days 증감 — 사용은 양수, 상쇄 등록은 음수. 그 음수 행도 삭제 대상이다(#265) * @param reason 사유 (없으면 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 29e32698..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,30 +52,20 @@ public static boolean isValidTotal(double days) { } /** - * 사용 내역으로 쓸 수 있는 값인가 — 양수만. 0 도 음수도 받지 않는다. + * 사용 내역의 증감으로 쓸 수 있는 값인가 — 0 은 막고 음수는 아직 받는다. * *

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

음수는 예전엔 받았다(#265 에서 닫았다). 삭제 API 가 없던 시절 화면이 취소를 표현할 방법이 - * 음수 등록뿐이었기 때문이다. 그런데 그 등록은 아무 상한이 없어, 같은 취소를 두 번 보내면 사용 합이 - * 음수로 내려가고 잔여가 총 연차를 넘었다 — 재시도 한 번에 없던 연차가 생긴 것이다. 취소는 - * 이제 {@code DELETE /me/usages/{id}} 로 한다. 상쇄 등록은 취소가 아니라 새 기록이라, 실수로 두 번 - * 보내면 장부가 그만큼 틀어진다. + *

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

그동안 사용자가 보는 증상은 막혀 있다 — 사용 합이 음수로 내려가도 {@link LeaveSummary} 가 잘라서 + * 잔여가 총 연차를 넘지 않는다(#265). 틀어진 장부는 삭제 API 로 정리한다. */ public static boolean isValidUsage(double days) { - return isValidUnit(days) && days > NONE && days <= MAX_TOTAL; - } - - /** - * 되돌리려고 넣는 음수 등록인가 — 받지 않는 값이지만 0.5 단위 위반과는 사유가 다르다(#265). - * - *

사유를 갈라야 화면이 "삭제로 취소하세요" 를 안내할 수 있다. 같은 400 으로 뭉뚱그리면 사용자는 - * 자기가 숫자를 잘못 넣은 줄 안다. - */ - public static boolean isReversal(double days) { - return days < NONE; + 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 2a73f315..dbb8f34d 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java @@ -54,7 +54,13 @@ public enum LeaveErrorCode implements ErrorCode { /** 지우려는 사용 내역이 없거나 남의 것 — 둘을 구분해 답하지 않는다(존재 여부를 흘리지 않는다). */ LEAVE_USAGE_NOT_FOUND("LEAVE-012", ErrorCategory.NOT_FOUND, "연차 사용 내역을 찾을 수 없습니다."), - /** 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다(#265). */ + /** + * 사용 내역을 음수로 등록하려 함 — 취소는 상쇄 등록이 아니라 삭제다. + * + *

아직 아무도 던지지 않는다. 죽은 코드가 아니라 자리를 잡아둔 것이다 — #276 이 이 코드를 그대로 + * 쓴다. #265 에서 거절까지 함께 넣었다가, 앱이 삭제 API 로 갈아타기 전에 배포되면 그 구간에서 취소가 + * 끊긴다는 것이 드러나 거절만 떼어냈다. 번호는 append-only 라(재사용·재배치 금지) 되돌리면서 지우지 않았다. + */ LEAVE_USAGE_REVERSAL_NOT_ALLOWED( "LEAVE-013", ErrorCategory.BAD_REQUEST, "연차 사용은 0.5일 단위의 양수여야 합니다. 되돌리려면 해당 내역을 삭제해 주세요."), 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 a6b29e21..c2aba809 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveException.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveException.java @@ -70,7 +70,12 @@ 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); } 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 d96947d3..59470f8a 100644 --- a/src/main/java/com/offway/core/leave/domain/LeaveUsage.java +++ b/src/main/java/com/offway/core/leave/domain/LeaveUsage.java @@ -19,6 +19,9 @@ * 들어오면 합이 음수로 내려가 잔여가 총 연차를 넘었다. 수동 내역은 {@code DELETE /me/usages/{id}} 로, * 코스 차감은 코스의 차감 취소로 지운다. * + *

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

{@code courseId} 는 raw ID 다(도메인 경계를 넘으므로 연관관계를 두지 않는다 — persistence-convention). * 코스 확정 차감(#91)이 이 값으로 중복 차감을 막는다 — 같은 코스로 이미 쌓인 내역이 있으면 건너뛴다. */ @@ -43,10 +46,10 @@ public class LeaveUsage { private LocalDate usedOn; /** - * 쓴 일수(0.5 단위 양수). 코스 차감만 0 을 허용한다(#212). + * 증감(0.5 단위). 사용은 양수, 취소는 음수. 코스 차감만 0 을 허용한다(#212). * - *

음수 행이 남아 있을 수 있다 — 삭제 API 가 없던 시절의 상쇄 등록이다(#265). 새로 들어오는 것은 - * {@link #requireDays} 가 막지만, 이미 적재된 행은 그대로 읽힌다(하이드레이션은 생성자를 거치지 않는다). + *

음수 행은 상쇄 등록이다 — 취소를 표현할 길이 그것뿐이던 시절의 것이 쌓여 있고, 앱이 삭제 + * API 로 갈아탈 때까지 계속 들어올 수 있다(#276 에서 닫는다). 사용자는 그 행을 삭제로 정리할 수 있다. */ @Column(name = "days", nullable = false) private double days; @@ -149,11 +152,6 @@ public void moveTo(LocalDate usedOn, double days) { * 직접 부르면 DTO 를 거치지 않고 들어온다. 그때 불변식 예외를 던지면 클라이언트 계약 위반이 500 으로 나간다. */ private static double requireDays(double days, Long courseId) { - // 음수 수동 등록은 사유를 갈라 답한다(#265) — "0.5 단위가 아님" 과 뭉뚱그리면 화면이 "삭제로 - // 취소하세요" 를 안내할 수 없다. 코스 차감은 애초에 음수가 들어올 길이 없어 갈라도 소득이 없다. - if (courseId == null && LeaveDays.isReversal(days)) { - throw LeaveException.leaveUsageReversalNotAllowed(); - } // 코스 차감은 0 을 허용한다(#212). 주말·공휴일뿐인 구간이면 깎을 연차가 없는데, 그것도 확정이다 — // 그 행이 차감량이자 확정 표식이기 때문이다. 수동 내역은 순수 증감 장부라 0 이 그대로 소음이다. boolean valid = courseId == null 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 8c8985b5..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 @@ -5,8 +5,9 @@ /** * 연차 사용 내역 추가 커맨드 — 서비스 내부용. * - * @param usedOn 연차를 쓴 날 - * @param days 쓴 일수(0.5 단위 양수). 되돌리는 것은 등록이 아니라 삭제다(#265) + * @param usedOn 연차를 쓴(또는 되돌린) 날 + * @param days 증감(0.5 단위). 사용은 양수, 취소는 음수 — 되돌리는 것은 등록이 아니라 삭제가 맞고, 음수 거절은 + * 앱이 갈아탄 뒤로 미뤘다(#276) * @param reason 사유 (선택) * @param courseId 이 내역을 만든 코스 (수동 입력이면 null) * @param halfDayStart 첫날 반차 여부. 코스 차감에서만 뜻이 있고, 날짜를 고칠 때 차감량을 다시 계산하는 입력이라 남긴다(#170) 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 f1efc78d..0793f933 100644 --- a/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java +++ b/src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java @@ -13,7 +13,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.jayway.jsonpath.JsonPath; -import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -26,7 +25,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; @@ -49,10 +47,6 @@ class MyLeaveIntegrationTest { @Autowired private MockMvc mockMvc; - /** 도메인이 막는 값을 옛 데이터로 심을 때만 쓴다 — 그 밖의 준비는 전부 API 로 한다. */ - @Autowired - private JdbcTemplate jdbcTemplate; - private ResultActions addUsage(String guest, String body) throws Exception { return mockMvc.perform(post(USAGES_URL).header(GUEST_HEADER, guest) .contentType(MediaType.APPLICATION_JSON) @@ -156,16 +150,16 @@ private static long onlyUsageId(String responseBody) { } @Test - void 음수_등록은_400_LEAVE_013_으로_거절하고_삭제를_안내한다() throws Exception { - // 상쇄 등록이 잔여를 총보다 크게 만들던 자리다. 0.5 단위 위반(LEAVE-010)과 코드를 가른다. + 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().isBadRequest()) - .andExpect(jsonPath("$.status").value(400)) - .andExpect(jsonPath("$.code").value("LEAVE-013")) - .andExpect(jsonPath("$.detail").value("연차 사용은 0.5일 단위의 양수여야 합니다. 되돌리려면 해당 내역을 삭제해 주세요.")) - .andExpect(jsonPath("$.data").doesNotExist()); + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value(201)) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.usages[0].days").value(-1.0)); } @Test @@ -206,25 +200,26 @@ private static long onlyUsageId(String responseBody) { } /** - * 이 PR 이 손대지 않기로 한 이미 쌓인 음수 행이 실제로 어떻게 보이고 어떻게 정리되는가(#265). + * #265 의 재현 시나리오 그 자체 — 지금 앱이 하는 그대로(상쇄 등록) 취소를 두 번 보낸 장부다. * - *

세 가지를 한 번에 잠근다. ① 원장 합이 음수여도 잔여가 총을 넘지 않는다(clamp 가 실데이터에서 돈다) - * ② 그래도 목록에는 음수 행이 그대로 보인다 — 마이그레이션으로 지우지 않기로 했으므로 이건 사양이다 - * ③ 그 행을 사용자가 직접 지울 수 있고, 지우면 장부가 실제로 맞아떨어진다. + *

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

API 로는 더 이상 음수를 넣을 수 없으므로 도메인을 우회해 직접 적재한다 — 그게 옛 데이터의 실제 모습이다 - * (하이드레이션은 생성자를 거치지 않는다). + *

#276 이 음수 등록을 닫으면 이 시나리오의 준비 단계는 API 로 만들 수 없다 — 그때 남아 있는 옛 행을 + * 재현하려면 도메인을 우회해 직접 적재해야 한다(하이드레이션은 생성자를 거치지 않는다). */ @Test - void 이미_쌓인_음수_행은_목록에_보이고_사용자가_지워_정리할_수_있다() throws Exception { - String guest = "leave-legacy-negative"; + 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()); - // 삭제 API 가 없던 시절의 상쇄 등록 — 같은 취소가 두 번 들어와 원장 합이 -2 가 된 그 장부다. - insertLegacyReversal(guest, -2.0); - insertLegacyReversal(guest, -2.0); + // 같은 취소가 두 번 — 재시도·중복 탭이면 실제로 이렇게 들어온다. 원장 합이 -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()) @@ -250,13 +245,6 @@ private static long onlyUsageId(String responseBody) { .andExpect(jsonPath("$.data.usages.length()").value(1)); } - /** 도메인을 우회해 음수 행을 심는다 — 이제 팩토리가 막으므로 옛 데이터는 이 길로만 재현된다. */ - private void insertLegacyReversal(String guest, double days) { - jdbcTemplate.update( - "INSERT INTO leave_usage (guest_id, used_on, days, reason) VALUES (?, ?, ?, ?)", - guest, LocalDate.of(2026, 5, 9), days, "하루 취소"); - } - @Test void 반차는_0점5로_센다() throws Exception { String guest = "leave-half"; 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 52c9c2cf..b04c8565 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java @@ -27,36 +27,23 @@ class LeaveDaysTest { } @ParameterizedTest - @ValueSource(doubles = {0.5, 1, 3, 99}) - void 사용_일수는_0점5_단위_양수다(double days) { - // 99 를 함께 둔 이유: 사용 일수도 MAX_TOTAL 을 쓰는데, 경계가 없으면 상한이 예전 365 로 + @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)); } @ParameterizedTest - @ValueSource(doubles = {0, 0.3, -0.2, 99.5, -99.5, 400, -0.5, -1, -99}) - void 사용_일수가_0이거나_음수거나_0점5_단위가_아니거나_상한_밖이면_거부한다(double days) { + @ValueSource(doubles = {0, 0.3, -0.2, 99.5, -99.5, 400}) + void 사용_증감이_0이거나_0점5_단위가_아니거나_상한_밖이면_거부한다(double days) { // 0 은 아무것도 바꾸지 않는 기록이라 소음이다. - // 음수는 예전엔 취소를 뜻해 받았는데, 같은 취소가 두 번 들어오면 잔여가 총을 넘었다(#265). // ±99.5 는 상한 바로 바깥 — 이게 없으면 상한이 365 여도 400 만 걸려 통과한다. assertFalse(LeaveDays.isValidUsage(days)); } - @ParameterizedTest - @ValueSource(doubles = {-0.5, -1, -2, -99, -0.3}) - void 음수는_상쇄_등록으로_가른다(double days) { - // 사유가 갈려야 화면이 "삭제로 취소하세요" 를 안내한다 — 0.5 단위 위반과 같은 코드로 답하면 - // 사용자는 자기가 숫자를 잘못 넣은 줄 안다. - assertTrue(LeaveDays.isReversal(days)); - } - - @ParameterizedTest - @ValueSource(doubles = {0, 0.5, 1, 99, 0.3}) - void 영_이상은_상쇄_등록이_아니다(double days) { - assertFalse(LeaveDays.isReversal(days)); - } - @ParameterizedTest @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) void 유한하지_않은_값은_거부한다(double days) { 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 6038edc4..91a18b2b 100644 --- a/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java +++ b/src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java @@ -111,13 +111,13 @@ class LeaveUsageTest { @ParameterizedTest @ValueSource(doubles = {-0.5, -1, -2, -99}) - void 수동_내역도_음수를_받지_않는다(double days) { - // 상쇄 등록이 잔여를 총 연차보다 크게 만들던 자리다(#265). 되돌리기는 이제 삭제다. - LeaveException thrown = - assertThrows(LeaveException.class, () -> LeaveUsage.manual("guest-1", WHEN, days, "취소")); + void 수동_내역은_아직_음수를_받는다(double days) { + // 되돌리기는 삭제가 맞지만, 거절을 지금 켜면 앱이 갈아타기 전 구간에서 취소가 끊긴다 — + // #276 으로 미뤘다. 미뤄도 잔여가 총을 넘는 증상은 LeaveSummary 의 clamp 가 막는다. + LeaveUsage usage = LeaveUsage.manual("guest-1", WHEN, days, "취소"); - assertEquals(LeaveErrorCode.LEAVE_USAGE_REVERSAL_NOT_ALLOWED, thrown.errorCode(), - "0.5 단위 위반과 사유가 달라야 화면이 '삭제로 취소하세요' 를 안내한다"); + assertEquals(days, usage.getDays()); + assertTrue(usage.isManual(), "상쇄 등록도 수동 내역이라 사용자가 삭제로 정리할 수 있다"); } @Test