From 05b54dc7ac0fa987adc5be65ebfa997f876993d8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 30 Jul 2026 08:53:12 +0200 Subject: [PATCH] Backend: gleiche Chargen (Artikel+MHD+Ort) werden zusammengefasst Nach dem Umlagern/Aufteilen entstanden Dubletten: zwei Chargen desselben Artikels mit gleichem MHD am gleichen Lagerort, obwohl das fuer den Nutzer eine Charge ist. Neu: consolidate_lot_group fasst solche Chargen zusammen (Mengen addieren, Bewegungen auf die aeltere Charge umhaengen, Rest loeschen) - aufgerufen nach split, bulk-location und update_lot. Bestand und Historie bleiben unveraendert. Beim Start raeumt consolidate_duplicate_lots einmalig bestehende Dubletten auf. Co-Authored-By: Claude Opus 4.8 --- backend/app/main.py | 4 ++ backend/app/routers/stock.py | 30 +++++++++++-- backend/app/services/stock.py | 79 ++++++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 298df88..41408bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -35,6 +35,7 @@ from .seed import ( ensure_first_admin, ) from .services.group_codes import backfill as backfill_group_codes +from .services.stock import consolidate_duplicate_lots settings = get_settings() @@ -221,6 +222,9 @@ async def lifespan(app: FastAPI): ensure_first_admin(db) # Codes bestehender Gruppen-Zuordnungen nachziehen. backfill_group_codes(db) + # Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort) + # einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst. + consolidate_duplicate_lots(db) finally: db.close() yield diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 5f2dee0..15976c1 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -28,6 +28,7 @@ from ..services.stock import ( check_in, check_out, check_out_lot, + consolidate_lot_group, current_stock, object_add, object_relocate, @@ -292,6 +293,16 @@ def bulk_lot_location( .filter(Lot.id.in_(payload.lot_ids)) .update({Lot.location_id: payload.location_id}, synchronize_session=False) ) + db.flush() + # Am Zielort entstandene Dubletten (gleicher Artikel + MHD) zusammenfassen. + gruppen = ( + db.query(Lot.product_id, Lot.best_before, Lot.best_before_precision) + .filter(Lot.id.in_(payload.lot_ids)) + .distinct() + .all() + ) + for product_id, best_before, precision in gruppen: + consolidate_lot_group(db, product_id, best_before, precision, payload.location_id) db.commit() return n @@ -335,9 +346,14 @@ def split_lot( location_id=payload.location_id, ) db.add(neu) + db.flush() + # Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide. + survivor = consolidate_lot_group( + db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id + ) db.commit() - db.refresh(neu) - return neu + db.refresh(survivor) + return survivor @router.patch("/lots/{lot_id}", response_model=LotOut) @@ -383,9 +399,15 @@ def update_lot( note="Charge korrigiert", ) ) + db.flush() + # Ist die Charge durch die Änderung (Lagerort/MHD) zur Dublette einer anderen + # geworden, werden beide zusammengefasst. + survivor = consolidate_lot_group( + db, lot.product_id, lot.best_before, lot.best_before_precision, lot.location_id + ) db.commit() - db.refresh(lot) - return lot + db.refresh(survivor) + return survivor @router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 2975d50..d570a1f 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import date -from sqlalchemy import asc +from sqlalchemy import asc, func from sqlalchemy.orm import Session from ..models import ( @@ -302,6 +302,83 @@ def check_in( return lot +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,