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>
252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
"""Kleine gemeinsame Helfer für Router."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections import defaultdict
|
||
from datetime import date
|
||
|
||
from fastapi import HTTPException, status
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from .models import (
|
||
Barcode,
|
||
BaseUnit,
|
||
Category,
|
||
CategoryTracking,
|
||
Item,
|
||
Lot,
|
||
Product,
|
||
ProductImage,
|
||
)
|
||
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
|
||
from .services.conversion import (
|
||
BASE_OF_KIND,
|
||
KIND_OF_BASE,
|
||
display_unit_info,
|
||
zweit_faktor,
|
||
)
|
||
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||
from .services.stock import current_stock, location_subtree_stock_base
|
||
|
||
|
||
def _min_anzeige(
|
||
product: Product, min_base: float, disp_name: str, disp_factor: float
|
||
) -> tuple[float, str]:
|
||
"""Mindestbestand (Basiseinheiten) in der ERFASSTEN Einheit + deren Name.
|
||
|
||
Die Erfassungseinheit darf seit der Zweiteinheit artfremd sein („500 g" an
|
||
einem stueckweise gefuehrten Artikel). ``zweit_faktor`` ist 1,0, solange sie
|
||
zur eigenen Art gehoert – dieselbe Formel deckt also beide Faelle ab.
|
||
"""
|
||
if product.min_stock_in_packages and product.package_size:
|
||
return min_base / product.package_size, "Pkg"
|
||
einheit = product.min_stock_unit
|
||
if einheit is not None:
|
||
bruecke = zweit_faktor(product, BASE_OF_KIND[einheit.kind])
|
||
if bruecke is not None:
|
||
return min_base * bruecke / einheit.factor, einheit.name
|
||
# Bruecke nachtraeglich entfernt: lieber die Artikeleinheit zeigen als
|
||
# eine Zahl in einer Einheit, die es fuer diesen Artikel nicht mehr gibt.
|
||
return min_base / disp_factor, disp_name
|
||
|
||
|
||
def _zweit_felder(out, product: Product) -> None:
|
||
"""Abgeleitete Zweiteinheit-Felder setzen, damit die Oberflaechen nicht
|
||
selbst dividieren muessen."""
|
||
if not product.secondary_base:
|
||
return
|
||
try:
|
||
ziel = BaseUnit(product.secondary_base)
|
||
except ValueError:
|
||
return # unbekannter Altwert – dann eben keine Bruecke
|
||
out.secondary_factor = zweit_faktor(product, ziel)
|
||
|
||
|
||
def product_tracking(db: Session, product: Product) -> str:
|
||
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet.
|
||
|
||
Ohne Kategorie gilt "food" – so bleibt das Verhalten bestehender
|
||
(reiner Lebensmittel-)Installationen unverändert. Ein Artikel in einer
|
||
Gegenstands-Kategorie wird als "object" geführt.
|
||
|
||
Einzelstücke und Verbrauchsgegenstände sind immer Gegenstände: Diese Arten
|
||
gibt es nur bei Gegenständen, sie werden aber (anders als "Menge je Lagerort")
|
||
über ein Flag am Artikel geführt. So bleibt ein als Verbrauchsgegenstand
|
||
umgestellter Artikel auch ohne (Gegenstands-)Kategorie ein Gegenstand.
|
||
"""
|
||
if product.individual or product.bulk:
|
||
return CategoryTracking.object.value
|
||
if product.category_id is None:
|
||
return CategoryTracking.food.value
|
||
cat = product.category or db.get(Category, product.category_id)
|
||
return cat.tracking if cat and cat.tracking else CategoryTracking.food.value
|
||
|
||
|
||
def product_to_out(db: Session, product: Product) -> ProductOut:
|
||
out = ProductOut.model_validate(product)
|
||
out.stock = current_stock(db, product.id)
|
||
out.expired_count = (
|
||
db.query(Lot)
|
||
.filter(
|
||
Lot.product_id == product.id,
|
||
Lot.best_before.isnot(None),
|
||
Lot.best_before < date.today(),
|
||
Lot.quantity > 0,
|
||
)
|
||
.count()
|
||
)
|
||
out.barcodes = [
|
||
BarcodeOut.model_validate(b)
|
||
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
|
||
]
|
||
out.category_name = product.category.name if product.category else None
|
||
# Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der
|
||
# Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
|
||
bild_ts = (
|
||
db.query(ProductImage.updated_at)
|
||
.filter(ProductImage.product_id == product.id)
|
||
.scalar()
|
||
)
|
||
out.image_version = int(bild_ts.timestamp()) if bild_ts is not None else None
|
||
out.tracking = CategoryTracking(product_tracking(db, product))
|
||
out.shop_name = product.shop.name if product.shop else None
|
||
out.kind = KIND_OF_BASE[product.base_unit].value
|
||
name, factor = display_unit_info(product)
|
||
out.unit_name = name
|
||
out.unit_factor = factor
|
||
|
||
# „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand.
|
||
# ``out.min_stock`` bleibt im Vertrag – Dashboards, Einkaufsliste und App
|
||
# lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle.
|
||
out.min_stock = lies_ueberall(product.location_min_stocks)
|
||
if out.min_stock is not None:
|
||
out.min_stock_display, out.min_stock_unit_label = _min_anzeige(
|
||
product, out.min_stock, name, factor
|
||
)
|
||
_zweit_felder(out, product)
|
||
|
||
out.location_min_stocks = [
|
||
LocationMinStockOut(
|
||
location_id=e.location_id,
|
||
location_name=UEBERALL_NAME if e.location_id is None else (
|
||
e.location.name if e.location else None
|
||
),
|
||
min_stock=e.min_stock,
|
||
stock=(
|
||
out.stock
|
||
if e.location_id is None
|
||
else location_subtree_stock_base(db, product, e.location_id)
|
||
),
|
||
)
|
||
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
|
||
for e in sorted(
|
||
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
|
||
)
|
||
]
|
||
return out
|
||
|
||
|
||
def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOut]:
|
||
"""Wie ``product_to_out``, aber für viele Artikel mit **gebündelten** Abfragen
|
||
statt N+1 – für die Listen-Ansichten. Bestand, abgelaufene Chargen, Codes und
|
||
Bild-Version werden je einmal für alle Artikel geholt; Relationen (Kategorie,
|
||
Shop, Einheiten, Bedarfe, Feldwerte) sollten vom Aufrufer eager-geladen sein.
|
||
"""
|
||
if not products:
|
||
return []
|
||
ids = [p.id for p in products]
|
||
|
||
# Bestand: Lot-Summen (Nicht-Einzelstücke) bzw. Item-Anzahl (Einzelstücke).
|
||
lot_sums = dict(
|
||
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
|
||
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
|
||
)
|
||
item_counts = dict(
|
||
db.query(Item.product_id, func.count(Item.id))
|
||
.filter(Item.product_id.in_(ids)).group_by(Item.product_id).all()
|
||
)
|
||
# Abgelaufene Chargen je Artikel.
|
||
heute = date.today()
|
||
expired = dict(
|
||
db.query(Lot.product_id, func.count(Lot.id))
|
||
.filter(
|
||
Lot.product_id.in_(ids),
|
||
Lot.best_before.isnot(None),
|
||
Lot.best_before < heute,
|
||
Lot.quantity > 0,
|
||
)
|
||
.group_by(Lot.product_id).all()
|
||
)
|
||
# Zusätzliche EAN-Codes je Artikel.
|
||
codes: dict[int, list[Barcode]] = defaultdict(list)
|
||
for b in db.query(Barcode).filter(Barcode.product_id.in_(ids)).order_by(Barcode.id).all():
|
||
codes[b.product_id].append(b)
|
||
# Bild-Version (Epoch der letzten Bildänderung) je Artikel.
|
||
imgs = dict(
|
||
db.query(ProductImage.product_id, ProductImage.updated_at)
|
||
.filter(ProductImage.product_id.in_(ids)).all()
|
||
)
|
||
|
||
out: list[ProductOut] = []
|
||
for product in products:
|
||
o = ProductOut.model_validate(product)
|
||
o.stock = (
|
||
float(item_counts.get(product.id, 0)) if product.individual
|
||
else float(lot_sums.get(product.id, 0.0))
|
||
)
|
||
o.expired_count = expired.get(product.id, 0)
|
||
o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])]
|
||
o.category_name = product.category.name if product.category else None
|
||
ts = imgs.get(product.id)
|
||
o.image_version = int(ts.timestamp()) if ts is not None else None
|
||
o.tracking = CategoryTracking(product_tracking(db, product))
|
||
o.shop_name = product.shop.name if product.shop else None
|
||
o.kind = KIND_OF_BASE[product.base_unit].value
|
||
name, factor = display_unit_info(product)
|
||
o.unit_name = name
|
||
o.unit_factor = factor
|
||
o.min_stock = lies_ueberall(product.location_min_stocks)
|
||
if o.min_stock is not None:
|
||
o.min_stock_display, o.min_stock_unit_label = _min_anzeige(
|
||
product, o.min_stock, name, factor
|
||
)
|
||
_zweit_felder(o, product)
|
||
o.location_min_stocks = [
|
||
LocationMinStockOut(
|
||
location_id=e.location_id,
|
||
location_name=UEBERALL_NAME if e.location_id is None else (
|
||
e.location.name if e.location else None
|
||
),
|
||
min_stock=e.min_stock,
|
||
stock=(
|
||
o.stock
|
||
if e.location_id is None
|
||
else location_subtree_stock_base(db, product, e.location_id)
|
||
),
|
||
)
|
||
for e in sorted(
|
||
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
|
||
)
|
||
]
|
||
out.append(o)
|
||
return out
|
||
|
||
|
||
def resolve_product(
|
||
db: Session, product_id: int | None, barcode: str | None
|
||
) -> Product:
|
||
"""Findet ein Produkt per ID oder Barcode; wirft 404, wenn keins passt."""
|
||
product: Product | None = None
|
||
if product_id is not None:
|
||
product = db.get(Product, product_id)
|
||
elif barcode:
|
||
product = db.query(Product).filter(Product.barcode == barcode).first()
|
||
else:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST, "product_id oder barcode erforderlich"
|
||
)
|
||
if product is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||
return product
|