diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java index 208ebf8..26928fc 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/controller/AdminCouponController.java @@ -2,25 +2,42 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDeleteResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDetailResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListWrapperDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponUpdateRequestDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponUpdateResponseDto; +import com.threestar.trainus.domain.coupon.admin.mapper.AdminCouponMapper; import com.threestar.trainus.domain.coupon.admin.service.AdminCouponService; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; import com.threestar.trainus.global.annotation.LoginUser; import com.threestar.trainus.global.unit.BaseResponse; +import com.threestar.trainus.global.unit.PagedResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @Tag(name = "관리자 쿠폰 API", description = "관리자 쿠폰 생성,수정 및 삭제 관련 API") @RestController -@RequestMapping("/api/v1/coupons") +@RequestMapping("/api/v1/admin/coupons") @RequiredArgsConstructor public class AdminCouponController { @@ -35,4 +52,54 @@ public ResponseEntity> createCoupon( CouponCreateResponseDto response = adminCouponService.createCoupon(request, loginUserId); return BaseResponse.ok("쿠폰 생성이 완료되었습니다.", response, HttpStatus.OK); } + + @GetMapping + @Operation(summary = "쿠폰 목록 조회", description = "관리자가 쿠폰 목록을 조회") + public ResponseEntity> getCoupons( + @RequestParam(defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") + @Max(value = 1000, message = "페이지는 1000 이하여야 합니다.") int page, + @RequestParam(defaultValue = "5") @Min(value = 1, message = "limit는 1 이상이어야 합니다.") + @Max(value = 100, message = "limit는 100 이하여야 합니다.") int limit, + @RequestParam(required = false) CouponStatus status, + @RequestParam(required = false) CouponCategory category, + @LoginUser Long loginUserId + ) { + CouponListResponseDto couponsInfo = adminCouponService.getCoupons(page, limit, status, category, loginUserId); + + CouponListWrapperDto coupons = AdminCouponMapper.toCouponListWrapperDto(couponsInfo); + + return PagedResponse.ok("쿠폰 목록 조회 완료.", coupons, couponsInfo.totalCount(), HttpStatus.OK); + } + + @GetMapping("/{couponId}") + @Operation(summary = "쿠폰 상세 조회", description = "관리자가 특정 쿠폰의 상세 정보를 조회") + public ResponseEntity> getCouponDetail( + @PathVariable Long couponId, + @LoginUser Long loginUserId + ) { + CouponDetailResponseDto response = adminCouponService.getCouponDetail(couponId, loginUserId); + return BaseResponse.ok("쿠폰 상세 조회 완료", response, HttpStatus.OK); + } + + @PatchMapping("/{couponId}") + @Operation(summary = "쿠폰 수정", description = "관리자가 쿠폰 정보를 수정") + public ResponseEntity> updateCoupon( + @PathVariable Long couponId, + @Valid @RequestBody CouponUpdateRequestDto request, + @LoginUser Long loginUserId + ) { + CouponUpdateResponseDto response = adminCouponService.updateCoupon(couponId, request, loginUserId); + return BaseResponse.ok("쿠폰 수정이 완료되었습니다.", response, HttpStatus.OK); + } + + @DeleteMapping("/{couponId}") + @Operation(summary = "쿠폰 삭제", description = "관리자가 쿠폰을 삭제") + public ResponseEntity> deleteCoupon( + @PathVariable Long couponId, + @LoginUser Long loginUserId + ) { + CouponDeleteResponseDto response = adminCouponService.deleteCoupon(couponId, loginUserId); + return BaseResponse.ok("쿠폰 삭제가 완료되었습니다.", response, HttpStatus.OK); + } + } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponCreateRequestDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponCreateRequestDto.java index 872b051..b044361 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponCreateRequestDto.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponCreateRequestDto.java @@ -26,7 +26,7 @@ public record CouponCreateRequestDto( Integer minOrderPrice, @NotNull(message = "쿠폰 상태는 필수입니다") - CouponStatus status, + CouponStatus status, //TODO : 일단은 내가 상태설정하게 두고, 리팩토링때 스케줄러로 처리하도록 변경 @Min(value = 1, message = "수량은 1개 이상이어야 합니다") Integer quantity, diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDeleteResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDeleteResponseDto.java new file mode 100644 index 0000000..f1bd2f6 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDeleteResponseDto.java @@ -0,0 +1,13 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.time.LocalDateTime; + +/** + * 쿠폰 삭제 응답 DTO + */ +public record CouponDeleteResponseDto( + Long couponId, + String couponName, + LocalDateTime deletedAt +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDetailResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDetailResponseDto.java new file mode 100644 index 0000000..02cc8ba --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponDetailResponseDto.java @@ -0,0 +1,26 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.time.LocalDateTime; + +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; + +/** + * 쿠폰 상세 조회 응답 DTO + */ +public record CouponDetailResponseDto( + Long id, + String couponName, + LocalDateTime expirationDate, + String discountPrice, + Integer minOrderPrice, + CouponStatus status, + Integer quantity, + CouponCategory couponCategory, + LocalDateTime couponOpenAt, + LocalDateTime couponDeadlineAt, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Integer issuedCount +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListItemDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListItemDto.java new file mode 100644 index 0000000..99e922a --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListItemDto.java @@ -0,0 +1,25 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.time.LocalDateTime; + +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; + +/** + * 개별의 쿠폰 정보 + */ +public record CouponListItemDto( + Long couponId, + String couponName, + LocalDateTime expirationDate, + String discountPrice, + Integer minOrderPrice, + LocalDateTime createdAt, + LocalDateTime updatedAt, + CouponStatus status, + Integer quantity, + CouponCategory category, + LocalDateTime couponOpenAt, + LocalDateTime couponDeadlineAt +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListResponseDto.java new file mode 100644 index 0000000..94e496f --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListResponseDto.java @@ -0,0 +1,9 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.util.List; + +public record CouponListResponseDto( + Integer totalCount, + List couponList +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListWrapperDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListWrapperDto.java new file mode 100644 index 0000000..538d2d7 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponListWrapperDto.java @@ -0,0 +1,8 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.util.List; + +public record CouponListWrapperDto( + List coupons +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateRequestDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateRequestDto.java new file mode 100644 index 0000000..98953ea --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateRequestDto.java @@ -0,0 +1,32 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.time.LocalDateTime; + +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +/** + * 쿠폰 수정 요청 DTO + * 일반쿠폰(NORMAL): 수량은 프론트에서 비활성화, 백엔드에서 자동으로 null 처리 + * 선착순쿠폰(OPEN_RUN): 수량 필수 입력 + */ +public record CouponUpdateRequestDto( + + @Size(max = 45, message = "쿠폰명은 45자 이하여야 합니다") + String couponName, + + CouponStatus status, + + @Min(value = 1, message = "수량은 1개 이상이어야 합니다") + Integer quantity, + + CouponCategory category, + + LocalDateTime couponOpenAt, + + LocalDateTime couponDeadlineAt +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateResponseDto.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateResponseDto.java new file mode 100644 index 0000000..7cab7e8 --- /dev/null +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/dto/CouponUpdateResponseDto.java @@ -0,0 +1,20 @@ +package com.threestar.trainus.domain.coupon.admin.dto; + +import java.time.LocalDateTime; + +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; + +/** + * 쿠폰 수정 응답 DTO + */ +public record CouponUpdateResponseDto( + String couponName, + CouponStatus status, + Integer quantity, + CouponCategory category, + LocalDateTime couponOpenAt, + LocalDateTime couponDeadlineAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java index 14f7b44..3d77f15 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/mapper/AdminCouponMapper.java @@ -1,7 +1,15 @@ package com.threestar.trainus.domain.coupon.admin.mapper; +import org.springframework.data.domain.Page; + import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDeleteResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDetailResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListItemDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListWrapperDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponUpdateResponseDto; import com.threestar.trainus.domain.coupon.user.entity.Coupon; public class AdminCouponMapper { @@ -31,4 +39,72 @@ public static CouponCreateResponseDto toCreateResponseDto(Coupon coupon) { coupon.getCreatedAt() ); } + + public static CouponListItemDto toCouponListItemDto(Coupon coupon) { + return new CouponListItemDto( + coupon.getId(), + coupon.getName(), + coupon.getExpirationDate(), + coupon.getDiscountPrice(), + coupon.getMinOrderPrice(), + coupon.getCreatedAt(), + coupon.getUpdatedAt(), + coupon.getStatus(), + coupon.getQuantity(), + coupon.getCategory(), + coupon.getOpenAt(), + coupon.getCloseAt() + ); + } + + public static CouponListResponseDto toCouponListResponseDto(Page couponPage) { + return new CouponListResponseDto( + (int)couponPage.getTotalElements(), + couponPage.getContent().stream() + .map(AdminCouponMapper::toCouponListItemDto) + .toList() + ); + } + + public static CouponDetailResponseDto toCouponDetailResponseDto(Coupon coupon, Integer issuedCount) { + return new CouponDetailResponseDto( + coupon.getId(), + coupon.getName(), + coupon.getExpirationDate(), + coupon.getDiscountPrice(), + coupon.getMinOrderPrice(), + coupon.getStatus(), + coupon.getQuantity(), + coupon.getCategory(), + coupon.getOpenAt(), + coupon.getCloseAt(), + coupon.getCreatedAt(), + coupon.getUpdatedAt(), + issuedCount + ); + } + + public static CouponUpdateResponseDto toCouponUpdateResponseDto(Coupon coupon) { + return new CouponUpdateResponseDto( + coupon.getName(), + coupon.getStatus(), + coupon.getQuantity(), + coupon.getCategory(), + coupon.getOpenAt(), + coupon.getCloseAt(), + coupon.getUpdatedAt() + ); + } + + public static CouponDeleteResponseDto toCouponDeleteResponseDto(Coupon coupon) { + return new CouponDeleteResponseDto( + coupon.getId(), + coupon.getName(), + coupon.getDeletedAt() + ); + } + + public static CouponListWrapperDto toCouponListWrapperDto(CouponListResponseDto couponsInfo) { + return new CouponListWrapperDto(couponsInfo.couponList()); + } } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java index fa8c2a9..ed5d8d6 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/admin/service/AdminCouponService.java @@ -2,15 +2,26 @@ import java.time.LocalDateTime; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateRequestDto; import com.threestar.trainus.domain.coupon.admin.dto.CouponCreateResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDeleteResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponDetailResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponListResponseDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponUpdateRequestDto; +import com.threestar.trainus.domain.coupon.admin.dto.CouponUpdateResponseDto; import com.threestar.trainus.domain.coupon.admin.mapper.AdminCouponMapper; import com.threestar.trainus.domain.coupon.user.entity.Coupon; import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; import com.threestar.trainus.domain.coupon.user.repository.CouponRepository; +import com.threestar.trainus.domain.coupon.user.repository.UserCouponRepository; import com.threestar.trainus.domain.user.service.UserService; import com.threestar.trainus.global.exception.domain.ErrorCode; import com.threestar.trainus.global.exception.handler.BusinessException; @@ -22,8 +33,10 @@ public class AdminCouponService { private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; private final UserService userService; + //쿠폰 생성 @Transactional public CouponCreateResponseDto createCoupon(CouponCreateRequestDto request, Long userId) { // 관리자 권한 검증 @@ -39,6 +52,91 @@ public CouponCreateResponseDto createCoupon(CouponCreateRequestDto request, Long return AdminCouponMapper.toCreateResponseDto(savedCoupon); } + //쿠폰 조회 + @Transactional(readOnly = true) + public CouponListResponseDto getCoupons(int page, int limit, CouponStatus status, CouponCategory category, + Long userId) { + // 관리자 권한 검증 + userService.validateAdminRole(userId); + + Pageable pageable = PageRequest.of(page - 1, limit, Sort.by("createdAt").descending()); + + // 조건에 따른 쿠폰 조회 + Page couponPage = couponRepository.findCouponsWithFilters(status, category, pageable); + + //응답 DTO 변환 + return AdminCouponMapper.toCouponListResponseDto(couponPage); + } + + //쿠폰 상세 조횧 + @Transactional(readOnly = true) + public CouponDetailResponseDto getCouponDetail(Long couponId, Long userId) { + // 관리자 권한 검증 + userService.validateAdminRole(userId); + + // 쿠폰 조회 + Coupon coupon = findCouponById(couponId); + + // 발급된 쿠폰 수 조회 + Long issuedCountLong = userCouponRepository.countByCouponId(couponId); + Integer issuedCount = issuedCountLong.intValue(); + + // 응답 DTO 변환 + return AdminCouponMapper.toCouponDetailResponseDto(coupon, issuedCount); + } + + //쿠폰 수정 + @Transactional + public CouponUpdateResponseDto updateCoupon(Long couponId, CouponUpdateRequestDto request, Long userId) { + // 관리자 권한 검증 + userService.validateAdminRole(userId); + + // 쿠폰 조회 + Coupon coupon = findCouponById(couponId); + + // 발급된 쿠폰 수 조회 + Long issuedCount = userCouponRepository.countByCouponId(couponId); + + // 수정 검증 로직 + validateCouponUpdate(coupon, request, issuedCount); + + // 수정된 쿠폰 업데이트 + updateCouponFields(coupon, request); + + Coupon updatedCoupon = couponRepository.save(coupon); + + // 응답 DTO 변환 + return AdminCouponMapper.toCouponUpdateResponseDto(updatedCoupon); + } + + //쿠폰 삭제 + @Transactional + public CouponDeleteResponseDto deleteCoupon(Long couponId, Long userId) { + // 관리자 권한 검증 + userService.validateAdminRole(userId); + + // 쿠폰 조회 + Coupon coupon = findCouponById(couponId); + + // 이미 삭제된 쿠폰인지 확인 + if (coupon.isDeleted()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + + // todo : 발급된 쿠폰 수 조회 + Long issuedCount = userCouponRepository.countByCouponId(couponId); + + // todo : 삭제 가능 여부 검증 -> 지금은 필요없는데 일단은 만들어둠! + validateCouponDeletion(coupon, issuedCount); + + // 쿠폰 삭제 처리 + coupon.markAsDeleted(); + Coupon deletedCoupon = couponRepository.save(coupon); + + // 응답 DTO 변환 + return AdminCouponMapper.toCouponDeleteResponseDto(deletedCoupon); + } + private void validateCouponRequest(CouponCreateRequestDto request) { // 할인가격이 뭔지 검증(퍼센트인지 금액인지) validateDiscountPrice(request.discountPrice()); @@ -53,6 +151,9 @@ private void validateCouponRequest(CouponCreateRequestDto request) { throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); } + // 상태와 오픈 시간 논리적으로 맞는지 검증 + validateStatusConsistency(request.status(), request.couponOpenAt()); + // 선착순 쿠폰 검증 if (request.category() == CouponCategory.OPEN_RUN) { // 선착순 쿠폰은 수량이 필수 @@ -102,4 +203,147 @@ private void validateDiscountPrice(String discountPrice) { } } } + + //status랑 오픈시각이랑 검증 메서드 -> open시간이 미래인데 active일경우 + private void validateStatusConsistency(CouponStatus status, LocalDateTime openAt) { + LocalDateTime now = LocalDateTime.now(); + + // ACTIVE 상태인데 오픈 시간이 미래인 경우 + if (status == CouponStatus.ACTIVE && openAt.isAfter(now)) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + + // INACTIVE 상태인데 오픈 시간이 과거인 경우 + if (status == CouponStatus.INACTIVE && openAt.isBefore(now)) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + private void validateCouponUpdate(Coupon coupon, CouponUpdateRequestDto request, Long issuedCount) { + LocalDateTime now = LocalDateTime.now(); + + // 선착순 쿠폰인데 수량이 없는 경우 + if (request.category() != null) { + if (request.category() == CouponCategory.OPEN_RUN && request.quantity() == null) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + // 수량 수정 검증 -> 선착순 쿠폰의 경우 + if (request.quantity() != null) { + // 이미 발급된 수량보다 적게 설정 불가 + if (request.quantity() < issuedCount.intValue()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + // 카테고리 변경 검증 -> 이미 발급한 쿠폰이 있다면 변경 못하게 막기 + if (request.category() != null && issuedCount > 0) { + // 이미 발급된 쿠폰이 있으면 카테고리 변경 제한 + if (!coupon.getCategory().equals(request.category())) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + // 선착순 → 일반 변경은 아무도 발급받지 않은 경우만 가능 + if (request.category() == CouponCategory.NORMAL && coupon.getCategory() == CouponCategory.OPEN_RUN + && issuedCount > 0) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + + // 이미 오픈된 쿠폰의 오픈시각은 수정 불가 + if (request.couponOpenAt() != null) { + if (coupon.getOpenAt().isBefore(now)) { + // 이미 오픈된 쿠폰의 오픈 시각은 수정 불가 + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + + // 새로운 오픈 시각이 마감 시각보다 늦으면 안됨 + LocalDateTime newCloseAt = + request.couponDeadlineAt() != null ? request.couponDeadlineAt() : coupon.getCloseAt(); + if (request.couponOpenAt().isAfter(newCloseAt)) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + // 이미 마감된 쿠폰의 마감시각은 수정 불가 -> 진행중인경우 연장만 가능 + if (request.couponDeadlineAt() != null) { + if (coupon.getCloseAt().isBefore(now)) { + // 이미 마감된 쿠폰의 마감 시각은 수정 불가 + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + + if (coupon.getOpenAt().isBefore(now) && coupon.getCloseAt().isAfter(now)) { + // 진행 중인 쿠폰은 연장만 가능 -> 단축은 못함 + if (request.couponDeadlineAt().isBefore(coupon.getCloseAt())) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + + // 새로운 마감 시각이 오픈 시각보다 이르면 안됨 + LocalDateTime newOpenAt = request.couponOpenAt() != null ? request.couponOpenAt() : coupon.getOpenAt(); + if (request.couponDeadlineAt().isBefore(newOpenAt)) { + throw new BusinessException(ErrorCode.INVALID_REQUEST_DATA); + } + } + } + + private void updateCouponFields(Coupon coupon, CouponUpdateRequestDto request) { + if (request.couponName() != null) { + coupon.updateName(request.couponName()); + } + + if (request.status() != null) { + // INACTIVE → ACTIVE로 변경 시, 오픈시각을 현재 시간으로 업데이트 + if (request.status() == CouponStatus.ACTIVE && coupon.getStatus() == CouponStatus.INACTIVE) { + LocalDateTime now = LocalDateTime.now(); + if (coupon.getOpenAt().isAfter(now)) { + coupon.updateOpenAt(now); + } + } + + coupon.updateStatus(request.status()); + } + + // 카테고리 변경 시 수량도 함께 처리 + if (request.category() != null) { + coupon.updateCategory(request.category()); + + if (request.category() == CouponCategory.NORMAL) { + // 일반 쿠폰으로 변경 시 수량을 null로 설정 + coupon.updateQuantity(null); + } else if (request.category() == CouponCategory.OPEN_RUN) { + // 선착순 쿠폰으로 변경 시 수량 설정 + if (request.quantity() != null) { + coupon.updateQuantity(request.quantity()); + } + } + } + + // 수량만 변경하는 경우 -> 선착순 쿠폰일 경우만 + if (request.quantity() != null && request.category() == null) { + // 현재 선착순 쿠폰인 경우에만 수량 변경 허용 + if (coupon.getCategory() == CouponCategory.OPEN_RUN) { + coupon.updateQuantity(request.quantity()); + } + } + + if (request.couponOpenAt() != null) { + coupon.updateOpenAt(request.couponOpenAt()); + } + + if (request.couponDeadlineAt() != null) { + coupon.updateCloseAt(request.couponDeadlineAt()); + } + } + + private void validateCouponDeletion(Coupon coupon, Long issuedCount) { + //todo: 일단은 다 삭제가능하게 설정 해놨는데, 나중에 결제 붙으면여기에 여러 검증들을 추가할 예정 + } + + //쿠폰id로 쿠폰을 조회하는 공통 메서드 + public Coupon findCouponById(Long couponId) { + return couponRepository.findById(couponId) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REQUEST_DATA)); + } } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/entity/Coupon.java b/src/main/java/com/threestar/trainus/domain/coupon/user/entity/Coupon.java index e649fdd..5de4c6d 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/entity/Coupon.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/entity/Coupon.java @@ -65,10 +65,52 @@ public class Coupon extends BaseDateEntity { @Column(nullable = false) private LocalDateTime closeAt; + private LocalDateTime deletedAt; + public void decreaseQuantity() { if (this.quantity <= 0) { throw new BusinessException(ErrorCode.COUPON_BE_EXHAUSTED); } this.quantity--; } + + //쿠폰 수정 관련 메서드 추가 + public void updateName(String name) { + this.name = name; + } + + public void updateStatus(CouponStatus status) { + this.status = status; + } + + public void updateQuantity(Integer quantity) { + this.quantity = quantity; + } + + public void updateCategory(CouponCategory category) { + this.category = category; + } + + public void updateOpenAt(LocalDateTime openAt) { + this.openAt = openAt; + } + + public void updateCloseAt(LocalDateTime closeAt) { + this.closeAt = closeAt; + } + + //삭제관련 메서드 추가 + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + //쿠폰이 삭제된 상태인지 확인 + public boolean isDeleted() { + return this.deletedAt != null; + } + + public LocalDateTime getDeletedAt() { + return this.deletedAt; + } + } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java index 0cb8dcb..cd51f82 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/CouponRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -11,6 +13,8 @@ import com.threestar.trainus.domain.coupon.user.dto.CouponResponseDto; import com.threestar.trainus.domain.coupon.user.entity.Coupon; +import com.threestar.trainus.domain.coupon.user.entity.CouponCategory; +import com.threestar.trainus.domain.coupon.user.entity.CouponStatus; import jakarta.persistence.LockModeType; @@ -25,6 +29,7 @@ public interface CouponRepository extends JpaRepository { FROM Coupon c LEFT JOIN UserCoupon uc ON c.id = uc.coupon.id AND uc.user.id = :userId WHERE c.closeAt > :now + AND c.deletedAt IS NULL ORDER BY c.openAt ASC """) List findAvailableCouponsWithOwnership(@Param("userId") Long userId, @@ -34,4 +39,17 @@ List findAvailableCouponsWithOwnership(@Param("userId") Long @Query("SELECT c from Coupon c where c.id = :couponId") Optional findByIdWithPessimisticLock(Long couponId); + //관리자 쿠폰 목록 조회 -> 필터링 + @Query(""" + SELECT c FROM Coupon c + WHERE (:status IS NULL OR c.status = :status) + AND (:category IS NULL OR c.category = :category) + AND c.deletedAt IS NULL + ORDER BY c.createdAt DESC + """) + Page findCouponsWithFilters( + @Param("status") CouponStatus status, + @Param("category") CouponCategory category, + Pageable pageable + ); } diff --git a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java index bd2c336..9de5a34 100644 --- a/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java +++ b/src/main/java/com/threestar/trainus/domain/coupon/user/repository/UserCouponRepository.java @@ -19,4 +19,9 @@ public interface UserCouponRepository extends JpaRepository { List findAllByUserIdAndStatusWithCoupon(@Param("userId") Long userId, @Param("status") CouponStatus status); + // 특정 쿠폰의 발급 수 조회 + Long countByCouponId(Long couponId); + + // 특정 사용자가 특정 쿠폰을 발급받은 수 조회 + Long countByUserIdAndCouponId(Long userId, Long couponId); } diff --git a/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java b/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java index 5a0fef1..2e6095d 100644 --- a/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java +++ b/src/main/java/com/threestar/trainus/global/config/MockDataInitializer.java @@ -107,6 +107,30 @@ private List createInstructors() { instructors.add(savedInstructor); } + // 관리자 계정 추가 + User admin = User.builder() + .email("admin@test.com") + .password(passwordEncoder.encode("admin123")) + .nickname("관리자") + .role(UserRole.ADMIN) //관리자 + .build(); + + User savedAdmin = userRepository.save(admin); + + // 관리자 Profile 생성 + Profile adminProfile = ProfileMapper.toDefaultEntity(savedAdmin); + adminProfile.updateProfile( + "https://example.com/admin.jpg", + "시스템 관리자입니다." + ); + profileRepository.save(adminProfile); + + // 관리자 ProfileMetadata 생성 + ProfileMetadata adminMetadata = ProfileMetadataMapper.toDefaultEntity(savedAdmin); + profileMetadataRepository.save(adminMetadata); + + instructors.add(savedAdmin); + return instructors; }