From 5325f4827ec60cc108ed2b6d05ea55badefd05a2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Sat, 4 Apr 2026 18:33:15 +0200 Subject: [PATCH] feat(shopping): refactor generateFromPlan to merge strategy When a shopping list already exists for the week plan, regeneration now merges: custom items and check states are preserved, existing generated items are updated, removed recipes' items are deleted, and new ingredients are added. Co-Authored-By: Claude Sonnet 4.6 --- .../recipeapp/shopping/ShoppingService.java | 65 +++++++++++++------ .../shopping/ShoppingServiceTest.java | 61 ++++++++++++++++- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java index 26d16cb..e0658d0 100644 --- a/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java +++ b/backend/src/main/java/com/recipeapp/shopping/ShoppingService.java @@ -73,25 +73,23 @@ public class ShoppingService { throw new ResourceNotFoundException("Week plan not found"); } - var household = weekPlan.getHousehold(); - - ShoppingList shoppingList = new ShoppingList(household, weekPlan); - shoppingList = shoppingListRepository.save(shoppingList); + // Find or create the shopping list + ShoppingList shoppingList = shoppingListRepository + .findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart()) + .orElseGet(() -> { + var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan); + return shoppingListRepository.save(newList); + }); // Aggregate ingredients across all slots/recipes - // Key: ingredientId + unit -> merged data Map merged = new LinkedHashMap<>(); - for (var slot : weekPlan.getSlots()) { var recipe = slot.getRecipe(); for (RecipeIngredient ri : recipe.getIngredients()) { Ingredient ingredient = ri.getIngredient(); - - // Filter out staples if (ingredient.isStaple()) { continue; } - String key = ingredient.getId().toString() + "|" + ri.getUnit(); merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit())) .addQuantity(ri.getQuantity()) @@ -99,19 +97,46 @@ public class ShoppingService { } } - // Create shopping list items - for (MergedIngredient mi : merged.values()) { - ShoppingListItem item = new ShoppingListItem( - shoppingList, - mi.ingredient, - null, - mi.totalQuantity, - mi.unit, - mi.recipeIds.stream().distinct().toArray(UUID[]::new) - ); - shoppingList.getItems().add(item); + // Build index of existing generated items by merge key + Map existingByKey = new HashMap<>(); + List customItems = new ArrayList<>(); + for (ShoppingListItem item : shoppingList.getItems()) { + if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) { + // Generated item + String key = (item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit(); + existingByKey.put(key, item); + } else { + customItems.add(item); + } } + // Merge: update existing, add new, collect keys to keep + Set mergedKeys = new HashSet<>(); + for (MergedIngredient mi : merged.values()) { + String key = mi.ingredient.getId().toString() + "|" + mi.unit; + mergedKeys.add(key); + + ShoppingListItem existing = existingByKey.get(key); + if (existing != null) { + // Update quantity and sources, preserve check state + existing.setQuantity(mi.totalQuantity); + existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new)); + } else { + // New item + ShoppingListItem item = new ShoppingListItem( + shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit, + mi.recipeIds.stream().distinct().toArray(UUID[]::new)); + shoppingList.getItems().add(item); + } + } + + // Remove generated items no longer in the plan + shoppingList.getItems().removeIf(item -> + item.getSourceRecipes() != null && item.getSourceRecipes().length > 0 + && !mergedKeys.contains( + (item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit())); + + shoppingList.setGeneratedAt(java.time.Instant.now()); shoppingListRepository.save(shoppingList); return toResponse(shoppingList); diff --git a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java index d5ce705..6be71dc 100644 --- a/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java +++ b/backend/src/test/java/com/recipeapp/shopping/ShoppingServiceTest.java @@ -161,9 +161,11 @@ class ShoppingServiceTest { plan.getSlots().add(slot2); when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { ShoppingList sl = i.getArgument(0); - setId(sl, ShoppingList.class, UUID.randomUUID()); + if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID()); return sl; }); when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2)); @@ -184,6 +186,59 @@ class ShoppingServiceTest { assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); } + @Test + void generateFromPlanShouldMergeWhenListAlreadyExists() { + var household = testHousehold(); + var plan = testWeekPlan(household); + var existingList = testShoppingList(household, plan); + + // Existing generated item: 2 tomatoes + var tomato = testIngredient(household, "Tomatoes", false); + var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs"); + existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()}); + existingList.getItems().add(existingItem); + + // Existing custom item (should be preserved) + var customItem = new ShoppingListItem(existingList, null, "Paper towels", + new BigDecimal("1"), "", new UUID[0]); + setId(customItem, ShoppingListItem.class, UUID.randomUUID()); + customItem.setChecked(true); + existingList.getItems().add(customItem); + + // New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added) + var recipe = testRecipe(household, "Pasta"); + var cheese = testIngredient(household, "Cheese", false); + recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1)); + recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2)); + var slot = new WeekPlanSlot(plan, recipe, WEEK_START); + setId(slot, WeekPlanSlot.class, UUID.randomUUID()); + plan.getSlots().add(slot); + + when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart())) + .thenReturn(Optional.of(existingList)); + when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0)); + when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe)); + + ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId()); + + // Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom) + assertThat(result.items()).hasSize(3); + + var tomatoResult = result.items().stream() + .filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow(); + assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00")); + + var cheeseResult = result.items().stream() + .filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow(); + assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00")); + + // Custom item preserved with check state + var customResult = result.items().stream() + .filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow(); + assertThat(customResult.isChecked()).isTrue(); + } + @Test void generateFromPlanShouldThrowWhenPlanNotFound() { var planId = UUID.randomUUID(); @@ -422,9 +477,11 @@ class ShoppingServiceTest { // no slots added when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan)); + when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START)) + .thenReturn(Optional.empty()); when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> { ShoppingList sl = i.getArgument(0); - setId(sl, ShoppingList.class, UUID.randomUUID()); + if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID()); return sl; });