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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-30 08:53:12 +02:00
parent 231d86895f
commit 05b54dc7ac
3 changed files with 108 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ from .seed import (
ensure_first_admin, ensure_first_admin,
) )
from .services.group_codes import backfill as backfill_group_codes from .services.group_codes import backfill as backfill_group_codes
from .services.stock import consolidate_duplicate_lots
settings = get_settings() settings = get_settings()
@@ -221,6 +222,9 @@ async def lifespan(app: FastAPI):
ensure_first_admin(db) ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen. # Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db) 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: finally:
db.close() db.close()
yield yield

View File

@@ -28,6 +28,7 @@ from ..services.stock import (
check_in, check_in,
check_out, check_out,
check_out_lot, check_out_lot,
consolidate_lot_group,
current_stock, current_stock,
object_add, object_add,
object_relocate, object_relocate,
@@ -292,6 +293,16 @@ def bulk_lot_location(
.filter(Lot.id.in_(payload.lot_ids)) .filter(Lot.id.in_(payload.lot_ids))
.update({Lot.location_id: payload.location_id}, synchronize_session=False) .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() db.commit()
return n return n
@@ -335,9 +346,14 @@ def split_lot(
location_id=payload.location_id, location_id=payload.location_id,
) )
db.add(neu) 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.commit()
db.refresh(neu) db.refresh(survivor)
return neu return survivor
@router.patch("/lots/{lot_id}", response_model=LotOut) @router.patch("/lots/{lot_id}", response_model=LotOut)
@@ -383,9 +399,15 @@ def update_lot(
note="Charge korrigiert", 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.commit()
db.refresh(lot) db.refresh(survivor)
return lot return survivor
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import date from datetime import date
from sqlalchemy import asc from sqlalchemy import asc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import ( from ..models import (
@@ -302,6 +302,83 @@ def check_in(
return lot 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( def check_out_lot(
db: Session, db: Session,
product: Product, product: Product,