233 lines
7.0 KiB
Python
233 lines
7.0 KiB
Python
"""Preiserfassung und Preisvergleich.
|
|
|
|
Ein Preis wird festgehalten, sobald er an einem Eintrag steht, der einem
|
|
Markt zugeordnet ist. Ohne Markt ergibt er keinen Vergleichswert und wird
|
|
deshalb nicht aufgenommen.
|
|
"""
|
|
|
|
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Article, ListItem, Market, PricePoint
|
|
from app.schemas_shopping import (
|
|
ArticlePrices,
|
|
MarketPrice,
|
|
PriceEntry,
|
|
PriceHint,
|
|
PriceOverview,
|
|
PriceOverviewRow,
|
|
)
|
|
from app.security import utcnow
|
|
|
|
# Innerhalb dieser Spanne gilt ein gleicher Preis am selben Markt als
|
|
# derselbe Beobachtungswert. Ohne das entstünde bei jedem Tippen im
|
|
# Preisfeld ein neuer Eintrag.
|
|
DEDUPE_WINDOW = timedelta(hours=12)
|
|
|
|
|
|
def record_price(db: Session, item: ListItem) -> None:
|
|
"""Hält den Preis eines Eintrags fest, sofern sinnvoll.
|
|
|
|
Wird nach jeder Änderung an einem Eintrag aufgerufen; die Prüfungen
|
|
hier entscheiden, ob daraus wirklich ein Datenpunkt wird.
|
|
"""
|
|
if not item.price_cents or not item.market_id:
|
|
return
|
|
|
|
latest = db.scalar(
|
|
select(PricePoint)
|
|
.where(
|
|
PricePoint.article_id == item.article_id,
|
|
PricePoint.market_id == item.market_id,
|
|
)
|
|
.order_by(PricePoint.recorded_at.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
if latest is not None:
|
|
same_price = latest.price_cents == item.price_cents
|
|
same_size = (latest.pack_size == item.pack_size
|
|
and latest.pack_unit == item.pack_unit)
|
|
if same_price and same_size and utcnow() - latest.recorded_at < DEDUPE_WINDOW:
|
|
return
|
|
|
|
db.add(
|
|
PricePoint(
|
|
list_id=item.list_id,
|
|
article_id=item.article_id,
|
|
market_id=item.market_id,
|
|
price_cents=item.price_cents,
|
|
pack_size=item.pack_size,
|
|
pack_unit=item.pack_unit,
|
|
)
|
|
)
|
|
|
|
|
|
def _unit_price(price_cents: int, pack_size: Decimal | None) -> int | None:
|
|
"""Preis je Mengeneinheit des Gebindes, in Zehntelcent - sonst gingen
|
|
bei kleinen Mengen zu viele Stellen verloren. Ohne Gebindeangabe
|
|
nicht bestimmbar."""
|
|
if not pack_size or pack_size <= 0:
|
|
return None
|
|
return int(round(price_cents * 10 / float(pack_size)))
|
|
|
|
|
|
def article_prices(db: Session, list_id: str, article: Article) -> ArticlePrices:
|
|
"""Preisverlauf eines Artikels, gegliedert nach Markt."""
|
|
points = db.scalars(
|
|
select(PricePoint)
|
|
.where(PricePoint.article_id == article.id)
|
|
.order_by(PricePoint.recorded_at.desc())
|
|
.limit(500)
|
|
).all()
|
|
|
|
markets = {
|
|
m.id: m
|
|
for m in db.scalars(
|
|
select(Market).where(Market.list_id == list_id)
|
|
).all()
|
|
}
|
|
|
|
grouped: dict[str, list[PricePoint]] = {}
|
|
for point in points:
|
|
grouped.setdefault(point.market_id, []).append(point)
|
|
|
|
rows: list[MarketPrice] = []
|
|
for market_id, entries in grouped.items():
|
|
market = markets.get(market_id)
|
|
if market is None or market.deleted_at is not None:
|
|
continue
|
|
|
|
amounts = [e.price_cents for e in entries]
|
|
newest = entries[0]
|
|
rows.append(
|
|
MarketPrice(
|
|
market_id=market_id,
|
|
market_name=market.name,
|
|
latest_cents=newest.price_cents,
|
|
latest_at=newest.recorded_at,
|
|
latest_pack_size=newest.pack_size,
|
|
latest_pack_unit=newest.pack_unit,
|
|
unit_price_deci=_unit_price(newest.price_cents, newest.pack_size),
|
|
min_cents=min(amounts),
|
|
max_cents=max(amounts),
|
|
observations=len(entries),
|
|
)
|
|
)
|
|
|
|
rows.sort(key=lambda r: r.latest_cents)
|
|
|
|
history = [
|
|
PriceEntry(
|
|
market_id=p.market_id,
|
|
market_name=markets[p.market_id].name if p.market_id in markets else "?",
|
|
price_cents=p.price_cents,
|
|
pack_size=p.pack_size,
|
|
pack_unit=p.pack_unit,
|
|
recorded_at=p.recorded_at,
|
|
)
|
|
for p in points[:100]
|
|
if p.market_id in markets
|
|
]
|
|
|
|
return ArticlePrices(
|
|
article_id=article.id,
|
|
article_name=article.name,
|
|
markets=rows,
|
|
history=history,
|
|
)
|
|
|
|
|
|
def overview(db: Session, list_id: str) -> PriceOverview:
|
|
"""Vergleichstabelle über alle Artikel mit erfassten Preisen."""
|
|
points = db.scalars(
|
|
select(PricePoint)
|
|
.where(PricePoint.list_id == list_id)
|
|
.order_by(PricePoint.recorded_at.desc())
|
|
.limit(5000)
|
|
).all()
|
|
|
|
markets = {
|
|
m.id: m
|
|
for m in db.scalars(
|
|
select(Market).where(
|
|
Market.list_id == list_id, Market.deleted_at.is_(None)
|
|
).order_by(Market.sort_order, Market.name)
|
|
).all()
|
|
}
|
|
articles = {
|
|
a.id: a
|
|
for a in db.scalars(
|
|
select(Article).where(
|
|
Article.list_id == list_id, Article.deleted_at.is_(None)
|
|
)
|
|
).all()
|
|
}
|
|
|
|
# Je Artikel und Markt nur der jüngste Wert - die Liste ist bereits
|
|
# absteigend sortiert, der erste Treffer gewinnt.
|
|
latest: dict[tuple[str, str], PricePoint] = {}
|
|
for point in points:
|
|
key = (point.article_id, point.market_id)
|
|
if key not in latest:
|
|
latest[key] = point
|
|
|
|
rows: list[PriceOverviewRow] = []
|
|
for article_id, article in articles.items():
|
|
prices = {
|
|
market_id: point.price_cents
|
|
for (aid, market_id), point in latest.items()
|
|
if aid == article_id and market_id in markets
|
|
}
|
|
if not prices:
|
|
continue
|
|
|
|
cheapest = min(prices.values())
|
|
best = [mid for mid, cents in prices.items() if cents == cheapest]
|
|
dearest = max(prices.values())
|
|
|
|
rows.append(
|
|
PriceOverviewRow(
|
|
article_id=article_id,
|
|
article_name=article.name,
|
|
prices=prices,
|
|
best_market_ids=best,
|
|
best_cents=cheapest,
|
|
spread_cents=dearest - cheapest,
|
|
)
|
|
)
|
|
|
|
rows.sort(key=lambda r: r.article_name.casefold())
|
|
|
|
return PriceOverview(
|
|
markets=[
|
|
MarketPrice(
|
|
market_id=m.id, market_name=m.name, latest_cents=0,
|
|
latest_at=utcnow(), min_cents=0, max_cents=0, observations=0,
|
|
)
|
|
for m in markets.values()
|
|
],
|
|
market_names={m.id: m.name for m in markets.values()},
|
|
rows=rows,
|
|
)
|
|
|
|
|
|
def hints(db: Session, list_id: str) -> list[PriceHint]:
|
|
"""Kurzhinweise für die Listenansicht: Wo war dieser Artikel zuletzt
|
|
am günstigsten?"""
|
|
data = overview(db, list_id)
|
|
return [
|
|
PriceHint(
|
|
article_id=row.article_id,
|
|
best_market_ids=row.best_market_ids,
|
|
best_cents=row.best_cents,
|
|
spread_cents=row.spread_cents,
|
|
prices=row.prices,
|
|
)
|
|
for row in data.rows
|
|
if row.spread_cents > 0
|
|
]
|