diff --git a/backend/app/services/planner/cost.py b/backend/app/services/planner/cost.py new file mode 100644 index 0000000..38840bf --- /dev/null +++ b/backend/app/services/planner/cost.py @@ -0,0 +1,123 @@ +"""Compute per-recipe cost and savings against ingredient_grocery_match. + +Inputs: + ingredients: list[dict] from recipe.ingredients JSONB + match_index: dict[ingredient_id, list[match_dict]] — pre-fetched, sorted by confidence DESC + pantry_ingredient_ids: set of ingredient_ids in home_pantry + +Strategy: + For each ingredient in the recipe, take the top-confidence match + (or skip if none). Cost = current_price * qty (best-effort scaling + that ignores unit conversion — see Limitations below). + Savings = max(regular - current, 0) * qty. + +Limitations: + Unit conversion (lb vs oz, cup vs ml) is NOT implemented in this + pass. The qty multiplier is treated as dimensionless. This produces + a biased-but-monotonic ranking signal: recipes that use more of an + expensive ingredient still rank as more expensive, which is what + the planner needs. Real dollar accuracy can come later. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Dict, Iterable, List, Set +from uuid import UUID + +from app.services.planner.types import IngredientCost, RecipeCost + + +def _decimal(v) -> Decimal: + if v is None: + return Decimal("0") + return v if isinstance(v, Decimal) else Decimal(str(v)) + + +def _scale(price: Decimal, qty: float) -> Decimal: + return (price * Decimal(str(qty))).quantize(Decimal("0.01")) + + +def compute_recipe_cost( + *, + recipe_id: UUID, + ingredients: Iterable[dict], + match_index: Dict[UUID, List[dict]], + pantry_ingredient_ids: Set[UUID], +) -> RecipeCost: + line_items: List[IngredientCost] = [] + total_cost = Decimal("0.00") + total_savings = Decimal("0.00") + sale_count = 0 + matched_count = 0 + pantry_hits = 0 + total = 0 + + for raw in ingredients: + total += 1 + ing_id = raw["ingredient_id"] + if isinstance(ing_id, str): + ing_id = UUID(ing_id) + qty = float(raw.get("qty") or 1.0) + unit = raw.get("unit") + + if ing_id in pantry_ingredient_ids: + pantry_hits += 1 + + candidates = match_index.get(ing_id) or [] + if not candidates: + line_items.append( + IngredientCost( + ingredient_id=ing_id, + qty=qty, + unit=unit, + grocery_item_id=None, + grocery_item_name=None, + current_price=None, + regular_price=None, + is_on_sale=False, + estimated_cost=Decimal("0.00"), + estimated_savings=Decimal("0.00"), + matched=False, + ) + ) + continue + + best = candidates[0] + current = _decimal(best.get("current_price")) + regular = _decimal(best.get("regular_price")) + is_on_sale = bool(best.get("is_on_sale")) + line_cost = _scale(current, qty) + line_savings = _scale(max(regular - current, Decimal("0")), qty) + + matched_count += 1 + if is_on_sale: + sale_count += 1 + total_cost += line_cost + total_savings += line_savings + + line_items.append( + IngredientCost( + ingredient_id=ing_id, + qty=qty, + unit=unit, + grocery_item_id=best.get("grocery_item_id"), + grocery_item_name=best.get("grocery_item_name"), + current_price=current, + regular_price=regular, + is_on_sale=is_on_sale, + estimated_cost=line_cost, + estimated_savings=line_savings, + matched=True, + ) + ) + + return RecipeCost( + recipe_id=recipe_id, + total_cost=total_cost.quantize(Decimal("0.01")), + total_savings=total_savings.quantize(Decimal("0.01")), + sale_ingredient_count=sale_count, + matched_ingredient_count=matched_count, + total_ingredient_count=total, + pantry_hit_count=pantry_hits, + line_items=line_items, + ) diff --git a/backend/tests/test_planner_cost.py b/backend/tests/test_planner_cost.py new file mode 100644 index 0000000..c5163a7 --- /dev/null +++ b/backend/tests/test_planner_cost.py @@ -0,0 +1,80 @@ +from decimal import Decimal +from uuid import uuid4 + +import pytest + +from app.services.planner.cost import compute_recipe_cost +from app.services.planner.types import RecipeCost + + +def _ingredient(ing_id, qty=1.0, unit=None): + return {"ingredient_id": ing_id, "qty": qty, "unit": unit} + + +def _match(grocery_id, name, current, regular, is_on_sale, confidence=0.9): + return { + "grocery_item_id": grocery_id, + "grocery_item_name": name, + "current_price": Decimal(str(current)), + "regular_price": Decimal(str(regular)), + "is_on_sale": is_on_sale, + "confidence": Decimal(str(confidence)), + } + + +def test_compute_recipe_cost_sums_ingredient_costs(): + recipe_id = uuid4() + i1, i2 = uuid4(), uuid4() + g1, g2 = uuid4(), uuid4() + matches = { + i1: [_match(g1, "Chicken", current=4.00, regular=5.00, is_on_sale=True)], + i2: [_match(g2, "Olive Oil", current=8.00, regular=8.00, is_on_sale=False)], + } + pantry_ingredient_ids = set() + + result = compute_recipe_cost( + recipe_id=recipe_id, + ingredients=[_ingredient(i1, qty=2.0), _ingredient(i2, qty=1.0)], + match_index=matches, + pantry_ingredient_ids=pantry_ingredient_ids, + ) + + assert isinstance(result, RecipeCost) + assert result.recipe_id == recipe_id + assert result.total_ingredient_count == 2 + assert result.matched_ingredient_count == 2 + assert result.sale_ingredient_count == 1 + assert result.total_cost == Decimal("16.00") # 2*4 + 1*8 + assert result.total_savings == Decimal("2.00") # 2*(5-4) + + +def test_unmatched_ingredient_zero_cost_and_savings(): + recipe_id = uuid4() + i1 = uuid4() + result = compute_recipe_cost( + recipe_id=recipe_id, + ingredients=[_ingredient(i1, qty=1.0)], + match_index={}, + pantry_ingredient_ids=set(), + ) + assert result.matched_ingredient_count == 0 + assert result.total_cost == Decimal("0.00") + assert result.total_savings == Decimal("0.00") + + +def test_pantry_hits_counted(): + recipe_id = uuid4() + i1, i2 = uuid4(), uuid4() + g1, g2 = uuid4(), uuid4() + matches = { + i1: [_match(g1, "X", current=2.00, regular=2.00, is_on_sale=False)], + i2: [_match(g2, "Y", current=3.00, regular=3.00, is_on_sale=False)], + } + result = compute_recipe_cost( + recipe_id=recipe_id, + ingredients=[_ingredient(i1), _ingredient(i2)], + match_index=matches, + pantry_ingredient_ids={i1}, + ) + assert result.pantry_hit_count == 1 + assert result.pantry_hit_pct == 0.5