Files

166 lines
5.1 KiB
Python

"""Shrinkflation detection — compare unit sizes across price history.
Flags cases where a product's size decreased while price stayed flat or increased.
"""
import uuid
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from sqlalchemy import and_, select
from sqlalchemy.orm import Session
from cartsnitch_common.constants import SizeUnit
from cartsnitch_common.models.product import NormalizedProduct
from cartsnitch_common.models.shrinkflation import ShrinkflationEvent
# Conversion factors to a common base unit (grams for weight, ml for volume, count for discrete)
_WEIGHT_TO_GRAMS: dict[SizeUnit, Decimal] = {
SizeUnit.G: Decimal("1"),
SizeUnit.KG: Decimal("1000"),
SizeUnit.OZ: Decimal("28.3495"),
SizeUnit.LB: Decimal("453.592"),
}
_VOLUME_TO_ML: dict[SizeUnit, Decimal] = {
SizeUnit.ML: Decimal("1"),
SizeUnit.L: Decimal("1000"),
SizeUnit.FL_OZ: Decimal("29.5735"),
}
_COUNT_UNITS: set[SizeUnit] = {SizeUnit.CT, SizeUnit.PK}
def _to_comparable(size: str, unit: SizeUnit) -> Decimal | None:
"""Convert a size+unit to a comparable numeric value.
Returns None if units are not comparable (different measurement systems).
"""
try:
size_val = Decimal(size)
except Exception:
return None
if unit in _WEIGHT_TO_GRAMS:
return size_val * _WEIGHT_TO_GRAMS[unit]
if unit in _VOLUME_TO_ML:
return size_val * _VOLUME_TO_ML[unit]
if unit in _COUNT_UNITS:
return size_val
return None
def _units_comparable(unit_a: SizeUnit, unit_b: SizeUnit) -> bool:
"""Check if two units are in the same measurement system."""
if unit_a in _WEIGHT_TO_GRAMS and unit_b in _WEIGHT_TO_GRAMS:
return True
if unit_a in _VOLUME_TO_ML and unit_b in _VOLUME_TO_ML:
return True
return unit_a in _COUNT_UNITS and unit_b in _COUNT_UNITS
@dataclass(frozen=True)
class ShrinkflationCandidate:
"""A potential shrinkflation detection before writing to DB."""
product: NormalizedProduct
old_size: str
new_size: str
old_unit: SizeUnit
new_unit: SizeUnit
old_price: Decimal | None
new_price: Decimal | None
confidence: Decimal
size_change_pct: Decimal
def detect_shrinkflation(
session: Session,
product: NormalizedProduct,
new_size: str,
new_unit: SizeUnit,
new_price: Decimal | None = None,
detected_date: date | None = None,
min_size_decrease_pct: Decimal = Decimal("1"),
) -> ShrinkflationEvent | None:
"""Check if a product's size has decreased (shrinkflation).
Compares the new size against the product's recorded size.
If size decreased while price stayed flat or increased, records a shrinkflation event.
Returns the ShrinkflationEvent if detected, None otherwise.
"""
if not product.size or not product.size_unit:
return None
old_unit = SizeUnit(product.size_unit)
if not _units_comparable(old_unit, new_unit):
return None
old_comparable = _to_comparable(product.size, old_unit)
new_comparable = _to_comparable(new_size, new_unit)
if old_comparable is None or new_comparable is None:
return None
if new_comparable >= old_comparable:
return None # Size didn't decrease
size_change_pct = ((old_comparable - new_comparable) / old_comparable * 100).quantize(
Decimal("0.01")
)
if size_change_pct < min_size_decrease_pct:
return None
# Check existing events to avoid duplicates
existing = session.execute(
select(ShrinkflationEvent).where(
and_(
ShrinkflationEvent.normalized_product_id == product.id,
ShrinkflationEvent.old_size == product.size,
ShrinkflationEvent.new_size == new_size,
)
)
).scalar_one_or_none()
if existing:
return existing
# Confidence: higher if size change is significant and price didn't drop
confidence = Decimal("0.70")
if size_change_pct >= Decimal("5"):
confidence = Decimal("0.85")
if size_change_pct >= Decimal("10"):
confidence = Decimal("0.95")
# Get the last known price for comparison
old_price: Decimal | None = None
if product.price_histories:
latest = max(product.price_histories, key=lambda ph: ph.observed_date)
old_price = latest.regular_price
if old_price is not None and new_price is not None and new_price < old_price:
# Price actually dropped — less likely to be shrinkflation
confidence = max(Decimal("0.30"), confidence - Decimal("0.30"))
event = ShrinkflationEvent(
id=uuid.uuid4(),
normalized_product_id=product.id,
detected_date=detected_date or date.today(),
old_size=product.size,
new_size=new_size,
old_unit=old_unit,
new_unit=new_unit,
price_at_old_size=old_price,
price_at_new_size=new_price,
confidence=confidence,
notes=(
f"Size decreased {size_change_pct}% ({product.size} {old_unit}{new_size} {new_unit})"
),
)
session.add(event)
session.flush()
return event