Files
mealprep/backend/src/main/java/com/recipeapp/planning/PlanningService.java
Marcel Raddatz 8dfc3df06b fix(planning): hasConflict only when scoreDelta strictly negative
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>
2026-04-09 16:33:12 +02:00

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;
}
}