diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/ActionPlan.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/ActionPlan.java index 2cf1d96..2a24702 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/ActionPlan.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/domain/ActionPlan.java @@ -21,6 +21,7 @@ public class ActionPlan { private Long id; private Long storeId; private Long userId; + private Long feedbackId; private String title; private String description; private String period; diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/ActionPlanService.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/ActionPlanService.java index 271da07..e4932fd 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/ActionPlanService.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/ActionPlanService.java @@ -41,6 +41,7 @@ public class ActionPlanService implements ActionPlanUseCase { .map(plan -> ActionPlanListResponse.builder() .id(plan.getId()) .title(plan.getTitle()) + .description(plan.getDescription()) .status(plan.getStatus()) .period(plan.getPeriod()) .createdAt(plan.getCreatedAt()) diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java index 3caffa1..3a2ceeb 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java @@ -115,7 +115,8 @@ public class AnalyticsService implements AnalyticsUseCase { // 2. AI 피드백이 없으면 새로 생성 aiFeedback = Optional.of(generateAIFeedback(storeId)); } - + + // 3. 응답 생성 AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder() .feedbackId(aiFeedback.get().getId()) @@ -128,6 +129,11 @@ public class AnalyticsService implements AnalyticsUseCase { .confidenceScore(aiFeedback.get().getConfidenceScore()) .generatedAt(aiFeedback.get().getGeneratedAt()) .build(); + + //(추가) 실행계획을 조회해서, 이미 생성된 improvementPoints인지 판단 + List actionPlanTitleList = actionPlanPort.findActionPlanTitleByFeedbackId(aiFeedback.get().getId()); + log.info("실행계획 확인 => {}", actionPlanTitleList.toString()); + response.updateImprovementCheck(actionPlanTitleList); //이미 생성된 실행계획 추가. log.info("AI 피드백 상세 조회 완료: storeId={}", storeId); return response; @@ -444,9 +450,10 @@ public class AnalyticsService implements AnalyticsUseCase { // 2. 실행계획 생성 (요청 시) List actionPlans = null; - if (Boolean.TRUE.equals(request.getGenerateActionPlan())) { - actionPlans = aiServicePort.generateActionPlan(aiFeedback); - } + //TODO : 추후에 AI 분석후에 바로 실행계획까지 생성해야 한다면 추가. + // if (Boolean.TRUE.equals(request.getGenerateActionPlan())) { + // actionPlans = aiServicePort.generateActionPlan(aiFeedback); + // } // 3. 응답 생성 AiAnalysisResponse response = AiAnalysisResponse.builder() @@ -459,7 +466,7 @@ public class AnalyticsService implements AnalyticsUseCase { .sentimentAnalysis(aiFeedback.getSentimentAnalysis()) .confidenceScore(aiFeedback.getConfidenceScore()) .totalReviewsAnalyzed(getTotalReviewsCount(storeId, request.getDays())) - .actionPlans(actionPlans) + .actionPlans(actionPlans) //TODO : 사용하는 값은 아니지만 의존성을 위해 그대로 둠, 추후에 변경 필요. .analyzedAt(aiFeedback.getGeneratedAt()) .build(); @@ -474,7 +481,7 @@ public class AnalyticsService implements AnalyticsUseCase { @Override @Transactional - public List generateActionPlansFromFeedback(Long feedbackId) { + public List generateActionPlansFromFeedback(ActionPlanCreateRequest request, Long feedbackId) { log.info("실행계획 생성: feedbackId={}", feedbackId); try { @@ -487,11 +494,11 @@ public class AnalyticsService implements AnalyticsUseCase { AiFeedback feedback = aiFeedback.get(); // 2. 기존 AIServicePort.generateActionPlan 메서드 활용 - List actionPlans = aiServicePort.generateActionPlan(aiFeedback.get()); + List actionPlans = aiServicePort.generateActionPlan(request.getActionPlanSelect(), aiFeedback.get()); // 3. DB에 실행계획 저장 - saveGeneratedActionPlansToDatabase(feedback, actionPlans); + saveGeneratedActionPlansToDatabase(request.getActionPlanSelect(), feedback, actionPlans); log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size()); return actionPlans; @@ -578,7 +585,7 @@ public class AnalyticsService implements AnalyticsUseCase { * 생성된 실행계획을 데이터베이스에 저장하는 메서드 * AI 피드백 기반으로 생성된 실행계획들을 ActionPlan 테이블에 저장 */ - private void saveGeneratedActionPlansToDatabase(AiFeedback feedback, List actionPlans) { + private void saveGeneratedActionPlansToDatabase(List actionPlanSelect, AiFeedback feedback, List actionPlans) { if (actionPlans.isEmpty()) { log.info("저장할 실행계획이 없습니다: storeId={}", feedback.getStoreId()); return; @@ -593,8 +600,9 @@ public class AnalyticsService implements AnalyticsUseCase { // ActionPlan 도메인 객체 생성 (기존 ActionPlanService의 패턴과 동일하게) ActionPlan actionPlan = ActionPlan.builder() .storeId(feedback.getStoreId()) - .userId(1L) // AI가 생성한 계획이므로 userId는 null - .title("AI 추천 실행계획 " + (i + 1)) + .userId(0L) // AI가 생성한 계획이므로 userId는 0 + .feedbackId(feedback.getId()) + .title(actionPlanSelect.get(i)) .description(planContent) .period("1개월") // 기본 실행 기간 .status(PlanStatus.PLANNED) diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java index eb13d03..b32ed09 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java @@ -44,6 +44,6 @@ public interface AnalyticsUseCase { /** * AI 피드백 기반 실행계획 생성 */ - List generateActionPlansFromFeedback(Long feedbackId); + List generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId); } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java index baafc08..30d4264 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/AIServicePort.java @@ -24,5 +24,5 @@ public interface AIServicePort { /** * 실행 계획 생성 */ - List generateActionPlan(AiFeedback feedback); + List generateActionPlan(List actionPlanSelect, AiFeedback feedback); } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ActionPlanPort.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ActionPlanPort.java index c32556a..89c73f1 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ActionPlanPort.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/out/ActionPlanPort.java @@ -30,4 +30,9 @@ public interface ActionPlanPort { * 실행 계획 삭제 */ void deleteActionPlan(Long planId); + + /** + * 피드백 id로 실행계획 title 조회 + */ + List findActionPlanTitleByFeedbackId(Long feedbackId); } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java index 0210373..14fbb4d 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java @@ -145,14 +145,17 @@ public class AnalyticsController { */ @Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.") @PostMapping("/ai-feedback/{feedbackId}/action-plans") - public ResponseEntity>> generateActionPlans( + public ResponseEntity> generateActionPlans( @Parameter(description = "AI 피드백 ID", required = true) - @PathVariable @NotNull Long feedbackId) { + @PathVariable @NotNull Long feedbackId, + @RequestBody ActionPlanCreateRequest request) { log.info("실행계획 생성 요청: feedbackId={}", feedbackId); - List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(feedbackId); + log.info("실행계획 바디 데이터 => {}", request); - return ResponseEntity.ok(SuccessResponse.of(actionPlans, "실행계획 생성 완료")); + List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(request,feedbackId); + + return ResponseEntity.ok(SuccessResponse.of("실행계획 생성 완료")); } } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCreateRequest.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCreateRequest.java new file mode 100644 index 0000000..0b8dc0c --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanCreateRequest.java @@ -0,0 +1,22 @@ +package com.ktds.hi.analytics.infra.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +/** + * 실행계획 생성요청 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class ActionPlanCreateRequest { + + private List actionPlanSelect; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanListResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanListResponse.java index 19b3c3d..d9e237b 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanListResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/ActionPlanListResponse.java @@ -20,6 +20,7 @@ public class ActionPlanListResponse { private Long id; private String title; private PlanStatus status; + private String description; private String period; private LocalDateTime createdAt; private LocalDateTime completedAt; diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java index edefea4..f0e9080 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiFeedbackDetailResponse.java @@ -7,6 +7,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * AI 피드백 상세 응답 DTO @@ -22,8 +24,23 @@ public class AiFeedbackDetailResponse { private String summary; private List positivePoints; private List improvementPoints; + private List existActionPlan; // improvemnetPoints 중에서 처리 된것. private List recommendations; private String sentimentAnalysis; private Double confidenceScore; private LocalDateTime generatedAt; + + + public void updateImprovementCheck(List actionPlanTitle){ + Set trimmedTitles = actionPlanTitle.stream() + .map(String::trim) + .collect(Collectors.toSet()); + + this.existActionPlan = + improvementPoints.stream() + .map(String::trim) + .filter(point -> trimmedTitles.stream() + .anyMatch(title -> title.contains(point))) + .toList(); + } } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderListResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderListResponse.java new file mode 100644 index 0000000..7e13bed --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderListResponse.java @@ -0,0 +1,19 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderListResponse { + private Long storeId; + private Integer totalCount; + private List orders; +} + diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderResponse.java new file mode 100644 index 0000000..8467107 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/OrderResponse.java @@ -0,0 +1,24 @@ +package com.ktds.hi.analytics.infra.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderResponse { + private Long id; + private Long storeId; + private Long menuId; + private Integer customerAge; + private String customerGender; + private BigDecimal orderAmount; + private LocalDateTime orderDate; + private LocalDateTime createdAt; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java index 978354b..b79ff67 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java @@ -125,25 +125,30 @@ public class AIServiceAdapter implements AIServicePort { } @Override - public List generateActionPlan(AiFeedback feedback) { + public List generateActionPlan(List actionPlanSelect, AiFeedback feedback) { log.info("OpenAI 실행 계획 생성 시작"); - try { + + StringBuffer planFormat = new StringBuffer(); + for(int i = 1; i <= actionPlanSelect.size(); i++) { + planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n"); + } String prompt = String.format( """ - 다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요. + 다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요. 각 계획은 실행 가능하고 구체적이어야 합니다. 요약: %s 개선점: %s + 실행계획 내용은 점주가 반영할 수 있도록 구체적어야 합니다. 실행 계획을 다음 형식으로 작성해주세요: - 1. [구체적인 실행 계획 1] - 2. [구체적인 실행 계획 2] - 3. [구체적인 실행 계획 3] + %s """, + actionPlanSelect.size(), feedback.getSummary(), - String.join(", ", feedback.getImprovementPoints()) + String.join(", ", actionPlanSelect), + planFormat ); String result = callOpenAI(prompt); @@ -151,10 +156,9 @@ public class AIServiceAdapter implements AIServicePort { } catch (Exception e) { log.error("OpenAI 실행 계획 생성 중 오류 발생", e); + //TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯. return Arrays.asList( - "서비스 품질 개선을 위한 직원 교육 실시", - "고객 피드백 수집 체계 구축", - "매장 운영 프로세스 개선" + "실행계획 생성 실패. 재생성 필요" ); } } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java index 12f06af..2ca3951 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/ActionPlanRepositoryAdapter.java @@ -52,7 +52,12 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort { public void deleteActionPlan(Long planId) { actionPlanJpaRepository.deleteById(planId); } - + + @Override + public List findActionPlanTitleByFeedbackId(Long feedbackId) { + return actionPlanJpaRepository.findActionPlanTitleByFeedbackId(feedbackId); + } + /** * Entity를 Domain으로 변환 */ @@ -79,6 +84,7 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort { private ActionPlanEntity toEntity(ActionPlan domain) { return ActionPlanEntity.builder() .id(domain.getId()) + .feedbackId(domain.getFeedbackId()) .storeId(domain.getStoreId()) .userId(domain.getUserId()) .title(domain.getTitle()) diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/OrderDataAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/OrderDataAdapter.java index b516cb2..aeb4c47 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/OrderDataAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/OrderDataAdapter.java @@ -2,16 +2,27 @@ package com.ktds.hi.analytics.infra.gateway; import com.ktds.hi.analytics.biz.domain.OrderStatistics; import com.ktds.hi.analytics.biz.usecase.out.OrderDataPort; +import com.ktds.hi.analytics.infra.dto.OrderListResponse; +import com.ktds.hi.analytics.infra.dto.OrderResponse; +import com.ktds.hi.common.dto.SuccessResponse; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 주문 데이터 어댑터 클래스 @@ -32,14 +43,32 @@ public class OrderDataAdapter implements OrderDataPort { log.info("주문 통계 조회: storeId={}, period={} ~ {}", storeId, startDate, endDate); try { - String url = String.format("%s/api/orders/stores/%d/statistics?startDate=%s&endDate=%s", - storeServiceUrl, storeId, startDate, endDate); - - OrderStatistics statistics = restTemplate.getForObject(url, OrderStatistics.class); - - if (statistics != null) { - log.info("주문 통계 조회 완료: storeId={}, totalOrders={}", - storeId, statistics.getTotalOrders()); + + // LocalDate를 LocalDateTime으로 변환 (시작일은 00:00:00, 종료일은 23:59:59) + LocalDateTime startDateTime = startDate.atStartOfDay(); + LocalDateTime endDateTime = endDate.atTime(23, 59, 59); + + String url = String.format("%s/api/stores/orders/store/%d/period?startDate=%s&endDate=%s", + storeServiceUrl, storeId, startDateTime, endDateTime); + + + // ParameterizedTypeReference를 사용하여 제네릭 타입 안전하게 파싱 + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference>() {}; + + ResponseEntity> responseEntity = + restTemplate.exchange(url, HttpMethod.GET, null, responseType); + + SuccessResponse successResponse = responseEntity.getBody(); + + if (successResponse != null && successResponse.isSuccess() && successResponse.getData() != null) { + OrderListResponse orderListResponse = successResponse.getData(); + + // OrderListResponse를 OrderStatistics로 변환 + OrderStatistics statistics = convertToOrderStatistics(orderListResponse); + + log.info("주문 통계 조회 완료: storeId={}, totalOrders={}", + storeId, statistics.getTotalOrders()); return statistics; } @@ -50,6 +79,125 @@ public class OrderDataAdapter implements OrderDataPort { // 실패 시 더미 데이터 반환 return createDummyOrderStatistics(storeId); } + + /** + * OrderListResponse를 OrderStatistics로 변환 + */ + private OrderStatistics convertToOrderStatistics(OrderListResponse orderListResponse) { + List orders = orderListResponse.getOrders(); + + if (orders == null || orders.isEmpty()) { + return createEmptyOrderStatistics(); + } + + // 총 주문 수 + int totalOrders = orders.size(); + + // 총 매출 계산 + BigDecimal totalRevenue = orders.stream() + .map(OrderResponse::getOrderAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 평균 주문 금액 계산 + double averageOrderValue = totalRevenue.doubleValue() / totalOrders; + + // 시간대별 주문 분석 (피크 시간 계산) + Map hourlyOrderCount = orders.stream() + .collect(Collectors.groupingBy( + order -> order.getOrderDate().getHour(), + Collectors.counting() + )); + + int peakHour = hourlyOrderCount.entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey) + .orElse(12); // 기본값 12시 + + // 연령대별 분포 계산 + Map ageDistribution = calculateCustomerAgeDistribution(orders); + + // 인기 메뉴 계산 (메뉴ID별 주문 횟수) - 실제로는 메뉴명을 가져와야 하지만 임시로 메뉴ID 사용 + List popularMenus = orders.stream() + .collect(Collectors.groupingBy( + OrderResponse::getMenuId, + Collectors.counting() + )) + .entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(4) + .map(entry -> "메뉴" + entry.getKey()) // 실제로는 메뉴명으로 변환 필요 + .collect(Collectors.toList()); + + return OrderStatistics.builder() + .totalOrders(totalOrders) + .totalRevenue(totalRevenue.longValue()) + .averageOrderValue(averageOrderValue) + .peakHour(peakHour) + .popularMenus(popularMenus) + .customerAgeDistribution(ageDistribution) + .build(); + } + + /** + * 연령대별 분포 계산 + */ + private Map calculateCustomerAgeDistribution(List orders) { + Map ageGroupCount = orders.stream() + .collect(Collectors.groupingBy( + order -> getAgeGroup(order.getCustomerAge()), + Collectors.counting() + )); + + // Long을 Integer로 변환 + Map result = new HashMap<>(); + ageGroupCount.forEach((key, value) -> result.put(key, value.intValue())); + + return result; + } + + /** + * 연령대별 분포 계산 + */ + private Map calculateAgeDistribution(List orders) { + Map ageGroupCount = orders.stream() + .collect(Collectors.groupingBy( + order -> getAgeGroup(order.getCustomerAge()), + Collectors.counting() + )); + + // Long을 Integer로 변환 + Map result = new HashMap<>(); + ageGroupCount.forEach((key, value) -> result.put(key, value.intValue())); + + return result; + } + + /** + * 나이를 연령대 그룹으로 변환 + */ + private String getAgeGroup(Integer age) { + if (age == null) return "미분류"; + if (age < 20) return "10대"; + if (age < 30) return "20대"; + if (age < 40) return "30대"; + if (age < 50) return "40대"; + if (age < 60) return "50대"; + return "60대+"; + } + + /** + * 빈 주문 통계 생성 + */ + private OrderStatistics createEmptyOrderStatistics() { + return OrderStatistics.builder() + .totalOrders(0) + .totalRevenue(0L) + .averageOrderValue(0.0) + .peakHour(12) + .popularMenus(Arrays.asList()) + .customerAgeDistribution(new HashMap<>()) + .build(); + } @Override public Integer getCurrentOrderCount(Long storeId) { diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java index f3a253b..39a65a6 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/entity/ActionPlanEntity.java @@ -49,7 +49,10 @@ public class ActionPlanEntity { @Column(name = "user_id", nullable = false) private Long userId; - + + @Column(name = "feedback_id", nullable = true) + private Long feedbackId; + @Column(name = "title", nullable = false, length = 100) private String title; diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java index 4b0a8d8..d486110 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/repository/ActionPlanJpaRepository.java @@ -3,10 +3,13 @@ package com.ktds.hi.analytics.infra.gateway.repository; import com.ktds.hi.analytics.biz.domain.PlanStatus; import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; +import io.lettuce.core.dynamic.annotation.Param; + /** * 실행 계획 JPA 리포지토리 인터페이스 * 실행 계획 데이터의 CRUD 작업을 담당 @@ -33,4 +36,10 @@ public interface ActionPlanJpaRepository extends JpaRepository findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId); + + /** + * 피드백 id로 실행계획 title 조회 + */ + @Query("SELECT a.title FROM ActionPlanEntity a WHERE a.feedbackId = :feedbackId") + List findActionPlanTitleByFeedbackId(@Param("feedbackId")Long feedbackId); } diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..5c686cf Binary files /dev/null and b/dump.rdb differ diff --git a/member/src/main/java/com/ktds/hi/member/repository/jpa/TagRepository.java b/member/src/main/java/com/ktds/hi/member/repository/jpa/TagRepository.java new file mode 100644 index 0000000..5e8b7da --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/repository/jpa/TagRepository.java @@ -0,0 +1,48 @@ +// member/src/main/java/com/ktds/hi/member/repository/jpa/TagRepository.java +package com.ktds.hi.member.repository.jpa; + +import com.ktds.hi.member.repository.entity.TagEntity; +import com.ktds.hi.member.repository.entity.TagCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 태그 정보 JPA 리포지토리 인터페이스 + * 태그 정보 데이터의 CRUD 작업을 담당 + */ +@Repository +public interface TagRepository extends JpaRepository { + + /** + * 태그명 목록으로 활성 태그 조회 + */ + List findByTagNameInAndIsActiveTrue(List tagNames); + + /** + * 활성 태그 전체 조회 (카테고리, 정렬순서별 정렬) + */ + List findByIsActiveTrueOrderByTagCategoryAscSortOrderAsc(); + + /** + * 카테고리별 활성 태그 조회 (정렬순서별 정렬) + */ + List findByTagCategoryAndIsActiveTrueOrderBySortOrderAsc(TagCategory tagCategory); + + /** + * 태그명으로 태그 조회 + */ + Optional findByTagNameAndIsActiveTrue(String tagName); + + /** + * 카테고리별 태그 개수 조회 + */ + Long countByTagCategoryAndIsActiveTrue(TagCategory tagCategory); + + /** + * 활성 태그 존재 여부 확인 + */ + boolean existsByTagNameAndIsActiveTrue(String tagName); +} \ No newline at end of file diff --git a/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java b/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java new file mode 100644 index 0000000..d8206f9 --- /dev/null +++ b/member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java @@ -0,0 +1,116 @@ +// member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java +package com.ktds.hi.member.service; + +import com.ktds.hi.member.dto.PreferenceRequest; +import com.ktds.hi.member.dto.TasteTagResponse; +import com.ktds.hi.member.domain.TagType; +import com.ktds.hi.member.repository.entity.PreferenceEntity; +import com.ktds.hi.member.repository.entity.TagEntity; +import com.ktds.hi.member.repository.entity.TagCategory; +import com.ktds.hi.member.repository.jpa.PreferenceRepository; +import com.ktds.hi.member.repository.jpa.TagRepository; +import com.ktds.hi.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 취향 관리 서비스 구현체 + * 취향 정보 등록/수정 및 태그 관리 기능을 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class PreferenceServiceImpl implements PreferenceService { + + private final PreferenceRepository preferenceRepository; + private final TagRepository tagRepository; + + @Override + public void savePreference(Long memberId, PreferenceRequest request) { + // 태그 유효성 검증 + List existingTags = tagRepository.findByTagNameInAndIsActiveTrue(request.getTags()); + if (existingTags.size() != request.getTags().size()) { + throw new BusinessException("유효하지 않은 태그가 포함되어 있습니다"); + } + + // 기존 취향 정보 조회 + PreferenceEntity preference = preferenceRepository.findByMemberId(memberId) + .orElse(null); + + if (preference != null) { + // 기존 정보 업데이트 + preference.updatePreference(request.getTags(), request.getHealthInfo(), request.getSpicyLevel()); + } else { + // 새로운 취향 정보 생성 + preference = PreferenceEntity.builder() + .memberId(memberId) + .tags(request.getTags()) + .healthInfo(request.getHealthInfo()) + .spicyLevel(request.getSpicyLevel()) + .build(); + } + + preferenceRepository.save(preference); + + log.info("취향 정보 저장 완료: memberId={}, tags={}", memberId, request.getTags()); + } + + @Override + @Transactional(readOnly = true) + public List getAvailableTags() { + List tags = tagRepository.findByIsActiveTrueOrderByTagCategoryAscSortOrderAsc(); + + return tags.stream() + .map(tag -> TasteTagResponse.builder() + .id(tag.getId()) + .tagName(tag.getTagName()) + .tagType(convertTagCategoryToTagType(tag.getTagCategory())) + .description(tag.getTagCategory().getDescription()) + .build()) + .collect(Collectors.toList()); + } + + @Override + @Transactional(readOnly = true) + public List getTagsByType(TagType tagType) { + TagCategory tagCategory = convertTagTypeToTagCategory(tagType); + List tags = tagRepository.findByTagCategoryAndIsActiveTrueOrderBySortOrderAsc(tagCategory); + + return tags.stream() + .map(tag -> TasteTagResponse.builder() + .id(tag.getId()) + .tagName(tag.getTagName()) + .tagType(tagType) + .description(tag.getTagCategory().getDescription()) + .build()) + .collect(Collectors.toList()); + } + + /** + * TagCategory를 TagType으로 변환 (기존 호환성을 위해) + */ + private TagType convertTagCategoryToTagType(TagCategory tagCategory) { + // TagType enum이 존재한다면 적절한 매핑 로직 구현 + // 임시로 기본값 반환 + return TagType.TASTE; // 실제 매핑 로직 필요 + } + + /** + * TagType을 TagCategory로 변환 (기존 호환성을 위해) + */ + private TagCategory convertTagTypeToTagCategory(TagType tagType) { + // TagType에 따른 TagCategory 매핑 + switch (tagType) { + case TASTE: + return TagCategory.TASTE; + default: + return TagCategory.TASTE; + } + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreTagClickEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreTagClickEntity.java new file mode 100644 index 0000000..055d364 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/StoreTagClickEntity.java @@ -0,0 +1,52 @@ +package com.ktds.hi.store.infra.gateway.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +/** + * 매장별 태그 클릭 통계 엔티티 + * 어떤 매장에서 어떤 태그가 얼마나 클릭되었는지 저장 + */ +@Entity +@Table(name = "store_tag_clicks") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreTagClickEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "tag_id", nullable = false) + private Long tagId; + + @Column(name = "click_count") + @Builder.Default + private Long clickCount = 0L; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 클릭 수 증가 + */ + public void incrementClickCount() { + this.clickCount = this.clickCount != null ? this.clickCount + 1 : 1L; + this.updatedAt = LocalDateTime.now(); + } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreTagClickJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreTagClickJpaRepository.java new file mode 100644 index 0000000..6d99390 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/StoreTagClickJpaRepository.java @@ -0,0 +1,43 @@ +package com.ktds.hi.store.infra.gateway.repository; + + +import com.ktds.hi.store.infra.gateway.entity.StoreTagClickEntity; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface StoreTagClickJpaRepository extends JpaRepository { + + /** + * 특정 매장의 가장 많이 클릭된 태그 5개 조회 + */ + @Query("SELECT stc FROM StoreTagClickEntity stc WHERE stc.storeId = :storeId " + + "ORDER BY stc.clickCount DESC") + List findTop5TagsByStoreIdOrderByClickCountDesc( + @Param("storeId") Long storeId, PageRequest pageRequest); + + /** + * 전체 매장에서 가장 많이 클릭된 태그 5개 조회 + */ + @Query("SELECT stc.tagId, SUM(stc.clickCount) as totalClicks " + + "FROM StoreTagClickEntity stc " + + "GROUP BY stc.tagId " + + "ORDER BY totalClicks DESC") + List findTop5TagsGloballyOrderByClickCount(PageRequest pageRequest); + + /** + * 매장별 태그별 클릭 통계 조회 + */ + Optional findByStoreIdAndTagId(Long storeId, Long tagId); + + /** + * 특정 매장의 모든 태그 클릭 통계 조회 + */ + List findByStoreIdOrderByClickCountDesc(Long storeId); +} \ No newline at end of file