MHD wahlweise nur mit Monat und Jahr (Backend)

Auf vielen Verpackungen steht nur "09/2026". Bisher liess sich ausschliesslich
ein Tagesdatum erfassen, was zu erfundener Genauigkeit fuehrte.

Entwurf: best_before bleibt ein echtes DATE, damit FEFO, die Ablauf-Abfragen
und alle bestehenden Sortierungen unveraendert weiterlaufen. Eine Monatsangabe
wird auf den Monatsletzten gelegt - die uebliche Lesart bei Lebensmitteln, und
die sichere Richtung, weil nicht zu frueh aussortiert wird. Zusaetzlich merkt
sich jede Charge in best_before_precision, wie genau die Angabe war; davon
haengt allein die Anzeige ab. Am Produkt steht in date_precision, welche
Genauigkeit dort ueblich ist (Voreinstellung der Eingabe, z.B. Konserven).

Bewusst als VARCHAR statt als DB-Enum gespeichert: So laesst sich die Spalte
auf bestehenden Tabellen per ADD COLUMN IF NOT EXISTS nachziehen, ohne vorher
einen neuen Postgres-Typ anzulegen. Beide Spalten haben ein Server-Default
'day', damit vorhandene Chargen unveraendert gueltig bleiben.

Export/Import bleiben verlustfrei: Das JSON-Backup fuehrt beide Felder mit. Die
CSV bekommt bewusst keine neue Spalte, damit die Datei in Excel unveraendert
bedienbar bleibt - stattdessen wird eine Monatsangabe als "09/2026" geschrieben
und beim Import an der Schreibweise wieder erkannt ("09/2026", "2026-09",
"09.2026"); tagesgenaue Formate werden weiterhin gelesen.

Beim Korrigieren einer Charge werden MHD und Genauigkeit gemeinsam ausgewertet,
sonst bliebe ein auf Monat umgestellter Wert auf dem alten Tag stehen.

Getestet: 40 pytest-Tests gruen (17 neue zu Monatsletztem, Schaltjahr,
Einlagern, Export-Schreibweise und Import-Erkennung). Zusaetzlich ein
Durchlauf ueber die echte API: Produkt mit Monatsvorgabe anlegen und aendern,
Sammel-Einlagern mit gemischten Genauigkeiten, Chargen wieder auslesen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-22 17:48:11 +02:00
parent d680aeff0e
commit eed7c9e1de
11 changed files with 247 additions and 7 deletions

View File

@@ -44,6 +44,10 @@ def _ensure_schema() -> None:
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
"NOT NULL DEFAULT FALSE",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS date_precision VARCHAR(8) "
"NOT NULL DEFAULT 'day'",
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) "
"NOT NULL DEFAULT 'day'",
]
with engine.begin() as conn:
for stmt in stmts:

View File

@@ -41,6 +41,17 @@ class MovementType(str, enum.Enum):
adjust = "adjust"
class DatePrecision(str, enum.Enum):
"""Wie genau ein MHD angegeben wurde.
Bewusst als kurzer String gespeichert und nicht als DB-Enum: So lässt sich
die Spalte auf bestehenden Tabellen per ADD COLUMN nachziehen, ohne vorher
einen neuen Postgres-Typ anlegen zu müssen.
"""
day = "day" # 07.09.2026
month = "month" # 09/2026
class UnitKind(str, enum.Enum):
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
count = "count" # Basis: Stück
@@ -121,6 +132,12 @@ class Product(Base):
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
date_precision: Mapped[str] = mapped_column(
String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value,
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
@@ -195,7 +212,14 @@ class Lot(Base):
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
# Immer ein echtes Datum, damit FEFO und Ablauf-Abfragen unverändert bleiben.
# Bei Monatsangaben steht hier der Monatsletzte (siehe services/dates.py).
best_before: Mapped[date | None] = mapped_column(Date, nullable=True)
# Wie genau die Angabe ursprünglich war entscheidet nur über die Anzeige.
best_before_precision: Mapped[str] = mapped_column(
String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value,
)
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)

View File

