Neutral suggestions (scoreDelta = 0) are not conflicts — they simply don't improve variety. Changing scoreDelta <= 0 to scoreDelta < 0 lets empty-plan additions and quality-neutral swaps show without a misleading ⚠ Variationskonflikt warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
445 lines
20 KiB
Java
445 lines
20 KiB
Java
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 PlanningService {
|
|
|
|
private static final double MAX_VARIETY_SCORE = 10.0;
|
|
|
|
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 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;
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@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());
|
|
|
|
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
|
|
|
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 simulatedScore = simulateVarietyScore(
|
|
plan, candidate, slotDate, config, recentlyCookedIds);
|
|
double scoreDelta = simulatedScore - currentScore;
|
|
boolean hasConflict = scoreDelta < 0;
|
|
return new SuggestionResponse.SuggestionItem(toSlotRecipe(candidate), scoreDelta, hasConflict);
|
|
})
|
|
.sorted((a, b) -> Double.compare(b.scoreDelta(), a.scoreDelta()))
|
|
.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) {
|
|
List<SimulatedSlot> simulatedSlots = new ArrayList<>();
|
|
for (WeekPlanSlot slot : plan.getSlots()) {
|
|
if (!slot.getSlotDate().equals(slotDate)) {
|
|
simulatedSlots.add(new SimulatedSlot(slot.getRecipe(), slot.getSlotDate()));
|
|
}
|
|
}
|
|
simulatedSlots.add(new SimulatedSlot(candidate, slotDate));
|
|
return scoreFromSimulatedSlots(simulatedSlots, config, recentlyCookedIds);
|
|
}
|
|
|
|
private double computeCurrentScore(WeekPlan plan, VarietyScoreConfig config, Set<UUID> recentlyCookedIds) {
|
|
List<SimulatedSlot> currentSlots = plan.getSlots().stream()
|
|
.map(s -> new SimulatedSlot(s.getRecipe(), s.getSlotDate()))
|
|
.toList();
|
|
return currentSlots.isEmpty() ? MAX_VARIETY_SCORE
|
|
: scoreFromSimulatedSlots(currentSlots, config, recentlyCookedIds);
|
|
}
|
|
|
|
private record SimulatedSlot(Recipe recipe, LocalDate date) {}
|
|
|
|
@Transactional(readOnly = true)
|
|
public VarietyPreviewResponse getVarietyPreview(UUID householdId, UUID planId, UUID recipeId, LocalDate date) {
|
|
WeekPlan plan = findPlan(planId, householdId);
|
|
Recipe candidate = findRecipe(recipeId, householdId);
|
|
|
|
VarietyScoreConfig config = varietyScoreConfigRepository.findByHouseholdId(householdId)
|
|
.orElse(VarietyScoreConfig.defaults(plan.getHousehold()));
|
|
|
|
Set<UUID> recentlyCookedIds = cookingLogRepository
|
|
.findByHouseholdIdAndCookedOnAfter(householdId,
|
|
plan.getWeekStart().minusDays(config.getHistoryDays()))
|
|
.stream()
|
|
.map(cl -> cl.getRecipe().getId())
|
|
.collect(Collectors.toSet());
|
|
|
|
double currentScore = computeCurrentScore(plan, config, recentlyCookedIds);
|
|
double projectedScore = simulateVarietyScore(plan, candidate, date, config, recentlyCookedIds);
|
|
|
|
return new VarietyPreviewResponse(currentScore, projectedScore, projectedScore - currentScore);
|
|
}
|
|
|
|
private double scoreFromSimulatedSlots(List<SimulatedSlot> slots, VarietyScoreConfig config,
|
|
Set<UUID> recentlyCookedIds) {
|
|
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 : slots) {
|
|
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 : slots) {
|
|
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 = slots.stream()
|
|
.map(s -> s.recipe.getId())
|
|
.distinct()
|
|
.filter(recentlyCookedIds::contains)
|
|
.count();
|
|
|
|
// 4. Duplicate recipes within the plan
|
|
Map<UUID, Long> recipeCounts = slots.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 = MAX_VARIETY_SCORE;
|
|
score -= tagRepeatCount * wTagRepeat;
|
|
score -= ingredientOverlapCount * wIngredientOverlap;
|
|
score -= recentRepeatCount * wRecentRepeat;
|
|
score -= duplicatePenaltyCount * wPlanDuplicate;
|
|
return Math.max(0, Math.min(MAX_VARIETY_SCORE, score));
|
|
}
|
|
|
|
@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 = MAX_VARIETY_SCORE;
|
|
score -= tagRepeats.size() * wTagRepeat;
|
|
score -= overlaps.size() * wIngredientOverlap;
|
|
score -= recentRepeats.size() * wRecentRepeat;
|
|
score -= duplicatePenaltyCount * wPlanDuplicate;
|
|
score = Math.max(0, Math.min(MAX_VARIETY_SCORE, 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;
|
|
}
|
|
}
|