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>
572 lines
18 KiB
Python
572 lines
18 KiB
Python
"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Sequence
|
||
from datetime import date
|
||
|
||
from sqlalchemy import asc, func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from ..models import (
|
||
DatePrecision,
|
||
Item,
|
||
Location,
|
||
Lot,
|
||
Movement,
|
||
MovementType,
|
||
Product,
|
||
User,
|
||
)
|
||
from .conversion import to_base
|
||
from .dates import clean_precision, normalize_best_before
|
||
|
||
|
||
class StockError(ValueError):
|
||
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
|
||
#
|
||
# Technisch wird dieselbe Lot-Tabelle genutzt – je (Produkt, Lagerort) genau
|
||
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
|
||
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
|
||
# oben (check_in/check_out) bleibt davon unberührt.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _object_lot(db: Session, product_id: int, location_id: str | None) -> Lot | None:
|
||
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
|
||
query = db.query(Lot).filter(
|
||
Lot.product_id == product_id, Lot.best_before.is_(None)
|
||
)
|
||
if location_id is None:
|
||
query = query.filter(Lot.location_id.is_(None))
|
||
else:
|
||
query = query.filter(Lot.location_id == location_id)
|
||
return query.first()
|
||
|
||
|
||
def object_add(
|
||
db: Session,
|
||
product: Product,
|
||
quantity: float,
|
||
location_id: str | None,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
) -> Lot:
|
||
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
|
||
lot = _object_lot(db, product.id, location_id)
|
||
if lot is None:
|
||
lot = Lot(
|
||
product_id=product.id,
|
||
quantity=0.0,
|
||
best_before=None,
|
||
best_before_precision=DatePrecision.day.value,
|
||
location_id=location_id,
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
lot.quantity += quantity
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=lot.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.in_,
|
||
quantity=quantity,
|
||
unit_used=product.base_unit.value,
|
||
note=note,
|
||
location_id=location_id,
|
||
)
|
||
)
|
||
return lot
|
||
|
||
|
||
def object_remove(
|
||
db: Session,
|
||
product: Product,
|
||
quantity: float,
|
||
location_id: str | None,
|
||
reason: str,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
) -> None:
|
||
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
|
||
lot = _object_lot(db, product.id, location_id)
|
||
have = lot.quantity if lot else 0.0
|
||
if quantity > have + 1e-9:
|
||
raise StockError(
|
||
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
|
||
f"(benötigt {quantity:g})."
|
||
)
|
||
lot.quantity -= quantity
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=lot.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.out,
|
||
quantity=quantity,
|
||
unit_used=product.base_unit.value,
|
||
note=note,
|
||
location_id=location_id,
|
||
reason=reason,
|
||
)
|
||
)
|
||
if lot.quantity <= 1e-9:
|
||
db.delete(lot)
|
||
|
||
|
||
def object_relocate(
|
||
db: Session,
|
||
product: Product,
|
||
quantity: float,
|
||
from_location_id: str | None,
|
||
to_location_id: str | None,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
) -> None:
|
||
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
|
||
if from_location_id == to_location_id:
|
||
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
|
||
src = _object_lot(db, product.id, from_location_id)
|
||
have = src.quantity if src else 0.0
|
||
if quantity > have + 1e-9:
|
||
raise StockError(
|
||
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
|
||
f"vorhanden (benötigt {quantity:g})."
|
||
)
|
||
beleg = note or "Umlagerung"
|
||
src.quantity -= quantity
|
||
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
|
||
# Ein-/Auslager-Statistiken nicht verfälschen.
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=src.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.adjust,
|
||
quantity=-quantity,
|
||
unit_used=product.base_unit.value,
|
||
note=beleg,
|
||
location_id=from_location_id,
|
||
)
|
||
)
|
||
if src.quantity <= 1e-9:
|
||
db.delete(src)
|
||
|
||
dest = _object_lot(db, product.id, to_location_id)
|
||
if dest is None:
|
||
dest = Lot(
|
||
product_id=product.id,
|
||
quantity=0.0,
|
||
best_before=None,
|
||
best_before_precision=DatePrecision.day.value,
|
||
location_id=to_location_id,
|
||
)
|
||
db.add(dest)
|
||
db.flush()
|
||
dest.quantity += quantity
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=dest.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.adjust,
|
||
quantity=quantity,
|
||
unit_used=product.base_unit.value,
|
||
note=beleg,
|
||
location_id=to_location_id,
|
||
)
|
||
)
|
||
|
||
|
||
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
|
||
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
|
||
rows = (
|
||
db.query(Movement)
|
||
.filter(
|
||
Movement.product_id == product_id,
|
||
Movement.type == MovementType.out,
|
||
Movement.reason.isnot(None),
|
||
)
|
||
.all()
|
||
)
|
||
stats: dict[str, dict] = {}
|
||
for m in rows:
|
||
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
|
||
eintrag["quantity"] += m.quantity
|
||
eintrag["count"] += 1
|
||
return stats
|
||
|
||
|
||
def current_stock(db: Session, product_id: int) -> float:
|
||
"""Bestand eines Produkts (in Basiseinheiten).
|
||
|
||
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
||
die Lot-Mengen.
|
||
"""
|
||
product = db.get(Product, product_id)
|
||
if product is not None and product.individual:
|
||
return float(db.query(Item).filter(Item.product_id == product_id).count())
|
||
total = (
|
||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||
)
|
||
return float(sum(q for (q,) in total))
|
||
|
||
|
||
def _summe_je_artikel(
|
||
db: Session, produkte: Sequence[Product], orte: set[str] | None
|
||
) -> dict[int, float]:
|
||
"""Bestand JE ARTIKEL (Basiseinheiten) in zwei Abfragen.
|
||
|
||
``orte`` = None zaehlt alles (auch Chargen ohne Lagerort), sonst nur die
|
||
genannten Orte. Gruppiert wird nach Artikel, weil verschiedene Artikel
|
||
unterschiedliche Umrechnungsfaktoren haben koennen (Zweiteinheit).
|
||
"""
|
||
if not produkte:
|
||
return {}
|
||
# Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre
|
||
# Items, alle anderen summieren die Lot-Mengen.
|
||
einzel = [p.id for p in produkte if p.individual]
|
||
lots = [p.id for p in produkte if not p.individual]
|
||
je_artikel: dict[int, float] = {}
|
||
if lots:
|
||
abfrage = (
|
||
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
|
||
.filter(Lot.product_id.in_(lots))
|
||
)
|
||
if orte is not None:
|
||
abfrage = abfrage.filter(Lot.location_id.in_(orte))
|
||
for pid, menge in abfrage.group_by(Lot.product_id).all():
|
||
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
||
if einzel:
|
||
abfrage = (
|
||
db.query(Item.product_id, func.count(Item.id))
|
||
.filter(Item.product_id.in_(einzel))
|
||
)
|
||
if orte is not None:
|
||
abfrage = abfrage.filter(Item.location_id.in_(orte))
|
||
for pid, anzahl in abfrage.group_by(Item.product_id).all():
|
||
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(anzahl or 0)
|
||
return je_artikel
|
||
|
||
|
||
def summe_bestand_gewichtet(
|
||
db: Session,
|
||
produkte: Sequence[Product],
|
||
faktoren: dict[int, float] | None = None,
|
||
) -> float:
|
||
"""Gesamtbestand mehrerer Artikel, je Artikel mit eigenem Faktor.
|
||
|
||
Der Faktor kommt aus der Zweiteinheit (``GroupMinContext.faktoren``): eine
|
||
Gruppe in Kilogramm rechnet einen in Stueck gefuehrten Artikel ueber
|
||
83,333 g je Stueck mit. Ohne Faktoren-Tabelle wird ungewichtet addiert.
|
||
"""
|
||
je_artikel = _summe_je_artikel(db, produkte, None)
|
||
if faktoren is None:
|
||
return float(sum(je_artikel.values()))
|
||
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
|
||
|
||
|
||
def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float:
|
||
"""Gesamtbestand mehrerer Artikel (Basiseinheiten), ungewichtet."""
|
||
return summe_bestand_gewichtet(db, produkte, None)
|
||
|
||
|
||
def summe_bestand_im_subtree_gewichtet(
|
||
db: Session,
|
||
produkte: Sequence[Product],
|
||
location_id: str,
|
||
faktoren: dict[int, float] | None = None,
|
||
) -> float:
|
||
"""Wie ``summe_bestand_gewichtet``, aber nur an einem Ort inkl. Unterorten.
|
||
|
||
Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst
|
||
nicht mit – im Gesamtbestand („Ueberall") dagegen schon.
|
||
"""
|
||
ids = {location_id} | descendant_location_ids(db, location_id)
|
||
je_artikel = _summe_je_artikel(db, produkte, ids)
|
||
if faktoren is None:
|
||
return float(sum(je_artikel.values()))
|
||
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
|
||
|
||
|
||
def summe_bestand_im_subtree_base(
|
||
db: Session, produkte: Sequence[Product], location_id: str
|
||
) -> float:
|
||
"""Ungewichtete Variante von ``summe_bestand_im_subtree_gewichtet``."""
|
||
return summe_bestand_im_subtree_gewichtet(db, produkte, location_id, None)
|
||
|
||
|
||
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||
|
||
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||
Ortes summiert.
|
||
"""
|
||
if product.individual:
|
||
return float(
|
||
db.query(Item)
|
||
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
||
.count()
|
||
)
|
||
total = (
|
||
db.query(Lot.quantity)
|
||
.filter(Lot.product_id == product.id, Lot.location_id == location_id)
|
||
.all()
|
||
)
|
||
return float(sum(q for (q,) in total))
|
||
|
||
|
||
def descendant_location_ids(db: Session, location_id: str) -> set[str]:
|
||
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
|
||
result: set[str] = set()
|
||
stack = [location_id]
|
||
while stack:
|
||
cur = stack.pop()
|
||
for (lid,) in db.query(Location.id).filter(Location.parent_id == cur).all():
|
||
if lid not in result:
|
||
result.add(lid)
|
||
stack.append(lid)
|
||
return result
|
||
|
||
|
||
def location_subtree_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
|
||
|
||
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
|
||
irgendwo darunter liegt (z.B. „Hedingen → Keller").
|
||
"""
|
||
ids = {location_id} | descendant_location_ids(db, location_id)
|
||
return float(sum(location_stock_base(db, product, lid) for lid in ids))
|
||
|
||
|
||
def check_in(
|
||
db: Session,
|
||
product: Product,
|
||
quantity: float,
|
||
unit: str,
|
||
best_before: date | None,
|
||
location_id: str | None,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
best_before_precision: str | None = DatePrecision.day.value,
|
||
) -> Lot:
|
||
"""Legt eine neue Charge an und protokolliert die Bewegung.
|
||
|
||
Ist nur Monat/Jahr angegeben, wird das MHD auf den Monatsletzten gelegt;
|
||
die Genauigkeit wird an der Charge vermerkt, damit die Anzeige "09/2026"
|
||
statt "30.09.2026" schreiben kann.
|
||
"""
|
||
quantity_base = to_base(db, product, quantity, unit)
|
||
precision = clean_precision(best_before_precision)
|
||
|
||
lot = Lot(
|
||
product_id=product.id,
|
||
quantity=quantity_base,
|
||
best_before=normalize_best_before(best_before, precision),
|
||
best_before_precision=precision,
|
||
location_id=location_id,
|
||
)
|
||
db.add(lot)
|
||
db.flush() # lot.id verfügbar machen
|
||
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=lot.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.in_,
|
||
quantity=quantity_base,
|
||
unit_used=unit,
|
||
note=note,
|
||
)
|
||
)
|
||
db.flush()
|
||
# Liegt am selben Ort schon eine Charge mit gleichem MHD, wird eingelagert =
|
||
# dazugebucht statt eine zweite Charge anzulegen. Die Bewegung bleibt als
|
||
# eigener Eintrag im Verlauf erhalten (nur auf die bestehende Charge bezogen).
|
||
return consolidate_lot_group(
|
||
db, product.id, lot.best_before, lot.best_before_precision, location_id
|
||
)
|
||
|
||
|
||
def consolidate_lot_group(
|
||
db: Session,
|
||
product_id: int,
|
||
best_before: date | None,
|
||
best_before_precision: str,
|
||
location_id: str | None,
|
||
) -> Lot | None:
|
||
"""Chargen mit gleichem Artikel, MHD, Genauigkeit und Lagerort zu EINER
|
||
zusammenfassen: Mengen addieren, Bewegungen auf die verbleibende (älteste)
|
||
Charge umhängen, die übrigen löschen. Gibt die verbleibende Charge zurück.
|
||
|
||
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
|
||
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
|
||
erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch.
|
||
"""
|
||
query = db.query(Lot).filter(
|
||
Lot.product_id == product_id,
|
||
Lot.best_before_precision == best_before_precision,
|
||
)
|
||
query = (
|
||
query.filter(Lot.best_before.is_(None))
|
||
if best_before is None
|
||
else query.filter(Lot.best_before == best_before)
|
||
)
|
||
query = (
|
||
query.filter(Lot.location_id.is_(None))
|
||
if location_id is None
|
||
else query.filter(Lot.location_id == location_id)
|
||
)
|
||
lots = query.order_by(asc(Lot.created_at), asc(Lot.id)).all()
|
||
if len(lots) <= 1:
|
||
return lots[0] if lots else None
|
||
|
||
survivor = lots[0]
|
||
for extra in lots[1:]:
|
||
survivor.quantity += extra.quantity
|
||
db.query(Movement).filter(Movement.lot_id == extra.id).update(
|
||
{Movement.lot_id: survivor.id}, synchronize_session=False
|
||
)
|
||
db.delete(extra)
|
||
db.flush()
|
||
return survivor
|
||
|
||
|
||
def consolidate_duplicate_lots(db: Session) -> int:
|
||
"""Alle bereits vorhandenen Dubletten einmalig zusammenfassen (beim Start).
|
||
Idempotent: Nach dem ersten Lauf gibt es keine Gruppen mit mehr als einer
|
||
Charge mehr. Gibt die Anzahl entfernter Chargen zurück."""
|
||
gruppen = (
|
||
db.query(
|
||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||
)
|
||
.group_by(
|
||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||
)
|
||
.having(func.count(Lot.id) > 1)
|
||
.all()
|
||
)
|
||
entfernt = 0
|
||
for product_id, best_before, precision, location_id in gruppen:
|
||
vorher = (
|
||
db.query(func.count(Lot.id))
|
||
.filter(
|
||
Lot.product_id == product_id,
|
||
Lot.best_before_precision == precision,
|
||
Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before,
|
||
Lot.location_id.is_(None) if location_id is None else Lot.location_id == location_id,
|
||
)
|
||
.scalar()
|
||
)
|
||
consolidate_lot_group(db, product_id, best_before, precision, location_id)
|
||
entfernt += max(0, (vorher or 0) - 1)
|
||
if entfernt:
|
||
db.commit()
|
||
return entfernt
|
||
|
||
|
||
def check_out_lot(
|
||
db: Session,
|
||
product: Product,
|
||
lot: Lot,
|
||
quantity: float,
|
||
unit: str,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
) -> list[dict]:
|
||
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
|
||
needed = to_base(db, product, quantity, unit)
|
||
if needed > lot.quantity + 1e-9:
|
||
raise StockError(
|
||
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
|
||
f"(benötigt {needed:g})."
|
||
)
|
||
|
||
lot.quantity -= needed
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=lot.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.out,
|
||
quantity=needed,
|
||
unit_used=unit,
|
||
note=note,
|
||
)
|
||
)
|
||
affected = [{"lot_id": lot.id, "quantity": needed}]
|
||
if lot.quantity <= 1e-9:
|
||
db.delete(lot)
|
||
return affected
|
||
|
||
|
||
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
|
||
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
|
||
lots = (
|
||
db.query(Lot)
|
||
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
||
.order_by(asc(Lot.created_at))
|
||
.all()
|
||
)
|
||
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
|
||
# es über SQLite und Postgres identisch funktioniert.
|
||
return sorted(
|
||
lots,
|
||
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
|
||
)
|
||
|
||
|
||
def check_out(
|
||
db: Session,
|
||
product: Product,
|
||
quantity: float,
|
||
unit: str,
|
||
user: User | None,
|
||
note: str | None = None,
|
||
) -> list[dict]:
|
||
"""Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab.
|
||
|
||
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
||
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
||
"""
|
||
needed = to_base(db, product, quantity, unit)
|
||
available = current_stock(db, product.id)
|
||
if needed > available + 1e-9:
|
||
raise StockError(
|
||
f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} "
|
||
f"{product.base_unit.value}"
|
||
)
|
||
|
||
affected: list[dict] = []
|
||
remaining = needed
|
||
for lot in _fefo_lots(db, product.id):
|
||
if remaining <= 1e-9:
|
||
break
|
||
take = min(lot.quantity, remaining)
|
||
lot.quantity -= take
|
||
remaining -= take
|
||
|
||
db.add(
|
||
Movement(
|
||
product_id=product.id,
|
||
lot_id=lot.id,
|
||
user_id=user.id if user else None,
|
||
type=MovementType.out,
|
||
quantity=take,
|
||
unit_used=unit,
|
||
note=note,
|
||
)
|
||
)
|
||
affected.append({"lot_id": lot.id, "quantity": take})
|
||
|
||
# Leere Charge entfernen, damit das "MHD-Array" sauber bleibt.
|
||
if lot.quantity <= 1e-9:
|
||
db.delete(lot)
|
||
|
||
return affected
|