diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java index 5b59b2b4..a4d18310 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/advertisement/persistence/repository/MetricFactRepository.java @@ -60,6 +60,26 @@ MetricSumProjection findMetricsSumByOrgIdAndDateRange( @Param("status") Status status ); + // orgId에 속한 모든 프로젝트의 지표중 해당 MetricFact 가 속한 AdCampaign 지표를 지정된 기간 범위 합산 (status 고려 x) + @Query("SELECT " + + "COALESCE(SUM(m.impressions), 0) AS totalImpressions, " + + "COALESCE(SUM(m.clicks), 0) AS totalClicks, " + + "COALESCE(SUM(m.conversions), 0) AS totalConversions, " + + "COALESCE(SUM(m.spend), 0) AS totalSpend, " + + "COALESCE(SUM(m.revenue), 0) AS totalRevenue " + + "FROM MetricFact m " + + "JOIN m.project p " + + "WHERE p.organization.id = :orgId " + + "AND p.organization.status = :orgStatus " + + "AND m.timeBucket >= :startDate AND m.timeBucket < :endDate " + ) + MetricSumProjection findMetricsSumByOrgIdAndDateRange( + @Param("orgId") Long orgId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + @Param("orgStatus") OrgStatus orgStatus + ); + //orgId 와 provider 가 일치하고 해당 MetricFact 가 속한 AdCampaign 의 status 가 ON_GOING 인 지표에 대해 지정된 기간 범위 합산 @Query("SELECT " + "COALESCE(SUM(m.impressions), 0) AS totalImpressions, " + diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java index e6907f23..4228b1b7 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/service/TimelineServiceImpl.java @@ -1,6 +1,8 @@ package com.whereyouad.WhereYouAd.domains.timeline.domain.service; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.MetricSumProjection; import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgRole; +import com.whereyouad.WhereYouAd.domains.organization.domain.constant.OrgStatus; import com.whereyouad.WhereYouAd.domains.organization.exception.code.OrgErrorCode; import com.whereyouad.WhereYouAd.domains.organization.exception.handler.OrgHandler; import com.whereyouad.WhereYouAd.domains.organization.persistence.entity.OrgMember; @@ -18,6 +20,7 @@ import com.whereyouad.WhereYouAd.domains.timeline.exception.code.TimelineErrorCode; import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline; import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Grain; +import com.whereyouad.WhereYouAd.domains.advertisement.domain.constant.Status; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.entity.MetricFact; import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.MetricFactRepository; import com.whereyouad.WhereYouAd.domains.timeline.persistence.repository.TimelineRepository; @@ -66,21 +69,33 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId // 비교 기준 날짜(지난 주, 지난 달, 지난 년도와 비교) ComparisonDateRange comparisonDates = calculateComparisonDates(dto.startDate(), dto.endDate(), dto.comparisonPeriodType()); - // 비교 기간에 성과 데이터가 없으면 타임라인 생성 불가 - boolean hasComparisonData = metricFactRepository.existsByTimeBucketBetweenAndOrg( + // 비교 기간 성과 합계 조회 (Projection 사용) + MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, comparisonDates.start().atStartOfDay(), - comparisonDates.end().atTime(LocalTime.MAX), - orgId + comparisonDates.end().plusDays(1).atStartOfDay(), + OrgStatus.ACTIVE ); - if (!hasComparisonData) { + + // 비교 기간에 성과 데이터가 없거나 모두 0이면 타임라인 생성 불가 + if (isProjectionEmpty(pastFacts)) { throw new TimelineException(TimelineErrorCode.TIMELINE_NO_COMPARISON_DATA); } + // 현재 기간 성과 합계 조회 (Projection 사용) + MetricSumProjection currentFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, + dto.startDate().atStartOfDay(), + dto.endDate().plusDays(1).atStartOfDay(), + OrgStatus.ACTIVE, + Status.ON_GOING + ); + // 입력받은 DTO를 타임라인 엔티티로 변환 Timeline timeline = TimelineConverter.toTimeline(dto, organization, userId, comparisonDates.start(), comparisonDates.end()); - // PerformanceStatus 계산 및 판별 로직 호출 및 저장 - PerformanceStatus status = timelineUtil.calculatePerformanceStatus(timeline); + // 성과 상태 - PerformanceStatus 계산 및 판별 로직 호출 및 저장 (초안) + PerformanceStatus status = timelineUtil.calculatePerformanceStatus(timeline, currentFacts, pastFacts); timeline.updatePerformanceStatus(status); // 엔티티 저장 및 반환 @@ -117,16 +132,28 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId // 6. 비교 기준 날짜 재계산 ComparisonDateRange comparisonDates = calculateComparisonDates(dto.startDate(), dto.endDate(), dto.comparisonPeriodType()); - // 7. 비교 기간 성과 데이터 존재 검증 - boolean hasComparisonData = metricFactRepository.existsByTimeBucketBetweenAndOrg( + // 7. 비교 기간 성과 합계 조회 (Projection 사용) + MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, comparisonDates.start().atStartOfDay(), - comparisonDates.end().atTime(LocalTime.MAX), - orgId + comparisonDates.end().plusDays(1).atStartOfDay(), + OrgStatus.ACTIVE ); - if (!hasComparisonData) { + + // 비교 기간에 성과 데이터가 없거나 모두 0이면 예외 처리 + if (isProjectionEmpty(pastFacts)) { throw new TimelineException(TimelineErrorCode.TIMELINE_NO_COMPARISON_DATA); } + // 현재 기간 성과 합계 조회 + MetricSumProjection currentFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, + dto.startDate().atStartOfDay(), + dto.endDate().plusDays(1).atStartOfDay(), + OrgStatus.ACTIVE, + Status.ON_GOING + ); + // 8. 성과 리스트 -> boolean 플래그 변환 boolean useClick = dto.metrics().contains(MetricType.CLICK); boolean useConversion = dto.metrics().contains(MetricType.CONVERSION); @@ -141,7 +168,7 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId timeline.updateSummary(null); // 10. PerformanceStatus 재계산 - PerformanceStatus status = timelineUtil.calculatePerformanceStatus(timeline); + PerformanceStatus status = timelineUtil.calculatePerformanceStatus(timeline, currentFacts, pastFacts); timeline.updatePerformanceStatus(status); // 11. 변환 후 반환 @@ -351,4 +378,14 @@ private ComparisonDateRange calculateComparisonDates(LocalDate startDate, LocalD case LAST_YEAR -> new ComparisonDateRange(startDate.minusYears(1), endDate.minusYears(1)); }; } + + // Projection 결과가 비어있는지(또는 모든 수치가 0인지) 확인하는 헬퍼 메서드 + private boolean isProjectionEmpty(MetricSumProjection projection) { + if (projection == null) return true; + return (projection.getTotalImpressions() == null || projection.getTotalImpressions() == 0L) && + (projection.getTotalClicks() == null || projection.getTotalClicks() == 0L) && + (projection.getTotalConversions() == null || projection.getTotalConversions() == 0L) && + (projection.getTotalSpend() == null || projection.getTotalSpend().compareTo(BigDecimal.ZERO) == 0) && + (projection.getTotalRevenue() == null || projection.getTotalRevenue().compareTo(BigDecimal.ZERO) == 0); + } } diff --git a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/util/TimelineUtil.java b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/util/TimelineUtil.java index 1f9af945..555c1c1a 100644 --- a/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/util/TimelineUtil.java +++ b/src/main/java/com/whereyouad/WhereYouAd/domains/timeline/domain/util/TimelineUtil.java @@ -1,13 +1,90 @@ package com.whereyouad.WhereYouAd.domains.timeline.domain.util; +import com.whereyouad.WhereYouAd.domains.advertisement.persistence.repository.projection.MetricSumProjection; import com.whereyouad.WhereYouAd.domains.timeline.domain.constant.PerformanceStatus; import com.whereyouad.WhereYouAd.domains.timeline.persistence.entity.Timeline; import org.springframework.stereotype.Component; +import java.math.BigDecimal; +import java.math.RoundingMode; + @Component public class TimelineUtil { - // TODO: PerformanceStatus 판별 로직 추가 - public PerformanceStatus calculatePerformanceStatus(Timeline timeline) { - return null; + + // 성과 상태 판별 로직 초안 + public PerformanceStatus calculatePerformanceStatus(Timeline timeline, MetricSumProjection currentFacts, MetricSumProjection pastFacts) { + + // 데이터가 아예 없는 경우 방어 로직 (Projection은 SUM이 없으면 0을 반환하도록 COALESCE 되어있음) + if (currentFacts == null || pastFacts == null) { + return PerformanceStatus.ON_TRACK; + } + + double totalRate = 0.0; + int activeMetricCount = 0; + + // 1. 클릭수 비교 + if (timeline.isUseClick()) { + long currentClicks = currentFacts.getTotalClicks() != null ? currentFacts.getTotalClicks() : 0L; + long pastClicks = pastFacts.getTotalClicks() != null ? pastFacts.getTotalClicks() : 0L; + + if (pastClicks > 0) { + totalRate += (double) currentClicks / pastClicks; + activeMetricCount++; + } + } + + // 2. 전환수 비교 + if (timeline.isUseConversion()) { + long currentConv = currentFacts.getTotalConversions() != null ? currentFacts.getTotalConversions() : 0L; + long pastConv = pastFacts.getTotalConversions() != null ? pastFacts.getTotalConversions() : 0L; + + if (pastConv > 0) { + totalRate += (double) currentConv / pastConv; + activeMetricCount++; + } + } + + // 3. 노출수 비교 + if (timeline.isUseImpression()) { + long currentImp = currentFacts.getTotalImpressions() != null ? currentFacts.getTotalImpressions() : 0L; + long pastImp = pastFacts.getTotalImpressions() != null ? pastFacts.getTotalImpressions() : 0L; + + if (pastImp > 0) { + totalRate += (double) currentImp / pastImp; + activeMetricCount++; + } + } + + // 4. ROAS 비교 + if (timeline.isUseRoas()) { + BigDecimal currentSpend = currentFacts.getTotalSpend() != null ? currentFacts.getTotalSpend() : BigDecimal.ZERO; + BigDecimal currentRev = currentFacts.getTotalRevenue() != null ? currentFacts.getTotalRevenue() : BigDecimal.ZERO; + BigDecimal currentRoas = currentSpend.compareTo(BigDecimal.ZERO) > 0 ? currentRev.divide(currentSpend, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO; + + BigDecimal pastSpend = pastFacts.getTotalSpend() != null ? pastFacts.getTotalSpend() : BigDecimal.ZERO; + BigDecimal pastRev = pastFacts.getTotalRevenue() != null ? pastFacts.getTotalRevenue() : BigDecimal.ZERO; + BigDecimal pastRoas = pastSpend.compareTo(BigDecimal.ZERO) > 0 ? pastRev.divide(pastSpend, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO; + + if (pastRoas.compareTo(BigDecimal.ZERO) > 0) { + totalRate += currentRoas.divide(pastRoas, 4, RoundingMode.HALF_UP).doubleValue(); + activeMetricCount++; + } + } + + // 비교할 유효 지표가 없는 경우 + if (activeMetricCount == 0) { + return PerformanceStatus.ON_TRACK; + } + + double avgRate = totalRate / activeMetricCount; + + // 초안: 10% 이상 상승하면 ABOVE_AVG, 10% 이상 하락하면 UNDERPERFORM, 그 외 ON_TRACK + if (avgRate >= 1.1) { + return PerformanceStatus.ABOVE_AVG; + } else if (avgRate <= 0.9) { + return PerformanceStatus.UNDERPERFORM; + } else { + return PerformanceStatus.ON_TRACK; + } } }