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