diff --git a/.gitignore b/.gitignore index 7a43d8b..6dfa7ef 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ ios/**/xcuserdata/ ios/**/DerivedData/ # Wird aus ios/project.yml erzeugt ("xcodegen generate") ios/ProjectGood.xcodeproj/ + +# Lokale Python-Umgebung fuer Tests +backend/.venv/ diff --git a/backend/app/main.py b/backend/app/main.py index 27d2ee2..1f2a474 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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: diff --git a/backend/app/models.py b/backend/app/models.py index 8705a8a..d20bc0d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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 ) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 5f70cd5..be48cf1 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -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() diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index d5dec1e..3522f7b 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -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) diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index a86582b..6c5e9ee 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -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) diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 1e83590..1a9b715 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -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, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 509dc7a..9c79fb9 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/dates.py b/backend/app/services/dates.py new file mode 100644 index 0000000..5dfdcc4 --- /dev/null +++ b/backend/app/services/dates.py @@ -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 diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index faf9a5d..d6e035b 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -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) diff --git a/backend/tests/test_dates.py b/backend/tests/test_dates.py new file mode 100644 index 0000000..98df978 --- /dev/null +++ b/backend/tests/test_dates.py @@ -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