Public Access
124 lines
4.0 KiB
Python
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,
|
|
)
|