forked from cartsnitch/cartsnitch
Merge commit '4cf6f91e954b770198578bcb8db5d98ac964bfed' as 'common'
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user