Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import chaeso.zip.server.simulation.application.dto.SimulationCommand;
import chaeso.zip.server.simulation.application.dto.SimulationResponse;
import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface SimulationService {

Expand All @@ -23,4 +26,14 @@ public interface SimulationService {
* @return 저장된 결과. 없으면 {@link Optional#empty()}
*/
Optional<SimulationResponse> findLatest(UUID userId);

/**
* 사용자가 저장한 시뮬레이션 목록을 최신순으로 반환한다. 매체별 항목은 담지 않는다.
*/
Page<SimulationSummaryResponse> findMySimulations(UUID userId, Pageable pageable);

/**
* 저장된 시뮬레이션 하나를 매체별 항목까지 재계산 없이 그대로 반환한다.
*/
SimulationResponse findSimulation(UUID userId, UUID simulationId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import chaeso.zip.server.simulation.application.dto.SimulationCommand;
import chaeso.zip.server.simulation.application.dto.SimulationItemResponse;
import chaeso.zip.server.simulation.application.dto.SimulationResponse;
import chaeso.zip.server.simulation.application.dto.SimulationSummaryResponse;
import chaeso.zip.server.simulation.domain.SimulationNotFoundException;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulation;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem;
import chaeso.zip.server.simulation.domain.repository.BudgetSimulationItemRepository;
Expand All @@ -32,6 +34,8 @@
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -80,6 +84,41 @@ public Optional<SimulationResponse> findLatest(UUID userId) {
.map(this::restore);
}

@Override
public Page<SimulationSummaryResponse> findMySimulations(UUID userId, Pageable pageable) {
Page<BudgetSimulation> simulations =
budgetSimulationRepository.findByUserIdOrderByCreatedAtDescIdDesc(userId, pageable);

Map<UUID, List<BudgetSimulationItem>> itemsBySimulation = itemsOf(
simulations.getContent().stream().map(BudgetSimulation::getId).toList());
Map<UUID, String> channelNames = channelNames(itemsBySimulation.values().stream()
.flatMap(List::stream)
.map(BudgetSimulationItem::getChannelId)
.distinct()
.toList());

return simulations.map(simulation -> SimulationSummaryResponse.from(simulation,
itemsBySimulation.getOrDefault(simulation.getId(), List.of()), channelNames));
}

@Override
public SimulationResponse findSimulation(UUID userId, UUID simulationId) {
// 남의 것을 조회했을 때도 없는 것과 같은 404로 응답해 그 id 가 존재한다는 사실을 알려주지 않는다
return budgetSimulationRepository.findById(simulationId)
.filter(simulation -> simulation.getUserId().equals(userId))
.map(this::restore)
.orElseThrow(() -> new SimulationNotFoundException(simulationId));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private Map<UUID, List<BudgetSimulationItem>> itemsOf(List<UUID> simulationIds) {
if (simulationIds.isEmpty()) {
return Map.of();
}
return budgetSimulationItemRepository
.findByBudgetSimulationIdInOrderBySortOrderAsc(simulationIds).stream()
.collect(Collectors.groupingBy(BudgetSimulationItem::getBudgetSimulationId));
}

private SimulationResponse restore(BudgetSimulation simulation) {
List<BudgetSimulationItem> items = budgetSimulationItemRepository
.findByBudgetSimulationIdOrderBySortOrderAsc(simulation.getId());
Expand Down Expand Up @@ -171,6 +210,9 @@ private Map<UUID, Channel> loadChannels(List<UUID> channelIds) {
}

private Map<UUID, String> channelNames(List<UUID> channelIds) {
if (channelIds.isEmpty()) {
return Map.of();
}
return channelRepository.findAllById(channelIds).stream()
.collect(Collectors.toMap(Channel::getId, Channel::getName));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,46 @@
import chaeso.zip.server.estimation.domain.vo.EstimationResult;
import chaeso.zip.server.simulation.domain.BasisNote;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.UUID;

@Schema(description = "매체별 시뮬레이션 결과")
@JsonInclude(Include.NON_NULL)
public record SimulationItemResponse(
@Schema(description = "채널 id", requiredMode = Schema.RequiredMode.REQUIRED)
UUID channelId,
@Schema(description = "채널명", example = "11번가 광고", requiredMode = Schema.RequiredMode.REQUIRED)
String channelName,
@Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 null", nullable = true)
@Schema(description = "추정 근거가 된 대표 상품 id. 단가 정보가 없으면 생략",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
UUID channelProductId,
@Schema(description = "배분 예산(원). 0 은 미집행", example = "1000000",
requiredMode = Schema.RequiredMode.REQUIRED)
long allocatedBudgetWon,
@Schema(description = "전체 예산 대비 배분 비율(%)", example = "40", nullable = true)
@Schema(description = "전체 예산 대비 배분 비율(%)", example = "40",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
BigDecimal allocationPct,
@Schema(description = "추정 노출 수 범위. 추정 불가 시 생략", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
CountRangeResponse estImpressions,
@Schema(description = "추정 클릭 수 범위. 추정 불가 시 생략", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
CountRangeResponse estClicks,
@Schema(description = """
클릭당 비용(원). 클릭당 과금 매체는 단가 그대로, 그 외 매체는 \
배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 null""",
nullable = true)
배분 예산 / 예상 클릭 수(중앙값)로 환산한다. 예상 클릭이 없으면 생략""",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
BigDecimal cpcWon,
@Schema(description = """
1000회 노출당 단가(원). 대표 단가가 CPM 일 때만 채워진다. \
화면에는 쓰지 않고 어떤 단가로 추정했는지 남기는 값""", nullable = true)
화면에는 쓰지 않고 어떤 단가로 추정했는지 남기는 값""",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
BigDecimal cpmWon,
@Schema(description = "배분 예산으로 집행 가능한지 여부", requiredMode = Schema.RequiredMode.REQUIRED)
boolean isExecutable,
@Schema(description = "집행에 부족한 금액(원). 집행 가능하면 null", example = "500000",
nullable = true)
@Schema(description = "집행에 부족한 금액(원). 집행 가능하면 생략", example = "500000",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
Comment thread
1117mg marked this conversation as resolved.
Long shortfallWon,
@Schema(description = "산출 근거 고지", requiredMode = Schema.RequiredMode.REQUIRED)
String basisNote) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
@Schema(description = "예산 시뮬레이션 결과")
@JsonInclude(Include.NON_NULL)
public record SimulationResponse(
@Schema(description = "저장된 시뮬레이션 id", nullable = true)
@Schema(description = "저장된 시뮬레이션 id. 저장 전 계산 결과에는 생략된다",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
UUID simulationId,
@Schema(description = "총 예산(원)", example = "3000000",
requiredMode = Schema.RequiredMode.REQUIRED)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package chaeso.zip.server.simulation.application.dto;

import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulation;
import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

@Schema(description = "저장된 시뮬레이션 목록 요약. 매체별 상세는 상세 조회에서 받는다")
public record SimulationSummaryResponse(
@Schema(description = "저장된 시뮬레이션 id", requiredMode = Schema.RequiredMode.REQUIRED)
UUID simulationId,
@Schema(description = "저장 시각", requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(description = "총 예산(원)", example = "3000000",
requiredMode = Schema.RequiredMode.REQUIRED)
long totalBudgetWon,
@Schema(description = "집행 기간(온보딩과 같은 구간)", example = "M1",
requiredMode = Schema.RequiredMode.REQUIRED)
CampaignPeriod period,
@Schema(description = "저장 당시 추정 노출 수 합(범위 중앙값 기준)", example = "1150000",
requiredMode = Schema.RequiredMode.REQUIRED)
long totalEstImpressions,
@Schema(description = "저장 당시 추정 클릭 수 합(범위 중앙값 기준)", example = "23000",
requiredMode = Schema.RequiredMode.REQUIRED)
long totalEstClicks,
@Schema(description = "예산을 배분한 매체 개수", example = "3",
requiredMode = Schema.RequiredMode.REQUIRED)
int channelCount,
@Schema(description = "집행 가능한 매체 개수", example = "2",
requiredMode = Schema.RequiredMode.REQUIRED)
int executableChannelCount,
@Schema(description = "어떤 조합이었는지 알아볼 수 있게 예산을 배분한 매체명만 최대 3개 보여 준다. null 이 아닌 배열",
requiredMode = Schema.RequiredMode.REQUIRED)
List<String> channelNames) {

/** 목록에서 조합을 알아볼 정도로만 보여 주는 대표 매체 수 */
private static final int PREVIEW_CHANNEL_NAMES = 3;

/**
* 스냅샷에는 사용자가 담아만 두고 예산을 주지 않은 매체(배분 0원)도 상세 화면을 그대로 재현하려고
* 남아 있다. 목록의 매체 수와 대표 매체명은 실제로 예산을 배분한 매체만 센다.
*/
public static SimulationSummaryResponse from(BudgetSimulation simulation,
List<BudgetSimulationItem> items, Map<UUID, String> channelNames) {
List<BudgetSimulationItem> allocated = items.stream()
.filter(item -> item.getAllocatedBudgetWon() > 0)
.toList();
return new SimulationSummaryResponse(
simulation.getId(),
simulation.getCreatedAt(),
simulation.getTotalBudgetWon(),
simulation.getPeriod(),
simulation.getTotalEstImpressions(),
simulation.getTotalEstClicks(),
allocated.size(),
(int) allocated.stream().filter(BudgetSimulationItem::isExecutable).count(),
allocated.stream()
.map(item -> channelNames.get(item.getChannelId()))
.filter(Objects::nonNull)
.limit(PREVIEW_CHANNEL_NAMES)
.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package chaeso.zip.server.simulation.domain;

import chaeso.zip.server.common.exception.ErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum SimulationErrorCode implements ErrorCode {

SIMULATION_NOT_FOUND(HttpStatus.NOT_FOUND, "SIM-001", "존재하지 않는 시뮬레이션입니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package chaeso.zip.server.simulation.domain;

import chaeso.zip.server.common.exception.BusinessException;
import java.util.UUID;

public class SimulationNotFoundException extends BusinessException {

public SimulationNotFoundException(UUID simulationId) {
super(SimulationErrorCode.SIMULATION_NOT_FOUND,
"존재하지 않는 시뮬레이션입니다. id=" + simulationId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public class BudgetSimulationItem {
@Column(name = "channel_id", nullable = false)
private UUID channelId;

/** 대표로 선택된 상품. 단가 정보가 있는 상품이 없으면 null */
/** 대표로 선택된 상품 */
@Column(name = "channel_product_id")
private UUID channelProductId;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package chaeso.zip.server.simulation.domain.repository;

import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -9,4 +10,7 @@ public interface BudgetSimulationItemRepository
extends JpaRepository<BudgetSimulationItem, UUID> {

List<BudgetSimulationItem> findByBudgetSimulationIdOrderBySortOrderAsc(UUID budgetSimulationId);

List<BudgetSimulationItem> findByBudgetSimulationIdInOrderBySortOrderAsc(
Collection<UUID> budgetSimulationIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import chaeso.zip.server.simulation.domain.entity.BudgetSimulation;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BudgetSimulationRepository extends JpaRepository<BudgetSimulation, UUID> {

Optional<BudgetSimulation> findFirstByUserIdOrderByCreatedAtDescIdDesc(UUID userId);

Page<BudgetSimulation> findByUserIdOrderByCreatedAtDescIdDesc(UUID userId, Pageable pageable);
}
Loading
Loading