Remove service interfaces — use concrete classes directly
Each domain had a single-implementation interface (e.g. AdminService interface + AdminServiceImpl). Merged implementation into the service class and deleted the redundant interfaces per KISS principle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,409 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.planning.entity.*;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface PlanningService {
|
||||
@Service
|
||||
public class PlanningService {
|
||||
|
||||
WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart);
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart);
|
||||
public PlanningService(WeekPlanRepository weekPlanRepository,
|
||||
WeekPlanSlotRepository weekPlanSlotRepository,
|
||||
CookingLogRepository cookingLogRepository,
|
||||
RecipeRepository recipeRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.weekPlanSlotRepository = weekPlanSlotRepository;
|
||||
this.cookingLogRepository = cookingLogRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request);
|
||||
@Transactional
|
||||
public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) {
|
||||
throw new ValidationException("weekStart must be a Monday");
|
||||
}
|
||||
if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) {
|
||||
throw new ConflictException("Week plan already exists for this week");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
void deleteSlot(UUID householdId, UUID planId, UUID slotId);
|
||||
@Transactional
|
||||
public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.save(
|
||||
new WeekPlanSlot(plan, recipe, request.slotDate()));
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
WeekPlanResponse confirmPlan(UUID householdId, UUID planId);
|
||||
@Transactional
|
||||
public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
slot.setRecipe(recipe);
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN);
|
||||
@Transactional
|
||||
public void deleteSlot(UUID householdId, UUID planId, UUID slotId) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
weekPlanSlotRepository.delete(slot);
|
||||
}
|
||||
|
||||
VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId);
|
||||
@Transactional
|
||||
public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
if ("confirmed".equals(plan.getStatus())) {
|
||||
throw new ValidationException("Plan is already confirmed");
|
||||
}
|
||||
if (plan.getSlots().isEmpty()) {
|
||||
throw new ValidationException("Plan has no slots");
|
||||
}
|
||||
plan.setStatus("confirmed");
|
||||
plan.setConfirmedAt(Instant.now());
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request);
|
||||
@Transactional(readOnly = true)
|
||||
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
int limit = (topN != null) ? topN : 5;
|
||||
if (limit <= 0) {
|
||||
return new SuggestionResponse(List.of());
|
||||
}
|
||||
|
||||
List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset);
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
Set<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
return new SuggestionResponse(suggestions);
|
||||
}
|
||||
|
||||
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
|
||||
if (lowerTagFilters.isEmpty()) return true;
|
||||
Set<String> recipeTags = recipe.getTags().stream()
|
||||
.map(t -> t.getName().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
return recipeTags.containsAll(lowerTagFilters);
|
||||
}
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
List<WeekPlanSlot> slots = plan.getSlots();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
// Load config (or use defaults)
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(),
|
||||
k -> new TagAccumulator(tag.getTagType()))
|
||||
.addDay(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue().days))
|
||||
.map(e -> new VarietyScoreResponse.TagRepeat(
|
||||
e.getKey(), e.getValue().tagType, e.getValue().days))
|
||||
.toList();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue()))
|
||||
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
LocalDate referenceDate = plan.getWeekStart();
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, referenceDate.minusDays(historyDays));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> recentRepeats = slots.stream()
|
||||
.map(s -> s.getRecipe())
|
||||
.filter(r -> recentlyCookedIds.contains(r.getId()))
|
||||
.map(Recipe::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
|
||||
|
||||
List<String> duplicatesInPlan = new ArrayList<>();
|
||||
long duplicatePenaltyCount = 0;
|
||||
for (var entry : recipeCounts.entrySet()) {
|
||||
if (entry.getValue() > 1) {
|
||||
String recipeName = slots.stream()
|
||||
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
|
||||
.findFirst()
|
||||
.map(s -> s.getRecipe().getName())
|
||||
.orElse("Unknown");
|
||||
duplicatesInPlan.add(recipeName);
|
||||
duplicatePenaltyCount += entry.getValue() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
private static class TagAccumulator {
|
||||
final String tagType;
|
||||
final List<LocalDate> days = new ArrayList<>();
|
||||
|
||||
TagAccumulator(String tagType) { this.tagType = tagType; }
|
||||
|
||||
void addDay(LocalDate day) { days.add(day); }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) {
|
||||
Recipe recipe = recipeRepository.findById(request.recipeId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now();
|
||||
CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user));
|
||||
|
||||
return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(),
|
||||
log.getCookedOn(), user.getId());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset) {
|
||||
return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(
|
||||
householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1)))
|
||||
.stream()
|
||||
.map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(),
|
||||
cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private WeekPlan findPlan(UUID planId, UUID householdId) {
|
||||
WeekPlan plan = weekPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
if (!plan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private Recipe findRecipe(UUID recipeId, UUID householdId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) {
|
||||
List<SlotResponse> slots = plan.getSlots().stream()
|
||||
.map(this::toSlotResponse)
|
||||
.toList();
|
||||
return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(),
|
||||
plan.getConfirmedAt(), slots);
|
||||
}
|
||||
|
||||
private SlotResponse toSlotResponse(WeekPlanSlot slot) {
|
||||
return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe()));
|
||||
}
|
||||
|
||||
private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) {
|
||||
return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(),
|
||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||
}
|
||||
|
||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||
if (days.size() < 2) return false;
|
||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||
for (int i = 1; i < sorted.size(); i++) {
|
||||
if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
package com.recipeapp.planning;
|
||||
|
||||
import com.recipeapp.auth.UserAccountRepository;
|
||||
import com.recipeapp.auth.entity.UserAccount;
|
||||
import com.recipeapp.common.ConflictException;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.common.ValidationException;
|
||||
import com.recipeapp.household.HouseholdRepository;
|
||||
import com.recipeapp.household.entity.Household;
|
||||
import com.recipeapp.planning.dto.*;
|
||||
import com.recipeapp.planning.entity.*;
|
||||
import com.recipeapp.recipe.RecipeRepository;
|
||||
import com.recipeapp.recipe.entity.Recipe;
|
||||
import com.recipeapp.recipe.entity.RecipeIngredient;
|
||||
import com.recipeapp.recipe.entity.Tag;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class PlanningServiceImpl implements PlanningService {
|
||||
|
||||
private final WeekPlanRepository weekPlanRepository;
|
||||
private final WeekPlanSlotRepository weekPlanSlotRepository;
|
||||
private final CookingLogRepository cookingLogRepository;
|
||||
private final RecipeRepository recipeRepository;
|
||||
private final HouseholdRepository householdRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final VarietyScoreConfigRepository varietyScoreConfigRepository;
|
||||
|
||||
public PlanningServiceImpl(WeekPlanRepository weekPlanRepository,
|
||||
WeekPlanSlotRepository weekPlanSlotRepository,
|
||||
CookingLogRepository cookingLogRepository,
|
||||
RecipeRepository recipeRepository,
|
||||
HouseholdRepository householdRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
VarietyScoreConfigRepository varietyScoreConfigRepository) {
|
||||
this.weekPlanRepository = weekPlanRepository;
|
||||
this.weekPlanSlotRepository = weekPlanSlotRepository;
|
||||
this.cookingLogRepository = cookingLogRepository;
|
||||
this.recipeRepository = recipeRepository;
|
||||
this.householdRepository = householdRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.varietyScoreConfigRepository = varietyScoreConfigRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public WeekPlanResponse getWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
WeekPlan plan = weekPlanRepository.findByHouseholdIdAndWeekStart(householdId, weekStart)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public WeekPlanResponse createWeekPlan(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart.getDayOfWeek() != DayOfWeek.MONDAY) {
|
||||
throw new ValidationException("weekStart must be a Monday");
|
||||
}
|
||||
if (weekPlanRepository.existsByHouseholdIdAndWeekStart(householdId, weekStart)) {
|
||||
throw new ConflictException("Week plan already exists for this week");
|
||||
}
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
WeekPlan plan = weekPlanRepository.save(new WeekPlan(household, weekStart));
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SlotResponse addSlot(UUID householdId, UUID planId, CreateSlotRequest request) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.save(
|
||||
new WeekPlanSlot(plan, recipe, request.slotDate()));
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public SlotResponse updateSlot(UUID householdId, UUID planId, UUID slotId, UpdateSlotRequest request) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
Recipe recipe = findRecipe(request.recipeId(), householdId);
|
||||
slot.setRecipe(recipe);
|
||||
return toSlotResponse(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void deleteSlot(UUID householdId, UUID planId, UUID slotId) {
|
||||
findPlan(planId, householdId);
|
||||
WeekPlanSlot slot = weekPlanSlotRepository.findById(slotId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Slot not found"));
|
||||
weekPlanSlotRepository.delete(slot);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public WeekPlanResponse confirmPlan(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
if ("confirmed".equals(plan.getStatus())) {
|
||||
throw new ValidationException("Plan is already confirmed");
|
||||
}
|
||||
if (plan.getSlots().isEmpty()) {
|
||||
throw new ValidationException("Plan has no slots");
|
||||
}
|
||||
plan.setStatus("confirmed");
|
||||
plan.setConfirmedAt(Instant.now());
|
||||
return toWeekPlanResponse(plan);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public SuggestionResponse getSuggestions(UUID householdId, UUID planId, LocalDate slotDate,
|
||||
List<String> tagFilters, Integer topN) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
int limit = (topN != null) ? topN : 5;
|
||||
if (limit <= 0) {
|
||||
return new SuggestionResponse(List.of());
|
||||
}
|
||||
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
Set<UUID> usedRecipeIds = plan.getSlots().stream()
|
||||
.map(s -> s.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<UUID> recentlyCookedIds = cookingLogRepository
|
||||
.findByHouseholdIdAndCookedOnAfter(householdId,
|
||||
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
||||
.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<Recipe> allRecipes = recipeRepository.findByHouseholdIdAndDeletedAtIsNull(householdId);
|
||||
|
||||
Set<String> lowerTagFilters = tagFilters.stream()
|
||||
.map(String::toLowerCase)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<SuggestionResponse.SuggestionItem> suggestions = allRecipes.stream()
|
||||
.filter(r -> !usedRecipeIds.contains(r.getId()))
|
||||
.filter(r -> matchesAllTags(r, lowerTagFilters))
|
||||
.map(candidate -> {
|
||||
double score = simulateVarietyScore(
|
||||
plan, candidate, slotDate, config, recentlyCookedIds);
|
||||
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), score);
|
||||
})
|
||||
.sorted((a, b) -> Double.compare(b.simulatedScore(), a.simulatedScore()))
|
||||
.limit(limit)
|
||||
.toList();
|
||||
|
||||
return new SuggestionResponse(suggestions);
|
||||
}
|
||||
|
||||
private boolean matchesAllTags(Recipe recipe, Set<String> lowerTagFilters) {
|
||||
if (lowerTagFilters.isEmpty()) return true;
|
||||
Set<String> recipeTags = recipe.getTags().stream()
|
||||
.map(t -> t.getName().toLowerCase())
|
||||
.collect(Collectors.toSet());
|
||||
return recipeTags.containsAll(lowerTagFilters);
|
||||
}
|
||||
|
||||
private double simulateVarietyScore(WeekPlan plan, Recipe candidate, LocalDate slotDate,
|
||||
VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
||||
// Build a simulated slot list: existing slots + candidate on slotDate
|
||||
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
||||
for (WeekPlanSlot slot : plan.getSlots()) {
|
||||
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
||||
}
|
||||
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, List<LocalDate>> tagDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (Tag tag : slot.recipe.getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long tagRepeatCount = tagDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (SimulatedSlot slot : simulatedSlots) {
|
||||
for (RecipeIngredient ri : slot.recipe.getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
long ingredientOverlapCount = ingredientDays.values().stream()
|
||||
.filter(this::hasConsecutiveDays)
|
||||
.count();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
long recentRepeatCount = simulatedSlots.stream()
|
||||
.map(s -> s.recipe.getId())
|
||||
.distinct()
|
||||
.filter(recentlyCookedIds::contains)
|
||||
.count();
|
||||
|
||||
// 4. Duplicate recipes within the simulated plan
|
||||
Map<UUID, Long> recipeCounts = simulatedSlots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.recipe.getId(), Collectors.counting()));
|
||||
long duplicatePenaltyCount = recipeCounts.values().stream()
|
||||
.filter(c -> c > 1)
|
||||
.mapToLong(c -> c - 1)
|
||||
.sum();
|
||||
|
||||
double score = 10.0;
|
||||
score -= tagRepeatCount * wTagRepeat;
|
||||
score -= ingredientOverlapCount * wIngredientOverlap;
|
||||
score -= recentRepeatCount * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
return Math.max(0, Math.min(10, score));
|
||||
}
|
||||
|
||||
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public VarietyScoreResponse getVarietyScore(UUID householdId, UUID planId) {
|
||||
WeekPlan plan = findPlan(planId, householdId);
|
||||
List<WeekPlanSlot> slots = plan.getSlots();
|
||||
|
||||
if (slots.isEmpty()) {
|
||||
return new VarietyScoreResponse(0, List.of(), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
// Load config (or use defaults)
|
||||
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
||||
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
||||
|
||||
List<String> checkedTagTypes = config.getRepeatTagTypes();
|
||||
double wTagRepeat = config.getWTagRepeat().doubleValue();
|
||||
double wIngredientOverlap = config.getWIngredientOverlap().doubleValue();
|
||||
double wRecentRepeat = config.getWRecentRepeat().doubleValue();
|
||||
double wPlanDuplicate = config.getWPlanDuplicate().doubleValue();
|
||||
int historyDays = config.getHistoryDays();
|
||||
|
||||
// 1. Tag-type repeats on consecutive days
|
||||
Map<String, TagAccumulator> tagDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (Tag tag : slot.getRecipe().getTags()) {
|
||||
if (checkedTagTypes.contains(tag.getTagType())) {
|
||||
tagDays.computeIfAbsent(tag.getName(),
|
||||
k -> new TagAccumulator(tag.getTagType()))
|
||||
.addDay(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.TagRepeat> tagRepeats = tagDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue().days))
|
||||
.map(e -> new VarietyScoreResponse.TagRepeat(
|
||||
e.getKey(), e.getValue().tagType, e.getValue().days))
|
||||
.toList();
|
||||
|
||||
// 2. Non-staple ingredient overlaps on consecutive days
|
||||
Map<String, List<LocalDate>> ingredientDays = new LinkedHashMap<>();
|
||||
for (WeekPlanSlot slot : slots) {
|
||||
for (RecipeIngredient ri : slot.getRecipe().getIngredients()) {
|
||||
if (!ri.getIngredient().isStaple()) {
|
||||
ingredientDays.computeIfAbsent(ri.getIngredient().getName(), k -> new ArrayList<>())
|
||||
.add(slot.getSlotDate());
|
||||
}
|
||||
}
|
||||
}
|
||||
List<VarietyScoreResponse.IngredientOverlap> overlaps = ingredientDays.entrySet().stream()
|
||||
.filter(e -> hasConsecutiveDays(e.getValue()))
|
||||
.map(e -> new VarietyScoreResponse.IngredientOverlap(e.getKey(), e.getValue()))
|
||||
.toList();
|
||||
|
||||
// 3. Recent repeats from cooking log
|
||||
LocalDate referenceDate = plan.getWeekStart();
|
||||
List<CookingLog> recentLogs = cookingLogRepository.findByHouseholdIdAndCookedOnAfter(
|
||||
householdId, referenceDate.minusDays(historyDays));
|
||||
Set<UUID> recentlyCookedIds = recentLogs.stream()
|
||||
.map(cl -> cl.getRecipe().getId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
List<String> recentRepeats = slots.stream()
|
||||
.map(s -> s.getRecipe())
|
||||
.filter(r -> recentlyCookedIds.contains(r.getId()))
|
||||
.map(Recipe::getName)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
// 4. Duplicate recipes within the plan
|
||||
Map<UUID, Long> recipeCounts = slots.stream()
|
||||
.collect(Collectors.groupingBy(s -> s.getRecipe().getId(), Collectors.counting()));
|
||||
|
||||
List<String> duplicatesInPlan = new ArrayList<>();
|
||||
long duplicatePenaltyCount = 0;
|
||||
for (var entry : recipeCounts.entrySet()) {
|
||||
if (entry.getValue() > 1) {
|
||||
String recipeName = slots.stream()
|
||||
.filter(s -> s.getRecipe().getId().equals(entry.getKey()))
|
||||
.findFirst()
|
||||
.map(s -> s.getRecipe().getName())
|
||||
.orElse("Unknown");
|
||||
duplicatesInPlan.add(recipeName);
|
||||
duplicatePenaltyCount += entry.getValue() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
double score = 10.0;
|
||||
score -= tagRepeats.size() * wTagRepeat;
|
||||
score -= overlaps.size() * wIngredientOverlap;
|
||||
score -= recentRepeats.size() * wRecentRepeat;
|
||||
score -= duplicatePenaltyCount * wPlanDuplicate;
|
||||
score = Math.max(0, Math.min(10, score));
|
||||
|
||||
return new VarietyScoreResponse(score, tagRepeats, overlaps, recentRepeats, duplicatesInPlan);
|
||||
}
|
||||
|
||||
private static class TagAccumulator {
|
||||
final String tagType;
|
||||
final List<LocalDate> days = new ArrayList<>();
|
||||
|
||||
TagAccumulator(String tagType) { this.tagType = tagType; }
|
||||
|
||||
void addDay(LocalDate day) { days.add(day); }
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public CookingLogResponse createCookingLog(UUID householdId, UUID userId, CreateCookingLogRequest request) {
|
||||
Recipe recipe = recipeRepository.findById(request.recipeId())
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
Household household = householdRepository.findById(householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Household not found"));
|
||||
UserAccount user = userAccountRepository.findById(userId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
|
||||
|
||||
LocalDate cookedOn = request.cookedOn() != null ? request.cookedOn() : LocalDate.now();
|
||||
CookingLog log = cookingLogRepository.save(new CookingLog(recipe, household, cookedOn, user));
|
||||
|
||||
return new CookingLogResponse(log.getId(), recipe.getId(), recipe.getName(),
|
||||
log.getCookedOn(), user.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public List<CookingLogResponse> listCookingLogs(UUID householdId, int limit, int offset) {
|
||||
return cookingLogRepository.findByHouseholdIdOrderByCookedOnDesc(
|
||||
householdId, PageRequest.of(offset / Math.max(limit, 1), Math.max(limit, 1)))
|
||||
.stream()
|
||||
.map(cl -> new CookingLogResponse(cl.getId(), cl.getRecipe().getId(),
|
||||
cl.getRecipe().getName(), cl.getCookedOn(), cl.getCookedBy().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private WeekPlan findPlan(UUID planId, UUID householdId) {
|
||||
WeekPlan plan = weekPlanRepository.findById(planId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
if (!plan.getHousehold().getId().equals(householdId)) {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private Recipe findRecipe(UUID recipeId, UUID householdId) {
|
||||
return recipeRepository.findByIdAndHouseholdIdAndDeletedAtIsNull(recipeId, householdId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Recipe not found"));
|
||||
}
|
||||
|
||||
private WeekPlanResponse toWeekPlanResponse(WeekPlan plan) {
|
||||
List<SlotResponse> slots = plan.getSlots().stream()
|
||||
.map(this::toSlotResponse)
|
||||
.toList();
|
||||
return new WeekPlanResponse(plan.getId(), plan.getWeekStart(), plan.getStatus(),
|
||||
plan.getConfirmedAt(), slots);
|
||||
}
|
||||
|
||||
private SlotResponse toSlotResponse(WeekPlanSlot slot) {
|
||||
return new SlotResponse(slot.getId(), slot.getSlotDate(), toSlotRecipe(slot.getRecipe()));
|
||||
}
|
||||
|
||||
private SlotResponse.SlotRecipe toSlotRecipe(Recipe recipe) {
|
||||
return new SlotResponse.SlotRecipe(recipe.getId(), recipe.getName(), recipe.getEffort(),
|
||||
recipe.getCookTimeMin(), recipe.getHeroImageUrl());
|
||||
}
|
||||
|
||||
private boolean hasConsecutiveDays(List<LocalDate> days) {
|
||||
if (days.size() < 2) return false;
|
||||
List<LocalDate> sorted = days.stream().sorted().toList();
|
||||
for (int i = 1; i < sorted.size(); i++) {
|
||||
if (sorted.get(i).toEpochDay() - sorted.get(i - 1).toEpochDay() == 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user