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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user