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

@@ -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,