feat(services): add shopping_list service with pantry diff

refs kiwi#68
This commit is contained in:
pyr0ball 2026-04-12 13:14:08 -07:00
parent ffb34c9c62
commit 4459b1ab7e
3 changed files with 140 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""

View 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

View 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 == []