"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab.""" from __future__ import annotations from collections.abc import Sequence from datetime import date, timedelta from sqlalchemy import asc, func from sqlalchemy.orm import Session from ..models import ( DatePrecision, Item, Location, Lot, Movement, MovementType, Product, User, ) from .conversion import article_unit, to_base from .dates import clean_precision, normalize_best_before from .loans import nicht_verliehen 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. Verliehene Stücke sind nicht im Haus und zählen nicht mit (siehe ``services/loans.py``). """ product = db.get(Product, product_id) if product is not None and product.individual: return float( nicht_verliehen(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 = nicht_verliehen( 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. Ein verliehenes Stück zählt hier nicht – sein Lagerort bleibt aber gesetzt, weil es dorthin zurückgehört. """ if product.individual: return float( nicht_verliehen( 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. **Angebrochene Chargen sind davon ausgenommen und werden NIE zusammengefasst.** Eine offene Packung mit eigenem Verbrauchsdatum ist etwas anderes als eine ungeöffnete mit demselben MHD; ohne diese Ausnahme würde die offene Packung beim nächsten Einlagern am selben Ort still einkassiert und ihr kürzeres Verbrauchsdatum ginge verloren. Bitte beim nächsten Umbau hier stehen lassen. """ query = db.query(Lot).filter( Lot.product_id == product_id, Lot.best_before_precision == best_before_precision, Lot.opened_on.is_(None), ) 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 ) # Angebrochene bleiben ausgenommen, siehe consolidate_lot_group. .filter(Lot.opened_on.is_(None)) .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, Lot.opened_on.is_(None), ) .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).""" # Hausmasse sind beim Auslagern erlaubt („2 EL"), beim Einlagern nicht. needed = to_base(db, product, quantity, unit, hausmasse=True) 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 # -------------------------------------------------------------------------- # Anbrechen und Teilverbrauch # -------------------------------------------------------------------------- def abspalten( db: Session, lot: Lot, quantity: float, location_id: str | None, *, gleicher_ort_erlaubt: bool = False, ) -> Lot: """``quantity`` Basiseinheiten als neue Charge mit gleichem MHD abzweigen. Reine Umbuchung, keine Bewegung – der Gesamtbestand ändert sich nicht. Gibt die neue Charge zurück (noch nicht konsolidiert; das entscheidet der Aufrufer, denn beim Anbrechen darf gerade NICHT konsolidiert werden). ``gleicher_ort_erlaubt`` ist der Unterschied zwischen den beiden Anlässen: Beim Aufteilen auf einen anderen Lagerort wäre derselbe Ort sinnlos, beim Anbrechen ist er der Normalfall – die offene Packung bleibt dort liegen, wo sie war. """ if quantity <= 0: raise StockError("Menge muss größer als 0 sein") if quantity > lot.quantity - 1e-9: raise StockError( f"So viel enthält die Charge nicht (vorhanden: {lot.quantity:g})." ) if not gleicher_ort_erlaubt and (location_id or None) == (lot.location_id or None): raise StockError("Bitte einen anderen Lagerort als den aktuellen wählen.") lot.quantity -= quantity neu = Lot( product_id=lot.product_id, quantity=quantity, best_before=lot.best_before, best_before_precision=lot.best_before_precision, location_id=location_id, ) db.add(neu) db.flush() return neu def anbrechen(db: Session, product: Product, lot: Lot, quantity: float | None = None) -> Lot: """Eine Packung der Charge anbrechen und als eigene Charge führen. Aus 4000 ml (4 × 1 l) wird eine offene Charge mit 1000 ml und eine ungeöffnete mit 3000 ml. Ohne das Abspalten würden die restlichen 3 l das kurze Verbrauchsdatum der geöffneten Packung mittragen, obwohl sie noch zu sind. ``quantity`` leer = eine Artikeleinheit (Packung, sonst Anzeigeeinheit); ist die Charge kleiner, wird sie als Ganzes angebrochen. """ from .vererbung import oeffnungsfrist if lot.opened_on is not None: return lot # schon offen – nichts zu tun menge = quantity if menge is None: faktor, _label = article_unit(product) menge = faktor or lot.quantity menge = min(menge, lot.quantity) ziel = lot if menge >= lot.quantity - 1e-9 else abspalten( db, lot, menge, lot.location_id, gleicher_ort_erlaubt=True ) heute = date.today() ziel.opened_on = heute ziel.opened_quantity = ziel.quantity ziel.consume_by = heute + timedelta(days=oeffnungsfrist(db, product)) db.flush() return ziel def verbrauchen( db: Session, product: Product, lot: Lot, quantity: float, unit: str, user: User | None, note: str | None = None, auto_open: bool = True, ) -> tuple[list[dict], Lot | None, bool]: """Teilmenge aus einer Charge verbrauchen, bei Bedarf vorher anbrechen. Gibt (betroffene Chargen, offene Charge oder None wenn leer, angebrochen?) zurück. Die Umrechnung läuft über ``to_base`` und kennt damit auch die Hausmaße („2 EL") – deshalb wird hier zuerst gerechnet und danach angebrochen: Scheitert die Einheit, soll keine Packung offen sein. """ # Erst rechnen: Scheitert die Einheit, soll keine Packung grundlos offen sein. menge_base = to_base(db, product, quantity, unit, hausmasse=True) angebrochen = False ziel = lot if lot.opened_on is None and auto_open: ziel = anbrechen(db, product, lot, max(menge_base, article_unit(product)[0] or 0.0)) angebrochen = True # Bewusst mit der ROH-Eingabe weiter, nicht mit menge_base: ``unit_used`` an # der Bewegung soll festhalten, was der Nutzer getippt hat („2 EL"), nicht # das Ergebnis der Umrechnung. Die zweite Umrechnung liefert denselben Wert. affected = check_out_lot(db, product, ziel, quantity, unit, user, note) offen = None if ziel.quantity <= 1e-9 else ziel return affected, offen, angebrochen def _fefo_lots(db: Session, product_id: int) -> list[Lot]: """Lots eines Produkts, sortiert nach dem WIRKSAMEN Datum (NULL zuletzt). Wirksam ist das frühere aus MHD und Verbrauchsdatum (``Lot.faellig_am``). Eine angebrochene Packung kommt damit meist von selbst zuerst, weil ihr Verbrauchsdatum näher liegt – aber eine ungeöffnete Charge, die morgen abläuft, geht vor. Erst bei gleichem Datum wird das Angebrochene vorgezogen, damit nicht eine zweite Packung aufgeht, während eine offen ist. """ lots = ( db.query(Lot) .filter(Lot.product_id == product_id, Lot.quantity > 0) .order_by(asc(Lot.created_at)) .all() ) # In Python sortieren, damit es über SQLite und Postgres identisch arbeitet. return sorted( lots, key=lambda lot: ( lot.faellig_am is None, lot.faellig_am or date.max, lot.opened_on is None, 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, hausmasse=True) 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