Files
Meal-Planner/backend/app/services/planner/cost.py
T

124 lines
4.0 KiB
Python

"""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,
)