diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/FindReceiptController.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/FindReceiptController.java index c3538b1..f0d7651 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/FindReceiptController.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/FindReceiptController.java @@ -4,9 +4,12 @@ import com.ClubAccount_BE.receipt.adapter.in.web.api.FindReceiptApi; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptCategoryResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptDetailResponse; +import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptExpenseResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptResponse; import com.ClubAccount_BE.receipt.application.port.in.FindReceiptUseCase; +import jakarta.validation.constraints.Positive; import java.time.LocalDate; +import java.util.List; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -50,4 +53,12 @@ public ReceiptCategoryResponse getReceiptCategoryRatio( ) { return findReceiptUseCase.getReceiptCategoryRatio(link); } + + @GetMapping("/{link}/receipts/expense") + public List getReceiptExpenseList( + @PathVariable(value = "link") UUID link, + @Positive(message = "유효하지 않은 연도입니다.") @RequestParam int year + ) { + return findReceiptUseCase.getReceiptExpenseList(link, year); + } } diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/FindReceiptApi.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/FindReceiptApi.java index 98a00ae..267e058 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/FindReceiptApi.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/api/FindReceiptApi.java @@ -3,10 +3,13 @@ import com.ClubAccount_BE.core.response.PagingResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptCategoryResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptDetailResponse; +import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptExpenseResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; import java.time.LocalDate; +import java.util.List; import java.util.UUID; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -39,4 +42,10 @@ ReceiptDetailResponse getReceipt( ReceiptCategoryResponse getReceiptCategoryRatio( @PathVariable(value = "link") UUID link ); + + @Operation(summary = "영수증 월별 지출 목록 조회", description = "등록된 영수증의 월별 지출을 조회한다.") + List getReceiptExpenseList( + @PathVariable(value = "link") UUID link, + @Positive(message = "유효하지 않은 연도입니다.") @RequestParam int year + ); } diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/dto/response/ReceiptExpenseResponse.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/dto/response/ReceiptExpenseResponse.java new file mode 100644 index 0000000..dc0edc0 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/in/web/dto/response/ReceiptExpenseResponse.java @@ -0,0 +1,24 @@ +package com.ClubAccount_BE.receipt.adapter.in.web.dto.response; + +import com.ClubAccount_BE.receipt.domain.DetailExpenseResult; +import java.math.BigDecimal; +import java.util.UUID; +import lombok.Builder; + +@Builder +public record ReceiptExpenseResponse( + UUID id, + int year, + int month, + BigDecimal totalExpense +) { + + public static ReceiptExpenseResponse of(DetailExpenseResult result) { + return ReceiptExpenseResponse.builder() + .id(UUID.nameUUIDFromBytes((result.getYear() + "-" + result.getMonth()).getBytes())) + .year(result.getYear()) + .month(result.getMonth()) + .totalExpense(result.getTotalExpense()) + .build(); + } +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java index 7bcc6dc..2af406a 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/ReceiptRepositoryAdapter.java @@ -58,6 +58,15 @@ public Receipt getReceipt(User user, Long receiptId) { .orElseThrow(() -> new ApiException(RECEIPT_NOT_FOUND)); } + @Override + public List getReceiptExpenseList(User user, int year) { + return receiptRepository + .findByUserIdAndYear(user.getId(), year) + .stream() + .map(ReceiptMapper::toDomain) + .toList(); + } + @Override public List getReceiptCategoryList(User user) { return receiptRepository diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepository.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepository.java index b099acf..bdb5283 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepository.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepository.java @@ -2,6 +2,7 @@ import com.ClubAccount_BE.receipt.adapter.out.persistence.entity.ReceiptEntity; import java.time.LocalDate; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -13,4 +14,6 @@ Page findAllByDate( LocalDate endDate, Pageable pageable ); + + List findByUserIdAndYear(Long userId, int year); } diff --git a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepositoryImpl.java b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepositoryImpl.java index 0df6073..a5b1032 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepositoryImpl.java +++ b/src/main/java/com/ClubAccount_BE/receipt/adapter/out/persistence/repository/ReceiptCustomRepositoryImpl.java @@ -1,6 +1,7 @@ package com.ClubAccount_BE.receipt.adapter.out.persistence.repository; -import com.ClubAccount_BE.receipt.adapter.out.persistence.entity.QReceiptEntity; +import static com.ClubAccount_BE.receipt.adapter.out.persistence.entity.QReceiptEntity.receiptEntity; + import com.ClubAccount_BE.receipt.adapter.out.persistence.entity.ReceiptEntity; import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQuery; @@ -24,28 +25,38 @@ public Page findAllByDate( LocalDate endDate, Pageable pageable ) { - QReceiptEntity receipt = QReceiptEntity.receiptEntity; BooleanBuilder where = new BooleanBuilder(); - where.and(receipt.user.id.eq(userId)); + where.and(receiptEntity.user.id.eq(userId)); if (startDate != null && endDate != null) { - where.and(receipt.date.between(startDate, endDate)); + where.and(receiptEntity.date.between(startDate, endDate)); } List content = queryFactory - .selectFrom(receipt) + .selectFrom(receiptEntity) .where(where) - .orderBy(receipt.date.desc()) + .orderBy(receiptEntity.date.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); JPAQuery count = queryFactory - .select(receipt.count()) - .from(receipt) + .select(receiptEntity.count()) + .from(receiptEntity) .where(where); return PageableExecutionUtils.getPage(content, pageable, count::fetchOne); } + + @Override + public List findByUserIdAndYear(Long userId, int year) { + return queryFactory + .selectFrom(receiptEntity) + .where( + receiptEntity.user.id.eq(userId), + receiptEntity.date.year().eq(year) + ) + .fetch(); + } } diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/in/FindReceiptUseCase.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/in/FindReceiptUseCase.java index f6e4213..9a6f4ea 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/port/in/FindReceiptUseCase.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/in/FindReceiptUseCase.java @@ -3,8 +3,10 @@ import com.ClubAccount_BE.core.response.PagingResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptCategoryResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptDetailResponse; +import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptExpenseResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptResponse; import java.time.LocalDate; +import java.util.List; import java.util.UUID; import org.springframework.data.domain.Pageable; @@ -20,4 +22,6 @@ PagingResponse getReceiptList( ); ReceiptDetailResponse getReceipt(UUID link, Long receiptId); + + List getReceiptExpenseList(UUID link, int year); } diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java index 91b078c..98ecf24 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/port/out/FindReceiptPort.java @@ -19,4 +19,6 @@ Page getReceiptList( List getReceiptCategoryList(User user); Receipt getReceipt(User user, Long receiptId); + + List getReceiptExpenseList(User user, int year); } diff --git a/src/main/java/com/ClubAccount_BE/receipt/application/service/FindReceiptService.java b/src/main/java/com/ClubAccount_BE/receipt/application/service/FindReceiptService.java index a0912e3..34d9e16 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/application/service/FindReceiptService.java +++ b/src/main/java/com/ClubAccount_BE/receipt/application/service/FindReceiptService.java @@ -6,10 +6,12 @@ import com.ClubAccount_BE.core.response.PagingResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptCategoryResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptDetailResponse; +import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptExpenseResponse; import com.ClubAccount_BE.receipt.adapter.in.web.dto.response.ReceiptResponse; import com.ClubAccount_BE.receipt.application.port.in.FindReceiptUseCase; import com.ClubAccount_BE.receipt.application.port.out.FindReceiptPort; import com.ClubAccount_BE.receipt.domain.DetailCategoryResult; +import com.ClubAccount_BE.receipt.domain.DetailExpenseResult; import com.ClubAccount_BE.receipt.domain.Receipt; import com.ClubAccount_BE.receipt.domain.service.ReceiptEditor; import com.ClubAccount_BE.user.application.port.out.FindUserPort; @@ -40,6 +42,17 @@ public ReceiptDetailResponse getReceipt(UUID link, Long receiptId) { return ReceiptDetailResponse.of(receipt); } + @Override + public List getReceiptExpenseList(UUID link, int year) { + + User user = findUserPort.getUserByLink(link); + List receiptList = findReceiptPort.getReceiptExpenseList(user, year); + List results = receiptEditor.calculateExpense(receiptList, year); + return results.stream() + .map(ReceiptExpenseResponse::of) + .toList(); + } + @Override public PagingResponse getReceiptList( diff --git a/src/main/java/com/ClubAccount_BE/receipt/domain/DetailExpenseResult.java b/src/main/java/com/ClubAccount_BE/receipt/domain/DetailExpenseResult.java new file mode 100644 index 0000000..33c99a0 --- /dev/null +++ b/src/main/java/com/ClubAccount_BE/receipt/domain/DetailExpenseResult.java @@ -0,0 +1,28 @@ +package com.ClubAccount_BE.receipt.domain; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; + +@Getter +public class DetailExpenseResult { + + private final int year; + private final int month; + private final BigDecimal totalExpense; + + @Builder + private DetailExpenseResult(int year, int month, BigDecimal totalExpense) { + this.year = year; + this.month = month; + this.totalExpense = totalExpense; + } + + public static DetailExpenseResult of(int year, int month, BigDecimal totalExpense) { + return DetailExpenseResult.builder() + .year(year) + .month(month) + .totalExpense(totalExpense) + .build(); + } +} diff --git a/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java b/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java index 73f3660..6d80bd1 100644 --- a/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java +++ b/src/main/java/com/ClubAccount_BE/receipt/domain/service/ReceiptEditor.java @@ -1,6 +1,7 @@ package com.ClubAccount_BE.receipt.domain.service; import com.ClubAccount_BE.receipt.domain.DetailCategoryResult; +import com.ClubAccount_BE.receipt.domain.DetailExpenseResult; import com.ClubAccount_BE.receipt.domain.Receipt; import com.ClubAccount_BE.receipt.domain.ReceiptItem; import com.ClubAccount_BE.receipt.domain.type.ReceiptCategory; @@ -8,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.springframework.stereotype.Service; @Service @@ -46,6 +48,24 @@ public DetailCategoryResult calculateCategoryRatio(List receipts) { ); } + /** + * 영수증 월별 지출 계산 + */ + public List calculateExpense(List receiptList, int year) { + Map monthlyExpense = receiptList.stream() + .collect(Collectors.groupingBy( + receipt -> receipt.getDate().getMonthValue(), + Collectors.reducing(BigDecimal.ZERO, Receipt::getAmount, BigDecimal::add) + )); + + return IntStream.rangeClosed(1, 12) + .mapToObj(month -> DetailExpenseResult.of( + year, + month, + monthlyExpense.getOrDefault(month, BigDecimal.ZERO))) + .collect(Collectors.toList()); + } + private float ratio(Long count, int total) { return count == null ? 0f : (count * 100f / total); }