feat(services): add shopping_list service with pantry diff
refs kiwi#68
This commit is contained in:
parent
ffb34c9c62
commit
4459b1ab7e
3 changed files with 140 additions and 0 deletions
1
app/services/meal_plan/__init__.py
Normal file
1
app/services/meal_plan/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""
|
||||||
88
app/services/meal_plan/shopping_list.py
Normal file
88
app/services/meal_plan/shopping_list.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# app/services/meal_plan/shopping_list.py
|
||||||
|
"""Compute a shopping list from a meal plan and current pantry inventory.
|
||||||
|
|
||||||
|
Pure function — no DB or network calls. Takes plain dicts from the Store
|
||||||
|
and returns GapItem dataclasses.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GapItem:
|
||||||
|
ingredient_name: str
|
||||||
|
needed_raw: str | None # first quantity token from recipe text, e.g. "300g"
|
||||||
|
have_quantity: float | None # pantry quantity when partial match
|
||||||
|
have_unit: str | None
|
||||||
|
covered: bool
|
||||||
|
retailer_links: list = field(default_factory=list) # filled by API layer
|
||||||
|
|
||||||
|
|
||||||
|
_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_quantity(ingredient_text: str) -> str | None:
|
||||||
|
"""Pull the leading quantity string from a raw ingredient line."""
|
||||||
|
m = _QUANTITY_RE.match(ingredient_text.strip())
|
||||||
|
return m.group(1).strip() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise(name: str) -> str:
|
||||||
|
"""Lowercase, strip possessives and plural -s for fuzzy matching."""
|
||||||
|
return name.lower().strip().rstrip("s")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_shopping_list(
|
||||||
|
recipes: list[dict],
|
||||||
|
inventory: list[dict],
|
||||||
|
) -> tuple[list[GapItem], list[GapItem]]:
|
||||||
|
"""Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts.
|
||||||
|
|
||||||
|
Deduplicates by normalised ingredient name — the first recipe's quantity
|
||||||
|
string wins when the same ingredient appears in multiple recipes.
|
||||||
|
"""
|
||||||
|
if not recipes:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
# Build pantry lookup: normalised_name → inventory row
|
||||||
|
pantry: dict[str, dict] = {}
|
||||||
|
for item in inventory:
|
||||||
|
pantry[_normalise(item["name"])] = item
|
||||||
|
|
||||||
|
# Collect unique ingredients with their first quantity token
|
||||||
|
seen: dict[str, str | None] = {} # normalised_name → needed_raw
|
||||||
|
for recipe in recipes:
|
||||||
|
names: list[str] = recipe.get("ingredient_names") or []
|
||||||
|
raw_lines: list[str] = recipe.get("ingredients") or []
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
key = _normalise(name)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
raw = raw_lines[i] if i < len(raw_lines) else ""
|
||||||
|
seen[key] = _extract_quantity(raw)
|
||||||
|
|
||||||
|
gaps: list[GapItem] = []
|
||||||
|
covered: list[GapItem] = []
|
||||||
|
|
||||||
|
for norm_name, needed_raw in seen.items():
|
||||||
|
pantry_row = pantry.get(norm_name)
|
||||||
|
if pantry_row:
|
||||||
|
covered.append(GapItem(
|
||||||
|
ingredient_name=norm_name,
|
||||||
|
needed_raw=needed_raw,
|
||||||
|
have_quantity=pantry_row.get("quantity"),
|
||||||
|
have_unit=pantry_row.get("unit"),
|
||||||
|
covered=True,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
gaps.append(GapItem(
|
||||||
|
ingredient_name=norm_name,
|
||||||
|
needed_raw=needed_raw,
|
||||||
|
have_quantity=None,
|
||||||
|
have_unit=None,
|
||||||
|
covered=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
return gaps, covered
|
||||||
51
tests/services/test_meal_plan_shopping_list.py
Normal file
51
tests/services/test_meal_plan_shopping_list.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# tests/services/test_meal_plan_shopping_list.py
|
||||||
|
"""Unit tests for shopping_list.py — no network, no DB."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from app.services.meal_plan.shopping_list import GapItem, compute_shopping_list
|
||||||
|
|
||||||
|
|
||||||
|
def _recipe(ingredient_names: list[str], ingredients: list[str]) -> dict:
|
||||||
|
return {"ingredient_names": ingredient_names, "ingredients": ingredients}
|
||||||
|
|
||||||
|
|
||||||
|
def _inv_item(name: str, quantity: float, unit: str) -> dict:
|
||||||
|
return {"name": name, "quantity": quantity, "unit": unit}
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_in_pantry_is_covered():
|
||||||
|
recipes = [_recipe(["pasta"], ["500g pasta"])]
|
||||||
|
inventory = [_inv_item("pasta", 400, "g")]
|
||||||
|
gaps, covered = compute_shopping_list(recipes, inventory)
|
||||||
|
assert len(covered) == 1
|
||||||
|
assert covered[0].ingredient_name == "pasta"
|
||||||
|
assert covered[0].covered is True
|
||||||
|
assert len(gaps) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_not_in_pantry_is_gap():
|
||||||
|
recipes = [_recipe(["chicken breast"], ["300g chicken breast"])]
|
||||||
|
inventory = []
|
||||||
|
gaps, covered = compute_shopping_list(recipes, inventory)
|
||||||
|
assert len(gaps) == 1
|
||||||
|
assert gaps[0].ingredient_name == "chicken breast"
|
||||||
|
assert gaps[0].covered is False
|
||||||
|
assert gaps[0].needed_raw == "300g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_ingredient_across_recipes_deduplicates():
|
||||||
|
recipes = [
|
||||||
|
_recipe(["onion"], ["2 onions"]),
|
||||||
|
_recipe(["onion"], ["1 onion"]),
|
||||||
|
]
|
||||||
|
inventory = []
|
||||||
|
gaps, _ = compute_shopping_list(recipes, inventory)
|
||||||
|
names = [g.ingredient_name for g in gaps]
|
||||||
|
assert names.count("onion") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_plan_returns_empty_lists():
|
||||||
|
gaps, covered = compute_shopping_list([], [])
|
||||||
|
assert gaps == []
|
||||||
|
assert covered == []
|
||||||
Loading…
Reference in a new issue