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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 18:33:15 +02:00
parent c26c2e1973
commit 5325f4827e
2 changed files with 104 additions and 22 deletions

View File

@@ -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<String, MergedIngredient> 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<String, ShoppingListItem> existingByKey = new HashMap<>();
List<ShoppingListItem> 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<String> 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);