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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ ios/**/xcuserdata/
|
|||||||
ios/**/DerivedData/
|
ios/**/DerivedData/
|
||||||
# Wird aus ios/project.yml erzeugt ("xcodegen generate")
|
# Wird aus ios/project.yml erzeugt ("xcodegen generate")
|
||||||
ios/ProjectGood.xcodeproj/
|
ios/ProjectGood.xcodeproj/
|
||||||
|
|
||||||
|
# Lokale Python-Umgebung fuer Tests
|
||||||
|
backend/.venv/
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ def _ensure_schema() -> None:
|
|||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||||||
"NOT NULL DEFAULT FALSE",
|
"NOT NULL DEFAULT FALSE",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
"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:
|
with engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ class MovementType(str, enum.Enum):
|
|||||||
adjust = "adjust"
|
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):
|
class UnitKind(str, enum.Enum):
|
||||||
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
|
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
|
||||||
count = "count" # Basis: Stück
|
count = "count" # Basis: Stück
|
||||||
@@ -121,6 +132,12 @@ class Product(Base):
|
|||||||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
|
||||||
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
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(
|
group_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -195,7 +212,14 @@ class Lot(Base):
|
|||||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
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)
|
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(
|
location_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ def create_product(
|
|||||||
display_unit_id=display_unit_id,
|
display_unit_id=display_unit_id,
|
||||||
package_size=payload.package_size,
|
package_size=payload.package_size,
|
||||||
package_label=payload.package_label or None,
|
package_label=payload.package_label or None,
|
||||||
|
date_precision=payload.date_precision.value,
|
||||||
group_id=payload.group_id,
|
group_id=payload.group_id,
|
||||||
min_stock=payload.min_stock,
|
min_stock=payload.min_stock,
|
||||||
min_stock_unit_id=payload.min_stock_unit_id,
|
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
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
if data.get("min_stock_in_packages") is None:
|
if data.get("min_stock_in_packages") is None:
|
||||||
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
|
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():
|
for field, value in data.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..schemas import (
|
|||||||
LotUpdate,
|
LotUpdate,
|
||||||
)
|
)
|
||||||
from ..services.conversion import ConversionError
|
from ..services.conversion import ConversionError
|
||||||
|
from ..services.dates import clean_precision, normalize_best_before
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
StockError,
|
StockError,
|
||||||
check_in,
|
check_in,
|
||||||
@@ -41,6 +42,7 @@ def stock_checkin(
|
|||||||
quantity=payload.quantity,
|
quantity=payload.quantity,
|
||||||
unit=payload.unit,
|
unit=payload.unit,
|
||||||
best_before=payload.best_before,
|
best_before=payload.best_before,
|
||||||
|
best_before_precision=payload.best_before_precision.value,
|
||||||
location_id=payload.location_id,
|
location_id=payload.location_id,
|
||||||
user=user,
|
user=user,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
@@ -76,6 +78,7 @@ def stock_checkin_batch(
|
|||||||
quantity=line.quantity,
|
quantity=line.quantity,
|
||||||
unit=payload.unit,
|
unit=payload.unit,
|
||||||
best_before=line.best_before,
|
best_before=line.best_before,
|
||||||
|
best_before_precision=line.best_before_precision.value,
|
||||||
location_id=line.location_id,
|
location_id=line.location_id,
|
||||||
user=user,
|
user=user,
|
||||||
note=payload.note,
|
note=payload.note,
|
||||||
@@ -160,6 +163,19 @@ def update_lot(
|
|||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
||||||
|
|
||||||
data = payload.model_dump(exclude_unset=True)
|
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
|
old_quantity = lot.quantity
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(lot, field, value)
|
setattr(lot, field, value)
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..services.dates import MONTH, clean_precision, normalize_best_before
|
||||||
from ..models import (
|
from ..models import (
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
|
DatePrecision,
|
||||||
Group,
|
Group,
|
||||||
Location,
|
Location,
|
||||||
Lot,
|
Lot,
|
||||||
@@ -90,7 +92,7 @@ def export_stock_csv(
|
|||||||
+ [
|
+ [
|
||||||
round(lot.quantity / factor, 6),
|
round(lot.quantity / factor, 6),
|
||||||
amount_label,
|
amount_label,
|
||||||
lot.best_before.isoformat() if lot.best_before else "",
|
_format_best_before(lot),
|
||||||
location.name if location else "",
|
location.name if location else "",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -145,6 +147,7 @@ def export_backup_json(
|
|||||||
"unit": unit_name,
|
"unit": unit_name,
|
||||||
"package_size": p.package_size,
|
"package_size": p.package_size,
|
||||||
"package_label": p.package_label,
|
"package_label": p.package_label,
|
||||||
|
"date_precision": p.date_precision,
|
||||||
"group": p.group.name if p.group else None,
|
"group": p.group.name if p.group else None,
|
||||||
"min_stock": p.min_stock,
|
"min_stock": p.min_stock,
|
||||||
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
|
"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,
|
"quantity": lot.quantity,
|
||||||
"best_before": lot.best_before.isoformat() if lot.best_before else None,
|
"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),
|
"location": loc_name.get(lot.location_id),
|
||||||
}
|
}
|
||||||
for lot in lots
|
for lot in lots
|
||||||
@@ -191,6 +195,39 @@ def _parse_date(value) -> date | None:
|
|||||||
raise ValueError(f"Datum nicht lesbar: {text}")
|
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"}
|
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,
|
display_unit_id=unit.id,
|
||||||
package_size=_num(row.get("packungsgroesse")),
|
package_size=_num(row.get("packungsgroesse")),
|
||||||
package_label=(row.get("gebinde") or "").strip() or None,
|
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,
|
group_id=group.id if group else None,
|
||||||
min_stock=_num(row.get("mindestbestand")),
|
min_stock=_num(row.get("mindestbestand")),
|
||||||
source="import",
|
source="import",
|
||||||
@@ -327,10 +366,12 @@ def _import_csv(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
continue # Zeile ohne Bestand: nur Stammdaten
|
continue # Zeile ohne Bestand: nur Stammdaten
|
||||||
quantity = _quantity_to_base(db, product, amount, row.get("menge_einheit", ""))
|
quantity = _quantity_to_base(db, product, amount, row.get("menge_einheit", ""))
|
||||||
location = _get_or_create_location(db, row.get("lagerort"))
|
location = _get_or_create_location(db, row.get("lagerort"))
|
||||||
|
best_before, precision = _parse_best_before(row.get("mhd"))
|
||||||
lot = Lot(
|
lot = Lot(
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
quantity=quantity,
|
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,
|
location_id=location.id if location else None,
|
||||||
)
|
)
|
||||||
db.add(lot)
|
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",
|
"einheit": entry.get("unit") or "Stück",
|
||||||
"packungsgroesse": entry.get("package_size") or "",
|
"packungsgroesse": entry.get("package_size") or "",
|
||||||
"gebinde": entry.get("package_label") or "",
|
"gebinde": entry.get("package_label") or "",
|
||||||
|
"mhd_genauigkeit": entry.get("date_precision") or "",
|
||||||
"gruppe": entry.get("group") or "",
|
"gruppe": entry.get("group") or "",
|
||||||
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
|
"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:
|
if quantity <= 0:
|
||||||
continue
|
continue
|
||||||
location = _get_or_create_location(db, lot_entry.get("location"))
|
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(
|
lot = Lot(
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
quantity=quantity,
|
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,
|
location_id=location.id if location else None,
|
||||||
)
|
)
|
||||||
db.add(lot)
|
db.add(lot)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ def expiring(
|
|||||||
quantity=lot.quantity,
|
quantity=lot.quantity,
|
||||||
base_unit=product.base_unit,
|
base_unit=product.base_unit,
|
||||||
best_before=lot.best_before,
|
best_before=lot.best_before,
|
||||||
|
best_before_precision=lot.best_before_precision,
|
||||||
days_left=(lot.best_before - today).days,
|
days_left=(lot.best_before - today).days,
|
||||||
package_size=product.package_size,
|
package_size=product.package_size,
|
||||||
package_label=product.package_label,
|
package_label=product.package_label,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import date, datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from .models import BaseUnit, Role, UnitKind
|
from .models import BaseUnit, DatePrecision, Role, UnitKind
|
||||||
|
|
||||||
|
|
||||||
# ---- Units ----
|
# ---- Units ----
|
||||||
@@ -127,6 +127,8 @@ class ProductBase(BaseModel):
|
|||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
||||||
package_label: str | None = Field(default=None, max_length=32)
|
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
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||||||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||||||
@@ -147,6 +149,7 @@ class ProductUpdate(BaseModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
package_label: str | None = Field(default=None, max_length=32)
|
package_label: str | None = Field(default=None, max_length=32)
|
||||||
|
date_precision: DatePrecision | None = None
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
@@ -164,6 +167,7 @@ class ProductOut(BaseModel):
|
|||||||
display_unit_id: int | None = None
|
display_unit_id: int | None = None
|
||||||
package_size: float | None
|
package_size: float | None
|
||||||
package_label: str | None = None
|
package_label: str | None = None
|
||||||
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None
|
group_id: int | None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
min_stock_unit_id: int | None = None
|
min_stock_unit_id: int | None = None
|
||||||
@@ -204,6 +208,8 @@ class CheckInRequest(BaseModel):
|
|||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
unit: str
|
unit: str
|
||||||
best_before: date | None = None
|
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
|
location_id: int | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
@@ -224,6 +230,7 @@ class LotOut(BaseModel):
|
|||||||
product_id: int
|
product_id: int
|
||||||
quantity: float
|
quantity: float
|
||||||
best_before: date | None
|
best_before: date | None
|
||||||
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None
|
location_id: int | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
@@ -232,6 +239,7 @@ class LotUpdate(BaseModel):
|
|||||||
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
|
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
|
||||||
quantity: float | None = Field(default=None, gt=0)
|
quantity: float | None = Field(default=None, gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
|
best_before_precision: DatePrecision | None = None
|
||||||
location_id: int | None = None
|
location_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +252,7 @@ class CheckInLine(BaseModel):
|
|||||||
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
|
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
|
||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None = None
|
location_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -283,6 +292,7 @@ class ExpiringItem(BaseModel):
|
|||||||
quantity: float # in Basiseinheiten
|
quantity: float # in Basiseinheiten
|
||||||
base_unit: BaseUnit
|
base_unit: BaseUnit
|
||||||
best_before: date
|
best_before: date
|
||||||
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
days_left: int
|
days_left: int
|
||||||
# Für die Anzeige in Artikeleinheiten:
|
# Für die Anzeige in Artikeleinheiten:
|
||||||
package_size: float | None = None
|
package_size: float | None = None
|
||||||
|
|||||||
36
backend/app/services/dates.py
Normal file
36
backend/app/services/dates.py
Normal 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
|
||||||
@@ -7,8 +7,9 @@ from datetime import date
|
|||||||
from sqlalchemy import asc
|
from sqlalchemy import asc
|
||||||
from sqlalchemy.orm import Session
|
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 .conversion import to_base
|
||||||
|
from .dates import clean_precision, normalize_best_before
|
||||||
|
|
||||||
|
|
||||||
class StockError(ValueError):
|
class StockError(ValueError):
|
||||||
@@ -32,14 +33,22 @@ def check_in(
|
|||||||
location_id: int | None,
|
location_id: int | None,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
|
best_before_precision: str | None = DatePrecision.day.value,
|
||||||
) -> Lot:
|
) -> 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)
|
quantity_base = to_base(db, product, quantity, unit)
|
||||||
|
precision = clean_precision(best_before_precision)
|
||||||
|
|
||||||
lot = Lot(
|
lot = Lot(
|
||||||
product_id=product.id,
|
product_id=product.id,
|
||||||
quantity=quantity_base,
|
quantity=quantity_base,
|
||||||
best_before=best_before,
|
best_before=normalize_best_before(best_before, precision),
|
||||||
|
best_before_precision=precision,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
)
|
)
|
||||||
db.add(lot)
|
db.add(lot)
|
||||||
|
|||||||
86
backend/tests/test_dates.py
Normal file
86
backend/tests/test_dates.py
Normal 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
|
||||||
Reference in New Issue
Block a user