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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user