From e2d3afc58289e3770bc86c344c4f9293bf2ee004 Mon Sep 17 00:00:00 2001 From: jinnieusLab Date: Mon, 18 May 2026 15:44:37 +0900 Subject: [PATCH 1/4] =?UTF-8?q?:sparkles:=20feat:=20=EC=84=B1=EA=B3=BC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A7=80=ED=91=9C=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=84=EA=B5=90=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../timeline/domain/util/TimelineUtil.java | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) 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; + } } } From 4e500bdef0f4f8d5471fa94d08bf3756d2674868 Mon Sep 17 00:00:00 2001 From: jinnieusLab Date: Mon, 18 May 2026 15:48:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?:recycle:=20refactor:=20TimelineService=20?= =?UTF-8?q?=EB=82=B4=20=EA=B8=B0=EC=A1=B4=20=EC=84=B1=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B3=84=EC=82=B0=20=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/TimelineServiceImpl.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) 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..3256b699 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; @@ -72,15 +75,33 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId comparisonDates.end().atTime(LocalTime.MAX), orgId ); + if (!hasComparisonData) { 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 + ); + + MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, + comparisonDates.start().atStartOfDay(), + comparisonDates.end().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); // 엔티티 저장 및 반환 @@ -127,6 +148,23 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId 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 + ); + + MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( + orgId, + comparisonDates.start().atStartOfDay(), + comparisonDates.end().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 +179,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. 변환 후 반환 From 33781e048a5f30b52bfe15cb7bcce70abe52e5c6 Mon Sep 17 00:00:00 2001 From: jinnieusLab Date: Mon, 18 May 2026 17:12:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?:recycle:=20refactor:=20TimelineService=20?= =?UTF-8?q?=EB=82=B4=EC=84=B1=EA=B3=BC=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=A4=91=EB=B3=B5=20=EC=A1=B0=ED=9A=8C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/service/TimelineServiceImpl.java | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) 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 3256b699..609d4c64 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 @@ -69,18 +69,21 @@ 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, + Status.ON_GOING ); - if (!hasComparisonData) { + // 비교 기간에 성과 데이터가 없거나 모두 0이면 타임라인 생성 불가 + if (isProjectionEmpty(pastFacts)) { throw new TimelineException(TimelineErrorCode.TIMELINE_NO_COMPARISON_DATA); } - // 현재 기간 및 비교 기간 성과 합계 조회 (Projection 사용) + // 현재 기간 성과 합계 조회 (Projection 사용) MetricSumProjection currentFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( orgId, dto.startDate().atStartOfDay(), @@ -89,14 +92,6 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId Status.ON_GOING ); - MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( - orgId, - comparisonDates.start().atStartOfDay(), - comparisonDates.end().plusDays(1).atStartOfDay(), - OrgStatus.ACTIVE, - Status.ON_GOING - ); - // 입력받은 DTO를 타임라인 엔티티로 변환 Timeline timeline = TimelineConverter.toTimeline(dto, organization, userId, comparisonDates.start(), comparisonDates.end()); @@ -138,17 +133,21 @@ 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, + Status.ON_GOING ); - if (!hasComparisonData) { + + // 비교 기간에 성과 데이터가 없거나 모두 0이면 예외 처리 + if (isProjectionEmpty(pastFacts)) { throw new TimelineException(TimelineErrorCode.TIMELINE_NO_COMPARISON_DATA); } - // 현재 기간 및 비교 기간 성과 합계 조회 (Projection 사용) + // 현재 기간 성과 합계 조회 MetricSumProjection currentFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( orgId, dto.startDate().atStartOfDay(), @@ -157,14 +156,6 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId Status.ON_GOING ); - MetricSumProjection pastFacts = metricFactRepository.findMetricsSumByOrgIdAndDateRange( - orgId, - comparisonDates.start().atStartOfDay(), - comparisonDates.end().plusDays(1).atStartOfDay(), - OrgStatus.ACTIVE, - Status.ON_GOING - ); - // 8. 성과 리스트 -> boolean 플래그 변환 boolean useClick = dto.metrics().contains(MetricType.CLICK); boolean useConversion = dto.metrics().contains(MetricType.CONVERSION); @@ -389,4 +380,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); + } } From f2f4036e4c211a137513326c1f75c2552be8e28d Mon Sep 17 00:00:00 2001 From: jinnieusLab Date: Wed, 20 May 2026 02:03:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?:recycle:=20refactor:=20Timeline=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=20=EB=B9=84=EA=B5=90=ED=95=98=EB=8A=94=20=EA=B3=BC=EA=B1=B0=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=9D=98=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EC=A7=91=ED=96=89=20=EC=83=81=ED=83=9C=20=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MetricFactRepository.java | 20 +++++++++++++++++++ .../domain/service/TimelineServiceImpl.java | 6 ++---- 2 files changed, 22 insertions(+), 4 deletions(-) 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 609d4c64..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 @@ -74,8 +74,7 @@ public TimelineResponse.CreateResponseDTO createTimeline(Long userId, Long orgId orgId, comparisonDates.start().atStartOfDay(), comparisonDates.end().plusDays(1).atStartOfDay(), - OrgStatus.ACTIVE, - Status.ON_GOING + OrgStatus.ACTIVE ); // 비교 기간에 성과 데이터가 없거나 모두 0이면 타임라인 생성 불가 @@ -138,8 +137,7 @@ public TimelineResponse.CreateResponseDTO updateTimeline(Long userId, Long orgId orgId, comparisonDates.start().atStartOfDay(), comparisonDates.end().plusDays(1).atStartOfDay(), - OrgStatus.ACTIVE, - Status.ON_GOING + OrgStatus.ACTIVE ); // 비교 기간에 성과 데이터가 없거나 모두 0이면 예외 처리