Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht. Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund: 250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und liefe damit gegen die Bestandspruefung beim Auslagern. Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit (GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet - weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck, und Runden wuerde stumm etwas anderes buchen als angegeben. WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in _gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren. Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit verrechnet. Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen schmalen Vertrag. 18 neue Tests. Dass test_wrong_kind_rejected und test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist selbst der Beleg: ohne Bruecke aendert sich nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
603 lines
22 KiB
Python
603 lines
22 KiB
Python
import math
|
||
from collections import defaultdict
|
||
from collections.abc import Iterable
|
||
from datetime import date, timedelta
|
||
from typing import Callable, TypeVar
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..database import get_db
|
||
from ..deps import get_current_user
|
||
from ..models import (
|
||
Group,
|
||
GroupLocationMinStock,
|
||
Item,
|
||
Location,
|
||
Lot,
|
||
Movement,
|
||
PackageType,
|
||
Product,
|
||
ProductLocationMinStock,
|
||
User,
|
||
)
|
||
from ..schemas import (
|
||
BaseUnit,
|
||
ExpiringItem,
|
||
GroupShoppingItem,
|
||
LocationContentEntry,
|
||
LocationContents,
|
||
LocationNeedGroup,
|
||
LocationNeedProduct,
|
||
LocationNeeds,
|
||
MovementOut,
|
||
ShoppingItem,
|
||
ShoppingListAll,
|
||
ShoppingNeed,
|
||
)
|
||
from ..services.conversion import (
|
||
BASE_OF_KIND,
|
||
article_unit,
|
||
display_unit_info,
|
||
group_min_context,
|
||
)
|
||
from ..services import gruppen as gruppen_graph
|
||
from ..services.stock import (
|
||
current_stock,
|
||
descendant_location_ids,
|
||
location_subtree_stock_base,
|
||
summe_bestand_gewichtet,
|
||
summe_bestand_im_subtree_gewichtet,
|
||
)
|
||
from .settings import get_expiry_warning_days
|
||
|
||
router = APIRouter(tags=["views"])
|
||
|
||
#: Schluesseltyp der Bedarfs-Verrechnung: ein Lagerort (bzw. None = „Ueberall")
|
||
#: oder ein Paar aus Gruppe und Ort.
|
||
K = TypeVar("K")
|
||
|
||
|
||
# ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) --------------------
|
||
_UNIT_SHORT: dict[BaseUnit, str] = {
|
||
BaseUnit.piece: "Stk",
|
||
BaseUnit.gram: "g",
|
||
BaseUnit.milliliter: "ml",
|
||
}
|
||
|
||
|
||
def _de_num(x: float) -> str:
|
||
"""Deutsche Kurzzahl ohne unnoetige Nullen: 500, 1, 1,4."""
|
||
r = round(float(x), 2)
|
||
if r == int(r):
|
||
return str(int(r))
|
||
return f"{r:.2f}".rstrip("0").rstrip(".").replace(".", ",")
|
||
|
||
|
||
def _package_plural(db: Session) -> dict[str, str]:
|
||
"""Einzahl -> Mehrzahl der Gebinde (Glas -> Gläser), aus der Gebinde-Tabelle."""
|
||
return {pt.singular: pt.plural for pt in db.query(PackageType).all()}
|
||
|
||
|
||
def _build_need(
|
||
*,
|
||
deficit_base: float,
|
||
stock_base: float,
|
||
min_base: float,
|
||
factor: float,
|
||
singular: str,
|
||
is_package: bool,
|
||
base_unit: BaseUnit | None,
|
||
plural: dict[str, str],
|
||
) -> ShoppingNeed:
|
||
"""Rechnet Basiseinheiten in Gebinde/Artikeleinheiten um und baut die Texte.
|
||
|
||
``factor`` = Basiseinheiten je Gebinde (Packungsgröße bzw. Einheitenfaktor).
|
||
Bei zählbaren Packungen wird die Kaufmenge auf ganze Gebinde aufgerundet."""
|
||
f = factor or 1.0
|
||
count = math.ceil(deficit_base / f - 1e-9) if is_package else round(deficit_base / f, 3)
|
||
label = singular if abs(count) == 1 else plural.get(singular, singular)
|
||
text = f"{_de_num(count)} {label}".strip()
|
||
hint = ""
|
||
base_amount: float | None = None
|
||
if (is_package or f != 1.0) and base_unit is not None:
|
||
base_amount = round(deficit_base, 3)
|
||
hint = f"{_de_num(deficit_base)} {_UNIT_SHORT.get(base_unit, base_unit.value)}"
|
||
return ShoppingNeed(
|
||
text=text,
|
||
hint=hint,
|
||
count=count,
|
||
label=label,
|
||
singular=singular,
|
||
is_package=is_package,
|
||
base_amount=base_amount,
|
||
base_unit=base_unit if base_amount is not None else None,
|
||
stock=round(stock_base / f, 3),
|
||
min_stock=round(min_base / f, 3),
|
||
)
|
||
|
||
|
||
def _artikel_bedarfe(
|
||
db: Session, ort_desc: dict[str, set[str]]
|
||
) -> dict[int, tuple[Product, dict, dict, dict]]:
|
||
"""Je Artikel Mindestbestand, Bestand und verrechneter Bedarf – je Ort.
|
||
|
||
Schluessel ``None`` ist „Ueberall" und liegt ueber allen Lagerorten. Alle
|
||
Mengen in Basiseinheiten.
|
||
"""
|
||
nach_artikel: dict[int, list[ProductLocationMinStock]] = defaultdict(list)
|
||
for e in db.query(ProductLocationMinStock).all():
|
||
if e.min_stock > 0:
|
||
nach_artikel[e.product_id].append(e)
|
||
|
||
ergebnis: dict[int, tuple[Product, dict, dict, dict]] = {}
|
||
for product_id, rows in nach_artikel.items():
|
||
product = db.get(Product, product_id)
|
||
if product is None:
|
||
continue
|
||
mins = {e.location_id: e.min_stock for e in rows}
|
||
bestand = {
|
||
loc: (
|
||
current_stock(db, product.id)
|
||
if loc is None
|
||
else location_subtree_stock_base(db, product, loc)
|
||
)
|
||
for loc in mins
|
||
}
|
||
needs = _netted_topups(mins, _ort_nachfahren(mins, ort_desc), bestand.__getitem__)
|
||
ergebnis[product_id] = (product, mins, bestand, needs)
|
||
return ergebnis
|
||
|
||
|
||
def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
|
||
"""Artikel, bei denen „Überall" etwas fehlt – egal wo im Haus.
|
||
|
||
Käufe für einzelne Lagerorte sind bereits abgezogen: derselbe Artikel steht
|
||
dadurch nicht mehr doppelt in der Liste (einmal „Gesamt", einmal je Ort).
|
||
"""
|
||
plural = _package_plural(db)
|
||
ort_desc = _ort_nachfahren_tabelle(db)
|
||
items: list[ShoppingItem] = []
|
||
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
|
||
if None not in mins:
|
||
continue # nur Ort-Bedarfe, kein „Überall"
|
||
fehlt = needs.get(None, 0.0)
|
||
if fehlt <= 1e-9:
|
||
continue
|
||
factor, singular = article_unit(product)
|
||
items.append(
|
||
ShoppingItem(
|
||
product_id=product.id,
|
||
name=product.name,
|
||
base_unit=product.base_unit,
|
||
package_size=product.package_size,
|
||
stock=round(bestand[None], 3),
|
||
min_stock=mins[None],
|
||
deficit=round(fehlt, 3),
|
||
need=_build_need(
|
||
deficit_base=fehlt,
|
||
stock_base=bestand[None],
|
||
min_base=mins[None],
|
||
factor=factor,
|
||
singular=singular,
|
||
is_package=bool(product.package_size and product.package_size > 0),
|
||
base_unit=product.base_unit,
|
||
plural=plural,
|
||
),
|
||
)
|
||
)
|
||
items.sort(key=lambda i: i.deficit, reverse=True)
|
||
return items
|
||
|
||
|
||
@router.get("/shopping-list", response_model=list[ShoppingItem])
|
||
def shopping_list(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[ShoppingItem]:
|
||
return einkaufsliste_artikel(db)
|
||
|
||
|
||
def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, dict, dict, dict, dict]:
|
||
"""Je (Gruppe, Ort) Mindestbestand, Bestand und verrechneter Bedarf.
|
||
|
||
Hier wirken ZWEI Hierarchien zusammen: der Gruppen-Graph und der
|
||
Lagerort-Baum. „1 kg Grillwurst in die Küche" deckt auch „Wurst in Lemgo".
|
||
Deshalb ist ein Schlüssel ein Paar, und ``(h, m)`` gilt als Nachfahre von
|
||
``(g, l)``, wenn h unter g und m unter l liegt (oder gleich ist).
|
||
|
||
Verrechnet wird nur, wo die zählenden Artikel der Untergruppe eine Teilmenge
|
||
der Obergruppe sind: zählt „Wurst" in Kilogramm, ihre Untergruppe aber in
|
||
Stück, kommt ein Kauf dort oben gar nicht an (siehe die Einheiten-Filterung
|
||
in ``group_min_context``).
|
||
|
||
Alle Mengen in Basiseinheiten – nur darin lassen sich Gruppen mit
|
||
verschiedenen Erfassungseinheiten überhaupt gegeneinander verrechnen.
|
||
"""
|
||
eintraege = [e for e in db.query(GroupLocationMinStock).all() if e.min_stock > 0]
|
||
gruppen: dict[int, Group] = {}
|
||
for e in eintraege:
|
||
if e.group_id not in gruppen:
|
||
g = db.get(Group, e.group_id)
|
||
if g is not None:
|
||
gruppen[e.group_id] = g
|
||
|
||
ctxs = {gid: group_min_context(g) for gid, g in gruppen.items()}
|
||
artikel = {gid: {p.id for p in c.matching} for gid, c in ctxs.items()}
|
||
gruppen_desc = {
|
||
gid: {
|
||
h.id
|
||
for h in gruppen_graph.teilgraph(g)
|
||
if h.id != gid
|
||
and h.id in artikel
|
||
# Nur zwischen Gruppen verrechnen, die in DERSELBEN Basiseinheit
|
||
# zaehlen. Frueher folgte das nebenbei aus der Teilmengen-Bedingung:
|
||
# Artikel verschiedener Arten waren zwangslaeufig disjunkt. Seit die
|
||
# Zweiteinheit einen Stueck-Artikel auch in einer Gramm-Gruppe
|
||
# mitzaehlen laesst, gilt das nicht mehr – und ein Bedarf in Stueck
|
||
# duerfte keinen Bedarf in Gramm decken (siehe _netted_topups:
|
||
# „Alle Mengen muessen in DERSELBEN Einheit vorliegen").
|
||
and ctxs[h.id].base_unit == ctxs[gid].base_unit
|
||
and artikel[h.id] <= artikel[gid]
|
||
}
|
||
for gid, g in gruppen.items()
|
||
}
|
||
|
||
minima: dict[tuple[int, str | None], float] = {}
|
||
bestand: dict[tuple[int, str | None], float] = {}
|
||
for e in eintraege:
|
||
ctx = ctxs.get(e.group_id)
|
||
if ctx is None:
|
||
continue
|
||
schluessel = (e.group_id, e.location_id)
|
||
minima[schluessel] = e.min_stock
|
||
bestand[schluessel] = (
|
||
summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
|
||
if e.location_id is None
|
||
else summe_bestand_im_subtree_gewichtet(
|
||
db, ctx.matching, e.location_id, ctx.faktoren)
|
||
)
|
||
|
||
alle_orte = {loc for (_, loc) in minima if loc is not None}
|
||
|
||
def nachfahren(k: tuple[int, str | None]) -> set[tuple[int, str | None]]:
|
||
gid, loc = k
|
||
unten = gruppen_desc.get(gid, set()) | {gid}
|
||
# „Überall" (None) liegt über allen Lagerorten.
|
||
orte = (alle_orte if loc is None else ort_desc.get(loc, set())) | {loc}
|
||
return {(h, m) for h in unten for m in orte if (h, m) in minima} - {k}
|
||
|
||
needs = _netted_topups(minima, nachfahren, bestand.__getitem__)
|
||
return gruppen, ctxs, minima, bestand, needs
|
||
|
||
|
||
def einkaufsliste_gruppen(db: Session) -> list[GroupShoppingItem]:
|
||
"""Gruppen, bei denen „Überall" etwas fehlt.
|
||
|
||
Gruppen-Bestand = Summe der Artikelbestände der Gruppe UND ihrer
|
||
Untergruppen (Basiseinheiten). Käufe für Untergruppen und für einzelne
|
||
Lagerorte sind bereits abgezogen.
|
||
"""
|
||
plural = _package_plural(db)
|
||
ort_desc = _ort_nachfahren_tabelle(db)
|
||
gruppen, ctxs, minima, bestand, needs = _gruppen_bedarfe(db, ort_desc)
|
||
|
||
items: list[GroupShoppingItem] = []
|
||
for (gid, loc), fehlt in needs.items():
|
||
if loc is not None or fehlt <= 1e-9:
|
||
continue
|
||
ctx = ctxs[gid]
|
||
schluessel = (gid, None)
|
||
items.append(
|
||
GroupShoppingItem(
|
||
group_id=gid,
|
||
name=gruppen[gid].name,
|
||
stock=round(bestand[schluessel] / ctx.divisor, 3),
|
||
min_stock=round(minima[schluessel] / ctx.divisor, 3),
|
||
deficit=round(fehlt / ctx.divisor, 3),
|
||
unit_name=ctx.label,
|
||
product_count=len(ctx.matching),
|
||
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
|
||
need=_build_need(
|
||
deficit_base=fehlt,
|
||
stock_base=bestand[schluessel],
|
||
min_base=minima[schluessel],
|
||
factor=ctx.divisor,
|
||
singular=ctx.label,
|
||
is_package=ctx.is_package,
|
||
base_unit=ctx.base_unit,
|
||
plural=plural,
|
||
),
|
||
)
|
||
)
|
||
items.sort(key=lambda i: i.deficit, reverse=True)
|
||
return items
|
||
|
||
|
||
@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem])
|
||
def group_shopping_list(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[GroupShoppingItem]:
|
||
return einkaufsliste_gruppen(db)
|
||
|
||
|
||
def _netted_topups(
|
||
minima: dict[K, float],
|
||
nachfahren: Callable[[K], set[K]],
|
||
stock_of: Callable[[K], float],
|
||
) -> dict[K, float]:
|
||
"""Bedarfe entlang ihrer Hierarchie verrechnen.
|
||
|
||
Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und
|
||
deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge –
|
||
ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5,
|
||
Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2.
|
||
|
||
``nachfahren`` liefert die TRANSITIVE Nachfahren-MENGE, nicht die direkten
|
||
Kinder. Beim Gruppen-Graphen ist eine Untergruppe ueber mehrere Wege
|
||
erreichbar – als Menge zaehlt sie in ``committed`` trotzdem nur einmal.
|
||
|
||
Alle Mengen muessen in DERSELBEN Einheit vorliegen (hier: Basiseinheiten),
|
||
sonst wird Aepfel mit Birnen verrechnet.
|
||
"""
|
||
schluessel = list(minima)
|
||
desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel}
|
||
memo: dict[K, float] = {}
|
||
|
||
def topup(k: K) -> float:
|
||
if k not in memo:
|
||
# Vorbelegen schuetzt vor einem Ring in den Daten: der wuerde sonst
|
||
# endlos rekursieren (die API laesst keinen zu, ein Import schon).
|
||
memo[k] = 0.0
|
||
committed = sum(topup(d) for d in desc[k])
|
||
memo[k] = max(0.0, minima[k] - (stock_of(k) + committed))
|
||
return memo[k]
|
||
|
||
return {k: topup(k) for k in schluessel}
|
||
|
||
|
||
def _ort_nachfahren(
|
||
schluessel: Iterable[str | None], ort_desc: dict[str, set[str]]
|
||
) -> Callable[[str | None], set[str | None]]:
|
||
"""Nachfahren-Funktion fuer die Ort-Hierarchie eines Bedarfssatzes.
|
||
|
||
„Ueberall" (``None``) liegt ueber ALLEN Lagerorten. Dadurch verrechnet
|
||
dieselbe Faltung auch Ueberall gegen die einzelnen Orte – der frueher
|
||
getrennte Gesamt-Mindestbestand erzeugt keinen zweiten Eintrag mehr.
|
||
"""
|
||
orte = list(schluessel)
|
||
|
||
def nachfahren(k: str | None) -> set[str | None]:
|
||
if k is None:
|
||
return {d for d in orte if d is not None}
|
||
return {d for d in orte if d is not None and d in ort_desc.get(k, set())}
|
||
|
||
return nachfahren
|
||
|
||
|
||
def _ort_nachfahren_tabelle(db: Session) -> dict[str, set[str]]:
|
||
"""Unterorte je Lagerort, einmal je Anfrage statt je Artikel und Gruppe.
|
||
|
||
``descendant_location_ids`` geht je Ebene an die Datenbank; frueher wurde es
|
||
fuer jeden Bedarfssatz neu aufgerufen.
|
||
"""
|
||
return {loc.id: descendant_location_ids(db, loc.id) for loc in db.query(Location).all()}
|
||
|
||
|
||
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
|
||
def shopping_list_by_location(
|
||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||
) -> list[LocationNeeds]:
|
||
"""Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
|
||
|
||
„Überall" gehört nicht hierher – das liefern ``/shopping-list`` und
|
||
``/shopping-list/groups``. Verrechnet wird über beide Listen hinweg, ein
|
||
Artikel steht also nur einmal drin.
|
||
"""
|
||
plural = _package_plural(db)
|
||
ort_desc = _ort_nachfahren_tabelle(db)
|
||
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
|
||
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
|
||
|
||
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
|
||
faktor, label = article_unit(product)
|
||
faktor = faktor or 1.0
|
||
ist_gebinde = bool(product.package_size and product.package_size > 0)
|
||
for loc, need in needs.items():
|
||
if loc is None or need <= 1e-9:
|
||
continue
|
||
prod_needs[loc].append(LocationNeedProduct(
|
||
product_id=product.id, name=product.name, unit_label=label,
|
||
# Nach aussen weiterhin in Artikeleinheiten – gespeichert und
|
||
# gerechnet wird intern in Basiseinheiten.
|
||
stock=round(bestand[loc] / faktor, 3),
|
||
min_stock=round(mins[loc] / faktor, 3),
|
||
deficit=round(need / faktor, 3),
|
||
need=_build_need(
|
||
deficit_base=need,
|
||
stock_base=bestand[loc],
|
||
min_base=mins[loc],
|
||
factor=faktor,
|
||
singular=label,
|
||
is_package=ist_gebinde,
|
||
base_unit=product.base_unit,
|
||
plural=plural,
|
||
),
|
||
))
|
||
|
||
gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc)
|
||
for (gid, loc), need in needs_g.items():
|
||
if loc is None or need <= 1e-9:
|
||
continue
|
||
ctx = ctxs[gid]
|
||
schluessel = (gid, loc)
|
||
group_needs[loc].append(LocationNeedGroup(
|
||
group_id=gid, name=gruppen[gid].name, unit_name=ctx.label,
|
||
stock=round(bestand_g[schluessel] / ctx.divisor, 3),
|
||
min_stock=round(minima[schluessel] / ctx.divisor, 3),
|
||
deficit=round(need / ctx.divisor, 3),
|
||
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
|
||
need=_build_need(
|
||
deficit_base=need,
|
||
stock_base=bestand_g[schluessel],
|
||
min_base=minima[schluessel],
|
||
factor=ctx.divisor,
|
||
singular=ctx.label,
|
||
is_package=ctx.is_package,
|
||
base_unit=ctx.base_unit,
|
||
plural=plural,
|
||
),
|
||
))
|
||
|
||
loc_ids = set(prod_needs) | set(group_needs)
|
||
namen = {
|
||
loc.id: loc.name
|
||
for loc in db.query(Location).filter(Location.id.in_(loc_ids)).all()
|
||
}
|
||
result: list[LocationNeeds] = []
|
||
for loc_id in sorted(loc_ids, key=lambda i: namen.get(i, "")):
|
||
result.append(LocationNeeds(
|
||
location_id=loc_id,
|
||
location_name=namen.get(loc_id, "?"),
|
||
products=sorted(prod_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
||
groups=sorted(group_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
|
||
))
|
||
return result
|
||
|
||
|
||
@router.get("/shopping-list/all", response_model=ShoppingListAll)
|
||
def shopping_list_all(
|
||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||
) -> ShoppingListAll:
|
||
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
|
||
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
|
||
Erspart drei getrennte Abfragen."""
|
||
return ShoppingListAll(
|
||
products=shopping_list(db, user),
|
||
groups=group_shopping_list(db, user),
|
||
by_location=shopping_list_by_location(db, user),
|
||
)
|
||
|
||
|
||
@router.get("/location/{code}", response_model=LocationContents)
|
||
def location_contents(
|
||
code: str,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> LocationContents:
|
||
"""Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte.
|
||
|
||
Ziel des Lagerort-QR (`/l/<code>`) beim Nachschlagen: einmal scannen und
|
||
sehen, was im Regal/Fach (und allem darunter) steht. Zählt sowohl
|
||
Lot-Bestände (Lebensmittel/Objekte) als auch Einzelstücke.
|
||
"""
|
||
loc = db.get(Location, code)
|
||
if loc is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||
|
||
ids = {code} | descendant_location_ids(db, code)
|
||
product_ids: set[int] = {
|
||
pid for (pid,) in db.query(Lot.product_id).filter(Lot.location_id.in_(ids)).distinct()
|
||
}
|
||
product_ids |= {
|
||
pid for (pid,) in db.query(Item.product_id).filter(Item.location_id.in_(ids)).distinct()
|
||
}
|
||
|
||
entries: list[LocationContentEntry] = []
|
||
for pid in product_ids:
|
||
product = db.get(Product, pid)
|
||
if product is None:
|
||
continue
|
||
faktor, label = article_unit(product)
|
||
stock = location_subtree_stock_base(db, product, code) / (faktor or 1.0)
|
||
if stock <= 0:
|
||
continue
|
||
entries.append(LocationContentEntry(
|
||
product_id=product.id, name=product.name, brand=product.brand,
|
||
stock=round(stock, 3), unit_label=label, individual=product.individual,
|
||
))
|
||
entries.sort(key=lambda e: e.name.lower())
|
||
return LocationContents(location_id=code, location_name=loc.name, products=entries)
|
||
|
||
|
||
@router.get("/expiring", response_model=list[ExpiringItem])
|
||
def expiring(
|
||
days: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[ExpiringItem]:
|
||
"""Chargen, die innerhalb der Warnfrist ablaufen (oder schon abgelaufen sind)."""
|
||
if days is None:
|
||
days = get_expiry_warning_days(db)
|
||
today = date.today()
|
||
threshold = today + timedelta(days=days)
|
||
|
||
lots = (
|
||
db.query(Lot)
|
||
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
|
||
.order_by(Lot.best_before)
|
||
.all()
|
||
)
|
||
result: list[ExpiringItem] = []
|
||
for lot in lots:
|
||
product = lot.product
|
||
unit_name, unit_factor = display_unit_info(product)
|
||
result.append(
|
||
ExpiringItem(
|
||
lot_id=lot.id,
|
||
product_id=product.id,
|
||
product_name=product.name,
|
||
quantity=lot.quantity,
|
||
base_unit=product.base_unit,
|
||
best_before=lot.best_before,
|
||
best_before_precision=lot.best_before_precision,
|
||
days_left=(lot.best_before - today).days,
|
||
package_size=product.package_size,
|
||
package_label=product.package_label,
|
||
unit_name=unit_name,
|
||
unit_factor=unit_factor,
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/movements", response_model=list[MovementOut])
|
||
def movements(
|
||
limit: int = 100,
|
||
product_id: int | None = None,
|
||
db: Session = Depends(get_db),
|
||
_: User = Depends(get_current_user),
|
||
) -> list[MovementOut]:
|
||
"""Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst."""
|
||
limit = max(1, min(limit, 500))
|
||
query = (
|
||
db.query(Movement, Product, User)
|
||
.join(Product, Movement.product_id == Product.id)
|
||
.outerjoin(User, Movement.user_id == User.id)
|
||
)
|
||
if product_id is not None:
|
||
query = query.filter(Movement.product_id == product_id)
|
||
rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all()
|
||
|
||
result: list[MovementOut] = []
|
||
for movement, product, user in rows:
|
||
unit_name, unit_factor = display_unit_info(product)
|
||
result.append(
|
||
MovementOut(
|
||
id=movement.id,
|
||
product_id=product.id,
|
||
product_name=product.name,
|
||
type=movement.type.value,
|
||
quantity=movement.quantity,
|
||
base_unit=product.base_unit,
|
||
unit_used=movement.unit_used,
|
||
username=user.username if user else None,
|
||
note=movement.note,
|
||
created_at=movement.created_at,
|
||
package_size=product.package_size,
|
||
package_label=product.package_label,
|
||
unit_name=unit_name,
|
||
unit_factor=unit_factor,
|
||
)
|
||
)
|
||
return result
|