@@ -99,6 +99,7 @@ def create_product(
display_unit_id=display_unit_id,
package_size=payload.package_size,
package_label=payload.package_label or None,
date_precision=payload.date_precision.value,
group_id=payload.group_id,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id,
@@ -146,6 +147,10 @@ def update_product(
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None:
data.pop("date_precision", None) # Spalte ist NOT NULL
else:
data["date_precision"] = data["date_precision"].value
for field, value in data.items():
setattr(product, field, value)
db.commit()

View File

@@ -16,6 +16,7 @@ from ..schemas import (
LotUpdate,
)
from ..services.conversion import ConversionError
from ..services.dates import clean_precision, normalize_best_before
from ..services.stock import (
StockError,
check_in,
@@ -41,6 +42,7 @@ def stock_checkin(
quantity=payload.quantity,
unit=payload.unit,
best_before=payload.best_before,
best_before_precision=payload.best_before_precision.value,
location_id=payload.location_id,
user=user,
note=payload.note,
@@ -76,6 +78,7 @@ def stock_checkin_batch(
quantity=line.quantity,
unit=payload.unit,
best_before=line.best_before,
best_before_precision=line.best_before_precision.value,
location_id=line.location_id,
user=user,
note=payload.note,
@@ -160,6 +163,19 @@ def update_lot(
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
data = payload.model_dump(exclude_unset=True)
# MHD und Genauigkeit hängen zusammen: Wird eines von beiden angefasst, muss
# der gespeicherte Tag neu bestimmt werden (Monatsangabe = Monatsletzter).
if "best_before" in data or "best_before_precision" in data:
precision = (
clean_precision(data["best_before_precision"])
if "best_before_precision" in data
else lot.best_before_precision
)
best_before = data["best_before"] if "best_before" in data else lot.best_before
data["best_before"] = normalize_best_before(best_before, precision)
data["best_before_precision"] = precision
old_quantity = lot.quantity
for field, value in data.items():
setattr(lot, field, value)

View File

@@ -17,8 +17,10 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..services.dates import MONTH, clean_precision, normalize_best_before
from ..models import (
BaseUnit,
DatePrecision,
Group,
Location,
Lot,
@@ -90,7 +92,7 @@ def export_stock_csv(
+ [
round(lot.quantity / factor, 6),
amount_label,
lot.best_before.isoformat() if lot.best_before else "",
_format_best_before(lot),
location.name if location else "",
]
)
@@ -145,6 +147,7 @@ def export_backup_json(
"unit": unit_name,
"package_size": p.package_size,
"package_label": p.package_label,
"date_precision": p.date_precision,
"group": p.group.name if p.group else None,
"min_stock": p.min_stock,
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
@@ -153,6 +156,7 @@ def export_backup_json(
{
"quantity": lot.quantity,
"best_before": lot.best_before.isoformat() if lot.best_before else None,
"best_before_precision": lot.best_before_precision,
"location": loc_name.get(lot.location_id),
}
for lot in lots
@@ -191,6 +195,39 @@ def _parse_date(value) -> date | None:
raise ValueError(f"Datum nicht lesbar: {text}")
def _parse_best_before(value, precision=None) -> tuple[date | None, str]:
"""Liest ein MHD und erkennt dabei, ob nur Monat/Jahr angegeben war.
Steht die Genauigkeit ausdrücklich in der Datei (JSON-Backup), gilt sie.
Sonst wird sie aus der Schreibweise abgeleitet "09/2026" ist eine
Monatsangabe, "2026-09-07" eine tagesgenaue.
"""
text = (str(value) if value is not None else "").strip()
if not text:
return None, DatePrecision.day.value
if precision == DatePrecision.month.value:
return normalize_best_before(_parse_date(text), MONTH), MONTH
for fmt in ("%m/%Y", "%Y-%m", "%m.%Y"):
try:
parsed = datetime.strptime(text, fmt).date()
except ValueError:
continue
return normalize_best_before(parsed, MONTH), MONTH
return _parse_date(text), DatePrecision.day.value
def _format_best_before(lot: Lot) -> str:
"""Monatsangaben als "09/2026" schreiben, damit die Datei lesbar bleibt."""
if not lot.best_before:
return ""
if lot.best_before_precision == MONTH:
return lot.best_before.strftime("%m/%Y")
return lot.best_before.isoformat()
IMPORT_MODES = {"add", "replace_listed", "replace_all"}
@@ -271,6 +308,8 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
display_unit_id=unit.id,
package_size=_num(row.get("packungsgroesse")),
package_label=(row.get("gebinde") or "").strip() or None,
# Nur im JSON-Backup enthalten; aus der CSV kommt hier nichts.
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
group_id=group.id if group else None,
min_stock=_num(row.get("mindestbestand")),
source="import",
@@ -327,10 +366,12 @@ def _import_csv(db: Session, content: bytes, user: User, mode: str) -> dict:
continue # Zeile ohne Bestand: nur Stammdaten
quantity = _quantity_to_base(db, product, amount, row.get("menge_einheit", ""))
location = _get_or_create_location(db, row.get("lagerort"))
best_before, precision = _parse_best_before(row.get("mhd"))
lot = Lot(
product_id=product.id,
quantity=quantity,
best_before=_parse_date(row.get("mhd")),
best_before=best_before,
best_before_precision=precision,
location_id=location.id if location else None,
)
db.add(lot)
@@ -412,6 +453,7 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
"einheit": entry.get("unit") or "Stück",
"packungsgroesse": entry.get("package_size") or "",
"gebinde": entry.get("package_label") or "",
"mhd_genauigkeit": entry.get("date_precision") or "",
"gruppe": entry.get("group") or "",
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
}
@@ -424,10 +466,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
if quantity <= 0:
continue
location = _get_or_create_location(db, lot_entry.get("location"))
best_before, precision = _parse_best_before(
lot_entry.get("best_before"), lot_entry.get("best_before_precision")
)
lot = Lot(
product_id=product.id,
quantity=quantity,
best_before=_parse_date(lot_entry.get("best_before")),
best_before=best_before,
best_before_precision=precision,
location_id=location.id if location else None,
)
db.add(lot)

View File

@@ -113,6 +113,7 @@ def expiring(
quantity=lot.quantity,
base_unit=product.base_unit,
best_before=lot.best_before,
best_before_precision=lot.best_before_precision,
days_left=(lot.best_before - today).days,
package_size=product.package_size,
package_label=product.package_label,

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from .models import BaseUnit, Role, UnitKind
from .models import BaseUnit, DatePrecision, Role, UnitKind
# ---- Units ----
@@ -127,6 +127,8 @@ class ProductBase(BaseModel):
package_size: float | None = Field(default=None, gt=0)
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
package_label: str | None = Field(default=None, max_length=32)
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
date_precision: DatePrecision = DatePrecision.day
group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
@@ -147,6 +149,7 @@ class ProductUpdate(BaseModel):
unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0)
package_label: str | None = Field(default=None, max_length=32)
date_precision: DatePrecision | None = None
group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
@@ -164,6 +167,7 @@ class ProductOut(BaseModel):
display_unit_id: int | None = None
package_size: float | None
package_label: str | None = None
date_precision: DatePrecision = DatePrecision.day
group_id: int | None
min_stock: float | None
min_stock_unit_id: int | None = None
@@ -204,6 +208,8 @@ class CheckInRequest(BaseModel):
quantity: float = Field(gt=0)
unit: str
best_before: date | None = None
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None
note: str | None = None
@@ -224,6 +230,7 @@ class LotOut(BaseModel):
product_id: int
quantity: float
best_before: date | None
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None
created_at: datetime
@@ -232,6 +239,7 @@ class LotUpdate(BaseModel):
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
quantity: float | None = Field(default=None, gt=0)
best_before: date | None = None
best_before_precision: DatePrecision | None = None
location_id: int | None = None
@@ -244,6 +252,7 @@ class CheckInLine(BaseModel):
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
quantity: float = Field(gt=0)
best_before: date | None = None
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None
@@ -283,6 +292,7 @@ class ExpiringItem(BaseModel):
quantity: float # in Basiseinheiten
base_unit: BaseUnit
best_before: date
best_before_precision: DatePrecision = DatePrecision.day
days_left: int
# Für die Anzeige in Artikeleinheiten:
package_size: float | None = None

View File

@@ -0,0 +1,36 @@
"""Genauigkeit von Mindesthaltbarkeitsdaten.
Auf vielen Verpackungen steht nur "09/2026" statt eines Tagesdatums. Damit die
FEFO-Sortierung und alle Ablauf-Abfragen weiter mit einem echten DATE arbeiten
können, wird ein solcher Monatswert auf den **Monatsletzten** gelegt das ist
die übliche Lesart bei Lebensmitteln. Wie genau die Angabe ursprünglich war,
merkt sich die Charge zusätzlich in ``best_before_precision``; nur davon hängt
ab, ob in der Anzeige "30.09.2026" oder "09/2026" steht.
"""
from __future__ import annotations
from calendar import monthrange
from datetime import date
from ..models import DatePrecision
DAY = DatePrecision.day.value
MONTH = DatePrecision.month.value
VALID_PRECISIONS = (DAY, MONTH)
def normalize_best_before(value: date | None, precision: str | None) -> date | None:
"""Legt ein Monats-MHD auf den letzten Tag dieses Monats.
Tagesgenaue Angaben und ``None`` bleiben unverändert.
"""
if value is None or precision != MONTH:
return value
return date(value.year, value.month, monthrange(value.year, value.month)[1])
def clean_precision(precision: str | None) -> str:
"""Unbekannte oder fehlende Angaben gelten als tagesgenau."""
return precision if precision in VALID_PRECISIONS else DAY

View File

@@ -7,8 +7,9 @@ from datetime import date
from sqlalchemy import asc
from sqlalchemy.orm import Session
from ..models import Lot, Movement, MovementType, Product, User
from ..models import DatePrecision, Lot, Movement, MovementType, Product, User
from .conversion import to_base
from .dates import clean_precision, normalize_best_before
class StockError(ValueError):
@@ -32,14 +33,22 @@ def check_in(
location_id: int | 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."""
"""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=best_before,
best_before=normalize_best_before(best_before, precision),
best_before_precision=precision,
location_id=location_id,
)
db.add(lot)

View File

@@ -0,0 +1,86 @@
from datetime import date
import pytest
from app.services.dates import DAY, MONTH, clean_precision, normalize_best_before
from app.services.stock import check_in
def test_monatsangabe_wird_auf_den_monatsletzten_gelegt():
assert normalize_best_before(date(2026, 9, 1), MONTH) == date(2026, 9, 30)
def test_monatsletzter_beachtet_schaltjahre():
assert normalize_best_before(date(2028, 2, 1), MONTH) == date(2028, 2, 29)
assert normalize_best_before(date(2027, 2, 1), MONTH) == date(2027, 2, 28)
def test_tagesgenaues_mhd_bleibt_unveraendert():
assert normalize_best_before(date(2026, 9, 7), DAY) == date(2026, 9, 7)
def test_ohne_mhd_bleibt_none():
assert normalize_best_before(None, MONTH) is None
@pytest.mark.parametrize("wert", [None, "", "woche", "jahr"])
def test_unbekannte_genauigkeit_gilt_als_tagesgenau(wert):
assert clean_precision(wert) == DAY
def test_bekannte_genauigkeiten_bleiben_erhalten():
assert clean_precision(MONTH) == MONTH
assert clean_precision(DAY) == DAY
def test_einlagern_mit_monatsangabe_speichert_monatsletzten(db, rice):
lot = check_in(
db, rice, 1, "package", date(2026, 9, 1), None, None,
best_before_precision=MONTH,
)
db.commit()
# Gespeichert wird ein echtes Datum, damit FEFO unveraendert funktioniert ...
assert lot.best_before == date(2026, 9, 30)
# ... die Herkunft der Angabe bleibt aber erhalten.
assert lot.best_before_precision == MONTH
@pytest.mark.parametrize(
"text,erwartet",
[
("09/2026", date(2026, 9, 30)),
("2026-09", date(2026, 9, 30)),
("09.2026", date(2026, 9, 30)),
],
)
def test_import_erkennt_monatsangaben_an_der_schreibweise(text, erwartet):
from app.routers.transfer import _parse_best_before
wert, genauigkeit = _parse_best_before(text)
assert (wert, genauigkeit) == (erwartet, MONTH)
@pytest.mark.parametrize("text", ["2026-09-07", "07.09.2026"])
def test_import_erkennt_tagesgenaue_angaben(text):
from app.routers.transfer import _parse_best_before
wert, genauigkeit = _parse_best_before(text)
assert (wert, genauigkeit) == (date(2026, 9, 7), DAY)
def test_export_schreibt_monatsangabe_lesbar(db, rice):
from app.routers.transfer import _format_best_before
lot = check_in(
db, rice, 1, "package", date(2026, 9, 1), None, None,
best_before_precision=MONTH,
)
db.commit()
assert _format_best_before(lot) == "09/2026"
def test_einlagern_ohne_angabe_bleibt_tagesgenau(db, rice):
lot = check_in(db, rice, 1, "package", date(2026, 9, 7), None, None)
db.commit()
assert lot.best_before == date(2026, 9, 7)
assert lot.best_before_precision == DAY