forked from cartsnitch/cartsnitch
166 lines
5.1 KiB
Python
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
|