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>
335 lines
8.9 KiB
Python
335 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from .models import BaseUnit, DatePrecision, Role, UnitKind
|
|
|
|
|
|
# ---- Units ----
|
|
class UnitOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
name: str
|
|
kind: UnitKind
|
|
factor: float
|
|
is_builtin: bool
|
|
|
|
|
|
class UnitCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=64)
|
|
kind: UnitKind
|
|
factor: float = Field(gt=0)
|
|
|
|
|
|
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
|
class ApiTokenOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
name: str
|
|
created_at: datetime
|
|
last_used_at: datetime | None = None
|
|
|
|
|
|
class ApiTokenCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
|
|
|
|
class ApiTokenCreated(ApiTokenOut):
|
|
token: str # nur einmalig beim Anlegen
|
|
|
|
|
|
# ---- Barcodes ----
|
|
class BarcodeOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
code: str
|
|
note: str | None = None
|
|
|
|
|
|
# ---- Auth / Users ----
|
|
class Token(BaseModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
role: Role
|
|
username: str
|
|
|
|
|
|
class UserOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
username: str
|
|
role: Role
|
|
created_at: datetime
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
username: str = Field(min_length=1, max_length=64)
|
|
password: str = Field(min_length=4, max_length=255)
|
|
role: Role = Role.user
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
password: str | None = Field(default=None, min_length=4, max_length=255)
|
|
role: Role | None = None
|
|
|
|
|
|
# ---- Groups ----
|
|
class GroupOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
name: str
|
|
min_stock: float | None = None
|
|
min_stock_unit_id: int | None = None
|
|
# angereichert:
|
|
product_count: int = 0
|
|
stock: float = 0.0 # Bestand in Basiseinheiten
|
|
min_stock_unit_name: str | None = None
|
|
kind: str | None = None # Art der Mindestbestand-Einheit
|
|
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
|
|
|
|
|
|
class GroupCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
min_stock: float | None = Field(default=None, ge=0)
|
|
min_stock_unit_id: int | None = None
|
|
|
|
|
|
class GroupUpdate(BaseModel):
|
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
min_stock: float | None = Field(default=None, ge=0)
|
|
min_stock_unit_id: int | None = None
|
|
|
|
|
|
# ---- Locations ----
|
|
class LocationOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
name: str
|
|
parent_id: int | None = None
|
|
|
|
|
|
class LocationCreate(BaseModel):
|
|
name: str = Field(min_length=1, max_length=120)
|
|
parent_id: int | None = None
|
|
|
|
|
|
# ---- Products ----
|
|
class ProductBase(BaseModel):
|
|
barcode: str | None = None
|
|
name: str = Field(min_length=1, max_length=255)
|
|
brand: str | None = None
|
|
image_url: str | None = None
|
|
base_unit: BaseUnit = BaseUnit.piece
|
|
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
|
|
unit_id: int | None = None
|
|
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.
|
|
min_stock_unit_id: int | None = None
|
|
min_stock_in_packages: bool = False
|
|
|
|
|
|
class ProductCreate(ProductBase):
|
|
pass
|
|
|
|
|
|
class ProductUpdate(BaseModel):
|
|
barcode: str | None = None
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
brand: str | None = None
|
|
image_url: str | None = None
|
|
base_unit: BaseUnit | None = None
|
|
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
|
|
min_stock_in_packages: bool | None = None
|
|
|
|
|
|
class ProductOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
barcode: str | None
|
|
name: str
|
|
brand: str | None
|
|
image_url: str | None
|
|
base_unit: BaseUnit
|
|
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
|
|
min_stock_in_packages: bool = False
|
|
source: str
|
|
created_at: datetime
|
|
# angereichert:
|
|
stock: float = 0.0
|
|
expired_count: int = 0
|
|
kind: str = ""
|
|
unit_name: str = ""
|
|
unit_factor: float = 1.0
|
|
# Mindestbestand in der erfassten Einheit (für die Anzeige):
|
|
min_stock_display: float | None = None
|
|
min_stock_unit_label: str = ""
|
|
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
|
|
barcodes: list[BarcodeOut] = []
|
|
|
|
|
|
class LookupResult(BaseModel):
|
|
found: bool
|
|
existing_product: ProductOut | None = None
|
|
suggestion: dict | None = None
|
|
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
|
|
group_id: int | None = None
|
|
group_name: str | None = None
|
|
|
|
|
|
class BarcodeCreate(BaseModel):
|
|
code: str = Field(min_length=4, max_length=64)
|
|
note: str | None = Field(default=None, max_length=120)
|
|
|
|
|
|
# ---- Stock movements ----
|
|
class CheckInRequest(BaseModel):
|
|
product_id: int | None = None
|
|
barcode: str | None = None
|
|
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
|
|
|
|
|
|
class CheckOutRequest(BaseModel):
|
|
product_id: int | None = None
|
|
barcode: str | None = None
|
|
quantity: float = Field(gt=0)
|
|
unit: str
|
|
# Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO).
|
|
lot_id: int | None = None
|
|
note: str | None = None
|
|
|
|
|
|
class LotOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
id: int
|
|
product_id: int
|
|
quantity: float
|
|
best_before: date | None
|
|
best_before_precision: DatePrecision = DatePrecision.day
|
|
location_id: int | None
|
|
created_at: datetime
|
|
|
|
|
|
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
|
|
|
|
|
|
class CheckInResponse(BaseModel):
|
|
lot: LotOut
|
|
product_stock: float
|
|
|
|
|
|
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
|
|
|
|
|
|
class BatchCheckInRequest(BaseModel):
|
|
product_id: int | None = None
|
|
barcode: str | None = None
|
|
unit: str
|
|
lines: list[CheckInLine] = Field(min_length=1)
|
|
note: str | None = None
|
|
|
|
|
|
class BatchCheckInResponse(BaseModel):
|
|
lots: list[LotOut]
|
|
product_stock: float
|
|
|
|
|
|
class CheckOutResponse(BaseModel):
|
|
affected_lots: list[dict]
|
|
product_stock: float
|
|
|
|
|
|
# ---- Views ----
|
|
class ShoppingItem(BaseModel):
|
|
product_id: int
|
|
name: str
|
|
base_unit: BaseUnit
|
|
package_size: float | None = None
|
|
stock: float
|
|
min_stock: float
|
|
deficit: float
|
|
|
|
|
|
class ExpiringItem(BaseModel):
|
|
lot_id: int
|
|
product_id: int
|
|
product_name: str
|
|
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
|
|
package_label: str | None = None
|
|
unit_name: str = ""
|
|
unit_factor: float = 1.0
|
|
|
|
|
|
class GroupShoppingItem(BaseModel):
|
|
group_id: int
|
|
name: str
|
|
stock: float
|
|
min_stock: float
|
|
deficit: float
|
|
unit_name: str = ""
|
|
product_count: int
|
|
|
|
|
|
class MovementOut(BaseModel):
|
|
id: int
|
|
product_id: int
|
|
product_name: str
|
|
type: str
|
|
quantity: float # in Basiseinheiten
|
|
base_unit: BaseUnit
|
|
unit_used: str
|
|
username: str | None
|
|
note: str | None
|
|
created_at: datetime
|
|
# Für die Anzeige in Artikeleinheiten:
|
|
package_size: float | None = None
|
|
package_label: str | None = None
|
|
unit_name: str = ""
|
|
unit_factor: float = 1.0
|
|
|
|
|
|
class SettingOut(BaseModel):
|
|
key: str
|
|
value: str
|