Compare commits
61
Commits
b2dda78a7b
..
main
@@ -52,6 +52,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
|
||||
- **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst
|
||||
gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt
|
||||
daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional).
|
||||
- **Sitzungsdauer einstellbar** (Einstellungen → Anmeldung): 7 Tage bis 1 Jahr
|
||||
oder unbegrenzt, für Website und App gemeinsam. Die Frist zählt ab dem letzten
|
||||
Besuch, nicht ab dem Anmelden.
|
||||
|
||||
### In der iOS-App zusätzlich
|
||||
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
||||
|
||||
+11
-1
@@ -12,7 +12,10 @@ class Settings(BaseSettings):
|
||||
# Auth / JWT
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
# Rueckfallwert fuer die Sitzungsdauer. Im Betrieb entscheidet die
|
||||
# Einstellung "session_days" (Verwaltung -> Einstellungen); dieser Wert gilt
|
||||
# nur, solange dort nichts gesetzt ist.
|
||||
jwt_expire_minutes: int = 60 * 24 * 30 # 30 Tage
|
||||
|
||||
# First admin, created on startup if no users exist
|
||||
admin_username: str = "admin"
|
||||
@@ -32,6 +35,13 @@ class Settings(BaseSettings):
|
||||
|
||||
# Behaviour
|
||||
expiry_warning_days_default: int = 7
|
||||
# Wie lange eine angebrochene Packung haelt, wenn weder Artikel noch
|
||||
# Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung
|
||||
# "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist.
|
||||
open_days_default: int = 3
|
||||
# Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der
|
||||
# Oberflaeche ein), hier nur die Geduld fuer eine Antwort.
|
||||
mealie_timeout_seconds: float = 10.0
|
||||
|
||||
# CORS (comma separated); "*" allows all
|
||||
cors_origins: str = "*"
|
||||
|
||||
@@ -27,6 +27,7 @@ from .services.conversion import (
|
||||
zweit_faktor,
|
||||
)
|
||||
from .services.loans import nicht_verliehen
|
||||
from .services.vererbung import frist_herkunft
|
||||
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||||
from .services.stock import current_stock, location_subtree_stock_base
|
||||
|
||||
@@ -87,6 +88,9 @@ def product_tracking(db: Session, product: Product) -> str:
|
||||
def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||
out = ProductOut.model_validate(product)
|
||||
out.stock = current_stock(db, product.id)
|
||||
# Wirksame Oeffnungsfrist samt Herkunft – das Formular soll den geerbten
|
||||
# Wert als Platzhalter zeigen koennen, ohne die Vererbung nachzubauen.
|
||||
out.effective_open_days, out.open_days_source = frist_herkunft(db, product)
|
||||
out.expired_count = (
|
||||
db.query(Lot)
|
||||
.filter(
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ _credentials_exc = HTTPException(
|
||||
)
|
||||
|
||||
|
||||
def _nutzer_aus_payload(db: Session, payload: dict) -> User | None:
|
||||
def nutzer_aus_payload(db: Session, payload: dict) -> User | None:
|
||||
"""Loest die Sitzung auf – bevorzugt ueber die Benutzer-ID.
|
||||
|
||||
Der Benutzername ist Anzeige und darf sich aendern; die ID nicht. Deshalb
|
||||
@@ -44,7 +44,7 @@ def get_current_user(
|
||||
"""Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token."""
|
||||
payload = decode_access_token(token)
|
||||
if payload is not None:
|
||||
user = _nutzer_aus_payload(db, payload)
|
||||
user = nutzer_aus_payload(db, payload)
|
||||
if user is None:
|
||||
raise _credentials_exc
|
||||
return user
|
||||
|
||||
@@ -12,25 +12,34 @@ from .routers import (
|
||||
branding,
|
||||
categories,
|
||||
dashboard,
|
||||
etiketten,
|
||||
field_definitions,
|
||||
groups,
|
||||
items,
|
||||
loans,
|
||||
locations,
|
||||
maintenance,
|
||||
mealie,
|
||||
measures,
|
||||
navigation,
|
||||
oidc,
|
||||
package_types,
|
||||
products,
|
||||
recipes,
|
||||
settings as settings_router,
|
||||
shopping,
|
||||
shops,
|
||||
stock,
|
||||
transfer,
|
||||
units,
|
||||
users,
|
||||
verwendung,
|
||||
views,
|
||||
)
|
||||
from .seed import (
|
||||
ensure_builtin_categories,
|
||||
ensure_builtin_measures,
|
||||
ensure_builtin_etiketten_vorlagen,
|
||||
ensure_builtin_package_types,
|
||||
ensure_builtin_units,
|
||||
ensure_example_object_categories,
|
||||
@@ -222,6 +231,27 @@ def _ensure_schema() -> None:
|
||||
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
||||
"WHERE email IS NOT NULL",
|
||||
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop).
|
||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS parent_item_id INTEGER "
|
||||
"REFERENCES items(id) ON DELETE SET NULL",
|
||||
"CREATE INDEX IF NOT EXISTS ix_items_parent_item_id ON items (parent_item_id)",
|
||||
# Angebrochene Packungen: Oeffnungstag, daraus gerechnetes
|
||||
# Verbrauchsdatum und die Menge beim Oeffnen (fuer „noch 400 von 1000 ml").
|
||||
# Alle drei nullbar – NULL heisst ungeoeffnet, und genau das sind alle
|
||||
# bestehenden Chargen.
|
||||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_on DATE",
|
||||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS consume_by DATE",
|
||||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_quantity DOUBLE PRECISION",
|
||||
# Frist nach dem Anbrechen. NULL = erbt (Artikel -> Kategorie ->
|
||||
# Oberkategorie -> globale Einstellung), siehe services/vererbung.py.
|
||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS open_days INTEGER",
|
||||
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS open_days INTEGER",
|
||||
# Verwendungsstatus an Einzelstuecken dieser Kategorie (Kleidung,
|
||||
# Werkzeug). Aus, solange niemand sie setzt - bestehende Kategorien
|
||||
# aendern sich also nicht. Die Tabelle item_usages selbst kommt aus
|
||||
# create_all, nur Spalten brauchen diesen Weg hier.
|
||||
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS usage_tracking "
|
||||
"BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||
@@ -250,7 +280,9 @@ async def lifespan(app: FastAPI):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_builtin_units(db)
|
||||
ensure_builtin_measures(db)
|
||||
ensure_builtin_package_types(db)
|
||||
ensure_builtin_etiketten_vorlagen(db)
|
||||
ensure_builtin_categories(db)
|
||||
ensure_example_object_categories(db)
|
||||
ensure_first_admin(db)
|
||||
@@ -287,6 +319,7 @@ def health() -> dict:
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(etiketten.router)
|
||||
app.include_router(oidc.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(products.router)
|
||||
@@ -296,14 +329,22 @@ app.include_router(groups.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(package_types.router)
|
||||
app.include_router(views.router)
|
||||
# Nach views.router: Die gerechnete Liste steht dort, die gespeicherten
|
||||
# Eintraege und Haken hier - gleicher Pfadanfang /shopping-list.
|
||||
app.include_router(shopping.router)
|
||||
app.include_router(transfer.router)
|
||||
app.include_router(api_tokens.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(branding.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(measures.router)
|
||||
app.include_router(mealie.router)
|
||||
app.include_router(recipes.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(shops.router)
|
||||
app.include_router(field_definitions.router)
|
||||
app.include_router(items.router)
|
||||
app.include_router(loans.router)
|
||||
app.include_router(verwendung.router)
|
||||
app.include_router(navigation.router)
|
||||
|
||||
@@ -98,6 +98,24 @@ class RemovalReason(str, enum.Enum):
|
||||
other = "other" # sonstiges
|
||||
|
||||
|
||||
class UsageStatus(str, enum.Enum):
|
||||
"""Woran ein Einzelstueck gerade ist (Hemd getragen, Auto in der Wasche).
|
||||
|
||||
Bewusst KEIN Wert fuer "verfuegbar": Fehlt die offene Zeile in
|
||||
:class:`ItemUsage`, ist das Stueck verfuegbar - wie bei ``lots.opened_on``
|
||||
und beim Verleih. Ein eigener Wert daneben koennte ihr widersprechen.
|
||||
|
||||
Absichtlich allgemein und nicht auf Kleidung zugeschnitten: "in_use" traegt
|
||||
auch eine Bohrmaschine auf der Baustelle.
|
||||
|
||||
Als kurzer String gespeichert (wie CategoryTracking, RemovalReason), damit
|
||||
sich die Spalte per ADD COLUMN nachziehen laesst.
|
||||
"""
|
||||
in_use = "in_use" # wird gerade benutzt/getragen
|
||||
cleaning = "cleaning" # in der Waesche/Reinigung
|
||||
repair = "repair" # in Reparatur
|
||||
|
||||
|
||||
class Unit(Base):
|
||||
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
||||
|
||||
@@ -349,6 +367,19 @@ class Category(Base):
|
||||
default=CategoryTracking.object.value,
|
||||
server_default=CategoryTracking.food.value,
|
||||
)
|
||||
# Wie lange eine angebrochene Packung dieser Kategorie noch haelt.
|
||||
# NULL = erbt von der Oberkategorie, zuletzt vom globalen Wert
|
||||
# (services/vererbung.py::oeffnungsfrist).
|
||||
open_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren
|
||||
# (Kleidung, Werkzeug - nicht Konserven). Wie tracking eine Kopie beim
|
||||
# Anlegen und keine Aufloesung beim Lesen: So kommt die Markierung ohne
|
||||
# zusaetzliche Abfrage am Einzelstueck an (services/items.py::item_to_out).
|
||||
# Ein Haken an einer Oberkategorie wandert beim Speichern in den Unterbaum,
|
||||
# siehe routers/categories.py::update_category.
|
||||
usage_tracking: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
|
||||
|
||||
class Product(Base):
|
||||
@@ -385,6 +416,11 @@ class Product(Base):
|
||||
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# Frist nach dem Anbrechen, nur fuer diesen Artikel. NULL = erbt von der
|
||||
# Kategorie (und die von ihren Oberkategorien, zuletzt vom globalen Wert).
|
||||
# Noetig fuer Ausnahmen: H-Milch haelt geoeffnet so lange wie Frischmilch,
|
||||
# steckt aber in derselben Kategorie.
|
||||
open_days: Mapped[int | None] = mapped_column(Integer, 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(
|
||||
@@ -511,10 +547,35 @@ class Lot(Base):
|
||||
location_id: Mapped[str | None] = mapped_column(
|
||||
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Angebrochen: NULL = ungeoeffnet. Bewusst KEIN eigenes Statusfeld – das
|
||||
# Datum traegt den Zustand mit und wird fuer die Frist sowieso gebraucht;
|
||||
# ein zweites Feld koennte ihm widersprechen.
|
||||
opened_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# „Zu verbrauchen bis" = opened_on + Frist (services/vererbung.py). Beim
|
||||
# Oeffnen gerechnet und GESPEICHERT: so bleibt das Datum an der offenen
|
||||
# Packung stehen, wenn die Frist spaeter geaendert wird, und die
|
||||
# Ablaufliste muss nicht je Zeile die Kategorie-Vererbung aufloesen.
|
||||
consume_by: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
# Menge beim Oeffnen, damit „noch 400 ml von 1 l" anzeigbar ist.
|
||||
opened_quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
product: Mapped[Product] = relationship(back_populates="lots")
|
||||
|
||||
@property
|
||||
def ist_angebrochen(self) -> bool:
|
||||
return self.opened_on is not None
|
||||
|
||||
@property
|
||||
def faellig_am(self) -> date | None:
|
||||
"""Das frueheste der beiden Daten – danach wird gewarnt.
|
||||
|
||||
MHD und Verbrauchsdatum bleiben getrennt sichtbar; fuer Ablaufliste,
|
||||
Zustand und Erinnerungen zaehlt aber immer das naehere von beiden.
|
||||
"""
|
||||
termine = [d for d in (self.best_before, self.consume_by) if d is not None]
|
||||
return min(termine) if termine else None
|
||||
|
||||
|
||||
class Movement(Base):
|
||||
__tablename__ = "movements"
|
||||
@@ -561,6 +622,81 @@ class PackageType(Base):
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class Measure(Base):
|
||||
"""Ein Hausmaß wie „Esslöffel" – nur der NAME, ohne Menge.
|
||||
|
||||
Die Menge gehört nicht an den Namen, sondern an die Kategorie bzw. den
|
||||
Artikel: 1 EL Mehl sind 10 g, 1 EL Zucker 12 g, 1 EL Öl 15 ml. Ein globaler
|
||||
Faktor wie bei :class:`Unit` wäre also schlicht falsch. Auch die ART
|
||||
(Gramm/Milliliter/Stück) steckt deshalb in der Menge, nicht hier.
|
||||
|
||||
Der Name ist trotzdem global, damit „EL" überall dasselbe Maß bezeichnet –
|
||||
sonst wären „EL" und „Esslöffel" in zwei Kategorien zwei verschiedene Dinge
|
||||
und ein Rezept fände keines davon wieder.
|
||||
"""
|
||||
|
||||
__tablename__ = "measures"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(40), nullable=False, unique=True)
|
||||
# Kurzform fuer Rezepte und schmale Spalten: „EL", „TL".
|
||||
short: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class CategoryMeasure(Base):
|
||||
"""Was ein Hausmaß in DIESER Kategorie wiegt bzw. fasst.
|
||||
|
||||
Gilt über den Kategorie-Baum auch für die Unterkategorien; der nächste
|
||||
Vorfahr mit Eintrag gewinnt (services/vererbung.py). Eigene Tabelle statt
|
||||
nullbarer Spalten in einer gemeinsamen, wie es
|
||||
:class:`ProductLocationMinStock` und :class:`GroupLocationMinStock` schon
|
||||
vormachen.
|
||||
"""
|
||||
|
||||
__tablename__ = "category_measures"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("measure_id", "category_id", name="uq_cat_measure"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
measure_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
category_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# Menge in ``base`` – also 10 fuer „1 EL = 10 g".
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
# Art der Menge: BaseUnit-Wert (piece/gram/milliliter). Als String wie
|
||||
# ``Product.secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist.
|
||||
base: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
|
||||
class ProductMeasure(Base):
|
||||
"""Hausmaß-Menge für EINEN Artikel – schlägt die der Kategorie.
|
||||
|
||||
Für Ausnahmen innerhalb einer Kategorie: Puderzucker ist deutlich leichter
|
||||
als Kristallzucker, steckt aber in derselben Kategorie.
|
||||
"""
|
||||
|
||||
__tablename__ = "product_measures"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("measure_id", "product_id", name="uq_prod_measure"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
measure_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
base: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
"""Lokale Kopie des Artikelbilds.
|
||||
|
||||
@@ -637,6 +773,86 @@ class DashboardLayout(Base):
|
||||
)
|
||||
|
||||
|
||||
class NavigationSichtbarkeit(Base):
|
||||
"""Welche Eintraege der Seitenleiste jemand ausgeblendet hat.
|
||||
|
||||
``user_id = NULL`` ist die Vorgabe des Administrators: Was dort steht,
|
||||
bekommt niemand zu sehen. Eine Zeile mit ``user_id`` ist die zusaetzliche
|
||||
Auswahl eines Kontos.
|
||||
|
||||
**Beide Listen gelten zusammen.** Wer etwas versteckt, versteckt es
|
||||
zusaetzlich; einblenden, was der Administrator ausgeblendet hat, kann
|
||||
niemand. Das erspart einen dritten Zustand je Eintrag ("erben / zeigen /
|
||||
verstecken") und laesst sich in einem Satz erklaeren.
|
||||
|
||||
Versteckt wird nur der EINTRAG, nicht die Seite: Lesezeichen,
|
||||
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
|
||||
abgewaehlt sein koennen.
|
||||
|
||||
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt :class:`Setting`
|
||||
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je
|
||||
Schluessel), Nutzlast als JSON-Text. Als Schluessel dient der Routenpfad -
|
||||
der ist ohnehin schon eindeutig, eine zweite Benennung waere nur eine
|
||||
weitere Stelle, die auseinanderlaufen kann.
|
||||
"""
|
||||
|
||||
__tablename__ = "navigation_sichtbarkeit"
|
||||
__table_args__ = (
|
||||
# Eine Zeile je Konto UND genau eine Vorgabezeile - beides mit einem
|
||||
# Index. Ueber coalesce, weil NULL in SQL nie gleich NULL ist: ein
|
||||
# schlichtes UNIQUE(user_id) liesse beliebig viele Vorgabezeilen zu.
|
||||
Index("uq_navigation_zeile", text("coalesce(user_id, -1)"), unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
# JSON-Liste von Routenpfaden, z.B. ["/rezepte", "/shopping"].
|
||||
versteckt: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
|
||||
# Loescht man ein Konto, geht seine Zeile mit (ON DELETE CASCADE oben).
|
||||
# In den Tests greift das nicht: SQLite erzwingt Fremdschluessel nur mit
|
||||
# PRAGMA foreign_keys=ON, und das setzt die Testumgebung nicht.
|
||||
|
||||
|
||||
class EtikettenVorlage(Base):
|
||||
"""Eine gespeicherte Zusammenstellung fuer den Etikettenexport.
|
||||
|
||||
Benannt, damit man sie wieder aufrufen kann („Kabelexport"), und mit frei
|
||||
gewaehlten Spalten - erst dadurch kommen eigene Felder wie eine Kabellaenge
|
||||
oder die Kapazitaet einer Powerbank ueberhaupt aufs Etikett.
|
||||
|
||||
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt ``Setting``
|
||||
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je Schluessel),
|
||||
Nutzlast als JSON-Text. Anders als dort ohne ``user_id`` - eine Vorlage
|
||||
gehoert allen, nicht einem Konto.
|
||||
"""
|
||||
|
||||
__tablename__ = "etiketten_vorlagen"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
|
||||
# Worauf sich die Vorlage bezieht: items | products | locations.
|
||||
# Als kurzer String und nicht als DB-Enum, wie DatePrecision und
|
||||
# CategoryTracking - so laesst sich eine weitere Art spaeter ohne neuen
|
||||
# Postgres-Typ nachziehen.
|
||||
art: Mapped[str] = mapped_column(String(16), nullable=False, default="items")
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Spaltenwahl, Reihenfolge, Filter und Trennzeichen als JSON-Text.
|
||||
konfiguration: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
|
||||
# Hinterlegte P-touch-Vorlage (.lbx). Hoechstens eine je Eintrag, deshalb
|
||||
# Spalten derselben Zeile statt einer zweiten Tabelle.
|
||||
datei_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# deferred: Sonst zoege jede Liste der Vorlagen die Binaerdaten mit - genau
|
||||
# das vermeidet ItemDocument.data aus demselben Grund.
|
||||
datei: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
@@ -644,6 +860,152 @@ class Setting(Base):
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
|
||||
class IntegrationSecret(Base):
|
||||
"""Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken).
|
||||
|
||||
Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem
|
||||
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre
|
||||
beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt
|
||||
keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist
|
||||
gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen.
|
||||
|
||||
Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir
|
||||
legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt
|
||||
damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater,
|
||||
solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin
|
||||
beim Setzen, nie in einer Antwort.
|
||||
"""
|
||||
|
||||
__tablename__ = "integration_secrets"
|
||||
|
||||
service: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
secret: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class ShoppingEntry(Base):
|
||||
"""Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat.
|
||||
|
||||
Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter
|
||||
ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt
|
||||
die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer
|
||||
"Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese
|
||||
Zutaten fehlen mir fuer das Rezept".
|
||||
|
||||
Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt.
|
||||
Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der
|
||||
Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten
|
||||
unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen
|
||||
kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier
|
||||
keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in
|
||||
der sie auf den Zettel kommt.
|
||||
|
||||
``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei
|
||||
einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl.
|
||||
|
||||
``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als
|
||||
Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man
|
||||
ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen
|
||||
Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die
|
||||
Menge neu, statt die Liste zu verdoppeln.
|
||||
"""
|
||||
|
||||
__tablename__ = "shopping_entries"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
product_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
group_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
free_text: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product
|
||||
# .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist.
|
||||
base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# "manual" | "recipe"
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||
recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, nullable=False
|
||||
)
|
||||
|
||||
product: Mapped[Product | None] = relationship()
|
||||
group: Mapped[Group | None] = relationship()
|
||||
|
||||
|
||||
class ShoppingCheck(Base):
|
||||
"""Ein abgehakter Posten der GERECHNETEN Einkaufsliste.
|
||||
|
||||
Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie
|
||||
entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich
|
||||
deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im
|
||||
Supermarkt.
|
||||
|
||||
Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst:
|
||||
``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem
|
||||
Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit
|
||||
bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der
|
||||
Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das
|
||||
Aufraeumen erledigt "Erledigte entfernen".
|
||||
"""
|
||||
|
||||
__tablename__ = "shopping_checks"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
checked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, nullable=False
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class MealieFoodMap(Base):
|
||||
"""Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y.
|
||||
|
||||
Der unscharfe Abgleich in ``services/rezepte.py`` raet, dieser Tisch weiss.
|
||||
Jede Korrektur von Hand landet hier und gilt ab dann fuer jedes Rezept, in
|
||||
dem dasselbe Lebensmittel vorkommt — eine Zutat wird also hoechstens einmal
|
||||
im Leben zugeordnet.
|
||||
|
||||
Schluessel ist der **normalisierte** Name (``matching.normalisieren``), damit
|
||||
"Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " dieselbe Zeile treffen.
|
||||
Daneben steht der Name, wie Mealie ihn schreibt — nur zur Anzeige in der
|
||||
Liste der gemerkten Zuordnungen.
|
||||
|
||||
Genau eines von ``product_id`` und ``group_id`` ist gesetzt; geprueft wird
|
||||
das beim Speichern (``services/zuordnung.py``), nicht in der Datenbank —
|
||||
dasselbe Muster wie bei den Bedarfszeilen. Verschwindet das Ziel spaeter,
|
||||
wird die Zeile beim Laden stillschweigend uebersprungen: Auf SQLite greift
|
||||
kein Fremdschluessel, und eine Zuordnung auf einen geloeschten Artikel darf
|
||||
den Rezeptabgleich nicht sprengen.
|
||||
"""
|
||||
|
||||
__tablename__ = "mealie_food_map"
|
||||
|
||||
food_key: Mapped[str] = mapped_column(String(200), primary_key=True)
|
||||
food_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
product_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
group_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
|
||||
class Shop(Base):
|
||||
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
|
||||
|
||||
@@ -742,9 +1104,30 @@ class Item(Base):
|
||||
# entsteht; Währung getrennt (z.B. CHF, EUR).
|
||||
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
# Zubehoer: Dieses Stueck gehoert zu einem anderen (Netzteil -> Laptop).
|
||||
# Genau ein Geraet je Teil, beliebig viele Teile je Geraet - dafuer genuegt
|
||||
# eine Spalte, es braucht keine Verknuepfungstabelle wie bei den Gruppen.
|
||||
# SET NULL statt CASCADE: Verschwindet das Geraet, wird das Netzteil wieder
|
||||
# eigenstaendig, statt ungefragt mitgeloescht zu werden.
|
||||
parent_item_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
product: Mapped[Product] = relationship()
|
||||
# Beide Richtungen sind gefragt: am Geraet "was gehoert dazu", am Teil
|
||||
# "wozu gehoert das". selectin, weil sonst jede Einzelstueckliste je Zeile
|
||||
# eine Extraabfrage ausloest (wie bei Group.parents).
|
||||
gehoert_zu: Mapped["Item | None"] = relationship(
|
||||
"Item", remote_side="Item.id", back_populates="zubehoer", lazy="joined",
|
||||
# joined statt selectin: Es ist hoechstens EIN Geraet je Teil, ein
|
||||
# many-to-one-Join kann die Zeilen also nicht vervielfachen - und ohne
|
||||
# das holte jede Einzelstueckliste je Zeile eine Extraabfrage.
|
||||
join_depth=1,
|
||||
)
|
||||
zubehoer: Mapped[list["Item"]] = relationship(
|
||||
"Item", back_populates="gehoert_zu", lazy="selectin", order_by="Item.uid"
|
||||
)
|
||||
location: Mapped[Location | None] = relationship()
|
||||
shop: Mapped[Shop | None] = relationship()
|
||||
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||
@@ -756,6 +1139,12 @@ class Item(Base):
|
||||
back_populates="item", cascade="all, delete-orphan",
|
||||
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
|
||||
)
|
||||
# Verwendungs-Historie, neueste zuerst. Der laufende Zustand (ended_at NULL)
|
||||
# ist hoechstens einer, siehe ItemUsage. Keine offene Zeile = verfuegbar.
|
||||
usages: Mapped[list["ItemUsage"]] = relationship(
|
||||
back_populates="item", cascade="all, delete-orphan",
|
||||
order_by="ItemUsage.started_at.desc(), ItemUsage.id.desc()",
|
||||
)
|
||||
|
||||
|
||||
class ItemDocument(Base):
|
||||
@@ -828,6 +1217,59 @@ class ItemLoan(Base):
|
||||
user: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
class ItemUsage(Base):
|
||||
"""Ein Zeitraum, in dem ein Einzelstueck in einem Verwendungszustand war.
|
||||
|
||||
Bewusst eine eigene Tabelle und keine Spalte am Einzelstueck: So bleibt
|
||||
nachvollziehbar, seit wann ein Hemd in der Waesche liegt und wie oft es
|
||||
getragen wurde. Abgeschlossene Zeitraeume bleiben stehen - damit ist die
|
||||
Tabelle gleichzeitig die Historie je Stueck.
|
||||
|
||||
``ended_at`` NULL heisst "laeuft noch"; ein Stueck kann nur einen solchen
|
||||
laufenden Zustand haben (Teilindex unten). Es gibt keine Zeile fuer
|
||||
"verfuegbar" - keine offene Zeile IST verfuegbar.
|
||||
|
||||
Anders als der Verleih aendert der Zustand den Bestand NICHT: ein getragenes
|
||||
Hemd ist weiterhin im Haushalt. Und wie beim Verleih wird keine Bewegung
|
||||
(Movement) geschrieben, siehe routers/loans.py.
|
||||
"""
|
||||
|
||||
__tablename__ = "item_usages"
|
||||
__table_args__ = (
|
||||
# Zwei Zustaende gleichzeitig kann ein Stueck nicht haben. Als Teilindex,
|
||||
# weil nur die LAUFENDEN eindeutig sein duerfen: abgeschlossene bleiben
|
||||
# beliebig oft je Stueck stehen.
|
||||
Index(
|
||||
"uq_item_usage_offen",
|
||||
"item_id",
|
||||
unique=True,
|
||||
postgresql_where=text("ended_at IS NULL"),
|
||||
sqlite_where=text("ended_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# UsageStatus-Wert als kurzer String (kein Postgres-Enumtyp).
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
# Zeitstempel und nicht nur ein Datum: Ein Hemd kann an einem Tag getragen,
|
||||
# gewaschen und wieder eingeraeumt werden.
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=_now
|
||||
)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Wer umgestellt hat. SET NULL, damit ein geloeschtes Konto den Vorgang
|
||||
# nicht mitnimmt.
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
item: Mapped[Item] = relationship(back_populates="usages")
|
||||
user: Mapped[User | None] = relationship()
|
||||
|
||||
|
||||
class ProductLocationMinStock(Base):
|
||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..deps import get_current_user, nutzer_aus_payload, oauth2_scheme
|
||||
from ..models import User
|
||||
from ..schemas import Token, UserOut
|
||||
from ..security import create_access_token, verify_password
|
||||
from ..security import create_access_token, decode_access_token, verify_password
|
||||
from .settings import get_session_minutes
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -21,10 +22,42 @@ def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Benutzername oder Passwort falsch",
|
||||
)
|
||||
token = create_access_token(user_id=user.id, role=user.role.value)
|
||||
token = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
def refresh(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> Token:
|
||||
"""Stellt der laufenden Sitzung ein frisches Token aus.
|
||||
|
||||
Ohne das laeuft die Frist ab dem *Anmelden*, nicht ab der letzten Nutzung:
|
||||
Wer die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf
|
||||
hinaus. Weboberflaeche und App rufen das beim Start auf; damit zaehlt die
|
||||
eingestellte Dauer ab dem letzten Besuch.
|
||||
|
||||
Bewusst NICHT ueber ``get_current_user``: das nimmt auch langlebige
|
||||
API-Token an (Home Assistant). Aus so einem Token hier ein JWT zu machen,
|
||||
haette aus einem jederzeit widerrufbaren Zugang einen unwiderruflichen
|
||||
gemacht.
|
||||
"""
|
||||
payload = decode_access_token(token)
|
||||
user = nutzer_aus_payload(db, payload) if payload is not None else None
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Ungültige oder abgelaufene Anmeldung",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
neu = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=neu, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import (
|
||||
Category,
|
||||
CategoryMeasure,
|
||||
CategoryTracking,
|
||||
FieldDefinition,
|
||||
Product,
|
||||
@@ -20,6 +21,7 @@ from ..models import (
|
||||
)
|
||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
||||
from ..services.fields import effective_field_definitions
|
||||
from ..services.vererbung import geerbte_kategorie_frist
|
||||
from .field_definitions import to_out as field_to_out
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
@@ -53,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut:
|
||||
out.product_count = (
|
||||
db.query(Product).filter(Product.category_id == category.id).count()
|
||||
)
|
||||
out.inherited_open_days, out.open_days_source = geerbte_kategorie_frist(db, category)
|
||||
return out
|
||||
|
||||
|
||||
@@ -82,7 +85,18 @@ def create_category(
|
||||
tracking = parent.tracking
|
||||
else:
|
||||
tracking = CategoryTracking.object.value
|
||||
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
|
||||
# Verwendungsstatus genauso: gewaehlt > vom Elternteil geerbt > aus.
|
||||
# Eine Unterkategorie von "Kleidung" soll ihn ohne Zutun fuehren.
|
||||
if payload.usage_tracking is not None:
|
||||
usage_tracking = payload.usage_tracking
|
||||
elif parent is not None:
|
||||
usage_tracking = parent.usage_tracking
|
||||
else:
|
||||
usage_tracking = False
|
||||
category = Category(
|
||||
name=payload.name, parent_id=payload.parent_id, tracking=tracking,
|
||||
open_days=payload.open_days, usage_tracking=usage_tracking,
|
||||
)
|
||||
db.add(category)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
@@ -119,6 +133,19 @@ def update_category(
|
||||
data["tracking"] = CategoryTracking(data["tracking"]).value
|
||||
for field, value in data.items():
|
||||
setattr(category, field, value)
|
||||
# Der Verwendungsstatus wandert in den ganzen Unterbaum. Ohne das erreichte
|
||||
# ein Haken an einer bestehenden Kategorie ("Kleidung") deren vorhandene
|
||||
# Unterkategorien nicht - und genau die gibt es ja schon. Bewusst sichtbar:
|
||||
# Die Haken stehen danach auch dort und lassen sich einzeln wieder abwaehlen.
|
||||
if data.get("usage_tracking") is not None:
|
||||
kinder = descendant_ids(db, category_id)
|
||||
if kinder:
|
||||
(
|
||||
db.query(Category)
|
||||
.filter(Category.id.in_(kinder))
|
||||
.update({"usage_tracking": data["usage_tracking"]},
|
||||
synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return _to_out(db, category)
|
||||
@@ -156,6 +183,11 @@ def delete_category(
|
||||
db.query(FieldDefinition).filter(
|
||||
FieldDefinition.category_id == category_id
|
||||
).delete(synchronize_session=False)
|
||||
# Hausmass-Mengen dieser Kategorie – aus demselben Grund ausdruecklich:
|
||||
# sonst blieben Waisen stehen und heften sich an neu vergebene IDs.
|
||||
db.query(CategoryMeasure).filter(
|
||||
CategoryMeasure.category_id == category_id
|
||||
).delete(synchronize_session=False)
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -48,6 +48,7 @@ from ..services.conversion import article_unit
|
||||
from ..services.loans import nicht_verliehen
|
||||
from ..services.stock import current_stock
|
||||
from .settings import get_expiry_warning_days
|
||||
from ..services import einkaufsliste
|
||||
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
@@ -283,14 +284,28 @@ def set_enforced(
|
||||
# --------------------------------------------------------------------------
|
||||
# Auswertungen
|
||||
# --------------------------------------------------------------------------
|
||||
def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str:
|
||||
"""Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung."""
|
||||
def _zustand(
|
||||
best_before: date | None, heute: date, warnfrist: int, *, angebrochen: bool = False
|
||||
) -> str:
|
||||
"""Ablaufzustand einer Charge: abgelaufen / bald / angebrochen / ohne MHD / ok.
|
||||
|
||||
``best_before`` ist hier das WIRKSAME Datum – das frühere aus aufgedrucktem
|
||||
MHD und Verbrauchsdatum (``Lot.faellig_am``).
|
||||
|
||||
Vorrang: abgelaufen > bald ablaufend > angebrochen > in Ordnung.
|
||||
Dringlichkeit schlägt Zustand: Eine angebrochene Packung, die morgen weg
|
||||
muss, gehört in „bald ablaufend" – sonst verstoeckte der ruhigere Zustand
|
||||
die Warnung. „angebrochen" ist damit das, was offen ist und noch Zeit hat.
|
||||
"""
|
||||
if best_before is not None:
|
||||
if best_before < heute:
|
||||
return "expired"
|
||||
if (best_before - heute).days <= warnfrist:
|
||||
return "soon"
|
||||
if angebrochen:
|
||||
return "opened"
|
||||
if best_before is None:
|
||||
return "no_date"
|
||||
if best_before < heute:
|
||||
return "expired"
|
||||
if (best_before - heute).days <= warnfrist:
|
||||
return "soon"
|
||||
return "ok"
|
||||
|
||||
|
||||
@@ -321,7 +336,10 @@ def _bestand_beitraege(
|
||||
continue
|
||||
faktor, _label = article_unit(product)
|
||||
menge = lot.quantity / (faktor or 1.0)
|
||||
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
|
||||
beitraege.append((
|
||||
product, menge,
|
||||
_zustand(lot.faellig_am, heute, warnfrist, angebrochen=lot.ist_angebrochen),
|
||||
))
|
||||
|
||||
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
||||
# Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit.
|
||||
@@ -356,7 +374,16 @@ def stats(
|
||||
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
|
||||
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
|
||||
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
|
||||
bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
|
||||
#
|
||||
# Dazu die selbst draufgesetzten Eintraege, offene wie die aus einem Rezept
|
||||
# uebernommenen: Sonst meldet die Kennzahl „alles erreicht", waehrend auf der
|
||||
# Liste fuenf Posten stehen. Abgehakte zaehlen nicht mehr mit - sie sind
|
||||
# gekauft.
|
||||
bedarf = (
|
||||
len(einkaufsliste_artikel(db))
|
||||
+ len(einkaufsliste_gruppen(db))
|
||||
+ len(einkaufsliste.eintraege(db, mit_erledigten=False))
|
||||
)
|
||||
|
||||
return DashboardStats(
|
||||
products_in_stock=len(bestand_produkte),
|
||||
@@ -374,7 +401,7 @@ def expiry_split(
|
||||
) -> ExpirySplit:
|
||||
heute = date.today()
|
||||
warnfrist = get_expiry_warning_days(db)
|
||||
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
||||
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "opened": 0.0, "no_date": 0.0}
|
||||
for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||
summen[zustand] += menge
|
||||
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
||||
@@ -417,7 +444,10 @@ def by_category(
|
||||
rollup_cache[cid] = ziel
|
||||
return ziel
|
||||
|
||||
leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
|
||||
leer = lambda: {
|
||||
"article_units": 0.0, "ok": 0.0, "soon": 0.0,
|
||||
"expired": 0.0, "opened": 0.0, "no_date": 0.0,
|
||||
}
|
||||
eimer: dict[int | None, dict] = defaultdict(leer)
|
||||
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||
topf = eimer[rollup(product.category_id)]
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Etiketten-Vorlagen: gespeicherte Zusammenstellungen fuer den Export.
|
||||
|
||||
Eine Vorlage haelt fest, worauf sie sich bezieht (Einzelstuecke, Artikel oder
|
||||
Lagerorte), welche Spalten in welcher Reihenfolge exportiert werden und wie
|
||||
gefiltert wird. Dazu kann eine P-touch-Datei (.lbx) hinterlegt werden, damit
|
||||
man sie auf einem fremden Rechner nicht suchen muss.
|
||||
|
||||
Vorlagen gehoeren allen: Anlegen und Aendern bleibt Administratoren
|
||||
vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import EtikettenVorlage, User
|
||||
from ..schemas import (
|
||||
EtikettenVorlageCreate,
|
||||
EtikettenVorlageOut,
|
||||
EtikettenVorlageUpdate,
|
||||
SpalteOut,
|
||||
)
|
||||
from ..services.etiketten import (
|
||||
ARTEN,
|
||||
csv_erzeugen,
|
||||
konfiguration_lesen,
|
||||
konfiguration_schreiben,
|
||||
verfuegbare_spalten,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/etiketten", tags=["etiketten"])
|
||||
|
||||
# Eine .lbx ist ein ZIP von wenigen Kilobyte. Die Grenze ist grosszuegig und
|
||||
# bleibt weit unter der nginx-Grenze (client_max_body_size 12m).
|
||||
LBX_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _vorlage_oder_404(db: Session, vorlage_id: int) -> EtikettenVorlage:
|
||||
vorlage = db.get(EtikettenVorlage, vorlage_id)
|
||||
if vorlage is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Vorlage nicht gefunden")
|
||||
return vorlage
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
"""Steuerzeichen und Anfuehrungszeichen raus – sonst Header-Injektion."""
|
||||
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
|
||||
return cleaned[:255] or "vorlage.lbx"
|
||||
|
||||
|
||||
def _disposition(name: str | None) -> str:
|
||||
"""``Content-Disposition`` fuer einen Dateinamen aus einem Upload.
|
||||
|
||||
Starlette kodiert Header-Werte als latin-1. Ein Dateiname kommt aber vom
|
||||
Dateisystem des Hochladenden und darf alles enthalten, was dort erlaubt
|
||||
ist: macOS legt ein "ä" ZERLEGT ab, als "a" + U+0308, und fuer diesen
|
||||
kombinierenden Akzent gibt es keine latin-1-Entsprechung. Roh eingesetzt
|
||||
sprengt so ein Name die Kodierung – die Route antwortet dann mit 500
|
||||
statt mit der Datei, obwohl der Name am Bildschirm voellig harmlos
|
||||
aussieht (".../Lagerplätze.lbx" von einem Mac ist genau dieser Fall).
|
||||
|
||||
Deshalb nach RFC 6266 zweigleisig: ``filename`` traegt eine ASCII-Fassung
|
||||
als Rueckfall, ``filename*`` den echten Namen prozentkodiert. Browser
|
||||
bevorzugen ``filename*`` und zeigen damit die Umlaute richtig an.
|
||||
"""
|
||||
sicher = _safe_filename(name)
|
||||
# NFKD zerlegt "ä" in "a" + Akzent, "ignore" wirft dann nur den Akzent
|
||||
# weg: "Lagerplätze.lbx" wird zu "Lagerplatze.lbx" und bleibt lesbar.
|
||||
ascii_fassung = (
|
||||
unicodedata.normalize("NFKD", sicher).encode("ascii", "ignore").decode("ascii")
|
||||
)
|
||||
# Ein Name ganz ohne ASCII-Anteil (z. B. rein kyrillisch) bliebe sonst leer.
|
||||
return (
|
||||
f'attachment; filename="{ascii_fassung.strip() or "vorlage.lbx"}"; '
|
||||
f"filename*=UTF-8''{quote(sicher, safe='')}"
|
||||
)
|
||||
|
||||
|
||||
def _pruefe_lbx(dateiname: str, daten: bytes) -> None:
|
||||
"""Ist das plausibel eine P-touch-Vorlage?
|
||||
|
||||
Eine .lbx ist ein ZIP mit label.xml (und prop.xml) darin. Geprueft wird
|
||||
genau das – nicht der vom Browser gemeldete Content-Type, denn fuer .lbx
|
||||
meldet der meist application/octet-stream oder gar nichts.
|
||||
"""
|
||||
if not daten:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
|
||||
if len(daten) > LBX_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
f"Datei zu groß (max. {LBX_MAX_BYTES // (1024 * 1024)} MB).",
|
||||
)
|
||||
if not dateiname.lower().endswith(".lbx"):
|
||||
raise HTTPException(
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
"Nur .lbx-Dateien aus dem P-touch Editor.",
|
||||
)
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(daten)) as archiv:
|
||||
namen = {n.lower() for n in archiv.namelist()}
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
"Das ist keine P-touch-Vorlage (die Datei ist kein ZIP-Archiv).",
|
||||
) from None
|
||||
if "label.xml" not in namen:
|
||||
raise HTTPException(
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
"Das ist keine P-touch-Vorlage (label.xml fehlt).",
|
||||
)
|
||||
|
||||
|
||||
def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut:
|
||||
konf = konfiguration_lesen(vorlage)
|
||||
return EtikettenVorlageOut(
|
||||
id=vorlage.id,
|
||||
name=vorlage.name,
|
||||
art=vorlage.art,
|
||||
position=vorlage.position,
|
||||
spalten=konf["spalten"],
|
||||
kategorie_ids=konf["kategorie_ids"],
|
||||
trennzeichen=konf["trennzeichen"],
|
||||
feldfilter=konf["feldfilter"],
|
||||
datei_name=vorlage.datei_name,
|
||||
updated_at=vorlage.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/spalten", response_model=list[SpalteOut])
|
||||
def spalten(
|
||||
art: str = "items",
|
||||
kategorie_ids: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[SpalteOut]:
|
||||
"""Welche Spalten fuer diese Bezugsart und Kategorien zur Wahl stehen.
|
||||
|
||||
Die eigenen Felder haengen an den Kategorien – ohne diesen Endpunkt
|
||||
wuesste die Oberflaeche nicht, dass es „Kapazität (mAh)" ueberhaupt gibt.
|
||||
"""
|
||||
if art not in ARTEN:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannte Bezugsart „{art}“")
|
||||
ids = [int(t) for t in (kategorie_ids or "").split(",") if t.strip().isdigit()]
|
||||
return [SpalteOut(**s) for s in verfuegbare_spalten(db, art, ids)]
|
||||
|
||||
|
||||
@router.get("/vorlagen", response_model=list[EtikettenVorlageOut])
|
||||
def vorlagen_auflisten(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[EtikettenVorlageOut]:
|
||||
rows = (
|
||||
db.query(EtikettenVorlage)
|
||||
.order_by(EtikettenVorlage.position, EtikettenVorlage.id)
|
||||
.all()
|
||||
)
|
||||
return [_als_out(v) for v in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/vorlagen", response_model=EtikettenVorlageOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def vorlage_anlegen(
|
||||
payload: EtikettenVorlageCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> EtikettenVorlageOut:
|
||||
name = payload.name.strip()
|
||||
if db.query(EtikettenVorlage).filter(EtikettenVorlage.name == name).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen")
|
||||
hoechste = (
|
||||
db.query(EtikettenVorlage).order_by(EtikettenVorlage.position.desc()).first()
|
||||
)
|
||||
vorlage = EtikettenVorlage(
|
||||
name=name,
|
||||
art=payload.art,
|
||||
position=(hoechste.position + 1) if hoechste else 0,
|
||||
konfiguration=konfiguration_schreiben(
|
||||
payload.spalten,
|
||||
payload.kategorie_ids,
|
||||
payload.trennzeichen,
|
||||
# json.dumps kann mit Pydantic-Modellen nichts anfangen.
|
||||
[f.model_dump() for f in payload.feldfilter],
|
||||
),
|
||||
)
|
||||
db.add(vorlage)
|
||||
db.commit()
|
||||
db.refresh(vorlage)
|
||||
return _als_out(vorlage)
|
||||
|
||||
|
||||
@router.patch("/vorlagen/{vorlage_id}", response_model=EtikettenVorlageOut)
|
||||
def vorlage_aendern(
|
||||
vorlage_id: int,
|
||||
payload: EtikettenVorlageUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> EtikettenVorlageOut:
|
||||
vorlage = _vorlage_oder_404(db, vorlage_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "name" in data:
|
||||
neuer = data["name"].strip()
|
||||
doppelt = (
|
||||
db.query(EtikettenVorlage)
|
||||
.filter(EtikettenVorlage.name == neuer, EtikettenVorlage.id != vorlage.id)
|
||||
.first()
|
||||
)
|
||||
if doppelt:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen"
|
||||
)
|
||||
vorlage.name = neuer
|
||||
if "art" in data:
|
||||
vorlage.art = data["art"]
|
||||
if "position" in data:
|
||||
vorlage.position = data["position"]
|
||||
|
||||
# Die Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
|
||||
# einen schickt, darf die anderen nicht verlieren.
|
||||
if {"spalten", "kategorie_ids", "trennzeichen", "feldfilter"} & data.keys():
|
||||
alt = konfiguration_lesen(vorlage)
|
||||
vorlage.konfiguration = konfiguration_schreiben(
|
||||
data.get("spalten", alt["spalten"]),
|
||||
data.get("kategorie_ids", alt["kategorie_ids"]),
|
||||
data.get("trennzeichen", alt["trennzeichen"]),
|
||||
data.get("feldfilter", alt["feldfilter"]),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(vorlage)
|
||||
return _als_out(vorlage)
|
||||
|
||||
|
||||
@router.delete("/vorlagen/{vorlage_id}")
|
||||
def vorlage_loeschen(
|
||||
vorlage_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Response:
|
||||
db.delete(_vorlage_oder_404(db, vorlage_id))
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/vorlagen/{vorlage_id}/csv")
|
||||
def vorlage_csv(
|
||||
vorlage_id: int,
|
||||
basis_url: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Die CSV zu einer Vorlage.
|
||||
|
||||
``basis_url`` braucht nur der Lagerort-QR (…/l/<id>); Einzelstuecke tragen
|
||||
vorrania://i/<UID> ohne jede Adresse. Die Oberflaeche reicht ihre eigene
|
||||
Herkunft durch, weil nur sie weiss, unter welcher Adresse sie erreichbar ist.
|
||||
"""
|
||||
vorlage = _vorlage_oder_404(db, vorlage_id)
|
||||
inhalt = csv_erzeugen(db, vorlage, basis_url=basis_url)
|
||||
dateiname = f"{vorlage.name}.csv".replace("/", "-")
|
||||
return Response(
|
||||
content=inhalt.encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
# Derselbe Riegel wie beim .lbx-Download: Der Vorlagenname ist frei
|
||||
# getippt und kann genauso Zeichen tragen, die latin-1 nicht kennt.
|
||||
"Content-Disposition": _disposition(dateiname),
|
||||
# Der Sinn dieses Downloads ist der aktuelle Stand. Ohne Angabe
|
||||
# darf ein Browser oder Proxy selbst entscheiden, ob er eine alte
|
||||
# Antwort wiederverwendet - und dann fehlen neue Zeilen.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vorlagen/{vorlage_id}/datei", response_model=EtikettenVorlageOut)
|
||||
async def datei_hochladen(
|
||||
vorlage_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> EtikettenVorlageOut:
|
||||
"""Die P-touch-Vorlage (.lbx) hinterlegen."""
|
||||
vorlage = _vorlage_oder_404(db, vorlage_id)
|
||||
daten = await file.read()
|
||||
name = _safe_filename(file.filename)
|
||||
_pruefe_lbx(name, daten)
|
||||
vorlage.datei_name = name
|
||||
vorlage.datei = daten
|
||||
db.commit()
|
||||
db.refresh(vorlage)
|
||||
return _als_out(vorlage)
|
||||
|
||||
|
||||
@router.get("/vorlagen/{vorlage_id}/datei")
|
||||
def datei_herunterladen(
|
||||
vorlage_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Response:
|
||||
vorlage = _vorlage_oder_404(db, vorlage_id)
|
||||
if vorlage.datei is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Zu dieser Vorlage ist keine Datei hinterlegt")
|
||||
return Response(
|
||||
content=vorlage.datei,
|
||||
# attachment und nicht inline: Die Datei soll gespeichert werden, der
|
||||
# Browser kann mit ihr ohnehin nichts anfangen.
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": _disposition(vorlage.datei_name)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/vorlagen/{vorlage_id}/datei")
|
||||
def datei_entfernen(
|
||||
vorlage_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Response:
|
||||
vorlage = _vorlage_oder_404(db, vorlage_id)
|
||||
vorlage.datei = None
|
||||
vorlage.datei_name = None
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
@@ -17,6 +17,7 @@ from ..models import (
|
||||
Item,
|
||||
ItemDocument,
|
||||
ItemLoan,
|
||||
ItemUsage,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
@@ -31,6 +32,7 @@ from ..schemas import (
|
||||
ItemOut,
|
||||
ItemRemove,
|
||||
ItemUpdate,
|
||||
ZubehoerZuordnen,
|
||||
)
|
||||
from ..services.items import generate_uid, item_to_out
|
||||
from ..services.warranty import (
|
||||
@@ -56,6 +58,18 @@ def _product_or_404(db: Session, product_id: int) -> Product:
|
||||
return product
|
||||
|
||||
|
||||
def _zubehoer_freigeben(item: Item) -> None:
|
||||
"""Zubehoer vom Geraet loesen, bevor das Geraet verschwindet.
|
||||
|
||||
ON DELETE SET NULL steht zwar am Fremdschluessel, aber SQLite erzwingt
|
||||
Fremdschluessel nicht - in den Tests bliebe sonst eine tote Zuordnung
|
||||
stehen. Dieselbe Vorsicht wie beim Loeschen einer Gruppe.
|
||||
"""
|
||||
for teil in list(item.zubehoer):
|
||||
teil.parent_item_id = None
|
||||
item.parent_item_id = None
|
||||
|
||||
|
||||
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||
item = db.get(Item, item_id)
|
||||
if item is None:
|
||||
@@ -89,7 +103,7 @@ def create_items(
|
||||
product_id: int,
|
||||
payload: ItemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
user: User = Depends(require_admin),
|
||||
) -> list[ItemOut]:
|
||||
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||
product = _product_or_404(db, product_id)
|
||||
@@ -109,6 +123,23 @@ def create_items(
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
# Wie beim Entfernen eine Bewegung schreiben (siehe remove_item). Ohne
|
||||
# sie kannte der Verlauf nur das Verschwinden eines Einzelstuecks, nie
|
||||
# sein Entstehen - und das Dashboard zeigte Abgaenge ohne Zugaenge.
|
||||
# Fuer den Bestand aendert das nichts: der zaehlt bei Einzelstuecken die
|
||||
# Zeilen in items (services/stock.py), er rechnet nicht aus Bewegungen.
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=None,
|
||||
user_id=user.id,
|
||||
type=MovementType.in_,
|
||||
quantity=1,
|
||||
unit_used="Stück",
|
||||
note=(payload.note or f"Einzelstück {item.uid}"),
|
||||
location_id=payload.location_id,
|
||||
)
|
||||
)
|
||||
created.append(item)
|
||||
db.commit()
|
||||
for item in created:
|
||||
@@ -136,6 +167,10 @@ def list_all_items(
|
||||
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
|
||||
# verliehenem Stueck einzeln nach (N+1).
|
||||
joinedload(Item.loans).joinedload(ItemLoan.user),
|
||||
# selectin und nicht joined: Hier haengen schon drei Sammlungen
|
||||
# dran, eine vierte vervielfachte die Zeilen ueber Kreuz. So kostet
|
||||
# es eine zusaetzliche Abfrage statt eines Kreuzprodukts.
|
||||
selectinload(Item.usages).joinedload(ItemUsage.user),
|
||||
)
|
||||
.order_by(Item.id)
|
||||
.all()
|
||||
@@ -191,6 +226,7 @@ def remove_item(
|
||||
) -> None:
|
||||
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
||||
item = _item_or_404(db, item_id)
|
||||
_zubehoer_freigeben(item)
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=item.product_id,
|
||||
@@ -215,7 +251,83 @@ def delete_item(
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
||||
db.delete(_item_or_404(db, item_id))
|
||||
item = _item_or_404(db, item_id)
|
||||
_zubehoer_freigeben(item)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop)
|
||||
# --------------------------------------------------------------------------
|
||||
def _ringpruefung(db: Session, geraet: Item, teil: Item) -> None:
|
||||
"""Verhindert, dass ein Stueck ueber Umwege sein eigenes Zubehoer wird.
|
||||
|
||||
Mit nur einem Elternteil je Teil ist die Kette kurz, aber A->B->A waere
|
||||
ohne Pruefung moeglich - und danach liefe jedes Hochlaufen der Kette
|
||||
endlos. Deshalb vom Geraet aus nach oben gehen, wie beim Kategoriebaum.
|
||||
"""
|
||||
if geraet.id == teil.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Einzelstück kann nicht sein eigenes Zubehör sein"
|
||||
)
|
||||
gesehen: set[int] = set()
|
||||
lauf = geraet.gehoert_zu
|
||||
while lauf is not None and lauf.id not in gesehen:
|
||||
if lauf.id == teil.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"„{teil.uid}“ ist bereits weiter oben in dieser Zubehör-Kette",
|
||||
)
|
||||
gesehen.add(lauf.id)
|
||||
lauf = lauf.gehoert_zu
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/zubehoer", response_model=ItemOut)
|
||||
def zubehoer_zuordnen(
|
||||
item_id: int,
|
||||
payload: ZubehoerZuordnen,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ItemOut:
|
||||
"""Ein Einzelstück als Zubehör an dieses Gerät hängen.
|
||||
|
||||
Eigener Endpunkt und nicht über ``ItemUpdate``: Das Schema sendet bewusst
|
||||
immer alle Felder mit, auch leere, damit sich Felder loeschen lassen. Eine
|
||||
Beziehung darueber zu pflegen hiesse, dass ein Aufrufer sie still kappt,
|
||||
sobald er das Feld vergisst - genau so gingen schon einmal Kaufpreis und
|
||||
Waehrung verloren.
|
||||
"""
|
||||
geraet = _item_or_404(db, item_id)
|
||||
teil = _item_or_404(db, payload.child_item_id)
|
||||
_ringpruefung(db, geraet, teil)
|
||||
# Ein Teil haengt an genau einem Geraet - ein Wechsel ist deshalb einfach
|
||||
# ein Umhaengen und braucht kein vorheriges Loesen.
|
||||
teil.parent_item_id = geraet.id
|
||||
db.commit()
|
||||
db.refresh(geraet)
|
||||
return item_to_out(geraet)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/items/{item_id}/zubehoer/{child_item_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def zubehoer_loesen(
|
||||
item_id: int,
|
||||
child_item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Zubehör vom Gerät lösen. Das Stück selbst bleibt bestehen."""
|
||||
geraet = _item_or_404(db, item_id)
|
||||
teil = _item_or_404(db, child_item_id)
|
||||
if teil.parent_item_id != geraet.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
f"„{teil.uid}“ gehört nicht zu „{geraet.uid}“",
|
||||
)
|
||||
teil.parent_item_id = None
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,28 @@ def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | No
|
||||
)
|
||||
|
||||
|
||||
def _zubehoer_pruefen(db: Session, geraet: Item, ids: list[int]) -> list[Item]:
|
||||
"""Die benannten Teile holen und pruefen, dass sie zu diesem Geraet gehoeren.
|
||||
|
||||
Ohne die Pruefung koennte ein Aufrufer ueber diesen Weg ein beliebiges
|
||||
fremdes Stueck mitverleihen.
|
||||
"""
|
||||
teile: list[Item] = []
|
||||
for teil_id in dict.fromkeys(ids):
|
||||
teil = db.get(Item, teil_id)
|
||||
if teil is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND, "Zubehörteil nicht gefunden"
|
||||
)
|
||||
if teil.parent_item_id != geraet.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"„{teil.uid}“ ist kein Zubehör von „{geraet.uid}“",
|
||||
)
|
||||
teile.append(teil)
|
||||
return teile
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -81,6 +103,27 @@ def lend_item(
|
||||
user_id=user.id,
|
||||
)
|
||||
db.add(loan)
|
||||
|
||||
# Zubehoer geht nur mit, wenn es ausdruecklich benannt wurde - die
|
||||
# Oberflaeche fragt danach. Ein Netzteil, das im Schrank bleibt, darf nicht
|
||||
# als verliehen gefuehrt werden, nur weil es am Geraet haengt.
|
||||
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
|
||||
if offener_verleih(db, teil.id) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"„{teil.uid}“ ist bereits verliehen.",
|
||||
)
|
||||
db.add(
|
||||
ItemLoan(
|
||||
item_id=teil.id,
|
||||
borrower=name,
|
||||
reason=(payload.reason or "").strip() or None,
|
||||
lent_on=lent_on,
|
||||
due_on=payload.due_on,
|
||||
user_id=user.id,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
@@ -103,6 +146,16 @@ def return_item(
|
||||
returned_on = payload.returned_on or date.today()
|
||||
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
||||
loan.returned_on = returned_on
|
||||
|
||||
# Spiegelbild zum Verleihen: Ohne das kaeme das Geraet zurueck und sein
|
||||
# Netzteil bliebe ewig „verliehen“, weil niemand mehr danach sucht.
|
||||
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
|
||||
offen = offener_verleih(db, teil.id)
|
||||
if offen is None:
|
||||
continue # war gar nicht mit draussen – kein Fehler, nur nichts zu tun
|
||||
_pruefe_zeitfolge(offen.lent_on, offen.due_on, returned_on)
|
||||
offen.returned_on = returned_on
|
||||
|
||||
db.commit()
|
||||
db.refresh(loan)
|
||||
return loan_to_out(loan)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Mealie einrichten und prüfen.
|
||||
|
||||
Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die
|
||||
Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei
|
||||
``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort
|
||||
stehen.
|
||||
|
||||
Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``:
|
||||
Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort
|
||||
nichts zu suchen.
|
||||
"""
|
||||
|
||||
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Setting, User
|
||||
from ..schemas import (
|
||||
MealieAvailable,
|
||||
MealieConfigIn,
|
||||
MealieStatus,
|
||||
MealieTokenIn,
|
||||
)
|
||||
from ..services import mealie
|
||||
|
||||
router = APIRouter(prefix="/mealie", tags=["mealie"])
|
||||
|
||||
|
||||
def _setze_adresse(db: Session, base_url: str) -> str:
|
||||
sauber = base_url.strip().rstrip("/")
|
||||
row = db.get(Setting, mealie.BASE_URL_KEY)
|
||||
if row is None:
|
||||
db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber))
|
||||
else:
|
||||
row.value = sauber
|
||||
db.commit()
|
||||
return sauber
|
||||
|
||||
|
||||
def _status(db: Session, pruefen: bool) -> MealieStatus:
|
||||
adresse = mealie.basis_adresse(db)
|
||||
hat_token = bool(mealie.token(db))
|
||||
status_out = MealieStatus(
|
||||
configured=bool(adresse) and hat_token,
|
||||
base_url=adresse,
|
||||
token_set=hat_token,
|
||||
)
|
||||
if not pruefen or not status_out.configured:
|
||||
return status_out
|
||||
try:
|
||||
status_out.version = mealie.version(db)
|
||||
# Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt.
|
||||
mealie.rezepte(db, pro_seite=1)
|
||||
status_out.reachable = True
|
||||
except mealie.MealieFehler as fehler:
|
||||
status_out.reachable = False
|
||||
status_out.error = str(fehler)
|
||||
return status_out
|
||||
|
||||
|
||||
@router.get("/status", response_model=MealieStatus)
|
||||
def mealie_status(
|
||||
pruefen: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an."""
|
||||
return _status(db, pruefen)
|
||||
|
||||
|
||||
@router.get("/available", response_model=MealieAvailable)
|
||||
def mealie_available(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> MealieAvailable:
|
||||
"""Ob die Rezept-Seite etwas anzeigen kann.
|
||||
|
||||
Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann
|
||||
zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen
|
||||
Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt.
|
||||
"""
|
||||
return MealieAvailable(available=mealie.eingerichtet(db))
|
||||
|
||||
|
||||
@router.put("/config", response_model=MealieStatus)
|
||||
def mealie_konfigurieren(
|
||||
payload: MealieConfigIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Mit Benutzername und Passwort anmelden und ein Dauertoken holen.
|
||||
|
||||
Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen
|
||||
– es steht in keiner Zeile der Datenbank und in keiner Antwort.
|
||||
"""
|
||||
try:
|
||||
dauertoken = mealie.hole_dauertoken(
|
||||
payload.base_url, payload.username, payload.password
|
||||
)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise HTTPException(_http_code(fehler), str(fehler)) from fehler
|
||||
_setze_adresse(db, payload.base_url)
|
||||
mealie.speichere_token(db, dauertoken)
|
||||
return _status(db, pruefen=True)
|
||||
|
||||
|
||||
@router.put("/token", response_model=MealieStatus)
|
||||
def mealie_token_setzen(
|
||||
payload: MealieTokenIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MealieStatus:
|
||||
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen.
|
||||
|
||||
Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸
|
||||
API-Tokens eines anlegen und hier einfügen.
|
||||
"""
|
||||
_setze_adresse(db, payload.base_url)
|
||||
mealie.speichere_token(db, payload.token.strip())
|
||||
return _status(db, pruefen=True)
|
||||
|
||||
|
||||
@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def mealie_trennen(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Verbindung lösen: Adresse und Token hier entfernen.
|
||||
|
||||
Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein
|
||||
will, löscht es unter Profil ▸ API-Tokens.
|
||||
"""
|
||||
mealie.loesche_zugang(db)
|
||||
|
||||
|
||||
def _http_code(fehler: mealie.MealieFehler) -> int:
|
||||
"""Fehlerkennung auf einen Statuscode abbilden.
|
||||
|
||||
Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort
|
||||
stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll.
|
||||
"""
|
||||
if fehler.kennung in ("anmeldung_fehlgeschlagen",):
|
||||
return status.HTTP_400_BAD_REQUEST
|
||||
if fehler.kennung == "nicht_eingerichtet":
|
||||
return status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return status.HTTP_502_BAD_GATEWAY
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Hausmaße: die Namen (Stammdaten) und ihre Mengen je Kategorie und Artikel.
|
||||
|
||||
Der Name ist global („Esslöffel"), die Menge nicht: 1 EL Mehl sind 10 g, 1 EL
|
||||
Zucker 12 g. Deshalb zwei Ebenen – ein Maß anlegen ist Stammdatenpflege, ihm
|
||||
eine Menge geben hängt an Kategorie oder Artikel.
|
||||
"""
|
||||
|
||||
# ACHTUNG: Hier steht bewusst KEIN ``from __future__ import annotations`` –
|
||||
# aus demselben Grund wie in package_types.py (``-> None`` wuerde als
|
||||
# Antwortmodell gewertet und bricht mit 204 den Start des Dienstes ab).
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import (
|
||||
Category,
|
||||
CategoryMeasure,
|
||||
Measure,
|
||||
Product,
|
||||
ProductMeasure,
|
||||
User,
|
||||
)
|
||||
from ..schemas import (
|
||||
MeasureAmountIn,
|
||||
MeasureAmountOut,
|
||||
MeasureIn,
|
||||
MeasureOut,
|
||||
)
|
||||
from ..services.conversion import PACKAGE_TOKENS, find_unit
|
||||
from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse
|
||||
|
||||
router = APIRouter(tags=["measures"])
|
||||
|
||||
|
||||
def _measure_or_404(db: Session, measure_id: int) -> Measure:
|
||||
measure = db.get(Measure, measure_id)
|
||||
if measure is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Maß nicht gefunden")
|
||||
return measure
|
||||
|
||||
|
||||
def _pruefe_namen(db: Session, name: str, short: str | None, ausser_id: int | None = None) -> None:
|
||||
"""Name und Kurzform müssen eindeutig und frei von Kollisionen sein.
|
||||
|
||||
Ein Maß, das genauso heißt wie eine verwaltete Einheit oder wie „Packung",
|
||||
wäre dauerhaft unerreichbar: ``to_base`` prüft beide vorher. Lieber hier
|
||||
ablehnen als still etwas anlegen, das nie greift.
|
||||
"""
|
||||
for wert, feld in ((name, "Name"), (short, "Kurzform")):
|
||||
if not wert:
|
||||
continue
|
||||
t = wert.strip().lower()
|
||||
if t in PACKAGE_TOKENS:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"'{wert}' ist als Packungs-Angabe vergeben und würde nie greifen.",
|
||||
)
|
||||
if find_unit(db, t) is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"'{wert}' ist bereits eine verwaltete Einheit. Als Maß würde es "
|
||||
f"nie greifen, weil Einheiten zuerst geprüft werden.",
|
||||
)
|
||||
doppelt = db.query(Measure).filter(
|
||||
(func.lower(Measure.name) == t) | (func.lower(Measure.short) == t)
|
||||
)
|
||||
if ausser_id is not None:
|
||||
doppelt = doppelt.filter(Measure.id != ausser_id)
|
||||
if doppelt.first() is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, f"{feld} '{wert}' gibt es schon.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Stammdaten
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/measures", response_model=list[MeasureOut])
|
||||
def list_measures(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[Measure]:
|
||||
return db.query(Measure).order_by(Measure.position, Measure.id).all()
|
||||
|
||||
|
||||
@router.post("/measures", response_model=MeasureOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_measure(
|
||||
payload: MeasureIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Measure:
|
||||
_pruefe_namen(db, payload.name, payload.short)
|
||||
measure = Measure(
|
||||
name=payload.name.strip(),
|
||||
short=(payload.short or "").strip() or None,
|
||||
position=payload.position,
|
||||
)
|
||||
db.add(measure)
|
||||
db.commit()
|
||||
db.refresh(measure)
|
||||
return measure
|
||||
|
||||
|
||||
@router.patch("/measures/{measure_id}", response_model=MeasureOut)
|
||||
def update_measure(
|
||||
measure_id: int,
|
||||
payload: MeasureIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Measure:
|
||||
"""Umbenennen – auch bei eingebauten Maßen, wie bei Einheiten und Gebinden.
|
||||
|
||||
Die Mengen hängen an der ID, nicht am Namen, und wandern deshalb mit.
|
||||
"""
|
||||
measure = _measure_or_404(db, measure_id)
|
||||
_pruefe_namen(db, payload.name, payload.short, ausser_id=measure.id)
|
||||
measure.name = payload.name.strip()
|
||||
measure.short = (payload.short or "").strip() or None
|
||||
measure.position = payload.position
|
||||
db.commit()
|
||||
db.refresh(measure)
|
||||
return measure
|
||||
|
||||
|
||||
@router.delete("/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_measure(
|
||||
measure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Maß löschen – samt aller Mengen, die daran hängen.
|
||||
|
||||
Die Mengen werden ausdrücklich abgeräumt: SQLite setzt Fremdschlüssel nicht
|
||||
ohne Weiteres um, sonst blieben Waisen stehen (gleiche Begründung wie in
|
||||
``delete_category``).
|
||||
"""
|
||||
measure = _measure_or_404(db, measure_id)
|
||||
if measure.is_builtin:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Eingebaute Maße lassen sich nicht löschen – umbenennen geht aber.",
|
||||
)
|
||||
db.query(CategoryMeasure).filter(CategoryMeasure.measure_id == measure.id).delete()
|
||||
db.query(ProductMeasure).filter(ProductMeasure.measure_id == measure.id).delete()
|
||||
db.delete(measure)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mengen je Kategorie und Artikel
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/products/{product_id}/measures", response_model=list[MeasureAmountOut])
|
||||
def product_measures(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[MeasureAmountOut]:
|
||||
"""Die für diesen Artikel WIRKSAMEN Maße, mit Herkunft.
|
||||
|
||||
Grundlage für die Einheitenauswahl beim Verbrauchen – dort soll „EL" nur
|
||||
auftauchen, wenn dafür auch eine Menge bekannt ist.
|
||||
"""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||
return [
|
||||
MeasureAmountOut(
|
||||
measure_id=a.measure.id, name=a.measure.name, short=a.measure.short,
|
||||
amount=a.amount, base=a.base, herkunft=a.herkunft,
|
||||
)
|
||||
for a in verfuegbare_masse(db, product)
|
||||
]
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/measures/{measure_id}", response_model=MeasureAmountOut)
|
||||
def set_product_measure(
|
||||
product_id: int,
|
||||
measure_id: int,
|
||||
payload: MeasureAmountIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MeasureAmountOut:
|
||||
"""Menge nur für diesen Artikel – schlägt die der Kategorie."""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||
measure = _measure_or_404(db, measure_id)
|
||||
row = (
|
||||
db.query(ProductMeasure)
|
||||
.filter(ProductMeasure.product_id == product.id,
|
||||
ProductMeasure.measure_id == measure.id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
row = ProductMeasure(product_id=product.id, measure_id=measure.id,
|
||||
amount=payload.amount, base=payload.base.value)
|
||||
db.add(row)
|
||||
else:
|
||||
row.amount = payload.amount
|
||||
row.base = payload.base.value
|
||||
db.commit()
|
||||
return MeasureAmountOut(
|
||||
measure_id=measure.id, name=measure.name, short=measure.short,
|
||||
amount=row.amount, base=row.base, herkunft="Artikel",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}/measures/{measure_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product_measure(
|
||||
product_id: int,
|
||||
measure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Eigene Menge entfernen – danach gilt wieder die der Kategorie."""
|
||||
db.query(ProductMeasure).filter(
|
||||
ProductMeasure.product_id == product_id,
|
||||
ProductMeasure.measure_id == measure_id,
|
||||
).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut])
|
||||
def category_measures(
|
||||
category_id: int,
|
||||
vererbt: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[MeasureAmountOut]:
|
||||
"""Die an DIESER Kategorie hinterlegten Mengen.
|
||||
|
||||
Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit
|
||||
``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer
|
||||
Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was
|
||||
gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende
|
||||
bekommt, zeigt ``/products/{id}/measures``.
|
||||
"""
|
||||
if db.get(Category, category_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||
rows = (
|
||||
db.query(CategoryMeasure, Measure)
|
||||
.join(Measure, CategoryMeasure.measure_id == Measure.id)
|
||||
.filter(CategoryMeasure.category_id == category_id)
|
||||
.order_by(Measure.position, Measure.id)
|
||||
.all()
|
||||
)
|
||||
eigene = [
|
||||
MeasureAmountOut(
|
||||
measure_id=m.id, name=m.name, short=m.short,
|
||||
amount=cm.amount, base=cm.base, herkunft="Kategorie",
|
||||
)
|
||||
for cm, m in rows
|
||||
]
|
||||
if not vererbt:
|
||||
return eigene
|
||||
|
||||
# Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit
|
||||
# beides: was hier steht und was gelten wuerde, wenn man es loescht.
|
||||
category = db.get(Category, category_id)
|
||||
gesetzt = {e.measure_id for e in eigene}
|
||||
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
|
||||
if measure.id in gesetzt:
|
||||
continue
|
||||
angabe = geerbtes_mass_kategorie(db, category, measure.id)
|
||||
if angabe is None:
|
||||
continue
|
||||
eigene.append(MeasureAmountOut(
|
||||
measure_id=measure.id, name=measure.name, short=measure.short,
|
||||
amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft,
|
||||
))
|
||||
return eigene
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut)
|
||||
def set_category_measure(
|
||||
category_id: int,
|
||||
measure_id: int,
|
||||
payload: MeasureAmountIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> MeasureAmountOut:
|
||||
"""Menge für eine Kategorie – gilt auch für ihre Unterkategorien."""
|
||||
if db.get(Category, category_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||
measure = _measure_or_404(db, measure_id)
|
||||
row = (
|
||||
db.query(CategoryMeasure)
|
||||
.filter(CategoryMeasure.category_id == category_id,
|
||||
CategoryMeasure.measure_id == measure.id)
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
row = CategoryMeasure(category_id=category_id, measure_id=measure.id,
|
||||
amount=payload.amount, base=payload.base.value)
|
||||
db.add(row)
|
||||
else:
|
||||
row.amount = payload.amount
|
||||
row.base = payload.base.value
|
||||
db.commit()
|
||||
return MeasureAmountOut(
|
||||
measure_id=measure.id, name=measure.name, short=measure.short,
|
||||
amount=row.amount, base=row.base, herkunft="Kategorie",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/categories/{category_id}/measures/{measure_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_category_measure(
|
||||
category_id: int,
|
||||
measure_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
db.query(CategoryMeasure).filter(
|
||||
CategoryMeasure.category_id == category_id,
|
||||
CategoryMeasure.measure_id == measure_id,
|
||||
).delete()
|
||||
db.commit()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Welche Einträge der Seitenleiste jemand ausgeblendet hat.
|
||||
|
||||
Zwei Ebenen: die **Vorgabe des Administrators** gilt für alle (``user_id``
|
||||
NULL), die **eigene Auswahl** eines Kontos kommt obendrauf. Beide Listen gelten
|
||||
zusammen — wer etwas versteckt, versteckt es zusätzlich; einblenden, was der
|
||||
Administrator ausgeblendet hat, kann niemand.
|
||||
|
||||
Die eigene Auswahl darf jeder für sich ändern (es betrifft nur seine Ansicht),
|
||||
die Vorgabe nur ein Administrator.
|
||||
|
||||
Versteckt wird ausschliesslich der **Eintrag**, nicht die Seite: Lesezeichen,
|
||||
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
|
||||
abgewählt sein können, und sollen weiter funktionieren.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import NavigationSichtbarkeit, User
|
||||
from ..schemas import NavigationOut, NavigationSetzen
|
||||
|
||||
router = APIRouter(prefix="/navigation", tags=["navigation"])
|
||||
|
||||
|
||||
def _liste(zeile: NavigationSichtbarkeit | None) -> list[str]:
|
||||
"""Die gespeicherte Liste – kaputter Inhalt gilt als leer.
|
||||
|
||||
Lieber eine vollständige Leiste als eine Fehlermeldung: Ein unlesbarer
|
||||
Eintrag darf niemanden aussperren.
|
||||
"""
|
||||
if zeile is None:
|
||||
return []
|
||||
try:
|
||||
werte = json.loads(zeile.versteckt)
|
||||
except ValueError:
|
||||
return []
|
||||
return [w for w in werte if isinstance(w, str)] if isinstance(werte, list) else []
|
||||
|
||||
|
||||
def _zeile(db: Session, user_id: int | None) -> NavigationSichtbarkeit | None:
|
||||
return (
|
||||
db.query(NavigationSichtbarkeit)
|
||||
.filter(NavigationSichtbarkeit.user_id.is_(None) if user_id is None
|
||||
else NavigationSichtbarkeit.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _schreiben(db: Session, user_id: int | None, versteckt: list[str]) -> None:
|
||||
"""Die Zeile setzen oder anlegen.
|
||||
|
||||
Doppelte und leere Einträge fallen weg; die Reihenfolge ist bedeutungslos
|
||||
und wird sortiert, damit zwei gleiche Auswahlen auch gleich aussehen.
|
||||
"""
|
||||
sauber = sorted({w.strip() for w in versteckt if w and w.strip()})
|
||||
zeile = _zeile(db, user_id)
|
||||
if zeile is None:
|
||||
zeile = NavigationSichtbarkeit(user_id=user_id, versteckt=json.dumps(sauber))
|
||||
db.add(zeile)
|
||||
else:
|
||||
zeile.versteckt = json.dumps(sauber)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _antwort(db: Session, user: User) -> NavigationOut:
|
||||
vorgabe = _liste(_zeile(db, None))
|
||||
eigen = _liste(_zeile(db, user.id))
|
||||
return NavigationOut(
|
||||
vorgabe=vorgabe, eigen=eigen, versteckt=sorted(set(vorgabe) | set(eigen))
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=NavigationOut)
|
||||
def navigation_lesen(
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> NavigationOut:
|
||||
"""Vorgabe, eigene Auswahl und die fertige Vereinigung."""
|
||||
return _antwort(db, user)
|
||||
|
||||
|
||||
@router.put("", response_model=NavigationOut)
|
||||
def eigene_auswahl_setzen(
|
||||
payload: NavigationSetzen,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> NavigationOut:
|
||||
"""Die eigene Auswahl – betrifft nur die eigene Ansicht."""
|
||||
_schreiben(db, user.id, payload.versteckt)
|
||||
return _antwort(db, user)
|
||||
|
||||
|
||||
@router.put("/vorgabe", response_model=NavigationOut)
|
||||
def vorgabe_setzen(
|
||||
payload: NavigationSetzen,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(require_admin),
|
||||
) -> NavigationOut:
|
||||
"""Die Vorgabe für alle."""
|
||||
_schreiben(db, None, payload.versteckt)
|
||||
return _antwort(db, user)
|
||||
@@ -35,6 +35,7 @@ from ..schemas import (
|
||||
Token,
|
||||
)
|
||||
from ..security import create_access_token
|
||||
from .settings import get_session_minutes
|
||||
from ..services import oidc as dienst
|
||||
from ..services import oidc_state
|
||||
|
||||
@@ -63,8 +64,12 @@ def _provider_oder_404(slug: str) -> OidcProvider:
|
||||
return provider
|
||||
|
||||
|
||||
def _token_fuer(user: User) -> Token:
|
||||
token = create_access_token(user_id=user.id, role=user.role.value)
|
||||
def _token_fuer(user: User, db: Session) -> Token:
|
||||
# Die Sitzungsdauer steht in den Einstellungen und gilt fuer JEDEN Weg
|
||||
# herein - nach Single Sign-on genauso wie nach dem Passwort-Login.
|
||||
token = create_access_token(
|
||||
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
|
||||
)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@@ -209,7 +214,7 @@ def anmeldung_abschliessen(
|
||||
logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
|
||||
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
|
||||
|
||||
token = _token_fuer(nutzer)
|
||||
token = _token_fuer(nutzer, db)
|
||||
logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username)
|
||||
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
|
||||
# an den Server geschickt und landen damit auch nicht in den nginx-Logs.
|
||||
@@ -253,7 +258,7 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler
|
||||
|
||||
logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -394,7 +399,7 @@ def offene_anmeldung_neues_konto(code: str, db: Session = Depends(get_db)) -> To
|
||||
except dienst.OidcFehler as fehler:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from fehler
|
||||
logger.info("SSO '%s': neues Konto '%s' auf Wunsch angelegt.", provider.slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
|
||||
@router.post("/pending/{code}/verknuepfen", response_model=Token)
|
||||
@@ -417,4 +422,4 @@ def offene_anmeldung_verknuepfen(
|
||||
)
|
||||
raise HTTPException(code_http, str(fehler)) from fehler
|
||||
logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username)
|
||||
return _token_fuer(nutzer)
|
||||
return _token_fuer(nutzer, db)
|
||||
|
||||
@@ -23,7 +23,9 @@ from ..models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Group,
|
||||
Item,
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
@@ -55,45 +57,17 @@ from .settings import get_receipt_match_threshold
|
||||
from ..services.conversion import ConversionError, resolve_product_unit
|
||||
from ..services.fields import FieldError, apply_field_values
|
||||
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
|
||||
from ..services.matching import match_score, normalisieren
|
||||
from ..services.min_stock import schreibe_ueberall
|
||||
from ..services.stock import removal_stats
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen.
|
||||
# ---------------------------------------------------------------------------
|
||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def _normalisieren(text: str) -> str:
|
||||
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
|
||||
text = (text or "").lower().translate(_UMLAUTE)
|
||||
return re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
|
||||
|
||||
def _match_score(zeile: str, name: str) -> int:
|
||||
"""Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname.
|
||||
|
||||
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
|
||||
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen.
|
||||
"""
|
||||
a, b = _normalisieren(zeile), _normalisieren(name)
|
||||
if not a or not b:
|
||||
return 0
|
||||
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
|
||||
a_tok, b_tok = a.split(), b.split()
|
||||
bester = []
|
||||
for t in a_tok:
|
||||
m = 0.0
|
||||
for u in b_tok:
|
||||
if u.startswith(t) or t.startswith(u):
|
||||
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
|
||||
else:
|
||||
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
|
||||
bester.append(m)
|
||||
token = sum(bester) / len(bester) if bester else 0.0
|
||||
return round(100 * max(gesamt, token))
|
||||
# Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen.
|
||||
# Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie
|
||||
# genauso braucht.
|
||||
_normalisieren = normalisieren
|
||||
_match_score = match_score
|
||||
|
||||
|
||||
@router.post("/match", response_model=list[MatchLine])
|
||||
@@ -141,7 +115,12 @@ def list_products(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[ProductOut]:
|
||||
"""Artikel auflisten, optional nach Name und Kategorie eingegrenzt.
|
||||
"""Artikel auflisten, optional nach Suchtext und Kategorie eingegrenzt.
|
||||
|
||||
``q`` wird in Wörter zerlegt; jedes muss in Name **oder** Marke vorkommen,
|
||||
die Reihenfolge ist egal. "Vogel Jack and Jones" findet damit dasselbe
|
||||
Shirt wie "Jack and Jones Vogel" – vorher suchte ein einzelnes ILIKE nach
|
||||
dem ganzen Ausdruck und fand gar nichts.
|
||||
|
||||
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
|
||||
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
||||
@@ -157,14 +136,24 @@ def list_products(
|
||||
selectinload(Product.field_values),
|
||||
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
||||
)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
query = query.filter(Product.name.ilike(like))
|
||||
if category_id == 0:
|
||||
query = query.filter(Product.category_id.is_(None))
|
||||
elif category_id is not None:
|
||||
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||
products = query.order_by(Product.name).all()
|
||||
|
||||
# Gleiche Form wie recipes.zuordnung_ziele. Bewusst in Python und nicht in
|
||||
# SQL: normalisieren() loest Umlaute auf ("Mühle" -> "muehle"), ein ILIKE
|
||||
# auf der Spalte faende den gespeicherten Wert damit nicht wieder. Die
|
||||
# Route laedt bei der normalen Artikelliste ohnehin alle Zeilen, der
|
||||
# gefilterte Weg kostet also nie mehr als der ungefilterte.
|
||||
teile = normalisieren(q or "").split()
|
||||
if teile:
|
||||
def passt(text: str) -> bool:
|
||||
ziel = normalisieren(text)
|
||||
return all(t in ziel for t in teile)
|
||||
|
||||
products = [p for p in products if passt(f"{p.name} {p.brand or ''}")]
|
||||
return products_to_out_bulk(db, products)
|
||||
|
||||
|
||||
@@ -680,9 +669,28 @@ def delete_product(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
"""Artikel löschen – nur, solange nichts mehr daran hängt.
|
||||
|
||||
Ohne diese Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder
|
||||
und den gesamten Bewegungsverlauf per Datenbank-Kaskade mit, ohne zu
|
||||
fragen. Bewegungen allein halten aber nicht auf: Sonst waere jeder je
|
||||
benutzte Artikel fuer immer unloeschbar. Geprueft wird der *heutige*
|
||||
Bestand, und der steht in Einzelstuecken und Chargen – auch bei
|
||||
Gegenstaenden, deren Menge je Lagerort ebenfalls als Charge liegt.
|
||||
"""
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
if db.query(Item).filter(Item.product_id == product_id).first() is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Der Artikel hat noch Einzelstücke. Erst entfernen, dann löschen.",
|
||||
)
|
||||
if db.query(Lot).filter(Lot.product_id == product_id).first() is not None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Der Artikel hat noch Bestand. Erst auslagern, dann löschen.",
|
||||
)
|
||||
detach_group_code(db, product)
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Rezepte aus Mealie, gegen den eigenen Bestand geprüft.
|
||||
|
||||
Die Seite beantwortet eine Frage: Kann ich das heute kochen, oder muss ich
|
||||
vorher einkaufen? Das Rechnen steckt in ``services/rezepte.py`` (ohne Netz),
|
||||
das Holen in ``services/mealie.py`` (ohne Rechnen).
|
||||
"""
|
||||
|
||||
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Group, MealieFoodMap, Product, User
|
||||
from ..schemas import (
|
||||
RecipeDetail,
|
||||
RecipePage,
|
||||
RecipeSummary,
|
||||
ShoppingFromRecipe,
|
||||
ZielVorschlag,
|
||||
ZuordnungIn,
|
||||
ZuordnungOut,
|
||||
ZutatKandidat,
|
||||
ZutatOut,
|
||||
)
|
||||
from ..services import einkaufsliste, mealie, rezepte, zuordnung
|
||||
from ..services.conversion import group_min_context
|
||||
from ..services.einkaufsliste_text import fehlmengen_text
|
||||
from .shopping import eintrag_out
|
||||
from ..services.matching import match_score, normalisieren
|
||||
|
||||
router = APIRouter(prefix="/recipes", tags=["recipes"])
|
||||
|
||||
|
||||
def _fehler(fehler: mealie.MealieFehler) -> HTTPException:
|
||||
"""Mealie-Kennung auf einen Statuscode abbilden.
|
||||
|
||||
503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" –
|
||||
die Oberfläche soll beides verschieden erklären können.
|
||||
|
||||
404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/<slug>): Seither
|
||||
lässt sich ein Rezept verlinken, und ein alter Link zeigt irgendwann ins
|
||||
Leere. Das ist kein Gateway-Fehler, sondern eine ganz gewöhnliche Auskunft –
|
||||
und sie soll auch so klingen, nicht nach „Mealie kennt /api/recipes/x nicht".
|
||||
"""
|
||||
if fehler.kennung == "nicht_gefunden":
|
||||
return HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
"Dieses Rezept gibt es in Mealie nicht (mehr).",
|
||||
)
|
||||
code = (
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
if fehler.kennung == "nicht_eingerichtet"
|
||||
else status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
return HTTPException(code, str(fehler))
|
||||
|
||||
|
||||
def _bild(eintrag: dict, basis: str) -> str | None:
|
||||
"""Mealie liefert Bilder unter /api/media/recipes/<id>/images/min-original.webp."""
|
||||
kennung = eintrag.get("id")
|
||||
if not kennung or not basis:
|
||||
return None
|
||||
return f"{basis}/api/media/recipes/{kennung}/images/min-original.webp"
|
||||
|
||||
|
||||
@router.get("", response_model=RecipePage)
|
||||
def list_recipes(
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RecipePage:
|
||||
"""Rezeptliste aus Mealie, blätterbar und durchsuchbar."""
|
||||
try:
|
||||
daten = mealie.rezepte(db, seite=page, pro_seite=per_page, suche=q)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise _fehler(fehler) from fehler
|
||||
basis = mealie.basis_adresse(db)
|
||||
eintraege = [
|
||||
RecipeSummary(
|
||||
slug=str(e.get("slug") or ""),
|
||||
name=str(e.get("name") or e.get("slug") or "Ohne Titel"),
|
||||
description=str(e.get("description") or "")[:300],
|
||||
image_url=_bild(e, basis),
|
||||
servings=e.get("recipeServings") or e.get("recipe_servings") or None,
|
||||
)
|
||||
for e in (daten.get("items") or [])
|
||||
if e.get("slug")
|
||||
]
|
||||
return RecipePage(
|
||||
items=eintraege, page=page, per_page=per_page,
|
||||
total=int(daten.get("total") or len(eintraege)),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zuordnungen von Hand
|
||||
#
|
||||
# ACHTUNG: Diese Routen stehen bewusst VOR ``/{slug}``. FastAPI probiert in
|
||||
# Reihenfolge der Deklaration – stünden sie darunter, würde „zuordnungen" als
|
||||
# Rezept-Slug gelesen und bei Mealie nachgefragt. ``test_zuordnung.py`` wacht
|
||||
# darüber.
|
||||
# --------------------------------------------------------------------------
|
||||
@router.get("/zuordnungen", response_model=list[ZuordnungOut])
|
||||
def list_zuordnungen(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[ZuordnungOut]:
|
||||
"""Alle gemerkten Zuordnungen – zum Nachsehen und Widerrufen."""
|
||||
aus = []
|
||||
for key, (art, ziel_id) in sorted(zuordnung.lade(db).items()):
|
||||
zeile = db.get(MealieFoodMap, key)
|
||||
aus.append(ZuordnungOut(
|
||||
food_key=key,
|
||||
food_name=(zeile.food_name if zeile is not None else key),
|
||||
art=art, ziel_id=ziel_id,
|
||||
ziel_name=zuordnung.ziel_name(db, art, ziel_id),
|
||||
))
|
||||
return aus
|
||||
|
||||
|
||||
@router.get("/zuordnungen/ziele", response_model=list[ZielVorschlag])
|
||||
def zuordnung_ziele(
|
||||
q: str | None = None,
|
||||
limit: int = 40,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[ZielVorschlag]:
|
||||
"""Artikel und Gruppen zur Auswahl, nach Suchtext eingegrenzt.
|
||||
|
||||
Anders als beim Raten wird hier **nicht** auf Lebensmittel eingeschränkt:
|
||||
Die Heuristik soll keine Gegenstände vorschlagen, aber wer von Hand
|
||||
zuordnet, weiss was er will – „Alufolie" steht in manchem Rezept. Nur
|
||||
Einzelstücke fallen weg, die sind per Definition unverbrauchbar.
|
||||
|
||||
Mitgeliefert wird die Basiseinheit, damit man vor dem Zuordnen sieht, ob
|
||||
das Ziel in Gramm oder Milliliter geführt wird.
|
||||
"""
|
||||
teile = normalisieren(q or "").split()
|
||||
|
||||
def passt(text: str) -> bool:
|
||||
ziel = normalisieren(text)
|
||||
return all(t in ziel for t in teile)
|
||||
|
||||
vorschlaege: list[ZielVorschlag] = []
|
||||
for gruppe in db.query(Group).all():
|
||||
if not passt(gruppe.name):
|
||||
continue
|
||||
ctx = group_min_context(gruppe)
|
||||
vorschlaege.append(ZielVorschlag(
|
||||
art="group", id=gruppe.id, name=gruppe.name, zusatz="Gruppe",
|
||||
basis_einheit=(ctx.base_unit.value if ctx.base_unit else ""),
|
||||
))
|
||||
for produkt in db.query(Product).all():
|
||||
if produkt.individual:
|
||||
continue
|
||||
voll = f"{produkt.name} {produkt.brand or ''}"
|
||||
if not passt(voll):
|
||||
continue
|
||||
vorschlaege.append(ZielVorschlag(
|
||||
art="product", id=produkt.id, name=produkt.name,
|
||||
zusatz=(produkt.brand or ""),
|
||||
basis_einheit=produkt.base_unit.value,
|
||||
))
|
||||
|
||||
if teile:
|
||||
vorschlaege.sort(key=lambda v: (-match_score(q or "", f"{v.name} {v.zusatz}"), v.name))
|
||||
else:
|
||||
vorschlaege.sort(key=lambda v: (0 if v.art == "group" else 1, v.name))
|
||||
return vorschlaege[: max(1, min(limit, 200))]
|
||||
|
||||
|
||||
@router.put("/zuordnungen", response_model=ZuordnungOut)
|
||||
def setze_zuordnung(
|
||||
payload: ZuordnungIn,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> ZuordnungOut:
|
||||
"""Eine Zutat von Hand zuordnen – gilt ab dann für jedes Rezept."""
|
||||
try:
|
||||
zeile = zuordnung.setze(db, payload.food, payload.art, payload.ziel_id)
|
||||
except zuordnung.ZuordnungFehler as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
return ZuordnungOut(
|
||||
food_key=zeile.food_key, food_name=zeile.food_name,
|
||||
art=payload.art, ziel_id=payload.ziel_id,
|
||||
ziel_name=zuordnung.ziel_name(db, payload.art, payload.ziel_id),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/zuordnungen", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def loesche_zuordnung(
|
||||
food: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Zuordnung vergessen; danach wird für diese Zutat wieder geraten.
|
||||
|
||||
Der Name reist als Abfrageparameter, nicht im Pfad: Lebensmittelnamen
|
||||
enthalten Schrägstriche („Öl/Fett") und würden den Pfad zerlegen.
|
||||
"""
|
||||
zuordnung.loesche(db, food)
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=RecipeDetail)
|
||||
def get_recipe(
|
||||
slug: str,
|
||||
servings: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> RecipeDetail:
|
||||
"""Ein Rezept mit allen Zutaten, auf ``servings`` Portionen hochgerechnet."""
|
||||
try:
|
||||
daten = mealie.rezept(db, slug)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise _fehler(fehler) from fehler
|
||||
|
||||
faktor, hinweis = rezepte.faktor_fuer_portionen(daten, servings)
|
||||
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
|
||||
pruefungen = rezepte.pruefe_zutaten(
|
||||
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
|
||||
basis_portionen = (
|
||||
daten.get("recipeServings") or daten.get("recipe_servings")
|
||||
or daten.get("recipeYieldQuantity") or daten.get("recipe_yield_quantity") or None
|
||||
)
|
||||
return RecipeDetail(
|
||||
slug=slug,
|
||||
name=str(daten.get("name") or slug),
|
||||
description=str(daten.get("description") or ""),
|
||||
image_url=_bild(daten, mealie.basis_adresse(db)),
|
||||
base_servings=basis_portionen,
|
||||
servings=servings or basis_portionen,
|
||||
skalierung_hinweis=hinweis,
|
||||
kochbar=rezepte.kochbar(pruefungen),
|
||||
ingredients=[_zutat_out(db, p) for p in pruefungen],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/to-shopping-list", response_model=ShoppingFromRecipe)
|
||||
def recipe_to_shopping_list(
|
||||
slug: str,
|
||||
servings: float | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> ShoppingFromRecipe:
|
||||
"""Was für dieses Rezept fehlt, auf die Einkaufsliste setzen.
|
||||
|
||||
Gerechnet wird dabei frisch — mit derselben Portionszahl, die auf dem
|
||||
Bildschirm steht. Sonst landete die Menge für vier Portionen auf der Liste,
|
||||
während die Seite zwölf anzeigt.
|
||||
|
||||
Übernommen wird die **Fehlmenge**, nicht die Rezeptmenge: Wer 250 g braucht
|
||||
und 100 g hat, kauft 150 g. Zutaten ohne Zuordnung kommen als Freitext drauf
|
||||
— gerade die unbekannte Zutat ist die, die man vergisst.
|
||||
"""
|
||||
try:
|
||||
daten = mealie.rezept(db, slug)
|
||||
except mealie.MealieFehler as fehler:
|
||||
raise _fehler(fehler) from fehler
|
||||
|
||||
faktor, _ = rezepte.faktor_fuer_portionen(daten, servings)
|
||||
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
|
||||
pruefungen = rezepte.pruefe_zutaten(
|
||||
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
eintraege, aktualisiert = einkaufsliste.aus_rezept(
|
||||
db, pruefungen, slug=slug, name=str(daten.get("name") or slug), user=user,
|
||||
)
|
||||
return ShoppingFromRecipe(
|
||||
entries=[eintrag_out(db, e) for e in eintraege],
|
||||
aktualisiert=aktualisiert,
|
||||
freitext=sum(1 for e in eintraege if e.free_text),
|
||||
)
|
||||
|
||||
|
||||
def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut:
|
||||
return ZutatOut(
|
||||
text=p.text, menge=p.menge, einheit=p.einheit,
|
||||
food=p.food, manuell=p.manuell,
|
||||
art=p.art, ziel_id=p.ziel_id, ziel_name=p.ziel_name,
|
||||
kandidaten=[
|
||||
ZutatKandidat(art=k.art, id=k.id, name=k.name, score=k.score)
|
||||
for k in p.kandidaten
|
||||
],
|
||||
benoetigt_base=p.benoetigt_base, bestand_base=p.bestand_base,
|
||||
fehlt_base=p.fehlt_base, basis_einheit=p.basis_einheit,
|
||||
status=p.status, hinweis=p.hinweis,
|
||||
fehlendes_mass=p.fehlendes_mass, kategorie_id=p.kategorie_id,
|
||||
fehlt_text=fehlmengen_text(db, p),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
@@ -6,6 +6,7 @@ from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Setting, User
|
||||
from ..schemas import SettingOut
|
||||
from ..services.vererbung import globale_oeffnungsfrist
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
@@ -14,6 +15,16 @@ EXPIRY_WARNING_KEY = "expiry_warning_days"
|
||||
# Treffer vorgeschlagen wird.
|
||||
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
|
||||
RECEIPT_THRESHOLD_DEFAULT = 45
|
||||
# Wie lange eine Anmeldung gilt, in Tagen. 0 = unbegrenzt.
|
||||
SESSION_DAYS_KEY = "session_days"
|
||||
# Zehn Jahre. Wer laenger will, nimmt "unbegrenzt" – ein Token mit einem
|
||||
# Ablauf im naechsten Jahrhundert waere dasselbe, nur unehrlicher.
|
||||
SESSION_DAYS_MAX = 3650
|
||||
# Wie lange eine angebrochene Packung gilt, wenn weder Artikel noch Kategorie
|
||||
# etwas sagen. Pflichtwert: Ohne ihn haette eine offene Packung kein
|
||||
# Verbrauchsdatum, und das Anbrechen waere sinnlos.
|
||||
OPEN_DAYS_KEY = "open_days"
|
||||
OPEN_DAYS_MAX = 3650
|
||||
|
||||
|
||||
def get_expiry_warning_days(db: Session) -> int:
|
||||
@@ -37,6 +48,31 @@ def get_receipt_match_threshold(db: Session) -> int:
|
||||
return RECEIPT_THRESHOLD_DEFAULT
|
||||
|
||||
|
||||
def get_session_days(db: Session) -> int:
|
||||
"""Gueltigkeitsdauer einer Anmeldung in Tagen; **0 heisst unbegrenzt**.
|
||||
|
||||
Ohne Eintrag gilt der Rueckfallwert aus der Umgebung
|
||||
(``JWT_EXPIRE_MINUTES``), damit eine frische Installation nicht erst
|
||||
eingestellt werden muss.
|
||||
"""
|
||||
vorgabe = max(1, round(get_settings().jwt_expire_minutes / (60 * 24)))
|
||||
row = db.get(Setting, SESSION_DAYS_KEY)
|
||||
if row is None:
|
||||
return vorgabe
|
||||
try:
|
||||
return max(0, min(SESSION_DAYS_MAX, int(row.value)))
|
||||
except ValueError:
|
||||
return vorgabe
|
||||
|
||||
|
||||
def get_session_minutes(db: Session) -> int:
|
||||
"""Dasselbe in Minuten, wie :func:`create_access_token` es erwartet.
|
||||
|
||||
Die 0 fuer "unbegrenzt" ueberlebt die Umrechnung unveraendert.
|
||||
"""
|
||||
return get_session_days(db) * 24 * 60
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingOut])
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
@@ -45,6 +81,8 @@ def list_settings(
|
||||
known = {r.key: r.value for r in rows}
|
||||
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
||||
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db)))
|
||||
known.setdefault(SESSION_DAYS_KEY, str(get_session_days(db)))
|
||||
known.setdefault(OPEN_DAYS_KEY, str(globale_oeffnungsfrist(db)))
|
||||
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
||||
|
||||
|
||||
@@ -55,6 +93,28 @@ def set_setting(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> SettingOut:
|
||||
if key == OPEN_DAYS_KEY:
|
||||
# Gleiche Begruendung wie unten: ein Tippfehler darf nicht still auf die
|
||||
# Vorgabe zurueckfallen, waehrend der Admin glaubt, er haette etwas gesetzt.
|
||||
try:
|
||||
tage = int(value)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Haltbarkeit nach dem Anbrechen muss eine Zahl in Tagen sein")
|
||||
if not 1 <= tage <= OPEN_DAYS_MAX:
|
||||
raise HTTPException(400, f"Haltbarkeit nach dem Anbrechen: 1 bis {OPEN_DAYS_MAX} Tage")
|
||||
value = str(tage)
|
||||
|
||||
if key == SESSION_DAYS_KEY:
|
||||
# Ohne diese Pruefung wuerde get_session_days einen Tippfehler still
|
||||
# auf die Vorgabe zurueckdrehen – und der Admin glaubte, es sei gesetzt.
|
||||
try:
|
||||
tage = int(value)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Sitzungsdauer muss eine Zahl in Tagen sein (0 = unbegrenzt)")
|
||||
if not 0 <= tage <= SESSION_DAYS_MAX:
|
||||
raise HTTPException(400, f"Sitzungsdauer: 0 (unbegrenzt) bis {SESSION_DAYS_MAX} Tage")
|
||||
value = str(tage)
|
||||
|
||||
row = db.get(Setting, key)
|
||||
if row is None:
|
||||
row = Setting(key=key, value=value)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Die gespeicherte Seite der Einkaufsliste: eigene Einträge und Haken.
|
||||
|
||||
Die gerechnete Liste steht in ``routers/views.py`` und bleibt dort. Dieser
|
||||
Router verwaltet nur, was jemand selbst draufgesetzt hat — und das Abhaken, das
|
||||
bisher im Browser verfiel und nach dem Neuladen mitten im Supermarkt weg war.
|
||||
"""
|
||||
|
||||
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import ShoppingEntry, User
|
||||
from ..schemas import ShoppingEntryIn, ShoppingEntryOut, ShoppingEntryPatch
|
||||
from ..services import einkaufsliste
|
||||
|
||||
router = APIRouter(prefix="/shopping-list", tags=["shopping"])
|
||||
|
||||
|
||||
def eintrag_out(db: Session, eintrag: ShoppingEntry) -> ShoppingEntryOut:
|
||||
art = (
|
||||
"product" if eintrag.product_id is not None
|
||||
else "group" if eintrag.group_id is not None
|
||||
else "text"
|
||||
)
|
||||
return ShoppingEntryOut(
|
||||
id=eintrag.id, art=art,
|
||||
product_id=eintrag.product_id, group_id=eintrag.group_id,
|
||||
free_text=eintrag.free_text,
|
||||
name=einkaufsliste.bezeichnung(db, eintrag),
|
||||
quantity_base=eintrag.quantity_base, base=eintrag.base,
|
||||
menge_text=einkaufsliste.mengen_text(db, eintrag),
|
||||
source=eintrag.source, recipe_slug=eintrag.recipe_slug,
|
||||
recipe_name=eintrag.recipe_name, done=eintrag.done,
|
||||
created_at=eintrag.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/entries", response_model=list[ShoppingEntryOut])
|
||||
def list_entries(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[ShoppingEntryOut]:
|
||||
"""Die eigenen Einträge, offene zuerst."""
|
||||
return [eintrag_out(db, e) for e in einkaufsliste.eintraege(db)]
|
||||
|
||||
|
||||
@router.post("/entries", response_model=ShoppingEntryOut,
|
||||
status_code=status.HTTP_201_CREATED)
|
||||
def create_entry(
|
||||
payload: ShoppingEntryIn,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> ShoppingEntryOut:
|
||||
"""Etwas auf die Liste setzen – Artikel, Gruppe oder einfach Text."""
|
||||
try:
|
||||
eintrag = einkaufsliste.anlegen(
|
||||
db, product_id=payload.product_id, group_id=payload.group_id,
|
||||
free_text=payload.free_text, quantity_base=payload.quantity_base,
|
||||
base=(payload.base.value if payload.base is not None else None),
|
||||
user=user,
|
||||
)
|
||||
except einkaufsliste.EintragFehler as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
return eintrag_out(db, eintrag)
|
||||
|
||||
|
||||
@router.patch("/entries/{entry_id}", response_model=ShoppingEntryOut)
|
||||
def update_entry(
|
||||
entry_id: int,
|
||||
payload: ShoppingEntryPatch,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> ShoppingEntryOut:
|
||||
"""Abhaken oder die Menge korrigieren."""
|
||||
eintrag = db.get(ShoppingEntry, entry_id)
|
||||
if eintrag is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
if payload.quantity_base is not None:
|
||||
eintrag.quantity_base = payload.quantity_base
|
||||
if payload.free_text is not None:
|
||||
if eintrag.free_text is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Dieser Eintrag zeigt auf einen Artikel – sein Text lässt sich "
|
||||
"nicht ändern.",
|
||||
)
|
||||
eintrag.free_text = payload.free_text.strip()[:120]
|
||||
if payload.done is not None:
|
||||
einkaufsliste.abhaken(db, eintrag, payload.done)
|
||||
else:
|
||||
db.commit()
|
||||
db.refresh(eintrag)
|
||||
return eintrag_out(db, eintrag)
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_entry(
|
||||
entry_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
eintrag = db.get(ShoppingEntry, entry_id)
|
||||
if eintrag is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
|
||||
einkaufsliste.loeschen(db, eintrag)
|
||||
|
||||
|
||||
@router.post("/clear-done", response_model=int)
|
||||
def clear_done(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> int:
|
||||
"""Nach dem Einkauf aufräumen: alles Abgehakte weg.
|
||||
|
||||
Bewusst ein POST und kein DELETE auf einer Sammlung: Es löscht zwei
|
||||
verschiedene Dinge (eigene Einträge und Haken an gerechneten Posten), und
|
||||
das ist eine Handlung, keine Ressource.
|
||||
"""
|
||||
return einkaufsliste.erledigte_entfernen(db)
|
||||
|
||||
|
||||
@router.put("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def set_check(
|
||||
key: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Einen gerechneten Posten abhaken – dauerhaft.
|
||||
|
||||
Der Schlüssel kommt aus der Oberfläche („p12", „g3", „lKUECHEp12"). Der
|
||||
Server prüft ihn nicht gegen die gerechnete Liste: Sie ändert sich mit jedem
|
||||
Einlagern, und ein Haken auf einen Posten, der gerade nicht mehr fehlt,
|
||||
schadet nichts — er verschwindet mit „Erledigte entfernen".
|
||||
"""
|
||||
try:
|
||||
einkaufsliste.haken_setzen(db, key, user)
|
||||
except einkaufsliste.EintragFehler as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
|
||||
|
||||
@router.delete("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def clear_check(
|
||||
key: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
einkaufsliste.haken_loesen(db, key)
|
||||
+182
-23
@@ -8,11 +8,16 @@ from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Pr
|
||||
from ..schemas import (
|
||||
BatchCheckInRequest,
|
||||
BatchCheckInResponse,
|
||||
BulkLotCheckout,
|
||||
BulkLotDelete,
|
||||
BulkLotLocation,
|
||||
CheckInRequest,
|
||||
CheckInResponse,
|
||||
CheckOutRequest,
|
||||
CheckOutResponse,
|
||||
LotConsume,
|
||||
LotConsumeOut,
|
||||
LotOpen,
|
||||
LotOut,
|
||||
LotRow,
|
||||
LotSplit,
|
||||
@@ -25,6 +30,8 @@ from ..services.conversion import ConversionError, display_unit_info
|
||||
from ..services.dates import clean_precision, normalize_best_before
|
||||
from ..services.stock import (
|
||||
StockError,
|
||||
abspalten,
|
||||
anbrechen,
|
||||
check_in,
|
||||
check_out,
|
||||
check_out_lot,
|
||||
@@ -33,6 +40,7 @@ from ..services.stock import (
|
||||
object_add,
|
||||
object_relocate,
|
||||
object_remove,
|
||||
verbrauchen,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["stock"])
|
||||
@@ -272,6 +280,11 @@ def list_all_lots(
|
||||
unit_name=name, unit_factor=factor,
|
||||
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
||||
location_id=lot.location_id, created_at=lot.created_at,
|
||||
# Angebrochen: ohne diese drei Felder saehe die Liste eine offene
|
||||
# Packung wie jede andere Charge - samt falscher Ablaufwarnung,
|
||||
# denn gewarnt wird nach dem fruheren der beiden Daten.
|
||||
opened_on=lot.opened_on, consume_by=lot.consume_by,
|
||||
opened_quantity=lot.opened_quantity,
|
||||
))
|
||||
return rows
|
||||
|
||||
@@ -307,6 +320,91 @@ def bulk_lot_location(
|
||||
return n
|
||||
|
||||
|
||||
@router.post("/lots/bulk-checkout", response_model=int)
|
||||
def bulk_lot_checkout(
|
||||
payload: BulkLotCheckout,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> int:
|
||||
"""Mehrere Chargen ausbuchen – komplett oder je Charge dieselbe Teilmenge.
|
||||
|
||||
Anders als ``bulk-location`` **kein** Sammel-UPDATE: Je Charge muss eine
|
||||
Bewegung entstehen, sonst fehlte der Vorgang im Verlauf und in der
|
||||
Verbrauchsstatistik. Schlägt eine Charge fehl (zu wenig Bestand), wird
|
||||
alles zurückgerollt – ein halb gebuchter Stand wäre schlimmer als gar
|
||||
keiner; dieselbe Haltung wie beim Stapel-Einlagern.
|
||||
|
||||
Gibt die Anzahl der betroffenen Chargen zurück.
|
||||
"""
|
||||
if not payload.lot_ids:
|
||||
return 0
|
||||
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
|
||||
if not lots:
|
||||
return 0
|
||||
|
||||
if payload.quantity is not None:
|
||||
# Eine gemeinsame Menge setzt eine gemeinsame Einheit voraus: „1 Packung"
|
||||
# bei Milch und Broetchen sind verschiedene Dinge.
|
||||
artikel = {lot.product_id for lot in lots}
|
||||
if len(artikel) > 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Eine gemeinsame Menge geht nur, wenn alle ausgewählten Chargen "
|
||||
"zum selben Artikel gehören. Sonst bitte komplett ausbuchen.",
|
||||
)
|
||||
|
||||
betroffen = 0
|
||||
try:
|
||||
for lot in lots:
|
||||
product = db.get(Product, lot.product_id)
|
||||
if product is None:
|
||||
continue
|
||||
menge = payload.quantity if payload.quantity is not None else lot.quantity
|
||||
einheit = payload.unit if payload.quantity is not None else product.base_unit.value
|
||||
check_out_lot(db, product, lot, menge, einheit, user, payload.note)
|
||||
betroffen += 1
|
||||
except ConversionError as fehler:
|
||||
db.rollback()
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
except StockError as fehler:
|
||||
db.rollback()
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
|
||||
db.commit()
|
||||
return betroffen
|
||||
|
||||
|
||||
@router.post("/lots/bulk-delete", response_model=int)
|
||||
def bulk_lot_delete(
|
||||
payload: BulkLotDelete,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> int:
|
||||
"""Mehrere Chargen als Korrektur entfernen – nicht als Verbrauch.
|
||||
|
||||
Je Charge eine Korrektur-Bewegung wie beim einzelnen Löschen, damit der
|
||||
Bestandsverlauf stimmt. Unbekannte IDs werden still übergangen, wie bei
|
||||
``bulk-location``.
|
||||
"""
|
||||
if not payload.lot_ids:
|
||||
return 0
|
||||
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
|
||||
for lot in lots:
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=lot.product_id,
|
||||
lot_id=None,
|
||||
user_id=user.id,
|
||||
type=MovementType.adjust,
|
||||
quantity=-lot.quantity,
|
||||
unit_used="base",
|
||||
note="Charge gelöscht",
|
||||
)
|
||||
)
|
||||
db.delete(lot)
|
||||
db.commit()
|
||||
return len(lots)
|
||||
|
||||
|
||||
@router.post("/lots/{lot_id}/split", response_model=LotOut)
|
||||
def split_lot(
|
||||
lot_id: int,
|
||||
@@ -321,32 +419,16 @@ def split_lot(
|
||||
lot = db.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
||||
# Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge
|
||||
# zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens.
|
||||
if payload.quantity >= lot.quantity - 1e-9:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"So viel enthält die Charge nicht – zum kompletten Umlagern das "
|
||||
"Lagerort-Feld nutzen.",
|
||||
)
|
||||
if (payload.location_id or None) == (lot.location_id or None):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Bitte einen anderen Lagerort als den aktuellen wählen.",
|
||||
)
|
||||
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||
|
||||
lot.quantity -= payload.quantity
|
||||
neu = Lot(
|
||||
product_id=lot.product_id,
|
||||
quantity=payload.quantity,
|
||||
best_before=lot.best_before,
|
||||
best_before_precision=lot.best_before_precision,
|
||||
location_id=payload.location_id,
|
||||
)
|
||||
db.add(neu)
|
||||
db.flush()
|
||||
# Die eigentliche Arbeit macht der Dienst; hier gilt weiter die strengere
|
||||
# Variante: nur echt weniger als die Gesamtmenge, und nur an einen ANDEREN
|
||||
# Ort. Das Anbrechen benutzt dieselbe Funktion mit gelockerten Regeln.
|
||||
try:
|
||||
neu = abspalten(db, lot, payload.quantity, payload.location_id)
|
||||
except StockError as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
|
||||
survivor = consolidate_lot_group(
|
||||
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
|
||||
@@ -356,6 +438,83 @@ def split_lot(
|
||||
return survivor
|
||||
|
||||
|
||||
@router.post("/lots/{lot_id}/open", response_model=LotOut)
|
||||
def open_lot(
|
||||
lot_id: int,
|
||||
payload: LotOpen,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> Lot:
|
||||
"""Eine Packung der Charge anbrechen und als eigene Charge führen.
|
||||
|
||||
Aus 4 × 1 l wird eine offene Charge mit 1 l (mit Verbrauchsdatum) und eine
|
||||
ungeöffnete mit 3 l. Bewusst **ohne** anschließende Konsolidierung: Die
|
||||
offene Packung soll gerade nicht mit der ungeöffneten verschmelzen.
|
||||
"""
|
||||
lot = db.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
||||
product = db.get(Product, lot.product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||
if product_tracking(db, product) == OBJECT:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Nur Chargen lassen sich anbrechen. Gegenstände, die nur als Menge "
|
||||
"je Lagerort geführt werden, haben keine Packung.",
|
||||
)
|
||||
try:
|
||||
offen = anbrechen(db, product, lot, payload.quantity)
|
||||
except StockError as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
db.commit()
|
||||
db.refresh(offen)
|
||||
return offen
|
||||
|
||||
|
||||
@router.post("/lots/{lot_id}/consume", response_model=LotConsumeOut)
|
||||
def consume_lot(
|
||||
lot_id: int,
|
||||
payload: LotConsume,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> LotConsumeOut:
|
||||
"""Teilmenge aus einer Charge verbrauchen („100 ml verwendet", „2 EL").
|
||||
|
||||
War die Charge noch zu, wird sie dabei angebrochen (``auto_open``) – die
|
||||
Rückfrage dazu stellt die Oberfläche. Die Einheit kennt neben den
|
||||
verwalteten Einheiten auch die Hausmaße der Kategorie.
|
||||
"""
|
||||
lot = db.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
||||
product = db.get(Product, lot.product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
|
||||
if product_tracking(db, product) == OBJECT:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Gegenstände werden über 'Entfernen' (mit Grund) gebucht, nicht verbraucht.",
|
||||
)
|
||||
try:
|
||||
affected, offen, geoeffnet = verbrauchen(
|
||||
db, product, lot, payload.quantity, payload.unit, user,
|
||||
payload.note, payload.auto_open,
|
||||
)
|
||||
except ConversionError as fehler:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||
except StockError as fehler:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
|
||||
db.commit()
|
||||
if offen is not None:
|
||||
db.refresh(offen)
|
||||
return LotConsumeOut(
|
||||
affected_lots=affected,
|
||||
lot=LotOut.model_validate(offen) if offen is not None else None,
|
||||
opened=geoeffnet,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/lots/{lot_id}", response_model=LotOut)
|
||||
def update_lot(
|
||||
lot_id: int,
|
||||
|
||||
@@ -6,6 +6,7 @@ werden ergänzt – es wird nie etwas gelöscht oder überschrieben.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -22,11 +23,13 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before
|
||||
from ..services import gruppen as gruppen_graph
|
||||
from ..services.group_codes import sync as sync_group_code
|
||||
from ..services.min_stock import lies_ueberall, schreibe_ueberall
|
||||
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen, konfiguration_schreiben
|
||||
from ..models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
DatePrecision,
|
||||
EtikettenVorlage,
|
||||
FieldDefinition,
|
||||
Group,
|
||||
GroupLocationMinStock,
|
||||
@@ -166,10 +169,11 @@ def export_backup_json(
|
||||
loc_name = {loc.id: loc.name for loc in locations}
|
||||
|
||||
data = {
|
||||
# v4: Etiketten-Vorlagen samt hinterlegter P-touch-Datei.
|
||||
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
|
||||
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
|
||||
# (Ort null = „Ueberall") in Basiseinheiten. Aeltere Sicherungen bleiben
|
||||
# lesbar – ihnen fehlen diese Listen einfach.
|
||||
"version": 3,
|
||||
"version": 4,
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
||||
"units": [
|
||||
@@ -196,7 +200,11 @@ def export_backup_json(
|
||||
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
|
||||
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
|
||||
"categories": [
|
||||
{"path": _category_path(db, c), "tracking": c.tracking}
|
||||
{
|
||||
"path": _category_path(db, c),
|
||||
"tracking": c.tracking,
|
||||
"usage_tracking": c.usage_tracking,
|
||||
}
|
||||
for c in db.query(Category).order_by(Category.id).all()
|
||||
],
|
||||
# Bezugsquellen (nur für Gegenstände).
|
||||
@@ -256,12 +264,19 @@ def export_backup_json(
|
||||
"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),
|
||||
# Angebrochene Packungen sollen eine Sicherung ueberleben –
|
||||
# sonst waere nach dem Zurueckspielen alles wieder zu.
|
||||
"opened_on": lot.opened_on.isoformat() if lot.opened_on else None,
|
||||
"consume_by": lot.consume_by.isoformat() if lot.consume_by else None,
|
||||
"opened_quantity": lot.opened_quantity,
|
||||
}
|
||||
for lot in lots
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
data["etiketten_vorlagen"] = _vorlagen_exportieren(db)
|
||||
|
||||
return Response(
|
||||
content=json.dumps(data, ensure_ascii=False, indent=2),
|
||||
media_type="application/json; charset=utf-8",
|
||||
@@ -340,6 +355,27 @@ def export_labels(
|
||||
# --------------------------------------------------------------------------
|
||||
# Import (nur additiv)
|
||||
# --------------------------------------------------------------------------
|
||||
def _parse_tag(value) -> "date | None":
|
||||
"""ISO-Datum aus einer Sicherung lesen; Muell wird still zu None.
|
||||
|
||||
Der Import ist bewusst additiv und soll nicht an einer einzelnen Zeile
|
||||
scheitern – dieselbe Haltung wie bei ``_parse_best_before``.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value)[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _zahl_oder_none(value) -> float | None:
|
||||
try:
|
||||
return float(value) if value is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _num(value) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -435,6 +471,146 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None:
|
||||
return loc
|
||||
|
||||
|
||||
def _vorlagen_exportieren(db: Session) -> list[dict]:
|
||||
"""Etiketten-Vorlagen fuer die Sicherung.
|
||||
|
||||
Kategorien als PFAD und eigene Felder als LABEL – IDs sind zwischen zwei
|
||||
Instanzen nicht dieselben, genau wie bei Gruppen und Artikeln.
|
||||
|
||||
Die hinterlegte .lbx kommt als Base64 mit. Das ist das erste Binaerformat in
|
||||
dieser Sicherung; vertretbar, weil eine .lbx wenige Kilobyte gross ist und
|
||||
eine Vorlage ohne ihre Datei nur eine halbe Vorlage waere.
|
||||
"""
|
||||
zeilen: list[dict] = []
|
||||
for v in db.query(EtikettenVorlage).order_by(EtikettenVorlage.position).all():
|
||||
konf = konfiguration_lesen(v)
|
||||
spalten: list[str] = []
|
||||
for schluessel in konf["spalten"]:
|
||||
if schluessel.startswith(FELD_PRAEFIX):
|
||||
rest = schluessel[len(FELD_PRAEFIX):]
|
||||
fd = db.get(FieldDefinition, int(rest)) if rest.isdigit() else None
|
||||
# Kategoriepfad mit dazu: Zwei Kategorien duerfen dasselbe Label
|
||||
# fuehren, erst beides zusammen ist eindeutig.
|
||||
spalten.append(
|
||||
f"{FELD_PRAEFIX}{_category_path(db, db.get(Category, fd.category_id))}"
|
||||
f"::{fd.label}" if fd else schluessel
|
||||
)
|
||||
else:
|
||||
spalten.append(schluessel)
|
||||
# Der Wertefilter zeigt ebenfalls auf Feld-IDs und braucht dieselbe
|
||||
# Umschreibung wie die Spalten - sonst zeigt er nach dem Zurueckspielen
|
||||
# auf ein fremdes Feld oder ist still weg.
|
||||
feldfilter = []
|
||||
for bedingung in konf["feldfilter"]:
|
||||
fd = db.get(FieldDefinition, bedingung["feld_id"])
|
||||
if fd is None:
|
||||
continue
|
||||
pfad = _category_path(db, db.get(Category, fd.category_id))
|
||||
feldfilter.append({
|
||||
"feld": f"{pfad}::{fd.label}",
|
||||
"op": bedingung.get("op", "="),
|
||||
"wert": bedingung["wert"],
|
||||
})
|
||||
|
||||
zeilen.append({
|
||||
"name": v.name,
|
||||
"art": v.art,
|
||||
"position": v.position,
|
||||
"spalten": spalten,
|
||||
"kategorien": [
|
||||
_category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"]
|
||||
],
|
||||
"trennzeichen": konf["trennzeichen"],
|
||||
"feldfilter": feldfilter,
|
||||
"datei_name": v.datei_name,
|
||||
"datei_base64": base64.b64encode(v.datei).decode() if v.datei else None,
|
||||
})
|
||||
return zeilen
|
||||
|
||||
|
||||
def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
|
||||
"""Vorlagen additiv einspielen – Bestehendes bleibt unberuehrt."""
|
||||
angelegt = 0
|
||||
pfad_zu_id: dict[str, int] = {}
|
||||
for cat in db.query(Category).all():
|
||||
pfad_zu_id[_category_path(db, cat)] = cat.id
|
||||
|
||||
for entry in zeilen:
|
||||
try:
|
||||
name = (entry.get("name") or "").strip()
|
||||
if not name or db.query(EtikettenVorlage).filter(
|
||||
EtikettenVorlage.name == name
|
||||
).first():
|
||||
continue
|
||||
|
||||
spalten: list[str] = []
|
||||
for roh in entry.get("spalten", []):
|
||||
if not str(roh).startswith(FELD_PRAEFIX):
|
||||
spalten.append(str(roh))
|
||||
continue
|
||||
rest = str(roh)[len(FELD_PRAEFIX):]
|
||||
pfad, _, label = rest.rpartition("::")
|
||||
kat_id = pfad_zu_id.get(pfad)
|
||||
fd = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(
|
||||
FieldDefinition.category_id == kat_id,
|
||||
FieldDefinition.label == label,
|
||||
).first()
|
||||
if kat_id else None
|
||||
)
|
||||
# Gibt es das Feld hier nicht, faellt die Spalte weg statt die
|
||||
# ganze Vorlage scheitern zu lassen.
|
||||
if fd is not None:
|
||||
spalten.append(f"{FELD_PRAEFIX}{fd.id}")
|
||||
|
||||
feldfilter: list[dict] = []
|
||||
for bedingung in entry.get("feldfilter", []):
|
||||
if not isinstance(bedingung, dict):
|
||||
continue
|
||||
pfad, _, label = str(bedingung.get("feld", "")).rpartition("::")
|
||||
kat_id = pfad_zu_id.get(pfad)
|
||||
fd = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(
|
||||
FieldDefinition.category_id == kat_id,
|
||||
FieldDefinition.label == label,
|
||||
).first()
|
||||
if kat_id else None
|
||||
)
|
||||
# Wie bei den Spalten: fehlt das Feld hier, faellt die Bedingung
|
||||
# weg statt die ganze Vorlage scheitern zu lassen.
|
||||
if fd is not None:
|
||||
feldfilter.append({
|
||||
"feld_id": fd.id,
|
||||
"op": bedingung.get("op", "="),
|
||||
"wert": bedingung.get("wert", ""),
|
||||
})
|
||||
|
||||
kategorie_ids = [
|
||||
pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id
|
||||
]
|
||||
roh_datei = entry.get("datei_base64")
|
||||
db.add(EtikettenVorlage(
|
||||
name=name,
|
||||
art=entry.get("art") or "items",
|
||||
position=int(entry.get("position") or 0),
|
||||
konfiguration=konfiguration_schreiben(
|
||||
spalten,
|
||||
kategorie_ids,
|
||||
entry.get("trennzeichen") or ",",
|
||||
feldfilter,
|
||||
),
|
||||
datei_name=entry.get("datei_name"),
|
||||
datei=base64.b64decode(roh_datei) if roh_datei else None,
|
||||
))
|
||||
angelegt += 1
|
||||
except Exception as exc:
|
||||
errors.append(f"Etiketten-Vorlage {entry.get('name')!r}: {exc}")
|
||||
db.commit()
|
||||
return angelegt
|
||||
|
||||
|
||||
def _category_path(db: Session, category: Category | None) -> str:
|
||||
"""Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte.
|
||||
|
||||
@@ -791,6 +967,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
)
|
||||
if cat is not None and entry.get("tracking"):
|
||||
cat.tracking = entry["tracking"]
|
||||
# Fehlt der Schluessel (aelteres Backup), bleibt die Markierung aus.
|
||||
if cat is not None:
|
||||
cat.usage_tracking = bool(entry.get("usage_tracking", False))
|
||||
for entry in data.get("shops", []):
|
||||
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
|
||||
db.flush()
|
||||
@@ -883,6 +1062,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
best_before=best_before,
|
||||
best_before_precision=precision,
|
||||
location_id=location.id if location else None,
|
||||
opened_on=_parse_tag(lot_entry.get("opened_on")),
|
||||
consume_by=_parse_tag(lot_entry.get("consume_by")),
|
||||
opened_quantity=_zahl_oder_none(lot_entry.get("opened_quantity")),
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush()
|
||||
@@ -901,9 +1083,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
||||
except Exception as exc:
|
||||
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
|
||||
|
||||
vorlagen_created = _vorlagen_importieren(
|
||||
db, data.get("etiketten_vorlagen", []), errors
|
||||
)
|
||||
|
||||
return {
|
||||
"format": "json",
|
||||
"mode": mode,
|
||||
"etiketten_vorlagen_created": vorlagen_created,
|
||||
"units_created": units_created,
|
||||
"products_created": len(created_products),
|
||||
"products_cleared": len(cleared),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Verwendungsstatus von Einzelstücken: umstellen und Verlauf.
|
||||
|
||||
Woran ist das Stück gerade? Getragen, in der Wäsche, in Reparatur – oder
|
||||
einfach verfügbar. Gedacht für eine Garderobe, aber bewusst allgemein: ein Auto
|
||||
in der Waschanlage ist genauso „in Reinigung".
|
||||
|
||||
Umstellen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
|
||||
angemeldete Benutzer es tun (wie Verleihen, Ein- und Auslagern), und es wird mit
|
||||
dem Konto protokolliert. Ausdrücklich **nicht** über ``PATCH /items/{id}``:
|
||||
Diese Route ist Admins vorbehalten, und die App schickt dort immer alle Felder
|
||||
mitsamt ``null`` (siehe ``ItemUpdateRequest.encode`` in ios/Sources/Models.swift)
|
||||
– jedes beliebige Speichern würde den Status stillschweigend zurücksetzen.
|
||||
Verleih und Zubehör haben aus demselben Grund eigene Endpunkte.
|
||||
|
||||
Bewusst **keine** Bewegung (``Movement``), aus demselben Grund wie beim Verleih
|
||||
(siehe ``routers/loans.py``). Und bewusst **keine** Auswirkung auf den Bestand:
|
||||
Ein getragenes Hemd ist weiterhin im Haushalt – anders als ein verliehenes.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Item, ItemUsage, User
|
||||
from ..schemas import ItemOut, VerwendungOut, VerwendungSetzen
|
||||
from ..services.items import item_to_out, verwendung_to_out
|
||||
|
||||
router = APIRouter(tags=["verwendung"])
|
||||
|
||||
|
||||
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||
item = db.get(Item, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
|
||||
return item
|
||||
|
||||
|
||||
def _laufend(db: Session, item_id: int) -> ItemUsage | None:
|
||||
"""Der laufende Zeitraum dieses Stücks, falls einer offen ist."""
|
||||
return (
|
||||
db.query(ItemUsage)
|
||||
.filter(ItemUsage.item_id == item_id, ItemUsage.ended_at.is_(None))
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/items/{item_id}/verwendung", response_model=ItemOut)
|
||||
def setze_verwendung(
|
||||
item_id: int,
|
||||
payload: VerwendungSetzen,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> ItemOut:
|
||||
"""Verwendungszustand setzen. Leerer Status = verfügbar.
|
||||
|
||||
Eine Route für alle Zustände, auch für den Ruhezustand: „verfügbar" ist
|
||||
nichts anderes als „kein laufender Zeitraum", also beendet der leere Status
|
||||
nur den aktuellen. Damit ist der Aufruf idempotent.
|
||||
|
||||
Antwortet mit dem ganzen Stück, damit die Oberflächen nach der Buchung nicht
|
||||
noch einmal laden müssen.
|
||||
"""
|
||||
item = _item_or_404(db, item_id)
|
||||
neu = payload.status.value if payload.status is not None else None
|
||||
laufend = _laufend(db, item.id)
|
||||
|
||||
if laufend is not None and laufend.status == neu:
|
||||
# Zweimal dasselbe angetippt – sonst entstünde ein Zeitraum der Länge 0.
|
||||
return item_to_out(item)
|
||||
|
||||
if laufend is not None:
|
||||
laufend.ended_at = datetime.now(UTC)
|
||||
# Vor dem Einfügen schreiben: Sonst darf SQLAlchemy das INSERT vor das
|
||||
# UPDATE stellen, und dann liegen kurzzeitig zwei offene Zeiträume an –
|
||||
# der Teilindex uq_item_usage_offen schlägt zu.
|
||||
db.flush()
|
||||
|
||||
if neu is not None:
|
||||
db.add(ItemUsage(item_id=item.id, status=neu, user_id=user.id))
|
||||
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item_to_out(item)
|
||||
|
||||
|
||||
@router.get("/items/{item_id}/verwendungen", response_model=list[VerwendungOut])
|
||||
def list_verwendungen(
|
||||
item_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[VerwendungOut]:
|
||||
"""Der ganze Verlauf dieses Stücks, neueste zuerst."""
|
||||
_item_or_404(db, item_id)
|
||||
rows = (
|
||||
db.query(ItemUsage)
|
||||
.options(joinedload(ItemUsage.user))
|
||||
.filter(ItemUsage.item_id == item_id)
|
||||
.order_by(ItemUsage.started_at.desc(), ItemUsage.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [verwendung_to_out(u) for u in rows]
|
||||
@@ -5,6 +5,7 @@ from datetime import date, timedelta
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -41,7 +42,9 @@ from ..services.conversion import (
|
||||
display_unit_info,
|
||||
group_min_context,
|
||||
)
|
||||
from ..services import einkaufsliste
|
||||
from ..services import gruppen as gruppen_graph
|
||||
from .shopping import eintrag_out
|
||||
from ..services.stock import (
|
||||
current_stock,
|
||||
descendant_location_ids,
|
||||
@@ -194,6 +197,14 @@ def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
|
||||
def shopping_list(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[ShoppingItem]:
|
||||
"""Artikel unter ihrem „Ueberall"-Mindestbestand – NUR die gerechneten.
|
||||
|
||||
Diese Route ist ein Drittel der Einkaufsliste. Was jemand selbst
|
||||
draufgesetzt hat (eigene Eintraege, uebernommene Rezeptzutaten) steht hier
|
||||
nicht, ebenso wenig Gruppen und Bedarfe je Lagerort. Wer „die Liste" will,
|
||||
nimmt ``/shopping-list/all`` – diese Aufteilung hat schon zweimal jemanden
|
||||
glauben lassen, es sei nichts da.
|
||||
"""
|
||||
return einkaufsliste_artikel(db)
|
||||
|
||||
|
||||
@@ -468,13 +479,20 @@ def shopping_list_by_location(
|
||||
def shopping_list_all(
|
||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> ShoppingListAll:
|
||||
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
|
||||
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
|
||||
Erspart drei getrennte Abfragen."""
|
||||
"""Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe
|
||||
(Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die
|
||||
dauerhaft gesetzten Haken. Erspart der Oberfläche fünf Abfragen.
|
||||
|
||||
Gerechnet und gespeichert stehen bewusst nebeneinander: Die Bedarfe aus den
|
||||
Mindestbeständen entstehen bei jedem Aufruf neu, die eigenen Einträge
|
||||
bleiben liegen, bis sie abgehakt sind.
|
||||
"""
|
||||
return ShoppingListAll(
|
||||
products=shopping_list(db, user),
|
||||
groups=group_shopping_list(db, user),
|
||||
by_location=shopping_list_by_location(db, user),
|
||||
entries=[eintrag_out(db, e) for e in einkaufsliste.eintraege(db)],
|
||||
checked=einkaufsliste.abgehakte(db),
|
||||
)
|
||||
|
||||
|
||||
@@ -531,14 +549,26 @@ def expiring(
|
||||
today = date.today()
|
||||
threshold = today + timedelta(days=days)
|
||||
|
||||
# Gewarnt wird nach dem FRÜHEREN von MHD und Verbrauchsdatum. Die frühere
|
||||
# Vorbedingung „best_before IS NOT NULL" musste weg: Eine angebrochene
|
||||
# Packung ohne aufgedrucktes MHD hat nur ein Verbrauchsdatum – und genau um
|
||||
# die geht es hier.
|
||||
lots = (
|
||||
db.query(Lot)
|
||||
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
|
||||
.order_by(Lot.best_before)
|
||||
.filter(
|
||||
Lot.quantity > 0,
|
||||
or_(
|
||||
and_(Lot.best_before.isnot(None), Lot.best_before <= threshold),
|
||||
and_(Lot.consume_by.isnot(None), Lot.consume_by <= threshold),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
result: list[ExpiringItem] = []
|
||||
for lot in lots:
|
||||
faellig = lot.faellig_am
|
||||
if faellig is None:
|
||||
continue # kann nicht vorkommen, der Filter oben schliesst es aus
|
||||
product = lot.product
|
||||
unit_name, unit_factor = display_unit_info(product)
|
||||
result.append(
|
||||
@@ -550,13 +580,23 @@ def expiring(
|
||||
base_unit=product.base_unit,
|
||||
best_before=lot.best_before,
|
||||
best_before_precision=lot.best_before_precision,
|
||||
days_left=(lot.best_before - today).days,
|
||||
opened_on=lot.opened_on,
|
||||
consume_by=lot.consume_by,
|
||||
warn_date=faellig,
|
||||
warn_source=(
|
||||
"consume_by" if lot.consume_by is not None and faellig == lot.consume_by
|
||||
else "best_before"
|
||||
),
|
||||
days_left=(faellig - today).days,
|
||||
package_size=product.package_size,
|
||||
package_label=product.package_label,
|
||||
unit_name=unit_name,
|
||||
unit_factor=unit_factor,
|
||||
)
|
||||
)
|
||||
# Nach dem wirksamen Datum sortieren – in Python, wie in _fefo_lots, damit
|
||||
# SQLite und Postgres dasselbe liefern.
|
||||
result.sort(key=lambda e: (e.warn_date, e.lot_id))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+463
-3
@@ -12,6 +12,7 @@ from .models import (
|
||||
RemovalReason,
|
||||
Role,
|
||||
UnitKind,
|
||||
UsageStatus,
|
||||
)
|
||||
|
||||
|
||||
@@ -275,6 +276,16 @@ class CategoryOut(BaseModel):
|
||||
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
||||
tracking: CategoryTracking = CategoryTracking.food
|
||||
product_count: int = 0
|
||||
# Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der
|
||||
# Oberkategorie, zuletzt vom globalen Wert.
|
||||
open_days: int | None = None
|
||||
# Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft
|
||||
# („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der
|
||||
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
|
||||
inherited_open_days: int = 0
|
||||
open_days_source: str = ""
|
||||
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
|
||||
usage_tracking: bool = False
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
@@ -282,12 +293,21 @@ class CategoryCreate(BaseModel):
|
||||
parent_id: int | None = None
|
||||
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
||||
tracking: CategoryTracking | None = None
|
||||
open_days: int | None = Field(default=None, ge=1)
|
||||
# None: erbt vom Elternteil bzw. neue Oberkategorie = aus.
|
||||
usage_tracking: bool | None = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
parent_id: int | None = None
|
||||
tracking: CategoryTracking | None = None
|
||||
# 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None
|
||||
# heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst.
|
||||
open_days: int | None = Field(default=None, ge=1)
|
||||
# Wird beim Speichern in den ganzen Unterbaum uebernommen, siehe
|
||||
# routers/categories.py::update_category.
|
||||
usage_tracking: bool | None = None
|
||||
|
||||
|
||||
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||||
@@ -403,6 +423,9 @@ class ProductBase(BaseModel):
|
||||
secondary_base: BaseUnit | None = None
|
||||
secondary_count: float | None = Field(default=None, gt=0)
|
||||
secondary_amount: float | None = Field(default=None, gt=0)
|
||||
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
|
||||
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
|
||||
open_days: int | None = Field(default=None, ge=1)
|
||||
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||||
date_precision: DatePrecision = DatePrecision.day
|
||||
group_id: int | None = None
|
||||
@@ -442,6 +465,9 @@ class ProductUpdate(BaseModel):
|
||||
secondary_base: BaseUnit | None = None
|
||||
secondary_count: float | None = Field(default=None, gt=0)
|
||||
secondary_amount: float | None = Field(default=None, gt=0)
|
||||
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
|
||||
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
|
||||
open_days: int | None = Field(default=None, ge=1)
|
||||
date_precision: DatePrecision | None = None
|
||||
group_id: int | None = None
|
||||
category_id: int | None = None
|
||||
@@ -470,6 +496,12 @@ class ProductOut(BaseModel):
|
||||
secondary_base: BaseUnit | None = None
|
||||
secondary_count: float | None = None
|
||||
secondary_amount: float | None = None
|
||||
open_days: int | None = None
|
||||
# Die tatsaechlich geltende Frist und woher sie kommt – damit das
|
||||
# Formular „geerbt: 3 Tage (Kategorie: Molkerei)" zeigen kann, ohne
|
||||
# die Vererbung selbst nachzubauen.
|
||||
effective_open_days: int | None = None
|
||||
open_days_source: str = ""
|
||||
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
|
||||
# damit die Oberflächen nicht selbst dividieren müssen.
|
||||
secondary_factor: float | None = None
|
||||
@@ -574,6 +606,11 @@ class LotOut(BaseModel):
|
||||
best_before_precision: DatePrecision = DatePrecision.day
|
||||
location_id: str | None
|
||||
created_at: datetime
|
||||
# Angebrochen: NULL = zu. MHD und Verbrauchsdatum stehen bewusst
|
||||
# nebeneinander – gewarnt wird nach dem fruheren (Lot.faellig_am).
|
||||
opened_on: date | None = None
|
||||
consume_by: date | None = None
|
||||
opened_quantity: float | None = None
|
||||
|
||||
|
||||
class LotUpdate(BaseModel):
|
||||
@@ -601,6 +638,9 @@ class LotRow(BaseModel):
|
||||
best_before_precision: DatePrecision = DatePrecision.day
|
||||
location_id: str | None = None
|
||||
created_at: datetime
|
||||
opened_on: date | None = None
|
||||
consume_by: date | None = None
|
||||
opened_quantity: float | None = None
|
||||
|
||||
|
||||
class BulkLotLocation(BaseModel):
|
||||
@@ -609,6 +649,25 @@ class BulkLotLocation(BaseModel):
|
||||
location_id: str | None = None
|
||||
|
||||
|
||||
class BulkLotCheckout(BaseModel):
|
||||
"""Mehrere Chargen auf einmal ausbuchen.
|
||||
|
||||
``quantity`` leer = jede Charge komplett. Mit Menge wird je Charge
|
||||
dieselbe Menge abgebucht – das ergibt nur Sinn, wenn alle zum selben
|
||||
Artikel gehören, sonst wäre die Einheit mehrdeutig (der Server lehnt es
|
||||
dann ab).
|
||||
"""
|
||||
lot_ids: list[int]
|
||||
quantity: float | None = Field(default=None, gt=0)
|
||||
unit: str | None = None
|
||||
note: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class BulkLotDelete(BaseModel):
|
||||
"""Mehrere Chargen als Korrektur entfernen (ohne sie als Verbrauch zu buchen)."""
|
||||
lot_ids: list[int]
|
||||
|
||||
|
||||
class LotSplit(BaseModel):
|
||||
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
||||
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
|
||||
@@ -616,6 +675,195 @@ class LotSplit(BaseModel):
|
||||
location_id: str | None = None
|
||||
|
||||
|
||||
class LotOpen(BaseModel):
|
||||
"""Eine Packung der Charge anbrechen.
|
||||
|
||||
``quantity`` in Basiseinheiten; leer = eine Artikeleinheit (Packung), bzw.
|
||||
die ganze Charge, wenn sie kleiner ist.
|
||||
"""
|
||||
quantity: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class LotConsume(BaseModel):
|
||||
"""Teilmenge aus einer Charge verbrauchen.
|
||||
|
||||
``unit`` nimmt alles, was auch beim Auslagern geht – Basiseinheit, verwaltete
|
||||
Einheit, „package" und die Hausmaße der Kategorie („EL", „Tasse").
|
||||
``auto_open`` bricht eine noch geschlossene Charge dabei an; die Rückfrage
|
||||
dazu stellt die Oberfläche, nicht der Server.
|
||||
"""
|
||||
quantity: float = Field(gt=0)
|
||||
unit: str
|
||||
note: str | None = Field(default=None, max_length=255)
|
||||
auto_open: bool = True
|
||||
|
||||
|
||||
class LotConsumeOut(BaseModel):
|
||||
"""Was der Verbrauch bewirkt hat."""
|
||||
affected_lots: list[dict] = []
|
||||
# Die offene Charge danach – None, wenn sie dabei leer geworden ist.
|
||||
lot: LotOut | None = None
|
||||
# Ob die Charge durch diesen Vorgang angebrochen wurde.
|
||||
opened: bool = False
|
||||
|
||||
|
||||
# ---- Mealie ----
|
||||
class MealieConfigIn(BaseModel):
|
||||
"""Zugangsdaten für die einmalige Anmeldung bei Mealie.
|
||||
|
||||
Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
|
||||
an, lässt sich ein Dauertoken ausstellen und vergisst es.
|
||||
"""
|
||||
base_url: str = Field(min_length=1, max_length=255)
|
||||
username: str = Field(min_length=1, max_length=120)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MealieTokenIn(BaseModel):
|
||||
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
|
||||
base_url: str = Field(min_length=1, max_length=255)
|
||||
token: str = Field(min_length=1)
|
||||
|
||||
|
||||
class MealieStatus(BaseModel):
|
||||
"""Auskunft über die Verbindung – **nie** das Token selbst."""
|
||||
configured: bool = False
|
||||
base_url: str = ""
|
||||
token_set: bool = False
|
||||
# Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
|
||||
reachable: bool | None = None
|
||||
version: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MealieAvailable(BaseModel):
|
||||
"""Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
|
||||
available: bool = False
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
"""Ein Rezept in der Liste."""
|
||||
slug: str
|
||||
name: str
|
||||
description: str = ""
|
||||
image_url: str | None = None
|
||||
servings: float | None = None
|
||||
|
||||
|
||||
class RecipePage(BaseModel):
|
||||
items: list[RecipeSummary] = []
|
||||
page: int = 1
|
||||
per_page: int = 50
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ZutatKandidat(BaseModel):
|
||||
art: str # product | group
|
||||
id: int
|
||||
name: str
|
||||
score: int
|
||||
|
||||
|
||||
class ZutatOut(BaseModel):
|
||||
"""Eine Zutat, auf den Bestand bezogen."""
|
||||
text: str
|
||||
menge: float | None = None
|
||||
einheit: str = ""
|
||||
# Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann.
|
||||
food: str = ""
|
||||
# True = so zugeordnet, weil es jemand einmal von Hand entschieden hat.
|
||||
manuell: bool = False
|
||||
art: str | None = None
|
||||
ziel_id: int | None = None
|
||||
ziel_name: str = ""
|
||||
kandidaten: list[ZutatKandidat] = []
|
||||
benoetigt_base: float | None = None
|
||||
bestand_base: float | None = None
|
||||
fehlt_base: float | None = None
|
||||
basis_einheit: str = ""
|
||||
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
|
||||
status: str = "kein_treffer"
|
||||
hinweis: str = ""
|
||||
fehlendes_mass: str = ""
|
||||
kategorie_id: int | None = None
|
||||
# Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste.
|
||||
fehlt_text: str = ""
|
||||
|
||||
|
||||
class RecipeDetail(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
description: str = ""
|
||||
image_url: str | None = None
|
||||
# Portionen laut Rezept und die, für die gerechnet wurde.
|
||||
base_servings: float | None = None
|
||||
servings: float | None = None
|
||||
skalierung_hinweis: str = ""
|
||||
kochbar: bool = False
|
||||
ingredients: list[ZutatOut] = []
|
||||
|
||||
|
||||
# ---- Gemerkte Zutaten-Zuordnungen ----
|
||||
class ZuordnungIn(BaseModel):
|
||||
"""Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen."""
|
||||
food: str = Field(min_length=1, max_length=200)
|
||||
art: str = Field(pattern="^(product|group)$")
|
||||
ziel_id: int
|
||||
|
||||
|
||||
class ZuordnungOut(BaseModel):
|
||||
"""Eine gemerkte Zuordnung, wie sie in der Liste steht."""
|
||||
food_key: str
|
||||
food_name: str
|
||||
art: str
|
||||
ziel_id: int
|
||||
ziel_name: str = ""
|
||||
|
||||
|
||||
class ZielVorschlag(BaseModel):
|
||||
"""Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen."""
|
||||
art: str
|
||||
id: int
|
||||
name: str
|
||||
# Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie.
|
||||
zusatz: str = ""
|
||||
basis_einheit: str = ""
|
||||
|
||||
|
||||
# ---- Hausmaße ----
|
||||
class MeasureIn(BaseModel):
|
||||
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
|
||||
name: str = Field(min_length=1, max_length=40)
|
||||
short: str | None = Field(default=None, max_length=10)
|
||||
position: int = 0
|
||||
|
||||
|
||||
class MeasureOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
short: str | None = None
|
||||
position: int = 0
|
||||
is_builtin: bool = False
|
||||
|
||||
|
||||
class MeasureAmountIn(BaseModel):
|
||||
"""Was ein Hausmaß hier wiegt bzw. fasst: ``amount`` in ``base``."""
|
||||
amount: float = Field(gt=0)
|
||||
base: BaseUnit
|
||||
|
||||
|
||||
class MeasureAmountOut(BaseModel):
|
||||
"""Ein für einen Artikel (oder eine Kategorie) geltendes Maß samt Herkunft."""
|
||||
measure_id: int
|
||||
name: str
|
||||
short: str | None = None
|
||||
amount: float
|
||||
base: BaseUnit
|
||||
# „Artikel", „Kategorie: Mehl & Zucker" oder „–" (nicht hinterlegt).
|
||||
herkunft: str = ""
|
||||
|
||||
|
||||
# ---- Kassenzettel-Abgleich ----
|
||||
class MatchRequest(BaseModel):
|
||||
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
||||
@@ -778,6 +1026,9 @@ class LoanCreate(BaseModel):
|
||||
lent_on: date | None = None
|
||||
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||||
due_on: date | None = None
|
||||
# Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche
|
||||
# fragt danach; der Server nimmt nie ungefragt etwas mit.
|
||||
mit_zubehoer: list[int] = []
|
||||
|
||||
|
||||
class LoanUpdate(BaseModel):
|
||||
@@ -792,6 +1043,9 @@ class LoanUpdate(BaseModel):
|
||||
class LoanReturn(BaseModel):
|
||||
"""Rückgabe buchen. Leeres Datum = heute."""
|
||||
returned_on: date | None = None
|
||||
# Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das
|
||||
# käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen".
|
||||
mit_zubehoer: list[int] = []
|
||||
|
||||
|
||||
class LoanOut(BaseModel):
|
||||
@@ -818,6 +1072,52 @@ class LoanOut(BaseModel):
|
||||
location_name: str | None = None
|
||||
|
||||
|
||||
class VerwendungSetzen(BaseModel):
|
||||
"""Verwendungsstatus eines Einzelstuecks setzen.
|
||||
|
||||
``status`` leer heisst verfuegbar – dafuer gibt es keinen eigenen Wert, es
|
||||
wird nur der laufende Zustand beendet (siehe models.py::UsageStatus).
|
||||
"""
|
||||
status: UsageStatus | None = None
|
||||
|
||||
|
||||
class VerwendungOut(BaseModel):
|
||||
"""Ein Zeitraum in einem Verwendungszustand – laufend oder abgeschlossen."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
item_id: int
|
||||
status: UsageStatus
|
||||
started_at: datetime
|
||||
# None = laeuft noch.
|
||||
ended_at: datetime | None = None
|
||||
# Wer umgestellt hat (Konto kann spaeter geloescht sein).
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class ItemKurz(BaseModel):
|
||||
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
|
||||
|
||||
Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes
|
||||
Teil wieder sein Geraet nach, endlos.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
uid: str
|
||||
product_id: int
|
||||
product_name: str | None = None
|
||||
product_brand: str | None = None
|
||||
location_name: str | None = None
|
||||
# Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann.
|
||||
verliehen: bool = False
|
||||
|
||||
|
||||
class ZubehoerZuordnen(BaseModel):
|
||||
"""Welches Stueck als Zubehoer an dieses Geraet gehaengt wird."""
|
||||
|
||||
child_item_id: int
|
||||
|
||||
|
||||
class ItemOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
@@ -842,6 +1142,104 @@ class ItemOut(BaseModel):
|
||||
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
||||
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
||||
loan: LoanOut | None = None
|
||||
# Der laufende Verwendungszustand – None heisst verfuegbar. Anders als beim
|
||||
# Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
|
||||
verwendung: VerwendungOut | None = None
|
||||
# Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
|
||||
# Steuert, ob die Oberflaeche die Bedienung dafuer zeigt.
|
||||
verwendung_moeglich: bool = False
|
||||
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
|
||||
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
|
||||
zubehoer: list[ItemKurz] = []
|
||||
gehoert_zu: ItemKurz | None = None
|
||||
|
||||
|
||||
# ---- Seitenleiste ----
|
||||
class NavigationOut(BaseModel):
|
||||
"""Was die Oberflaechen brauchen, in einem Zug.
|
||||
|
||||
``versteckt`` ist die fertige Vereinigung aus Vorgabe und eigener Auswahl –
|
||||
die Leiste rechnet damit nichts mehr selbst. ``vorgabe`` und ``eigen``
|
||||
stehen daneben, weil die Einstellungsseite bzw. die Kontoseite genau ihre
|
||||
eigene Ebene zum Bearbeiten brauchen.
|
||||
"""
|
||||
vorgabe: list[str] = []
|
||||
eigen: list[str] = []
|
||||
versteckt: list[str] = []
|
||||
|
||||
|
||||
class NavigationSetzen(BaseModel):
|
||||
versteckt: list[str] = []
|
||||
|
||||
|
||||
# ---- Etiketten-Vorlagen ----
|
||||
class SpalteOut(BaseModel):
|
||||
"""Eine waehlbare Spalte – fest oder ein eigenes Feld."""
|
||||
|
||||
schluessel: str
|
||||
titel: str
|
||||
# Nur bei eigenen Feldern gefuellt. Die Oberflaeche braucht beides, um beim
|
||||
# Wertefilter die passende Eingabe anzubieten (Checkbox, Auswahl, Zahl).
|
||||
typ: str = ""
|
||||
optionen: list[str] = []
|
||||
|
||||
|
||||
class FeldFilter(BaseModel):
|
||||
"""Eine Bedingung auf einem eigenen Feld.
|
||||
|
||||
Mehrere Bedingungen muessen alle zutreffen.
|
||||
|
||||
``op`` ist die Vergleichsart. Vorgabe ist ``=``, damit aeltere Vorlagen
|
||||
ohne Angabe unveraendert weiterlaufen:
|
||||
|
||||
* ``=`` / ``!=`` – genau gleich bzw. ungleich, ohne Ruecksicht auf
|
||||
Gross-/Kleinschreibung. Ein leerer ``wert`` trifft bei ``=`` die Zeilen
|
||||
OHNE Wert in diesem Feld.
|
||||
* ``>=`` / ``<=`` – nur bei Zahlenfeldern sinnvoll; Zeilen ohne Wert oder
|
||||
mit unlesbarer Zahl treffen nie.
|
||||
"""
|
||||
|
||||
feld_id: int
|
||||
op: str = Field(default="=", pattern=r"^(=|!=|>=|<=)$")
|
||||
wert: str = ""
|
||||
|
||||
|
||||
class EtikettenVorlageOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
art: str
|
||||
position: int
|
||||
# Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht
|
||||
# selbst parsen muss.
|
||||
spalten: list[str] = []
|
||||
kategorie_ids: list[int] = []
|
||||
trennzeichen: str = ","
|
||||
feldfilter: list[FeldFilter] = []
|
||||
# Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt.
|
||||
datei_name: str | None = None
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class EtikettenVorlageCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
art: str = Field(default="items", pattern="^(items|products|locations)$")
|
||||
spalten: list[str] = []
|
||||
kategorie_ids: list[int] = []
|
||||
trennzeichen: str = ","
|
||||
feldfilter: list[FeldFilter] = []
|
||||
|
||||
|
||||
class EtikettenVorlageUpdate(BaseModel):
|
||||
"""Nur mitgeschickte Felder zaehlen."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
art: str | None = Field(default=None, pattern="^(items|products|locations)$")
|
||||
spalten: list[str] | None = None
|
||||
kategorie_ids: list[int] | None = None
|
||||
trennzeichen: str | None = None
|
||||
feldfilter: list[FeldFilter] | None = None
|
||||
position: int | None = None
|
||||
|
||||
|
||||
# ---- Views ----
|
||||
@@ -879,8 +1277,17 @@ class ExpiringItem(BaseModel):
|
||||
product_name: str
|
||||
quantity: float # in Basiseinheiten
|
||||
base_unit: BaseUnit
|
||||
best_before: date
|
||||
# Darf fehlen: Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein
|
||||
# Verbrauchsdatum – und genau die soll hier auftauchen.
|
||||
best_before: date | None = None
|
||||
best_before_precision: DatePrecision = DatePrecision.day
|
||||
# Angebrochen: Öffnungstag und das daraus gerechnete Verbrauchsdatum.
|
||||
opened_on: date | None = None
|
||||
consume_by: date | None = None
|
||||
# Das Datum, nach dem gewarnt wird (das frühere der beiden), und welches es
|
||||
# ist – damit die Anzeige „läuft ab" von „angebrochen" unterscheiden kann.
|
||||
warn_date: date
|
||||
warn_source: str = "best_before" # best_before | consume_by
|
||||
days_left: int
|
||||
# Für die Anzeige in Artikeleinheiten:
|
||||
package_size: float | None = None
|
||||
@@ -933,12 +1340,61 @@ class LocationNeeds(BaseModel):
|
||||
groups: list[LocationNeedGroup] = []
|
||||
|
||||
|
||||
# ---- Gespeicherte Einkaufsliste ----
|
||||
class ShoppingEntryIn(BaseModel):
|
||||
"""Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext."""
|
||||
product_id: int | None = None
|
||||
group_id: int | None = None
|
||||
free_text: str | None = Field(default=None, max_length=120)
|
||||
quantity_base: float | None = Field(default=None, gt=0)
|
||||
base: BaseUnit | None = None
|
||||
|
||||
|
||||
class ShoppingEntryPatch(BaseModel):
|
||||
"""Abhaken oder die Menge korrigieren."""
|
||||
done: bool | None = None
|
||||
quantity_base: float | None = Field(default=None, gt=0)
|
||||
free_text: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class ShoppingEntryOut(BaseModel):
|
||||
"""Ein gespeicherter Eintrag, fertig für die Liste."""
|
||||
id: int
|
||||
art: str # product | group | text
|
||||
product_id: int | None = None
|
||||
group_id: int | None = None
|
||||
free_text: str | None = None
|
||||
name: str = ""
|
||||
quantity_base: float | None = None
|
||||
base: BaseUnit | None = None
|
||||
# „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten.
|
||||
menge_text: str = ""
|
||||
source: str = "manual" # manual | recipe
|
||||
recipe_slug: str | None = None
|
||||
recipe_name: str | None = None
|
||||
done: bool = False
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ShoppingFromRecipe(BaseModel):
|
||||
"""Was die Übernahme eines Rezepts bewirkt hat."""
|
||||
entries: list[ShoppingEntryOut] = []
|
||||
# Wie viele davon schon dastanden und nur neu gerechnet wurden.
|
||||
aktualisiert: int = 0
|
||||
# Zutaten ohne Zuordnung, die als Freitext draufkamen.
|
||||
freitext: int = 0
|
||||
|
||||
|
||||
class ShoppingListAll(BaseModel):
|
||||
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
|
||||
die Oberfläche zusammenführt."""
|
||||
"""Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die
|
||||
eigenen Einträge und die dauerhaft gesetzten Haken."""
|
||||
products: list[ShoppingItem] = []
|
||||
groups: list[GroupShoppingItem] = []
|
||||
by_location: list[LocationNeeds] = []
|
||||
# Eigene und aus Rezepten übernommene Einträge.
|
||||
entries: list[ShoppingEntryOut] = []
|
||||
# Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12").
|
||||
checked: list[str] = []
|
||||
|
||||
|
||||
class LocationContentEntry(BaseModel):
|
||||
@@ -1036,6 +1492,9 @@ class ExpirySplit(BaseModel):
|
||||
ok: float
|
||||
soon: float
|
||||
expired: float
|
||||
# Angebrochen und noch nicht in der Warnfrist. Was offen ist UND bald
|
||||
# abläuft, zählt als "soon" – Dringlichkeit schlägt Zustand.
|
||||
opened: float = 0.0
|
||||
no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
|
||||
|
||||
|
||||
@@ -1046,6 +1505,7 @@ class CategoryShare(BaseModel):
|
||||
ok: float
|
||||
soon: float
|
||||
expired: float
|
||||
opened: float = 0.0
|
||||
no_date: float
|
||||
|
||||
|
||||
|
||||
+14
-3
@@ -31,7 +31,9 @@ def verify_password(password: str, password_hash: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: int, role: str) -> str:
|
||||
def create_access_token(
|
||||
user_id: int, role: str, gueltig_minuten: int | None = None
|
||||
) -> str:
|
||||
"""Stellt ein Sitzungs-Token aus, das den Benutzer ueber seine ID benennt.
|
||||
|
||||
Frueher stand hier der Benutzername. Damit war der Name die Kennung der
|
||||
@@ -41,9 +43,18 @@ def create_access_token(user_id: int, role: str) -> str:
|
||||
``uid`` steht zusaetzlich zu ``sub`` drin, damit sich alte Token (``sub`` =
|
||||
Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst
|
||||
schlicht "42" heissen.
|
||||
|
||||
``gueltig_minuten`` kommt aus der Einstellung ``session_days``; ``None``
|
||||
nimmt den Rueckfallwert aus der Umgebung. **0 heisst unbegrenzt** – dann
|
||||
bekommt das Token gar kein ``exp`` und laeuft nie ab. Diese Wahl ist
|
||||
bewusst folgenreich: Ein ausgestelltes Token laesst sich nicht
|
||||
zurueckrufen, es gibt keine Sperrliste. Wer alle Anmeldungen beenden will,
|
||||
muss JWT_SECRET aendern.
|
||||
"""
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": str(user_id), "uid": user_id, "role": role, "exp": expire}
|
||||
minuten = settings.jwt_expire_minutes if gueltig_minuten is None else gueltig_minuten
|
||||
payload: dict = {"sub": str(user_id), "uid": user_id, "role": role}
|
||||
if minuten > 0:
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=minuten)
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
|
||||
+66
-1
@@ -5,7 +5,18 @@ import json
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
|
||||
from .models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
EtikettenVorlage,
|
||||
FieldDefinition,
|
||||
Measure,
|
||||
PackageType,
|
||||
Role,
|
||||
Unit,
|
||||
UnitKind,
|
||||
User,
|
||||
)
|
||||
from .security import hash_password
|
||||
from .services.fields import slugify
|
||||
|
||||
@@ -62,6 +73,30 @@ def ensure_builtin_units(db: Session) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
# Hausmaße: nur die Namen. Was eines davon WIEGT, steht an der Kategorie bzw.
|
||||
# am Artikel (models.py::CategoryMeasure) – 1 EL Mehl ist nicht 1 EL Zucker.
|
||||
# Ohne hinterlegte Menge taucht ein Maß in der Eingabe gar nicht auf, die Liste
|
||||
# hier schadet also nicht, wenn man sie nicht braucht.
|
||||
BUILTIN_MEASURES: list[tuple[str, str | None]] = [
|
||||
("Esslöffel", "EL"),
|
||||
("Teelöffel", "TL"),
|
||||
("Tasse", None),
|
||||
("Prise", None),
|
||||
("Messerspitze", None),
|
||||
("Handvoll", None),
|
||||
]
|
||||
|
||||
|
||||
def ensure_builtin_measures(db: Session) -> None:
|
||||
changed = False
|
||||
for position, (name, short) in enumerate(BUILTIN_MEASURES):
|
||||
if not db.query(Measure).filter(Measure.name == name).first():
|
||||
db.add(Measure(name=name, short=short, position=position, is_builtin=True))
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
|
||||
|
||||
# Startliste für den Überblick über den Vorrat. Zweistufig, damit die
|
||||
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
|
||||
# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen.
|
||||
@@ -197,3 +232,33 @@ def ensure_first_admin(db: Session) -> None:
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
|
||||
# Die zwei Vorlagen, die den frueheren festen Etikettenexport nachbilden: Wer
|
||||
# nichts einstellt, bekommt genau dieselben Spalten wie bisher.
|
||||
BUILTIN_ETIKETTEN_VORLAGEN = [
|
||||
("Einzelstücke", "items",
|
||||
["qr", "uid", "produkt", "marke", "kategorie", "lagerort"]),
|
||||
("Lagerorte", "locations", ["qr", "id", "name", "pfad"]),
|
||||
]
|
||||
|
||||
|
||||
def ensure_builtin_etiketten_vorlagen(db: Session) -> None:
|
||||
"""Legt die mitgelieferten Etiketten-Vorlagen an – einmalig.
|
||||
|
||||
Nur wenn noch GAR KEINE Vorlage existiert: Wer seine eigenen gebaut und die
|
||||
mitgelieferten geloescht hat, soll sie nicht bei jedem Start zurueckbekommen.
|
||||
"""
|
||||
if db.query(EtikettenVorlage).first() is not None:
|
||||
return
|
||||
for position, (name, art, spalten) in enumerate(BUILTIN_ETIKETTEN_VORLAGEN):
|
||||
db.add(EtikettenVorlage(
|
||||
name=name,
|
||||
art=art,
|
||||
position=position,
|
||||
konfiguration=json.dumps(
|
||||
{"spalten": spalten, "kategorie_ids": [], "trennzeichen": ","},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
@@ -98,8 +98,27 @@ def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> fl
|
||||
return menge * anzahl / zweit
|
||||
|
||||
|
||||
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
|
||||
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
|
||||
def to_base(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
unit_token: str,
|
||||
*,
|
||||
hausmasse: bool = False,
|
||||
) -> float:
|
||||
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um.
|
||||
|
||||
Reihenfolge: Packung → verwaltete Einheit → Hausmaß → Zweiteinheit-Brücke.
|
||||
Die Einheitentabelle steht VOR den Hausmaßen, weil sie die harte Physik ist:
|
||||
Ein versehentlich angelegtes Hausmaß namens „Liter" würde sonst die echte
|
||||
Einheit dauerhaft verdecken.
|
||||
|
||||
``hausmasse=True`` lässt Hausmaße überhaupt zu („2 EL"). Vorgabe ist False,
|
||||
weil ein Esslöffel ein Schätzwert ist: Beim **Verbrauchen und Auslagern** ist
|
||||
das richtig, beim **Einlagern** und bei **Mindestbeständen** ausdrücklich
|
||||
nicht. Der Schalter hält das in der Signatur fest, statt es der Disziplin an
|
||||
jeder Aufrufstelle zu überlassen.
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise ConversionError("Menge muss größer als 0 sein")
|
||||
|
||||
@@ -113,6 +132,10 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
|
||||
|
||||
unit = find_unit(db, t)
|
||||
if unit is None:
|
||||
if hausmasse:
|
||||
gerechnet = _hausmass_to_base(db, product, quantity, t)
|
||||
if gerechnet is not None:
|
||||
return gerechnet
|
||||
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
||||
if unit.kind != kind_of_product(product):
|
||||
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
|
||||
@@ -130,6 +153,50 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
|
||||
return quantity * unit.factor
|
||||
|
||||
|
||||
def _hausmass_to_base(
|
||||
db: Session, product: Product, quantity: float, token: str
|
||||
) -> float | None:
|
||||
"""Hausmaß-Token auflösen. ``None`` = kein Hausmaß dieses Namens.
|
||||
|
||||
Unterschied, der wichtig ist: Ein **unbekannter Name** gibt ``None`` zurück,
|
||||
damit danach die normale Einheitensuche greift. Ein **bekanntes Maß ohne
|
||||
Menge** für diesen Artikel ist dagegen ein Fehler mit klarer Ansage – genau
|
||||
die Meldung, aus der der Rezeptabgleich sein „Maß jetzt anlegen" macht.
|
||||
"""
|
||||
from .vererbung import mass_aufloesen, mass_in_basiseinheit
|
||||
|
||||
angabe = mass_aufloesen(db, product, token)
|
||||
if angabe is None:
|
||||
bekannt = _bekanntes_mass(db, token)
|
||||
if bekannt is not None:
|
||||
# Den richtigen Namen nennen, nicht das klein geschriebene Token –
|
||||
# die Meldung steht so in der Oberflaeche.
|
||||
raise ConversionError(
|
||||
f"Für diese Kategorie ist kein Maß '{bekannt.name}' hinterlegt."
|
||||
)
|
||||
return None
|
||||
ergebnis = mass_in_basiseinheit(db, product, token, quantity)
|
||||
if ergebnis is None:
|
||||
raise ConversionError(
|
||||
f"Das Maß '{angabe.measure.name}' ist in "
|
||||
f"{BASE_LABEL[BaseUnit(angabe.base)]} hinterlegt und passt nicht zur "
|
||||
f"Art dieses Artikels ({BASE_LABEL[product.base_unit]}). Mit einer "
|
||||
f"Zweiteinheit am Artikel ginge es."
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _bekanntes_mass(db: Session, token: str):
|
||||
"""Das Hausmaß mit diesem Namen oder dieser Kurzform – sonst ``None``."""
|
||||
from ..models import Measure
|
||||
|
||||
t = (token or "").strip().lower()
|
||||
for measure in db.query(Measure).all():
|
||||
if t == measure.name.lower() or (measure.short and t == measure.short.lower()):
|
||||
return measure
|
||||
return None
|
||||
|
||||
|
||||
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
|
||||
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
|
||||
unit = db.get(Unit, unit_id)
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Die gespeicherte Seite der Einkaufsliste.
|
||||
|
||||
Gerechnet wird die Liste in ``routers/views.py`` – Artikel unter ihrem
|
||||
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt so. Hier
|
||||
liegt daneben das, was jemand **bewusst** draufgesetzt hat: eigene Einträge,
|
||||
übernommene Rezeptzutaten, und das dauerhafte Abhaken der gerechneten
|
||||
Vorschläge.
|
||||
|
||||
Beides getrennt zu halten ist Absicht. Die gerechneten Posten dürfen keine
|
||||
Zeilen in der Datenbank bekommen: Sie ändern sich mit jedem Einlagern, und eine
|
||||
materialisierte Kopie würde sofort von der Wirklichkeit abweichen. Was gemerkt
|
||||
wird, ist nur der Haken daran.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
Group,
|
||||
Product,
|
||||
ShoppingCheck,
|
||||
ShoppingEntry,
|
||||
User,
|
||||
)
|
||||
from .einkaufsliste_text import fehlmengen_text
|
||||
|
||||
|
||||
class EintragFehler(ValueError):
|
||||
"""Unbrauchbarer Eintrag – der Router macht daraus eine 400."""
|
||||
|
||||
|
||||
def _jetzt() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Eigene Einträge
|
||||
# --------------------------------------------------------------------------
|
||||
def eintraege(db: Session, *, mit_erledigten: bool = True) -> list[ShoppingEntry]:
|
||||
"""Alle gespeicherten Einträge, offene zuerst.
|
||||
|
||||
Zeigt ein Eintrag auf einen Artikel oder eine Gruppe, die es nicht mehr
|
||||
gibt, fällt er hier still heraus: Auf SQLite greift kein Fremdschlüssel, und
|
||||
einen gelöschten Artikel kann man nicht einkaufen.
|
||||
"""
|
||||
reihen = db.query(ShoppingEntry).order_by(
|
||||
ShoppingEntry.done, ShoppingEntry.created_at
|
||||
).all()
|
||||
gueltig = [e for e in reihen if _ziel_existiert(db, e)]
|
||||
if mit_erledigten:
|
||||
return gueltig
|
||||
return [e for e in gueltig if not e.done]
|
||||
|
||||
|
||||
def _ziel_existiert(db: Session, eintrag: ShoppingEntry) -> bool:
|
||||
if eintrag.product_id is not None:
|
||||
return db.get(Product, eintrag.product_id) is not None
|
||||
if eintrag.group_id is not None:
|
||||
return db.get(Group, eintrag.group_id) is not None
|
||||
return bool(eintrag.free_text)
|
||||
|
||||
|
||||
def bezeichnung(db: Session, eintrag: ShoppingEntry) -> str:
|
||||
"""Was auf dem Zettel steht."""
|
||||
if eintrag.product_id is not None:
|
||||
produkt = db.get(Product, eintrag.product_id)
|
||||
return produkt.name if produkt is not None else ""
|
||||
if eintrag.group_id is not None:
|
||||
gruppe = db.get(Group, eintrag.group_id)
|
||||
return gruppe.name if gruppe is not None else ""
|
||||
return eintrag.free_text or ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Menge:
|
||||
"""Die Form, die ``fehlmengen_text`` erwartet.
|
||||
|
||||
Umweg mit Absicht: So formatiert die Einkaufsliste ihre Mengen mit genau
|
||||
derselben Funktion wie der Rezeptabgleich („2 Packungen" statt „1000 ml",
|
||||
aufgerundet auf ganze Gebinde). Zwei Formatierungen wuerden auseinander
|
||||
laufen.
|
||||
"""
|
||||
|
||||
art: str
|
||||
ziel_id: int | None
|
||||
fehlt_base: float | None
|
||||
basis_einheit: str
|
||||
|
||||
|
||||
def mengen_text(db: Session, eintrag: ShoppingEntry) -> str:
|
||||
"""„2 Packungen" bzw. „150 g" – leer, wenn keine Menge dransteht."""
|
||||
if not eintrag.quantity_base:
|
||||
return ""
|
||||
art = "product" if eintrag.product_id is not None else "group"
|
||||
return fehlmengen_text(db, _Menge(
|
||||
art=art, ziel_id=eintrag.product_id or eintrag.group_id,
|
||||
fehlt_base=eintrag.quantity_base, basis_einheit=eintrag.base or "",
|
||||
))
|
||||
|
||||
|
||||
def anlegen(
|
||||
db: Session,
|
||||
*,
|
||||
product_id: int | None = None,
|
||||
group_id: int | None = None,
|
||||
free_text: str | None = None,
|
||||
quantity_base: float | None = None,
|
||||
base: str | None = None,
|
||||
source: str = "manual",
|
||||
recipe_slug: str | None = None,
|
||||
recipe_name: str | None = None,
|
||||
user: User | None = None,
|
||||
) -> ShoppingEntry:
|
||||
"""Einen Eintrag anlegen. Genau ein Ziel, und es muss existieren."""
|
||||
text = (free_text or "").strip() or None
|
||||
ziele = [z for z in (product_id, group_id, text) if z is not None]
|
||||
if len(ziele) != 1:
|
||||
raise EintragFehler(
|
||||
"Ein Eintrag zeigt auf genau eines: einen Artikel, eine Gruppe oder "
|
||||
"einen freien Text."
|
||||
)
|
||||
if product_id is not None and db.get(Product, product_id) is None:
|
||||
raise EintragFehler("Diesen Artikel gibt es nicht.")
|
||||
if group_id is not None and db.get(Group, group_id) is None:
|
||||
raise EintragFehler("Diese Gruppe gibt es nicht.")
|
||||
if quantity_base is not None and quantity_base <= 0:
|
||||
raise EintragFehler("Eine Menge muss größer als 0 sein.")
|
||||
|
||||
eintrag = ShoppingEntry(
|
||||
product_id=product_id, group_id=group_id, free_text=text,
|
||||
quantity_base=quantity_base, base=base, source=source,
|
||||
recipe_slug=recipe_slug, recipe_name=recipe_name,
|
||||
created_by=(user.id if user is not None else None),
|
||||
)
|
||||
db.add(eintrag)
|
||||
db.commit()
|
||||
db.refresh(eintrag)
|
||||
return eintrag
|
||||
|
||||
|
||||
def abhaken(db: Session, eintrag: ShoppingEntry, done: bool) -> ShoppingEntry:
|
||||
eintrag.done = done
|
||||
eintrag.done_at = _jetzt() if done else None
|
||||
db.commit()
|
||||
db.refresh(eintrag)
|
||||
return eintrag
|
||||
|
||||
|
||||
def loeschen(db: Session, eintrag: ShoppingEntry) -> None:
|
||||
db.delete(eintrag)
|
||||
db.commit()
|
||||
|
||||
|
||||
def erledigte_entfernen(db: Session) -> int:
|
||||
"""Alles Abgehakte weg – eigene Einträge und die Haken an den gerechneten.
|
||||
|
||||
Ein Durchgang nach dem Einkauf. Bewusst beides zusammen: Für den Nutzer ist
|
||||
es eine Liste, auch wenn sie aus zwei Quellen kommt.
|
||||
"""
|
||||
anzahl = 0
|
||||
for eintrag in db.query(ShoppingEntry).filter(ShoppingEntry.done.is_(True)).all():
|
||||
db.delete(eintrag)
|
||||
anzahl += 1
|
||||
for haken in db.query(ShoppingCheck).all():
|
||||
db.delete(haken)
|
||||
anzahl += 1
|
||||
db.commit()
|
||||
return anzahl
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Haken an den gerechneten Posten
|
||||
# --------------------------------------------------------------------------
|
||||
def abgehakte(db: Session) -> list[str]:
|
||||
return [h.key for h in db.query(ShoppingCheck).all()]
|
||||
|
||||
|
||||
def haken_setzen(db: Session, key: str, user: User | None = None) -> None:
|
||||
sauber = (key or "").strip()[:80]
|
||||
if not sauber:
|
||||
raise EintragFehler("Ohne Kennung lässt sich kein Haken merken.")
|
||||
if db.get(ShoppingCheck, sauber) is None:
|
||||
db.add(ShoppingCheck(
|
||||
key=sauber, user_id=(user.id if user is not None else None),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def haken_loesen(db: Session, key: str) -> None:
|
||||
haken = db.get(ShoppingCheck, (key or "").strip()[:80])
|
||||
if haken is not None:
|
||||
db.delete(haken)
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aus einem Rezept übernehmen
|
||||
# --------------------------------------------------------------------------
|
||||
def aus_rezept(
|
||||
db: Session,
|
||||
pruefungen: list,
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
user: User | None = None,
|
||||
) -> tuple[list[ShoppingEntry], int]:
|
||||
"""Was für dieses Rezept fehlt, auf die Liste setzen.
|
||||
|
||||
Übernommen wird, was fehlt oder zu wenig ist — mit der **Fehlmenge**, nicht
|
||||
der Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g.
|
||||
|
||||
Zutaten ohne Zuordnung kommen als Freitext drauf. Das ist der wichtigere
|
||||
Teil, als es klingt: Gerade die unbekannte Zutat ist die, die man vergisst.
|
||||
|
||||
Je Rezept und Ziel gibt es höchstens einen offenen Eintrag. Ein zweiter
|
||||
Druck auf den Knopf — etwa nach dem Hochdrehen der Portionen — rechnet die
|
||||
Menge neu, statt die Liste zu verdoppeln. Über verschiedene Rezepte hinweg
|
||||
bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen.
|
||||
|
||||
Rückgabe: die betroffenen Einträge und wie viele davon aktualisiert (statt
|
||||
neu angelegt) wurden.
|
||||
"""
|
||||
betroffen: list[ShoppingEntry] = []
|
||||
aktualisiert = 0
|
||||
|
||||
for p in pruefungen:
|
||||
if p.status in ("fehlt", "zu_wenig"):
|
||||
menge = p.fehlt_base if p.fehlt_base else None
|
||||
vorhanden = _offener_eintrag(
|
||||
db, slug, product_id=(p.ziel_id if p.art == "product" else None),
|
||||
group_id=(p.ziel_id if p.art == "group" else None),
|
||||
)
|
||||
if vorhanden is not None:
|
||||
vorhanden.quantity_base = menge
|
||||
vorhanden.base = p.basis_einheit or None
|
||||
aktualisiert += 1
|
||||
betroffen.append(vorhanden)
|
||||
continue
|
||||
betroffen.append(anlegen(
|
||||
db,
|
||||
product_id=(p.ziel_id if p.art == "product" else None),
|
||||
group_id=(p.ziel_id if p.art == "group" else None),
|
||||
quantity_base=menge, base=p.basis_einheit or None,
|
||||
source="recipe", recipe_slug=slug, recipe_name=name, user=user,
|
||||
))
|
||||
elif p.status == "kein_treffer":
|
||||
text = (p.food or p.text or "").strip()[:120]
|
||||
if not text:
|
||||
continue
|
||||
vorhanden = _offener_eintrag(db, slug, free_text=text)
|
||||
if vorhanden is not None:
|
||||
aktualisiert += 1
|
||||
betroffen.append(vorhanden)
|
||||
continue
|
||||
betroffen.append(anlegen(
|
||||
db, free_text=text, source="recipe",
|
||||
recipe_slug=slug, recipe_name=name, user=user,
|
||||
))
|
||||
|
||||
db.commit()
|
||||
return betroffen, aktualisiert
|
||||
|
||||
|
||||
def _offener_eintrag(
|
||||
db: Session,
|
||||
slug: str,
|
||||
*,
|
||||
product_id: int | None = None,
|
||||
group_id: int | None = None,
|
||||
free_text: str | None = None,
|
||||
) -> ShoppingEntry | None:
|
||||
"""Ein noch nicht abgehakter Eintrag dieses Rezepts für dasselbe Ziel."""
|
||||
frage = db.query(ShoppingEntry).filter(
|
||||
ShoppingEntry.recipe_slug == slug,
|
||||
ShoppingEntry.done.is_(False),
|
||||
)
|
||||
if product_id is not None:
|
||||
frage = frage.filter(ShoppingEntry.product_id == product_id)
|
||||
elif group_id is not None:
|
||||
frage = frage.filter(ShoppingEntry.group_id == group_id)
|
||||
else:
|
||||
frage = frage.filter(ShoppingEntry.free_text == free_text)
|
||||
return frage.first()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Fehlmenge als lesbare Angabe – „2 Packungen (1 l)" statt „1000 ml".
|
||||
|
||||
Eigene kleine Datei, damit der Rezeptabgleich dieselbe Sprache spricht wie die
|
||||
Einkaufsliste: Dort wird eine Fehlmenge ebenfalls als zählbares Gebinde
|
||||
ausgegeben und aufgerundet, weil man nur ganze Packungen kauft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Product
|
||||
from .conversion import BASE_LABEL, BaseUnit, article_unit
|
||||
|
||||
|
||||
def fehlmengen_text(db: Session, pruefung) -> str:
|
||||
"""„2 Packungen" bzw. „300 g" – leer, wenn nichts fehlt."""
|
||||
fehlt = getattr(pruefung, "fehlt_base", None)
|
||||
if not fehlt or fehlt <= 1e-9:
|
||||
return ""
|
||||
if pruefung.art == "product" and pruefung.ziel_id is not None:
|
||||
produkt = db.get(Product, pruefung.ziel_id)
|
||||
if produkt is not None:
|
||||
faktor, label = article_unit(produkt)
|
||||
if produkt.package_size and produkt.package_size > 0:
|
||||
# Ganze Gebinde: man kauft keine halbe Packung.
|
||||
anzahl = math.ceil(fehlt / (faktor or 1.0) - 1e-9)
|
||||
return f"{anzahl:g} {label}" + ("" if anzahl == 1 else "en" if label.endswith("ung") else "")
|
||||
return f"{fehlt:g} {label}"
|
||||
basis = pruefung.basis_einheit
|
||||
try:
|
||||
name = BASE_LABEL[BaseUnit(basis)]
|
||||
except (ValueError, KeyError):
|
||||
name = basis
|
||||
return f"{fehlt:g} {name}"
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Etikettenexport: welche Spalten es gibt und wie daraus eine CSV wird.
|
||||
|
||||
Frueher entstand die CSV in der Weboberflaeche, mit sechs festen Spalten. Mit
|
||||
frei waehlbaren Spalten, eigenen Feldern und drei Bezugsarten gehoert das an
|
||||
eine Stelle – und zwar hierher, wo es sich pruefen laesst: Fuer die Oberflaeche
|
||||
gibt es keine Tests, fuer das Backend ueber dreihundert.
|
||||
|
||||
Alles hier ist bewusst frei von HTTP: Eine Vorlage, eine Datenbank-Sitzung und
|
||||
eine Basis-Adresse hinein, fertige CSV heraus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .fields import options_list
|
||||
from ..models import (
|
||||
Category,
|
||||
EtikettenVorlage,
|
||||
FieldDefinition,
|
||||
FieldType,
|
||||
Item,
|
||||
Location,
|
||||
Product,
|
||||
ProductFieldValue,
|
||||
)
|
||||
|
||||
# Bezugsarten einer Vorlage.
|
||||
ARTEN = ("items", "products", "locations")
|
||||
|
||||
# Zellen, die damit beginnen, deutet Excel als Formel. Ein vorangestelltes
|
||||
# Apostroph macht sie zu reinem Text. Uebernommen aus der fruaeheren
|
||||
# Browser-Fassung (web/src/pages/Transfer.jsx).
|
||||
_FORMEL_START = re.compile(r"^[=+\-@\t\r]")
|
||||
|
||||
# Praefix eines selbst definierten Feldes. Die ID und nicht der key: FieldDefinition.key
|
||||
# ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.
|
||||
FELD_PRAEFIX = "feld:"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Kontext:
|
||||
"""Was die Spaltenfunktionen ausser der Zeile selbst brauchen."""
|
||||
|
||||
db: Session
|
||||
# Oeffentliche Adresse der Oberflaeche – nur fuer Lagerort-QR noetig
|
||||
# (…/l/<id>). Einzelstuecke tragen vorrania://i/<UID> ohne jede Adresse.
|
||||
basis_url: str = ""
|
||||
# Werte der eigenen Felder, vorab gesammelt: {product_id: {fd_id: wert}}.
|
||||
feldwerte: dict[int, dict[int, str | None]] | None = None
|
||||
|
||||
|
||||
def _kategorie_pfad(db: Session, category: Category | None) -> str:
|
||||
"""„Elektronik > Powerbanks" in einer Spalte."""
|
||||
if category is None:
|
||||
return ""
|
||||
teile = [category.name]
|
||||
aktuell, tiefe = category, 0
|
||||
while aktuell.parent_id is not None and tiefe < 10: # Schutz vor einem Ring
|
||||
aktuell = db.get(Category, aktuell.parent_id)
|
||||
if aktuell is None:
|
||||
break
|
||||
teile.append(aktuell.name)
|
||||
tiefe += 1
|
||||
return " > ".join(reversed(teile))
|
||||
|
||||
|
||||
def _lagerort_pfad(db: Session, ort: Location | None) -> str:
|
||||
if ort is None:
|
||||
return ""
|
||||
teile = [ort.name]
|
||||
aktuell, tiefe = ort, 0
|
||||
while aktuell.parent_id is not None and tiefe < 10:
|
||||
aktuell = db.get(Location, aktuell.parent_id)
|
||||
if aktuell is None:
|
||||
break
|
||||
teile.append(aktuell.name)
|
||||
tiefe += 1
|
||||
return " > ".join(reversed(teile))
|
||||
|
||||
|
||||
# Signatur wie alle Spaltenfunktionen (Zeile, Kontext) – auch wenn der
|
||||
# Kontext hier nicht gebraucht wird.
|
||||
def _preis(item: Item, _kontext: "Kontext") -> str:
|
||||
if item.price_cents is None:
|
||||
return ""
|
||||
return f"{item.price_cents / 100:.2f}"
|
||||
|
||||
|
||||
def _offener_verleih(item: Item):
|
||||
for loan in item.loans:
|
||||
if loan.returned_on is None:
|
||||
return loan
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Spalten-Registry je Bezugsart: Schluessel -> (Titel, Funktion)
|
||||
# --------------------------------------------------------------------------
|
||||
SPALTEN: dict[str, dict[str, tuple[str, Callable[[Any, Kontext], str]]]] = {
|
||||
"items": {
|
||||
"qr": ("QR-Inhalt", lambda i, k: f"vorrania://i/{i.uid}"),
|
||||
"uid": ("UID", lambda i, k: i.uid),
|
||||
"produkt": ("Produkt", lambda i, k: i.product.name if i.product else ""),
|
||||
"marke": ("Marke", lambda i, k: (i.product.brand if i.product else "") or ""),
|
||||
"kategorie": (
|
||||
"Kategorie",
|
||||
lambda i, k: _kategorie_pfad(k.db, i.product.category if i.product else None),
|
||||
),
|
||||
"lagerort": ("Lagerort", lambda i, k: i.location.name if i.location else ""),
|
||||
"lagerort_pfad": ("Lagerort (Pfad)", lambda i, k: _lagerort_pfad(k.db, i.location)),
|
||||
"gekauft_am": ("Gekauft am", lambda i, k: str(i.acquired_on or "")),
|
||||
"garantie_bis": ("Garantie bis", lambda i, k: str(i.warranty_until or "")),
|
||||
"gekauft_bei": ("Gekauft bei", lambda i, k: i.shop.name if i.shop else ""),
|
||||
"kaufpreis": ("Kaufpreis", _preis),
|
||||
"waehrung": ("Währung", lambda i, k: i.currency or ""),
|
||||
"notiz": ("Notiz", lambda i, k: i.note or ""),
|
||||
"verliehen_an": (
|
||||
"Verliehen an",
|
||||
lambda i, k: (_offener_verleih(i).borrower if _offener_verleih(i) else ""),
|
||||
),
|
||||
"gehoert_zu": (
|
||||
"Gehört zu",
|
||||
lambda i, k: (
|
||||
f"{i.gehoert_zu.product.name if i.gehoert_zu.product else ''} "
|
||||
f"({i.gehoert_zu.uid})".strip()
|
||||
if i.gehoert_zu else ""
|
||||
),
|
||||
),
|
||||
},
|
||||
"products": {
|
||||
"name": ("Name", lambda p, k: p.name),
|
||||
"marke": ("Marke", lambda p, k: p.brand or ""),
|
||||
"kategorie": ("Kategorie", lambda p, k: _kategorie_pfad(k.db, p.category)),
|
||||
"barcode": ("Barcode", lambda p, k: p.barcode or ""),
|
||||
"gebinde": ("Gebinde", lambda p, k: p.package_label or ""),
|
||||
"gebindegroesse": (
|
||||
"Gebindegröße",
|
||||
lambda p, k: "" if p.package_size is None else _zahl(p.package_size),
|
||||
),
|
||||
"mindestbestand": (
|
||||
"Mindestbestand",
|
||||
lambda p, k: "" if p.min_stock is None else _zahl(p.min_stock),
|
||||
),
|
||||
"gekauft_bei": ("Gekauft bei", lambda p, k: p.shop.name if p.shop else ""),
|
||||
"produktlink": ("Produktlink", lambda p, k: p.product_url or ""),
|
||||
},
|
||||
"locations": {
|
||||
"qr": ("QR-Inhalt", lambda o, k: f"{k.basis_url.rstrip('/')}/l/{o.id}"),
|
||||
"id": ("ID", lambda o, k: o.id),
|
||||
"name": ("Lagerort", lambda o, k: o.name),
|
||||
"pfad": ("Pfad", lambda o, k: _lagerort_pfad(k.db, o)),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _zahl(wert: float) -> str:
|
||||
"""Ganze Zahlen ohne „.0" – auf einem Etikett stoert das."""
|
||||
return str(int(wert)) if float(wert).is_integer() else str(wert)
|
||||
|
||||
|
||||
def feld_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]:
|
||||
"""Die eigenen Felder, die als Spalten zur Wahl stehen.
|
||||
|
||||
Eigene Felder haengen an Kategorien und damit am ARTIKEL – nicht am
|
||||
Einzelstueck. Alle Stuecke eines Artikels teilen sich denselben Wert; fuer
|
||||
ein Etikett ist genau das gewollt.
|
||||
"""
|
||||
if art == "locations":
|
||||
return [] # Lagerorte haben keine eigenen Felder
|
||||
frage = db.query(FieldDefinition)
|
||||
if kategorie_ids:
|
||||
# Ein Feld gilt auch fuer Unterkategorien; umgekehrt heisst das: alle
|
||||
# Definitionen entlang der Vorfahren der gewaehlten Kategorien.
|
||||
from ..routers.categories import descendant_ids
|
||||
|
||||
erlaubt: set[int] = set()
|
||||
for kid in kategorie_ids:
|
||||
erlaubt |= descendant_ids(db, kid)
|
||||
erlaubt |= {c.id for c in _vorfahren(db, kid)}
|
||||
frage = frage.filter(FieldDefinition.category_id.in_(erlaubt or {-1}))
|
||||
gesehen: set[int] = set()
|
||||
spalten: list[dict] = []
|
||||
for fd in frage.order_by(FieldDefinition.position, FieldDefinition.id).all():
|
||||
if fd.id in gesehen:
|
||||
continue
|
||||
gesehen.add(fd.id)
|
||||
titel = f"{fd.label} ({fd.unit})" if fd.unit else fd.label
|
||||
# Art und Auswahlwerte kommen mit: Erst damit kann die Oberflaeche beim
|
||||
# Wertefilter eine Checkbox, ein Dropdown oder ein Zahlenfeld anbieten
|
||||
# statt eines nackten Textfelds, in das man sich vertippt.
|
||||
spalten.append({
|
||||
"schluessel": f"{FELD_PRAEFIX}{fd.id}",
|
||||
"titel": titel,
|
||||
"typ": fd.field_type,
|
||||
"optionen": options_list(fd),
|
||||
})
|
||||
return spalten
|
||||
|
||||
|
||||
def _vorfahren(db: Session, category_id: int) -> list[Category]:
|
||||
kette: list[Category] = []
|
||||
aktuell = db.get(Category, category_id)
|
||||
gesehen: set[int] = set()
|
||||
while aktuell is not None and aktuell.id not in gesehen:
|
||||
gesehen.add(aktuell.id)
|
||||
kette.append(aktuell)
|
||||
aktuell = db.get(Category, aktuell.parent_id) if aktuell.parent_id else None
|
||||
return kette
|
||||
|
||||
|
||||
def verfuegbare_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]:
|
||||
"""Feste Spalten der Bezugsart plus die passenden eigenen Felder."""
|
||||
fest = [
|
||||
{"schluessel": s, "titel": titel}
|
||||
for s, (titel, _) in SPALTEN.get(art, {}).items()
|
||||
]
|
||||
return fest + feld_spalten(db, art, kategorie_ids)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Konfiguration
|
||||
# --------------------------------------------------------------------------
|
||||
def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict:
|
||||
"""JSON-Text zu einem Woerterbuch – kaputter Inhalt ergibt Vorgaben."""
|
||||
try:
|
||||
roh = json.loads(vorlage.konfiguration or "{}")
|
||||
except (ValueError, TypeError):
|
||||
roh = {}
|
||||
if not isinstance(roh, dict):
|
||||
roh = {}
|
||||
spalten = [str(s) for s in roh.get("spalten", []) if isinstance(s, (str, int))]
|
||||
kategorie_ids = [int(k) for k in roh.get("kategorie_ids", []) if str(k).lstrip("-").isdigit()]
|
||||
trenn = roh.get("trennzeichen") or ","
|
||||
if trenn not in (",", ";", "\t"):
|
||||
trenn = ","
|
||||
# Kaputte Eintraege fallen einzeln weg, nicht der ganze Filter - sonst
|
||||
# entschiede ein Tippfehler darueber, ob ein Export alles ausgibt.
|
||||
feldfilter = []
|
||||
for eintrag in roh.get("feldfilter", []) if isinstance(roh.get("feldfilter"), list) else []:
|
||||
if not isinstance(eintrag, dict):
|
||||
continue
|
||||
fid = eintrag.get("feld_id")
|
||||
if not str(fid).lstrip("-").isdigit():
|
||||
continue
|
||||
wert = eintrag.get("wert", "")
|
||||
op = eintrag.get("op", "=")
|
||||
if op not in ("=", "!=", ">=", "<="):
|
||||
op = "="
|
||||
feldfilter.append({
|
||||
"feld_id": int(fid),
|
||||
"op": op,
|
||||
"wert": str(wert) if wert is not None else "",
|
||||
})
|
||||
return {
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": trenn,
|
||||
"feldfilter": feldfilter,
|
||||
}
|
||||
|
||||
|
||||
def konfiguration_schreiben(
|
||||
spalten: list[str],
|
||||
kategorie_ids: list[int],
|
||||
trennzeichen: str,
|
||||
feldfilter: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Gegenstueck zu konfiguration_lesen.
|
||||
|
||||
Lag frueher als _konfiguration im Router. Hierher gezogen, weil sonst DREI
|
||||
Stellen denselben JSON-Text bauen (POST, PATCH und der Sicherungs-Import)
|
||||
und ein neuer Schluessel an jeder einzeln nachgetragen werden muesste.
|
||||
"""
|
||||
return json.dumps(
|
||||
{
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids,
|
||||
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
|
||||
"feldfilter": feldfilter or [],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _als_zahl(text: str | None) -> float | None:
|
||||
"""Zahl aus dem gespeicherten Text – Komma gilt wie Punkt."""
|
||||
try:
|
||||
return float(str(text).replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _feld_passt(fd: FieldDefinition, op: str, bedingung: str, ist: str | None) -> bool:
|
||||
"""Trifft der gespeicherte Wert die Bedingung?
|
||||
|
||||
Ja/Nein ist der heikle Fall: Nimmt man den Haken weg, loescht
|
||||
apply_field_values die Zeile (services/fields.py). „nein" und „nie gesetzt"
|
||||
sind in den Daten also dasselbe - beide muessen treffen, sonst faende
|
||||
„alle mit Nein" so gut wie nichts.
|
||||
|
||||
coerce_value wird NUR fuer Ja/Nein benutzt: Bei Auswahl, Zahl und Datum
|
||||
wirft es bei unpassendem Text, und ein vertippter Filterwert soll keinen
|
||||
Serverfehler beim Herunterladen ausloesen.
|
||||
"""
|
||||
if fd.field_type == FieldType.boolean.value:
|
||||
from .fields import coerce_value
|
||||
|
||||
gleich = (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
|
||||
return not gleich if op == "!=" else gleich
|
||||
|
||||
if op in (">=", "<="):
|
||||
# Groesser/kleiner gibt es nur auf Zahlen. Ohne Wert oder mit
|
||||
# unlesbarem Text trifft die Bedingung nie - „leer" ist nicht kleiner
|
||||
# als drei, es ist unvergleichbar.
|
||||
links, rechts = _als_zahl(ist), _als_zahl(bedingung)
|
||||
if links is None or rechts is None:
|
||||
return False
|
||||
return links >= rechts if op == ">=" else links <= rechts
|
||||
|
||||
gleich = (ist or "").casefold() == (bedingung or "").strip().casefold()
|
||||
return not gleich if op == "!=" else gleich
|
||||
|
||||
|
||||
def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict]) -> list:
|
||||
"""Zeilen auf die reduzieren, die ALLE Bedingungen erfuellen.
|
||||
|
||||
In Python und nicht in SQL: Auf ProductFieldValue.value liegt kein Index,
|
||||
„fehlende Zeile zaehlt als leer" braeuchte je Bedingung ein NOT EXISTS, und
|
||||
die Mengen sind haushaltsgross. _feldwerte holt ohnehin alles in einem Zug.
|
||||
|
||||
Ein geloeschtes Feld laesst seine Bedingung wegfallen - genau wie eine
|
||||
Spalte zu einem geloeschten Feld still verschwindet.
|
||||
"""
|
||||
bedingungen = []
|
||||
for eintrag in feldfilter:
|
||||
fd = db.get(FieldDefinition, eintrag["feld_id"])
|
||||
if fd is not None:
|
||||
bedingungen.append((fd, eintrag.get("op", "="), eintrag["wert"]))
|
||||
if not bedingungen:
|
||||
return zeilen
|
||||
|
||||
werte = _feldwerte(db, art, zeilen)
|
||||
passend = []
|
||||
for zeile in zeilen:
|
||||
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
|
||||
eigene = werte.get(produkt_id, {})
|
||||
if all(_feld_passt(fd, op, wert, eigene.get(fd.id)) for fd, op, wert in bedingungen):
|
||||
passend.append(zeile)
|
||||
return passend
|
||||
|
||||
|
||||
def _zeilen(
|
||||
db: Session, art: str, kategorie_ids: list[int], feldfilter: list[dict] | None = None
|
||||
) -> list:
|
||||
if art == "locations":
|
||||
return db.query(Location).order_by(Location.name).all()
|
||||
|
||||
from ..routers.categories import descendant_ids
|
||||
|
||||
ids: set[int] | None = None
|
||||
if kategorie_ids:
|
||||
ids = set()
|
||||
for kid in kategorie_ids:
|
||||
ids |= descendant_ids(db, kid)
|
||||
|
||||
if art == "products":
|
||||
frage = db.query(Product)
|
||||
if ids is not None:
|
||||
frage = frage.filter(Product.category_id.in_(ids or {-1}))
|
||||
zeilen = frage.order_by(Product.name).all()
|
||||
else:
|
||||
frage = db.query(Item)
|
||||
if ids is not None:
|
||||
frage = frage.join(Product, Item.product_id == Product.id).filter(
|
||||
Product.category_id.in_(ids or {-1})
|
||||
)
|
||||
zeilen = frage.order_by(Item.id).all()
|
||||
|
||||
return _nach_feldwerten(db, art, zeilen, feldfilter) if feldfilter else zeilen
|
||||
|
||||
|
||||
def _feldwerte(db: Session, art: str, zeilen: list) -> dict[int, dict[int, str | None]]:
|
||||
"""Alle Feldwerte der beteiligten Artikel in EINER Abfrage.
|
||||
|
||||
Ohne das fragt jede Zeile einzeln nach – bei ein paar hundert Etiketten
|
||||
ist das der Unterschied zwischen einer Abfrage und Hunderten.
|
||||
"""
|
||||
if art == "locations":
|
||||
return {}
|
||||
produkt_ids = {
|
||||
(z.product_id if art == "items" else z.id) for z in zeilen
|
||||
}
|
||||
if not produkt_ids:
|
||||
return {}
|
||||
werte: dict[int, dict[int, str | None]] = {}
|
||||
for pfv in (
|
||||
db.query(ProductFieldValue)
|
||||
.filter(ProductFieldValue.product_id.in_(produkt_ids))
|
||||
.all()
|
||||
):
|
||||
werte.setdefault(pfv.product_id, {})[pfv.field_definition_id] = pfv.value
|
||||
return werte
|
||||
|
||||
|
||||
def _zelle(schluessel: str, zeile, art: str, kontext: Kontext) -> str | None:
|
||||
"""Wert einer Spalte – None, wenn es die Spalte nicht (mehr) gibt."""
|
||||
if schluessel.startswith(FELD_PRAEFIX):
|
||||
rest = schluessel[len(FELD_PRAEFIX):]
|
||||
if not rest.isdigit():
|
||||
return None
|
||||
fd_id = int(rest)
|
||||
if kontext.db.get(FieldDefinition, fd_id) is None:
|
||||
return None # Feld wurde geloescht – Spalte faellt still weg
|
||||
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
|
||||
return (kontext.feldwerte or {}).get(produkt_id, {}).get(fd_id) or ""
|
||||
eintrag = SPALTEN.get(art, {}).get(schluessel)
|
||||
if eintrag is None:
|
||||
return None
|
||||
return eintrag[1](zeile, kontext)
|
||||
|
||||
|
||||
def _titel(schluessel: str, art: str, db: Session) -> str | None:
|
||||
if schluessel.startswith(FELD_PRAEFIX):
|
||||
rest = schluessel[len(FELD_PRAEFIX):]
|
||||
if not rest.isdigit():
|
||||
return None
|
||||
fd = db.get(FieldDefinition, int(rest))
|
||||
if fd is None:
|
||||
return None
|
||||
return f"{fd.label} ({fd.unit})" if fd.unit else fd.label
|
||||
eintrag = SPALTEN.get(art, {}).get(schluessel)
|
||||
return eintrag[0] if eintrag else None
|
||||
|
||||
|
||||
def _schuetzen(wert: str) -> str:
|
||||
"""Formel-Injektion verhindern (=, +, -, @ am Zellenanfang)."""
|
||||
text = "" if wert is None else str(wert)
|
||||
return f"'{text}" if _FORMEL_START.match(text) else text
|
||||
|
||||
|
||||
def csv_erzeugen(db: Session, vorlage: EtikettenVorlage, basis_url: str = "") -> str:
|
||||
"""Die fertige CSV zu einer Vorlage.
|
||||
|
||||
Mit BOM und CRLF, damit Excel und P-touch die Umlaute richtig lesen.
|
||||
Unbekannte Spalten (geloeschtes Feld, geaenderte Bezugsart) fallen still
|
||||
weg, statt den Export zu zerreissen – so wie die Tabellen in der
|
||||
Oberflaeche verschwundene Spalten aussortieren.
|
||||
"""
|
||||
art = vorlage.art if vorlage.art in ARTEN else "items"
|
||||
konf = konfiguration_lesen(vorlage)
|
||||
|
||||
gueltig = [s for s in konf["spalten"] if _titel(s, art, db) is not None]
|
||||
if not gueltig:
|
||||
# Lieber die festen Spalten der Bezugsart als eine leere Datei.
|
||||
gueltig = list(SPALTEN.get(art, {}).keys())
|
||||
|
||||
zeilen = _zeilen(db, art, konf["kategorie_ids"], konf["feldfilter"])
|
||||
kontext = Kontext(db=db, basis_url=basis_url, feldwerte=_feldwerte(db, art, zeilen))
|
||||
|
||||
puffer = io.StringIO()
|
||||
schreiber = csv.writer(
|
||||
puffer, delimiter=konf["trennzeichen"], lineterminator="\r\n",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
)
|
||||
schreiber.writerow([_schuetzen(_titel(s, art, db) or s) for s in gueltig])
|
||||
for zeile in zeilen:
|
||||
schreiber.writerow(
|
||||
[_schuetzen(_zelle(s, zeile, art, kontext) or "") for s in gueltig]
|
||||
)
|
||||
return "" + puffer.getvalue()
|
||||
@@ -40,8 +40,13 @@ def options_list(fd: FieldDefinition) -> list[str]:
|
||||
return [str(o) for o in data] if isinstance(data, list) else []
|
||||
|
||||
|
||||
def _ancestry(db: Session, category_id: int) -> list[Category]:
|
||||
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
|
||||
def ancestry(db: Session, category_id: int) -> list[Category]:
|
||||
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich).
|
||||
|
||||
Auch von ``services/vererbung.py`` benutzt – dort allerdings umgedreht,
|
||||
weil ein einzelner geerbter Wert vom NÄCHSTEN Vorfahren kommen soll,
|
||||
während Felder von allen Vorfahren gesammelt werden.
|
||||
"""
|
||||
chain: list[Category] = []
|
||||
seen: set[int] = set()
|
||||
cid: int | None = category_id
|
||||
@@ -67,7 +72,7 @@ def effective_field_definitions(
|
||||
if category_id is None:
|
||||
return []
|
||||
result: list[tuple[FieldDefinition, bool]] = []
|
||||
for cat in _ancestry(db, category_id):
|
||||
for cat in ancestry(db, category_id):
|
||||
defs = (
|
||||
db.query(FieldDefinition)
|
||||
.filter(FieldDefinition.category_id == cat.id)
|
||||
|
||||
@@ -6,8 +6,8 @@ import secrets
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Item, ItemLoan
|
||||
from ..schemas import ItemOut
|
||||
from ..models import Item, ItemLoan, ItemUsage
|
||||
from ..schemas import ItemKurz, ItemOut, VerwendungOut
|
||||
from .loans import loan_to_out
|
||||
|
||||
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||
@@ -34,8 +34,41 @@ def _offener(item: Item) -> ItemLoan | None:
|
||||
return None
|
||||
|
||||
|
||||
def _offene_verwendung(item: Item) -> ItemUsage | None:
|
||||
"""Der laufende Verwendungszustand aus den schon geladenen Zeitraeumen.
|
||||
|
||||
Wie ``_offener``: ueber die Beziehung statt per eigener Abfrage, damit die
|
||||
Einzelstueck-Liste nicht je Zeile nachfragt. Keine offene Zeile heisst
|
||||
verfuegbar - dafuer gibt es keinen gespeicherten Wert.
|
||||
"""
|
||||
for usage in item.usages:
|
||||
if usage.ended_at is None:
|
||||
return usage
|
||||
return None
|
||||
|
||||
|
||||
def verwendung_to_out(usage: ItemUsage) -> VerwendungOut:
|
||||
"""VerwendungOut samt Konto, das umgestellt hat."""
|
||||
out = VerwendungOut.model_validate(usage)
|
||||
out.username = usage.user.username if usage.user else None
|
||||
return out
|
||||
|
||||
|
||||
def item_kurz(item: Item) -> ItemKurz:
|
||||
"""Ein verknuepftes Stueck in Kurzform (Zubehoer, Geraet)."""
|
||||
return ItemKurz(
|
||||
id=item.id,
|
||||
uid=item.uid,
|
||||
product_id=item.product_id,
|
||||
product_name=item.product.name if item.product else None,
|
||||
product_brand=item.product.brand if item.product else None,
|
||||
location_name=item.location.name if item.location else None,
|
||||
verliehen=_offener(item) is not None,
|
||||
)
|
||||
|
||||
|
||||
def item_to_out(item: Item) -> ItemOut:
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen, Verleih und Zubehoer."""
|
||||
out = ItemOut.model_validate(item)
|
||||
out.location_name = item.location.name if item.location else None
|
||||
out.shop_name = item.shop.name if item.shop else None
|
||||
@@ -46,4 +79,14 @@ def item_to_out(item: Item) -> ItemOut:
|
||||
out.category_name = kategorie.name if kategorie else None
|
||||
loan = _offener(item)
|
||||
out.loan = loan_to_out(loan) if loan is not None else None
|
||||
# Die Kategorie steckt schon oben in ``kategorie`` - die Markierung kostet
|
||||
# deshalb keine zusaetzliche Abfrage (siehe Category.usage_tracking).
|
||||
out.verwendung_moeglich = bool(kategorie.usage_tracking) if kategorie else False
|
||||
verwendung = _offene_verwendung(item)
|
||||
out.verwendung = verwendung_to_out(verwendung) if verwendung is not None else None
|
||||
# Beide Richtungen ausdruecklich setzen: model_validate wuerde die Beziehung
|
||||
# zwar finden, aber nur die Spalten fuellen - Artikelname und Lagerort des
|
||||
# verknuepften Stuecks blieben leer.
|
||||
out.zubehoer = [item_kurz(t) for t in item.zubehoer]
|
||||
out.gehoert_zu = item_kurz(item.gehoert_zu) if item.gehoert_zu else None
|
||||
return out
|
||||
|
||||
@@ -35,7 +35,8 @@ def export_master_data(db: Session) -> dict:
|
||||
return {
|
||||
FORMAT_KEY: FORMAT_VERSION,
|
||||
"categories": [
|
||||
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
|
||||
{"id": c.id, "name": c.name, "parent_id": c.parent_id,
|
||||
"tracking": c.tracking, "usage_tracking": c.usage_tracking}
|
||||
for c in db.query(Category).order_by(Category.id).all()
|
||||
],
|
||||
"field_definitions": [
|
||||
@@ -152,6 +153,7 @@ def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
|
||||
for row in cats:
|
||||
upsert(Category, row, "categories", lambda r: {
|
||||
"name": r["name"], "tracking": r.get("tracking") or "food",
|
||||
"usage_tracking": bool(r.get("usage_tracking", False)),
|
||||
}, is_tree=True)
|
||||
set_parents(Category, cats)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Unscharfer Namensabgleich – aus dem Kassenzettel-Scan, jetzt auch für Rezepte.
|
||||
|
||||
Lag bis dahin in ``routers/products.py``. Hierher gezogen, weil der
|
||||
Rezeptabgleich dieselbe Frage stellt: Welcher Artikel ist mit „Mehl" oder
|
||||
„MÜHLEN SCHNITZ" gemeint? Zwei Kopien derselben Heuristik würden mit der Zeit
|
||||
auseinanderlaufen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import re
|
||||
|
||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
||||
|
||||
|
||||
def normalisieren(text: str) -> str:
|
||||
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
|
||||
text = (text or "").lower().translate(_UMLAUTE)
|
||||
return re.sub(r"[^a-z0-9]+", " ", text).strip()
|
||||
|
||||
|
||||
def match_score(zeile: str, name: str) -> int:
|
||||
"""Ähnlichkeit 0–100 zwischen gesuchtem Text und einem Namen.
|
||||
|
||||
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
|
||||
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen –
|
||||
und damit eine Rezeptzutat „Mehl" den „Weissmehl Typ 550" findet.
|
||||
"""
|
||||
a, b = normalisieren(zeile), normalisieren(name)
|
||||
if not a or not b:
|
||||
return 0
|
||||
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
|
||||
a_tok, b_tok = a.split(), b.split()
|
||||
bester = []
|
||||
for t in a_tok:
|
||||
m = 0.0
|
||||
for u in b_tok:
|
||||
if u.startswith(t) or t.startswith(u):
|
||||
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
|
||||
else:
|
||||
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
|
||||
bester.append(m)
|
||||
token = sum(bester) / len(bester) if bester else 0.0
|
||||
return round(100 * max(gesamt, token))
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte).
|
||||
|
||||
Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist
|
||||
Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still
|
||||
zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen
|
||||
muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine
|
||||
Rezepte gibt oder der Server weg ist.
|
||||
|
||||
Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**.
|
||||
Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen
|
||||
und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar
|
||||
in Mealie unter Profil ▸ API-Tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models import IntegrationSecret, Setting
|
||||
|
||||
# Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle.
|
||||
BASE_URL_KEY = "mealie_base_url"
|
||||
SECRET_SERVICE = "mealie"
|
||||
|
||||
# Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da
|
||||
# zugreift, und man es gezielt widerrufen kann.
|
||||
TOKEN_NAME = "Vorrania"
|
||||
|
||||
CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante
|
||||
REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen
|
||||
|
||||
# Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter
|
||||
# /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden
|
||||
# gleich; der Unterschied zählt erst für die Einkaufslisten.
|
||||
_variante_cache: dict[str, tuple[float, str]] = {}
|
||||
_rezepte_cache: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
# Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten.
|
||||
_transport: httpx.BaseTransport | None = None
|
||||
|
||||
|
||||
class MealieFehler(Exception):
|
||||
"""Mealie hat nicht geantwortet oder abgelehnt.
|
||||
|
||||
``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der
|
||||
Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in
|
||||
keinem der beiden.**
|
||||
"""
|
||||
|
||||
def __init__(self, kennung: str, text: str) -> None:
|
||||
super().__init__(text)
|
||||
self.kennung = kennung
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
"""Nur für Tests und zum erzwungenen Neuladen."""
|
||||
_variante_cache.clear()
|
||||
_rezepte_cache.clear()
|
||||
|
||||
|
||||
def setze_transport(transport: httpx.BaseTransport | None) -> None:
|
||||
"""Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten."""
|
||||
global _transport
|
||||
_transport = transport
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return httpx.Client(
|
||||
timeout=get_settings().mealie_timeout_seconds,
|
||||
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
|
||||
follow_redirects=True,
|
||||
transport=_transport,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Konfiguration
|
||||
# --------------------------------------------------------------------------
|
||||
def basis_adresse(db: Session) -> str:
|
||||
row = db.get(Setting, BASE_URL_KEY)
|
||||
return (row.value if row else "").strip().rstrip("/")
|
||||
|
||||
|
||||
def token(db: Session) -> str:
|
||||
row = db.get(IntegrationSecret, SECRET_SERVICE)
|
||||
return row.secret if row else ""
|
||||
|
||||
|
||||
def eingerichtet(db: Session) -> bool:
|
||||
return bool(basis_adresse(db)) and bool(token(db))
|
||||
|
||||
|
||||
def _zugang(db: Session) -> tuple[str, str]:
|
||||
basis, tok = basis_adresse(db), token(db)
|
||||
if not basis or not tok:
|
||||
raise MealieFehler(
|
||||
"nicht_eingerichtet",
|
||||
"Für Mealie sind noch keine Zugangsdaten hinterlegt.",
|
||||
)
|
||||
return basis, tok
|
||||
|
||||
|
||||
def speichere_token(db: Session, wert: str) -> None:
|
||||
row = db.get(IntegrationSecret, SECRET_SERVICE)
|
||||
if row is None:
|
||||
db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert))
|
||||
else:
|
||||
row.secret = wert
|
||||
db.commit()
|
||||
cache_leeren()
|
||||
|
||||
|
||||
def loesche_zugang(db: Session) -> None:
|
||||
row = db.get(IntegrationSecret, SECRET_SERVICE)
|
||||
if row is not None:
|
||||
db.delete(row)
|
||||
adresse = db.get(Setting, BASE_URL_KEY)
|
||||
if adresse is not None:
|
||||
db.delete(adresse)
|
||||
db.commit()
|
||||
cache_leeren()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Einrichten: Passwort einmal, danach nur noch das Dauertoken
|
||||
# --------------------------------------------------------------------------
|
||||
def hole_dauertoken(base_url: str, username: str, password: str) -> str:
|
||||
"""Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben.
|
||||
|
||||
1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder
|
||||
liefert ein kurzlebiges Sitzungstoken.
|
||||
2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre),
|
||||
das genau einmal im Klartext in der Antwort steht.
|
||||
|
||||
Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert,
|
||||
in keiner Fehlermeldung und in keiner Protokollzeile.
|
||||
"""
|
||||
basis = base_url.strip().rstrip("/")
|
||||
if not basis:
|
||||
raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.")
|
||||
|
||||
with _client() as client:
|
||||
try:
|
||||
antwort = client.post(
|
||||
f"{basis}/api/auth/token",
|
||||
data={"username": username, "password": password},
|
||||
)
|
||||
except httpx.HTTPError as fehler:
|
||||
raise MealieFehler(
|
||||
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
|
||||
) from fehler
|
||||
if antwort.status_code in (401, 403):
|
||||
raise MealieFehler(
|
||||
"anmeldung_fehlgeschlagen",
|
||||
"Benutzername oder Passwort stimmen bei Mealie nicht.",
|
||||
)
|
||||
if antwort.status_code >= 400:
|
||||
raise MealieFehler(
|
||||
"abgelehnt",
|
||||
f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).",
|
||||
)
|
||||
try:
|
||||
sitzung = antwort.json().get("access_token")
|
||||
except ValueError:
|
||||
sitzung = None
|
||||
if not sitzung:
|
||||
raise MealieFehler(
|
||||
"abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben."
|
||||
)
|
||||
|
||||
try:
|
||||
erstellt = client.post(
|
||||
f"{basis}/api/users/api-tokens",
|
||||
json={"name": TOKEN_NAME, "integration_id": "vorrania"},
|
||||
headers={"Authorization": f"Bearer {sitzung}"},
|
||||
)
|
||||
except httpx.HTTPError as fehler:
|
||||
raise MealieFehler(
|
||||
"nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}"
|
||||
) from fehler
|
||||
if erstellt.status_code >= 400:
|
||||
raise MealieFehler(
|
||||
"kein_dauertoken",
|
||||
f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).",
|
||||
)
|
||||
try:
|
||||
dauertoken = erstellt.json().get("token")
|
||||
except ValueError:
|
||||
dauertoken = None
|
||||
if not dauertoken:
|
||||
raise MealieFehler(
|
||||
"kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben."
|
||||
)
|
||||
return dauertoken
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Abfragen
|
||||
# --------------------------------------------------------------------------
|
||||
def _hole(db: Session, pfad: str) -> dict:
|
||||
basis, tok = _zugang(db)
|
||||
with _client() as client:
|
||||
try:
|
||||
antwort = client.get(
|
||||
f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"}
|
||||
)
|
||||
except httpx.HTTPError as fehler:
|
||||
raise MealieFehler(
|
||||
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
|
||||
) from fehler
|
||||
if antwort.status_code in (401, 403):
|
||||
raise MealieFehler(
|
||||
"token_fehlt",
|
||||
"Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.",
|
||||
)
|
||||
if antwort.status_code == 404:
|
||||
raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.")
|
||||
if antwort.status_code >= 400:
|
||||
raise MealieFehler(
|
||||
"abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}."
|
||||
)
|
||||
try:
|
||||
return antwort.json()
|
||||
except ValueError as fehler:
|
||||
raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler
|
||||
|
||||
|
||||
def pfadvariante(db: Session) -> str:
|
||||
"""„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse.
|
||||
|
||||
Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst
|
||||
für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher
|
||||
Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer —
|
||||
die melden Installationen uneinheitlich.
|
||||
"""
|
||||
basis = basis_adresse(db)
|
||||
gepuffert = _variante_cache.get(basis)
|
||||
if gepuffert and gepuffert[0] > time.monotonic():
|
||||
return gepuffert[1]
|
||||
variante = "households"
|
||||
try:
|
||||
_hole(db, "/api/households/shopping/lists?perPage=1")
|
||||
except MealieFehler as fehler:
|
||||
if fehler.kennung == "nicht_gefunden":
|
||||
variante = "groups"
|
||||
elif fehler.kennung != "abgelehnt":
|
||||
raise
|
||||
_variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante)
|
||||
return variante
|
||||
|
||||
|
||||
def version(db: Session) -> str | None:
|
||||
"""Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler."""
|
||||
try:
|
||||
return str(_hole(db, "/api/app/about").get("version") or "") or None
|
||||
except MealieFehler:
|
||||
return None
|
||||
|
||||
|
||||
def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict:
|
||||
"""Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt."""
|
||||
pfad = f"/api/recipes?page={seite}&perPage={pro_seite}"
|
||||
if suche:
|
||||
pfad += f"&search={httpx.QueryParams({'s': suche})['s']}"
|
||||
schluessel = f"{basis_adresse(db)}|{pfad}"
|
||||
gepuffert = _rezepte_cache.get(schluessel)
|
||||
if gepuffert and gepuffert[0] > time.monotonic():
|
||||
return gepuffert[1]
|
||||
daten = _hole(db, pfad)
|
||||
_rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten)
|
||||
return daten
|
||||
|
||||
|
||||
def rezept(db: Session, slug: str) -> dict:
|
||||
"""Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert –
|
||||
es wird gezielt geöffnet und soll dann aktuell sein."""
|
||||
return _hole(db, f"/api/recipes/{slug}")
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Rezeptzutaten gegen den Bestand prüfen: Kann ich das kochen?
|
||||
|
||||
Bewusst **ohne HTTP** – die Zutaten kommen als Wörterbücher herein, so wie
|
||||
Mealie sie liefert. Dadurch lässt sich der ganze Abgleich mit handgeschriebenen
|
||||
Zutaten prüfen, ohne eine Mealie-Instanz zu brauchen; das Netz steckt allein in
|
||||
``services/mealie.py``.
|
||||
|
||||
Je Zutat beantwortet das hier drei Fragen nacheinander:
|
||||
1. **Welcher Artikel ist gemeint?** Erst eine gemerkte Zuordnung, sonst unscharf
|
||||
über Namen – gegen Gruppen und Artikel, denn „Milch" ist markenübergreifend
|
||||
gemeint und das ist genau, wofür es Gruppen gibt.
|
||||
2. **Wie viel ist das in Gramm/Milliliter/Stück?** Über die Einheiten und die
|
||||
Hausmaße der Kategorie („2 EL Mehl").
|
||||
3. **Reicht der Bestand?**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Category, CategoryTracking, Group, Product
|
||||
from .conversion import (
|
||||
BASE_OF_KIND,
|
||||
ConversionError,
|
||||
article_unit,
|
||||
find_unit,
|
||||
group_min_context,
|
||||
kind_of_product,
|
||||
to_base,
|
||||
zweit_faktor,
|
||||
)
|
||||
from .matching import match_score, normalisieren
|
||||
from .stock import current_stock, summe_bestand_gewichtet
|
||||
|
||||
# Gesperrt wird über den Wortanker (siehe ``_hat_anker``), nicht über eine
|
||||
# Punktzahl. Eine Ähnlichkeitszahl ist als Sperre untauglich: „Vollmilch
|
||||
# Zimmertemperatur" gegen „H Vollmilch" landet nur bei etwa 40 Punkten, weil das
|
||||
# lange Beiwort den Durchschnitt zieht – obwohl es offensichtlich dasselbe ist.
|
||||
# Umgekehrt kam „Mehl" auf über 60 gegen „Mühlen Geschnetzeltes". Die Punkte
|
||||
# entscheiden deshalb nur noch die Reihenfolge unter den Kandidaten, die den
|
||||
# Anker schon bestanden haben.
|
||||
|
||||
|
||||
@dataclass
|
||||
class Kandidat:
|
||||
art: str # "product" | "group"
|
||||
id: int
|
||||
name: str
|
||||
score: int
|
||||
# Laesst sich die Rezept-Einheit fuer diesen Kandidaten umrechnen? Passt sie
|
||||
# nicht, ist es meist das falsche Produkt (siehe ``_einheit_passt``).
|
||||
einheit_passt: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZutatPruefung:
|
||||
"""Eine Zutat des Rezepts, auf den Bestand bezogen."""
|
||||
|
||||
text: str # wie das Rezept es schreibt
|
||||
menge: float | None = None # skalierte Menge in der Rezept-Einheit
|
||||
einheit: str = ""
|
||||
# Der reine Lebensmittelname aus Mealie – Schluessel fuer die gemerkte
|
||||
# Zuordnung von Hand (``services/zuordnung.py``).
|
||||
food: str = ""
|
||||
# True = die Zuordnung kommt aus einer gemerkten Entscheidung, nicht vom Raten.
|
||||
manuell: bool = False
|
||||
# Zuordnung
|
||||
art: str | None = None # "product" | "group"
|
||||
ziel_id: int | None = None
|
||||
ziel_name: str = ""
|
||||
kandidaten: list[Kandidat] = field(default_factory=list)
|
||||
# Mengen in Basiseinheiten
|
||||
benoetigt_base: float | None = None
|
||||
bestand_base: float | None = None
|
||||
fehlt_base: float | None = None
|
||||
basis_einheit: str = ""
|
||||
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
|
||||
status: str = "kein_treffer"
|
||||
hinweis: str = ""
|
||||
# Für „Maß jetzt anlegen": welches Maß fehlt und für welche Kategorie.
|
||||
fehlendes_mass: str = ""
|
||||
kategorie_id: int | None = None
|
||||
|
||||
|
||||
def _zutat_text(zutat: dict) -> str:
|
||||
"""Wie das Rezept die Zutat schreibt – für die Anzeige."""
|
||||
for schluessel in ("display", "originalText", "original_text", "note"):
|
||||
wert = zutat.get(schluessel)
|
||||
if wert:
|
||||
return str(wert)
|
||||
essen = zutat.get("food") or {}
|
||||
return str(essen.get("name") or "Zutat")
|
||||
|
||||
|
||||
def _lebensmittel(zutat: dict) -> str:
|
||||
essen = zutat.get("food") or {}
|
||||
name = essen.get("name") or ""
|
||||
if name:
|
||||
return str(name)
|
||||
# Ohne verknüpftes Lebensmittel bleibt nur der freie Text („2 Eier").
|
||||
return str(zutat.get("note") or zutat.get("display") or "")
|
||||
|
||||
|
||||
def _einheit(zutat: dict) -> str:
|
||||
einheit = zutat.get("unit") or {}
|
||||
if not isinstance(einheit, dict):
|
||||
return ""
|
||||
return str(einheit.get("abbreviation") or einheit.get("name") or "")
|
||||
|
||||
|
||||
def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, str]:
|
||||
"""Skalierungsfaktor und, falls nötig, ein Hinweis warum nicht skaliert wird."""
|
||||
if not portionen or portionen <= 0:
|
||||
return 1.0, ""
|
||||
basis = rezept.get("recipeServings") or rezept.get("recipe_servings") or 0
|
||||
if not basis:
|
||||
basis = rezept.get("recipeYieldQuantity") or rezept.get("recipe_yield_quantity") or 0
|
||||
try:
|
||||
basis = float(basis)
|
||||
except (TypeError, ValueError):
|
||||
basis = 0.0
|
||||
if basis <= 0:
|
||||
return 1.0, (
|
||||
"Das Rezept nennt keine Portionszahl – die Mengen gelten deshalb "
|
||||
"unverändert."
|
||||
)
|
||||
return portionen / basis, ""
|
||||
|
||||
|
||||
# Umlaut-Reste zusammenziehen, damit „Apfel" auch „Äpfel" (= „aepfel") findet.
|
||||
# Nur fuer den Wortvergleich, nie fuer die Anzeige.
|
||||
_UMLAUT_REST = str.maketrans({"a": "a"})
|
||||
|
||||
|
||||
def _stamm(text: str) -> str:
|
||||
roh = normalisieren(text)
|
||||
return roh.replace("ae", "a").replace("oe", "o").replace("ue", "u")
|
||||
|
||||
|
||||
# Wörter, die eine Zutat BESCHREIBEN, aber nicht benennen. Sie dürfen allein
|
||||
# keinen Treffer begründen, sonst passt jedes vegane Produkt auf jede vegane
|
||||
# Zutat: „vegane Butter" landete so auf „veganes Mühlen Knusper Bleu", und
|
||||
# „veganer Lime Curd" auf „Vegane Mühlen-Schnitzel". Geteilt wird dort nur die
|
||||
# Eigenschaft, nicht die Sache.
|
||||
#
|
||||
# Verglichen wird gegen die zusammengezogene Form aus ``_stamm`` (ae/oe/ue schon
|
||||
# aufgelöst) - die Listen unten stehen deshalb ebenfalls in dieser Form.
|
||||
#
|
||||
# Zwei Toepfe, weil es zwei Sorten Wort sind. Die Trennung ist nicht Kosmetik:
|
||||
# Als EINE Liste mit Praefix-Vergleich hat sie ganze Artikelnamen verschluckt.
|
||||
# 'Frischkaese' begann mit 'frisch', galt damit als blosse Eigenschaft und fiel
|
||||
# aus dem Vergleich - die Zutat 'Kaese' fand den Frischkaese deshalb NIE. Ebenso
|
||||
# 'Biomilch' (bio), 'Rohrzucker' (roh), 'Kleingebaeck' (klein), 'Ganzkornbrot'
|
||||
# (ganz). Der Fehler war unsichtbar: kein falscher Treffer, nur ausbleibende
|
||||
# richtige.
|
||||
#
|
||||
# 1. Stammformen, die in JEDER Beugung gemeint sind. Praefix-Vergleich, weil
|
||||
# Deutsch beugt: 'getrock' deckt getrocknet/getrocknete/getrocknetes ab.
|
||||
# Diese Stuecke fangen keine Komposita an, die ein Lebensmittel benennen.
|
||||
_EIGENSCHAFT_STAMM = (
|
||||
"vegan", "vegetar", "biologisch",
|
||||
"gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert",
|
||||
"getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt",
|
||||
"gemahl", "gepresst", "zimmertemperatur", "lauwarm", "gehauft", "gestrich",
|
||||
"optional",
|
||||
)
|
||||
|
||||
# 2. Kurze Alltagswoerter, die AUCH am Anfang echter Namen stehen. Hier zaehlt
|
||||
# nur das Wort selbst, hoechstens mit einer Beugungsendung dran: 'frische'
|
||||
# ja, 'frischkase' nein.
|
||||
_EIGENSCHAFT_WORT = (
|
||||
"bio", "frisch", "roh", "klein", "gross", "mittel", "fein", "grob", "ganz",
|
||||
"oder", "sowie", "etwa", "circa", "nach", "ohne", "evtl",
|
||||
"block", "stueck", "stuck",
|
||||
)
|
||||
|
||||
# Deutsche Beugungsendungen. Was laenger ist, ist keine Endung mehr, sondern ein
|
||||
# zweites Wort - und genau das soll den Namen retten.
|
||||
_BEUGUNG = ("", "e", "er", "es", "en", "em", "ene", "enes", "ener", "enen")
|
||||
|
||||
|
||||
def _nur_eigenschaft(wort: str) -> bool:
|
||||
if wort.isdigit() or wort.startswith(_EIGENSCHAFT_STAMM):
|
||||
return True
|
||||
return any(
|
||||
wort.startswith(w) and wort[len(w):] in _BEUGUNG
|
||||
for w in _EIGENSCHAFT_WORT
|
||||
)
|
||||
|
||||
|
||||
def _woerter(text: str) -> list[str]:
|
||||
"""Bedeutungstragende Wörter einer Zutat.
|
||||
|
||||
Weg fallen sehr kurze („1 TL"), reine Zahlen und alles, was die Zutat nur
|
||||
beschreibt (siehe ``_nur_eigenschaft``). Übrig bleibt, was sie IST: „Mehl",
|
||||
„Apfel", „Butter", „Joghurt".
|
||||
"""
|
||||
return [
|
||||
w for w in _stamm(text).split()
|
||||
if len(w) >= 4 and not _nur_eigenschaft(w)
|
||||
]
|
||||
|
||||
|
||||
def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool:
|
||||
"""Teilen Zutat und Kandidat ein echtes Wort?
|
||||
|
||||
Das ist die eigentliche Sperre. Reine Ähnlichkeitswerte halten „Eier" und
|
||||
„Reis" oder „Mehl" und „Mühlen Geschnetzeltes" für verwandt, weil sich
|
||||
Buchstaben überlappen – für einen Rezeptabgleich ist das unbrauchbar.
|
||||
Verlangt wird deshalb, dass ein ganzes Wort im anderen Namen steckt:
|
||||
„Mehl" in „Weissmehl Typ 550" ja, „Mehl" in „Mühlen Geschnetzeltes" nein.
|
||||
"""
|
||||
ziel = _stamm(kandidat)
|
||||
if not ziel:
|
||||
return False
|
||||
ziel_woerter = [
|
||||
w for w in ziel.split() if len(w) >= 4 and not _nur_eigenschaft(w)
|
||||
]
|
||||
# Auch der Teilstring-Vergleich darf nicht über eine Eigenschaft laufen:
|
||||
# „vegan" steckt sonst weiter in „veganes Mühlen Knusper Bleu".
|
||||
ziel_ohne = " ".join(ziel_woerter)
|
||||
for wort in zutat_woerter:
|
||||
if wort in ziel_ohne:
|
||||
return True
|
||||
# Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch".
|
||||
for zw in ziel_woerter:
|
||||
if zw in wort:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]:
|
||||
"""Kategorien, deren Name ein Wort mit der Zutat teilt – samt Unterkategorien.
|
||||
|
||||
Damit wird zuerst dort gesucht, wo es hingehört: „Mehl Typ 405" in „Mehl &
|
||||
Zucker", nicht im ganzen Katalog. Erst wenn dort nichts passt, zählt der
|
||||
Rest.
|
||||
"""
|
||||
from ..routers.categories import descendant_ids
|
||||
|
||||
passende: set[int] = set()
|
||||
for kategorie in db.query(Category).all():
|
||||
if _hat_anker(zutat_woerter, kategorie.name):
|
||||
passende |= descendant_ids(db, kategorie.id)
|
||||
return passende
|
||||
|
||||
|
||||
def _einheit_passt(db: Session, produkt: Product, token: str) -> bool:
|
||||
"""Lässt sich die Rezept-Einheit für diesen Artikel überhaupt umrechnen?
|
||||
|
||||
Das ist ein überraschend gutes Zeichen für „falsches Produkt": Sagt das
|
||||
Rezept Milliliter und der Kandidat wird in Gramm geführt, ist es meist
|
||||
etwas anderes – „200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen".
|
||||
Eine Zweiteinheit am Artikel („3 Stück ≙ 250 g") gilt weiterhin als
|
||||
passend, dafür ist sie da.
|
||||
|
||||
Entscheidend ist, WARUM eine Umrechnung scheitert – nur einer der Gründe
|
||||
sagt etwas über den Artikel:
|
||||
|
||||
* **Bekannte Einheit falscher Art, ohne Brücke** → spricht gegen den
|
||||
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
|
||||
* **Unbekanntes Wort oder nicht hinterlegtes Hausmaß** („1 Tasse Linsen")
|
||||
→ sagt gar nichts. Der Artikel kann völlig richtig sein, es fehlt nur die
|
||||
Maßangabe – die lässt sich nachtragen.
|
||||
* **Keine Einheit** (etwa „3 Eier") → passt immer.
|
||||
"""
|
||||
if not token:
|
||||
return True
|
||||
unit = find_unit(db, token)
|
||||
if unit is None:
|
||||
# Unbekannt oder Hausmass: kein Urteil ueber den Artikel.
|
||||
return True
|
||||
if unit.kind == kind_of_product(produkt):
|
||||
return True
|
||||
return zweit_faktor(produkt, BASE_OF_KIND[unit.kind]) is not None
|
||||
|
||||
|
||||
def _kandidaten(db: Session, name: str, einheit: str = "") -> list[Kandidat]:
|
||||
"""Artikel und Gruppen, die zur Zutat passen können – bester zuerst.
|
||||
|
||||
Drei Stufen, in dieser Reihenfolge:
|
||||
|
||||
1. **Nur Lebensmittel.** Gegenstände bleiben außen vor, wie beim
|
||||
Kassenzettel-Abgleich. Ein Joystick ist kein Zucker, und eine
|
||||
Waffelplatte kein Apfel.
|
||||
2. **Wortanker.** Ohne ein geteiltes ganzes Wort gibt es keinen Treffer,
|
||||
egal wie hoch die Buchstabenähnlichkeit wäre.
|
||||
3. **Kategorie zuerst.** Passt der Zutatenname zu einer Kategorie, gilt nur
|
||||
was darin liegt – sonst der ganze (gefilterte) Katalog.
|
||||
"""
|
||||
woerter = _woerter(name)
|
||||
if not woerter:
|
||||
return []
|
||||
|
||||
# 1. Nur Lebensmittel, und keine Einzelstücke.
|
||||
from ..crud import product_tracking
|
||||
|
||||
lebensmittel = [
|
||||
p for p in db.query(Product).all()
|
||||
if not p.individual and product_tracking(db, p) != CategoryTracking.object.value
|
||||
]
|
||||
|
||||
# 3. Kategorie zuerst – aber nur, wenn darin auch etwas mit Anker liegt.
|
||||
kategorien = _kategorien_zur_zutat(db, woerter)
|
||||
if kategorien:
|
||||
in_kategorie = [
|
||||
p for p in lebensmittel
|
||||
if p.category_id in kategorien and _hat_anker(woerter, f"{p.name} {p.brand or ''}")
|
||||
]
|
||||
if in_kategorie:
|
||||
lebensmittel = in_kategorie
|
||||
|
||||
treffer: list[Kandidat] = []
|
||||
for gruppe in db.query(Group).all():
|
||||
if _hat_anker(woerter, gruppe.name):
|
||||
treffer.append(
|
||||
Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name))
|
||||
)
|
||||
for produkt in lebensmittel:
|
||||
voll = f"{produkt.name} {produkt.brand or ''}".strip()
|
||||
if _hat_anker(woerter, voll):
|
||||
k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll))
|
||||
k.einheit_passt = _einheit_passt(db, produkt, einheit)
|
||||
treffer.append(k)
|
||||
|
||||
# Reihenfolge: passende Einheit zuerst, dann Punkte; Gruppen bei Gleichstand
|
||||
# vor Artikeln, weil „Milch" markenuebergreifend gemeint ist.
|
||||
treffer.sort(key=lambda k: (
|
||||
0 if k.einheit_passt else 1, -k.score, 0 if k.art == "group" else 1, k.name,
|
||||
))
|
||||
return treffer[:5]
|
||||
|
||||
|
||||
def _bestand(db: Session, art: str, ziel_id: int) -> tuple[float, str]:
|
||||
"""Bestand in Basiseinheiten plus die Bezeichnung der Basiseinheit."""
|
||||
if art == "product":
|
||||
produkt = db.get(Product, ziel_id)
|
||||
if produkt is None:
|
||||
return 0.0, ""
|
||||
return current_stock(db, produkt.id), produkt.base_unit.value
|
||||
gruppe = db.get(Group, ziel_id)
|
||||
if gruppe is None:
|
||||
return 0.0, ""
|
||||
ctx = group_min_context(gruppe)
|
||||
menge = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
|
||||
return menge, (ctx.base_unit.value if ctx.base_unit else "")
|
||||
|
||||
|
||||
def _referenzartikel(db: Session, art: str, ziel_id: int) -> Product | None:
|
||||
"""Der Artikel, dessen Kategorie die Hausmaße liefert.
|
||||
|
||||
Bei einer Gruppe der erste zugehörige Artikel – „1 Tasse Milch" hängt an der
|
||||
Kategorie der Milch, nicht an der Gruppe selbst.
|
||||
"""
|
||||
if art == "product":
|
||||
return db.get(Product, ziel_id)
|
||||
gruppe = db.get(Group, ziel_id)
|
||||
if gruppe is None:
|
||||
return None
|
||||
ctx = group_min_context(gruppe)
|
||||
return ctx.matching[0] if ctx.matching else None
|
||||
|
||||
|
||||
def pruefe_zutaten(
|
||||
db: Session,
|
||||
zutaten: list[dict],
|
||||
*,
|
||||
faktor: float = 1.0,
|
||||
zuordnung: dict[str, tuple[str, int]] | None = None,
|
||||
) -> list[ZutatPruefung]:
|
||||
"""Alle Zutaten eines Rezepts prüfen.
|
||||
|
||||
``zuordnung`` sind gemerkte Entscheidungen („Mealie-Lebensmittel X ist bei
|
||||
uns Gruppe Y"), Schlüssel ist der normalisierte Name.
|
||||
"""
|
||||
zuordnung = zuordnung or {}
|
||||
ergebnis: list[ZutatPruefung] = []
|
||||
|
||||
for zutat in zutaten:
|
||||
pruefung = ZutatPruefung(text=_zutat_text(zutat))
|
||||
name = _lebensmittel(zutat)
|
||||
pruefung.food = name
|
||||
pruefung.einheit = _einheit(zutat)
|
||||
|
||||
rohmenge = zutat.get("quantity")
|
||||
if zutat.get("disableAmount") or zutat.get("disable_amount") or not rohmenge:
|
||||
pruefung.menge = None
|
||||
else:
|
||||
try:
|
||||
pruefung.menge = float(rohmenge) * faktor
|
||||
except (TypeError, ValueError):
|
||||
pruefung.menge = None
|
||||
|
||||
# 1. Zuordnen
|
||||
gemerkt = zuordnung.get(normalisieren(name))
|
||||
if gemerkt is not None:
|
||||
pruefung.art, pruefung.ziel_id = gemerkt
|
||||
pruefung.manuell = True
|
||||
else:
|
||||
pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
|
||||
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
|
||||
# Passt die Einheit beim besten Kandidaten nicht, wird NICHT
|
||||
# zugeordnet: Sagt das Rezept Milliliter und der Artikel wird in
|
||||
# Gramm gefuehrt, ist es meist etwas anderes. Lieber offen lassen
|
||||
# und von Hand zuordnen lassen, als etwas Falsches behaupten.
|
||||
if bester is not None and bester.einheit_passt:
|
||||
pruefung.art, pruefung.ziel_id = bester.art, bester.id
|
||||
elif bester is not None:
|
||||
pruefung.hinweis = (
|
||||
f"Nichts Passendes gefunden: '{bester.name}' würde zum Namen "
|
||||
f"passen, wird aber nicht in '{pruefung.einheit}' geführt."
|
||||
)
|
||||
|
||||
if pruefung.ziel_id is None:
|
||||
pruefung.status = "kein_treffer"
|
||||
if not pruefung.hinweis:
|
||||
pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet."
|
||||
ergebnis.append(pruefung)
|
||||
continue
|
||||
|
||||
referenz = _referenzartikel(db, pruefung.art or "product", pruefung.ziel_id)
|
||||
pruefung.ziel_name = (
|
||||
referenz.name if pruefung.art == "product" and referenz is not None
|
||||
else (db.get(Group, pruefung.ziel_id).name if pruefung.art == "group" else "")
|
||||
)
|
||||
if referenz is not None:
|
||||
pruefung.kategorie_id = referenz.category_id
|
||||
|
||||
bestand, basis = _bestand(db, pruefung.art or "product", pruefung.ziel_id)
|
||||
pruefung.bestand_base = bestand
|
||||
pruefung.basis_einheit = basis
|
||||
|
||||
# 2. Menge umrechnen
|
||||
if pruefung.menge is None:
|
||||
pruefung.status = "menge_unbekannt"
|
||||
pruefung.hinweis = "Das Rezept nennt keine Menge."
|
||||
elif referenz is None:
|
||||
pruefung.status = "menge_unbekannt"
|
||||
pruefung.hinweis = "Zu dieser Gruppe gibt es noch keinen Artikel."
|
||||
else:
|
||||
try:
|
||||
pruefung.benoetigt_base = to_base(
|
||||
db, referenz, pruefung.menge,
|
||||
pruefung.einheit or referenz.base_unit.value,
|
||||
hausmasse=True,
|
||||
)
|
||||
except ConversionError as fehler:
|
||||
pruefung.status = "menge_unbekannt"
|
||||
pruefung.hinweis = str(fehler)
|
||||
if "kein Maß" in str(fehler):
|
||||
pruefung.fehlendes_mass = pruefung.einheit
|
||||
ergebnis.append(pruefung)
|
||||
continue
|
||||
|
||||
# 3. Vergleichen
|
||||
if pruefung.benoetigt_base is not None:
|
||||
fehlt = pruefung.benoetigt_base - (pruefung.bestand_base or 0.0)
|
||||
pruefung.fehlt_base = max(0.0, fehlt)
|
||||
if fehlt <= 1e-9:
|
||||
pruefung.status = "vorhanden"
|
||||
elif (pruefung.bestand_base or 0.0) > 1e-9:
|
||||
pruefung.status = "zu_wenig"
|
||||
else:
|
||||
pruefung.status = "fehlt"
|
||||
elif pruefung.status == "menge_unbekannt" and (pruefung.bestand_base or 0.0) <= 1e-9:
|
||||
# Ohne Menge lässt sich nur sagen, ob überhaupt etwas da ist.
|
||||
pruefung.status = "fehlt"
|
||||
pruefung.hinweis = (pruefung.hinweis + " Vorrätig ist davon nichts.").strip()
|
||||
|
||||
ergebnis.append(pruefung)
|
||||
|
||||
return ergebnis
|
||||
|
||||
|
||||
def kochbar(pruefungen: list[ZutatPruefung]) -> bool:
|
||||
"""Reicht alles? Unklare Mengen zählen bewusst als „nicht sicher"."""
|
||||
return all(p.status == "vorhanden" for p in pruefungen)
|
||||
|
||||
|
||||
def artikeleinheiten(db: Session, pruefung: ZutatPruefung) -> tuple[float, str]:
|
||||
"""Fehlmenge als zählbares Gebinde („2 Packungen") statt in Gramm."""
|
||||
if pruefung.fehlt_base is None or pruefung.art != "product" or pruefung.ziel_id is None:
|
||||
return 0.0, ""
|
||||
produkt = db.get(Product, pruefung.ziel_id)
|
||||
if produkt is None:
|
||||
return 0.0, ""
|
||||
faktor, label = article_unit(produkt)
|
||||
return (pruefung.fehlt_base / (faktor or 1.0)), label
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy import asc, func
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -18,7 +18,7 @@ from ..models import (
|
||||
Product,
|
||||
User,
|
||||
)
|
||||
from .conversion import to_base
|
||||
from .conversion import article_unit, to_base
|
||||
from .dates import clean_precision, normalize_best_before
|
||||
from .loans import nicht_verliehen
|
||||
|
||||
@@ -414,10 +414,17 @@ def consolidate_lot_group(
|
||||
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
|
||||
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
|
||||
erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch.
|
||||
|
||||
**Angebrochene Chargen sind davon ausgenommen und werden NIE zusammengefasst.**
|
||||
Eine offene Packung mit eigenem Verbrauchsdatum ist etwas anderes als eine
|
||||
ungeöffnete mit demselben MHD; ohne diese Ausnahme würde die offene Packung
|
||||
beim nächsten Einlagern am selben Ort still einkassiert und ihr kürzeres
|
||||
Verbrauchsdatum ginge verloren. Bitte beim nächsten Umbau hier stehen lassen.
|
||||
"""
|
||||
query = db.query(Lot).filter(
|
||||
Lot.product_id == product_id,
|
||||
Lot.best_before_precision == best_before_precision,
|
||||
Lot.opened_on.is_(None),
|
||||
)
|
||||
query = (
|
||||
query.filter(Lot.best_before.is_(None))
|
||||
@@ -452,6 +459,8 @@ def consolidate_duplicate_lots(db: Session) -> int:
|
||||
db.query(
|
||||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||||
)
|
||||
# Angebrochene bleiben ausgenommen, siehe consolidate_lot_group.
|
||||
.filter(Lot.opened_on.is_(None))
|
||||
.group_by(
|
||||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
||||
)
|
||||
@@ -467,6 +476,7 @@ def consolidate_duplicate_lots(db: Session) -> int:
|
||||
Lot.best_before_precision == precision,
|
||||
Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before,
|
||||
Lot.location_id.is_(None) if location_id is None else Lot.location_id == location_id,
|
||||
Lot.opened_on.is_(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
@@ -487,7 +497,8 @@ def check_out_lot(
|
||||
note: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
|
||||
needed = to_base(db, product, quantity, unit)
|
||||
# Hausmasse sind beim Auslagern erlaubt („2 EL"), beim Einlagern nicht.
|
||||
needed = to_base(db, product, quantity, unit, hausmasse=True)
|
||||
if needed > lot.quantity + 1e-9:
|
||||
raise StockError(
|
||||
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
|
||||
@@ -512,19 +523,143 @@ def check_out_lot(
|
||||
return affected
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Anbrechen und Teilverbrauch
|
||||
# --------------------------------------------------------------------------
|
||||
def abspalten(
|
||||
db: Session,
|
||||
lot: Lot,
|
||||
quantity: float,
|
||||
location_id: str | None,
|
||||
*,
|
||||
gleicher_ort_erlaubt: bool = False,
|
||||
) -> Lot:
|
||||
"""``quantity`` Basiseinheiten als neue Charge mit gleichem MHD abzweigen.
|
||||
|
||||
Reine Umbuchung, keine Bewegung – der Gesamtbestand ändert sich nicht. Gibt
|
||||
die neue Charge zurück (noch nicht konsolidiert; das entscheidet der
|
||||
Aufrufer, denn beim Anbrechen darf gerade NICHT konsolidiert werden).
|
||||
|
||||
``gleicher_ort_erlaubt`` ist der Unterschied zwischen den beiden Anlässen:
|
||||
Beim Aufteilen auf einen anderen Lagerort wäre derselbe Ort sinnlos, beim
|
||||
Anbrechen ist er der Normalfall – die offene Packung bleibt dort liegen,
|
||||
wo sie war.
|
||||
"""
|
||||
if quantity <= 0:
|
||||
raise StockError("Menge muss größer als 0 sein")
|
||||
if quantity > lot.quantity - 1e-9:
|
||||
raise StockError(
|
||||
f"So viel enthält die Charge nicht (vorhanden: {lot.quantity:g})."
|
||||
)
|
||||
if not gleicher_ort_erlaubt and (location_id or None) == (lot.location_id or None):
|
||||
raise StockError("Bitte einen anderen Lagerort als den aktuellen wählen.")
|
||||
|
||||
lot.quantity -= quantity
|
||||
neu = Lot(
|
||||
product_id=lot.product_id,
|
||||
quantity=quantity,
|
||||
best_before=lot.best_before,
|
||||
best_before_precision=lot.best_before_precision,
|
||||
location_id=location_id,
|
||||
)
|
||||
db.add(neu)
|
||||
db.flush()
|
||||
return neu
|
||||
|
||||
|
||||
def anbrechen(db: Session, product: Product, lot: Lot, quantity: float | None = None) -> Lot:
|
||||
"""Eine Packung der Charge anbrechen und als eigene Charge führen.
|
||||
|
||||
Aus 4000 ml (4 × 1 l) wird eine offene Charge mit 1000 ml und eine
|
||||
ungeöffnete mit 3000 ml. Ohne das Abspalten würden die restlichen 3 l das
|
||||
kurze Verbrauchsdatum der geöffneten Packung mittragen, obwohl sie noch zu
|
||||
sind.
|
||||
|
||||
``quantity`` leer = eine Artikeleinheit (Packung, sonst Anzeigeeinheit);
|
||||
ist die Charge kleiner, wird sie als Ganzes angebrochen.
|
||||
"""
|
||||
from .vererbung import oeffnungsfrist
|
||||
|
||||
if lot.opened_on is not None:
|
||||
return lot # schon offen – nichts zu tun
|
||||
|
||||
menge = quantity
|
||||
if menge is None:
|
||||
faktor, _label = article_unit(product)
|
||||
menge = faktor or lot.quantity
|
||||
menge = min(menge, lot.quantity)
|
||||
|
||||
ziel = lot if menge >= lot.quantity - 1e-9 else abspalten(
|
||||
db, lot, menge, lot.location_id, gleicher_ort_erlaubt=True
|
||||
)
|
||||
|
||||
heute = date.today()
|
||||
ziel.opened_on = heute
|
||||
ziel.opened_quantity = ziel.quantity
|
||||
ziel.consume_by = heute + timedelta(days=oeffnungsfrist(db, product))
|
||||
db.flush()
|
||||
return ziel
|
||||
|
||||
|
||||
def verbrauchen(
|
||||
db: Session,
|
||||
product: Product,
|
||||
lot: Lot,
|
||||
quantity: float,
|
||||
unit: str,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
auto_open: bool = True,
|
||||
) -> tuple[list[dict], Lot | None, bool]:
|
||||
"""Teilmenge aus einer Charge verbrauchen, bei Bedarf vorher anbrechen.
|
||||
|
||||
Gibt (betroffene Chargen, offene Charge oder None wenn leer, angebrochen?)
|
||||
zurück. Die Umrechnung läuft über ``to_base`` und kennt damit auch die
|
||||
Hausmaße („2 EL") – deshalb wird hier zuerst gerechnet und danach
|
||||
angebrochen: Scheitert die Einheit, soll keine Packung offen sein.
|
||||
"""
|
||||
# Erst rechnen: Scheitert die Einheit, soll keine Packung grundlos offen sein.
|
||||
menge_base = to_base(db, product, quantity, unit, hausmasse=True)
|
||||
angebrochen = False
|
||||
ziel = lot
|
||||
if lot.opened_on is None and auto_open:
|
||||
ziel = anbrechen(db, product, lot, max(menge_base, article_unit(product)[0] or 0.0))
|
||||
angebrochen = True
|
||||
# Bewusst mit der ROH-Eingabe weiter, nicht mit menge_base: ``unit_used`` an
|
||||
# der Bewegung soll festhalten, was der Nutzer getippt hat („2 EL"), nicht
|
||||
# das Ergebnis der Umrechnung. Die zweite Umrechnung liefert denselben Wert.
|
||||
affected = check_out_lot(db, product, ziel, quantity, unit, user, note)
|
||||
offen = None if ziel.quantity <= 1e-9 else ziel
|
||||
return affected, offen, angebrochen
|
||||
|
||||
|
||||
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
|
||||
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
|
||||
"""Lots eines Produkts: Angebrochenes zuerst, dann nach dem wirksamen Datum.
|
||||
|
||||
Eine offene Packung wird immer vor einer geschlossenen geleert, auch wenn
|
||||
die geschlossene früher datiert ist. Das aufgedruckte MHD einer
|
||||
ungeöffneten Packung ist konservativ und wird real meist überlebt – eine
|
||||
offene verdirbt dagegen tatsächlich. Und wer eine zweite Packung aufmacht,
|
||||
während die erste offen steht, hat am Ende zwei angebrochene.
|
||||
|
||||
Innerhalb beider Gruppen gilt weiter FEFO über das nähere von MHD und
|
||||
Verbrauchsdatum (``Lot.faellig_am``), NULL zuletzt.
|
||||
"""
|
||||
lots = (
|
||||
db.query(Lot)
|
||||
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
||||
.order_by(asc(Lot.created_at))
|
||||
.all()
|
||||
)
|
||||
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
|
||||
# es über SQLite und Postgres identisch funktioniert.
|
||||
# In Python sortieren, damit es über SQLite und Postgres identisch arbeitet.
|
||||
return sorted(
|
||||
lots,
|
||||
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
|
||||
key=lambda lot: (
|
||||
lot.opened_on is None,
|
||||
lot.faellig_am is None,
|
||||
lot.faellig_am or date.max,
|
||||
lot.id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -541,7 +676,7 @@ def check_out(
|
||||
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
||||
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
||||
"""
|
||||
needed = to_base(db, product, quantity, unit)
|
||||
needed = to_base(db, product, quantity, unit, hausmasse=True)
|
||||
available = current_stock(db, product.id)
|
||||
if needed > available + 1e-9:
|
||||
raise StockError(
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Werte, die über den Kategorie-Baum geerbt werden: Öffnungsfrist und Hausmaße.
|
||||
|
||||
Die Regel ist bei beiden dieselbe und bewusst eine andere als bei den selbst
|
||||
definierten Feldern: Dort werden die Felder **aller** Vorfahren gesammelt
|
||||
(``services/fields.py::effective_field_definitions``), hier gewinnt der
|
||||
**nächste** Vorfahr mit einem Eintrag. Ein einzelner Wert kann nur einmal
|
||||
gelten — „Backwaren: 1 EL = 10 g" darf von „Mehl: 1 EL = 9 g" überschrieben
|
||||
werden, ohne dass beide gleichzeitig zählen.
|
||||
|
||||
Reihenfolge immer: Artikel → nächste Kategorie mit Wert → … → global.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import (
|
||||
BaseUnit,
|
||||
CategoryMeasure,
|
||||
Measure,
|
||||
Product,
|
||||
ProductMeasure,
|
||||
Setting,
|
||||
)
|
||||
from .fields import ancestry
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Globale Öffnungsfrist. Pflichtwert: Ohne ihn hätte eine angebrochene Packung
|
||||
# ohne Kategorie-Eintrag kein Verbrauchsdatum, und der ganze Zweck entfiele.
|
||||
OPEN_DAYS_KEY = "open_days"
|
||||
|
||||
|
||||
def _vorfahren_abwaerts(db: Session, category_id: int | None):
|
||||
"""Kategorien vom Artikel aufwärts: nächste zuerst, Wurzel zuletzt.
|
||||
|
||||
``ancestry`` liefert die Kette Wurzel-zuerst (dort richtig, weil Felder von
|
||||
oben nach unten gelesen werden); hier wird sie umgedreht.
|
||||
"""
|
||||
if category_id is None:
|
||||
return []
|
||||
return list(reversed(ancestry(db, category_id)))
|
||||
|
||||
|
||||
def naechster_wert(
|
||||
db: Session, category_id: int | None, lese: Callable[[int], T | None]
|
||||
) -> T | None:
|
||||
"""Ersten gesetzten Wert entlang der Kategorie-Kette nach oben suchen."""
|
||||
for cat in _vorfahren_abwaerts(db, category_id):
|
||||
wert = lese(cat.id)
|
||||
if wert is not None:
|
||||
return wert
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Öffnungsfrist
|
||||
# --------------------------------------------------------------------------
|
||||
def globale_oeffnungsfrist(db: Session) -> int:
|
||||
"""Globaler Wert in Tagen – Muster wie ``get_expiry_warning_days``."""
|
||||
from ..config import get_settings
|
||||
|
||||
vorgabe = get_settings().open_days_default
|
||||
row = db.get(Setting, OPEN_DAYS_KEY)
|
||||
if row is None:
|
||||
return vorgabe
|
||||
try:
|
||||
tage = int(row.value)
|
||||
except (TypeError, ValueError):
|
||||
return vorgabe
|
||||
return tage if tage > 0 else vorgabe
|
||||
|
||||
|
||||
def oeffnungsfrist(db: Session, product: Product) -> int:
|
||||
"""Wie viele Tage eine angebrochene Packung dieses Artikels noch hält."""
|
||||
if product.open_days is not None and product.open_days > 0:
|
||||
return product.open_days
|
||||
geerbt = naechster_wert(
|
||||
db,
|
||||
product.category_id,
|
||||
lambda cid: _kategorie_frist(db, cid),
|
||||
)
|
||||
return geerbt if geerbt is not None else globale_oeffnungsfrist(db)
|
||||
|
||||
|
||||
def _kategorie_frist(db: Session, category_id: int) -> int | None:
|
||||
from ..models import Category
|
||||
|
||||
cat = db.get(Category, category_id)
|
||||
if cat is None or cat.open_days is None or cat.open_days <= 0:
|
||||
return None
|
||||
return cat.open_days
|
||||
|
||||
|
||||
def frist_herkunft(db: Session, product: Product) -> tuple[int, str]:
|
||||
"""Frist plus Klartext, woher sie kommt – für den Platzhalter in der Maske."""
|
||||
if product.open_days is not None and product.open_days > 0:
|
||||
return product.open_days, "Artikel"
|
||||
for cat in _vorfahren_abwaerts(db, product.category_id):
|
||||
if cat.open_days is not None and cat.open_days > 0:
|
||||
return cat.open_days, f"Kategorie: {cat.name}"
|
||||
return globale_oeffnungsfrist(db), "Einstellungen"
|
||||
|
||||
|
||||
def geerbte_kategorie_frist(db: Session, category) -> tuple[int, str]:
|
||||
"""Die Frist, die fuer diese Kategorie gelten WUERDE, haette sie keine eigene.
|
||||
|
||||
Gefragt wird also nach dem Platzhalter in der Maske: Was passiert, wenn ich
|
||||
das Feld leer lasse? Deshalb beginnt die Suche beim Elternteil, nicht bei
|
||||
der Kategorie selbst.
|
||||
"""
|
||||
for cat in _vorfahren_abwaerts(db, category.parent_id):
|
||||
if cat.open_days is not None and cat.open_days > 0:
|
||||
return cat.open_days, f"Kategorie: {cat.name}"
|
||||
return globale_oeffnungsfrist(db), "Einstellungen"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Hausmaße
|
||||
# --------------------------------------------------------------------------
|
||||
class MassAngabe:
|
||||
"""Ein für einen Artikel auflösbares Hausmaß samt Herkunft."""
|
||||
|
||||
__slots__ = ("measure", "amount", "base", "herkunft")
|
||||
|
||||
def __init__(self, measure: Measure, amount: float, base: str, herkunft: str):
|
||||
self.measure = measure
|
||||
self.amount = amount
|
||||
self.base = base
|
||||
self.herkunft = herkunft
|
||||
|
||||
|
||||
def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAngabe | None:
|
||||
eigen = (
|
||||
db.query(ProductMeasure)
|
||||
.filter(
|
||||
ProductMeasure.product_id == product.id,
|
||||
ProductMeasure.measure_id == measure_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
measure = db.get(Measure, measure_id)
|
||||
if measure is None:
|
||||
return None
|
||||
if eigen is not None:
|
||||
return MassAngabe(measure, eigen.amount, eigen.base, "Artikel")
|
||||
for cat in _vorfahren_abwaerts(db, product.category_id):
|
||||
row = (
|
||||
db.query(CategoryMeasure)
|
||||
.filter(
|
||||
CategoryMeasure.category_id == cat.id,
|
||||
CategoryMeasure.measure_id == measure_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}")
|
||||
return None
|
||||
|
||||
|
||||
def geerbtes_mass_kategorie(
|
||||
db: Session, category, measure_id: int
|
||||
) -> MassAngabe | None:
|
||||
"""Was eine Kategorie fuer dieses Mass ERBT, ohne ihren eigenen Eintrag.
|
||||
|
||||
Dasselbe wie bei der Frist: Die Maske soll zeigen, was gilt, wenn das Feld
|
||||
leer bleibt. Deshalb beginnt die Suche beim Elternteil.
|
||||
"""
|
||||
measure = db.get(Measure, measure_id)
|
||||
if measure is None:
|
||||
return None
|
||||
for cat in _vorfahren_abwaerts(db, category.parent_id):
|
||||
row = (
|
||||
db.query(CategoryMeasure)
|
||||
.filter(
|
||||
CategoryMeasure.category_id == cat.id,
|
||||
CategoryMeasure.measure_id == measure_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row is not None:
|
||||
return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}")
|
||||
return None
|
||||
|
||||
|
||||
def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]:
|
||||
"""Alle Hausmaße, die für diesen Artikel eine Menge haben.
|
||||
|
||||
Grundlage für die Einheitenauswahl im Verbrauchen-Dialog und für den
|
||||
Hinweis, woher die Menge stammt.
|
||||
"""
|
||||
ergebnis: list[MassAngabe] = []
|
||||
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
|
||||
angabe = _mass_fuer_artikel(db, product, measure.id)
|
||||
if angabe is not None:
|
||||
ergebnis.append(angabe)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def mass_aufloesen(db: Session, product: Product, token: str) -> MassAngabe | None:
|
||||
"""Ein Eingabe-Token („EL", „esslöffel", „Tasse") zum Hausmaß auflösen.
|
||||
|
||||
Vergleicht Name und Kurzform ohne Rücksicht auf Groß-/Kleinschreibung.
|
||||
``None`` heißt: Entweder gibt es das Maß nicht, oder es ist für diesen
|
||||
Artikel (und seine Kategorien) ohne Menge — beides behandelt der Aufrufer
|
||||
gleich, nämlich als „Maß hier nicht hinterlegt".
|
||||
"""
|
||||
t = (token or "").strip().lower()
|
||||
if not t:
|
||||
return None
|
||||
for measure in db.query(Measure).all():
|
||||
namen = {measure.name.lower()}
|
||||
if measure.short:
|
||||
namen.add(measure.short.lower())
|
||||
if t in namen:
|
||||
return _mass_fuer_artikel(db, product, measure.id)
|
||||
return None
|
||||
|
||||
|
||||
def mass_in_basiseinheit(db: Session, product: Product, token: str, menge: float) -> float | None:
|
||||
"""Menge in einem Hausmaß in die Basiseinheit des Artikels umrechnen.
|
||||
|
||||
``None`` heißt „Maß für diesen Artikel nicht hinterlegt" — der Aufrufer
|
||||
entscheidet, ob das ein Fehler ist (Buchung) oder nur eine Lücke, die man
|
||||
anlegen kann (Rezeptabgleich).
|
||||
"""
|
||||
angabe = mass_aufloesen(db, product, token)
|
||||
if angabe is None:
|
||||
return None
|
||||
from .conversion import in_artikel_basis
|
||||
|
||||
roh = menge * angabe.amount
|
||||
try:
|
||||
quell = BaseUnit(angabe.base)
|
||||
except ValueError:
|
||||
return None
|
||||
if quell == product.base_unit:
|
||||
return roh
|
||||
# Art passt nicht zur Basiseinheit des Artikels: derselbe Weg wie bei jeder
|
||||
# artfremden Einheit, naemlich ueber die Zweiteinheit des Artikels.
|
||||
return in_artikel_basis(product, roh, quell)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Gemerkte Zuordnungen: Mealie-Lebensmittel → eigener Artikel oder Gruppe.
|
||||
|
||||
Der unscharfe Abgleich in :mod:`services.rezepte` ist eine Heuristik und wird
|
||||
immer Fälle haben, die er nicht erraten kann — „Lime Curd", „Ras el Hanout",
|
||||
oder einfach ein Hausname. Hier liegt der Korrekturweg: Einmal von Hand
|
||||
zugeordnet, gilt es für jedes Rezept, in dem dasselbe Lebensmittel vorkommt.
|
||||
|
||||
Der Schlüssel ist der normalisierte Name, nicht die Mealie-ID. Mealie-IDs sind
|
||||
UUIDs, die beim Neuanlegen eines Lebensmittels wechseln; der Name bleibt. Und
|
||||
nur über den Namen greift die Zuordnung auch für Zutaten, die in Mealie gar
|
||||
kein verknüpftes Lebensmittel haben (freier Text „2 Eier").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Group, MealieFoodMap, Product
|
||||
from .matching import normalisieren
|
||||
|
||||
|
||||
class ZuordnungFehler(ValueError):
|
||||
"""Unbrauchbare Zuordnung – der Router macht daraus eine 400/404."""
|
||||
|
||||
|
||||
def schluessel(name: str) -> str:
|
||||
"""Normalisierter Schlüssel. Leer heisst „damit lässt sich nichts merken"."""
|
||||
return normalisieren(name)[:200]
|
||||
|
||||
|
||||
def lade(db: Session) -> dict[str, tuple[str, int]]:
|
||||
"""Alle gültigen Zuordnungen als ``{schluessel: (art, id)}``.
|
||||
|
||||
Zeilen, deren Ziel es nicht mehr gibt, fallen hier still heraus statt eine
|
||||
Ausnahme zu werfen: Auf SQLite greift kein Fremdschlüssel, und ein
|
||||
gelöschter Artikel darf nicht dazu führen, dass sich kein Rezept mehr
|
||||
öffnen lässt.
|
||||
"""
|
||||
ergebnis: dict[str, tuple[str, int]] = {}
|
||||
for zeile in db.query(MealieFoodMap).all():
|
||||
if zeile.product_id is not None:
|
||||
if db.get(Product, zeile.product_id) is not None:
|
||||
ergebnis[zeile.food_key] = ("product", zeile.product_id)
|
||||
elif zeile.group_id is not None:
|
||||
if db.get(Group, zeile.group_id) is not None:
|
||||
ergebnis[zeile.food_key] = ("group", zeile.group_id)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def ziel_name(db: Session, art: str, ziel_id: int) -> str:
|
||||
if art == "product":
|
||||
produkt = db.get(Product, ziel_id)
|
||||
return produkt.name if produkt is not None else ""
|
||||
gruppe = db.get(Group, ziel_id)
|
||||
return gruppe.name if gruppe is not None else ""
|
||||
|
||||
|
||||
def setze(db: Session, food: str, art: str, ziel_id: int) -> MealieFoodMap:
|
||||
"""Zuordnung anlegen oder ändern. Prüft, dass das Ziel existiert."""
|
||||
key = schluessel(food)
|
||||
if not key:
|
||||
raise ZuordnungFehler(
|
||||
"Zu dieser Zutat steht kein Name da – ohne Namen lässt sich die "
|
||||
"Zuordnung nicht merken."
|
||||
)
|
||||
if art not in ("product", "group"):
|
||||
raise ZuordnungFehler("Zugeordnet wird auf einen Artikel oder eine Gruppe.")
|
||||
if art == "product" and db.get(Product, ziel_id) is None:
|
||||
raise ZuordnungFehler("Diesen Artikel gibt es nicht.")
|
||||
if art == "group" and db.get(Group, ziel_id) is None:
|
||||
raise ZuordnungFehler("Diese Gruppe gibt es nicht.")
|
||||
|
||||
zeile = db.get(MealieFoodMap, key)
|
||||
if zeile is None:
|
||||
zeile = MealieFoodMap(food_key=key)
|
||||
db.add(zeile)
|
||||
# Immer beide Felder setzen, damit ein Wechsel Artikel → Gruppe nicht
|
||||
# beide gefüllt zurücklässt.
|
||||
zeile.food_name = (food or "").strip()[:200]
|
||||
zeile.product_id = ziel_id if art == "product" else None
|
||||
zeile.group_id = ziel_id if art == "group" else None
|
||||
db.commit()
|
||||
db.refresh(zeile)
|
||||
return zeile
|
||||
|
||||
|
||||
def loesche(db: Session, food: str) -> bool:
|
||||
"""Zuordnung vergessen. ``False``, wenn es keine gab."""
|
||||
zeile = db.get(MealieFoodMap, schluessel(food))
|
||||
if zeile is None:
|
||||
return False
|
||||
db.delete(zeile)
|
||||
db.commit()
|
||||
return True
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import BaseUnit, Product
|
||||
from app.seed import ensure_builtin_units
|
||||
from app.seed import ensure_builtin_measures, ensure_builtin_units
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -19,6 +19,7 @@ def db():
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
session = TestingSession()
|
||||
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
|
||||
ensure_builtin_measures(session) # Hausmaße (EL, TL, Tasse …) – ohne Mengen
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Zwei Daten an einer Charge: aufgedrucktes MHD und „zu verbrauchen bis".
|
||||
|
||||
Beide bleiben getrennt sichtbar, gewarnt wird nach dem früheren. Der heikle
|
||||
Fall ist die angebrochene Packung **ohne** aufgedrucktes MHD – die fiel vorher
|
||||
durch den Filter `best_before IS NOT NULL`.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import BaseUnit, Lot, Product, Role, User
|
||||
from app.routers.dashboard import _zustand
|
||||
from app.routers.stock import open_lot
|
||||
from app.routers.views import expiring
|
||||
from app.schemas import LotOpen
|
||||
from app.services.stock import _fefo_lots
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def milch(db):
|
||||
product = Product(
|
||||
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _lot(db, product, menge, best_before=None, opened_on=None, consume_by=None):
|
||||
lot = Lot(
|
||||
product_id=product.id, quantity=menge, best_before=best_before,
|
||||
opened_on=opened_on, consume_by=consume_by,
|
||||
)
|
||||
db.add(lot)
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
return lot
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ablaufliste
|
||||
# --------------------------------------------------------------------------
|
||||
def test_angebrochene_charge_ohne_mhd_taucht_auf(db, user, milch):
|
||||
"""Der Fall, den der alte Filter verschluckt hätte."""
|
||||
_lot(db, milch, 500, best_before=None,
|
||||
opened_on=date.today(), consume_by=date.today() + timedelta(days=2))
|
||||
|
||||
liste = expiring(days=7, db=db, _=user)
|
||||
|
||||
assert len(liste) == 1
|
||||
assert liste[0].best_before is None
|
||||
assert liste[0].warn_source == "consume_by"
|
||||
assert liste[0].days_left == 2
|
||||
|
||||
|
||||
def test_beide_daten_stehen_getrennt_in_der_liste(db, user, milch):
|
||||
mhd = date.today() + timedelta(days=30)
|
||||
verbrauch = date.today() + timedelta(days=3)
|
||||
_lot(db, milch, 500, best_before=mhd, opened_on=date.today(), consume_by=verbrauch)
|
||||
|
||||
eintrag = expiring(days=7, db=db, _=user)[0]
|
||||
|
||||
assert eintrag.best_before == mhd # bleibt sichtbar
|
||||
assert eintrag.consume_by == verbrauch
|
||||
assert eintrag.warn_date == verbrauch # gewarnt wird nach dem früheren
|
||||
assert eintrag.days_left == 3
|
||||
|
||||
|
||||
def test_frueheres_mhd_gewinnt_gegen_verbrauchsdatum(db, user, milch):
|
||||
mhd = date.today() + timedelta(days=1)
|
||||
_lot(db, milch, 500, best_before=mhd,
|
||||
opened_on=date.today(), consume_by=date.today() + timedelta(days=5))
|
||||
|
||||
eintrag = expiring(days=7, db=db, _=user)[0]
|
||||
|
||||
assert eintrag.warn_date == mhd
|
||||
assert eintrag.warn_source == "best_before"
|
||||
|
||||
|
||||
def test_ungeoeffnete_charge_verhaelt_sich_wie_bisher(db, user, milch):
|
||||
mhd = date.today() + timedelta(days=3)
|
||||
_lot(db, milch, 1000, best_before=mhd)
|
||||
|
||||
eintrag = expiring(days=7, db=db, _=user)[0]
|
||||
|
||||
assert eintrag.warn_date == mhd
|
||||
assert eintrag.warn_source == "best_before"
|
||||
assert eintrag.consume_by is None
|
||||
|
||||
|
||||
def test_ausserhalb_der_frist_bleibt_draussen(db, user, milch):
|
||||
_lot(db, milch, 500, opened_on=date.today(),
|
||||
consume_by=date.today() + timedelta(days=30))
|
||||
|
||||
assert expiring(days=7, db=db, _=user) == []
|
||||
|
||||
|
||||
def test_liste_ist_nach_dem_wirksamen_datum_sortiert(db, user, milch):
|
||||
_lot(db, milch, 100, best_before=date.today() + timedelta(days=6))
|
||||
_lot(db, milch, 100, opened_on=date.today(), consume_by=date.today() + timedelta(days=1))
|
||||
_lot(db, milch, 100, best_before=date.today() + timedelta(days=3))
|
||||
|
||||
tage = [e.days_left for e in expiring(days=7, db=db, _=user)]
|
||||
|
||||
assert tage == [1, 3, 6]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zustand fürs Dashboard
|
||||
# --------------------------------------------------------------------------
|
||||
def test_zustand_angebrochen_ist_ein_eigener(db):
|
||||
heute = date.today()
|
||||
assert _zustand(heute + timedelta(days=30), heute, 7, angebrochen=True) == "opened"
|
||||
|
||||
|
||||
def test_dringlichkeit_schlaegt_angebrochen(db):
|
||||
heute = date.today()
|
||||
# Angebrochen UND bald ablaufend: die Warnung muss gewinnen.
|
||||
assert _zustand(heute + timedelta(days=2), heute, 7, angebrochen=True) == "soon"
|
||||
assert _zustand(heute - timedelta(days=1), heute, 7, angebrochen=True) == "expired"
|
||||
|
||||
|
||||
def test_zustand_ohne_daten_bleibt_no_date(db):
|
||||
heute = date.today()
|
||||
assert _zustand(None, heute, 7) == "no_date"
|
||||
# Angebrochen ohne jedes Datum ist trotzdem angebrochen.
|
||||
assert _zustand(None, heute, 7, angebrochen=True) == "opened"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# FEFO
|
||||
# --------------------------------------------------------------------------
|
||||
def test_angebrochenes_wird_zuerst_geleert(db, milch):
|
||||
spaet = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=30))
|
||||
offen = _lot(db, milch, 500, best_before=date.today() + timedelta(days=30),
|
||||
opened_on=date.today(), consume_by=date.today() + timedelta(days=2))
|
||||
|
||||
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
|
||||
|
||||
assert reihenfolge == [offen.id, spaet.id]
|
||||
|
||||
|
||||
def test_angebrochenes_geht_auch_vor_knappem_mhd(db, milch):
|
||||
"""Offen zuerst, selbst wenn eine geschlossene Packung früher datiert ist.
|
||||
|
||||
Das MHD einer ungeöffneten Packung ist konservativ und wird real meist
|
||||
überlebt; eine offene verdirbt dagegen tatsächlich. Und wer die zweite
|
||||
aufmacht, während die erste offen steht, hat am Ende zwei angebrochene.
|
||||
"""
|
||||
morgen = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=1))
|
||||
offen = _lot(db, milch, 500, opened_on=date.today(),
|
||||
consume_by=date.today() + timedelta(days=5))
|
||||
|
||||
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
|
||||
|
||||
assert reihenfolge == [offen.id, morgen.id]
|
||||
|
||||
|
||||
def test_unter_den_angebrochenen_gilt_weiter_fefo(db, milch):
|
||||
spaet = _lot(db, milch, 500, opened_on=date.today(),
|
||||
consume_by=date.today() + timedelta(days=5))
|
||||
frueh = _lot(db, milch, 500, opened_on=date.today(),
|
||||
consume_by=date.today() + timedelta(days=1))
|
||||
|
||||
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
|
||||
|
||||
assert reihenfolge == [frueh.id, spaet.id]
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Angebrochene Packungen: Abspalten, Verbrauchsdatum, Teilverbrauch, Frist-Vererbung."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Lot,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
Role,
|
||||
Setting,
|
||||
User,
|
||||
)
|
||||
from app.routers.stock import consume_lot, list_all_lots, open_lot
|
||||
from app.schemas import LotConsume, LotOpen
|
||||
from app.services.stock import check_in, current_stock
|
||||
from app.services.vererbung import frist_herkunft, oeffnungsfrist
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def milch(db):
|
||||
"""Milliliter-Artikel mit 1000 ml je Packung, in einer Lebensmittel-Kategorie."""
|
||||
cat = Category(name="Molkereiprodukte", tracking=CategoryTracking.food.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(
|
||||
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
|
||||
category_id=cat.id, source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _charge(db, product, menge_base, best_before=None):
|
||||
lot = Lot(product_id=product.id, quantity=menge_base, best_before=best_before)
|
||||
db.add(lot)
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
return lot
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Anbrechen spaltet eine Packung ab
|
||||
# --------------------------------------------------------------------------
|
||||
def test_anbrechen_spaltet_eine_packung_ab(db, user, milch):
|
||||
lot = _charge(db, milch, 4000) # 4 x 1 l in einer Zeile
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert offen.quantity == 1000
|
||||
assert offen.opened_on == date.today()
|
||||
assert offen.opened_quantity == 1000
|
||||
# Der Rest bleibt zu – sonst trüge er das kurze Verbrauchsdatum mit.
|
||||
rest = db.query(Lot).filter(Lot.id != offen.id).one()
|
||||
assert rest.quantity == 3000
|
||||
assert rest.opened_on is None
|
||||
assert rest.consume_by is None
|
||||
# Der Gesamtbestand ändert sich beim Anbrechen nicht.
|
||||
assert current_stock(db, milch.id) == 4000
|
||||
|
||||
|
||||
def test_anbrechen_einer_einzelnen_packung_spaltet_nichts(db, user, milch):
|
||||
lot = _charge(db, milch, 1000)
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert offen.id == lot.id
|
||||
assert db.query(Lot).count() == 1
|
||||
assert offen.quantity == 1000
|
||||
|
||||
|
||||
def test_anbrechen_nimmt_hoechstens_die_ganze_charge(db, user, milch):
|
||||
lot = _charge(db, milch, 400) # weniger als eine Packung
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert offen.quantity == 400
|
||||
assert db.query(Lot).count() == 1
|
||||
|
||||
|
||||
def test_anbrechen_mit_eigener_menge(db, user, milch):
|
||||
lot = _charge(db, milch, 4000)
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(quantity=500), db=db, _=user)
|
||||
|
||||
assert offen.quantity == 500
|
||||
assert db.query(Lot).filter(Lot.id != offen.id).one().quantity == 3500
|
||||
|
||||
|
||||
def test_zweimal_anbrechen_aendert_nichts(db, user, milch):
|
||||
lot = _charge(db, milch, 4000)
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
nochmal = open_lot(offen.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert nochmal.id == offen.id
|
||||
assert nochmal.quantity == 1000
|
||||
assert db.query(Lot).count() == 2
|
||||
|
||||
|
||||
def test_verbrauchsdatum_kommt_aus_der_frist(db, user, milch):
|
||||
db.add(Setting(key="open_days", value="5"))
|
||||
db.commit()
|
||||
lot = _charge(db, milch, 1000)
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert offen.consume_by == date.today() + timedelta(days=5)
|
||||
|
||||
|
||||
def test_mhd_bleibt_neben_dem_verbrauchsdatum_stehen(db, user, milch):
|
||||
mhd = date.today() + timedelta(days=20)
|
||||
lot = _charge(db, milch, 1000, best_before=mhd)
|
||||
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
assert offen.best_before == mhd # unangetastet
|
||||
assert offen.consume_by is not None
|
||||
# Gewarnt wird nach dem früheren der beiden.
|
||||
frisch = db.get(Lot, offen.id)
|
||||
assert frisch.faellig_am == frisch.consume_by
|
||||
|
||||
|
||||
def test_frueheres_mhd_schlaegt_das_verbrauchsdatum(db, user, milch):
|
||||
mhd = date.today() + timedelta(days=1) # läuft vor der Öffnungsfrist ab
|
||||
lot = _charge(db, milch, 1000, best_before=mhd)
|
||||
|
||||
open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
frisch = db.query(Lot).one()
|
||||
assert frisch.faellig_am == mhd
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verbrauchen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_verbrauchen_oeffnet_in_einem_schritt(db, user, milch):
|
||||
lot = _charge(db, milch, 4000)
|
||||
|
||||
ergebnis = consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
|
||||
|
||||
assert ergebnis.opened is True
|
||||
assert ergebnis.lot is not None
|
||||
assert ergebnis.lot.quantity == 900 # 1000 geöffnet, 100 verbraucht
|
||||
assert ergebnis.lot.opened_quantity == 1000
|
||||
assert current_stock(db, milch.id) == 3900
|
||||
# Der Verbrauch ist eine Bewegung, das Anbrechen allein nicht.
|
||||
bewegungen = db.query(Movement).all()
|
||||
assert len(bewegungen) == 1
|
||||
assert bewegungen[0].type == MovementType.out
|
||||
assert bewegungen[0].quantity == 100
|
||||
|
||||
|
||||
def test_verbrauchen_ohne_anbrechen_wenn_abgewaehlt(db, user, milch):
|
||||
lot = _charge(db, milch, 4000)
|
||||
|
||||
ergebnis = consume_lot(
|
||||
lot.id, LotConsume(quantity=100, unit="ml", auto_open=False), db=db, user=user
|
||||
)
|
||||
|
||||
assert ergebnis.opened is False
|
||||
assert db.query(Lot).count() == 1
|
||||
assert db.query(Lot).one().opened_on is None
|
||||
assert current_stock(db, milch.id) == 3900
|
||||
|
||||
|
||||
def test_weiterer_verbrauch_nutzt_die_offene_packung(db, user, milch):
|
||||
lot = _charge(db, milch, 4000)
|
||||
consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
|
||||
offen = db.query(Lot).filter(Lot.opened_on.isnot(None)).one()
|
||||
|
||||
consume_lot(offen.id, LotConsume(quantity=200, unit="ml"), db=db, user=user)
|
||||
|
||||
assert db.get(Lot, offen.id).quantity == 700
|
||||
assert db.query(Lot).count() == 2
|
||||
|
||||
|
||||
def test_leere_offene_packung_verschwindet(db, user, milch):
|
||||
lot = _charge(db, milch, 1000)
|
||||
open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
ergebnis = consume_lot(lot.id, LotConsume(quantity=1000, unit="ml"), db=db, user=user)
|
||||
|
||||
assert ergebnis.lot is None
|
||||
assert db.query(Lot).count() == 0
|
||||
assert current_stock(db, milch.id) == 0
|
||||
|
||||
|
||||
def test_mehr_verbrauchen_als_drin_ist_scheitert(db, user, milch):
|
||||
lot = _charge(db, milch, 1000)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
consume_lot(lot.id, LotConsume(quantity=2000, unit="ml"), db=db, user=user)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_unbekannte_einheit_bricht_nichts_an(db, user, milch):
|
||||
"""Erst rechnen, dann anbrechen – sonst stünde eine Packung grundlos offen."""
|
||||
lot = _charge(db, milch, 4000)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
consume_lot(lot.id, LotConsume(quantity=1, unit="furlong"), db=db, user=user)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert db.query(Lot).one().opened_on is None
|
||||
|
||||
|
||||
def test_verbrauchen_in_packungen(db, user, rice, milch):
|
||||
"""„1 Packung" muss weiter gehen – die Hausmaße dürfen das nicht verdrängen."""
|
||||
lot = check_in(db, rice, 3, "package", None, None, None)
|
||||
db.commit()
|
||||
|
||||
consume_lot(lot.id, LotConsume(quantity=1, unit="package"), db=db, user=user)
|
||||
|
||||
assert current_stock(db, rice.id) == 1000
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Frist: Artikel > Kategorie > Oberkategorie > global
|
||||
# --------------------------------------------------------------------------
|
||||
def test_frist_vererbung_ueber_die_ebenen(db, milch):
|
||||
# 1. Ohne alles gilt der Standard aus der Konfiguration.
|
||||
assert oeffnungsfrist(db, milch) == 3
|
||||
assert frist_herkunft(db, milch)[1] == "Einstellungen"
|
||||
|
||||
# 2. Globale Einstellung.
|
||||
db.add(Setting(key="open_days", value="7"))
|
||||
db.commit()
|
||||
assert oeffnungsfrist(db, milch) == 7
|
||||
|
||||
# 3. Oberkategorie.
|
||||
ober = db.get(Category, milch.category_id)
|
||||
unter = Category(name="Frischmilch", parent_id=ober.id,
|
||||
tracking=CategoryTracking.food.value)
|
||||
db.add(unter)
|
||||
db.flush()
|
||||
milch.category_id = unter.id
|
||||
ober.open_days = 4
|
||||
db.commit()
|
||||
assert oeffnungsfrist(db, milch) == 4
|
||||
assert frist_herkunft(db, milch)[1] == "Kategorie: Molkereiprodukte"
|
||||
|
||||
# 4. Die nähere Kategorie gewinnt.
|
||||
unter.open_days = 2
|
||||
db.commit()
|
||||
assert oeffnungsfrist(db, milch) == 2
|
||||
assert frist_herkunft(db, milch)[1] == "Kategorie: Frischmilch"
|
||||
|
||||
# 5. Der Artikel schlägt alles.
|
||||
milch.open_days = 1
|
||||
db.commit()
|
||||
assert oeffnungsfrist(db, milch) == 1
|
||||
assert frist_herkunft(db, milch)[1] == "Artikel"
|
||||
|
||||
|
||||
def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch):
|
||||
db.add(Setting(key="open_days", value="viele"))
|
||||
db.commit()
|
||||
assert oeffnungsfrist(db, milch) == 3
|
||||
|
||||
|
||||
def test_die_chargenliste_zeigt_den_offenen_zustand(db, user, milch):
|
||||
"""Ohne diese Felder saehe die Liste eine offene Packung wie jede andere.
|
||||
|
||||
Die Zeile traegt dann auch die falsche Ablaufwarnung, denn gewarnt wird nach
|
||||
dem fruheren von MHD und Verbrauchsdatum.
|
||||
"""
|
||||
check_in(db, milch, 4000, "ml", date.today() + timedelta(days=30), None, None)
|
||||
db.commit()
|
||||
[charge] = db.query(Lot).all()
|
||||
|
||||
open_lot(charge.id, LotOpen(quantity=None), db=db, _=user)
|
||||
|
||||
zeilen = {z.quantity: z for z in list_all_lots(db=db, _=user)}
|
||||
offen = zeilen[1000]
|
||||
zu = zeilen[3000]
|
||||
|
||||
assert offen.opened_on == date.today()
|
||||
assert offen.consume_by == date.today() + timedelta(days=3)
|
||||
assert offen.opened_quantity == 1000
|
||||
assert zu.opened_on is None
|
||||
assert zu.consume_by is None
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Artikel löschen – und wann der Server sich weigert.
|
||||
|
||||
Ohne Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder und den
|
||||
gesamten Bewegungsverlauf per Kaskade mit. Geprüft wird deshalb der heutige
|
||||
Bestand. Bewegungen allein halten bewusst NICHT auf, sonst waere jeder je
|
||||
benutzte Artikel fuer immer unloeschbar.
|
||||
|
||||
Geprüft wird nur die Sperre selbst – die Kaskade nicht: Die Tests laufen auf
|
||||
SQLite ohne ``PRAGMA foreign_keys=ON``, eine Kaskaden-Zusicherung waere dort
|
||||
ohne Aussagekraft.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import BaseUnit, Item, Lot, Product, Role, User
|
||||
from app.routers.products import delete_product
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _artikel(db, name="Hammer"):
|
||||
product = Product(name=name, base_unit=BaseUnit.piece, package_size=1,
|
||||
source="manual")
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def test_loeschen_ohne_bestand_geht_durch(db, admin):
|
||||
product = _artikel(db)
|
||||
|
||||
delete_product(product_id=product.id, db=db, _=admin)
|
||||
|
||||
assert db.get(Product, product.id) is None
|
||||
|
||||
|
||||
def test_loeschen_mit_einzelstuecken_wird_abgelehnt(db, admin):
|
||||
product = _artikel(db)
|
||||
db.add(Item(uid="ABC12345", product_id=product.id))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
delete_product(product_id=product.id, db=db, _=admin)
|
||||
|
||||
assert fehler.value.status_code == 409
|
||||
assert "Einzelstücke" in fehler.value.detail
|
||||
assert db.get(Product, product.id) is not None
|
||||
|
||||
|
||||
def test_loeschen_mit_chargen_wird_abgelehnt(db, admin):
|
||||
product = _artikel(db)
|
||||
db.add(Lot(product_id=product.id, quantity=5))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
delete_product(product_id=product.id, db=db, _=admin)
|
||||
|
||||
assert fehler.value.status_code == 409
|
||||
assert "Bestand" in fehler.value.detail
|
||||
assert db.get(Product, product.id) is not None
|
||||
|
||||
|
||||
def test_loeschen_nach_leergeraeumter_charge_geht_durch(db, admin):
|
||||
"""Wer alles auslagert, muss den Artikel loswerden können.
|
||||
|
||||
Haelt fest, dass die Sperre am heutigen Bestand haengt und nicht zu einer
|
||||
Verlaufspruefung ausgebaut wird – sonst waere ein einmal benutzter Artikel
|
||||
nie wieder loeschbar.
|
||||
"""
|
||||
product = _artikel(db)
|
||||
lot = Lot(product_id=product.id, quantity=5)
|
||||
db.add(lot)
|
||||
db.commit()
|
||||
db.delete(lot) # wie beim vollstaendigen Auslagern
|
||||
db.commit()
|
||||
|
||||
delete_product(product_id=product.id, db=db, _=admin)
|
||||
|
||||
assert db.get(Product, product.id) is None
|
||||
|
||||
|
||||
def test_unbekannter_artikel_bleibt_ein_404(db, admin):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
delete_product(product_id=9999, db=db, _=admin)
|
||||
|
||||
assert fehler.value.status_code == 404
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Artikelsuche über Name UND Marke, wortweise.
|
||||
|
||||
Vorher verglich ein einzelnes ILIKE den ganzen Suchtext gegen den Namen. Wer
|
||||
"Vogel Jack and Jones" eintippte, bekam nichts – dieser Ausdruck steht in
|
||||
keinem Artikelnamen. Gesucht wird jetzt Wort für Wort über Name und Marke
|
||||
zusammen, in beliebiger Reihenfolge.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import BaseUnit, Category, CategoryTracking, Product, Role, User
|
||||
from app.routers.products import list_products
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nutzer(db):
|
||||
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _kategorie(db, name, tracking=CategoryTracking.object):
|
||||
cat = Category(name=name, tracking=tracking.value)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
def _artikel(db, name, cat=None, **extra):
|
||||
product = Product(name=name, base_unit=BaseUnit.piece, package_size=1,
|
||||
category_id=(cat.id if cat else None), source="manual", **extra)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _namen(db, nutzer, **kwargs):
|
||||
return [p.name for p in list_products(db=db, _=nutzer, **kwargs)]
|
||||
|
||||
|
||||
def test_suche_findet_ueber_marke_und_name_in_beliebiger_reihenfolge(db, nutzer):
|
||||
"""Der Fall, der vorher gar nichts lieferte."""
|
||||
_artikel(db, "Vogel-Shirt", brand="Jack and Jones")
|
||||
_artikel(db, "Hose", brand="Levis")
|
||||
|
||||
assert _namen(db, nutzer, q="vogel jack and jones") == ["Vogel-Shirt"]
|
||||
# Dieselben Wörter andersherum – die Reihenfolge darf nichts ändern.
|
||||
assert _namen(db, nutzer, q="jack and jones vogel") == ["Vogel-Shirt"]
|
||||
# Nur die Marke, ohne ein Wort aus dem Namen.
|
||||
assert _namen(db, nutzer, q="jones") == ["Vogel-Shirt"]
|
||||
|
||||
|
||||
def test_suche_loest_umlaute_auf(db, nutzer):
|
||||
"""Der Grund, warum gefiltert wird und nicht per ILIKE gesucht."""
|
||||
_artikel(db, "Weissmehl Typ 550", brand="Mühle Nord")
|
||||
_artikel(db, "Zucker")
|
||||
|
||||
assert _namen(db, nutzer, q="muehle") == ["Weissmehl Typ 550"]
|
||||
assert _namen(db, nutzer, q="MÜHLE") == ["Weissmehl Typ 550"]
|
||||
|
||||
|
||||
def test_suche_grenzt_weiterhin_auf_kategorie_ein(db, nutzer):
|
||||
"""Der Kategoriefilter bleibt in SQL – der Umbau darf ihn nicht verlieren."""
|
||||
kleidung = _kategorie(db, "Kleidung")
|
||||
werkzeug = _kategorie(db, "Werkzeug")
|
||||
_artikel(db, "Vogel-Shirt", kleidung, brand="Jack and Jones")
|
||||
_artikel(db, "Vogel-Hammer", werkzeug)
|
||||
|
||||
assert _namen(db, nutzer, q="vogel") == ["Vogel-Hammer", "Vogel-Shirt"]
|
||||
assert _namen(db, nutzer, q="vogel", category_id=kleidung.id) == ["Vogel-Shirt"]
|
||||
|
||||
|
||||
def test_artikel_ohne_marke_bleibt_auffindbar(db, nutzer):
|
||||
"""``brand`` ist nullbar – das darf den Vergleich nicht sprengen."""
|
||||
_artikel(db, "Hammer", brand=None)
|
||||
|
||||
assert _namen(db, nutzer, q="hammer") == ["Hammer"]
|
||||
|
||||
|
||||
def test_leere_suche_liefert_alles(db, nutzer):
|
||||
"""Auch eine Suche aus lauter Leerzeichen: Ohne Wörter wird nicht gefiltert."""
|
||||
_artikel(db, "Hammer")
|
||||
_artikel(db, "Zange")
|
||||
|
||||
assert _namen(db, nutzer) == ["Hammer", "Zange"]
|
||||
assert _namen(db, nutzer, q=" ") == ["Hammer", "Zange"]
|
||||
@@ -60,7 +60,8 @@ def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
|
||||
db.commit()
|
||||
|
||||
daten = json.loads(_sicherung(db, user))
|
||||
assert daten["version"] == 3
|
||||
# v4 seit den Etiketten-Vorlagen; aeltere Sicherungen bleiben lesbar.
|
||||
assert daten["version"] == 4
|
||||
nach_name = {g["name"]: g for g in daten["groups"]}
|
||||
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
|
||||
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Die Einkaufsliste bekommt ein Gedächtnis: eigene Einträge, Haken, Rezepte.
|
||||
|
||||
Bisher war die Liste vollständig gerechnet. Was hier geprüft wird, ist das
|
||||
Daneben: Dinge, die jemand selbst draufgesetzt hat, das dauerhafte Abhaken, und
|
||||
die Übernahme der fehlenden Zutaten eines Rezepts — ohne Mealie, die Zutaten
|
||||
stehen direkt im Test.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Product,
|
||||
ProductLocationMinStock,
|
||||
Role,
|
||||
ShoppingCheck,
|
||||
ShoppingEntry,
|
||||
User,
|
||||
)
|
||||
from app.routers.shopping import (
|
||||
clear_check,
|
||||
clear_done,
|
||||
create_entry,
|
||||
delete_entry,
|
||||
list_entries,
|
||||
set_check,
|
||||
update_entry,
|
||||
)
|
||||
from app.routers.dashboard import stats as dashboard_stats
|
||||
from app.routers.views import shopping_list_all
|
||||
from app.schemas import ShoppingEntryIn, ShoppingEntryPatch
|
||||
from app.services import einkaufsliste
|
||||
from app.services.rezepte import pruefe_zutaten
|
||||
from app.services.stock import check_in
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nutzer(db):
|
||||
person = User(username="einkaeufer", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _zutat(name, menge=None, einheit=None):
|
||||
return {
|
||||
"quantity": menge,
|
||||
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
|
||||
"food": {"name": name},
|
||||
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
|
||||
}
|
||||
|
||||
|
||||
def _kategorie(db, name="Vorrat"):
|
||||
cat = Category(name=name, tracking=CategoryTracking.food.value)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0):
|
||||
produkt = Product(name=name, base_unit=base, package_size=package_size,
|
||||
category_id=cat.id, source="manual")
|
||||
db.add(produkt)
|
||||
db.commit()
|
||||
db.refresh(produkt)
|
||||
if bestand:
|
||||
check_in(db, produkt, bestand, base.value, None, None, None)
|
||||
db.commit()
|
||||
return produkt
|
||||
|
||||
|
||||
def _uebernehmen(db, zutaten, nutzer, *, slug="pfannkuchen", name="Pfannkuchen"):
|
||||
"""Wie der Endpunkt, nur ohne Mealie davor."""
|
||||
pruefungen = pruefe_zutaten(db, zutaten)
|
||||
return einkaufsliste.aus_rezept(db, pruefungen, slug=slug, name=name, user=nutzer)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Eigene Einträge
|
||||
# --------------------------------------------------------------------------
|
||||
def test_freitext_landet_auf_der_liste(db, nutzer):
|
||||
"""Der Fall, für den es bisher keinen Ort gab: 'Milch, weil ich das sage'."""
|
||||
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
|
||||
|
||||
[eintrag] = list_entries(db=db, _=nutzer)
|
||||
|
||||
assert eintrag.art == "text"
|
||||
assert eintrag.name == "Backpapier"
|
||||
assert eintrag.done is False
|
||||
|
||||
|
||||
def test_artikel_eintrag_nennt_die_menge_lesbar(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
|
||||
eintrag = create_entry(
|
||||
ShoppingEntryIn(product_id=mehl.id, quantity_base=1500, base=BaseUnit.gram),
|
||||
db=db, user=nutzer,
|
||||
)
|
||||
|
||||
assert eintrag.name == "Weissmehl"
|
||||
# Man kauft keine halbe Packung - dieselbe Sprache wie die gerechnete Liste.
|
||||
assert eintrag.menge_text == "2 Packungen"
|
||||
|
||||
|
||||
def test_genau_ein_ziel_ist_pflicht(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
|
||||
with pytest.raises(HTTPException) as leer:
|
||||
create_entry(ShoppingEntryIn(), db=db, user=nutzer)
|
||||
assert leer.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as doppelt:
|
||||
create_entry(
|
||||
ShoppingEntryIn(product_id=mehl.id, free_text="Mehl"), db=db, user=nutzer,
|
||||
)
|
||||
assert doppelt.value.status_code == 400
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
def test_abhaken_haelt_das_neuladen_aus(db, nutzer):
|
||||
"""Der eigentliche Punkt: Im Supermarkt wird die Seite neu geladen."""
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
|
||||
|
||||
[frisch] = list_entries(db=db, _=nutzer)
|
||||
assert frisch.done is True
|
||||
assert db.get(ShoppingEntry, eintrag.id).done_at is not None
|
||||
|
||||
|
||||
def test_text_eines_artikeleintrags_laesst_sich_nicht_umschreiben(db, nutzer):
|
||||
"""Sonst stünde im Eintrag ein Name, der nichts mit dem Artikel zu tun hat."""
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
eintrag = create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(free_text="Zucker"), db=db, _=nutzer)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
def test_eintrag_auf_einen_geloeschten_artikel_faellt_heraus(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
|
||||
|
||||
db.delete(mehl)
|
||||
db.commit()
|
||||
|
||||
assert list_entries(db=db, _=nutzer) == []
|
||||
|
||||
|
||||
def test_loeschen_entfernt_den_eintrag(db, nutzer):
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
|
||||
delete_entry(eintrag.id, db=db, _=nutzer)
|
||||
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Haken an den gerechneten Posten
|
||||
# --------------------------------------------------------------------------
|
||||
def test_haken_am_gerechneten_posten_bleibt(db, nutzer):
|
||||
"""Gerechnete Posten haben keine Zeile - gemerkt wird nur der Haken."""
|
||||
set_check("p12", db=db, user=nutzer)
|
||||
|
||||
assert einkaufsliste.abgehakte(db) == ["p12"]
|
||||
|
||||
clear_check("p12", db=db, _=nutzer)
|
||||
assert einkaufsliste.abgehakte(db) == []
|
||||
|
||||
|
||||
def test_derselbe_haken_zweimal_ist_kein_fehler(db, nutzer):
|
||||
set_check("g3", db=db, user=nutzer)
|
||||
set_check("g3", db=db, user=nutzer)
|
||||
|
||||
assert db.query(ShoppingCheck).count() == 1
|
||||
|
||||
|
||||
def test_erledigte_entfernen_raeumt_beide_quellen(db, nutzer):
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
|
||||
offen = create_entry(ShoppingEntryIn(free_text="Salz"), db=db, user=nutzer)
|
||||
set_check("p12", db=db, user=nutzer)
|
||||
|
||||
anzahl = clear_done(db=db, _=nutzer)
|
||||
|
||||
assert anzahl == 2
|
||||
assert [e.id for e in list_entries(db=db, _=nutzer)] == [offen.id]
|
||||
assert einkaufsliste.abgehakte(db) == []
|
||||
|
||||
|
||||
def test_die_gebuendelte_liste_fuehrt_beides(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
mehl = _artikel(db, "Weissmehl", cat, bestand=200)
|
||||
db.add(ProductLocationMinStock(product_id=mehl.id, location_id=None, min_stock=1000))
|
||||
db.commit()
|
||||
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
|
||||
set_check(f"p{mehl.id}", db=db, user=nutzer)
|
||||
|
||||
alles = shopping_list_all(db=db, user=nutzer)
|
||||
|
||||
assert [i.name for i in alles.products] == ["Weissmehl"]
|
||||
assert [e.name for e in alles.entries] == ["Backpapier"]
|
||||
assert alles.checked == [f"p{mehl.id}"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Aus einem Rezept übernehmen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_uebernommen_wird_die_fehlmenge_nicht_die_rezeptmenge(db, nutzer):
|
||||
"""Wer 250 g braucht und 100 g hat, kauft 150 g."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=100)
|
||||
|
||||
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert aktualisiert == 0
|
||||
[eintrag] = eintraege
|
||||
assert eintrag.quantity_base == 150
|
||||
assert eintrag.source == "recipe"
|
||||
assert eintrag.recipe_name == "Pfannkuchen"
|
||||
|
||||
|
||||
def test_vorhandene_zutaten_kommen_nicht_drauf(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=5000)
|
||||
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert eintraege == []
|
||||
assert db.query(ShoppingEntry).count() == 0
|
||||
|
||||
|
||||
def test_zutat_ohne_zuordnung_kommt_als_freitext_drauf(db, nutzer):
|
||||
"""Gerade die unbekannte Zutat ist die, die man vergisst."""
|
||||
_kategorie(db)
|
||||
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Ras el Hanout", 1, "Prise")], nutzer)
|
||||
|
||||
[eintrag] = eintraege
|
||||
assert eintrag.free_text == "Ras el Hanout"
|
||||
assert eintrag.product_id is None
|
||||
|
||||
|
||||
def test_zweimal_uebernehmen_verdoppelt_die_liste_nicht(db, nutzer):
|
||||
"""Nach dem Hochdrehen der Portionen drückt man den Knopf noch einmal."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=100)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 500, "g")], nutzer)
|
||||
|
||||
assert aktualisiert == 1
|
||||
assert db.query(ShoppingEntry).count() == 1
|
||||
assert eintraege[0].quantity_base == 400 # 500 gebraucht, 100 da
|
||||
|
||||
|
||||
def test_zwei_rezepte_brauchen_denselben_artikel_zweimal(db, nutzer):
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 300, "g")], nutzer,
|
||||
slug="brot", name="Brot")
|
||||
|
||||
zeilen = db.query(ShoppingEntry).all()
|
||||
assert len(zeilen) == 2
|
||||
assert {z.recipe_name for z in zeilen} == {"Pfannkuchen", "Brot"}
|
||||
|
||||
|
||||
def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer):
|
||||
"""Abgehakt heisst gekauft – braucht man es wieder, ist es eine neue Zeile."""
|
||||
cat = _kategorie(db)
|
||||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||||
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
einkaufsliste.abhaken(db, db.get(ShoppingEntry, eintraege[0].id), True)
|
||||
|
||||
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
|
||||
|
||||
assert db.query(ShoppingEntry).count() == 2
|
||||
|
||||
|
||||
def test_die_kennzahl_zaehlt_die_eigenen_eintraege_mit(db, nutzer):
|
||||
"""Sonst meldet das Dashboard „alles erreicht", waehrend die Liste voll ist."""
|
||||
vorher = dashboard_stats(db=db, _=nutzer).shopping_items
|
||||
|
||||
eintrag = create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
|
||||
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher + 1
|
||||
|
||||
# Abgehakt heisst gekauft - und zaehlt nicht mehr als Bedarf.
|
||||
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
|
||||
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher
|
||||
@@ -0,0 +1,735 @@
|
||||
"""Etiketten-Vorlagen: Spaltenwahl, eigene Felder, CSV-Form und .lbx-Prüfung.
|
||||
|
||||
Der Export ist eine reine Funktion über einer Datenbank-Sitzung – geprüft wird
|
||||
deshalb ohne HTTP, direkt am Ergebnis.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
EtikettenVorlage,
|
||||
FieldDefinition,
|
||||
Item,
|
||||
Location,
|
||||
Product,
|
||||
ProductFieldValue,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.routers.etiketten import _pruefe_lbx, datei_herunterladen, spalten
|
||||
from app.services.etiketten import csv_erzeugen, konfiguration_lesen, verfuegbare_spalten
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kabel(db):
|
||||
"""Kategorie „Kabel" mit eigenem Feld „Länge" in Metern."""
|
||||
cat = Category(name="Kabel", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(
|
||||
category_id=cat.id, label="Länge", key="laenge", field_type="number", unit="m"
|
||||
)
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
db.refresh(fd)
|
||||
return cat, fd
|
||||
|
||||
|
||||
def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=",", feldfilter=None):
|
||||
v = EtikettenVorlage(
|
||||
name=f"Test {len(db.query(EtikettenVorlage).all())}",
|
||||
art=art,
|
||||
konfiguration=json.dumps({
|
||||
"spalten": spalten,
|
||||
"kategorie_ids": kategorie_ids or [],
|
||||
"trennzeichen": trenn,
|
||||
"feldfilter": feldfilter or [],
|
||||
}),
|
||||
)
|
||||
db.add(v)
|
||||
db.commit()
|
||||
db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
def _stueck(db, cat, name="HDMI-Kabel", laenge=None, fd=None, ort=None):
|
||||
product = Product(name=name, category_id=cat.id, individual=True, package_size=1)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
if laenge is not None and fd is not None:
|
||||
db.add(ProductFieldValue(
|
||||
product_id=product.id, field_definition_id=fd.id, value=laenge
|
||||
))
|
||||
item = Item(uid=f"UID{product.id:05d}", product_id=product.id,
|
||||
location_id=(ort.id if ort else None))
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def _zeilen(csv_text):
|
||||
"""Ohne BOM, als Liste von Zeilen."""
|
||||
assert csv_text.startswith(""), "BOM fehlt – Excel zeigt sonst kaputte Umlaute"
|
||||
return csv_text[1:].rstrip("\r\n").split("\r\n")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Spalten und Reihenfolge
|
||||
# --------------------------------------------------------------------------
|
||||
def test_spalten_kommen_in_gespeicherter_reihenfolge(db, kabel):
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat)
|
||||
v = _vorlage(db, ["produkt", "uid", "qr"])
|
||||
|
||||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||||
assert kopf == "Produkt,UID,QR-Inhalt"
|
||||
|
||||
|
||||
def test_eigenes_feld_landet_in_der_csv(db, kabel):
|
||||
cat, fd = kabel
|
||||
_stueck(db, cat, laenge="3", fd=fd)
|
||||
v = _vorlage(db, ["uid", f"feld:{fd.id}"])
|
||||
|
||||
kopf, zeile = _zeilen(csv_erzeugen(db, v))
|
||||
assert kopf == "UID,Länge (m)"
|
||||
assert zeile.endswith(",3")
|
||||
|
||||
|
||||
def test_fehlender_feldwert_gibt_eine_leere_zelle(db, kabel):
|
||||
"""Nicht etwa eine Luecke – sonst verrutschen alle folgenden Spalten."""
|
||||
cat, fd = kabel
|
||||
_stueck(db, cat) # ohne Wert
|
||||
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
|
||||
|
||||
zeile = _zeilen(csv_erzeugen(db, v))[1]
|
||||
assert zeile.count(",") == 2
|
||||
assert ",," in zeile
|
||||
|
||||
|
||||
def test_geloeschtes_feld_zerreisst_den_export_nicht(db, kabel):
|
||||
cat, fd = kabel
|
||||
_stueck(db, cat)
|
||||
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
|
||||
db.delete(fd)
|
||||
db.commit()
|
||||
|
||||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||||
assert kopf == "UID,Produkt" # die tote Spalte faellt still weg
|
||||
|
||||
|
||||
def test_ohne_gueltige_spalte_greifen_die_festen(db, kabel):
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat)
|
||||
v = _vorlage(db, ["gibtsnicht"])
|
||||
|
||||
kopf = _zeilen(csv_erzeugen(db, v))[0]
|
||||
assert kopf.startswith("QR-Inhalt,UID,Produkt")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CSV-Form
|
||||
# --------------------------------------------------------------------------
|
||||
def test_trennzeichen_semikolon_und_tab(db, kabel):
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat)
|
||||
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn=";")))[0] \
|
||||
== "UID;Produkt"
|
||||
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn="\t")))[0] \
|
||||
== "UID\tProdukt"
|
||||
|
||||
|
||||
def test_zelle_mit_trennzeichen_wird_eingefasst(db, kabel):
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat, name="Kabel, lang")
|
||||
v = _vorlage(db, ["produkt"])
|
||||
|
||||
assert _zeilen(csv_erzeugen(db, v))[1] == '"Kabel, lang"'
|
||||
|
||||
|
||||
def test_formel_wird_entschaerft(db, kabel):
|
||||
"""Ohne das fuehrt Excel den Zellinhalt als Formel aus."""
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat, name="=1+1")
|
||||
v = _vorlage(db, ["produkt"])
|
||||
|
||||
assert _zeilen(csv_erzeugen(db, v))[1] == "'=1+1"
|
||||
|
||||
|
||||
def test_zeilenende_ist_crlf(db, kabel):
|
||||
cat, _ = kabel
|
||||
_stueck(db, cat)
|
||||
assert "\r\n" in csv_erzeugen(db, _vorlage(db, ["uid"]))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bezugsarten und Filter
|
||||
# --------------------------------------------------------------------------
|
||||
def test_kategoriefilter_greift_samt_unterkategorien(db, kabel):
|
||||
cat, _ = kabel
|
||||
unter = Category(name="HDMI", tracking=CategoryTracking.object.value, parent_id=cat.id)
|
||||
andere = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
|
||||
db.add_all([unter, andere])
|
||||
db.commit()
|
||||
_stueck(db, unter, name="HDMI 2.1")
|
||||
_stueck(db, andere, name="Hammer")
|
||||
|
||||
v = _vorlage(db, ["produkt"], kategorie_ids=[cat.id])
|
||||
zeilen = _zeilen(csv_erzeugen(db, v))
|
||||
assert zeilen[1:] == ["HDMI 2.1"]
|
||||
|
||||
|
||||
def test_lagerorte_brauchen_die_basis_adresse_fuer_den_qr(db):
|
||||
ort = Location(name="Keller")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
v = _vorlage(db, ["qr", "name"], art="locations")
|
||||
|
||||
zeile = _zeilen(csv_erzeugen(db, v, basis_url="https://haus.example/"))[1]
|
||||
assert zeile.startswith(f"https://haus.example/l/{ort.id},")
|
||||
|
||||
|
||||
def test_artikel_als_bezugsart(db, kabel):
|
||||
cat, fd = kabel
|
||||
_stueck(db, cat, name="USB-C", laenge="1", fd=fd)
|
||||
v = _vorlage(db, ["name", f"feld:{fd.id}"], art="products")
|
||||
|
||||
kopf, zeile = _zeilen(csv_erzeugen(db, v))
|
||||
assert kopf == "Name,Länge (m)"
|
||||
assert zeile == "USB-C,1"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Konfiguration lesen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_kaputte_konfiguration_ergibt_vorgaben(db):
|
||||
v = EtikettenVorlage(name="Kaputt", art="items", konfiguration="{nicht json")
|
||||
db.add(v)
|
||||
db.commit()
|
||||
|
||||
konf = konfiguration_lesen(v)
|
||||
assert konf == {
|
||||
"spalten": [],
|
||||
"kategorie_ids": [],
|
||||
"trennzeichen": ",",
|
||||
"feldfilter": [],
|
||||
}
|
||||
|
||||
|
||||
def test_unerlaubtes_trennzeichen_faellt_auf_komma_zurueck(db):
|
||||
v = _vorlage(db, ["uid"], trenn="|")
|
||||
assert konfiguration_lesen(v)["trennzeichen"] == ","
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Spaltenliste für die Oberfläche
|
||||
# --------------------------------------------------------------------------
|
||||
def test_spaltenliste_nennt_eigene_felder_der_kategorie(db, admin, kabel):
|
||||
cat, fd = kabel
|
||||
liste = spalten(art="items", kategorie_ids=str(cat.id), db=db, _=admin)
|
||||
schluessel = {s.schluessel for s in liste}
|
||||
|
||||
assert "qr" in schluessel and "uid" in schluessel
|
||||
assert f"feld:{fd.id}" in schluessel
|
||||
assert next(s.titel for s in liste if s.schluessel == f"feld:{fd.id}") == "Länge (m)"
|
||||
|
||||
|
||||
def test_lagerorte_haben_keine_eigenen_felder(db, admin, kabel):
|
||||
liste = spalten(art="locations", kategorie_ids=None, db=db, _=admin)
|
||||
assert not any(s.schluessel.startswith("feld:") for s in liste)
|
||||
|
||||
|
||||
def test_unbekannte_bezugsart_wird_abgelehnt(db, admin):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
spalten(art="quatsch", kategorie_ids=None, db=db, _=admin)
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# .lbx-Prüfung
|
||||
# --------------------------------------------------------------------------
|
||||
def _zip(namen):
|
||||
puffer = io.BytesIO()
|
||||
with zipfile.ZipFile(puffer, "w") as z:
|
||||
for n in namen:
|
||||
z.writestr(n, "<xml/>")
|
||||
return puffer.getvalue()
|
||||
|
||||
|
||||
def test_echte_lbx_wird_angenommen(db):
|
||||
_pruefe_lbx("etikett.lbx", _zip(["label.xml", "prop.xml"])) # wirft nicht
|
||||
|
||||
|
||||
def test_zip_ohne_label_xml_wird_abgelehnt(db):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_pruefe_lbx("etikett.lbx", _zip(["irgendwas.txt"]))
|
||||
assert fehler.value.status_code == 415
|
||||
|
||||
|
||||
def test_nicht_zip_wird_abgelehnt(db):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_pruefe_lbx("etikett.lbx", b"\x89PNG\r\n\x1a\n und noch was")
|
||||
assert fehler.value.status_code == 415
|
||||
|
||||
|
||||
def test_falsche_endung_wird_abgelehnt(db):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_pruefe_lbx("bild.png", _zip(["label.xml"]))
|
||||
assert fehler.value.status_code == 415
|
||||
|
||||
|
||||
def test_leere_datei_wird_abgelehnt(db):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_pruefe_lbx("etikett.lbx", b"")
|
||||
assert fehler.value.status_code == 400
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Backup-Umlauf
|
||||
# --------------------------------------------------------------------------
|
||||
def test_vorlage_uebersteht_die_sicherung(db, admin, kabel):
|
||||
"""Kategorien als Pfad, eigene Felder als Label – IDs sind zwischen zwei
|
||||
Instanzen nicht dieselben."""
|
||||
import json as _json
|
||||
|
||||
from app.routers.transfer import _import_json, export_backup_json
|
||||
from app.seed import ensure_builtin_units
|
||||
|
||||
cat, fd = kabel
|
||||
_stueck(db, cat, laenge="5", fd=fd)
|
||||
v = _vorlage(db, ["uid", f"feld:{fd.id}"], kategorie_ids=[cat.id], trenn=";")
|
||||
v.name = "Kabelexport"
|
||||
v.datei_name = "kabel.lbx"
|
||||
v.datei = _zip(["label.xml"])
|
||||
db.commit()
|
||||
|
||||
inhalt = export_backup_json(db=db, _=admin).body
|
||||
daten = _json.loads(inhalt)
|
||||
gesichert = daten["etiketten_vorlagen"]
|
||||
assert [e["name"] for e in gesichert] == ["Kabelexport"]
|
||||
# Das Feld steht als Pfad::Label drin, nicht als ID.
|
||||
assert gesichert[0]["spalten"][1] == "feld:Kabel::Länge"
|
||||
|
||||
# In eine zweite, leere Instanz einspielen.
|
||||
from app.database import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
zweiter_motor = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(bind=zweiter_motor)
|
||||
zweite = sessionmaker(bind=zweiter_motor)()
|
||||
ensure_builtin_units(zweite)
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
zweite.add(person)
|
||||
zweite.commit()
|
||||
|
||||
_import_json(zweite, inhalt, person, "add")
|
||||
|
||||
neu = zweite.query(EtikettenVorlage).filter(
|
||||
EtikettenVorlage.name == "Kabelexport"
|
||||
).one()
|
||||
konf = konfiguration_lesen(neu)
|
||||
assert konf["trennzeichen"] == ";"
|
||||
assert neu.datei_name == "kabel.lbx"
|
||||
assert neu.datei == _zip(["label.xml"])
|
||||
# Das Feld wurde ueber Pfad und Label wiedergefunden – mit NEUER ID.
|
||||
feld_spalte = [s for s in konf["spalten"] if s.startswith("feld:")][0]
|
||||
neue_fd = zweite.query(FieldDefinition).filter(FieldDefinition.label == "Länge").one()
|
||||
assert feld_spalte == f"feld:{neue_fd.id}"
|
||||
zweite.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Download der hinterlegten .lbx
|
||||
# --------------------------------------------------------------------------
|
||||
def _disposition_pruefen(antwort, erwarteter_name):
|
||||
"""Header muss sich senden lassen UND den echten Namen tragen."""
|
||||
roh = antwort.headers["content-disposition"]
|
||||
# Starlette kodiert Header als latin-1. Wirft das hier, antwortet die
|
||||
# Route im Betrieb mit 500 statt mit der Datei.
|
||||
roh.encode("latin-1")
|
||||
stern = roh.split("filename*=UTF-8''", 1)[1]
|
||||
assert unquote(stern) == erwarteter_name
|
||||
|
||||
|
||||
def test_lbx_mit_zerlegtem_umlaut_laesst_sich_herunterladen(db, admin):
|
||||
"""macOS legt Dateinamen ZERLEGT ab: das "ä" ist dort "a" + U+0308.
|
||||
|
||||
Am Bildschirm sieht das wie die zusammengesetzte Form aus, aber nur die
|
||||
passt in latin-1 - die Kodierung, mit der HTTP-Header geschrieben werden.
|
||||
Ein Name, den der Dateiwaehler eines Macs liefert, darf den Download
|
||||
nicht sprengen.
|
||||
"""
|
||||
name = unicodedata.normalize("NFD", "Lagerplätze.lbx")
|
||||
v = _vorlage(db, ["uid"])
|
||||
v.datei_name = name
|
||||
v.datei = _zip(["label.xml"])
|
||||
db.commit()
|
||||
|
||||
antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin)
|
||||
|
||||
assert antwort.body == _zip(["label.xml"])
|
||||
_disposition_pruefen(antwort, name)
|
||||
|
||||
|
||||
def test_lbx_mit_schlichtem_namen_bleibt_unveraendert(db, admin):
|
||||
"""Der haeufige Fall darf von der Umstellung nicht beruehrt werden."""
|
||||
v = _vorlage(db, ["uid"])
|
||||
v.datei_name = "kabel.lbx"
|
||||
v.datei = _zip(["label.xml"])
|
||||
db.commit()
|
||||
|
||||
antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin)
|
||||
|
||||
assert 'filename="kabel.lbx"' in antwort.headers["content-disposition"]
|
||||
_disposition_pruefen(antwort, "kabel.lbx")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Wertefilter: nur Zeilen mit bestimmten Feldwerten
|
||||
# --------------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def haken(db):
|
||||
"""Kategorie „Kleidung" mit einem Ja/Nein-Feld „Verliehen"."""
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(
|
||||
category_id=cat.id, label="Verliehen", key="verliehen", field_type="boolean"
|
||||
)
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
db.refresh(fd)
|
||||
return cat, fd
|
||||
|
||||
|
||||
def _artikel(db, cat, name, fd=None, wert=None):
|
||||
"""Artikel, optional mit einem Feldwert. ``wert=None`` legt KEINE Zeile an."""
|
||||
product = Product(name=name, category_id=cat.id, package_size=1)
|
||||
db.add(product)
|
||||
db.flush()
|
||||
if fd is not None and wert is not None:
|
||||
db.add(ProductFieldValue(
|
||||
product_id=product.id, field_definition_id=fd.id, value=wert
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _namen(db, vorlage):
|
||||
"""Die Namensspalte des erzeugten CSV, ohne Kopfzeile."""
|
||||
return _zeilen(csv_erzeugen(db, vorlage))[1:]
|
||||
|
||||
|
||||
def test_feldfilter_waehlt_nur_passende_artikel(db, haken):
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_nein_trifft_auch_artikel_ohne_zeile(db, haken):
|
||||
"""Der Kern: Haken wegnehmen loescht die Zeile.
|
||||
|
||||
„nein" und „nie gesetzt" sind in den Daten dasselbe. Ohne diese Gleichung
|
||||
faende „alle mit Nein" nur die, die einmal angehakt und wieder abgehakt
|
||||
wurden – und das ist fast niemand.
|
||||
"""
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
_artikel(db, cat, "Schal") # nie angefasst, gar keine Zeile
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "nein"}])
|
||||
|
||||
assert sorted(_namen(db, v)) == ["Hose", "Schal"]
|
||||
|
||||
|
||||
def test_leerer_wert_trifft_artikel_ohne_wert(db, kabel):
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Mit Laenge", fd, "5")
|
||||
_artikel(db, cat, "Ohne Laenge")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": ""}])
|
||||
|
||||
assert _namen(db, v) == ["Ohne Laenge"]
|
||||
|
||||
|
||||
def test_feldfilter_vergleicht_genau_nicht_teilweise(db, kabel):
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Kurz", fd, "3")
|
||||
_artikel(db, cat, "Lang", fd, "30")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "3"}])
|
||||
|
||||
assert _namen(db, v) == ["Kurz"]
|
||||
|
||||
|
||||
def test_mehrere_bedingungen_muessen_alle_zutreffen(db, haken):
|
||||
cat, fd_bool = haken
|
||||
fd_text = FieldDefinition(
|
||||
category_id=cat.id, label="Farbe", key="farbe", field_type="text"
|
||||
)
|
||||
db.add(fd_text)
|
||||
db.commit()
|
||||
db.refresh(fd_text)
|
||||
|
||||
blau = _artikel(db, cat, "Blaue Jacke", fd_bool, "true")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=blau.id, field_definition_id=fd_text.id, value="blau"
|
||||
))
|
||||
rot = _artikel(db, cat, "Rote Jacke", fd_bool, "true")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=rot.id, field_definition_id=fd_text.id, value="rot"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["name"], art="products", feldfilter=[
|
||||
{"feld_id": fd_bool.id, "wert": "ja"},
|
||||
{"feld_id": fd_text.id, "wert": "blau"},
|
||||
])
|
||||
|
||||
assert _namen(db, v) == ["Blaue Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_und_kategorie_greifen_zusammen(db, haken):
|
||||
cat, fd = haken
|
||||
zweite = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
|
||||
db.add(zweite)
|
||||
db.commit()
|
||||
db.refresh(zweite)
|
||||
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
# Gleicher Feldwert, andere Kategorie – die Kategorie muss ihn aussperren.
|
||||
fremder = _artikel(db, zweite, "Hammer")
|
||||
db.add(ProductFieldValue(
|
||||
product_id=fremder.id, field_definition_id=fd.id, value="true"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["name"], art="products", kategorie_ids=[cat.id],
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_feldfilter_wirkt_auch_bei_einzelstuecken(db, haken):
|
||||
"""Der Wert haengt am Artikel – das Stueck erbt ihn."""
|
||||
cat, fd = haken
|
||||
verliehen = _artikel(db, cat, "Jacke", fd, "true")
|
||||
daheim = _artikel(db, cat, "Hose", fd, "false")
|
||||
db.add(Item(uid="UIDAAAAA", product_id=verliehen.id))
|
||||
db.add(Item(uid="UIDBBBBB", product_id=daheim.id))
|
||||
db.commit()
|
||||
|
||||
v = _vorlage(db, ["produkt"], art="items",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Jacke"]
|
||||
|
||||
|
||||
def test_geloeschtes_feld_im_filter_zerreisst_den_export_nicht(db, haken):
|
||||
"""Wie bei einer Spalte zu einem geloeschten Feld: die Bedingung faellt weg.
|
||||
|
||||
Der Export wird dadurch WEITER, nicht leer – die harmlosere der beiden
|
||||
Fehlrichtungen, und hier festgehalten, damit sie nicht versehentlich kippt.
|
||||
"""
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
db.delete(fd)
|
||||
db.commit()
|
||||
|
||||
assert sorted(_namen(db, v)) == ["Hose", "Jacke"]
|
||||
|
||||
|
||||
def test_kaputter_feldfilter_ergibt_keinen_filter(db):
|
||||
for nr, roh in enumerate(('{"feldfilter": "quatsch"}',
|
||||
'{"feldfilter": [{"feld_id": "abc"}]}',
|
||||
'{"feldfilter": [42]}')):
|
||||
v = EtikettenVorlage(name=f"Kaputt {nr}", art="items", konfiguration=roh)
|
||||
db.add(v)
|
||||
db.commit()
|
||||
assert konfiguration_lesen(v)["feldfilter"] == []
|
||||
|
||||
|
||||
def test_feldfilter_uebersteht_die_sicherung(db, haken):
|
||||
"""Feld-IDs sind zwischen zwei Instanzen nicht dieselben.
|
||||
|
||||
Deshalb steht in der Sicherung „Kategorie::Label" statt einer ID – und
|
||||
zwar fuer Spalten UND Filter. Fehlte die Umschreibung beim Filter, zeigte
|
||||
er nach dem Zurueckspielen auf ein fremdes Feld oder waere still weg, und
|
||||
der Export gaebe mehr Zeilen aus als das Original.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from app.routers.transfer import _import_json, export_backup_json
|
||||
from app.seed import ensure_builtin_units
|
||||
|
||||
cat, fd = haken
|
||||
person = User(username="chef", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
|
||||
v.name = "Verliehenes"
|
||||
db.commit()
|
||||
|
||||
inhalt = export_backup_json(db=db, _=person).body
|
||||
gesichert = _json.loads(inhalt)["etiketten_vorlagen"][0]
|
||||
# Nach Pfad und Label, nie nach ID.
|
||||
assert gesichert["feldfilter"] == [
|
||||
{"feld": "Kleidung::Verliehen", "op": "=", "wert": "ja"}
|
||||
]
|
||||
|
||||
from app.database import Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
motor = create_engine(
|
||||
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||
)
|
||||
Base.metadata.create_all(bind=motor)
|
||||
zweite = sessionmaker(bind=motor)()
|
||||
ensure_builtin_units(zweite)
|
||||
zweiter_nutzer = User(username="chef", password_hash="x", role=Role.admin)
|
||||
zweite.add(zweiter_nutzer)
|
||||
zweite.commit()
|
||||
|
||||
_import_json(zweite, inhalt, zweiter_nutzer, "add")
|
||||
|
||||
neu = zweite.query(EtikettenVorlage).filter(
|
||||
EtikettenVorlage.name == "Verliehenes"
|
||||
).one()
|
||||
neues_feld = zweite.query(FieldDefinition).filter(
|
||||
FieldDefinition.label == "Verliehen"
|
||||
).one()
|
||||
konf = konfiguration_lesen(neu)
|
||||
# Auf die ID der ZWEITEN Instanz umgeschrieben, Wert unveraendert.
|
||||
assert konf["feldfilter"] == [{"feld_id": neues_feld.id, "op": "=", "wert": "ja"}]
|
||||
assert _namen(zweite, neu) == ["Jacke"]
|
||||
zweite.close()
|
||||
|
||||
|
||||
def test_zahlenfeld_groesser_gleich(db, kabel):
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Kurz", fd, "2")
|
||||
_artikel(db, cat, "Mittel", fd, "5")
|
||||
_artikel(db, cat, "Lang", fd, "10")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "op": ">=", "wert": "5"}])
|
||||
|
||||
assert sorted(_namen(db, v)) == ["Lang", "Mittel"]
|
||||
|
||||
|
||||
def test_zahlenfeld_kleiner_gleich_mit_komma(db, kabel):
|
||||
"""Gespeichert wird der getippte Text – „2,5" muss als Zahl gelten."""
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Kurz", fd, "2,5")
|
||||
_artikel(db, cat, "Lang", fd, "10")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "op": "<=", "wert": "3"}])
|
||||
|
||||
assert _namen(db, v) == ["Kurz"]
|
||||
|
||||
|
||||
def test_groesser_gleich_trifft_artikel_ohne_wert_nicht(db, kabel):
|
||||
"""„leer" ist nicht kleiner als drei – es ist unvergleichbar."""
|
||||
cat, fd = kabel
|
||||
_artikel(db, cat, "Mit Wert", fd, "9")
|
||||
_artikel(db, cat, "Ohne Wert")
|
||||
for op in (">=", "<="):
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "op": op, "wert": "3"}])
|
||||
assert "Ohne Wert" not in _namen(db, v)
|
||||
|
||||
|
||||
def test_ungleich_bei_auswahlfeld(db):
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
fd = FieldDefinition(
|
||||
category_id=cat.id, label="Größe", key="groesse", field_type="select",
|
||||
options=json.dumps(["S", "M", "L"]),
|
||||
)
|
||||
db.add(fd)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
db.refresh(fd)
|
||||
|
||||
_artikel(db, cat, "Klein", fd, "S")
|
||||
_artikel(db, cat, "Mittel", fd, "M")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "op": "!=", "wert": "S"}])
|
||||
|
||||
assert _namen(db, v) == ["Mittel"]
|
||||
|
||||
|
||||
def test_ungleich_bei_ja_nein(db, haken):
|
||||
cat, fd = haken
|
||||
_artikel(db, cat, "Jacke", fd, "true")
|
||||
_artikel(db, cat, "Hose", fd, "false")
|
||||
v = _vorlage(db, ["name"], art="products",
|
||||
feldfilter=[{"feld_id": fd.id, "op": "!=", "wert": "ja"}])
|
||||
|
||||
assert _namen(db, v) == ["Hose"]
|
||||
|
||||
|
||||
def test_unbekannte_vergleichsart_faellt_auf_gleich_zurueck(db, haken):
|
||||
cat, fd = haken
|
||||
v = EtikettenVorlage(name="Krumm", art="products", konfiguration=json.dumps({
|
||||
"spalten": ["name"],
|
||||
"feldfilter": [{"feld_id": fd.id, "op": "~~", "wert": "ja"}],
|
||||
}))
|
||||
db.add(v)
|
||||
db.commit()
|
||||
|
||||
assert konfiguration_lesen(v)["feldfilter"][0]["op"] == "="
|
||||
|
||||
|
||||
def test_auswahlfeld_liefert_seine_optionen_zur_spalte(db):
|
||||
"""Die Oberflaeche kann nur dann ein Dropdown anbieten."""
|
||||
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
db.add(FieldDefinition(
|
||||
category_id=cat.id, label="Größe", key="groesse", field_type="select",
|
||||
options=json.dumps(["S", "M"]),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
spalten = verfuegbare_spalten(db, "products", [cat.id])
|
||||
feld = [s for s in spalten if s["schluessel"].startswith("feld:")][0]
|
||||
assert feld["typ"] == "select"
|
||||
assert feld["optionen"] == ["S", "M"]
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Hausmaße: „2 EL" wiegt bei Mehl anders als bei Zucker.
|
||||
|
||||
Die Menge gehört an die Kategorie bzw. den Artikel, nicht an den Namen – ein
|
||||
globaler Faktor wie bei den verwalteten Einheiten wäre hier zwangsläufig falsch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryMeasure,
|
||||
CategoryTracking,
|
||||
Measure,
|
||||
Product,
|
||||
ProductMeasure,
|
||||
)
|
||||
from app.routers.measures import category_measures
|
||||
from app.seed import ensure_builtin_measures
|
||||
from app.services.conversion import ConversionError, to_base
|
||||
from app.services.stock import check_in, check_out, current_stock
|
||||
from app.services.vererbung import (
|
||||
geerbte_kategorie_frist,
|
||||
mass_aufloesen,
|
||||
verfuegbare_masse,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def masse(db):
|
||||
ensure_builtin_measures(db)
|
||||
return {m.name: m for m in db.query(Measure).all()}
|
||||
|
||||
|
||||
def _kategorie(db, name, parent=None):
|
||||
cat = Category(
|
||||
name=name,
|
||||
parent_id=parent.id if parent else None,
|
||||
tracking=CategoryTracking.food.value,
|
||||
)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000):
|
||||
product = Product(
|
||||
name=name, base_unit=base, package_size=package_size,
|
||||
category_id=cat.id, source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _mass_an_kategorie(db, measure, cat, amount, base=BaseUnit.gram):
|
||||
db.add(CategoryMeasure(
|
||||
measure_id=measure.id, category_id=cat.id, amount=amount, base=base.value
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Derselbe Löffel, zwei Gewichte
|
||||
# --------------------------------------------------------------------------
|
||||
def test_esslöffel_wiegt_je_kategorie_anders(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
mehl_kat = _kategorie(db, "Mehl")
|
||||
zucker_kat = _kategorie(db, "Zucker")
|
||||
mehl = _artikel(db, "Weissmehl", mehl_kat)
|
||||
zucker = _artikel(db, "Kristallzucker", zucker_kat)
|
||||
_mass_an_kategorie(db, el, mehl_kat, 10)
|
||||
_mass_an_kategorie(db, el, zucker_kat, 12)
|
||||
|
||||
assert to_base(db, mehl, 2, "EL", hausmasse=True) == 20
|
||||
assert to_base(db, zucker, 2, "EL", hausmasse=True) == 24
|
||||
|
||||
|
||||
def test_kurzform_und_grossschreibung(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
_mass_an_kategorie(db, el, kat, 10)
|
||||
|
||||
for token in ("EL", "el", "Esslöffel", "esslöffel", " EL "):
|
||||
assert to_base(db, mehl, 1, token, hausmasse=True) == 10
|
||||
|
||||
|
||||
def test_tasse_in_millilitern(db, masse):
|
||||
tasse = masse["Tasse"]
|
||||
kat = _kategorie(db, "Molkerei")
|
||||
milch = _artikel(db, "Vollmilch", kat, base=BaseUnit.milliliter)
|
||||
_mass_an_kategorie(db, tasse, kat, 200, base=BaseUnit.milliliter)
|
||||
|
||||
assert to_base(db, milch, 1.5, "Tasse", hausmasse=True) == 300
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Vererbung: Artikel > nächste Kategorie > Oberkategorie
|
||||
# --------------------------------------------------------------------------
|
||||
def test_unterkategorie_erbt_von_oben(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
trocken = _kategorie(db, "Trockenwaren")
|
||||
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
|
||||
mehl = _artikel(db, "Weissmehl", mehl_kat)
|
||||
_mass_an_kategorie(db, el, trocken, 11)
|
||||
|
||||
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 11
|
||||
angabe = mass_aufloesen(db, mehl, "EL")
|
||||
assert angabe.herkunft == "Kategorie: Trockenwaren"
|
||||
|
||||
|
||||
def test_naehere_kategorie_schlaegt_die_obere(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
trocken = _kategorie(db, "Trockenwaren")
|
||||
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
|
||||
mehl = _artikel(db, "Weissmehl", mehl_kat)
|
||||
_mass_an_kategorie(db, el, trocken, 11)
|
||||
_mass_an_kategorie(db, el, mehl_kat, 9)
|
||||
|
||||
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 9
|
||||
|
||||
|
||||
def test_artikel_schlaegt_die_kategorie(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Zucker")
|
||||
puder = _artikel(db, "Puderzucker", kat)
|
||||
_mass_an_kategorie(db, el, kat, 12)
|
||||
db.add(ProductMeasure(
|
||||
measure_id=el.id, product_id=puder.id, amount=8, base=BaseUnit.gram.value
|
||||
))
|
||||
db.commit()
|
||||
|
||||
assert to_base(db, puder, 1, "EL", hausmasse=True) == 8
|
||||
assert mass_aufloesen(db, puder, "EL").herkunft == "Artikel"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Lücken und Fehler
|
||||
# --------------------------------------------------------------------------
|
||||
def test_mass_ohne_menge_meldet_sich_deutlich(db, masse):
|
||||
"""Die Meldung ist die Grundlage für „Maß jetzt anlegen" im Rezeptabgleich."""
|
||||
kat = _kategorie(db, "Hülsenfrüchte")
|
||||
linsen = _artikel(db, "Berglinsen", kat)
|
||||
|
||||
with pytest.raises(ConversionError) as fehler:
|
||||
to_base(db, linsen, 1, "Tasse", hausmasse=True)
|
||||
assert "kein Maß 'Tasse' hinterlegt" in str(fehler.value)
|
||||
|
||||
|
||||
def test_voellig_unbekannte_einheit_bleibt_unbekannte_einheit(db, masse):
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
|
||||
with pytest.raises(ConversionError) as fehler:
|
||||
to_base(db, mehl, 1, "furlong", hausmasse=True)
|
||||
assert "Unbekannte Einheit" in str(fehler.value)
|
||||
|
||||
|
||||
def test_artfremdes_mass_braucht_die_zweiteinheit(db, masse):
|
||||
"""Ein Gramm-Maß an einem Stück-Artikel geht nur über die Brücke."""
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Backwaren")
|
||||
broetchen = _artikel(db, "Brötchen", kat, base=BaseUnit.piece, package_size=6)
|
||||
_mass_an_kategorie(db, el, kat, 20) # in Gramm
|
||||
|
||||
with pytest.raises(ConversionError) as fehler:
|
||||
to_base(db, broetchen, 1, "EL", hausmasse=True)
|
||||
assert "Zweiteinheit" in str(fehler.value)
|
||||
|
||||
# Mit Brücke „3 Stück ≙ 150 g" sind 20 g dann 0,4 Stück.
|
||||
broetchen.secondary_base = BaseUnit.gram.value
|
||||
broetchen.secondary_count = 3
|
||||
broetchen.secondary_amount = 150
|
||||
db.commit()
|
||||
assert to_base(db, broetchen, 1, "EL", hausmasse=True) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_packung_schlaegt_das_hausmass(db, masse):
|
||||
"""„package" wird zuerst geprüft – daran darf ein Hausmaß nichts ändern."""
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat, package_size=1000)
|
||||
neu = Measure(name="Packung", short="Pck", position=99)
|
||||
db.add(neu)
|
||||
db.commit()
|
||||
_mass_an_kategorie(db, neu, kat, 7)
|
||||
|
||||
assert to_base(db, mehl, 1, "package", hausmasse=True) == 1000
|
||||
|
||||
|
||||
def test_ohne_schalter_gilt_das_hausmass_nicht(db, masse):
|
||||
"""Einlagern und Mindestbestände dürfen keine Schätzwerte annehmen."""
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
_mass_an_kategorie(db, el, kat, 10)
|
||||
|
||||
with pytest.raises(ConversionError) as fehler:
|
||||
to_base(db, mehl, 2, "EL") # ohne hausmasse=True
|
||||
assert "Unbekannte Einheit" in str(fehler.value)
|
||||
|
||||
|
||||
def test_einlagern_nimmt_kein_hausmass(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
_mass_an_kategorie(db, el, kat, 10)
|
||||
|
||||
with pytest.raises(ConversionError):
|
||||
check_in(db, mehl, 2, "EL", None, None, None)
|
||||
|
||||
|
||||
def test_auslagern_nimmt_ein_hausmass(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
_mass_an_kategorie(db, el, kat, 10)
|
||||
check_in(db, mehl, 1, "package", None, None, None)
|
||||
db.commit()
|
||||
|
||||
check_out(db, mehl, 2, "EL", None)
|
||||
db.commit()
|
||||
|
||||
assert current_stock(db, mehl.id) == 980
|
||||
|
||||
|
||||
def test_einheit_schlaegt_gleichnamiges_hausmass(db, masse):
|
||||
"""Ein Maß „Liter" darf die echte Einheit nicht verdecken."""
|
||||
kat = _kategorie(db, "Getränke")
|
||||
saft = _artikel(db, "Apfelsaft", kat, base=BaseUnit.milliliter, package_size=1000)
|
||||
liter_mass = Measure(name="Liter", short="Ltr", position=99)
|
||||
db.add(liter_mass)
|
||||
db.commit()
|
||||
_mass_an_kategorie(db, liter_mass, kat, 5, base=BaseUnit.milliliter)
|
||||
|
||||
assert to_base(db, saft, 1, "Liter", hausmasse=True) == 1000
|
||||
|
||||
|
||||
def test_verfuegbare_masse_listet_nur_hinterlegte(db, masse):
|
||||
el = masse["Esslöffel"]
|
||||
kat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", kat)
|
||||
_mass_an_kategorie(db, el, kat, 10)
|
||||
|
||||
liste = verfuegbare_masse(db, mehl)
|
||||
|
||||
assert [a.measure.name for a in liste] == ["Esslöffel"]
|
||||
assert liste[0].amount == 10
|
||||
assert liste[0].herkunft == "Kategorie: Mehl"
|
||||
|
||||
|
||||
def test_artikel_ohne_kategorie_hat_keine_masse(db, masse):
|
||||
product = Product(name="Irgendwas", base_unit=BaseUnit.gram, source="manual")
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
assert verfuegbare_masse(db, product) == []
|
||||
assert mass_aufloesen(db, product, "EL") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bearbeitungsansicht: eigene und geerbte Mengen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_kategorie_sieht_ihre_eigenen_mengen(db, masse):
|
||||
"""Ohne Vererbung ist die Liste die reine Bearbeitungsansicht."""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
|
||||
liste = category_measures(ober.id, db=db, _=None)
|
||||
|
||||
assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")]
|
||||
|
||||
|
||||
def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse):
|
||||
"""Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?"""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
unter = _kategorie(db, "Backzutaten", parent=ober)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
|
||||
ohne = category_measures(unter.id, db=db, _=None)
|
||||
mit = category_measures(unter.id, vererbt=True, db=db, _=None)
|
||||
|
||||
assert ohne == []
|
||||
assert [(e.name, e.amount, e.herkunft) for e in mit] == [
|
||||
("Esslöffel", 10, "Kategorie: Mehl & Zucker"),
|
||||
]
|
||||
|
||||
|
||||
def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse):
|
||||
"""Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen."""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
unter = _kategorie(db, "Backzutaten", parent=ober)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], unter, 12)
|
||||
|
||||
liste = category_measures(unter.id, vererbt=True, db=db, _=None)
|
||||
|
||||
assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")]
|
||||
|
||||
|
||||
def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db):
|
||||
ober = _kategorie(db, "Molkerei")
|
||||
unter = _kategorie(db, "Frischmilch", parent=ober)
|
||||
ober.open_days = 5
|
||||
db.commit()
|
||||
|
||||
assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei")
|
||||
# Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich.
|
||||
assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen"
|
||||
@@ -8,12 +8,15 @@ from app.models import (
|
||||
Item,
|
||||
ItemDocument,
|
||||
Location,
|
||||
Movement,
|
||||
MovementType,
|
||||
Product,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
|
||||
from app.schemas import ItemCreate
|
||||
from app.services.stock import current_stock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -39,7 +42,7 @@ def _product(db):
|
||||
def test_item_kaufpreis_round_trip(db, admin):
|
||||
product = _product(db)
|
||||
out = create_items(
|
||||
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
|
||||
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, user=admin
|
||||
)
|
||||
assert out[0].price_cents == 49900
|
||||
assert out[0].currency == "CHF"
|
||||
@@ -47,7 +50,7 @@ def test_item_kaufpreis_round_trip(db, admin):
|
||||
|
||||
def test_item_out_listet_belege(db, admin):
|
||||
product = _product(db)
|
||||
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
|
||||
item_id = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0].id
|
||||
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
|
||||
content_type="application/pdf", data=b"%PDF-1.4 x"))
|
||||
db.commit()
|
||||
@@ -68,7 +71,7 @@ def test_list_all_items_angereichert(db, admin):
|
||||
ort = Location(name="Keller")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin)
|
||||
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, user=admin)
|
||||
|
||||
alle = list_all_items(db=db, _=admin)
|
||||
assert len(alle) == 2
|
||||
@@ -84,3 +87,44 @@ def test_safe_filename_entfernt_header_zeichen():
|
||||
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
|
||||
assert _safe_filename(" ") == "beleg"
|
||||
assert _safe_filename(None) == "beleg"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verlauf: Anlegen und Entfernen muessen beide auftauchen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_anlegen_schreibt_bewegung_je_stueck(db, admin):
|
||||
"""Frueher kannte der Verlauf nur das Verschwinden, nie das Entstehen."""
|
||||
product = _product(db)
|
||||
ort = Location(name="Keller")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
|
||||
create_items(product.id, ItemCreate(count=3, location_id=ort.id), db=db, user=admin)
|
||||
|
||||
bewegungen = db.query(Movement).filter(Movement.product_id == product.id).all()
|
||||
assert len(bewegungen) == 3
|
||||
for b in bewegungen:
|
||||
assert b.type == MovementType.in_
|
||||
assert b.quantity == 1
|
||||
assert b.unit_used == "Stück"
|
||||
assert b.location_id == ort.id
|
||||
assert b.user_id == admin.id
|
||||
# Die UID gehoert in den Eintrag, sonst sagt der Verlauf nicht, welches Stueck.
|
||||
uids = {i.uid for i in db.query(Item).filter(Item.product_id == product.id)}
|
||||
assert {b.note.removeprefix("Einzelstück ") for b in bewegungen} == uids
|
||||
|
||||
|
||||
def test_anlegen_uebernimmt_die_notiz_als_grund(db, admin):
|
||||
"""Eine eigene Notiz ist aussagekraeftiger als die UID - sie gewinnt."""
|
||||
product = _product(db)
|
||||
create_items(product.id, ItemCreate(count=1, note="Geschenk von Oma"), db=db, user=admin)
|
||||
|
||||
bewegung = db.query(Movement).filter(Movement.product_id == product.id).one()
|
||||
assert bewegung.note == "Geschenk von Oma"
|
||||
|
||||
|
||||
def test_anlegen_veraendert_den_bestand_nicht_doppelt(db, admin):
|
||||
"""Der Bestand zaehlt Zeilen in items - die Bewegung darf nichts dazutun."""
|
||||
product = _product(db)
|
||||
create_items(product.id, ItemCreate(count=2), db=db, user=admin)
|
||||
assert current_stock(db, product.id) == 2.0
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Zusammenfassen gleichartiger Chargen – und die Ausnahme für angebrochene.
|
||||
|
||||
Diese Logik war bisher ungetestet, obwohl vier Stellen sie aufrufen
|
||||
(`check_in`, `bulk_lot_location`, `split_lot`, `update_lot`). Mit dem Anbrechen
|
||||
wird sie gefährlich: Ohne die Ausnahme würde eine offene Packung beim nächsten
|
||||
Einlagern am selben Ort still mit einer ungeöffneten verschmolzen und ihr
|
||||
kürzeres Verbrauchsdatum verlieren.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import BaseUnit, Location, Lot, Movement, MovementType, Product, Role, User
|
||||
from app.routers.stock import bulk_lot_location, open_lot
|
||||
from app.schemas import BulkLotLocation, LotOpen
|
||||
from app.services.stock import (
|
||||
check_in,
|
||||
consolidate_duplicate_lots,
|
||||
consolidate_lot_group,
|
||||
current_stock,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def keller(db):
|
||||
ort = Location(name="Keller")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
db.refresh(ort)
|
||||
return ort
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def milch(db):
|
||||
product = Product(
|
||||
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _lot(db, product, menge, best_before=None, ort=None):
|
||||
lot = Lot(
|
||||
product_id=product.id, quantity=menge, best_before=best_before,
|
||||
location_id=ort.id if ort else None,
|
||||
)
|
||||
db.add(lot)
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
return lot
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Das bisherige Verhalten muss bleiben
|
||||
# --------------------------------------------------------------------------
|
||||
def test_gleiche_chargen_werden_zusammengefasst(db, milch, keller):
|
||||
mhd = date.today() + timedelta(days=10)
|
||||
_lot(db, milch, 1000, mhd, keller)
|
||||
_lot(db, milch, 2000, mhd, keller)
|
||||
|
||||
survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
|
||||
db.commit()
|
||||
|
||||
assert survivor.quantity == 3000
|
||||
assert db.query(Lot).count() == 1
|
||||
|
||||
|
||||
def test_verschiedenes_mhd_bleibt_getrennt(db, milch, keller):
|
||||
_lot(db, milch, 1000, date.today() + timedelta(days=5), keller)
|
||||
_lot(db, milch, 1000, date.today() + timedelta(days=9), keller)
|
||||
|
||||
consolidate_duplicate_lots(db)
|
||||
|
||||
assert db.query(Lot).count() == 2
|
||||
|
||||
|
||||
def test_bewegungen_haengen_an_der_verbleibenden_charge(db, milch, keller, user):
|
||||
mhd = date.today() + timedelta(days=10)
|
||||
alt = _lot(db, milch, 1000, mhd, keller)
|
||||
neu = _lot(db, milch, 1000, mhd, keller)
|
||||
db.add(Movement(product_id=milch.id, lot_id=neu.id, type=MovementType.in_,
|
||||
quantity=1000, unit_used="ml"))
|
||||
db.commit()
|
||||
|
||||
survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
|
||||
db.commit()
|
||||
|
||||
assert survivor.id == alt.id
|
||||
assert db.query(Movement).one().lot_id == alt.id
|
||||
|
||||
|
||||
def test_einlagern_fasst_mit_der_bestehenden_charge_zusammen(db, milch, keller):
|
||||
mhd = date.today() + timedelta(days=10)
|
||||
check_in(db, milch, 1, "package", mhd, keller.id, None)
|
||||
check_in(db, milch, 1, "package", mhd, keller.id, None)
|
||||
db.commit()
|
||||
|
||||
assert db.query(Lot).count() == 1
|
||||
assert current_stock(db, milch.id) == 2000
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Die Ausnahme: Angebrochenes bleibt für sich
|
||||
# --------------------------------------------------------------------------
|
||||
def test_angebrochene_charge_wird_nicht_eingesammelt(db, milch, keller, user):
|
||||
mhd = date.today() + timedelta(days=20)
|
||||
lot = _lot(db, milch, 2000, mhd, keller)
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
verbrauchsdatum = offen.consume_by
|
||||
|
||||
consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
|
||||
db.commit()
|
||||
|
||||
assert db.query(Lot).count() == 2
|
||||
frisch = db.get(Lot, offen.id)
|
||||
assert frisch is not None
|
||||
assert frisch.consume_by == verbrauchsdatum
|
||||
|
||||
|
||||
def test_einlagern_laesst_die_offene_packung_in_ruhe(db, milch, keller, user):
|
||||
"""Der gefährlichste Pfad: nachkaufen, während eine Packung offen ist."""
|
||||
mhd = date.today() + timedelta(days=20)
|
||||
lot = _lot(db, milch, 2000, mhd, keller)
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
|
||||
check_in(db, milch, 1, "package", mhd, keller.id, None)
|
||||
db.commit()
|
||||
|
||||
frisch = db.get(Lot, offen.id)
|
||||
assert frisch.quantity == 1000
|
||||
assert frisch.opened_on is not None
|
||||
# Die zugekaufte Packung verschmilzt mit der verbliebenen ungeöffneten.
|
||||
zu = db.query(Lot).filter(Lot.opened_on.is_(None)).all()
|
||||
assert len(zu) == 1
|
||||
assert zu[0].quantity == 2000
|
||||
assert current_stock(db, milch.id) == 3000
|
||||
|
||||
|
||||
def test_umlagern_mehrerer_chargen_laesst_offenes_getrennt(db, milch, keller, user):
|
||||
mhd = date.today() + timedelta(days=20)
|
||||
lot = _lot(db, milch, 3000, mhd, None)
|
||||
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
zweite = _lot(db, milch, 1000, mhd, None)
|
||||
|
||||
bulk_lot_location(
|
||||
BulkLotLocation(lot_ids=[offen.id, zweite.id, lot.id], location_id=keller.id),
|
||||
db=db, _=user,
|
||||
)
|
||||
|
||||
offene = db.query(Lot).filter(Lot.opened_on.isnot(None)).all()
|
||||
assert len(offene) == 1
|
||||
assert offene[0].quantity == 1000
|
||||
assert offene[0].location_id == keller.id
|
||||
assert current_stock(db, milch.id) == 4000
|
||||
|
||||
|
||||
def test_start_aufraeumer_ruehrt_offenes_nicht_an(db, milch, keller, user):
|
||||
mhd = date.today() + timedelta(days=20)
|
||||
lot = _lot(db, milch, 2000, mhd, keller)
|
||||
open_lot(lot.id, LotOpen(), db=db, _=user)
|
||||
# Zwei ungeöffnete Dubletten dazu, die zusammengefasst werden sollen.
|
||||
_lot(db, milch, 500, mhd, keller)
|
||||
|
||||
entfernt = consolidate_duplicate_lots(db)
|
||||
|
||||
assert entfernt == 1
|
||||
assert db.query(Lot).filter(Lot.opened_on.isnot(None)).count() == 1
|
||||
assert db.query(Lot).filter(Lot.opened_on.is_(None)).count() == 1
|
||||
assert current_stock(db, milch.id) == 2500
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Mehrere Chargen auf einmal: komplett ausbuchen, Teilmenge, löschen."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import BaseUnit, Lot, Movement, MovementType, Product, Role, User
|
||||
from app.routers.stock import bulk_lot_checkout, bulk_lot_delete
|
||||
from app.schemas import BulkLotCheckout, BulkLotDelete
|
||||
from app.services.stock import current_stock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
person = User(username="tester", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def milch(db):
|
||||
product = Product(
|
||||
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _lot(db, product, menge, tage=None):
|
||||
lot = Lot(
|
||||
product_id=product.id, quantity=menge,
|
||||
best_before=(date.today() + timedelta(days=tage)) if tage is not None else None,
|
||||
)
|
||||
db.add(lot)
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
return lot
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Komplett ausbuchen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_mehrere_chargen_komplett_ausbuchen(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
b = _lot(db, milch, 2000, 9)
|
||||
|
||||
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
|
||||
|
||||
assert anzahl == 2
|
||||
assert db.query(Lot).count() == 0
|
||||
assert current_stock(db, milch.id) == 0
|
||||
|
||||
|
||||
def test_je_charge_eine_bewegung(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
b = _lot(db, milch, 2000, 9)
|
||||
|
||||
bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
|
||||
|
||||
bewegungen = db.query(Movement).all()
|
||||
assert len(bewegungen) == 2
|
||||
assert {m.quantity for m in bewegungen} == {1000, 2000}
|
||||
assert all(m.type == MovementType.out for m in bewegungen)
|
||||
|
||||
|
||||
def test_notiz_landet_an_der_bewegung(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[a.id], note="Grossputz"), db=db, user=user
|
||||
)
|
||||
|
||||
assert db.query(Movement).one().note == "Grossputz"
|
||||
|
||||
|
||||
def test_leere_auswahl_tut_nichts(db, user, milch):
|
||||
_lot(db, milch, 1000, 5)
|
||||
assert bulk_lot_checkout(BulkLotCheckout(lot_ids=[]), db=db, user=user) == 0
|
||||
assert current_stock(db, milch.id) == 1000
|
||||
|
||||
|
||||
def test_unbekannte_ids_werden_uebergangen(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
|
||||
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, 999]), db=db, user=user)
|
||||
|
||||
assert anzahl == 1
|
||||
assert current_stock(db, milch.id) == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Teilmenge je Charge
|
||||
# --------------------------------------------------------------------------
|
||||
def test_teilmenge_je_charge(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
b = _lot(db, milch, 2000, 9)
|
||||
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=200, unit="ml"), db=db, user=user
|
||||
)
|
||||
|
||||
assert db.get(Lot, a.id).quantity == 800
|
||||
assert db.get(Lot, b.id).quantity == 1800
|
||||
|
||||
|
||||
def test_teilmenge_bei_gemischten_artikeln_wird_abgelehnt(db, user, milch):
|
||||
"""„1 Packung" bei Milch und Brötchen sind verschiedene Dinge."""
|
||||
broetchen = Product(name="Brötchen", base_unit=BaseUnit.piece, package_size=6,
|
||||
source="manual")
|
||||
db.add(broetchen)
|
||||
db.commit()
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
b = _lot(db, broetchen, 6, 2)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=1, unit="package"),
|
||||
db=db, user=user,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert db.get(Lot, a.id).quantity == 1000 # nichts gebucht
|
||||
assert db.get(Lot, b.id).quantity == 6
|
||||
|
||||
|
||||
def test_zu_wenig_bestand_rollt_alles_zurueck(db, user, milch):
|
||||
"""Ein halb gebuchter Stand wäre schlimmer als gar keiner."""
|
||||
viel = _lot(db, milch, 1000, 5)
|
||||
wenig = _lot(db, milch, 100, 9)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[viel.id, wenig.id], quantity=500, unit="ml"),
|
||||
db=db, user=user,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 409
|
||||
assert current_stock(db, milch.id) == 1100 # unverändert
|
||||
assert db.query(Movement).count() == 0
|
||||
|
||||
|
||||
def test_unbekannte_einheit_bucht_nichts(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[a.id], quantity=1, unit="furlong"), db=db, user=user
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert current_stock(db, milch.id) == 1000
|
||||
|
||||
|
||||
def test_teilmenge_leert_und_entfernt_die_charge(db, user, milch):
|
||||
a = _lot(db, milch, 500, 5)
|
||||
|
||||
bulk_lot_checkout(
|
||||
BulkLotCheckout(lot_ids=[a.id], quantity=500, unit="ml"), db=db, user=user
|
||||
)
|
||||
|
||||
assert db.get(Lot, a.id) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Löschen als Korrektur
|
||||
# --------------------------------------------------------------------------
|
||||
def test_mehrere_chargen_loeschen(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
b = _lot(db, milch, 2000, 9)
|
||||
|
||||
anzahl = bulk_lot_delete(BulkLotDelete(lot_ids=[a.id, b.id]), db=db, user=user)
|
||||
|
||||
assert anzahl == 2
|
||||
assert db.query(Lot).count() == 0
|
||||
|
||||
|
||||
def test_loeschen_schreibt_korrektur_nicht_verbrauch(db, user, milch):
|
||||
a = _lot(db, milch, 1000, 5)
|
||||
|
||||
bulk_lot_delete(BulkLotDelete(lot_ids=[a.id]), db=db, user=user)
|
||||
|
||||
bewegung = db.query(Movement).one()
|
||||
assert bewegung.type == MovementType.adjust
|
||||
assert bewegung.quantity == -1000
|
||||
assert bewegung.note == "Charge gelöscht"
|
||||
# Keine Charge mehr, an der die Bewegung haengen koennte.
|
||||
assert bewegung.lot_id is None
|
||||
|
||||
|
||||
def test_loeschen_mit_leerer_auswahl(db, user, milch):
|
||||
_lot(db, milch, 1000, 5)
|
||||
assert bulk_lot_delete(BulkLotDelete(lot_ids=[]), db=db, user=user) == 0
|
||||
assert current_stock(db, milch.id) == 1000
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Mealie-Anbindung: Anmelden, Dauertoken holen, und das Passwort vergessen.
|
||||
|
||||
Ohne Netz: ``httpx.MockTransport`` liefert die Antworten, die eine echte
|
||||
Mealie-Instanz geben würde.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import IntegrationSecret, Role, Setting, User
|
||||
from app.routers.mealie import (
|
||||
mealie_available,
|
||||
mealie_konfigurieren,
|
||||
mealie_status,
|
||||
mealie_token_setzen,
|
||||
mealie_trennen,
|
||||
)
|
||||
from app.routers.recipes import get_recipe
|
||||
from app.routers.settings import list_settings
|
||||
from app.schemas import MealieConfigIn, MealieTokenIn
|
||||
from app.services import mealie
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sauber():
|
||||
"""Caches und Transport zwischen den Tests zuruecksetzen."""
|
||||
mealie.cache_leeren()
|
||||
yield
|
||||
mealie.setze_transport(None)
|
||||
mealie.cache_leeren()
|
||||
|
||||
|
||||
def _mealie_server(*, passwort_ok=True, token_ok=True, erreichbar=True):
|
||||
"""Eine Mealie-Instanz nachstellen. Merkt sich, was sie gesehen hat."""
|
||||
gesehen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
gesehen.append(request)
|
||||
if not erreichbar:
|
||||
raise httpx.ConnectError("keine Verbindung", request=request)
|
||||
pfad = request.url.path
|
||||
if pfad == "/api/auth/token":
|
||||
if not passwort_ok:
|
||||
return httpx.Response(401, json={"detail": "Unauthorized"})
|
||||
return httpx.Response(200, json={
|
||||
"access_token": "kurzlebig", "token_type": "bearer", "expires_in": 3600,
|
||||
})
|
||||
if pfad == "/api/users/api-tokens":
|
||||
if not token_ok:
|
||||
return httpx.Response(403, json={"detail": "nope"})
|
||||
return httpx.Response(201, json={"token": "dauertoken-xyz", "name": "Vorrania"})
|
||||
if pfad == "/api/app/about":
|
||||
return httpx.Response(200, json={"version": "2.4.1"})
|
||||
if pfad == "/api/recipes":
|
||||
return httpx.Response(200, json={"items": [], "total": 0, "page": 1})
|
||||
if pfad == "/api/households/shopping/lists":
|
||||
return httpx.Response(200, json={"items": []})
|
||||
return httpx.Response(404, json={"detail": "Not found"})
|
||||
|
||||
mealie.setze_transport(httpx.MockTransport(handler))
|
||||
return gesehen
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Einrichten
|
||||
# --------------------------------------------------------------------------
|
||||
def test_einrichten_holt_ein_dauertoken(db, admin):
|
||||
_mealie_server()
|
||||
|
||||
status = mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://mealie.example.com",
|
||||
username="guido", password="geheim"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert status.configured is True
|
||||
assert status.token_set is True
|
||||
assert status.base_url == "https://mealie.example.com"
|
||||
assert status.reachable is True
|
||||
assert status.version == "2.4.1"
|
||||
assert db.get(IntegrationSecret, "mealie").secret == "dauertoken-xyz"
|
||||
|
||||
|
||||
def test_passwort_wird_nirgends_gespeichert(db, admin):
|
||||
"""Der Kern der Zusage: Das Passwort sieht nur die Anmeldung."""
|
||||
_mealie_server()
|
||||
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://mealie.example.com",
|
||||
username="guido", password="streng-geheim"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
alle_werte = [s.value for s in db.query(Setting).all()]
|
||||
alle_werte += [s.secret for s in db.query(IntegrationSecret).all()]
|
||||
assert all("streng-geheim" not in wert for wert in alle_werte)
|
||||
|
||||
|
||||
def test_passwort_steht_nicht_im_fehlertext(db, admin):
|
||||
_mealie_server(passwort_ok=False)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://mealie.example.com",
|
||||
username="guido", password="streng-geheim"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert "streng-geheim" not in str(fehler.value.detail)
|
||||
|
||||
|
||||
def test_adresse_wird_ohne_schraegstrich_abgelegt(db, admin):
|
||||
_mealie_server()
|
||||
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://mealie.example.com/",
|
||||
username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert db.get(Setting, "mealie_base_url").value == "https://mealie.example.com"
|
||||
|
||||
|
||||
def test_falsches_passwort_wird_als_solches_gemeldet(db, admin):
|
||||
_mealie_server(passwort_ok=False)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert "Passwort" in str(fehler.value.detail)
|
||||
assert db.get(IntegrationSecret, "mealie") is None
|
||||
|
||||
|
||||
def test_server_weg_ist_kein_passwortfehler(db, admin):
|
||||
"""Unterscheidung, die der Nutzer braucht: 502 statt 400."""
|
||||
_mealie_server(erreichbar=False)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 502
|
||||
|
||||
|
||||
def test_kein_dauertoken_wird_gemeldet(db, admin):
|
||||
_mealie_server(token_ok=False)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert fehler.value.status_code == 502
|
||||
assert db.get(IntegrationSecret, "mealie") is None
|
||||
|
||||
|
||||
def test_eigenes_token_einsetzen(db, admin):
|
||||
_mealie_server()
|
||||
|
||||
status = mealie_token_setzen(
|
||||
MealieTokenIn(base_url="https://m.example.com", token="selbst-erzeugt"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert status.configured is True
|
||||
assert db.get(IntegrationSecret, "mealie").secret == "selbst-erzeugt"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Auskunft – und was sie NICHT verrät
|
||||
# --------------------------------------------------------------------------
|
||||
def test_status_gibt_das_token_nie_zurueck(db, admin):
|
||||
_mealie_server()
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
status = mealie_status(db=db, _=admin)
|
||||
|
||||
assert status.token_set is True
|
||||
assert "dauertoken-xyz" not in json.dumps(status.model_dump(mode="json"))
|
||||
|
||||
|
||||
def test_einstellungen_enthalten_das_token_nicht(db, admin):
|
||||
"""GET /settings liest jeder angemeldete Nutzer – deshalb eigene Tabelle."""
|
||||
_mealie_server()
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
eintraege = list_settings(db=db, _=admin)
|
||||
|
||||
assert all("dauertoken-xyz" not in e.value for e in eintraege)
|
||||
# Die Adresse darf dort stehen, sie ist kein Geheimnis.
|
||||
assert any(e.key == "mealie_base_url" for e in eintraege)
|
||||
|
||||
|
||||
def test_ohne_einrichtung_ist_nichts_verfuegbar(db, admin):
|
||||
assert mealie_available(db=db, _=admin).available is False
|
||||
status = mealie_status(db=db, _=admin)
|
||||
assert status.configured is False
|
||||
assert status.reachable is None # gar nicht erst nachgefragt
|
||||
|
||||
|
||||
def test_nach_einrichtung_ist_es_verfuegbar(db, admin):
|
||||
_mealie_server()
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert mealie_available(db=db, _=admin).available is True
|
||||
|
||||
|
||||
def test_trennen_entfernt_adresse_und_token(db, admin):
|
||||
_mealie_server()
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
mealie_trennen(db=db, _=admin)
|
||||
|
||||
assert db.get(IntegrationSecret, "mealie") is None
|
||||
assert db.get(Setting, "mealie_base_url") is None
|
||||
assert mealie_available(db=db, _=admin).available is False
|
||||
|
||||
|
||||
def test_abgelaufenes_token_meldet_sich_deutlich(db, admin):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"detail": "expired"})
|
||||
|
||||
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
|
||||
db.add(IntegrationSecret(service="mealie", secret="alt"))
|
||||
db.commit()
|
||||
mealie.setze_transport(httpx.MockTransport(handler))
|
||||
|
||||
status = mealie_status(pruefen=True, db=db, _=admin)
|
||||
|
||||
assert status.reachable is False
|
||||
assert "Token" in (status.error or "")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fassungsunterschied 1.x / 2.x
|
||||
# --------------------------------------------------------------------------
|
||||
def test_erkennt_die_haushalts_pfade(db, admin):
|
||||
_mealie_server()
|
||||
mealie_konfigurieren(
|
||||
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
|
||||
db=db, _=admin,
|
||||
)
|
||||
|
||||
assert mealie.pfadvariante(db) == "households"
|
||||
|
||||
|
||||
def test_faellt_auf_die_gruppen_pfade_zurueck(db, admin):
|
||||
"""Mealie 1.x kennt /api/households/ nicht."""
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.startswith("/api/households/"):
|
||||
return httpx.Response(404, json={"detail": "Not Found"})
|
||||
return httpx.Response(200, json={"items": []})
|
||||
|
||||
db.add(Setting(key="mealie_base_url", value="https://alt.example.com"))
|
||||
db.add(IntegrationSecret(service="mealie", secret="t"))
|
||||
db.commit()
|
||||
mealie.setze_transport(httpx.MockTransport(handler))
|
||||
|
||||
assert mealie.pfadvariante(db) == "groups"
|
||||
|
||||
|
||||
def test_pfadvariante_wird_gepuffert(db, admin):
|
||||
gesehen = _mealie_server()
|
||||
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
|
||||
db.add(IntegrationSecret(service="mealie", secret="t"))
|
||||
db.commit()
|
||||
|
||||
mealie.pfadvariante(db)
|
||||
anzahl = len(gesehen)
|
||||
mealie.pfadvariante(db)
|
||||
|
||||
assert len(gesehen) == anzahl # keine zweite Anfrage
|
||||
|
||||
|
||||
def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin):
|
||||
"""Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen.
|
||||
|
||||
Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text
|
||||
landet in der Oberflaeche, darf also nicht nach Innenleben klingen.
|
||||
"""
|
||||
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
|
||||
db.add(IntegrationSecret(service="mealie", secret="t"))
|
||||
db.commit()
|
||||
mealie.setze_transport(httpx.MockTransport(
|
||||
lambda request: httpx.Response(404, json={"detail": "Not found"}),
|
||||
))
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
get_recipe("gibtsnicht", db=db, _=admin)
|
||||
|
||||
assert fehler.value.status_code == 404
|
||||
assert "Rezept" in str(fehler.value.detail)
|
||||
assert "/api/" not in str(fehler.value.detail)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Seitenleiste ausblenden: Vorgabe des Admins, eigene Auswahl, Vereinigung."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.models import NavigationSichtbarkeit, Role, User
|
||||
from app.routers.navigation import (
|
||||
eigene_auswahl_setzen,
|
||||
navigation_lesen,
|
||||
vorgabe_setzen,
|
||||
)
|
||||
from app.schemas import NavigationSetzen
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mitbewohner(db):
|
||||
person = User(username="mitbewohner", password_hash="x", role=Role.user)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _setze_vorgabe(db, admin, werte):
|
||||
return vorgabe_setzen(NavigationSetzen(versteckt=werte), db=db, user=admin)
|
||||
|
||||
|
||||
def _setze_eigen(db, user, werte):
|
||||
return eigene_auswahl_setzen(NavigationSetzen(versteckt=werte), db=db, user=user)
|
||||
|
||||
|
||||
# ---- Vorgabe ----
|
||||
|
||||
def test_ohne_eintrag_ist_nichts_versteckt(db, admin):
|
||||
out = navigation_lesen(db=db, user=admin)
|
||||
assert out.vorgabe == [] and out.eigen == [] and out.versteckt == []
|
||||
|
||||
|
||||
def test_vorgabe_gilt_fuer_alle(db, admin, mitbewohner):
|
||||
_setze_vorgabe(db, admin, ["/rezepte"])
|
||||
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/rezepte"]
|
||||
assert navigation_lesen(db=db, user=admin).versteckt == ["/rezepte"]
|
||||
|
||||
|
||||
def test_normaler_nutzer_darf_die_vorgabe_nicht_setzen(db, mitbewohner):
|
||||
"""require_admin greift – geprueft ueber die Rolle, wie in deps.py."""
|
||||
from app.deps import require_admin
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
require_admin(mitbewohner)
|
||||
assert fehler.value.status_code == 403
|
||||
|
||||
|
||||
# ---- Eigene Auswahl ----
|
||||
|
||||
def test_eigene_auswahl_betrifft_nur_einen(db, admin, mitbewohner):
|
||||
_setze_eigen(db, mitbewohner, ["/history"])
|
||||
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/history"]
|
||||
assert navigation_lesen(db=db, user=admin).versteckt == []
|
||||
|
||||
|
||||
def test_eigene_auswahl_kommt_zur_vorgabe_dazu(db, admin, mitbewohner):
|
||||
_setze_vorgabe(db, admin, ["/rezepte"])
|
||||
_setze_eigen(db, mitbewohner, ["/history"])
|
||||
|
||||
out = navigation_lesen(db=db, user=mitbewohner)
|
||||
assert out.vorgabe == ["/rezepte"]
|
||||
assert out.eigen == ["/history"]
|
||||
assert out.versteckt == ["/history", "/rezepte"]
|
||||
|
||||
|
||||
def test_nutzer_kann_die_vorgabe_nicht_aufheben(db, admin, mitbewohner):
|
||||
"""Die eigene Auswahl addiert nur – sie nimmt nichts weg."""
|
||||
_setze_vorgabe(db, admin, ["/shopping"])
|
||||
_setze_eigen(db, mitbewohner, [])
|
||||
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/shopping"]
|
||||
|
||||
|
||||
# ---- Speicherung ----
|
||||
|
||||
def test_zweimal_setzen_legt_keine_zweite_zeile_an(db, mitbewohner):
|
||||
_setze_eigen(db, mitbewohner, ["/history"])
|
||||
_setze_eigen(db, mitbewohner, ["/verliehen"])
|
||||
|
||||
zeilen = db.query(NavigationSichtbarkeit).all()
|
||||
assert len(zeilen) == 1
|
||||
assert json.loads(zeilen[0].versteckt) == ["/verliehen"]
|
||||
|
||||
|
||||
def test_nur_eine_vorgabezeile(db, admin):
|
||||
"""Der Teilindex muss eine zweite Vorgabe verhindern."""
|
||||
_setze_vorgabe(db, admin, ["/rezepte"])
|
||||
|
||||
db.add(NavigationSichtbarkeit(user_id=None, versteckt='["/shopping"]'))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_nur_eine_zeile_je_konto(db, mitbewohner):
|
||||
_setze_eigen(db, mitbewohner, ["/history"])
|
||||
|
||||
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="[]"))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_doppelte_und_leere_eintraege_fallen_weg(db, mitbewohner):
|
||||
out = _setze_eigen(db, mitbewohner, ["/history", "/history", "", " ", "/verliehen"])
|
||||
assert out.eigen == ["/history", "/verliehen"]
|
||||
|
||||
|
||||
def test_kaputter_inhalt_sperrt_niemanden_aus(db, mitbewohner):
|
||||
"""Lieber die volle Leiste als eine Fehlermeldung."""
|
||||
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="kein JSON"))
|
||||
db.commit()
|
||||
assert navigation_lesen(db=db, user=mitbewohner).versteckt == []
|
||||
|
||||
|
||||
def test_unbekannter_schluessel_wird_mitgefuehrt_aber_stoert_nicht(db, mitbewohner):
|
||||
"""Ein umbenannter Eintrag darf die Leiste nicht zerreissen – der Server
|
||||
kennt die Pfade gar nicht, die Oberflaeche ignoriert Unbekanntes."""
|
||||
out = _setze_eigen(db, mitbewohner, ["/gibtsnichtmehr"])
|
||||
assert out.versteckt == ["/gibtsnichtmehr"]
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Rezeptzutaten gegen den Bestand: Kann ich das kochen?
|
||||
|
||||
Ohne Mealie – die Zutaten stehen hier so im Test, wie Mealie sie liefern würde.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryMeasure,
|
||||
CategoryTracking,
|
||||
Group,
|
||||
Measure,
|
||||
Product,
|
||||
)
|
||||
from app.services.rezepte import faktor_fuer_portionen, kochbar, pruefe_zutaten
|
||||
from app.services.stock import check_in
|
||||
|
||||
|
||||
def _zutat(name, menge=None, einheit=None, text=None):
|
||||
"""Eine Zutat im Format von Mealie."""
|
||||
return {
|
||||
"quantity": menge,
|
||||
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
|
||||
"food": {"name": name},
|
||||
"display": text or f"{menge or ''} {einheit or ''} {name}".strip(),
|
||||
}
|
||||
|
||||
|
||||
def _kategorie(db, name):
|
||||
cat = Category(name=name, tracking=CategoryTracking.food.value)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0):
|
||||
product = Product(name=name, base_unit=base, package_size=package_size,
|
||||
category_id=cat.id, source="manual")
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
if bestand:
|
||||
check_in(db, product, bestand, base.value, None, None, None)
|
||||
db.commit()
|
||||
return product
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Portionen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_portionen_skalieren_die_mengen(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=5000)
|
||||
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 4}, 6)
|
||||
|
||||
assert faktor == 1.5
|
||||
assert hinweis == ""
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")], faktor=faktor)
|
||||
assert zutat.menge == 300
|
||||
assert zutat.benoetigt_base == 300
|
||||
|
||||
|
||||
def test_ohne_portionsangabe_wird_nicht_skaliert(db):
|
||||
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 0}, 6)
|
||||
|
||||
assert faktor == 1.0
|
||||
assert "keine Portionszahl" in hinweis
|
||||
|
||||
|
||||
def test_ohne_wunsch_bleibt_es_wie_im_rezept(db):
|
||||
assert faktor_fuer_portionen({"recipeServings": 4}, None) == (1.0, "")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reicht der Bestand?
|
||||
# --------------------------------------------------------------------------
|
||||
def test_genug_vorhanden(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
|
||||
|
||||
assert zutat.status == "vorhanden"
|
||||
assert zutat.fehlt_base == 0
|
||||
assert kochbar([zutat]) is True
|
||||
|
||||
|
||||
def test_zu_wenig_meldet_die_fehlmenge(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=150)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
|
||||
|
||||
assert zutat.status == "zu_wenig"
|
||||
assert zutat.fehlt_base == 350
|
||||
assert kochbar([zutat]) is False
|
||||
|
||||
|
||||
def test_gar_nichts_da(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=0)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
|
||||
|
||||
assert zutat.status == "fehlt"
|
||||
assert zutat.fehlt_base == 500
|
||||
|
||||
|
||||
def test_unbekannte_zutat_wird_nicht_still_als_vorhanden_gewertet(db):
|
||||
_kategorie(db, "Trockenwaren")
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Safranfäden", 1, "g")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
assert kochbar([zutat]) is False
|
||||
|
||||
|
||||
def test_zutat_ohne_menge_ist_unklar(db):
|
||||
cat = _kategorie(db, "Gewürze")
|
||||
_artikel(db, "Salz", cat, bestand=500)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None, text="Salz nach Geschmack")])
|
||||
|
||||
assert zutat.status == "menge_unbekannt"
|
||||
assert kochbar([zutat]) is False
|
||||
|
||||
|
||||
def test_ohne_menge_und_ohne_bestand_fehlt_es(db):
|
||||
cat = _kategorie(db, "Gewürze")
|
||||
_artikel(db, "Salz", cat, bestand=0)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None)])
|
||||
|
||||
assert zutat.status == "fehlt"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zuordnung
|
||||
# --------------------------------------------------------------------------
|
||||
def test_aehnlicher_name_wird_gefunden(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl Typ 550", cat, bestand=2000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
|
||||
|
||||
assert zutat.art == "product"
|
||||
assert zutat.ziel_name == "Weissmehl Typ 550"
|
||||
|
||||
|
||||
def test_gruppe_zaehlt_marken_zusammen(db):
|
||||
"""„Milch" ist markenuebergreifend gemeint – dafuer gibt es Gruppen."""
|
||||
gruppe = Group(name="Milch")
|
||||
db.add(gruppe)
|
||||
db.commit()
|
||||
cat = _kategorie(db, "Molkerei")
|
||||
a = _artikel(db, "Valflora Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
|
||||
b = _artikel(db, "Emmi Vollmilch", cat, base=BaseUnit.milliliter, bestand=500)
|
||||
a.group_id = gruppe.id
|
||||
b.group_id = gruppe.id
|
||||
db.commit()
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Milch", 1200, "ml")])
|
||||
|
||||
assert zutat.art == "group"
|
||||
assert zutat.bestand_base == 1500 # beide Marken zusammen
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_gemerkte_zuordnung_schlaegt_die_suche(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
richtig = _artikel(db, "Dinkelmehl", cat, bestand=2000)
|
||||
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Mehl", 100, "g")],
|
||||
zuordnung={"mehl": ("product", richtig.id)},
|
||||
)
|
||||
|
||||
assert zutat.ziel_id == richtig.id
|
||||
assert zutat.ziel_name == "Dinkelmehl"
|
||||
|
||||
|
||||
def test_voellig_fremde_zutat_liefert_gar_keinen_vorschlag(db):
|
||||
"""Lieber nichts als Unsinn.
|
||||
|
||||
Fruehere Fassung schlug hier „Weissmehl" vor, weil sich Buchstaben
|
||||
ueberlappen. Ein Vorschlag, den niemand nachvollziehen kann, ist schlimmer
|
||||
als keiner – er verleitet zum Durchklicken.
|
||||
"""
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Kurkuma", 1, "g")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
assert zutat.ziel_id is None
|
||||
assert zutat.kandidaten == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Was NICHT passen darf – die Faelle aus dem echten Katalog
|
||||
# --------------------------------------------------------------------------
|
||||
def test_gegenstaende_kommen_nicht_in_frage(db):
|
||||
"""Ein Joystick ist kein Zucker und eine Waffelplatte kein Apfel."""
|
||||
essen = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", essen, bestand=1000)
|
||||
kram = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||
db.add(kram)
|
||||
db.commit()
|
||||
db.refresh(kram)
|
||||
for name in ("Joystick für WII", "Snack Collection Platten 4 (Waffeln)",
|
||||
"Shirt Casual Fit"):
|
||||
db.add(Product(name=name, base_unit=BaseUnit.piece, category_id=kram.id,
|
||||
source="manual"))
|
||||
db.commit()
|
||||
|
||||
for zutat_name in ("Zucker", "Apfel", "Salz"):
|
||||
[zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 1, "g")])
|
||||
assert zutat.status == "kein_treffer", zutat_name
|
||||
assert all("Joystick" not in k.name for k in zutat.kandidaten)
|
||||
|
||||
|
||||
def test_mehl_trifft_nicht_das_geschnetzelte(db):
|
||||
"""Buchstaben-Aehnlichkeit allein reicht nicht – es braucht ein ganzes Wort."""
|
||||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||||
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_eier_treffen_nicht_den_reis(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Reis", cat, bestand=2000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Eier", 3, None)])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_kategorie_wird_zuerst_durchsucht(db):
|
||||
"""Steht die Zutat in einer passenden Kategorie, gilt nur die."""
|
||||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||||
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
|
||||
backen = _kategorie(db, "Mehl & Zucker")
|
||||
richtig = _artikel(db, "Weissmehl Typ 405", backen, bestand=2000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
|
||||
|
||||
assert zutat.ziel_id == richtig.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_vegan_allein_begruendet_keinen_treffer(db):
|
||||
"""„vegan" ist eine Eigenschaft, kein Lebensmittel.
|
||||
|
||||
Sonst passt jedes vegane Produkt auf jede vegane Zutat: „vegane Butter"
|
||||
landete auf „veganes Mühlen Knusper Bleu", „veganer Lime Curd" auf
|
||||
„Vegane Mühlen-Schnitzel".
|
||||
"""
|
||||
fleisch = _kategorie(db, "Fleischersatz")
|
||||
_artikel(db, "veganes Mühlen Knusper Bleu", fleisch, bestand=400)
|
||||
_artikel(db, "Vegane Mühlen Nuggets Klassisch", fleisch, bestand=400)
|
||||
_artikel(db, "Vegane Mühlen-Schnitzel auf Basis von Soja", fleisch, bestand=400)
|
||||
|
||||
zutaten = [
|
||||
_zutat("vegane Butter, Block, ungesalzen", 45, "g"),
|
||||
_zutat("veganer griechischer Joghurt oder Skyr", 80, "g"),
|
||||
_zutat("veganer Lime Curd", 120, "g"),
|
||||
]
|
||||
for zutat in pruefe_zutaten(db, zutaten):
|
||||
assert zutat.status == "kein_treffer", zutat.text
|
||||
assert zutat.ziel_name == ""
|
||||
|
||||
|
||||
def test_vegane_zutat_findet_das_passende_vegane_produkt(db):
|
||||
"""Die Eigenschaft darf nicht sperren, nur nicht allein tragen."""
|
||||
molk = _kategorie(db, "Molkereiprodukte")
|
||||
butter = _artikel(db, "Vegane Butter", molk, bestand=500)
|
||||
_artikel(db, "Vegane Mühlen-Schnitzel", molk, bestand=400)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("vegane Butter, Block, ungesalzen", 45, "g")])
|
||||
|
||||
assert zutat.ziel_id == butter.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_zutat_aus_lauter_eigenschaften_trifft_nichts(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("frisch gemahlen, optional", 1, "g")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_beiwoerter_stoeren_den_echten_treffer_nicht(db):
|
||||
cat = _kategorie(db, "Obst & Gemüse")
|
||||
aepfel = _artikel(db, "Äpfel", cat, bestand=3000)
|
||||
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Apfel geschält und in 3 mm dünne Scheiben geschnitten", 900, "g")]
|
||||
)
|
||||
|
||||
assert zutat.ziel_id == aepfel.id
|
||||
|
||||
|
||||
def test_falsche_einheit_spricht_gegen_den_artikel(db):
|
||||
"""„200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen"."""
|
||||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||||
_artikel(db, "Kräuter Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
assert "nicht in 'ml' geführt" in zutat.hinweis
|
||||
|
||||
|
||||
def test_passende_einheit_gewinnt_gegen_hoehere_punktzahl(db):
|
||||
molk = _kategorie(db, "Molkereiprodukte")
|
||||
fleisch = _kategorie(db, "Fleisch & Fisch")
|
||||
_artikel(db, "Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
|
||||
richtig = _artikel(db, "Schlagsahne Rahm", molk, base=BaseUnit.milliliter, bestand=500)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
|
||||
|
||||
assert zutat.ziel_id == richtig.id
|
||||
|
||||
|
||||
def test_fehlendes_hausmass_spricht_nicht_gegen_den_artikel(db):
|
||||
"""Nur eine BEKANNTE Einheit falscher Art ist ein Hinweis.
|
||||
|
||||
„1 Tasse Linsen" heisst nicht, dass die Linsen falsch sind – es fehlt bloss
|
||||
die Maßangabe, und die laesst sich nachtragen.
|
||||
"""
|
||||
cat = _kategorie(db, "Hülsenfrüchte")
|
||||
linsen = _artikel(db, "Berglinsen", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
|
||||
|
||||
assert zutat.ziel_id == linsen.id
|
||||
assert zutat.status == "menge_unbekannt"
|
||||
|
||||
|
||||
def test_zweiteinheit_laesst_die_fremde_einheit_zu(db):
|
||||
cat = _kategorie(db, "Backwaren")
|
||||
broetchen = _artikel(db, "Brötchen", cat, base=BaseUnit.piece, package_size=6,
|
||||
bestand=12)
|
||||
broetchen.secondary_base = BaseUnit.gram.value
|
||||
broetchen.secondary_count = 1
|
||||
broetchen.secondary_amount = 50
|
||||
db.commit()
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Brötchen", 100, "g")])
|
||||
|
||||
assert zutat.ziel_id == broetchen.id
|
||||
|
||||
|
||||
def test_umlaut_und_mehrzahl_finden_sich(db):
|
||||
"""Rezept schreibt „Apfel", im Katalog stehen „Äpfel"."""
|
||||
cat = _kategorie(db, "Obst & Gemüse")
|
||||
aepfel = _artikel(db, "Äpfel", cat, base=BaseUnit.gram, bestand=2000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Apfel", 900, "g")])
|
||||
|
||||
assert zutat.ziel_id == aepfel.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_eigenschaftswort_verschluckt_den_artikelnamen_nicht(db):
|
||||
'''Der Fund aus der Modellprobe: 'Frischkaese' fing mit 'frisch' an.
|
||||
|
||||
Damit galt der ganze Name als blosse Eigenschaft, fiel aus dem Vergleich -
|
||||
und die Zutat 'Kaese' fand den Frischkaese NIE. Unsichtbar, weil kein
|
||||
falscher Treffer entstand, sondern ein richtiger ausblieb.
|
||||
'''
|
||||
cat = _kategorie(db, "Molkerei")
|
||||
kaese = _artikel(db, "Frischkäse", cat, bestand=400)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Käse", 100, "g")])
|
||||
|
||||
assert zutat.ziel_id == kaese.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("artikel,zutat_name", [
|
||||
("Biomilch", "Milch"),
|
||||
("Rohrzucker", "Zucker"),
|
||||
("Kleingebäck", "Gebäck"),
|
||||
("Ganzkornbrot", "Brot"),
|
||||
("Grobgrieß", "Grieß"),
|
||||
])
|
||||
def test_komposita_mit_eigenschaft_am_anfang_bleiben_auffindbar(db, artikel, zutat_name):
|
||||
cat = _kategorie(db, "Vorrat")
|
||||
ziel = _artikel(db, artikel, cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 100, "g")])
|
||||
|
||||
assert zutat.ziel_id == ziel.id
|
||||
|
||||
|
||||
def test_das_eigenschaftswort_selbst_begruendet_weiter_keinen_treffer(db):
|
||||
"""Die Gegenprobe – sonst waere die Sperre bloss ausgehebelt."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
_artikel(db, "Frische Kräuter", cat, bestand=100)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("frische Hefe", 20, "g")])
|
||||
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_vollmilch_findet_die_h_vollmilch(db):
|
||||
cat = _kategorie(db, "Molkerei")
|
||||
milch = _artikel(db, "H Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Vollmilch Zimmertemperatur", 120, "ml")])
|
||||
|
||||
assert zutat.ziel_id == milch.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Hausmaße
|
||||
# --------------------------------------------------------------------------
|
||||
def test_essloeffel_wird_umgerechnet(db):
|
||||
cat = _kategorie(db, "Mehl")
|
||||
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
el = db.query(Measure).filter(Measure.name == "Esslöffel").one()
|
||||
db.add(CategoryMeasure(measure_id=el.id, category_id=cat.id, amount=10,
|
||||
base=BaseUnit.gram.value))
|
||||
db.commit()
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 2, "EL")])
|
||||
|
||||
assert zutat.benoetigt_base == 20
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_fehlendes_mass_wird_zum_anlegen_gemeldet(db):
|
||||
"""Daraus baut die Oberflaeche „Maß jetzt anlegen"."""
|
||||
cat = _kategorie(db, "Hülsenfrüchte")
|
||||
_artikel(db, "Berglinsen", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
|
||||
|
||||
assert zutat.status == "menge_unbekannt"
|
||||
assert zutat.fehlendes_mass == "Tasse"
|
||||
assert zutat.kategorie_id == cat.id
|
||||
assert "kein Maß" in zutat.hinweis
|
||||
|
||||
|
||||
def test_unbekannte_einheit_wird_benannt(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
|
||||
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 1, "furlong")])
|
||||
|
||||
assert zutat.status == "menge_unbekannt"
|
||||
assert "furlong" in zutat.hinweis
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Ganzes Rezept
|
||||
# --------------------------------------------------------------------------
|
||||
def test_ein_rezept_mit_allem(db):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
_artikel(db, "Zucker", cat, bestand=50)
|
||||
|
||||
zutaten = [
|
||||
_zutat("Weissmehl", 200, "g"), # reicht
|
||||
_zutat("Zucker", 100, "g"), # zu wenig
|
||||
_zutat("Safran", 1, "g"), # kennt niemand
|
||||
]
|
||||
ergebnis = pruefe_zutaten(db, zutaten)
|
||||
|
||||
assert [z.status for z in ergebnis] == ["vorhanden", "zu_wenig", "kein_treffer"]
|
||||
assert kochbar(ergebnis) is False
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Wie lange eine Anmeldung gilt – einstellbar, bis hin zu unbegrenzt.
|
||||
|
||||
Vorher war die Frist fest verdrahtet und lief ab dem *Anmelden*: Wer die App
|
||||
taeglich benutzte, flog trotzdem punktgenau nach Ablauf hinaus. Beides ist
|
||||
hier geregelt – die Dauer ueber eine Einstellung, das Weiterzaehlen ueber
|
||||
/auth/refresh.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from jose import jwt
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import ApiToken, Role, Setting, User
|
||||
from app.routers.auth import refresh
|
||||
from app.routers.settings import (
|
||||
SESSION_DAYS_KEY,
|
||||
SESSION_DAYS_MAX,
|
||||
get_session_days,
|
||||
get_session_minutes,
|
||||
set_setting,
|
||||
)
|
||||
from app.security import create_access_token, hash_api_token, hash_password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def eva(db) -> User:
|
||||
nutzer = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
|
||||
db.add(nutzer)
|
||||
db.commit()
|
||||
db.refresh(nutzer)
|
||||
return nutzer
|
||||
|
||||
|
||||
def _inhalt(token: str) -> dict:
|
||||
e = get_settings()
|
||||
# Ohne Ablaufpruefung, damit auch ein abgelaufenes Token lesbar bleibt.
|
||||
return jwt.decode(
|
||||
token, e.jwt_secret, algorithms=[e.jwt_algorithm],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Einstellung
|
||||
|
||||
def test_ohne_eintrag_gilt_der_wert_aus_der_umgebung(db):
|
||||
erwartet = round(get_settings().jwt_expire_minutes / (60 * 24))
|
||||
assert get_session_days(db) == erwartet
|
||||
|
||||
|
||||
def test_gesetzte_dauer_landet_im_token(db, eva):
|
||||
set_setting(key=SESSION_DAYS_KEY, value="60", db=db, _=eva)
|
||||
|
||||
assert get_session_days(db) == 60
|
||||
assert get_session_minutes(db) == 60 * 24 * 60
|
||||
|
||||
inhalt = _inhalt(
|
||||
create_access_token(eva.id, eva.role.value, gueltig_minuten=get_session_minutes(db))
|
||||
)
|
||||
tage = (datetime.fromtimestamp(inhalt["exp"], timezone.utc)
|
||||
- datetime.now(timezone.utc)).days
|
||||
# 59, weil der angebrochene Tag abgeschnitten wird.
|
||||
assert tage == 59
|
||||
|
||||
|
||||
def test_null_heisst_unbegrenzt(db, eva):
|
||||
"""Die Zusage "einfach fuer immer" – ein Token ganz ohne Ablauf."""
|
||||
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
|
||||
|
||||
assert get_session_minutes(db) == 0
|
||||
inhalt = _inhalt(create_access_token(eva.id, eva.role.value, gueltig_minuten=0))
|
||||
assert "exp" not in inhalt
|
||||
|
||||
|
||||
def test_unbegrenztes_token_wird_angenommen(db, eva):
|
||||
"""Ein Token ohne exp darf nicht an der Pruefung scheitern."""
|
||||
from app.deps import get_current_user
|
||||
|
||||
token = create_access_token(eva.id, eva.role.value, gueltig_minuten=0)
|
||||
assert get_current_user(token=token, db=db).id == eva.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wert", ["viel", "-1", str(SESSION_DAYS_MAX + 1), "3.5"])
|
||||
def test_unsinnige_dauer_wird_abgelehnt(db, eva, wert):
|
||||
"""Absagen statt still auf die Vorgabe zurueckdrehen – sonst glaubt der
|
||||
Admin, es sei gesetzt."""
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
set_setting(key=SESSION_DAYS_KEY, value=wert, db=db, _=eva)
|
||||
assert fehler.value.status_code == 400
|
||||
assert db.get(Setting, SESSION_DAYS_KEY) is None
|
||||
|
||||
|
||||
def test_beschaedigter_eintrag_faellt_auf_die_vorgabe_zurueck(db):
|
||||
"""Von Hand in der Datenbank verbogen: lieber die Vorgabe als ein Absturz."""
|
||||
db.add(Setting(key=SESSION_DAYS_KEY, value="kaputt"))
|
||||
db.commit()
|
||||
assert get_session_days(db) == round(get_settings().jwt_expire_minutes / (60 * 24))
|
||||
|
||||
|
||||
def test_dauer_steht_in_der_einstellungsliste(db, eva):
|
||||
from app.routers.settings import list_settings
|
||||
|
||||
schluessel = {s.key for s in list_settings(db=db, _=eva)}
|
||||
assert SESSION_DAYS_KEY in schluessel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Verlaengern
|
||||
|
||||
def test_refresh_gibt_ein_frisches_token(db, eva):
|
||||
set_setting(key=SESSION_DAYS_KEY, value="30", db=db, _=eva)
|
||||
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
|
||||
|
||||
neu = refresh(token=alt, db=db)
|
||||
|
||||
assert neu.username == "eva"
|
||||
assert _inhalt(neu.access_token)["exp"] > _inhalt(alt)["exp"]
|
||||
|
||||
|
||||
def test_refresh_uebernimmt_die_neue_dauer(db, eva):
|
||||
"""Der Sinn der Sache: eine Aenderung greift, ohne dass man sich abmeldet."""
|
||||
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
|
||||
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
|
||||
|
||||
assert "exp" not in _inhalt(refresh(token=alt, db=db).access_token)
|
||||
|
||||
|
||||
def test_refresh_lehnt_ein_abgelaufenes_token_ab(db, eva):
|
||||
# Von Hand gebaut: ueber create_access_token laesst sich kein abgelaufenes
|
||||
# Token bestellen – alles <= 0 heisst dort "unbegrenzt".
|
||||
e = get_settings()
|
||||
abgelaufen = jwt.encode(
|
||||
{"sub": str(eva.id), "uid": eva.id, "role": eva.role.value,
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1)},
|
||||
e.jwt_secret, algorithm=e.jwt_algorithm,
|
||||
)
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
refresh(token=abgelaufen, db=db)
|
||||
assert fehler.value.status_code == 401
|
||||
|
||||
|
||||
def test_refresh_macht_aus_einem_api_token_kein_jwt(db, eva):
|
||||
"""Ein API-Token (Home Assistant) ist jederzeit widerrufbar. Ein daraus
|
||||
gemachtes JWT waere es nicht – deshalb hier eine Absage."""
|
||||
db.add(ApiToken(name="ha", token_hash=hash_api_token("geheim"), user_id=eva.id))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
refresh(token="geheim", db=db)
|
||||
assert fehler.value.status_code == 401
|
||||
@@ -65,7 +65,7 @@ def _stueck(db, admin, product, ort=None):
|
||||
return create_items(
|
||||
product.id,
|
||||
ItemCreate(count=1, location_id=(ort.id if ort else None)),
|
||||
db=db, _=admin,
|
||||
db=db, user=admin,
|
||||
)[0]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Verwendungsstatus von Einzelstücken: umstellen, Verlauf, Kategorie-Markierung."""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Item,
|
||||
ItemUsage,
|
||||
Location,
|
||||
Product,
|
||||
Role,
|
||||
UsageStatus,
|
||||
User,
|
||||
)
|
||||
from app.routers.categories import create_category, update_category
|
||||
from app.routers.items import create_items, delete_item, get_item, list_all_items
|
||||
from app.routers.verwendung import list_verwendungen, setze_verwendung
|
||||
from app.schemas import (
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
ItemCreate,
|
||||
VerwendungSetzen,
|
||||
)
|
||||
from app.services.stock import current_stock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nutzer(db):
|
||||
person = User(username="mitbewohner", password_hash="x", role=Role.user)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def schrank(db):
|
||||
ort = Location(name="Schrank")
|
||||
db.add(ort)
|
||||
db.commit()
|
||||
db.refresh(ort)
|
||||
return ort
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def hemd(db):
|
||||
"""Ein Einzelstueck in einer Kategorie, die den Status fuehrt."""
|
||||
cat = Category(
|
||||
name="Kleidung", tracking=CategoryTracking.object.value, usage_tracking=True
|
||||
)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name="Hemd", category_id=cat.id, individual=True, package_size=1)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def konserve(db):
|
||||
"""Ein Einzelstueck in einer Kategorie OHNE Verwendungsstatus."""
|
||||
cat = Category(name="Vorrat", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name="Bohrmaschine", category_id=cat.id, individual=True,
|
||||
package_size=1)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _stueck(db, admin, product, ort=None):
|
||||
return create_items(
|
||||
product.id,
|
||||
ItemCreate(count=1, location_id=(ort.id if ort else None)),
|
||||
db=db, user=admin,
|
||||
)[0]
|
||||
|
||||
|
||||
def _setze(db, user, item_id, status):
|
||||
return setze_verwendung(item_id, VerwendungSetzen(status=status), db=db, user=user)
|
||||
|
||||
|
||||
# ---- Umstellen ----
|
||||
|
||||
def test_neuer_status_legt_offene_zeile_an(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
out = _setze(db, admin, stueck.id, UsageStatus.in_use)
|
||||
|
||||
assert out.verwendung is not None
|
||||
assert out.verwendung.status == UsageStatus.in_use
|
||||
assert out.verwendung.ended_at is None
|
||||
assert out.verwendung.username == "admin"
|
||||
|
||||
|
||||
def test_frisches_stueck_ist_verfuegbar(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
assert get_item(stueck.id, db=db, _=admin).verwendung is None
|
||||
assert db.query(ItemUsage).count() == 0
|
||||
|
||||
|
||||
def test_wechsel_schliesst_die_vorige_zeile(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
_setze(db, admin, stueck.id, UsageStatus.in_use)
|
||||
out = _setze(db, admin, stueck.id, UsageStatus.cleaning)
|
||||
|
||||
assert out.verwendung.status == UsageStatus.cleaning
|
||||
zeilen = db.query(ItemUsage).order_by(ItemUsage.id).all()
|
||||
assert len(zeilen) == 2
|
||||
assert zeilen[0].status == UsageStatus.in_use.value
|
||||
assert zeilen[0].ended_at is not None
|
||||
assert zeilen[1].ended_at is None
|
||||
|
||||
|
||||
def test_nur_eine_offene_zeile_je_stueck(db, admin, hemd):
|
||||
"""Der Teilindex muss einen zweiten laufenden Zeitraum verhindern."""
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
_setze(db, admin, stueck.id, UsageStatus.in_use)
|
||||
|
||||
db.add(ItemUsage(item_id=stueck.id, status=UsageStatus.repair.value))
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_verfuegbar_schliesst_nur_und_legt_nichts_an(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
_setze(db, admin, stueck.id, UsageStatus.cleaning)
|
||||
out = _setze(db, admin, stueck.id, None)
|
||||
|
||||
assert out.verwendung is None
|
||||
zeilen = db.query(ItemUsage).all()
|
||||
assert len(zeilen) == 1
|
||||
assert zeilen[0].ended_at is not None
|
||||
|
||||
|
||||
def test_verfuegbar_ohne_offene_zeile_ist_kein_fehler(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
out = _setze(db, admin, stueck.id, None)
|
||||
assert out.verwendung is None
|
||||
assert db.query(ItemUsage).count() == 0
|
||||
|
||||
|
||||
def test_gleicher_status_zweimal_legt_keine_zweite_zeile_an(db, admin, hemd):
|
||||
"""Sonst entstuende bei jedem Antippen ein Zeitraum der Laenge 0."""
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
_setze(db, admin, stueck.id, UsageStatus.repair)
|
||||
_setze(db, admin, stueck.id, UsageStatus.repair)
|
||||
|
||||
zeilen = db.query(ItemUsage).all()
|
||||
assert len(zeilen) == 1
|
||||
assert zeilen[0].ended_at is None
|
||||
|
||||
|
||||
def test_unbekanntes_stueck_ergibt_404(db, admin):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_setze(db, admin, 9999, UsageStatus.in_use)
|
||||
assert fehler.value.status_code == 404
|
||||
|
||||
|
||||
def test_normaler_nutzer_darf_status_setzen(db, admin, nutzer, hemd):
|
||||
"""Umstellen ist eine Buchung, keine Stammdatenaenderung."""
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
out = _setze(db, nutzer, stueck.id, UsageStatus.cleaning)
|
||||
assert out.verwendung.username == "mitbewohner"
|
||||
|
||||
|
||||
# ---- Bestand ----
|
||||
|
||||
def test_status_aendert_den_bestand_nicht(db, admin, hemd, schrank):
|
||||
"""Ein getragenes Hemd ist weiterhin im Haushalt - anders als ein verliehenes."""
|
||||
stueck = _stueck(db, admin, hemd, schrank)
|
||||
vorher = current_stock(db, hemd.id)
|
||||
|
||||
_setze(db, admin, stueck.id, UsageStatus.in_use)
|
||||
assert current_stock(db, hemd.id) == vorher
|
||||
|
||||
_setze(db, admin, stueck.id, UsageStatus.cleaning)
|
||||
assert current_stock(db, hemd.id) == vorher
|
||||
|
||||
|
||||
def test_status_schreibt_keine_bewegung(db, admin, hemd):
|
||||
"""Der Dashboard-Verlauf rechnet aus Bewegungen - siehe routers/loans.py."""
|
||||
from app.models import Movement
|
||||
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
vorher = db.query(Movement).count()
|
||||
_setze(db, admin, stueck.id, UsageStatus.in_use)
|
||||
assert db.query(Movement).count() == vorher
|
||||
|
||||
|
||||
# ---- Verlauf ----
|
||||
|
||||
def test_verlauf_kommt_neueste_zuerst(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
for status in (UsageStatus.in_use, UsageStatus.cleaning, UsageStatus.repair):
|
||||
_setze(db, admin, stueck.id, status)
|
||||
|
||||
verlauf = list_verwendungen(stueck.id, db=db, _=admin)
|
||||
assert [v.status for v in verlauf] == [
|
||||
UsageStatus.repair, UsageStatus.cleaning, UsageStatus.in_use
|
||||
]
|
||||
assert verlauf[0].ended_at is None
|
||||
assert all(v.ended_at is not None for v in verlauf[1:])
|
||||
|
||||
|
||||
def test_geloeschtes_stueck_nimmt_den_verlauf_mit(db, admin, hemd):
|
||||
stueck = _stueck(db, admin, hemd)
|
||||
_setze(db, admin, stueck.id, UsageStatus.cleaning)
|
||||
assert db.query(ItemUsage).count() == 1
|
||||
|
||||
delete_item(stueck.id, db=db, _=admin)
|
||||
assert db.query(Item).filter(Item.id == stueck.id).count() == 0
|
||||
assert db.query(ItemUsage).count() == 0
|
||||
|
||||
|
||||
# ---- Kategorie-Markierung ----
|
||||
|
||||
def test_verwendung_moeglich_steht_am_einzelstueck(db, admin, hemd, konserve):
|
||||
mit = _stueck(db, admin, hemd)
|
||||
ohne = _stueck(db, admin, konserve)
|
||||
|
||||
assert get_item(mit.id, db=db, _=admin).verwendung_moeglich is True
|
||||
assert get_item(ohne.id, db=db, _=admin).verwendung_moeglich is False
|
||||
|
||||
nach_uid = {i.uid: i for i in list_all_items(db=db, _=admin)}
|
||||
assert nach_uid[mit.uid].verwendung_moeglich is True
|
||||
assert nach_uid[ohne.uid].verwendung_moeglich is False
|
||||
|
||||
|
||||
def test_markierung_erbt_beim_anlegen(db, admin, hemd):
|
||||
eltern = db.query(Category).filter(Category.name == "Kleidung").one()
|
||||
kind = create_category(
|
||||
CategoryCreate(name="Hemden", parent_id=eltern.id), db=db, _=admin
|
||||
)
|
||||
assert kind.usage_tracking is True
|
||||
|
||||
|
||||
def test_neue_oberkategorie_ist_ohne_markierung(db, admin):
|
||||
neu = create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
|
||||
assert neu.usage_tracking is False
|
||||
|
||||
|
||||
def test_markierung_wandert_in_die_unterkategorien(db, admin):
|
||||
"""Der Fall aus der Praxis: Die Garderobe ist schon erfasst, erst dann
|
||||
kommt der Haken an die Oberkategorie."""
|
||||
oben = create_category(CategoryCreate(name="Kleidung"), db=db, _=admin)
|
||||
mitte = create_category(
|
||||
CategoryCreate(name="Oberteile", parent_id=oben.id), db=db, _=admin
|
||||
)
|
||||
unten = create_category(
|
||||
CategoryCreate(name="Hemden", parent_id=mitte.id), db=db, _=admin
|
||||
)
|
||||
assert unten.usage_tracking is False
|
||||
|
||||
update_category(oben.id, CategoryUpdate(usage_tracking=True), db=db, _=admin)
|
||||
|
||||
for cid in (oben.id, mitte.id, unten.id):
|
||||
assert db.get(Category, cid).usage_tracking is True
|
||||
|
||||
|
||||
def test_markierung_abwaehlen_wandert_auch(db, admin):
|
||||
oben = create_category(
|
||||
CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin
|
||||
)
|
||||
unten = create_category(
|
||||
CategoryCreate(name="Hosen", parent_id=oben.id), db=db, _=admin
|
||||
)
|
||||
assert unten.usage_tracking is True
|
||||
|
||||
update_category(oben.id, CategoryUpdate(usage_tracking=False), db=db, _=admin)
|
||||
assert db.get(Category, unten.id).usage_tracking is False
|
||||
|
||||
|
||||
def test_andere_aenderung_laesst_die_markierung_in_ruhe(db, admin):
|
||||
"""Ein Umbenennen darf die Unterkategorien nicht anfassen."""
|
||||
oben = create_category(CategoryCreate(name="Kleidung"), db=db, _=admin)
|
||||
unten = create_category(
|
||||
CategoryCreate(name="Hemden", parent_id=oben.id, usage_tracking=True),
|
||||
db=db, _=admin,
|
||||
)
|
||||
update_category(oben.id, CategoryUpdate(name="Garderobe"), db=db, _=admin)
|
||||
assert db.get(Category, unten.id).usage_tracking is True
|
||||
|
||||
|
||||
# ---- Sicherung ----
|
||||
|
||||
def test_markierung_uebersteht_die_volle_sicherung(db, admin):
|
||||
"""Die Markierung haengt an der Kategorie und muss im Backup mitfahren.
|
||||
|
||||
Ohne die Umschreibung in transfer.py waere sie nach dem Zurueckspielen aus
|
||||
- und niemand wuerde merken, dass die Garderobe ihren Status verloren hat.
|
||||
"""
|
||||
import json
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.routers.transfer import _import_json, export_backup_json
|
||||
from app.seed import ensure_builtin_units
|
||||
|
||||
oben = create_category(
|
||||
CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin
|
||||
)
|
||||
create_category(CategoryCreate(name="Hemden", parent_id=oben.id), db=db, _=admin)
|
||||
create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
|
||||
|
||||
inhalt = export_backup_json(db=db, _=admin).body
|
||||
daten = json.loads(inhalt)
|
||||
nach_pfad = {c["path"]: c for c in daten["categories"]}
|
||||
assert nach_pfad["Kleidung"]["usage_tracking"] is True
|
||||
assert nach_pfad["Kleidung > Hemden"]["usage_tracking"] is True
|
||||
assert nach_pfad["Werkzeug"]["usage_tracking"] is False
|
||||
|
||||
# Zweite, leere Instanz.
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||
ensure_builtin_units(zweite)
|
||||
person = User(username="zwei", password_hash="x", role=Role.admin)
|
||||
zweite.add(person)
|
||||
zweite.commit()
|
||||
|
||||
_import_json(zweite, inhalt, person, "add")
|
||||
|
||||
def markierung(name):
|
||||
return zweite.query(Category).filter(Category.name == name).one().usage_tracking
|
||||
|
||||
assert markierung("Kleidung") is True
|
||||
assert markierung("Hemden") is True
|
||||
assert markierung("Werkzeug") is False
|
||||
zweite.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_markierung_uebersteht_die_stammdaten_sicherung(db, admin):
|
||||
from app.services.master_data import export_master_data, import_master_data
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
|
||||
create_category(CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin)
|
||||
create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
|
||||
daten = export_master_data(db)
|
||||
|
||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
|
||||
import_master_data(zweite, daten, "add")
|
||||
|
||||
def markierung(name):
|
||||
return zweite.query(Category).filter(Category.name == name).one().usage_tracking
|
||||
|
||||
assert markierung("Kleidung") is True
|
||||
assert markierung("Werkzeug") is False
|
||||
zweite.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_altes_backup_ohne_markierung_bleibt_lesbar(db, admin):
|
||||
"""Eine Sicherung von vor dieser Aenderung kennt den Schluessel nicht."""
|
||||
from app.services.master_data import FORMAT_KEY, import_master_data
|
||||
|
||||
import_master_data(
|
||||
db,
|
||||
{FORMAT_KEY: 1,
|
||||
"categories": [{"id": 1, "name": "Alt", "parent_id": None, "tracking": "food"}]},
|
||||
"add",
|
||||
)
|
||||
assert db.query(Category).filter(Category.name == "Alt").one().usage_tracking is False
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Zubehör: ein Einzelstück gehört zu einem anderen (Netzteil → Laptop).
|
||||
|
||||
Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
|
||||
Geprüft werden die Zuordnung selbst, ihre Grenzen (Selbstbezug, Ring) und das
|
||||
Mitverleihen, das die Oberfläche erfragt und der Server nie von sich aus tut.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import Category, CategoryTracking, Item, Product, Role, User
|
||||
from app.routers.items import (
|
||||
create_items,
|
||||
delete_item,
|
||||
get_item,
|
||||
zubehoer_loesen,
|
||||
zubehoer_zuordnen,
|
||||
)
|
||||
from app.routers.loans import lend_item, return_item
|
||||
from app.schemas import ItemCreate, LoanCreate, LoanReturn, ZubehoerZuordnen
|
||||
from app.services.loans import offener_verleih
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin(db):
|
||||
person = User(username="admin", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def laptop(db):
|
||||
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
product = Product(name="Laptop", category_id=cat.id, individual=True, package_size=1)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
def _stueck(db, admin, product) -> Item:
|
||||
out = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0]
|
||||
return db.get(Item, out.id)
|
||||
|
||||
|
||||
def _haenge(db, admin, geraet: Item, teil: Item):
|
||||
return zubehoer_zuordnen(
|
||||
geraet.id, ZubehoerZuordnen(child_item_id=teil.id), db=db, _=admin
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Zuordnen und lösen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_geraet_traegt_mehrere_teile(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
tasche = _stueck(db, admin, laptop)
|
||||
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
out = _haenge(db, admin, geraet, tasche)
|
||||
|
||||
assert {t.uid for t in out.zubehoer} == {netzteil.uid, tasche.uid}
|
||||
# Und die Gegenrichtung steht am Teil.
|
||||
assert get_item(netzteil.id, db=db, _=admin).gehoert_zu.uid == geraet.uid
|
||||
|
||||
|
||||
def test_teil_wechselt_das_geraet_ohne_dublette(db, admin, laptop):
|
||||
"""Ein Teil haengt an genau einem Geraet – Umhaengen ist ein Wechsel."""
|
||||
a = _stueck(db, admin, laptop)
|
||||
b = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
|
||||
_haenge(db, admin, a, netzteil)
|
||||
_haenge(db, admin, b, netzteil)
|
||||
|
||||
assert get_item(a.id, db=db, _=admin).zubehoer == []
|
||||
assert [t.uid for t in get_item(b.id, db=db, _=admin).zubehoer] == [netzteil.uid]
|
||||
|
||||
|
||||
def test_loesen_laesst_das_stueck_bestehen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
zubehoer_loesen(geraet.id, netzteil.id, db=db, _=admin)
|
||||
|
||||
assert get_item(geraet.id, db=db, _=admin).zubehoer == []
|
||||
assert db.get(Item, netzteil.id) is not None
|
||||
|
||||
|
||||
def test_loesen_eines_fremden_teils_ist_ein_fehler(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
fremdes = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
zubehoer_loesen(geraet.id, fremdes.id, db=db, _=admin)
|
||||
assert fehler.value.status_code == 404
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Grenzen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_selbstbezug_wird_abgelehnt(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, geraet, geraet)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_ring_wird_abgelehnt(db, admin, laptop):
|
||||
"""A trägt B – dann darf B nicht A tragen, sonst läuft jede Kette endlos."""
|
||||
a = _stueck(db, admin, laptop)
|
||||
b = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, a, b)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, b, a)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_laengerer_ring_wird_abgelehnt(db, admin, laptop):
|
||||
a, b, c = (_stueck(db, admin, laptop) for _ in range(3))
|
||||
_haenge(db, admin, a, b)
|
||||
_haenge(db, admin, b, c)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
_haenge(db, admin, c, a)
|
||||
assert fehler.value.status_code == 409
|
||||
|
||||
|
||||
def test_geraet_loeschen_gibt_das_teil_frei(db, admin, laptop):
|
||||
"""Das Netzteil ueberlebt den Laptop – es wird wieder eigenstaendig."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
delete_item(geraet.id, db=db, _=admin)
|
||||
|
||||
uebrig = db.get(Item, netzteil.id)
|
||||
assert uebrig is not None
|
||||
assert uebrig.parent_item_id is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Verleihen und Zurücknehmen mit Zubehör
|
||||
# --------------------------------------------------------------------------
|
||||
def test_ohne_angabe_geht_nur_das_geraet(db, admin, laptop):
|
||||
"""Der Server nimmt nie ungefragt etwas mit."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
|
||||
assert offener_verleih(db, geraet.id) is not None
|
||||
assert offener_verleih(db, netzteil.id) is None
|
||||
|
||||
|
||||
def test_mit_zubehoer_verleihen_und_zuruecknehmen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
tasche = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
_haenge(db, admin, geraet, tasche)
|
||||
teile = [netzteil.id, tasche.id]
|
||||
|
||||
lend_item(
|
||||
geraet.id,
|
||||
LoanCreate(borrower="Markus", mit_zubehoer=teile),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert all(offener_verleih(db, i) is not None for i in [geraet.id, *teile])
|
||||
|
||||
return_item(geraet.id, LoanReturn(mit_zubehoer=teile), db=db, _=admin)
|
||||
# Keines darf offen haengenbleiben – sonst sucht es niemand mehr.
|
||||
assert all(offener_verleih(db, i) is None for i in [geraet.id, *teile])
|
||||
|
||||
|
||||
def test_fremdes_stueck_laesst_sich_nicht_mitverleihen(db, admin, laptop):
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
fremdes = _stueck(db, admin, laptop)
|
||||
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
lend_item(
|
||||
geraet.id,
|
||||
LoanCreate(borrower="Markus", mit_zubehoer=[fremdes.id]),
|
||||
db=db, user=admin,
|
||||
)
|
||||
assert fehler.value.status_code == 400
|
||||
assert offener_verleih(db, fremdes.id) is None
|
||||
|
||||
|
||||
def test_zuruecknehmen_uebergeht_ein_teil_das_dablieb(db, admin, laptop):
|
||||
"""Wer das Netzteil behalten hat, verleiht es nicht – die Rückgabe darf
|
||||
daran trotzdem nicht scheitern."""
|
||||
geraet = _stueck(db, admin, laptop)
|
||||
netzteil = _stueck(db, admin, laptop)
|
||||
_haenge(db, admin, geraet, netzteil)
|
||||
|
||||
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
|
||||
return_item(geraet.id, LoanReturn(mit_zubehoer=[netzteil.id]), db=db, _=admin)
|
||||
|
||||
assert offener_verleih(db, geraet.id) is None
|
||||
@@ -0,0 +1,305 @@
|
||||
"""Zutaten von Hand zuordnen – und dass sich Vorrania das merkt.
|
||||
|
||||
Der unscharfe Abgleich wird immer Zutaten haben, die er nicht erraten kann
|
||||
(„Lime Curd") oder falsch errät. Hier liegt der Korrekturweg: einmal von Hand
|
||||
entschieden, gilt es für jedes Rezept.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.models import (
|
||||
BaseUnit,
|
||||
Category,
|
||||
CategoryTracking,
|
||||
Group,
|
||||
MealieFoodMap,
|
||||
Product,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.routers.recipes import (
|
||||
list_zuordnungen,
|
||||
loesche_zuordnung,
|
||||
router,
|
||||
setze_zuordnung,
|
||||
zuordnung_ziele,
|
||||
)
|
||||
from app.schemas import ZuordnungIn
|
||||
from app.services import zuordnung
|
||||
from app.services.rezepte import pruefe_zutaten
|
||||
from app.services.stock import check_in
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def nutzer(db):
|
||||
person = User(username="koch", password_hash="x", role=Role.admin)
|
||||
db.add(person)
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
def _zutat(name, menge=None, einheit=None):
|
||||
return {
|
||||
"quantity": menge,
|
||||
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
|
||||
"food": {"name": name},
|
||||
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
|
||||
}
|
||||
|
||||
|
||||
def _kategorie(db, name, tracking=CategoryTracking.food):
|
||||
cat = Category(name=name, tracking=tracking.value)
|
||||
db.add(cat)
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
def _artikel(db, name, cat, base=BaseUnit.gram, bestand=0.0, **extra):
|
||||
product = Product(name=name, base_unit=base, package_size=1000,
|
||||
category_id=cat.id, source="manual", **extra)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
if bestand:
|
||||
check_in(db, product, bestand, base.value, None, None, None)
|
||||
db.commit()
|
||||
return product
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Die Zuordnung greift
|
||||
# --------------------------------------------------------------------------
|
||||
def test_gemerkte_zuordnung_findet_was_das_raten_nicht_findet(db, nutzer):
|
||||
"""„Lime Curd" hat mit „Zitronencreme" kein Wort gemeinsam."""
|
||||
cat = _kategorie(db, "Aufstriche")
|
||||
creme = _artikel(db, "Zitronencreme", cat, bestand=500)
|
||||
|
||||
[vorher] = pruefe_zutaten(db, [_zutat("Lime Curd", 100, "g")])
|
||||
assert vorher.status == "kein_treffer"
|
||||
|
||||
setze_zuordnung(
|
||||
ZuordnungIn(food="Lime Curd", art="product", ziel_id=creme.id),
|
||||
db=db, _=nutzer,
|
||||
)
|
||||
|
||||
[nachher] = pruefe_zutaten(
|
||||
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert nachher.status == "vorhanden"
|
||||
assert nachher.ziel_name == "Zitronencreme"
|
||||
assert nachher.manuell is True
|
||||
|
||||
|
||||
def test_zuordnung_schlaegt_einen_falschen_treffer(db, nutzer):
|
||||
"""Der Kern der Beschwerde: Sahne ist kein Kräuter-Sahne-Hähnchen."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
_artikel(db, "Kräuter Sahne Hähnchen", cat, bestand=2000)
|
||||
sahne = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
|
||||
|
||||
setze_zuordnung(
|
||||
ZuordnungIn(food="Sahne", art="product", ziel_id=sahne.id),
|
||||
db=db, _=nutzer,
|
||||
)
|
||||
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Sahne", 200, "ml")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert zutat.ziel_id == sahne.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_gross_und_kleinschreibung_treffen_dieselbe_zeile(db, nutzer):
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
rahm = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
|
||||
setze_zuordnung(
|
||||
ZuordnungIn(food="Schlagsahne", art="product", ziel_id=rahm.id),
|
||||
db=db, _=nutzer,
|
||||
)
|
||||
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat(" SCHLAGSAHNE ", 200, "ml")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert zutat.ziel_id == rahm.id
|
||||
|
||||
|
||||
def test_zuordnung_auf_eine_gruppe(db, nutzer):
|
||||
"""„Milch" ist markenübergreifend gemeint – dafür gibt es Gruppen."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
gruppe = Group(name="Trinkmilch")
|
||||
db.add(gruppe)
|
||||
db.commit()
|
||||
db.refresh(gruppe)
|
||||
_artikel(db, "H-Milch 3,5%", cat, base=BaseUnit.milliliter,
|
||||
bestand=2000, group_id=gruppe.id)
|
||||
|
||||
setze_zuordnung(
|
||||
ZuordnungIn(food="Vollmilch", art="group", ziel_id=gruppe.id),
|
||||
db=db, _=nutzer,
|
||||
)
|
||||
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Vollmilch", 500, "ml")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert zutat.art == "group"
|
||||
assert zutat.ziel_id == gruppe.id
|
||||
assert zutat.status == "vorhanden"
|
||||
|
||||
|
||||
def test_wechsel_auf_eine_gruppe_laesst_den_artikel_nicht_stehen(db, nutzer):
|
||||
"""Sonst stünden beide Ziele in der Zeile und eines gewänne zufällig."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
milch = _artikel(db, "H-Milch", cat, base=BaseUnit.milliliter)
|
||||
gruppe = Group(name="Trinkmilch")
|
||||
db.add(gruppe)
|
||||
db.commit()
|
||||
db.refresh(gruppe)
|
||||
|
||||
setze_zuordnung(ZuordnungIn(food="Milch", art="product", ziel_id=milch.id),
|
||||
db=db, _=nutzer)
|
||||
setze_zuordnung(ZuordnungIn(food="Milch", art="group", ziel_id=gruppe.id),
|
||||
db=db, _=nutzer)
|
||||
|
||||
zeile = db.get(MealieFoodMap, "milch")
|
||||
assert zeile.product_id is None
|
||||
assert zeile.group_id == gruppe.id
|
||||
assert db.query(MealieFoodMap).count() == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Widerrufen und Aufräumen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_loeschen_laesst_wieder_raten(db, nutzer):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
setze_zuordnung(ZuordnungIn(food="Reis", art="product", ziel_id=mehl.id),
|
||||
db=db, _=nutzer)
|
||||
|
||||
loesche_zuordnung(food="Reis", db=db, _=nutzer)
|
||||
|
||||
assert zuordnung.lade(db) == {}
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Reis", 100, "g")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_loeschen_was_es_nicht_gibt_ist_kein_fehler(db, nutzer):
|
||||
loesche_zuordnung(food="Gibtsnicht", db=db, _=nutzer)
|
||||
|
||||
|
||||
def test_zuordnung_auf_einen_geloeschten_artikel_wird_uebersprungen(db, nutzer):
|
||||
"""Auf SQLite greift kein Fremdschlüssel – das darf kein Rezept sprengen."""
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
|
||||
setze_zuordnung(ZuordnungIn(food="Lime Curd", art="product", ziel_id=mehl.id),
|
||||
db=db, _=nutzer)
|
||||
|
||||
db.delete(mehl)
|
||||
db.commit()
|
||||
|
||||
assert zuordnung.lade(db) == {}
|
||||
[zutat] = pruefe_zutaten(
|
||||
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
|
||||
)
|
||||
assert zutat.status == "kein_treffer"
|
||||
|
||||
|
||||
def test_unbekanntes_ziel_wird_abgelehnt(db, nutzer):
|
||||
with pytest.raises(HTTPException) as fehler:
|
||||
setze_zuordnung(ZuordnungIn(food="Mehl", art="product", ziel_id=999),
|
||||
db=db, _=nutzer)
|
||||
|
||||
assert fehler.value.status_code == 400
|
||||
assert db.query(MealieFoodMap).count() == 0
|
||||
|
||||
|
||||
def test_zutat_ohne_namen_laesst_sich_nicht_merken(db, nutzer):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
|
||||
with pytest.raises(zuordnung.ZuordnungFehler):
|
||||
zuordnung.setze(db, " ", "product", mehl.id)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Liste und Auswahl
|
||||
# --------------------------------------------------------------------------
|
||||
def test_liste_nennt_das_ziel_mit_namen(db, nutzer):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
mehl = _artikel(db, "Weissmehl", cat)
|
||||
setze_zuordnung(ZuordnungIn(food="Flour", art="product", ziel_id=mehl.id),
|
||||
db=db, _=nutzer)
|
||||
|
||||
[eintrag] = list_zuordnungen(db=db, _=nutzer)
|
||||
|
||||
assert eintrag.food_name == "Flour"
|
||||
assert eintrag.food_key == "flour"
|
||||
assert eintrag.ziel_name == "Weissmehl"
|
||||
|
||||
|
||||
def test_ziele_suchen_ueber_name_und_marke(db, nutzer):
|
||||
cat = _kategorie(db, "Trockenwaren")
|
||||
_artikel(db, "Weissmehl Typ 550", cat, brand="Mühle Nord")
|
||||
_artikel(db, "Zucker", cat)
|
||||
|
||||
namen = [z.name for z in zuordnung_ziele(q="mehl", db=db, _=nutzer)]
|
||||
assert namen == ["Weissmehl Typ 550"]
|
||||
|
||||
nach_marke = [z.name for z in zuordnung_ziele(q="mühle nord", db=db, _=nutzer)]
|
||||
assert nach_marke == ["Weissmehl Typ 550"]
|
||||
|
||||
|
||||
def test_ziele_nennen_die_basiseinheit(db, nutzer):
|
||||
"""Damit man vor dem Zuordnen sieht, ob ml oder g geführt wird."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
_artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter)
|
||||
|
||||
[ziel] = zuordnung_ziele(q="schlagrahm", db=db, _=nutzer)
|
||||
|
||||
assert ziel.basis_einheit == "milliliter"
|
||||
|
||||
|
||||
def test_ziele_enthalten_gegenstaende_aber_keine_einzelstuecke(db, nutzer):
|
||||
"""Raten darf nur Lebensmittel; von Hand zuordnen darf alles Zählbare.
|
||||
|
||||
Backpapier ist kein Lebensmittel und steht trotzdem in Rezepten. Ein
|
||||
Einzelstück dagegen ist ein benanntes Objekt und nie eine Zutat.
|
||||
"""
|
||||
sachen = _kategorie(db, "Haushalt", tracking=CategoryTracking.object)
|
||||
_artikel(db, "Backpapier", sachen, base=BaseUnit.piece)
|
||||
_artikel(db, "Backpapier-Spender", sachen, base=BaseUnit.piece, individual=True)
|
||||
|
||||
namen = [z.name for z in zuordnung_ziele(q="backpapier", db=db, _=nutzer)]
|
||||
|
||||
assert namen == ["Backpapier"]
|
||||
|
||||
|
||||
def test_gruppen_stehen_vor_den_artikeln(db, nutzer):
|
||||
"""Ohne Suchtext zuerst die Gruppen – sie sind die bessere Zuordnung."""
|
||||
cat = _kategorie(db, "Kuehlregal")
|
||||
_artikel(db, "Alpenmilch", cat, base=BaseUnit.milliliter)
|
||||
db.add(Group(name="Trinkmilch"))
|
||||
db.commit()
|
||||
|
||||
arten = [z.art for z in zuordnung_ziele(db=db, _=nutzer)]
|
||||
|
||||
assert arten[0] == "group"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Routenreihenfolge
|
||||
# --------------------------------------------------------------------------
|
||||
def test_zuordnungs_routen_stehen_vor_dem_slug():
|
||||
"""Sonst liest FastAPI „zuordnungen" als Rezept-Slug und fragt Mealie.
|
||||
|
||||
Die Reihenfolge der Deklaration entscheidet das – ein Umsortieren der
|
||||
Datei würde den Fehler sonst still einbauen.
|
||||
"""
|
||||
pfade = [r.path for r in router.routes]
|
||||
slug = pfade.index("/recipes/{slug}")
|
||||
|
||||
assert pfade.index("/recipes/zuordnungen") < slug
|
||||
assert pfade.index("/recipes/zuordnungen/ziele") < slug
|
||||
@@ -10,8 +10,14 @@ POSTGRES_PASSWORD=vorrania
|
||||
POSTGRES_DB=vorrania
|
||||
|
||||
# Sicherheit – im Betrieb unbedingt ändern!
|
||||
# Ein neuer Wert beendet ALLE laufenden Anmeldungen – das ist zugleich der
|
||||
# einzige Weg, eine unbegrenzt gültige Sitzung wieder loszuwerden.
|
||||
JWT_SECRET=change-me-please
|
||||
|
||||
# Wie lange eine Anmeldung gilt, in Minuten. Nur der Startwert: im Betrieb
|
||||
# stellt man das unter Einstellungen -> Anmeldung ein (auch "unbegrenzt").
|
||||
#JWT_EXPIRE_MINUTES=43200
|
||||
|
||||
# Erster Admin-Benutzer (wird nur beim allerersten Start angelegt)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
@@ -131,6 +131,21 @@ actor APIClient {
|
||||
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
|
||||
}
|
||||
|
||||
/// Schiebt die Frist der laufenden Sitzung nach vorn.
|
||||
///
|
||||
/// Ohne das zaehlt die am Server eingestellte Dauer ab dem *Anmelden*: Wer
|
||||
/// die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf hinaus.
|
||||
///
|
||||
/// Bewusst ohne die Erneuerungsschicht: Ein 401 heisst hier, dass die
|
||||
/// Sitzung wirklich vorbei ist – dann gehoert sie beendet und nicht ueber
|
||||
/// einen Umweg gerettet.
|
||||
func sitzungVerlaengern() async throws -> TokenResponse {
|
||||
let request = try makeRequest("/auth/refresh", method: "POST")
|
||||
let (daten, antwort) = try await URLSession.shared.data(for: request)
|
||||
try check(antwort, data: daten)
|
||||
return try JSONDecoder().decode(TokenResponse.self, from: daten)
|
||||
}
|
||||
|
||||
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
|
||||
func oidcProviders() async throws -> [OidcProviderResponse] {
|
||||
let (daten, antwort) = try await URLSession.shared.data(
|
||||
@@ -201,6 +216,12 @@ actor APIClient {
|
||||
return try await send(request, as: Product.self)
|
||||
}
|
||||
|
||||
/// Artikel löschen. Der Server lehnt mit 409 ab, solange Einzelstücke oder
|
||||
/// Chargen dranhängen – die Begründung steht dann in der Fehlermeldung.
|
||||
func deleteProduct(id: Int) async throws {
|
||||
try await sendNoContent(try makeRequest("/products/\(id)", method: "DELETE"))
|
||||
}
|
||||
|
||||
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
|
||||
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
||||
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
|
||||
@@ -264,6 +285,12 @@ actor APIClient {
|
||||
return try await send(request, as: [Item].self)
|
||||
}
|
||||
|
||||
/// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das
|
||||
/// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen.
|
||||
func item(id: Int) async throws -> Item {
|
||||
try await send(try makeRequest("/items/\(id)"), as: Item.self)
|
||||
}
|
||||
|
||||
/// QR-Auflösung: UID → Einzelstück.
|
||||
func itemByUid(uid: String) async throws -> Item {
|
||||
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
||||
@@ -278,6 +305,21 @@ actor APIClient {
|
||||
|
||||
// MARK: - Verleih von Einzelstuecken
|
||||
|
||||
/// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen
|
||||
/// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung
|
||||
/// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen.
|
||||
func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item {
|
||||
var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST")
|
||||
try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId))
|
||||
return try await send(request, as: Item.self)
|
||||
}
|
||||
|
||||
func zubehoerLoesen(itemId: Int, childItemId: Int) async throws {
|
||||
try await sendNoContent(
|
||||
try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE")
|
||||
)
|
||||
}
|
||||
|
||||
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
|
||||
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
|
||||
var request = try makeRequest("/items/\(id)/loan", method: "POST")
|
||||
@@ -297,6 +339,25 @@ actor APIClient {
|
||||
try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self)
|
||||
}
|
||||
|
||||
// MARK: - Verwendungsstatus
|
||||
|
||||
/// Umstellen; `status` nil heisst verfuegbar. Eigener Endpunkt aus
|
||||
/// demselben Grund wie Zubehoer, und weil updateItem Admins vorbehalten ist
|
||||
/// - umstellen darf jeder angemeldete Benutzer.
|
||||
///
|
||||
/// Antwortet mit dem ganzen Stueck, die Aufrufstelle braucht also kein
|
||||
/// zweites Laden.
|
||||
func setzeVerwendung(id: Int, status: VerwendungStatus?) async throws -> Item {
|
||||
var request = try makeRequest("/items/\(id)/verwendung", method: "PUT")
|
||||
try jsonBody(&request, VerwendungSetzenRequest(status: status?.rawValue))
|
||||
return try await send(request, as: Item.self)
|
||||
}
|
||||
|
||||
/// Verwendungs-Verlauf eines Stuecks, neueste zuerst.
|
||||
func itemVerwendungen(id: Int) async throws -> [Verwendung] {
|
||||
try await send(try makeRequest("/items/\(id)/verwendungen"), as: [Verwendung].self)
|
||||
}
|
||||
|
||||
/// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren
|
||||
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
|
||||
func loans(dueIn: Int? = nil) async throws -> [Loan] {
|
||||
@@ -383,6 +444,22 @@ actor APIClient {
|
||||
|
||||
// MARK: - Listen
|
||||
|
||||
/// Die ganze Einkaufsliste - gerechnete Bedarfe UND eigene Eintraege.
|
||||
///
|
||||
/// Ein Aufruf statt drei, und vor allem der einzige, der die selbst
|
||||
/// draufgesetzten Posten mitbringt.
|
||||
func shoppingAll() async throws -> ShoppingListAll {
|
||||
try await send(try makeRequest("/shopping-list/all"), as: ShoppingListAll.self)
|
||||
}
|
||||
|
||||
/// Einen eigenen Eintrag abhaken oder wieder aufmachen.
|
||||
@discardableResult
|
||||
func setShoppingEntryDone(_ id: Int, done: Bool) async throws -> ShoppingEntry {
|
||||
var request = try makeRequest("/shopping-list/entries/\(id)", method: "PATCH")
|
||||
try jsonBody(&request, ["done": done])
|
||||
return try await send(request, as: ShoppingEntry.self)
|
||||
}
|
||||
|
||||
func shoppingList() async throws -> [ShoppingItem] {
|
||||
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
|
||||
}
|
||||
@@ -425,6 +502,39 @@ actor APIClient {
|
||||
return try await send(request, as: StockResponse.self)
|
||||
}
|
||||
|
||||
/// Alle Chargen mit Artikelangaben - fuer die uebergreifende Chargenliste.
|
||||
func allLotRows() async throws -> [LotRow] {
|
||||
try await send(try makeRequest("/lots/rows"), as: [LotRow].self)
|
||||
}
|
||||
|
||||
/// Eine Packung anbrechen: Sie wird als eigene, offene Charge gefuehrt und
|
||||
/// bekommt ein "zu verbrauchen bis". Ohne Menge ist es eine Artikeleinheit.
|
||||
@discardableResult
|
||||
func openLot(id: Int, quantity: Double? = nil) async throws -> Lot {
|
||||
var request = try makeRequest("/lots/\(id)/open", method: "POST")
|
||||
try jsonBody(&request, ["quantity": quantity])
|
||||
return try await send(request, as: Lot.self)
|
||||
}
|
||||
|
||||
/// Teilmenge aus einer Charge verbrauchen.
|
||||
///
|
||||
/// `autoOpen` bricht eine noch geschlossene Charge dabei an - die Rueckfrage
|
||||
/// dazu stellt die Oberflaeche, nicht der Server.
|
||||
@discardableResult
|
||||
func consumeLot(id: Int, quantity: Double, unit: String,
|
||||
note: String?, autoOpen: Bool) async throws -> ConsumeResult {
|
||||
var request = try makeRequest("/lots/\(id)/consume", method: "POST")
|
||||
try jsonBody(&request, ConsumeRequest(quantity: quantity, unit: unit,
|
||||
note: note, autoOpen: autoOpen))
|
||||
return try await send(request, as: ConsumeResult.self)
|
||||
}
|
||||
|
||||
/// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft.
|
||||
func productMeasures(productId: Int) async throws -> [MeasureAmount] {
|
||||
try await send(try makeRequest("/products/\(productId)/measures"),
|
||||
as: [MeasureAmount].self)
|
||||
}
|
||||
|
||||
func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot {
|
||||
var request = try makeRequest("/lots/\(id)", method: "PATCH")
|
||||
try jsonBody(&request, payload)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Zeile einer flach ausgelegten Baumliste, mit ihrer Tiefe.
|
||||
///
|
||||
/// Lagerorte und Kategorien sind Baeume, kommen vom Server aber als flache
|
||||
/// Liste nach Namen sortiert. Daraus wird eine Auswahl, in der "Garage → Fach A"
|
||||
/// und "Keller → Fach A" irgendwo zwischen allem anderen stehen - Geschwister
|
||||
/// und Kinder finden nie zusammen. Diese Reihenfolge stellt das her.
|
||||
struct Baumzeile<T: TreeItem>: Identifiable {
|
||||
let item: T
|
||||
let tiefe: Int
|
||||
|
||||
var id: T.ID { item.id }
|
||||
|
||||
/// Name mit Einrueckung je Ebene.
|
||||
///
|
||||
/// Eingerueckt wird mit fuehrenden Leerzeichen, weil ein aufklappendes
|
||||
/// Auswahlmenue ausser Text nichts annimmt - Layout ignoriert es.
|
||||
/// Bekannte Grenze: Bricht ein langer Name auf eine zweite Zeile um,
|
||||
/// faengt die wieder ganz links an. Eine Listendarstellung
|
||||
/// (`.pickerStyle(.navigationLink)`) koennte das, riss aber im Blatt den
|
||||
/// ganzen Navigationsstapel mit und ist deshalb wieder draussen.
|
||||
var label: String { String(repeating: " ", count: tiefe) + item.name }
|
||||
}
|
||||
|
||||
/// Bringt eine flache Eltern-Kind-Liste in Baumreihenfolge, mit Tiefe.
|
||||
///
|
||||
/// Waisen - deren Elternteil weggefiltert oder geloescht ist - gelten als
|
||||
/// oberste Ebene. Ohne diese Regel verschwaenden sie lautlos aus der Auswahl.
|
||||
func baum<T: TreeItem>(_ liste: [T]) -> [Baumzeile<T>] {
|
||||
let ids = Set(liste.map(\.id))
|
||||
var ergebnis: [Baumzeile<T>] = []
|
||||
|
||||
func gehe(_ knoten: T, _ tiefe: Int) {
|
||||
ergebnis.append(Baumzeile(item: knoten, tiefe: tiefe))
|
||||
for kind in liste.filter({ $0.parentId == knoten.id }) {
|
||||
gehe(kind, tiefe + 1)
|
||||
}
|
||||
}
|
||||
for wurzel in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||
gehe(wurzel, 0)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
@@ -13,9 +13,8 @@ struct CategoryPicker: View {
|
||||
var body: some View {
|
||||
Picker("Kategorie", selection: $selection) {
|
||||
Text("– keine –").tag(Int?.none)
|
||||
ForEach(flattened(), id: \.item.id) { eintrag in
|
||||
Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name)
|
||||
.tag(Int?.some(eintrag.item.id))
|
||||
ForEach(baum(gefiltert)) { eintrag in
|
||||
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,23 +23,6 @@ struct CategoryPicker: View {
|
||||
guard let tracking else { return categories }
|
||||
return categories.filter { $0.tracking == tracking }
|
||||
}
|
||||
|
||||
private func flattened() -> [(item: CategoryItem, depth: Int)] {
|
||||
let quelle = gefiltert
|
||||
let ids = Set(quelle.map(\.id))
|
||||
var result: [(CategoryItem, Int)] = []
|
||||
|
||||
func walk(_ node: CategoryItem, _ depth: Int) {
|
||||
result.append((node, depth))
|
||||
for child in quelle.filter({ $0.parentId == node.id }) {
|
||||
walk(child, depth + 1)
|
||||
}
|
||||
}
|
||||
for root in quelle.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||
walk(root, 0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht
|
||||
|
||||
@@ -211,8 +211,8 @@ struct CheckInFormView: View {
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– keiner –").tag(String?.none)
|
||||
ForEach(locations) { location in
|
||||
Text(location.path(in: locations)).tag(String?.some(location.id))
|
||||
ForEach(baum(locations)) { eintrag in
|
||||
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||
}
|
||||
}
|
||||
Button {
|
||||
|
||||
@@ -56,6 +56,22 @@ struct CheckInView: View {
|
||||
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
||||
}
|
||||
|
||||
// Eingenaehte Tags an Kleidung lassen sich nicht scannen. Ein
|
||||
// Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er
|
||||
// dorthin und nicht in die Artikelsuche.
|
||||
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
|
||||
Task {
|
||||
error = nil; status = nil
|
||||
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||
scannedItem = found
|
||||
} else {
|
||||
error = "Kein Einzelstück mit der UID \(uid)."
|
||||
}
|
||||
}
|
||||
} onFehler: { meldung in
|
||||
error = meldung
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||
product = created
|
||||
@@ -675,7 +691,11 @@ struct KassenzettelPruefenView: View {
|
||||
HStack {
|
||||
Menu {
|
||||
Button("– ohne –") { z.wrappedValue.ortId = nil }
|
||||
ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } }
|
||||
// Voller Pfad statt nur des Namens: In dieser engen Zeile gibt es
|
||||
// keine Einrueckung, und "Fach A" allein ist nicht eindeutig.
|
||||
ForEach(locations) { loc in
|
||||
Button(loc.path(in: locations)) { z.wrappedValue.ortId = loc.id }
|
||||
}
|
||||
} label: {
|
||||
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ struct CheckOutView: View {
|
||||
@State private var manualCodeShown = false
|
||||
@State private var manualCode = ""
|
||||
@State private var searchShown = false
|
||||
/// Per NFC gelesenes Einzelstueck – Auslagern laeuft dort ueber „Entfernen".
|
||||
@State private var scannedItem: Item?
|
||||
|
||||
var body: some View {
|
||||
// Kamera als Feld oben, siehe CheckInView.
|
||||
@@ -48,6 +50,25 @@ struct CheckOutView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
// Ein Tag benennt immer genau ein Einzelstueck. Auslagern
|
||||
// heisst dort „entfernen mit Grund", und das steht in der
|
||||
// Stueck-Ansicht – deshalb fuehrt der Tag dorthin.
|
||||
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
|
||||
Task {
|
||||
error = nil; status = nil
|
||||
paused = true
|
||||
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||
scannedItem = found
|
||||
} else {
|
||||
error = "Kein Einzelstück mit der UID \(uid)."
|
||||
paused = false
|
||||
}
|
||||
}
|
||||
} onFehler: { meldung in
|
||||
error = meldung
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -70,6 +91,9 @@ struct CheckOutView: View {
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { paused = false }
|
||||
}
|
||||
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
|
||||
NavigationStack { ItemEditView(item: it) }
|
||||
}
|
||||
.sheet(isPresented: $searchShown) {
|
||||
NavigationStack {
|
||||
ProductSearchView { picked in
|
||||
|
||||
@@ -226,18 +226,33 @@ struct ShoppingCard: View {
|
||||
let stand: Int
|
||||
|
||||
@State private var items: [ShoppingItem] = []
|
||||
/// Selbst draufgesetzte Posten. Ohne sie meldete die Karte, alles sei
|
||||
/// gedeckt, waehrend auf der Liste fuenf Dinge standen.
|
||||
@State private var eintraege: [ShoppingEntry] = []
|
||||
@State private var fehler: String?
|
||||
|
||||
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fehler {
|
||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||
} else if items.isEmpty {
|
||||
} else if items.isEmpty && offene.isEmpty {
|
||||
Text("Alle Mindestbestände sind gedeckt.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items.prefix(5)) { item in
|
||||
// Eigene Eintraege zuerst: Sie stehen da, weil jemand sie
|
||||
// bewusst draufgesetzt hat.
|
||||
ForEach(offene.prefix(5)) { eintrag in
|
||||
HStack {
|
||||
Text(eintrag.name).font(.callout)
|
||||
Spacer()
|
||||
Text(eintrag.mengeText.isEmpty ? "selbst notiert" : eintrag.mengeText)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
ForEach(items.prefix(max(0, 5 - offene.count))) { item in
|
||||
HStack {
|
||||
Text(item.name).font(.callout)
|
||||
Spacer()
|
||||
@@ -252,7 +267,11 @@ struct ShoppingCard: View {
|
||||
}
|
||||
.task(id: stand) {
|
||||
do {
|
||||
items = try await APIClient.shared.shoppingList()
|
||||
// Die gebuendelte Route: Nur sie bringt auch die selbst
|
||||
// draufgesetzten Posten mit.
|
||||
let alles = try await APIClient.shared.shoppingAll()
|
||||
items = alles.products
|
||||
eintraege = alles.entries
|
||||
fehler = nil
|
||||
} catch {
|
||||
self.fehler = error.localizedDescription
|
||||
@@ -291,7 +310,7 @@ struct ExpiryCard: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.productName).font(.callout)
|
||||
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))")
|
||||
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision))")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
|
||||
@@ -217,8 +217,8 @@ struct GroupEditor: View {
|
||||
HStack {
|
||||
Picker("Ort", selection: row.locationId) {
|
||||
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
||||
ForEach(locations) { l in
|
||||
Text(l.path(in: locations)).tag(String?.some(l.id))
|
||||
ForEach(baum(locations)) { eintrag in
|
||||
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||
}
|
||||
}
|
||||
TextField("Menge", text: row.amount)
|
||||
|
||||
@@ -11,6 +11,28 @@ struct ItemListView: View {
|
||||
@State private var locationFilter: String? // Lagerort-ID
|
||||
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
|
||||
@State private var verleihFilter: VerleihFilter = .alle
|
||||
@State private var verwendungFilter: VerwendungFilter = .alle
|
||||
|
||||
/// Blickwinkel auf den Verwendungszustand – „was liegt gerade in der
|
||||
/// Wäsche?" ist die Frage dahinter. „Verfügbar" ist dabei das Fehlen eines
|
||||
/// laufenden Zeitraums, kein eigener Serverwert.
|
||||
enum VerwendungFilter: Hashable, CaseIterable {
|
||||
case alle
|
||||
case verfuegbar
|
||||
case status(VerwendungStatus)
|
||||
|
||||
static var allCases: [VerwendungFilter] {
|
||||
[.alle, .verfuegbar] + VerwendungStatus.allCases.map { .status($0) }
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .alle: return "Alle"
|
||||
case .verfuegbar: return "Verfügbar"
|
||||
case let .status(s): return s.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die
|
||||
/// Frage, die man im Alltag an diese Liste stellt.
|
||||
@@ -28,6 +50,8 @@ struct ItemListView: View {
|
||||
}
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
|
||||
@State private var zurueckFuer: Item?
|
||||
|
||||
private struct TreeEntry: Identifiable {
|
||||
let item: CategoryItem
|
||||
@@ -95,6 +119,16 @@ struct ItemListView: View {
|
||||
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
|
||||
case .imHaus: if it.loan != nil { return false }
|
||||
}
|
||||
switch verwendungFilter {
|
||||
case .alle: break
|
||||
// Stuecke ohne Verwendungsstatus fallen hier bewusst raus: Ein
|
||||
// Schraubenzieher ist nicht „verfuegbar", er fuehrt den Zustand gar
|
||||
// nicht.
|
||||
case .verfuegbar:
|
||||
if !it.zeigtVerwendung || it.verwendungStatus != nil { return false }
|
||||
case let .status(gesucht):
|
||||
if it.verwendungStatus != gesucht { return false }
|
||||
}
|
||||
if !q.isEmpty {
|
||||
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
||||
.compactMap { $0 }.joined(separator: " ").lowercased()
|
||||
@@ -106,6 +140,7 @@ struct ItemListView: View {
|
||||
|
||||
private var hatFilter: Bool {
|
||||
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
|
||||
|| verwendungFilter != .alle
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -118,6 +153,23 @@ struct ItemListView: View {
|
||||
NavigationLink { ItemEditView(item: it) } label: {
|
||||
ItemRow(item: it, locations: locations)
|
||||
}
|
||||
// Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber
|
||||
// das Bearbeiten-Formular des Stuecks.
|
||||
.swipeActions(edge: .leading) {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.tint(.green)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if it.istVerliehen {
|
||||
Button { zurueckFuer = it } label: {
|
||||
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||
@@ -128,11 +180,39 @@ struct ItemListView: View {
|
||||
.refreshable { await load() }
|
||||
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
||||
.onAppear { Task { await load() } }
|
||||
.confirmationDialog(
|
||||
zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "",
|
||||
isPresented: Binding(get: { zurueckFuer != nil },
|
||||
set: { if !$0 { zurueckFuer = nil } }),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { zurueckFuer = nil }
|
||||
} message: {
|
||||
if let it = zurueckFuer, let l = it.loan {
|
||||
let mit = it.teile.filter(\.verliehen)
|
||||
Text("Von \(l.borrower)." + (mit.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mit.map(\.uid).joined(separator: ", ") + "."))
|
||||
}
|
||||
}
|
||||
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: { Text(error ?? "") }
|
||||
}
|
||||
|
||||
private func nimmZurueck() async {
|
||||
guard let it = zurueckFuer else { return }
|
||||
zurueckFuer = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: it.id,
|
||||
LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id))
|
||||
)
|
||||
await load()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private var filterMenu: some View {
|
||||
Menu {
|
||||
Menu("Lagerort") {
|
||||
@@ -146,6 +226,11 @@ struct ItemListView: View {
|
||||
check(f.label, verleihFilter == f) { verleihFilter = f }
|
||||
}
|
||||
}
|
||||
Menu("Verwendung") {
|
||||
ForEach(VerwendungFilter.allCases, id: \.self) { f in
|
||||
check(f.label, verwendungFilter == f) { verwendungFilter = f }
|
||||
}
|
||||
}
|
||||
if !filterKategorien.isEmpty {
|
||||
Menu("Kategorie") {
|
||||
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
||||
@@ -157,15 +242,18 @@ struct ItemListView: View {
|
||||
if hatFilter {
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
locationFilter = nil; categoryFilter = nil; verleihFilter = .alle
|
||||
locationFilter = nil; categoryFilter = nil
|
||||
verleihFilter = .alle; verwendungFilter = .alle
|
||||
} label: {
|
||||
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: hatFilter
|
||||
Label(hatFilter ? verleihFilter.label : "Filter",
|
||||
systemImage: hatFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +302,13 @@ struct ItemRow: View {
|
||||
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
// Nur, wenn etwas laeuft: „Verfuegbar" unter jeder zweiten
|
||||
// Zeile waere Rauschen.
|
||||
if let status = item.verwendungStatus {
|
||||
Label(status.label, systemImage: status.symbol)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
+430
-15
@@ -20,6 +20,41 @@ private func anzeigeDatum(_ iso: String?) -> String {
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
/// ISO-Zeitstempel -> "20.09.2026, 18:42". Anders als bei Daten steht hier ein
|
||||
/// voller Zeitpunkt: Ein Hemd wechselt mehrmals am Tag den Zustand.
|
||||
func anzeigeZeitpunkt(_ iso: String?) -> String {
|
||||
guard let iso else { return "–" }
|
||||
guard let d = zeitpunktAus(iso) else { return iso }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "de_DE")
|
||||
f.dateFormat = "dd.MM.yyyy, HH:mm"
|
||||
return f.string(from: d)
|
||||
}
|
||||
|
||||
/// Zeitstempel des Servers lesen - mit und ohne Zeitzonenangabe.
|
||||
///
|
||||
/// Postgres liefert sie mit Zone ("…+00:00"), SQLite laesst sie weg. Ohne
|
||||
/// Angabe ist es UTC, denn der Server schreibt `datetime.now(UTC)`. Ohne diesen
|
||||
/// Zweig stuende in der App der rohe ISO-Text.
|
||||
private func zeitpunktAus(_ iso: String) -> Date? {
|
||||
let mitZone = ISO8601DateFormatter()
|
||||
mitZone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = mitZone.date(from: iso) { return d }
|
||||
mitZone.formatOptions = [.withInternetDateTime]
|
||||
if let d = mitZone.date(from: iso) { return d }
|
||||
|
||||
let ohneZone = DateFormatter()
|
||||
ohneZone.locale = Locale(identifier: "en_US_POSIX")
|
||||
ohneZone.timeZone = TimeZone(identifier: "UTC")
|
||||
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSS",
|
||||
"yyyy-MM-dd'T'HH:mm:ss"] {
|
||||
ohneZone.dateFormat = muster
|
||||
if let d = ohneZone.date(from: iso) { return d }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
|
||||
/// Ueberfaelliges kommt beim Aufrufer rot daher.
|
||||
func verleihZeile(_ loan: Loan) -> String {
|
||||
@@ -78,9 +113,14 @@ struct ProductItemsSection: View {
|
||||
var onChanged: () async -> Void
|
||||
/// Bis zur Liste schließen (für „Anlegen & fertig").
|
||||
var onFinish: (() -> Void)? = nil
|
||||
/// Hochzaehlen laedt die Liste neu. Noetig, seit das Anlege-Blatt oben
|
||||
/// haengt: Von dort kommt kein Rueckruf mehr in diesen Abschnitt.
|
||||
var nachladen: Int = 0
|
||||
/// Oeffnet das Anlege-Blatt. Die Bindung gehoert der Elternansicht - das
|
||||
/// Blatt wird dort gezeigt, nicht hier (siehe unten).
|
||||
@Binding var anlegen: Bool
|
||||
|
||||
@State private var items: [Item] = []
|
||||
@State private var showAdd = false
|
||||
|
||||
var body: some View {
|
||||
Section("Einzelstücke (\(items.count))") {
|
||||
@@ -97,17 +137,15 @@ struct ProductItemsSection: View {
|
||||
if items.isEmpty {
|
||||
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
|
||||
}
|
||||
Button { showAdd = true } label: {
|
||||
Button { anlegen = true } label: {
|
||||
Label("Einzelstücke anlegen", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
.task(id: product.id) { await reload() }
|
||||
.sheet(isPresented: $showAdd) {
|
||||
NavigationStack {
|
||||
ItemAddSheet(productId: product.id, onDone: { await refresh() },
|
||||
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
|
||||
}
|
||||
}
|
||||
.task(id: "\(product.id)-\(nachladen)") { await reload() }
|
||||
// Kein .sheet hier. Aus einem Abschnitt heraus gezeigt, schloss es sich
|
||||
// beim ERSTEN Oeffnen sofort wieder und hielt erst beim zweiten Versuch;
|
||||
// ProductDetailView hat die Mengen-Blaetter aus genau diesem Grund
|
||||
// laengst auf die Form-Ebene gelegt. Hier stand es nur nie nach.
|
||||
}
|
||||
|
||||
private func untertitel(_ it: Item) -> String {
|
||||
@@ -223,6 +261,13 @@ struct ItemEditView: View {
|
||||
@State private var previewURL: URL?
|
||||
@State private var locScanShown = false
|
||||
@State private var nfcShown = false
|
||||
@State private var zurueckFrage = false
|
||||
// Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen
|
||||
// ist das uebergebene Stueck veraltet, die Ansicht aber noch offen.
|
||||
@State private var teile: [ItemKurz] = []
|
||||
@State private var gehoertZu: ItemKurz?
|
||||
@State private var zubehoerWaehlen = false
|
||||
@State private var mitVerleihen: Set<Int> = []
|
||||
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||||
@State private var loan: Loan?
|
||||
@State private var loanHistory: [Loan] = []
|
||||
@@ -232,6 +277,17 @@ struct ItemEditView: View {
|
||||
@State private var hasDue = false
|
||||
@State private var due = Date()
|
||||
@State private var busyLoan = false
|
||||
// Verwendung: der laufende Zustand (nil = verfuegbar), nachgeladen wie der
|
||||
// Verleih. `zeigtVerwendung` kommt vom uebergebenen Stueck und aendert sich
|
||||
// waehrend der Ansicht nicht.
|
||||
@State private var verwendung: VerwendungStatus?
|
||||
// Was der Server zuletzt gemeldet hat. Ohne diesen Abgleich buchte das
|
||||
// onChange der Leiste auch dann, wenn `verwendung` nur beim Laden gesetzt
|
||||
// wurde - also bei jedem Oeffnen einmal ins Leere.
|
||||
@State private var verwendungServer: VerwendungStatus?
|
||||
@State private var verwendungSeit: String?
|
||||
@State private var verwendungAnzahl = 0
|
||||
@State private var busyVerwendung = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -258,6 +314,39 @@ struct ItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Woran ist es gerade? Steht oben, weil alle Scan-Wege hier landen
|
||||
// und „gerade aus der Waesche geholt" ein Griff sein soll - also
|
||||
// eine Buchung wie der Verleih, kein Feld, das auf „Speichern"
|
||||
// wartet. Vier Woerter waeren auf einem iPhone zu breit, vier
|
||||
// Symbole passen immer.
|
||||
if item.zeigtVerwendung {
|
||||
Section {
|
||||
Picker("Verwendung", selection: $verwendung) {
|
||||
ForEach(VerwendungStatus.auswahl, id: \.self) { status in
|
||||
Image(systemName: VerwendungStatus.symbol(status))
|
||||
.tag(status)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(busyVerwendung)
|
||||
.onChange(of: verwendung) { neu in
|
||||
Task { await setzeVerwendung(neu) }
|
||||
}
|
||||
if verwendungAnzahl > 0 {
|
||||
NavigationLink("Verwendungs-Verlauf (\(verwendungAnzahl))") {
|
||||
VerwendungHistorieView(uid: item.uid, itemId: item.id)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Verwendung")
|
||||
} footer: {
|
||||
Text(busyVerwendung
|
||||
? "Moment…"
|
||||
: VerwendungStatus.label(verwendung)
|
||||
+ (verwendungSeit.map { " · seit \($0)" } ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
|
||||
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
|
||||
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
|
||||
@@ -279,7 +368,7 @@ struct ItemEditView: View {
|
||||
LabeledContent("Zurück bis",
|
||||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
||||
}
|
||||
Button { Task { await nimmZurueck() } } label: {
|
||||
Button { zurueckFrage = true } label: {
|
||||
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||
}
|
||||
.disabled(busyLoan)
|
||||
@@ -296,10 +385,44 @@ struct ItemEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an
|
||||
// genau einem Geraet, ein Geraet traegt beliebig viele Teile.
|
||||
Section("Zubehör") {
|
||||
if let g = gehoertZu {
|
||||
LabeledContent("Gehört zu") {
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text(g.productName ?? "Einzelstück")
|
||||
Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(teile) { t in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück")
|
||||
Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if t.verliehen {
|
||||
Text("verliehen").font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.swipeActions {
|
||||
Button("Lösen", role: .destructive) { Task { await loese(t) } }
|
||||
}
|
||||
}
|
||||
if teile.isEmpty && gehoertZu == nil {
|
||||
Text("Kein Zubehör.").foregroundStyle(.secondary)
|
||||
}
|
||||
Button { zubehoerWaehlen = true } label: {
|
||||
Label("Zubehör hinzufügen", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
|
||||
}
|
||||
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
|
||||
// des Einzelstücks. Praktisch z.B. bei Kleidung.
|
||||
@@ -400,6 +523,25 @@ struct ItemEditView: View {
|
||||
.sheet(isPresented: $nfcShown) {
|
||||
NavigationStack { NFCSchreibenView(item: item) }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"„\(item.uid)“ zurücknehmen?",
|
||||
isPresented: $zurueckFrage, titleVisibility: .visible
|
||||
) {
|
||||
Button("Zurücknehmen") { Task { await nimmZurueck() } }
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
let wer = loan.map { "Von \($0.borrower)." } ?? ""
|
||||
let mit = mitZurueck.isEmpty ? "" :
|
||||
" Das mitverliehene Zubehör kommt mit zurück: "
|
||||
+ mitZurueck.map(\.uid).joined(separator: ", ") + "."
|
||||
Text(wer + mit)
|
||||
}
|
||||
.sheet(isPresented: $zubehoerWaehlen) {
|
||||
// Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts.
|
||||
ItemPickerSheet { gewaehlt in
|
||||
Task { await haengeAn(gewaehlt) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showRemove) {
|
||||
NavigationStack { removeSheet }
|
||||
}
|
||||
@@ -445,6 +587,29 @@ struct ItemEditView: View {
|
||||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
||||
}
|
||||
// Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig
|
||||
// verliehene Teile stehen nicht zur Wahl - die sind gar nicht da.
|
||||
if !teile.filter({ !$0.verliehen }).isEmpty {
|
||||
Section("Zubehör mitgeben?") {
|
||||
ForEach(teile.filter { !$0.verliehen }) { t in
|
||||
Button {
|
||||
if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) }
|
||||
else { mitVerleihen.insert(t.id) }
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: mitVerleihen.contains(t.id)
|
||||
? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(Color.marke)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.productName ?? "Einzelstück").foregroundStyle(.primary)
|
||||
Text(t.uid).font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||
Section {
|
||||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||||
@@ -481,6 +646,9 @@ struct ItemEditView: View {
|
||||
hasDue = false
|
||||
due = Date()
|
||||
error = nil
|
||||
// Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt
|
||||
// das Netzteil meist mit. Abwaehlen geht mit einem Tipp.
|
||||
mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id))
|
||||
showLend = true
|
||||
}
|
||||
|
||||
@@ -490,23 +658,68 @@ struct ItemEditView: View {
|
||||
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
|
||||
borrower: borrower.trimmingCharacters(in: .whitespaces),
|
||||
reason: loanReason.isEmpty ? nil : loanReason,
|
||||
dueOn: hasDue ? dateToString(due) : nil))
|
||||
dueOn: hasDue ? dateToString(due) : nil,
|
||||
mitZubehoer: Array(mitVerleihen)))
|
||||
showLend = false
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
/// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt.
|
||||
private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } }
|
||||
|
||||
private func nimmZurueck() async {
|
||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.returnItem(id: item.id)
|
||||
_ = try await APIClient.shared.returnItem(
|
||||
id: item.id,
|
||||
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
|
||||
)
|
||||
loan = nil
|
||||
await ladeVerleihe()
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
/// Verwendungszustand umstellen. Leer = verfuegbar.
|
||||
///
|
||||
/// Die Antwort traegt das ganze Stueck, ein zweites Laden entfaellt. Nur
|
||||
/// der Verlaufszaehler wird nachgeholt.
|
||||
private func setzeVerwendung(_ neu: VerwendungStatus?) async {
|
||||
guard neu != verwendungServer, !busyVerwendung else { return }
|
||||
busyVerwendung = true; defer { busyVerwendung = false }
|
||||
error = nil
|
||||
do {
|
||||
let frisch = try await APIClient.shared.setzeVerwendung(id: item.id, status: neu)
|
||||
uebernimm(frisch.verwendung)
|
||||
await ladeVerwendungen()
|
||||
onChanged?()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
// Zurueck auf den Stand des Servers, sonst zeigt die Leiste eine
|
||||
// Stellung an, die nie gebucht wurde.
|
||||
await ladeVerwendungen()
|
||||
}
|
||||
}
|
||||
|
||||
private func uebernimm(_ offen: Verwendung?) {
|
||||
verwendungServer = offen?.status
|
||||
verwendung = offen?.status
|
||||
verwendungSeit = offen.map { anzeigeZeitpunkt($0.startedAt) }
|
||||
}
|
||||
|
||||
/// Verlauf holen - fuer den Zaehler am Verlaufs-Eintrag und um den
|
||||
/// laufenden Zustand gegen den Server abzugleichen.
|
||||
private func ladeVerwendungen() async {
|
||||
guard item.zeigtVerwendung else { return }
|
||||
let verlauf = (try? await APIClient.shared.itemVerwendungen(id: item.id)) ?? []
|
||||
verwendungAnzahl = verlauf.count
|
||||
uebernimm(verlauf.first(where: { $0.laeuft }))
|
||||
}
|
||||
|
||||
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
|
||||
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
|
||||
private func ladeVerleihe() async {
|
||||
@@ -527,7 +740,38 @@ struct ItemEditView: View {
|
||||
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
|
||||
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||||
loan = item.loan
|
||||
teile = item.teile
|
||||
gehoertZu = item.gehoertZu
|
||||
uebernimm(item.verwendung)
|
||||
await ladeVerleihe()
|
||||
await ladeVerwendungen()
|
||||
}
|
||||
|
||||
private func haengeAn(_ gewaehlt: Item) async {
|
||||
error = nil
|
||||
do {
|
||||
_ = try await APIClient.shared.zubehoerZuordnen(
|
||||
itemId: item.id, childItemId: gewaehlt.id
|
||||
)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func loese(_ teil: ItemKurz) async {
|
||||
error = nil
|
||||
do {
|
||||
try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id)
|
||||
await ladeZubehoer()
|
||||
onChanged?()
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
|
||||
private func ladeZubehoer() async {
|
||||
guard let frisch = try? await APIClient.shared.item(id: item.id) else { return }
|
||||
teile = frisch.teile
|
||||
gehoertZu = frisch.gehoertZu
|
||||
loan = frisch.loan
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
@@ -641,6 +885,73 @@ struct ItemEditView: View {
|
||||
|
||||
// MARK: - Anlegen
|
||||
|
||||
extension View {
|
||||
/// Nach dem Anlegen eines Artikels gleich dessen Einzelstücke erfassen.
|
||||
///
|
||||
/// „Artikel anlegen" endete bisher im Nichts: `ProductFormView` liefert den
|
||||
/// neuen Artikel, aber die Aufrufer warfen ihn weg – und „Einzelstücke
|
||||
/// anlegen" steckt ausschliesslich in der Artikelansicht, die man von dort
|
||||
/// aus gar nicht erreicht.
|
||||
///
|
||||
/// Die Bindung wird erst im `onDismiss` des Formulars gesetzt: Beides im
|
||||
/// selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet dann
|
||||
/// erscheinen soll, waehrend das erste noch verschwindet.
|
||||
/// Je nach Verwaltungsart geht es zu den Einzelstuecken oder zur Menge je
|
||||
/// Lagerort. Beides haengt an EINEM sheet-Modifikator, nicht an zweien:
|
||||
/// Zwei Praesentationen am selben View vertragen sich in SwiftUI nicht
|
||||
/// zuverlaessig - dann schliesst sich das Blatt beim ersten Mal sofort
|
||||
/// wieder und erst der zweite Anlauf haelt.
|
||||
func nachAnlegen(_ artikel: Binding<Product?>) -> some View {
|
||||
sheet(item: artikel) { p in
|
||||
NavigationStack {
|
||||
if fuehrtEinzelstuecke(p) {
|
||||
ItemAddSheet(productId: p.id,
|
||||
onDone: {},
|
||||
onFinish: { artikel.wrappedValue = nil })
|
||||
} else {
|
||||
MengeNachAnlegenSheet(product: p) { artikel.wrappedValue = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hat dieser Artikel ueberhaupt Einzelstuecke? Nur Gegenstaende, die je Stueck
|
||||
/// eine eigene UID fuehren – Lebensmittel und Mengen-Gegenstaende nicht.
|
||||
func fuehrtEinzelstuecke(_ p: Product?) -> Bool {
|
||||
guard let p else { return false }
|
||||
return p.isObject && p.isIndividual
|
||||
}
|
||||
|
||||
/// Gegenstand, dessen Bestand nur als Menge je Lagerort gezaehlt wird – also
|
||||
/// weder Einzelstuecke noch Verbrauchsgegenstand (der laeuft als Charge).
|
||||
func fuehrtMengeJeLagerort(_ p: Product?) -> Bool {
|
||||
guard let p else { return false }
|
||||
return p.isObject && !p.isIndividual && !p.isBulk
|
||||
}
|
||||
|
||||
/// Laedt die Lagerorte nach und zeigt dann das vorhandene Mengen-Blatt.
|
||||
///
|
||||
/// `ObjectAddSheet` nimmt die Lagerorte als Konstante und holt sie – anders als
|
||||
/// `ItemAddSheet` – nicht selbst. Direkt nach dem Anlegen hat aber niemand eine
|
||||
/// Liste zur Hand, deshalb diese duenne Schale.
|
||||
private struct MengeNachAnlegenSheet: View {
|
||||
let product: Product
|
||||
var onFertig: () -> Void
|
||||
|
||||
@State private var locations: [StorageLocation] = []
|
||||
|
||||
var body: some View {
|
||||
ObjectAddSheet(
|
||||
product: product,
|
||||
locations: locations,
|
||||
einheit: product.unitName.isEmpty ? "Stück" : product.unitName,
|
||||
perform: { onFertig() }
|
||||
)
|
||||
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
|
||||
}
|
||||
}
|
||||
|
||||
struct ItemAddSheet: View {
|
||||
let productId: Int
|
||||
var onDone: () async -> Void
|
||||
@@ -671,6 +982,12 @@ struct ItemAddSheet: View {
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
@State private var locScanShown = false
|
||||
/// Frisch angelegtes Stueck, dessen Tag gleich beschrieben werden soll.
|
||||
@State private var nfcItem: Item?
|
||||
/// Loest den Schub zum Tag-Schritt aus (navigationDestination, iOS 16).
|
||||
@State private var nfcPush = false
|
||||
/// Ob nach dem Tag-Schritt bis zur Liste geschlossen wird („Anlegen & fertig").
|
||||
@State private var nfcFinish = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -678,7 +995,7 @@ struct ItemAddSheet: View {
|
||||
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
|
||||
}
|
||||
Button { locScanShown = true } label: {
|
||||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||
@@ -733,6 +1050,22 @@ struct ItemAddSheet: View {
|
||||
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
|
||||
.disabled(busy)
|
||||
}
|
||||
// Der Knopf steht hier und nicht weiter oben, weil ein Tag erst
|
||||
// beschrieben werden kann, wenn das Stueck existiert: Die UID
|
||||
// vergibt der Server beim Anlegen. Deshalb eine Taste, die
|
||||
// beides tut - sichtbar an der Stelle, wo man sie sucht.
|
||||
if count == 1, NFCDienst.verfuegbar {
|
||||
Button { Task { await create(finish: false, tagDanach: true) } } label: {
|
||||
Label(busy ? "Anlegen…" : "Anlegen & NFC-Tag beschreiben",
|
||||
systemImage: "wave.3.right")
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
} footer: {
|
||||
if NFCDienst.verfuegbar && count > 1 {
|
||||
Text("Ein NFC-Tag gehört zu genau einem Stück. Bei Anzahl 1 lässt "
|
||||
+ "er sich hier gleich mit beschreiben.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einzelstücke anlegen")
|
||||
@@ -766,6 +1099,28 @@ struct ItemAddSheet: View {
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
shops = (try? await APIClient.shared.shops()) ?? []
|
||||
}
|
||||
// GESCHOBEN und nicht als zweites Blatt aufgelegt. Ein Blatt aus einem
|
||||
// Blatt heraus ist hier gleich dreifach riskant: Dieses Formular traegt
|
||||
// schon ein fullScreenCover (Lagerort scannen), und es wird selbst aus
|
||||
// einer Section heraus gezeigt - wovor ProductDetailView:349 und
|
||||
// ItemViews:775 jeweils aus eigener Erfahrung warnen. Ein Schub in den
|
||||
// ohnehin vorhandenen NavigationStack umgeht alle drei Fallen.
|
||||
.navigationDestination(isPresented: $nfcPush) {
|
||||
if let stueck = nfcItem {
|
||||
NFCSchreibenView(item: stueck, onFertig: {
|
||||
// Jetzt erst auffrischen: vorher raeumte das Neuladen der
|
||||
// Elternansicht dieses Blatt samt Tag-Schritt ab.
|
||||
Task {
|
||||
await onDone()
|
||||
// Erst den Schub verlassen, dann das Blatt: Solange der
|
||||
// Tag-Schritt oben liegt, greift dismiss() auf ihn und
|
||||
// nicht auf das Blatt darunter.
|
||||
nfcPush = false
|
||||
if nfcFinish, let onFinish { onFinish() } else { dismiss() }
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
|
||||
@@ -797,7 +1152,7 @@ struct ItemAddSheet: View {
|
||||
: "Übernommen: \(teile.joined(separator: ", "))."
|
||||
}
|
||||
|
||||
private func create(finish: Bool) async {
|
||||
private func create(finish: Bool, tagDanach: Bool = false) async {
|
||||
busy = true; defer { busy = false }; error = nil
|
||||
do {
|
||||
var sid = shopId
|
||||
@@ -819,12 +1174,72 @@ struct ItemAddSheet: View {
|
||||
itemId: first.id, data: data,
|
||||
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
|
||||
}
|
||||
// Weiter zum Tag-Schritt - und zwar BEVOR onDone() laeuft.
|
||||
//
|
||||
// onDone() laedt die Elternansicht neu, und dabei wird dieses Blatt
|
||||
// abgeraeumt: Jeder Zustand, den man danach setzt, laeuft ins Leere.
|
||||
// Genau daran ist der Schritt vorher unsichtbar gescheitert. Das
|
||||
// Neuladen holt deshalb „Fertig" im Tag-Schritt nach.
|
||||
if tagDanach, created.count == 1, NFCDienst.verfuegbar {
|
||||
nfcFinish = finish
|
||||
nfcItem = created[0]
|
||||
nfcPush = true
|
||||
return
|
||||
}
|
||||
await onDone()
|
||||
if finish, let onFinish { onFinish() } else { dismiss() }
|
||||
} catch { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verwendungs-Verlauf
|
||||
|
||||
/// Alle Verwendungszeitraeume eines Stuecks, neueste zuerst. Abgeschlossene
|
||||
/// bleiben erhalten - so ist nachvollziehbar, seit wann etwas in der Waesche
|
||||
/// liegt und wie oft es getragen wurde.
|
||||
struct VerwendungHistorieView: View {
|
||||
let uid: String
|
||||
let itemId: Int
|
||||
|
||||
@State private var zeilen: [Verwendung] = []
|
||||
@State private var geladen = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if !geladen {
|
||||
Text("Wird geladen…").foregroundStyle(.secondary)
|
||||
} else if zeilen.isEmpty {
|
||||
Text("War noch nie in Verwendung.").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(zeilen) { zeile in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Label(zeile.status.label, systemImage: zeile.status.symbol)
|
||||
.font(.body.bold())
|
||||
Spacer()
|
||||
if zeile.laeuft {
|
||||
Text("läuft").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text(anzeigeZeitpunkt(zeile.startedAt)
|
||||
+ (zeile.endedAt.map { " – " + anzeigeZeitpunkt($0) } ?? ""))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let von = zeile.username {
|
||||
Text("von \(von)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verwendung \(uid)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
zeilen = (try? await APIClient.shared.itemVerwendungen(id: itemId)) ?? []
|
||||
geladen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verleih-Verlauf
|
||||
|
||||
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
|
||||
|
||||
+498
-9
@@ -51,14 +51,39 @@ struct ShoppingListView: View {
|
||||
@State private var items: [ShoppingItem] = []
|
||||
@State private var groups: [GroupShoppingItem] = []
|
||||
@State private var byLocation: [LocationNeeds] = []
|
||||
/// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen.
|
||||
@State private var entries: [ShoppingEntry] = []
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
|
||||
/// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum
|
||||
/// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die
|
||||
/// Weboberflaeche.
|
||||
private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
if !offeneEintraege.isEmpty {
|
||||
Section("Selbst draufgesetzt") {
|
||||
ForEach(offeneEintraege) { eintrag in
|
||||
Button {
|
||||
Task { await hake(eintrag) }
|
||||
} label: {
|
||||
ListRow(
|
||||
systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox",
|
||||
title: eintrag.name,
|
||||
subtitle: eintrag.untertitel
|
||||
) {
|
||||
if eintrag.istGruppe { GroupBadge() }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !items.isEmpty {
|
||||
Section("Artikel unter Mindestbestand") {
|
||||
ForEach(items) { item in
|
||||
@@ -103,7 +128,8 @@ struct ShoppingListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
|
||||
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
|
||||
&& offeneEintraege.isEmpty && !busy {
|
||||
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -114,13 +140,32 @@ struct ShoppingListView: View {
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
/// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief,
|
||||
/// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
|
||||
private func hake(_ eintrag: ShoppingEntry) async {
|
||||
let vorher = entries
|
||||
entries.removeAll { $0.id == eintrag.id }
|
||||
do {
|
||||
try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true)
|
||||
} catch {
|
||||
entries = vorher
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
items = try await APIClient.shared.shoppingList()
|
||||
groups = try await APIClient.shared.shoppingGroups()
|
||||
byLocation = try await APIClient.shared.shoppingByLocation()
|
||||
// EIN Aufruf statt drei - und der einzige, der die selbst
|
||||
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
|
||||
// nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die
|
||||
// eigenen Eintraege ueberhaupt nicht.
|
||||
let alles = try await APIClient.shared.shoppingAll()
|
||||
items = alles.products
|
||||
groups = alles.groups
|
||||
byLocation = alles.byLocation
|
||||
entries = alles.entries
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
@@ -146,7 +191,7 @@ struct ExpiringView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.productName)
|
||||
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))")
|
||||
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision)) · \(articleAmount(item))")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
@@ -362,6 +407,10 @@ struct ProductListView: View {
|
||||
@State private var verwaltungFilter: String?
|
||||
@State private var busy = false
|
||||
@State private var showNew = false
|
||||
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
|
||||
@State private var angelegt: Product?
|
||||
/// Der gerade angelegte Artikel, bis der Folgeschritt dafuer durch ist.
|
||||
@State private var nachAnlegenFuer: Product?
|
||||
|
||||
enum SortMode: String, CaseIterable, Identifiable {
|
||||
case nameAsc = "Name A–Z"
|
||||
@@ -488,12 +537,19 @@ struct ProductListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNew) {
|
||||
.sheet(isPresented: $showNew, onDismiss: {
|
||||
if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) {
|
||||
nachAnlegenFuer = angelegt
|
||||
}
|
||||
angelegt = nil
|
||||
}) {
|
||||
NavigationStack {
|
||||
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||||
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
|
||||
// Liste wird aufgefrischt.
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
|
||||
// vorbelegen. Nach dem Anlegen schließt sich das Formular, die
|
||||
// Liste wird aufgefrischt – und bei Gegenständen geht es gleich
|
||||
// weiter: zu den Einzelstücken, oder zur Menge je Lagerort.
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
|
||||
angelegt = created
|
||||
showNew = false
|
||||
Task { await load() }
|
||||
}
|
||||
@@ -504,6 +560,7 @@ struct ProductListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.nachAnlegen($nachAnlegenFuer)
|
||||
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
||||
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
||||
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
|
||||
@@ -578,6 +635,11 @@ struct ProductRow: View {
|
||||
if let size = product.packageSize, size > 0 {
|
||||
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
||||
}
|
||||
// Marke voran: Die Suche findet Artikel ueber sie, ohne sie in der
|
||||
// Zeile bliebe ein Treffer auf "Jack and Jones" unerklaerlich.
|
||||
if let marke = product.brand, !marke.isEmpty {
|
||||
text = "\(marke) · \(text)"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -595,3 +657,430 @@ struct ProductRow: View {
|
||||
product.stockLevel == .below ? .red : .orange
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Chargen
|
||||
|
||||
/// Alle Chargen über alle Artikel – das Gegenstück zur Chargenliste im Web.
|
||||
///
|
||||
/// Hier passiert das Anbrechen und der Teilverbrauch. Beides gab es in der App
|
||||
/// bisher nirgends: Chargen waren nur im Artikel selbst zu sehen, und dort nur
|
||||
/// zum Korrigieren.
|
||||
struct ChargenListView: View {
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
|
||||
@State private var zeilen: [LotRow] = []
|
||||
@State private var locations: [StorageLocation] = []
|
||||
@State private var suche = ""
|
||||
@State private var nurAngebrochen = false
|
||||
@State private var verbrauchen: LotRow?
|
||||
@State private var anbrechen: LotRow?
|
||||
@State private var ansehen: LotRow?
|
||||
@State private var busy = true
|
||||
@State private var error: String?
|
||||
@State private var hinweis: String?
|
||||
|
||||
private var gefiltert: [LotRow] {
|
||||
zeilen.filter { zeile in
|
||||
if nurAngebrochen && !zeile.istAngebrochen { return false }
|
||||
guard !suche.isEmpty else { return true }
|
||||
let text = "\(zeile.productName) \(zeile.productBrand ?? "")".lowercased()
|
||||
return text.contains(suche.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
if let hinweis {
|
||||
Section { Text(hinweis).foregroundStyle(.green).font(.callout) }
|
||||
}
|
||||
Section {
|
||||
Toggle("Nur angebrochene", isOn: $nurAngebrochen)
|
||||
}
|
||||
Section("\(gefiltert.count) Chargen") {
|
||||
ForEach(gefiltert) { zeile in
|
||||
// Antippen oeffnet die Charge. Das Wischen allein genuegte
|
||||
// nicht: Man sieht es nicht, und man kam gar nicht an die
|
||||
// Angaben heran.
|
||||
Button {
|
||||
ansehen = zeile
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(zeile.productName)
|
||||
if zeile.istAngebrochen {
|
||||
Text("angebrochen")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text(untertitel(zeile))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
// Nach links wischen: die beiden Buchungen. Loeschen gibt es
|
||||
// hier bewusst nicht - eine Charge verschwindet durchs
|
||||
// Verbrauchen, und das Korrigieren sitzt beim Artikel.
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
verbrauchen = zeile
|
||||
} label: { Label("Verbrauchen", systemImage: "minus.circle") }
|
||||
.tint(.orange)
|
||||
if !zeile.istAngebrochen {
|
||||
Button {
|
||||
anbrechen = zeile
|
||||
} label: { Label("Anbrechen", systemImage: "arrow.up.bin") }
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if gefiltert.isEmpty && !busy {
|
||||
Text(nurAngebrochen ? "Keine angebrochene Packung."
|
||||
: "Keine Chargen im Bestand.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $suche, prompt: "Artikel suchen")
|
||||
.navigationTitle("Chargen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable { await load() }
|
||||
.task { await load() }
|
||||
.sheet(item: $ansehen) { zeile in
|
||||
NavigationStack {
|
||||
ChargeDetailSheet(zeile: zeile, locations: locations) { meldung in
|
||||
hinweis = meldung
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $verbrauchen) { zeile in
|
||||
NavigationStack {
|
||||
VerbrauchSheet(zeile: zeile) { meldung in
|
||||
hinweis = meldung
|
||||
Task { await load() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Eine Packung anbrechen?",
|
||||
isPresented: Binding(get: { anbrechen != nil },
|
||||
set: { if !$0 { anbrechen = nil } }),
|
||||
titleVisibility: .visible) {
|
||||
Button("Anbrechen") {
|
||||
if let zeile = anbrechen { Task { await brichAn(zeile) } }
|
||||
anbrechen = nil
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { anbrechen = nil }
|
||||
} message: {
|
||||
Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.")
|
||||
}
|
||||
}
|
||||
|
||||
private func untertitel(_ zeile: LotRow) -> String {
|
||||
var teile = [zeile.mengeText]
|
||||
if zeile.istAngebrochen, let start = zeile.openedQuantity {
|
||||
teile.append("von \(formatAmount(start)) \(zeile.baseKurz)")
|
||||
}
|
||||
if let bis = zeile.consumeBy {
|
||||
teile.append("verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))")
|
||||
} else if zeile.bestBefore != nil {
|
||||
teile.append("MHD \(display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision))")
|
||||
}
|
||||
if let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) {
|
||||
teile.append(ort.path(in: locations))
|
||||
}
|
||||
return teile.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func brichAn(_ zeile: LotRow) async {
|
||||
do {
|
||||
let offen = try await APIClient.shared.openLot(id: zeile.id)
|
||||
if let bis = offen.consumeBy {
|
||||
hinweis = "Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))."
|
||||
} else {
|
||||
hinweis = "Angebrochen."
|
||||
}
|
||||
error = nil
|
||||
await load()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
zeilen = try await APIClient.shared.allLotRows()
|
||||
locations = (try? await APIClient.shared.locations()) ?? []
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Teilmenge aus einer Charge verbrauchen – „100 ml genommen“, „2 EL Mehl“.
|
||||
///
|
||||
/// Eingetippt wird die VERBRAUCHTE Menge, nicht der Rest: Das ist die Zahl, die
|
||||
/// man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken
|
||||
/// dafür ist abwählbar, falls man nur eine Buchung nachträgt.
|
||||
struct VerbrauchSheet: View {
|
||||
let zeile: LotRow
|
||||
var onDone: (String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var menge = ""
|
||||
@State private var einheit = ""
|
||||
@State private var notiz = ""
|
||||
@State private var alsAngebrochen = true
|
||||
@State private var masse: [MeasureAmount] = []
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
/// Alles, was als Einheit gewählt werden kann: Basiseinheit, Gebinde und
|
||||
/// die Hausmaße der Kategorie.
|
||||
private var einheiten: [(token: String, label: String)] {
|
||||
var liste: [(token: String, label: String)] = [(zeile.baseToken, zeile.baseKurz)]
|
||||
if let p = zeile.packageSize, p > 0 {
|
||||
let name = zeile.packageLabel ?? "Packung"
|
||||
liste.append(("package", "\(name) (\(formatAmount(p)) \(zeile.baseKurz))"))
|
||||
}
|
||||
for mass in masse {
|
||||
let kurz = mass.short.map { " (\($0))" } ?? ""
|
||||
liste.append((mass.token,
|
||||
"\(mass.name)\(kurz) ≈ \(formatAmount(mass.amount)) \(kurzFuer(mass.base))"))
|
||||
}
|
||||
return liste
|
||||
}
|
||||
|
||||
private func kurzFuer(_ base: String) -> String {
|
||||
switch base {
|
||||
case "gram": return "g"
|
||||
case "milliliter": return "ml"
|
||||
default: return "Stück"
|
||||
}
|
||||
}
|
||||
|
||||
private var zahl: Double? {
|
||||
Double(menge.replacingOccurrences(of: ",", with: "."))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
Section {
|
||||
Text(zeile.productName).font(.headline)
|
||||
Text("aktuell \(zeile.mengeText)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Verbrauchte Menge") {
|
||||
QuantityField(label: "Menge", text: $menge)
|
||||
Picker("Einheit", selection: $einheit) {
|
||||
ForEach(einheiten, id: \.token) { eintrag in
|
||||
Text(eintrag.label).tag(eintrag.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
if masse.isEmpty {
|
||||
Section {
|
||||
// Das schließende Anführungszeichen MUSS das typografische
|
||||
// sein: Ein gerades " würde die Zeichenkette hier beenden.
|
||||
Text("Für die Kategorie dieses Artikels ist kein Hausmaß hinterlegt – „2 EL“ lässt sich deshalb noch nicht buchen. Mengen dafür stehen in der Weboberfläche unter Kategorien.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !zeile.istAngebrochen {
|
||||
Section {
|
||||
Toggle("Packung als angebrochen führen", isOn: $alsAngebrochen)
|
||||
} footer: {
|
||||
Text("Eine Packung wird abgespalten und bekommt ein Verbrauchsdatum; der Rest behält sein MHD. Ohne Haken wird nur die Menge abgebucht.")
|
||||
}
|
||||
}
|
||||
Section("Notiz (freiwillig)") {
|
||||
TextField("z.B. für Pfannkuchen", text: $notiz)
|
||||
}
|
||||
Section {
|
||||
Button(busy ? "Wird gebucht…" : "Verbrauchen") { Task { await buche() } }
|
||||
.disabled(busy || zahl == nil || (zahl ?? 0) <= 0 || einheit.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Verbrauchen")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
|
||||
}
|
||||
.task {
|
||||
einheit = zeile.baseToken
|
||||
masse = (try? await APIClient.shared.productMeasures(productId: zeile.productId)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private func buche() async {
|
||||
guard let wert = zahl, wert > 0 else { return }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let antwort = try await APIClient.shared.consumeLot(
|
||||
id: zeile.id, quantity: wert, unit: einheit,
|
||||
note: notiz.isEmpty ? nil : notiz,
|
||||
autoOpen: zeile.istAngebrochen ? false : alsAngebrochen)
|
||||
dismiss()
|
||||
if antwort.lot == nil {
|
||||
onDone("Verbraucht – die Charge ist damit leer.")
|
||||
} else {
|
||||
onDone(antwort.opened ? "Verbraucht und angebrochen." : "Verbraucht.")
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Charge ansehen – und von hier aus anbrechen oder verbrauchen.
|
||||
///
|
||||
/// Das Wischen in der Liste allein genügt nicht: Man sieht es nicht, und man
|
||||
/// sieht dort auch nicht, was an der Charge dranhängt. Hier stehen alle Angaben
|
||||
/// und beide Handlungen als sichtbare Knöpfe.
|
||||
struct ChargeDetailSheet: View {
|
||||
let zeile: LotRow
|
||||
let locations: [StorageLocation]
|
||||
/// Meldung nach einer Buchung – die Liste zeigt sie an und lädt neu.
|
||||
var onDone: (String) -> Void
|
||||
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var anbrechenGefragt = false
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
|
||||
private var ortText: String {
|
||||
guard let id = zeile.locationId,
|
||||
let ort = locations.first(where: { $0.id == id }) else { return "ohne Lagerort" }
|
||||
return ort.path(in: locations)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let error {
|
||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(zeile.productName).font(.headline)
|
||||
if let marke = zeile.productBrand, !marke.isEmpty {
|
||||
Text(marke).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Bestand") {
|
||||
LabeledContent("Menge", value: zeile.mengeText)
|
||||
if zeile.istAngebrochen, let start = zeile.openedQuantity {
|
||||
LabeledContent("Beim Öffnen", value: "\(formatAmount(start)) \(zeile.baseKurz)")
|
||||
}
|
||||
LabeledContent("Zustand",
|
||||
value: zeile.istAngebrochen ? "angebrochen" : "ungeöffnet")
|
||||
LabeledContent("Lagerort", value: ortText)
|
||||
if let seit = zeile.createdAt {
|
||||
LabeledContent("Eingelagert",
|
||||
value: display.formatBestBefore(String(seit.prefix(10)),
|
||||
precision: "day"))
|
||||
}
|
||||
}
|
||||
|
||||
Section("Fristen") {
|
||||
LabeledContent("MHD", value: zeile.bestBefore == nil
|
||||
? "ohne MHD"
|
||||
: display.formatBestBefore(zeile.bestBefore,
|
||||
precision: zeile.bestBeforePrecision))
|
||||
if let geoeffnet = zeile.openedOn {
|
||||
LabeledContent("Angebrochen am",
|
||||
value: display.formatBestBefore(geoeffnet, precision: "day"))
|
||||
}
|
||||
if let bis = zeile.consumeBy {
|
||||
LabeledContent("Verbrauchen bis",
|
||||
value: display.formatBestBefore(bis, precision: "day"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
// Bewusst ein Weiterblaettern und kein zweites Fenster: Ein
|
||||
// Sheet im Sheet aufzuziehen und beide gleich wieder zu
|
||||
// schliessen ist in SwiftUI eine zuverlaessige Quelle fuer
|
||||
// haengende Ansichten.
|
||||
NavigationLink {
|
||||
VerbrauchSheet(zeile: zeile) { meldung in
|
||||
dismiss()
|
||||
onDone(meldung)
|
||||
}
|
||||
} label: {
|
||||
Label("Verbrauchen", systemImage: "minus.circle")
|
||||
}
|
||||
if !zeile.istAngebrochen {
|
||||
Button {
|
||||
anbrechenGefragt = true
|
||||
} label: {
|
||||
Label("Anbrechen", systemImage: "arrow.up.bin")
|
||||
}
|
||||
.disabled(busy)
|
||||
}
|
||||
} footer: {
|
||||
Text(zeile.istAngebrochen
|
||||
? "Diese Packung ist offen. Was du verbrauchst, geht von ihr ab."
|
||||
: "Anbrechen spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum; "
|
||||
+ "der Rest bleibt ungeöffnet mit seinem MHD.")
|
||||
}
|
||||
|
||||
Section {
|
||||
// Eine einzelne Charge hat keinen eigenen Verlauf im Backend;
|
||||
// die Bewegungen haengen am Artikel. Das ist hier die naechste
|
||||
// Frage nach "was ist das" - "was ist damit passiert".
|
||||
NavigationLink {
|
||||
HistoryView(productId: zeile.productId)
|
||||
} label: {
|
||||
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Charge")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||
}
|
||||
.confirmationDialog("Eine Packung anbrechen?", isPresented: $anbrechenGefragt,
|
||||
titleVisibility: .visible) {
|
||||
Button("Anbrechen") { Task { await brichAn() } }
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.")
|
||||
}
|
||||
}
|
||||
|
||||
private func brichAn() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
let offen = try await APIClient.shared.openLot(id: zeile.id)
|
||||
dismiss()
|
||||
if let bis = offen.consumeBy {
|
||||
onDone("Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day")).")
|
||||
} else {
|
||||
onDone("Angebrochen.")
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ struct LookupScanView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(.horizontal)
|
||||
|
||||
Text("Barcode, Einzelstück- oder Lagerort-QR scannen – nur ansehen")
|
||||
Text("Barcode, Einzelstück- oder Lagerort-QR scannen – oder unten "
|
||||
+ "einen NFC-Tag lesen. Nur ansehen.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -41,6 +42,8 @@ struct LookupScanView: View {
|
||||
} onFehler: { meldung in
|
||||
error = meldung
|
||||
}
|
||||
// Unauffaellig unter der Kamerakarte uebersieht man ihn sonst.
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if let error {
|
||||
Text(error).font(.callout).foregroundStyle(.red)
|
||||
|
||||
@@ -197,33 +197,11 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
@State private var addShown = false
|
||||
@State private var pendingDeletion: Item?
|
||||
|
||||
private struct Node: Identifiable {
|
||||
let item: Item
|
||||
let depth: Int
|
||||
var id: Item.ID { item.id }
|
||||
}
|
||||
|
||||
private var shown: [Item] { items.filter(include) }
|
||||
|
||||
/// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten
|
||||
/// als oberste Ebene, damit nichts verschwindet.
|
||||
private var nodes: [Node] {
|
||||
let list = shown
|
||||
let ids = Set(list.map(\.id))
|
||||
var result: [Node] = []
|
||||
func walk(_ n: Item, _ depth: Int) {
|
||||
result.append(Node(item: n, depth: depth))
|
||||
for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) }
|
||||
}
|
||||
for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
|
||||
walk(root, 0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
|
||||
|
||||
private var visible: [Node] {
|
||||
private var visible: [Baumzeile<Item>] {
|
||||
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
||||
func hidden(_ id: Item.ID) -> Bool {
|
||||
var p = parent[id] ?? nil
|
||||
@@ -233,7 +211,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
}
|
||||
return false
|
||||
}
|
||||
return nodes.filter { !hidden($0.item.id) }
|
||||
return baum(shown).filter { !hidden($0.item.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -286,7 +264,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ node: Node) -> some View {
|
||||
private func row(_ node: Baumzeile<Item>) -> some View {
|
||||
let kinder = childCount(node.item.id)
|
||||
let zu = collapsed.contains(node.item.id)
|
||||
HStack(spacing: 8) {
|
||||
@@ -321,7 +299,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.leading, CGFloat(node.depth) * 16)
|
||||
.padding(.leading, CGFloat(node.tiefe) * 16)
|
||||
}
|
||||
|
||||
private func toggle(_ id: Item.ID) {
|
||||
@@ -468,8 +446,8 @@ struct LocationEditor: View {
|
||||
Section {
|
||||
Picker("Übergeordnet", selection: $parentId) {
|
||||
Text("– oberste Ebene –").tag(String?.none)
|
||||
ForEach(parentOptions) { loc in
|
||||
Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
|
||||
ForEach(baum(parentOptions)) { eintrag in
|
||||
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -523,19 +501,6 @@ struct LocationEditor: View {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// "Keller → Regal" – der Pfad macht gleiche Namen im flachen Picker
|
||||
/// unterscheidbar.
|
||||
private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String {
|
||||
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
|
||||
var teile = [loc.name]
|
||||
var pid = loc.parentId
|
||||
while let cur = pid, let parent = byId[cur] {
|
||||
teile.insert(parent.name, at: 0)
|
||||
pid = parent.parentId
|
||||
}
|
||||
return teile.joined(separator: " → ")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnitsView: View {
|
||||
@@ -792,6 +757,7 @@ struct CategoryEditor: View {
|
||||
|
||||
@State private var name: String
|
||||
@State private var tracking: String
|
||||
@State private var usageTracking: Bool
|
||||
@State private var parentId: Int? // nur beim Anlegen
|
||||
@State private var error: String?
|
||||
@State private var busy = false
|
||||
@@ -802,6 +768,7 @@ struct CategoryEditor: View {
|
||||
self.done = done
|
||||
_name = State(initialValue: item?.name ?? "")
|
||||
_tracking = State(initialValue: item?.tracking ?? "object")
|
||||
_usageTracking = State(initialValue: item?.fuehrtVerwendung ?? false)
|
||||
_parentId = State(initialValue: nil)
|
||||
}
|
||||
|
||||
@@ -820,8 +787,8 @@ struct CategoryEditor: View {
|
||||
Section {
|
||||
Picker("Übergeordnet", selection: $parentId) {
|
||||
Text("– oberste Ebene –").tag(Int?.none)
|
||||
ForEach(all) { c in
|
||||
Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id))
|
||||
ForEach(baum(all)) { eintrag in
|
||||
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
@@ -847,6 +814,14 @@ struct CategoryEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !erbtVonEltern {
|
||||
Section {
|
||||
Toggle("Verwendungsstatus führen", isOn: $usageTracking)
|
||||
} footer: {
|
||||
Text("Einzelstücke dieser Kategorie führen, woran sie gerade sind – in Verwendung, in Reinigung, in Reparatur. Gedacht für Kleidung und Werkzeug, nicht für Vorräte. Beim Bearbeiten gilt die Einstellung auch für alle Unterkategorien.")
|
||||
}
|
||||
}
|
||||
|
||||
if let item {
|
||||
Section {
|
||||
NavigationLink {
|
||||
@@ -882,13 +857,17 @@ struct CategoryEditor: View {
|
||||
do {
|
||||
if let item {
|
||||
_ = try await APIClient.shared.updateCategory(
|
||||
id: item.id, CategoryUpdateRequest(name: n, tracking: tracking))
|
||||
id: item.id,
|
||||
CategoryUpdateRequest(name: n, tracking: tracking,
|
||||
usageTracking: usageTracking))
|
||||
} else {
|
||||
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
|
||||
// gewählte Art.
|
||||
_ = try await APIClient.shared.createCategory(
|
||||
NewCategoryRequest(name: n, parentId: parentId,
|
||||
tracking: parentId == nil ? tracking : nil))
|
||||
tracking: parentId == nil ? tracking : nil,
|
||||
// Mit Oberkategorie die Markierung erben.
|
||||
usageTracking: parentId == nil ? usageTracking : nil))
|
||||
}
|
||||
done()
|
||||
dismiss()
|
||||
@@ -896,17 +875,6 @@ struct CategoryEditor: View {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String {
|
||||
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
|
||||
var teile = [c.name]
|
||||
var pid = c.parentId
|
||||
while let cur = pid, let parent = byId[cur] {
|
||||
teile.insert(parent.name, at: 0)
|
||||
pid = parent.parentId
|
||||
}
|
||||
return teile.joined(separator: " → ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Eigene Felder je Kategorie
|
||||
|
||||
+363
-3
@@ -344,6 +344,14 @@ struct Lot: Codable, Identifiable, Hashable {
|
||||
let bestBefore: String?
|
||||
let bestBeforePrecision: String?
|
||||
let locationId: String?
|
||||
/// Oeffnungstag. nil = die Packung ist noch zu.
|
||||
let openedOn: String?
|
||||
/// "Zu verbrauchen bis" - beim Anbrechen aus der Frist gerechnet.
|
||||
let consumeBy: String?
|
||||
/// Menge beim Oeffnen, damit "noch 400 von 1000 ml" anzeigbar ist.
|
||||
let openedQuantity: Double?
|
||||
|
||||
var istAngebrochen: Bool { openedOn != nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, quantity
|
||||
@@ -351,9 +359,121 @@ struct Lot: Codable, Identifiable, Hashable {
|
||||
case bestBefore = "best_before"
|
||||
case bestBeforePrecision = "best_before_precision"
|
||||
case locationId = "location_id"
|
||||
case openedOn = "opened_on"
|
||||
case consumeBy = "consume_by"
|
||||
case openedQuantity = "opened_quantity"
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Charge mit den Artikelangaben dran - fuer die uebergreifende
|
||||
/// Chargenliste, in der Chargen verschiedener Artikel nebeneinander stehen.
|
||||
struct LotRow: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let productId: Int
|
||||
let productName: String
|
||||
let productBrand: String?
|
||||
let quantity: Double
|
||||
let baseUnit: String
|
||||
let packageSize: Double?
|
||||
let packageLabel: String?
|
||||
let unitName: String
|
||||
let unitFactor: Double
|
||||
let bestBefore: String?
|
||||
let bestBeforePrecision: String?
|
||||
let locationId: String?
|
||||
let openedOn: String?
|
||||
let consumeBy: String?
|
||||
let openedQuantity: Double?
|
||||
/// Wann die Charge eingelagert wurde - gehoert zu den Angaben, die man an
|
||||
/// einer Charge sucht ("seit wann liegt das hier?").
|
||||
let createdAt: String?
|
||||
|
||||
var istAngebrochen: Bool { openedOn != nil }
|
||||
|
||||
/// Basiseinheiten je Artikeleinheit: das Gebinde, sonst die Anzeigeeinheit.
|
||||
var artikelFaktor: Double {
|
||||
if let p = packageSize, p > 0 { return p }
|
||||
return unitFactor > 0 ? unitFactor : 1
|
||||
}
|
||||
|
||||
/// Wie die Menge dasteht. Bei einer offenen Packung zaehlt die Restmenge in
|
||||
/// Basiseinheiten - "0,98 Packungen" sagt niemandem etwas, "980 g" schon.
|
||||
var mengeText: String {
|
||||
if istAngebrochen { return "\(formatAmount(quantity)) \(baseKurz)" }
|
||||
let menge = quantity / artikelFaktor
|
||||
let label = (packageSize ?? 0) > 0 ? (packageLabel ?? "Packung")
|
||||
: (unitName.isEmpty ? baseKurz : unitName)
|
||||
return "\(formatAmount(menge)) \(label)"
|
||||
}
|
||||
|
||||
var baseKurz: String {
|
||||
switch baseUnit {
|
||||
case "gram": return "g"
|
||||
case "milliliter": return "ml"
|
||||
default: return "Stück"
|
||||
}
|
||||
}
|
||||
|
||||
/// Token fuer die Basiseinheit, wie es das Backend beim Buchen erwartet.
|
||||
var baseToken: String {
|
||||
switch baseUnit {
|
||||
case "gram": return "g"
|
||||
case "milliliter": return "ml"
|
||||
default: return "piece"
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, quantity
|
||||
case productId = "product_id"
|
||||
case productName = "product_name"
|
||||
case productBrand = "product_brand"
|
||||
case baseUnit = "base_unit"
|
||||
case packageSize = "package_size"
|
||||
case packageLabel = "package_label"
|
||||
case unitName = "unit_name"
|
||||
case unitFactor = "unit_factor"
|
||||
case bestBefore = "best_before"
|
||||
case bestBeforePrecision = "best_before_precision"
|
||||
case locationId = "location_id"
|
||||
case openedOn = "opened_on"
|
||||
case consumeBy = "consume_by"
|
||||
case openedQuantity = "opened_quantity"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Hausmass, das fuer einen Artikel wirklich gilt - "1 EL = 10 g".
|
||||
///
|
||||
/// Die Mengen haengen an der Kategorie (oder am Artikel), nicht am Mass: Ein
|
||||
/// Essloeffel Mehl wiegt anders als einer Zucker.
|
||||
struct MeasureAmount: Codable, Identifiable, Hashable {
|
||||
let measureId: Int
|
||||
let name: String
|
||||
let short: String?
|
||||
let amount: Double
|
||||
let base: String
|
||||
let herkunft: String
|
||||
|
||||
var id: Int { measureId }
|
||||
|
||||
/// Was beim Buchen als Einheit mitgeschickt wird.
|
||||
var token: String { name }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, short, amount, base, herkunft
|
||||
case measureId = "measure_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// Antwort auf einen Teilverbrauch.
|
||||
struct ConsumeResult: Codable {
|
||||
/// Wurde die Charge dabei angebrochen?
|
||||
let opened: Bool
|
||||
/// Die offene Charge danach - nil, wenn sie dabei leer geworden ist.
|
||||
let lot: Lot?
|
||||
}
|
||||
|
||||
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
@@ -482,6 +602,59 @@ struct NewProductRequest: Codable {
|
||||
|
||||
// MARK: - Listen (Einkaufsliste, bald ablaufend)
|
||||
|
||||
/// Ein Posten, den jemand selbst auf die Einkaufsliste gesetzt hat - von Hand
|
||||
/// oder uebernommen aus einem Rezept.
|
||||
///
|
||||
/// Die gerechneten Posten (Artikel und Gruppen unter ihrem Mindestbestand)
|
||||
/// entstehen bei jedem Aufruf neu; diese hier liegen in der Datenbank und
|
||||
/// bleiben, bis sie abgehakt sind. Beides zusammen ergibt erst die Liste.
|
||||
struct ShoppingEntry: Codable, Identifiable {
|
||||
let id: Int
|
||||
/// "product" | "group" | "text"
|
||||
let art: String
|
||||
let name: String
|
||||
/// "2 Packungen" bzw. "150 g" - fertig formatiert vom Server, damit App und
|
||||
/// Weboberflaeche dieselbe Sprache sprechen.
|
||||
let mengeText: String
|
||||
let source: String?
|
||||
let recipeName: String?
|
||||
let done: Bool
|
||||
|
||||
var istNotiz: Bool { art == "text" }
|
||||
var istGruppe: Bool { art == "group" }
|
||||
|
||||
/// Zeile unter dem Namen: woher der Posten kommt und wie viel fehlt.
|
||||
var untertitel: String {
|
||||
[recipeName.map { "für \($0)" }, mengeText.isEmpty ? nil : mengeText]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, art, name, source, done
|
||||
case mengeText = "menge_text"
|
||||
case recipeName = "recipe_name"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die komplette Einkaufsliste in einer Antwort: gerechnete Bedarfe UND
|
||||
/// gespeicherte Eintraege. Die drei Einzelrouten liefern nur die gerechnete
|
||||
/// Seite - wer nur sie abfragt, sieht die selbst draufgesetzten Posten nicht.
|
||||
struct ShoppingListAll: Codable {
|
||||
let products: [ShoppingItem]
|
||||
let groups: [GroupShoppingItem]
|
||||
let byLocation: [LocationNeeds]
|
||||
let entries: [ShoppingEntry]
|
||||
/// Kennungen der abgehakten GERECHNETEN Posten. Die App zeigt sie noch
|
||||
/// nicht an, liest sie aber mit, damit das Feld nicht ueberrascht.
|
||||
let checked: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case products, groups, entries, checked
|
||||
case byLocation = "by_location"
|
||||
}
|
||||
}
|
||||
|
||||
struct ShoppingItem: Codable, Identifiable {
|
||||
let productId: Int
|
||||
let name: String
|
||||
@@ -617,8 +790,19 @@ struct ExpiringItem: Codable, Identifiable {
|
||||
let productName: String
|
||||
let quantity: Double
|
||||
let baseUnit: String
|
||||
let bestBefore: String
|
||||
/// Darf fehlen! Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein
|
||||
/// Verbrauchsdatum - und genau die gehoert in diese Liste. Stand hier frueher
|
||||
/// ein nicht-optionaler String, scheiterte das Entschluesseln der GANZEN
|
||||
/// Liste an einer einzigen solchen Charge, samt Erinnerungen.
|
||||
let bestBefore: String?
|
||||
let bestBeforePrecision: String?
|
||||
let openedOn: String?
|
||||
let consumeBy: String?
|
||||
/// Das Datum, nach dem gewarnt wird: das fruehere von MHD und
|
||||
/// Verbrauchsdatum. Immer gesetzt - der Server rechnet es aus.
|
||||
let warnDate: String
|
||||
/// "best_before" oder "consume_by" - sagt, welches der beiden Daten gewann.
|
||||
let warnSource: String?
|
||||
let daysLeft: Int
|
||||
let packageSize: Double?
|
||||
let packageLabel: String?
|
||||
@@ -627,6 +811,17 @@ struct ExpiringItem: Codable, Identifiable {
|
||||
|
||||
var id: Int { lotId }
|
||||
|
||||
/// Ist die Packung angebrochen und draengt deshalb?
|
||||
var istAngebrochen: Bool { warnSource == "consume_by" }
|
||||
|
||||
/// Beschriftung vor dem Datum. "MHD 05.10." und "verbrauchen bis 05.10."
|
||||
/// sind zwei verschiedene Aussagen, und die Liste zeigt beide nebeneinander.
|
||||
var fristLabel: String { istAngebrochen ? "verbrauchen bis" : "MHD" }
|
||||
|
||||
/// Genauigkeit des angezeigten Datums. Ein Verbrauchsdatum ist immer
|
||||
/// taggenau, auch wenn auf der Packung nur ein Monat stand.
|
||||
var fristPrecision: String? { istAngebrochen ? "day" : bestBeforePrecision }
|
||||
|
||||
/// Restlaufzeit als kurzer Text. „0 Tage" ergibt keinen Sinn - am Ablauftag
|
||||
/// selbst steht „heute". Karte und Liste nutzen dieselbe Formulierung.
|
||||
var restlaufzeitText: String {
|
||||
@@ -644,6 +839,10 @@ struct ExpiringItem: Codable, Identifiable {
|
||||
case baseUnit = "base_unit"
|
||||
case bestBefore = "best_before"
|
||||
case bestBeforePrecision = "best_before_precision"
|
||||
case openedOn = "opened_on"
|
||||
case consumeBy = "consume_by"
|
||||
case warnDate = "warn_date"
|
||||
case warnSource = "warn_source"
|
||||
case daysLeft = "days_left"
|
||||
case packageSize = "package_size"
|
||||
case packageLabel = "package_label"
|
||||
@@ -742,6 +941,22 @@ struct ProductUpdateRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Teilverbrauch aus einer Charge.
|
||||
///
|
||||
/// `unit` nimmt dasselbe wie das Auslagern: die Basiseinheit, eine verwaltete
|
||||
/// Einheit, "package" - und die Hausmasse der Kategorie ("EL", "Tasse").
|
||||
struct ConsumeRequest: Encodable {
|
||||
var quantity: Double
|
||||
var unit: String
|
||||
var note: String?
|
||||
var autoOpen: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case quantity, unit, note
|
||||
case autoOpen = "auto_open"
|
||||
}
|
||||
}
|
||||
|
||||
struct LotUpdateRequest: Encodable {
|
||||
var quantity: Double?
|
||||
var bestBefore: String?
|
||||
@@ -864,13 +1079,18 @@ struct CategoryItem: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
let tracking: String?
|
||||
/// Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
|
||||
/// Optional, weil ein aelterer Server das Feld nicht schickt.
|
||||
var usageTracking: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, tracking
|
||||
case parentId = "parent_id"
|
||||
case usageTracking = "usage_tracking"
|
||||
}
|
||||
|
||||
var isObject: Bool { tracking == "object" }
|
||||
var fuehrtVerwendung: Bool { usageTracking ?? false }
|
||||
}
|
||||
|
||||
// MARK: - Hierarchische Stammdaten
|
||||
@@ -1051,6 +1271,73 @@ struct ItemDocument: Codable, Identifiable, Hashable {
|
||||
///
|
||||
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
|
||||
/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig.
|
||||
/// Woran ein Einzelstueck gerade ist.
|
||||
///
|
||||
/// "Verfuegbar" ist bewusst kein Serverwert, sondern das Fehlen eines
|
||||
/// laufenden Zeitraums - deshalb traegt der Ruhezustand hier `nil`. Absichtlich
|
||||
/// allgemein und nicht auf Kleidung zugeschnitten: Eine Bohrmaschine auf der
|
||||
/// Baustelle ist genauso "in Verwendung".
|
||||
enum VerwendungStatus: String, Codable, CaseIterable, Identifiable, Hashable {
|
||||
case inUse = "in_use"
|
||||
case cleaning = "cleaning"
|
||||
case repair = "repair"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .inUse: return "In Verwendung"
|
||||
case .cleaning: return "In Reinigung"
|
||||
case .repair: return "In Reparatur"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .inUse: return "figure.walk"
|
||||
case .cleaning: return "drop"
|
||||
case .repair: return "wrench.and.screwdriver"
|
||||
}
|
||||
}
|
||||
|
||||
/// Die vier Schaltstellungen inklusive des Ruhezustands (nil).
|
||||
static let auswahl: [VerwendungStatus?] = [nil] + allCases.map { Optional($0) }
|
||||
|
||||
static func label(_ status: VerwendungStatus?) -> String {
|
||||
status?.label ?? "Verfügbar"
|
||||
}
|
||||
|
||||
static func symbol(_ status: VerwendungStatus?) -> String {
|
||||
status?.symbol ?? "checkmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Zeitraum in einem Verwendungszustand - laufend oder abgeschlossen.
|
||||
struct Verwendung: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let itemId: Int
|
||||
let status: VerwendungStatus
|
||||
let startedAt: String
|
||||
/// nil = laeuft noch.
|
||||
let endedAt: String?
|
||||
let username: String?
|
||||
|
||||
var laeuft: Bool { endedAt == nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, status, username
|
||||
case itemId = "item_id"
|
||||
case startedAt = "started_at"
|
||||
case endedAt = "ended_at"
|
||||
}
|
||||
}
|
||||
|
||||
struct VerwendungSetzenRequest: Encodable {
|
||||
/// nil = verfuegbar. Muss AUCH dann mitgeschickt werden, sonst liest der
|
||||
/// Server den Vorgabewert und nicht "verfuegbar".
|
||||
let status: String?
|
||||
}
|
||||
|
||||
struct Loan: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let itemId: Int
|
||||
@@ -1094,18 +1381,55 @@ struct LoanCreateRequest: Encodable {
|
||||
var reason: String? = nil
|
||||
var lentOn: String? = nil
|
||||
var dueOn: String? = nil
|
||||
/// Zubehoerteile, die mitgehen. Leer = nur das Geraet selbst; der Server
|
||||
/// nimmt nie ungefragt etwas mit.
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case borrower, reason
|
||||
case lentOn = "lent_on"
|
||||
case dueOn = "due_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct LoanReturnRequest: Encodable {
|
||||
var returnedOn: String? = nil
|
||||
/// Spiegelbild zu LoanCreateRequest: Ohne das kaeme das Geraet zurueck und
|
||||
/// sein Netzteil bliebe ewig „verliehen".
|
||||
var mitZubehoer: [Int] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case returnedOn = "returned_on"
|
||||
case mitZubehoer = "mit_zubehoer"
|
||||
}
|
||||
}
|
||||
|
||||
struct ZubehoerZuordnenRequest: Encodable {
|
||||
let childItemId: Int
|
||||
enum CodingKeys: String, CodingKey { case childItemId = "child_item_id" }
|
||||
}
|
||||
|
||||
/// Ein verknuepftes Einzelstueck in Kurzform (Zubehoer bzw. das Geraet dazu).
|
||||
///
|
||||
/// Bewusst nicht `Item`: sonst zoege das Geraet seine Teile und jedes Teil
|
||||
/// wieder sein Geraet nach, endlos.
|
||||
struct ItemKurz: Codable, Identifiable, Hashable {
|
||||
let id: Int
|
||||
let uid: String
|
||||
let productId: Int
|
||||
let productName: String?
|
||||
let productBrand: String?
|
||||
let locationName: String?
|
||||
let verliehen: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, verliehen
|
||||
case productId = "product_id"
|
||||
case productName = "product_name"
|
||||
case productBrand = "product_brand"
|
||||
case locationName = "location_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct Item: Codable, Identifiable, Hashable {
|
||||
@@ -1130,11 +1454,37 @@ struct Item: Codable, Identifiable, Hashable {
|
||||
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
|
||||
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
||||
let loan: Loan?
|
||||
/// Zubehoer dieses Geraets und, umgekehrt, das Geraet, zu dem dieses Stueck
|
||||
/// gehoert.
|
||||
///
|
||||
/// Beide optional, und das ist keine Bequemlichkeit: Die App spricht mit
|
||||
/// mehreren Serverprofilen, und ein aelterer Server schickt die Felder gar
|
||||
/// nicht. Ein nicht-optionales Feld mit Vorgabewert reicht dafuer NICHT -
|
||||
/// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen.
|
||||
var zubehoer: [ItemKurz]?
|
||||
var gehoertZu: ItemKurz?
|
||||
/// Der laufende Verwendungszustand - nil heisst verfuegbar. Anders als beim
|
||||
/// Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
|
||||
///
|
||||
/// Beide Felder optional, aus demselben Grund wie `zubehoer` oben: ein
|
||||
/// aelterer Server schickt sie gar nicht.
|
||||
var verwendung: Verwendung?
|
||||
/// Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
|
||||
var verwendungMoeglich: Bool?
|
||||
|
||||
/// Zubehoer ohne Optional-Gehampel an der Aufrufstelle.
|
||||
var teile: [ItemKurz] { zubehoer ?? [] }
|
||||
|
||||
var istVerliehen: Bool { loan != nil }
|
||||
|
||||
/// Ohne Optional-Gehampel an der Aufrufstelle.
|
||||
var zeigtVerwendung: Bool { verwendungMoeglich ?? false }
|
||||
var verwendungStatus: VerwendungStatus? { verwendung?.status }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, uid, note, currency, documents, loan
|
||||
case id, uid, note, currency, documents, loan, zubehoer, verwendung
|
||||
case gehoertZu = "gehoert_zu"
|
||||
case verwendungMoeglich = "verwendung_moeglich"
|
||||
case productId = "product_id"
|
||||
case locationId = "location_id"
|
||||
case locationName = "location_name"
|
||||
@@ -1515,16 +1865,26 @@ struct NewCategoryRequest: Codable {
|
||||
let name: String
|
||||
let parentId: Int?
|
||||
var tracking: String? = nil
|
||||
/// nil = erbt vom Elternteil bzw. neue Oberkategorie = aus.
|
||||
var usageTracking: Bool? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, tracking
|
||||
case parentId = "parent_id"
|
||||
case usageTracking = "usage_tracking"
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryUpdateRequest: Codable {
|
||||
let name: String?
|
||||
let tracking: String?
|
||||
/// Wird vom Server in den ganzen Unterbaum uebernommen.
|
||||
var usageTracking: Bool? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, tracking
|
||||
case usageTracking = "usage_tracking"
|
||||
}
|
||||
}
|
||||
|
||||
struct NewShopRequest: Codable {
|
||||
|
||||
@@ -99,6 +99,48 @@ final class NFCDienst: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kurzfassung eines CoreNFC-Fehlers.
|
||||
///
|
||||
/// Apples ``localizedDescription`` ist hier unbrauchbar – sie liefert Dinge
|
||||
/// wie „Stack Error", die niemandem sagen, was zu tun ist. Fast alle Faelle
|
||||
/// sind voruebergehend: Tag zu frueh weg, Funkkontakt verloren, Stack belegt.
|
||||
///
|
||||
/// Ein unbekannter Code behaelt seine Nummer. Damit steht beim naechsten
|
||||
/// Auftreten sofort fest, welcher Fall hier noch fehlt.
|
||||
static func kurz(_ fehler: Error) -> String {
|
||||
guard let code = (fehler as? NFCReaderError)?.code else {
|
||||
return "Fehlgeschlagen."
|
||||
}
|
||||
switch code {
|
||||
case .readerTransceiveErrorTagConnectionLost:
|
||||
return "Tag zu früh entfernt."
|
||||
case .readerTransceiveErrorRetryExceeded:
|
||||
return "Keine Antwort vom Tag."
|
||||
case .readerTransceiveErrorTagResponseError:
|
||||
return "Tag antwortet fehlerhaft."
|
||||
case .readerTransceiveErrorTagNotConnected:
|
||||
return "Kein Tag verbunden."
|
||||
case .readerSessionInvalidationErrorSessionTimeout:
|
||||
return "Zeit abgelaufen."
|
||||
case .readerSessionInvalidationErrorSystemIsBusy:
|
||||
return "NFC gerade belegt."
|
||||
case .readerSessionInvalidationErrorSessionTerminatedUnexpectedly:
|
||||
return "Sitzung abgebrochen."
|
||||
case .ndefReaderSessionErrorTagUpdateFailure:
|
||||
return "Schreiben fehlgeschlagen."
|
||||
case .ndefReaderSessionErrorTagNotWritable:
|
||||
return "Tag nicht beschreibbar."
|
||||
case .readerErrorRadioDisabled:
|
||||
return "NFC ist aus."
|
||||
case .readerErrorSecurityViolation:
|
||||
return "NFC nicht erlaubt."
|
||||
case .readerErrorUnsupportedFeature:
|
||||
return "Tag-Typ nicht unterstützt."
|
||||
default:
|
||||
return "Fehlgeschlagen (Code \(code.rawValue))."
|
||||
}
|
||||
}
|
||||
|
||||
private enum Auftrag {
|
||||
case lesen
|
||||
case schreiben(nutzlast: String, sperren: Bool)
|
||||
@@ -155,8 +197,10 @@ final class NFCDienst: NSObject {
|
||||
if let meldung {
|
||||
session?.alertMessage = meldung
|
||||
session?.invalidate()
|
||||
} else if case .failure = ergebnis {
|
||||
session?.invalidate()
|
||||
} else if case let .failure(fehler) = ergebnis {
|
||||
// Mit Text: Ohne ihn schliesst sich Apples Blatt wortlos und die
|
||||
// Meldung steht erst danach in der App.
|
||||
session?.invalidate(errorMessage: fehler.localizedDescription)
|
||||
}
|
||||
session = nil
|
||||
fortsetzung.resume(with: ergebnis)
|
||||
@@ -166,7 +210,7 @@ final class NFCDienst: NSObject {
|
||||
private func lesen(_ tag: NFCNDEFTag) {
|
||||
tag.readNDEF { nachricht, fehler in
|
||||
if let fehler {
|
||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
||||
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||
return
|
||||
}
|
||||
guard let text = nachricht?.records.compactMap(Self.text(aus:)).first, !text.isEmpty else {
|
||||
@@ -187,7 +231,7 @@ final class NFCDienst: NSObject {
|
||||
|
||||
tag.queryNDEFStatus { status, kapazitaet, fehler in
|
||||
if let fehler {
|
||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
||||
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||
return
|
||||
}
|
||||
switch status {
|
||||
@@ -210,7 +254,7 @@ final class NFCDienst: NSObject {
|
||||
|
||||
tag.writeNDEF(nachricht) { fehler in
|
||||
if let fehler {
|
||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
||||
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||
return
|
||||
}
|
||||
guard sperren else {
|
||||
@@ -224,8 +268,8 @@ final class NFCDienst: NSObject {
|
||||
// Das gehört gesagt, statt den ganzen Vorgang als
|
||||
// gescheitert auszugeben.
|
||||
self.fertig(.failure(Fehler.system(
|
||||
"Tag beschrieben, aber das Sperren ist fehlgeschlagen: "
|
||||
+ fehler.localizedDescription)))
|
||||
"Tag beschrieben, Sperren fehlgeschlagen: "
|
||||
+ Self.kurz(fehler))))
|
||||
return
|
||||
}
|
||||
AudioServicesPlaySystemSound(1057)
|
||||
@@ -274,7 +318,7 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
|
||||
}
|
||||
session.connect(to: ersteser) { fehler in
|
||||
if let fehler {
|
||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
||||
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||
return
|
||||
}
|
||||
switch self.auftrag {
|
||||
@@ -293,6 +337,6 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
|
||||
let abbruch = (error as? NFCReaderError)?.code
|
||||
== .readerSessionInvalidationErrorUserCanceled
|
||||
self.session = nil
|
||||
fertig(.failure(abbruch ? Fehler.abgebrochen : Fehler.system(error.localizedDescription)))
|
||||
fertig(.failure(abbruch ? Fehler.abgebrochen : Fehler.system(Self.kurz(error))))
|
||||
}
|
||||
}
|
||||
|
||||
+36
-17
@@ -7,6 +7,11 @@ import SwiftUI
|
||||
/// steht – `vorrania://i/<UID>`.
|
||||
struct NFCSchreibenView: View {
|
||||
let item: Item
|
||||
/// Was „Fertig" tun soll. Ohne Angabe schliesst die Ansicht sich selbst –
|
||||
/// das ist der Fall, wenn sie aus der Stueck-Bearbeitung heraus aufgerufen
|
||||
/// wird. Haengt sie als Schritt an einem Ablauf (gleich nach dem Anlegen),
|
||||
/// muss stattdessen der ganze Ablauf enden.
|
||||
var onFertig: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -28,30 +33,40 @@ struct NFCSchreibenView: View {
|
||||
Text("Auf das Tag wird geschrieben: \(nutzlast)")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Tag dauerhaft schreibschützen", isOn: $sperren)
|
||||
} footer: {
|
||||
if sperren {
|
||||
Text("Das lässt sich nicht rückgängig machen. Das Tag gehört danach "
|
||||
+ "für immer zu diesem Einzelstück und kann nicht neu beschrieben werden.")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text("Ohne Schreibschutz kannst du das Tag später einem anderen "
|
||||
+ "Einzelstück zuweisen.")
|
||||
// Nach dem Erfolg hat das Tag seinen Inhalt. Knopf und Schalter
|
||||
// stehenzulassen hiesse, es noch einmal beschreiben zu wollen.
|
||||
if !erfolg {
|
||||
Section {
|
||||
Toggle("Tag dauerhaft schreibschützen", isOn: $sperren)
|
||||
} footer: {
|
||||
if sperren {
|
||||
Text("Das lässt sich nicht rückgängig machen. Das Tag gehört danach "
|
||||
+ "für immer zu diesem Einzelstück und kann nicht neu beschrieben werden.")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text("Ohne Schreibschutz kannst du das Tag später einem anderen "
|
||||
+ "Einzelstück zuweisen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(laeuft ? "Tag anhalten…" : "Tag beschreiben") {
|
||||
Task { await schreiben() }
|
||||
Section {
|
||||
Button(laeuft ? "Tag anhalten…" : "Tag beschreiben") {
|
||||
Task { await schreiben() }
|
||||
}
|
||||
.disabled(laeuft)
|
||||
}
|
||||
.disabled(laeuft)
|
||||
}
|
||||
|
||||
if erfolg {
|
||||
Section {
|
||||
Label("Tag beschrieben.", systemImage: "checkmark.circle")
|
||||
Label(sperren ? "Tag beschrieben und gesperrt." : "Tag beschrieben.",
|
||||
systemImage: "checkmark.circle")
|
||||
.foregroundStyle(.green)
|
||||
// Hier ist nichts mehr zu tun; „Fertig" gehoert deshalb in
|
||||
// den Inhalt und nicht nur oben in die Leiste. Ohne das
|
||||
// stuende man im Anlege-Ablauf vor einem fast leeren Blatt.
|
||||
Button("Fertig") { if let onFertig { onFertig() } else { dismiss() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
if let fehler {
|
||||
@@ -62,7 +77,11 @@ struct NFCSchreibenView: View {
|
||||
}
|
||||
.navigationTitle("NFC-Tag")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Fertig") { if let onFertig { onFertig() } else { dismiss() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schreiben() async {
|
||||
|
||||
@@ -75,7 +75,9 @@ enum ExpiryPlanner {
|
||||
}
|
||||
|
||||
for item in snapshot.expiring {
|
||||
guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue }
|
||||
// Das Warndatum, nicht das MHD: Eine angebrochene Packung ohne
|
||||
// aufgedrucktes Datum braucht die Erinnerung am dringendsten.
|
||||
guard let mhd = parseTag(item.warnDate, calendar: calendar) else { continue }
|
||||
let mhdTag = calendar.startOfDay(for: mhd)
|
||||
|
||||
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
|
||||
|
||||
@@ -152,7 +152,7 @@ private struct LocationPicker: View {
|
||||
var body: some View {
|
||||
Picker(title, selection: $selection) {
|
||||
Text("– ohne Lagerort –").tag(String?.none)
|
||||
ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) }
|
||||
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ struct ProductDetailView: View {
|
||||
let product: Product
|
||||
|
||||
@EnvironmentObject private var display: DisplaySettings
|
||||
@EnvironmentObject private var session: Session
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var current: Product
|
||||
@State private var showLeaveConfirm = false
|
||||
@State private var pendingDelete = false
|
||||
@State private var lots: [Lot] = []
|
||||
@State private var busy = false
|
||||
@State private var error: String?
|
||||
@@ -44,6 +46,9 @@ struct ProductDetailView: View {
|
||||
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
|
||||
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
|
||||
@State private var objectSheet: ObjectStockSheet?
|
||||
@State private var itemsAnlegen = false
|
||||
/// Hochzaehlen laesst den Einzelstueck-Abschnitt neu laden.
|
||||
@State private var itemsNachladen = 0
|
||||
@State private var objectRemovals: RemovalSummary?
|
||||
|
||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||
@@ -96,7 +101,9 @@ struct ProductDetailView: View {
|
||||
|
||||
if current.isObject && current.isIndividual {
|
||||
ProductItemsSection(product: current, onChanged: { await reload() },
|
||||
onFinish: { dismiss() })
|
||||
onFinish: { dismiss() },
|
||||
nachladen: itemsNachladen,
|
||||
anlegen: $itemsAnlegen)
|
||||
} else if current.isObject && !current.isBulk {
|
||||
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
|
||||
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||
@@ -284,7 +291,15 @@ struct ProductDetailView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||
// Eine offene Packung sah hier wie jede
|
||||
// andere Charge aus - bei zwei Zeilen mit
|
||||
// gleicher Menge ist das nicht zu erraten.
|
||||
if lot.istAngebrochen, let bis = lot.consumeBy {
|
||||
Text("angebrochen · verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
|
||||
}
|
||||
// Ohne den Lagerort sehen zwei Chargen mit
|
||||
// gleichem MHD identisch aus – er ist hier
|
||||
// oft das einzige Unterscheidungsmerkmal.
|
||||
@@ -313,6 +328,14 @@ struct ProductDetailView: View {
|
||||
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
}
|
||||
|
||||
// Loeschen verlangt Adminrechte (DELETE /products/{id}). Ohne den
|
||||
// Riegel bekaeme ein normaler Benutzer nur ein wortkarges 403.
|
||||
if session.isAdmin {
|
||||
Section {
|
||||
Button("Artikel löschen", role: .destructive) { pendingDelete = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(current.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -331,6 +354,15 @@ struct ProductDetailView: View {
|
||||
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
|
||||
+ "Foto und Lagerort-Bedarf werden sofort gespeichert.")
|
||||
}
|
||||
// alert und nicht confirmationDialog: Letzteres faehrt als Blase von
|
||||
// unten herein. Ein Loeschen will mittig stehen und gelesen werden.
|
||||
.alert("„\(current.name)“ löschen?", isPresented: $pendingDelete) {
|
||||
Button("Löschen", role: .destructive) { Task { await loeschen() } }
|
||||
Button("Abbrechen", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Barcodes, Bild und der Bewegungsverlauf gehen dabei verloren. "
|
||||
+ "Hat der Artikel noch Einzelstücke oder Bestand, lehnt der Server ab.")
|
||||
}
|
||||
.sheet(item: $editLot) { lot in
|
||||
NavigationStack {
|
||||
LotEditView(lot: lot, product: current, locations: locations) {
|
||||
@@ -338,6 +370,18 @@ struct ProductDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Aus demselben Grund wie die Mengen-Sheets darunter: aus dem Abschnitt
|
||||
// heraus gezeigt, schloss sich dieses Blatt beim ersten Oeffnen sofort
|
||||
// wieder und hielt erst beim zweiten Versuch.
|
||||
.sheet(isPresented: $itemsAnlegen) {
|
||||
NavigationStack {
|
||||
ItemAddSheet(
|
||||
productId: current.id,
|
||||
onDone: { itemsNachladen += 1; await reload() },
|
||||
onFinish: { itemsAnlegen = false; dismiss() }
|
||||
)
|
||||
}
|
||||
}
|
||||
// Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst
|
||||
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
|
||||
.sheet(item: $objectSheet) { welche in
|
||||
@@ -598,6 +642,25 @@ struct ProductDetailView: View {
|
||||
}
|
||||
await reload()
|
||||
}
|
||||
|
||||
/// Artikel loeschen und zurueck zur Liste.
|
||||
///
|
||||
/// Bewusst `dismiss()` und nicht `attemptLeave()`: Letzteres fragte nach
|
||||
/// ungespeicherten Aenderungen an einem Artikel, den es nicht mehr gibt.
|
||||
/// Lehnt der Server ab (409, noch Einzelstuecke oder Bestand), bleibt die
|
||||
/// Ansicht stehen und seine Begruendung landet unveraendert in der
|
||||
/// Fehlerzeile – sie nennt genau, was noch dranhaengt.
|
||||
private func loeschen() async {
|
||||
error = nil
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
do {
|
||||
try await APIClient.shared.deleteProduct(id: current.id)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht –
|
||||
@@ -680,7 +743,7 @@ struct LotEditView: View {
|
||||
Section("Lagerort") {
|
||||
Picker("Lagerort", selection: $locationId) {
|
||||
Text("– ohne –").tag(String?.none)
|
||||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
|
||||
}
|
||||
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
|
||||
Button { locScanShown = true } label: {
|
||||
@@ -792,7 +855,7 @@ struct ProductLocationMinView: View {
|
||||
HStack {
|
||||
Picker("Ort", selection: $row.locationId) {
|
||||
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
||||
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
|
||||
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
|
||||
}
|
||||
TextField("Menge", text: $row.amount)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
@@ -23,6 +23,9 @@ struct RootView: View {
|
||||
if session.isLoggedIn {
|
||||
await display.load()
|
||||
await session.refreshMe()
|
||||
// Nach refreshMe, damit eine wirklich abgelaufene Sitzung erst
|
||||
// sauber endet, statt hier noch verlaengert zu werden.
|
||||
await session.sitzungVerlaengern()
|
||||
}
|
||||
}
|
||||
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||||
@@ -117,6 +120,10 @@ struct ScanTabView: View {
|
||||
@EnvironmentObject private var router: Router
|
||||
@EnvironmentObject private var session: Session
|
||||
@State private var showNew = false
|
||||
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
|
||||
@State private var angelegt: Product?
|
||||
/// Der gerade angelegte Artikel, bis der Folgeschritt dafuer durch ist.
|
||||
@State private var nachAnlegenFuer: Product?
|
||||
@State private var showKassenzettel = false
|
||||
|
||||
var body: some View {
|
||||
@@ -174,13 +181,20 @@ struct ScanTabView: View {
|
||||
.padding()
|
||||
.navigationTitle("Scannen")
|
||||
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
||||
.sheet(isPresented: $showNew) {
|
||||
.sheet(isPresented: $showNew, onDismiss: {
|
||||
if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) {
|
||||
nachAnlegenFuer = angelegt
|
||||
}
|
||||
angelegt = nil
|
||||
}) {
|
||||
NavigationStack {
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||
angelegt = created
|
||||
showNew = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.nachAnlegen($nachAnlegenFuer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +223,12 @@ struct ListsTabView: View {
|
||||
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
||||
systemImage: "tag")
|
||||
}
|
||||
NavigationLink {
|
||||
ChargenListView()
|
||||
} label: {
|
||||
ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen",
|
||||
systemImage: "shippingbox.and.arrow.backward")
|
||||
}
|
||||
NavigationLink {
|
||||
ShoppingListView()
|
||||
} label: {
|
||||
|
||||
@@ -224,6 +224,24 @@ final class Session: ObservableObject {
|
||||
// kurzer Netzausfall einen nicht aus der App wirft.
|
||||
}
|
||||
}
|
||||
|
||||
/// Holt beim Start ein frisches Token, damit die am Server eingestellte
|
||||
/// Sitzungsdauer ab dem letzten Besuch zaehlt und nicht ab dem Anmelden.
|
||||
///
|
||||
/// Schluckt jeden Fehler: Ein aelterer Server kennt /auth/refresh noch gar
|
||||
/// nicht (404), und eine wirklich abgelaufene Sitzung hat refreshMe() einen
|
||||
/// Schritt vorher schon beendet. Hier deshalb nie abmelden.
|
||||
@MainActor
|
||||
func sitzungVerlaengern() async {
|
||||
guard isLoggedIn, let profil = activeProfile else { return }
|
||||
guard let neu = try? await APIClient.shared.sitzungVerlaengern() else { return }
|
||||
// oidcSlug MUSS mitgegeben werden: store() setzt das Feld immer, und
|
||||
// ohne ihn verloere das Profil seinen Anmeldedienst – die stille
|
||||
// Erneuerung nach einem 401 fiele damit aus.
|
||||
store(token: neu.accessToken, username: neu.username,
|
||||
isAdmin: neu.role == "admin", persist: stayLoggedIn,
|
||||
oidcSlug: profil.oidcSlug)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
|
||||
93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F4CDB145DD84793DFC90F1 /* Baum.swift */; };
|
||||
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
|
||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
|
||||
@@ -77,6 +78,7 @@
|
||||
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = "<group>"; };
|
||||
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
|
||||
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
85F4CDB145DD84793DFC90F1 /* Baum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Baum.swift; sourceTree = "<group>"; };
|
||||
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
|
||||
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
|
||||
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||
@@ -115,6 +117,7 @@
|
||||
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
||||
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
|
||||
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
|
||||
85F4CDB145DD84793DFC90F1 /* Baum.swift */,
|
||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
||||
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
|
||||
@@ -253,6 +256,7 @@
|
||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
||||
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
|
||||
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
|
||||
93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */,
|
||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
||||
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
|
||||
@@ -367,7 +371,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -390,7 +394,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ targets:
|
||||
# MARKETING_VERSION ist die sichtbare Version (App Store),
|
||||
# CURRENT_PROJECT_VERSION die Build-Nummer: muss bei jedem Upload
|
||||
# innerhalb derselben Version steigen.
|
||||
MARKETING_VERSION: "1.1"
|
||||
MARKETING_VERSION: "1.2"
|
||||
CURRENT_PROJECT_VERSION: "2"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
SWIFT_VERSION: "5.9"
|
||||
|
||||
+132
-14
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { api } from "./api";
|
||||
import { useAuth } from "./auth";
|
||||
import { gruppenPfade } from "./nav";
|
||||
import Icon from "./components/Icon";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import BrandMark from "./components/BrandMark";
|
||||
@@ -23,17 +24,39 @@ import ItemResolve from "./pages/ItemResolve";
|
||||
import LocationResolve from "./pages/LocationResolve";
|
||||
import Locations from "./pages/Locations";
|
||||
import PackageTypes from "./pages/PackageTypes";
|
||||
import Masse from "./pages/Masse";
|
||||
import Units from "./pages/Units";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
import Rezepte from "./pages/Rezepte";
|
||||
import History from "./pages/History";
|
||||
import Verliehen from "./pages/Verliehen";
|
||||
import Transfer from "./pages/Transfer";
|
||||
import Settings from "./pages/Settings";
|
||||
import Konto from "./pages/Konto";
|
||||
|
||||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
||||
|
||||
// Ausgeblendete Eintraege stehen hier drin. Ueber einen Kontext und nicht als
|
||||
// Eigenschaft an jedem NavItem: So bleiben die zwanzig Zeilen der Leiste
|
||||
// unveraendert lesbar.
|
||||
const VersteckteNav = createContext(new Set());
|
||||
|
||||
// Die vier Bestandslisten stehen unter einer zuklappbaren Gruppe. Sie sind der
|
||||
// laengste Block der Leiste und wird im Alltag selten gebraucht – wer einlagert,
|
||||
// nutzt die Knoepfe darueber.
|
||||
const LISTEN = [
|
||||
{ to: "/lebensmittel", icon: "food", label: "Lebensmittel" },
|
||||
{ to: "/gegenstaende", icon: "package", label: "Gegenstände" },
|
||||
{ to: "/items", icon: "tag", label: "Einzelstücke" },
|
||||
{ to: "/chargen", icon: "box", label: "Chargen" },
|
||||
];
|
||||
const LISTEN_OFFEN_KEY = "nav:listen-offen";
|
||||
|
||||
function NavItem({ to, icon, label, end }) {
|
||||
// Gar nicht erst rendern statt per CSS verbergen: Unter 768px blendet die
|
||||
// CSS die Listen-Kinder wieder ein, eine Klasse bisse sich damit.
|
||||
if (useContext(VersteckteNav).has(to)) return null;
|
||||
return (
|
||||
<NavLink to={to} end={end} className={navClass}>
|
||||
<Icon name={icon} size={17} />
|
||||
@@ -45,7 +68,34 @@ function NavItem({ to, icon, label, end }) {
|
||||
function Sidebar() {
|
||||
const { user, isAdmin, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [dashboards, setDashboards] = useState([]);
|
||||
// Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler
|
||||
// hier darf die Leiste nicht stoeren, deshalb still abfangen.
|
||||
const [mealie, setMealie] = useState(false);
|
||||
// Vorgabe des Administrators plus eigene Auswahl, vom Server schon
|
||||
// zusammengerechnet. Ein Fehler darf die Leiste nicht leeren.
|
||||
const [versteckt, setVersteckt] = useState(() => new Set());
|
||||
// Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die
|
||||
// Spalteneinstellungen der Tabellen.
|
||||
const [listenOffen, setListenOffen] = useState(
|
||||
() => localStorage.getItem(LISTEN_OFFEN_KEY) === "1",
|
||||
);
|
||||
// Steht man auf einer dieser Seiten, ist die Gruppe offen – ohne den
|
||||
// gemerkten Zustand zu ueberschreiben. Sonst saehe man nicht, wo man ist.
|
||||
const sichtbareListen = LISTEN.filter((e) => !versteckt.has(e.to));
|
||||
const aufEinerListe = sichtbareListen.some((e) => pathname.startsWith(e.to));
|
||||
// Eine Gruppe ohne Kinder waere ein Knopf, der nichts aufklappt; eine
|
||||
// Ueberschrift ohne Eintraege darunter genauso sinnlos.
|
||||
const zeigtGruppe = (gruppe) => gruppenPfade(gruppe).some((t) => !versteckt.has(t));
|
||||
|
||||
function klappeListen() {
|
||||
setListenOffen((alt) => {
|
||||
const neu = !alt;
|
||||
localStorage.setItem(LISTEN_OFFEN_KEY, neu ? "1" : "0");
|
||||
return neu;
|
||||
});
|
||||
}
|
||||
|
||||
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
|
||||
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
|
||||
@@ -59,6 +109,24 @@ function Sidebar() {
|
||||
return () => window.removeEventListener("dashboards-geaendert", laden);
|
||||
}, []);
|
||||
|
||||
// Dasselbe Spiel fuer die ausgeblendeten Eintraege: Einstellungen und
|
||||
// Kontoseite loesen beim Speichern ein Ereignis aus, statt die Seite neu zu
|
||||
// laden.
|
||||
useEffect(() => {
|
||||
function laden() {
|
||||
api.navigation()
|
||||
.then((a) => setVersteckt(new Set(a.versteckt || [])))
|
||||
.catch(() => {});
|
||||
}
|
||||
laden();
|
||||
window.addEventListener("navigation-geaendert", laden);
|
||||
return () => window.removeEventListener("navigation-geaendert", laden);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
navigate("/login");
|
||||
@@ -69,36 +137,64 @@ function Sidebar() {
|
||||
<div className="sidebar-brand">
|
||||
<BrandMark />
|
||||
</div>
|
||||
<VersteckteNav.Provider value={versteckt}>
|
||||
<nav className="sidebar-nav">
|
||||
<NavItem to="/" end icon="dashboard" label="Übersicht" />
|
||||
{/* Ohne `end`: Der Eintrag soll auch bei /home/edit markiert bleiben.
|
||||
Bei "/" war `end` noetig, weil sonst JEDER Pfad als Treffer galt. */}
|
||||
<NavItem to="/home" icon="dashboard" label="Übersicht" />
|
||||
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
|
||||
waere sie nur eine Verdopplung von "Übersicht". */}
|
||||
{dashboards.length > 1 && dashboards.map((d, index) => (
|
||||
<NavLink key={d.id} to={index === 0 ? "/" : `/d/${d.id}`} end
|
||||
<NavLink key={d.id} to={index === 0 ? "/home" : `/d/${d.id}`}
|
||||
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
|
||||
<span className="label">{d.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||||
<NavItem to="/lebensmittel" icon="food" label="Lebensmittel" />
|
||||
<NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
|
||||
<NavItem to="/items" icon="tag" label="Einzelstücke" />
|
||||
<NavItem to="/chargen" icon="box" label="Chargen" />
|
||||
{/* Auf schmalen Bildschirmen wird die Leiste waagerecht; dort gibt es
|
||||
keine Abschnitte (styles.css) und die Eintraege stehen flach
|
||||
nebeneinander – zuklappen loest dort kein Platzproblem. */}
|
||||
{sichtbareListen.length > 0 && (
|
||||
<button type="button" className="nav-link nav-group" onClick={klappeListen}
|
||||
aria-expanded={listenOffen || aufEinerListe}>
|
||||
<Icon name="chevron" size={17}
|
||||
className={listenOffen || aufEinerListe ? "" : "rot-90"} />
|
||||
<span className="label">Listen</span>
|
||||
</button>
|
||||
)}
|
||||
{sichtbareListen.map((e) => (
|
||||
<NavLink key={e.to} to={e.to}
|
||||
className={({ isActive }) => [
|
||||
"nav-link nav-child",
|
||||
isActive ? "active" : "",
|
||||
// Nicht ausgehaengt, nur verborgen: auf schmalen Bildschirmen
|
||||
// blendet die CSS sie wieder ein, dort gibt es keine Gruppe.
|
||||
listenOffen || aufEinerListe ? "" : "nav-zu",
|
||||
].filter(Boolean).join(" ")}>
|
||||
<Icon name={e.icon} size={16} />
|
||||
<span className="label">{e.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{mealie && <NavItem to="/rezepte" icon="food" label="Rezepte" />}
|
||||
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
|
||||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||||
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||
{isAdmin && (
|
||||
<>
|
||||
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
|
||||
gepflegt – deshalb alle zusammen hier, wie in der iOS-App. */}
|
||||
<div className="nav-section">Stammdaten</div>
|
||||
{zeigtGruppe("stammdaten") && <div className="nav-section">Stammdaten</div>}
|
||||
<NavItem to="/categories" icon="package" label="Kategorien" />
|
||||
<NavItem to="/groups" icon="tag" label="Gruppen" />
|
||||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||||
<NavItem to="/shops" icon="cart" label="Shops" />
|
||||
<NavItem to="/units" icon="box" label="Einheiten" />
|
||||
<NavItem to="/package-types" icon="package" label="Gebinde" />
|
||||
<NavItem to="/masse" icon="split" label="Maße" />
|
||||
{/* "System" steht immer, weil die Einstellungen darunter nicht
|
||||
ausblendbar sind – sonst käme man nicht mehr zurück. */}
|
||||
<div className="nav-section">System</div>
|
||||
<NavItem to="/users" icon="users" label="Benutzer" />
|
||||
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
||||
@@ -106,6 +202,7 @@ function Sidebar() {
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</VersteckteNav.Provider>
|
||||
<div className="sidebar-foot">
|
||||
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
|
||||
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
|
||||
@@ -147,7 +244,7 @@ function Protected({ children, adminOnly = false, wide = false }) {
|
||||
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
||||
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
||||
if (adminOnly && !isAdmin) return <Navigate to="/home" replace />;
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
@@ -156,8 +253,14 @@ function Protected({ children, adminOnly = false, wide = false }) {
|
||||
<div className={wide ? "content content--wide" : "content"}>
|
||||
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
|
||||
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
|
||||
zurueck, sonst haenge man nach einem Fehler dauerhaft fest. */}
|
||||
<ErrorBoundary key={pfad} bereich="Diese Seite">{children}</ErrorBoundary>
|
||||
zurueck, sonst haenge man nach einem Fehler dauerhaft fest.
|
||||
|
||||
Nur das ERSTE Pfadsegment: Sonst gilt /home/edit als andere Seite
|
||||
als /home, der ganze Baum wird neu eingehaengt und laedt beim
|
||||
Umschalten in den Bearbeitungsmodus alles noch einmal. */}
|
||||
<ErrorBoundary key={pfad.split("/")[1] || "home"} bereich="Diese Seite">
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,10 +274,19 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
|
||||
element={loading ? null : user ? <Navigate to="/home" replace /> : <Login />}
|
||||
/>
|
||||
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
|
||||
{/* Die Startseite hat einen Namen, damit in der Adresszeile steht, wo
|
||||
man ist; "/" leitet sichtbar dorthin um. Der Bearbeitungsmodus ist
|
||||
ein eigener Pfad statt eines Zustands im Speicher: So ueberlebt er
|
||||
ein Neuladen, laesst sich verschicken, und die Zurueck-Taste
|
||||
verlaesst ihn. Den Modus gibt die Prop vor, damit der Pfad nicht ein
|
||||
zweites Mal in der Komponente steht. */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
<Route path="/home" element={<Protected wide><Dashboard /></Protected>} />
|
||||
<Route path="/home/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
|
||||
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
|
||||
<Route path="/d/:id/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
|
||||
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||
<Route path="/lebensmittel" element={<Protected wide><Products fixedType="food" /></Protected>} />
|
||||
@@ -189,7 +301,12 @@ export default function App() {
|
||||
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
|
||||
<Route path="/rezepte/:slug" element={<Protected wide><Rezepte /></Protected>} />
|
||||
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
|
||||
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
|
||||
und Auslagern, keine Stammdatenänderung. */}
|
||||
<Route path="/verliehen" element={<Protected><Verliehen /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
||||
@@ -197,6 +314,7 @@ export default function App() {
|
||||
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
||||
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
|
||||
<Route path="/masse" element={<Protected adminOnly wide><Masse /></Protected>} />
|
||||
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||
@@ -206,7 +324,7 @@ export default function App() {
|
||||
<Route path="/auth/callback" element={<OidcCallback />} />
|
||||
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
|
||||
<Route path="/sso-neu" element={<SsoNeu />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
+136
@@ -154,6 +154,10 @@ export const api = {
|
||||
login: (username, password) =>
|
||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||
me: () => request("/auth/me"),
|
||||
// Schiebt die Frist der laufenden Sitzung nach vorn. Wird beim Start
|
||||
// gerufen, damit die eingestellte Dauer ab dem letzten Besuch zaehlt und
|
||||
// nicht ab dem Anmelden.
|
||||
sitzungVerlaengern: () => request("/auth/refresh", { method: "POST" }),
|
||||
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
|
||||
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
|
||||
// und es taucht kein SSO-Knopf auf.
|
||||
@@ -232,6 +236,18 @@ export const api = {
|
||||
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
|
||||
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
|
||||
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
|
||||
// Verwendungsstatus. Leerer Status = verfügbar. Eigener Endpunkt aus
|
||||
// demselben Grund wie Zubehör (siehe unten) und weil updateItem Admins
|
||||
// vorbehalten ist – umstellen darf jeder.
|
||||
setzeVerwendung: (itemId, status) =>
|
||||
request(`/items/${itemId}/verwendung`, { method: "PUT", body: { status } }),
|
||||
itemVerwendungen: (itemId) => request(`/items/${itemId}/verwendungen`),
|
||||
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
|
||||
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
|
||||
zubehoerZuordnen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }),
|
||||
zubehoerLoesen: (itemId, childItemId) =>
|
||||
request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }),
|
||||
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
|
||||
// in so vielen Tagen ansteht (überfällige sind immer dabei).
|
||||
loans: ({ openOnly = true, dueIn } = {}) => {
|
||||
@@ -260,9 +276,47 @@ export const api = {
|
||||
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
|
||||
bulkLotLocation: (lotIds, locationId) =>
|
||||
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
|
||||
// Mehrere Chargen ausbuchen: ohne Menge komplett, mit Menge je Charge
|
||||
// dieselbe (nur bei einheitlichem Artikel erlaubt).
|
||||
bulkLotCheckout: (lotIds, { quantity = null, unit = null, note = null } = {}) =>
|
||||
request("/lots/bulk-checkout", {
|
||||
method: "POST",
|
||||
body: { lot_ids: lotIds, quantity, unit, note },
|
||||
}),
|
||||
// Mehrere Chargen als Korrektur entfernen (nicht als Verbrauch buchen).
|
||||
bulkLotDelete: (lotIds) =>
|
||||
request("/lots/bulk-delete", { method: "POST", body: { lot_ids: lotIds } }),
|
||||
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
||||
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
||||
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
|
||||
// Anbrechen: spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum.
|
||||
// Ohne Menge ist es eine Artikeleinheit (bzw. die ganze Charge, wenn kleiner).
|
||||
openLot: (id, quantity = null) =>
|
||||
request(`/lots/${id}/open`, { method: "POST", body: { quantity } }),
|
||||
// Teilverbrauch. `auto_open` bricht eine noch geschlossene Charge dabei an.
|
||||
consumeLot: (id, body) => request(`/lots/${id}/consume`, { method: "POST", body }),
|
||||
// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft - Grundlage der
|
||||
// Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker
|
||||
// verschieden viel Gramm).
|
||||
productMeasures: (id) => request(`/products/${id}/measures`),
|
||||
// Hausmasse: die NAMEN sind global, die MENGEN haengen an Kategorie bzw.
|
||||
// Artikel - "1 EL" ist bei Mehl und Zucker verschieden schwer.
|
||||
listMeasures: () => request("/measures"),
|
||||
createMeasure: (body) => request("/measures", { method: "POST", body }),
|
||||
updateMeasure: (id, body) => request(`/measures/${id}`, { method: "PATCH", body }),
|
||||
deleteMeasure: (id) => request(`/measures/${id}`, { method: "DELETE" }),
|
||||
// `vererbt` holt zusaetzlich, was die Kategorie von oben erbt - die Maske
|
||||
// zeigt damit auch, was gelten wuerde, wenn man den eigenen Eintrag loescht.
|
||||
categoryMeasures: (id, vererbt = false) =>
|
||||
request(`/categories/${id}/measures${vererbt ? "?vererbt=true" : ""}`),
|
||||
setCategoryMeasure: (catId, measureId, body) =>
|
||||
request(`/categories/${catId}/measures/${measureId}`, { method: "PUT", body }),
|
||||
deleteCategoryMeasure: (catId, measureId) =>
|
||||
request(`/categories/${catId}/measures/${measureId}`, { method: "DELETE" }),
|
||||
setProductMeasure: (productId, measureId, body) =>
|
||||
request(`/products/${productId}/measures/${measureId}`, { method: "PUT", body }),
|
||||
deleteProductMeasure: (productId, measureId) =>
|
||||
request(`/products/${productId}/measures/${measureId}`, { method: "DELETE" }),
|
||||
|
||||
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
|
||||
setProductLocationMinStock: (id, list) =>
|
||||
@@ -277,6 +331,21 @@ export const api = {
|
||||
|
||||
// Views
|
||||
shoppingList: () => request("/shopping-list"),
|
||||
// Alles in einem Aufruf: die gerechneten Bedarfe, die gespeicherten Eintraege
|
||||
// und die dauerhaft gesetzten Haken.
|
||||
shoppingAll: () => request("/shopping-list/all"),
|
||||
addShoppingEntry: (body) => request("/shopping-list/entries", { method: "POST", body }),
|
||||
updateShoppingEntry: (id, body) =>
|
||||
request(`/shopping-list/entries/${id}`, { method: "PATCH", body }),
|
||||
deleteShoppingEntry: (id) =>
|
||||
request(`/shopping-list/entries/${id}`, { method: "DELETE" }),
|
||||
clearDoneShopping: () => request("/shopping-list/clear-done", { method: "POST" }),
|
||||
// Haken an einem GERECHNETEN Posten. Der Schluessel kommt von hier ("p12",
|
||||
// "g3", "lKUECHEp12") - der Server haengt ihn nur daran.
|
||||
setShoppingCheck: (key) =>
|
||||
request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "PUT" }),
|
||||
clearShoppingCheck: (key) =>
|
||||
request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "DELETE" }),
|
||||
groupShoppingList: () => request("/shopping-list/groups"),
|
||||
shoppingByLocation: () => request("/shopping-list/by-location"),
|
||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||
@@ -353,6 +422,44 @@ export const api = {
|
||||
exportJson: () =>
|
||||
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
||||
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
|
||||
// ---- Etiketten-Vorlagen ----
|
||||
etikettenVorlagen: () => request("/etiketten/vorlagen"),
|
||||
etikettenSpalten: (art, categoryIds) => {
|
||||
const p = new URLSearchParams({ art });
|
||||
if (categoryIds && categoryIds.length) p.set("kategorie_ids", categoryIds.join(","));
|
||||
return request(`/etiketten/spalten?${p}`);
|
||||
},
|
||||
etikettenVorlageAnlegen: (body) =>
|
||||
request("/etiketten/vorlagen", { method: "POST", body }),
|
||||
etikettenVorlageAendern: (id, body) =>
|
||||
request(`/etiketten/vorlagen/${id}`, { method: "PATCH", body }),
|
||||
etikettenVorlageLoeschen: (id) =>
|
||||
request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }),
|
||||
// Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/<id>);
|
||||
// nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist.
|
||||
// Seitenleiste: Vorgabe des Admins, eigene Auswahl und die fertige
|
||||
// Vereinigung – alles in einem Zug, das Zusammenrechnen macht der Server.
|
||||
navigation: () => request("/navigation"),
|
||||
setzeEigeneNavigation: (versteckt) =>
|
||||
request("/navigation", { method: "PUT", body: { versteckt } }),
|
||||
setzeNavigationsVorgabe: (versteckt) =>
|
||||
request("/navigation/vorgabe", { method: "PUT", body: { versteckt } }),
|
||||
|
||||
etikettenCsv: (id, name) =>
|
||||
downloadFile(
|
||||
`/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`,
|
||||
`${name || "etiketten"}.csv`,
|
||||
),
|
||||
etikettenDateiHochladen: (id, file) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return request(`/etiketten/vorlagen/${id}/datei`, { method: "POST", formData: fd });
|
||||
},
|
||||
etikettenDatei: (id, name) =>
|
||||
downloadFile(`/etiketten/vorlagen/${id}/datei`, name || "vorlage.lbx"),
|
||||
etikettenDateiEntfernen: (id) =>
|
||||
request(`/etiketten/vorlagen/${id}/datei`, { method: "DELETE" }),
|
||||
|
||||
labelRows: (categoryIds) =>
|
||||
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
|
||||
importStock: (file, mode = "add") => {
|
||||
@@ -394,6 +501,35 @@ export const api = {
|
||||
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
|
||||
|
||||
// Einstellungen
|
||||
// Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie
|
||||
// zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein".
|
||||
mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`),
|
||||
mealieVerfuegbar: () => request("/mealie/available"),
|
||||
mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
|
||||
mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
|
||||
mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
|
||||
// Rezepte aus Mealie, gegen den Bestand geprueft.
|
||||
recipes: ({ q, page = 1, perPage = 50 } = {}) => {
|
||||
const s = new URLSearchParams({ page: String(page), per_page: String(perPage) });
|
||||
if (q) s.set("q", q);
|
||||
return request(`/recipes?${s.toString()}`);
|
||||
},
|
||||
recipe: (slug, servings) =>
|
||||
request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`),
|
||||
// Zutaten von Hand zuordnen. Der Zutatenname ist der Schluessel - er reist
|
||||
// als Abfrageparameter, weil Lebensmittelnamen Schraegstriche enthalten.
|
||||
zuordnungen: () => request("/recipes/zuordnungen"),
|
||||
zuordnungsZiele: (q) =>
|
||||
request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }),
|
||||
// Fehlende Zutaten uebernehmen - mit der Portionszahl, die auf dem Schirm steht.
|
||||
rezeptAufEinkaufsliste: (slug, servings) =>
|
||||
request(
|
||||
`/recipes/${encodeURIComponent(slug)}/to-shopping-list${servings ? `?servings=${servings}` : ""}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
loescheZuordnung: (food) =>
|
||||
request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }),
|
||||
listSettings: () => request("/settings"),
|
||||
setSetting: (key, value) =>
|
||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||
|
||||
@@ -13,6 +13,22 @@ export function AuthProvider({ children }) {
|
||||
// meldet einen ab.
|
||||
const [startFehler, setStartFehler] = useState(null);
|
||||
|
||||
// Holt ein frisches Token, damit die eingestellte Dauer ab dem letzten
|
||||
// Besuch zaehlt statt ab dem Anmelden.
|
||||
//
|
||||
// Der Fehler wird geschluckt, weil ein Server- oder Netzfehler hier nichts
|
||||
// kostet: das bisherige Token gilt ja weiter. Ein 401 waere etwas anderes –
|
||||
// um den kuemmert sich bereits die zentrale Abmeldung in api.js, und er kann
|
||||
// direkt nach einem geglueckten me() ohnehin nicht auftreten.
|
||||
async function sitzungVerlaengern() {
|
||||
try {
|
||||
const res = await api.sitzungVerlaengern();
|
||||
setToken(res.access_token);
|
||||
} catch {
|
||||
/* siehe oben */
|
||||
}
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
setStartFehler(null);
|
||||
if (!getToken()) {
|
||||
@@ -21,6 +37,7 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
try {
|
||||
setUser(await api.me());
|
||||
await sitzungVerlaengern();
|
||||
} catch (err) {
|
||||
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
||||
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
|
||||
|
||||
@@ -20,7 +20,11 @@ export default function CategorySelect({
|
||||
// Zusätzliche Einträge über dem Baum, z.B. { value: 0, label: "Ohne Kategorie" }
|
||||
// für Filter. Ihr Wert wird unverändert an onChange gereicht.
|
||||
extraOptions = [],
|
||||
// Aus, wo „nichts gewaehlt" gar kein gueltiger Wert ist (Mindestbestaende:
|
||||
// dort heisst die oberste Zeile „Ueberall" und steht in extraOptions).
|
||||
showRoot = true,
|
||||
disabled = false,
|
||||
title,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(() => new Set());
|
||||
@@ -123,6 +127,7 @@ export default function CategorySelect({
|
||||
ref={btnRef}
|
||||
className="tree-select-btn"
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
@@ -138,14 +143,16 @@ export default function CategorySelect({
|
||||
className="tree-pop"
|
||||
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
||||
style={{ paddingLeft: 12 }}
|
||||
onClick={() => pick(null)}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{showRoot && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
||||
style={{ paddingLeft: 12 }}
|
||||
onClick={() => pick(null)}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
)}
|
||||
{extraOptions.map((o) => (
|
||||
<button
|
||||
key={String(o.value)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import Icon from "./Icon";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
import { leseTabelle, schreibeTabelle } from "../urlState";
|
||||
|
||||
/**
|
||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
||||
@@ -11,7 +13,8 @@ import { Skeleton } from "./Skeleton";
|
||||
*
|
||||
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
|
||||
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
|
||||
* fixed:true (kein Reorder/Resize), width, min }.
|
||||
* fixed:true (kein Reorder/Resize), width, min,
|
||||
* defaultHidden:true (erscheint erst, wenn man sie im Spalten-Menü einschaltet) }.
|
||||
*
|
||||
* `tree` (optional): { column, idOf(row), parentIdOf(row) } – zeigt die Zeilen als
|
||||
* ein-/ausklappbaren Baum in der Spalte `column`. Sobald gefiltert oder sortiert
|
||||
@@ -34,6 +37,16 @@ export default function DataTable({
|
||||
}) {
|
||||
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
/**
|
||||
* Was beim Aufruf in der Adresse stand - einmal beim Mounten festgehalten.
|
||||
*
|
||||
* Die Regel dahinter: Der Browserspeicher ist die persoenliche Vorgabe, die
|
||||
* URL schlaegt sie. Ein verschickter Link zeigt damit beim Empfaenger
|
||||
* dasselbe, auch wenn der sich etwas anderes eingestellt hat.
|
||||
*/
|
||||
const [vonUrl] = useState(() => leseTabelle(params, (k) => !!colByKey[k]));
|
||||
|
||||
const [order, setOrder] = useState(() => {
|
||||
const p = loadPersisted(id);
|
||||
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
|
||||
@@ -48,11 +61,25 @@ export default function DataTable({
|
||||
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
|
||||
// gewaehlte Reihenfolge soll nicht umspringen.
|
||||
const schluessel = columns.map((c) => c.key).join("|");
|
||||
// Spiegel der Reihenfolge, um sie im Effekt zu lesen, ohne sie in die
|
||||
// Abhaengigkeiten zu nehmen (das liefe im Kreis). Gleiche Technik wie bei
|
||||
// onVisibleRef weiter unten.
|
||||
const ordnungRef = useRef(order);
|
||||
ordnungRef.current = order;
|
||||
useEffect(() => {
|
||||
setOrder((alt) => {
|
||||
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
||||
return fehlend.length ? [...alt, ...fehlend] : alt;
|
||||
});
|
||||
const fehlend = columns.map((c) => c.key).filter((k) => !ordnungRef.current.includes(k));
|
||||
if (!fehlend.length) return;
|
||||
setOrder((alt) => [...alt, ...fehlend.filter((k) => !alt.includes(k))]);
|
||||
// Spalten, die sich als „erst auf Wunsch" melden (defaultHidden), starten
|
||||
// verborgen - aber nur beim ERSTEN Auftauchen. Wer sie einschaltet, steht
|
||||
// danach in `order` und taucht hier nie wieder auf.
|
||||
//
|
||||
// setzeHidden und nicht setHidden: Nur der merkt den Stand ueberhaupt
|
||||
// (siehe Speicher-Effekt). Mit setHidden stuende die Spalte zwar in der
|
||||
// gemerkten Reihenfolge, aber nicht unter den versteckten - beim zweiten
|
||||
// Besuch waere sie sichtbar.
|
||||
const verbergen = fehlend.filter((k) => colByKey[k]?.defaultHidden);
|
||||
if (verbergen.length) setzeHidden((s) => new Set([...s, ...verbergen]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schluessel]);
|
||||
|
||||
@@ -61,6 +88,7 @@ export default function DataTable({
|
||||
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
|
||||
// Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets.
|
||||
const [filters, setFilters] = useState(() => {
|
||||
if (vonUrl) return vonUrl.filters;
|
||||
const saved = loadPersisted(id).filters || {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
@@ -70,28 +98,84 @@ export default function DataTable({
|
||||
return out;
|
||||
}); // { key: { text, values:Set|null } }
|
||||
const [sort, setSort] = useState(() => {
|
||||
if (vonUrl) return vonUrl.sort;
|
||||
const s = loadPersisted(id).sort;
|
||||
return s && s.key && colByKey[s.key] ? s : null;
|
||||
}); // { key, dir }
|
||||
const [filterShown, setFilterShown] = useState(true);
|
||||
const [openFilter, setOpenFilter] = useState(null);
|
||||
const [collapsed, setCollapsed] = useState(() => new Set());
|
||||
const [hidden, setHidden] = useState(
|
||||
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
|
||||
);
|
||||
const [hidden, setHidden] = useState(() => (vonUrl
|
||||
? vonUrl.hidden
|
||||
: new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k]))));
|
||||
const [chooserOpen, setChooserOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Kam die letzte Aenderung an Filter, Sortierung oder Spaltenauswahl von
|
||||
* einer Bedienhandlung?
|
||||
*
|
||||
* Nur dann wird die persoenliche Vorgabe im Browserspeicher fortgeschrieben.
|
||||
* Wer einen fremden gefilterten Link oeffnet, soll seine eigenen
|
||||
* Einstellungen nicht verlieren, bloss weil er geschaut hat.
|
||||
*
|
||||
* Bewusst ein Merker an den Settern und kein "ueberspring den ersten
|
||||
* Durchlauf": React fuehrt Effekte im Entwicklungsmodus doppelt aus, ein
|
||||
* Zaehler am Mounten wird dadurch wertlos.
|
||||
*/
|
||||
const vomBenutzer = useRef(false);
|
||||
const setzeFilters = (wert) => { vomBenutzer.current = true; setFilters(wert); };
|
||||
const setzeSort = (wert) => { vomBenutzer.current = true; setSort(wert); };
|
||||
const setzeHidden = (wert) => { vomBenutzer.current = true; setHidden(wert); };
|
||||
|
||||
/**
|
||||
* Die persoenliche Vorgabe fortschreiben.
|
||||
*
|
||||
* Spaltenreihenfolge und -breiten sind reines Layout und entstehen immer
|
||||
* durch Ziehen - die werden ohne Wenn und Aber gemerkt. Filter, Sortierung
|
||||
* und ausgeblendete Spalten koennen dagegen aus einer fremden URL stammen;
|
||||
* dann bleibt stehen, was vorher gespeichert war.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein Filter);
|
||||
// Sets werden zu Arrays serialisiert.
|
||||
const filtersSer = {};
|
||||
for (const [k, v] of Object.entries(filters)) {
|
||||
if (!v || (!v.text && v.values == null)) continue;
|
||||
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
|
||||
const bisher = loadPersisted(id);
|
||||
const neu = { order, widths };
|
||||
if (vomBenutzer.current) {
|
||||
vomBenutzer.current = false;
|
||||
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein
|
||||
// Filter); Sets werden zu Arrays serialisiert.
|
||||
const filtersSer = {};
|
||||
for (const [k, v] of Object.entries(filters)) {
|
||||
if (!v || (!v.text && v.values == null)) continue;
|
||||
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
|
||||
}
|
||||
Object.assign(neu, { hidden: [...hidden], filters: filtersSer, sort });
|
||||
} else {
|
||||
Object.assign(neu, {
|
||||
hidden: bisher.hidden || [],
|
||||
filters: bisher.filters || {},
|
||||
sort: bisher.sort ?? null,
|
||||
});
|
||||
}
|
||||
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
|
||||
savePersisted(id, neu);
|
||||
}, [id, order, widths, hidden, filters, sort]);
|
||||
|
||||
/**
|
||||
* Was man sieht, steht in der Adresse - auch wenn es aus der gemerkten
|
||||
* Vorgabe stammt. Erst damit laesst sich ein gefilterter Stand verschicken.
|
||||
*
|
||||
* `setParams` bekommt eine Funktion, damit fremde Parameter derselben Seite
|
||||
* (auf der Kategorienseite etwa `kat` und `panel`) aus dem AKTUELLEN Stand
|
||||
* kommen und nicht aus einem veralteten Abschluss. Immer `replace`: Ein
|
||||
* Filter ist kein Ort, zu dem man zurueckblaettern will, und jeder
|
||||
* Tastendruck im Filterfeld wuerde sonst einen Verlaufseintrag anlegen.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setParams((alt) => {
|
||||
const neu = schreibeTabelle(alt, { filters, sort, hidden });
|
||||
return neu.toString() === alt.toString() ? alt : neu;
|
||||
}, { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, sort, hidden]);
|
||||
|
||||
// Containerbreite messen, damit die grow-Spalte den Rest füllt.
|
||||
const wrapRef = useRef(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
@@ -135,7 +219,7 @@ export default function DataTable({
|
||||
}, [chooserOpen]);
|
||||
|
||||
function toggleHidden(key) {
|
||||
setHidden((s) => {
|
||||
setzeHidden((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) { n.delete(key); return n; }
|
||||
// Mindestens eine Spalte sichtbar lassen.
|
||||
@@ -254,7 +338,7 @@ export default function DataTable({
|
||||
|
||||
function toggleSort(c) {
|
||||
if (!c.sortValue && !c.filterText) return;
|
||||
setSort((s) => {
|
||||
setzeSort((s) => {
|
||||
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
|
||||
if (s.dir === "asc") return { key: c.key, dir: "desc" };
|
||||
return null;
|
||||
@@ -265,34 +349,77 @@ export default function DataTable({
|
||||
}
|
||||
|
||||
// --- Resize (Pointer) ---
|
||||
//
|
||||
// Gerechnet wird mit der BEWEGUNG seit dem letzten Schritt, nicht mit dem
|
||||
// Abstand zum Startpunkt. Nur so laesst sich die letzte Spalte verbreitern:
|
||||
// Ihr Anfasser sitzt am rechten Rand, und dort ist fuer den Zeiger Schluss -
|
||||
// nach rechts gibt es kein Fenster mehr. Steht er in der Randzone, waechst die
|
||||
// Spalte deshalb von selbst weiter, Schritt fuer Schritt, solange die Taste
|
||||
// haelt. Ein Taktgeber treibt das an, denn ein stehender Zeiger meldet keine
|
||||
// Bewegung mehr.
|
||||
const resizing = useRef(null);
|
||||
const rollTimer = useRef(null);
|
||||
const RAND = 36; // Breite der Randzone
|
||||
const SCHRITT = 14; // Pixel je Takt in der Randzone
|
||||
|
||||
function startResize(e, c) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const startW = widthOf(c);
|
||||
// Die flexible (grow-)Spalte auf ihre aktuelle Pixelbreite einfrieren. Sonst
|
||||
// "klaut" das Breiterziehen einer Spalte der grow-Spalte den Platz – dann
|
||||
// "klaut" das Breiterziehen einer Spalte der grow-Spalte den Platz - dann
|
||||
// schrumpft die Nachbarspalte und alles wandert nach links. Danach verhalten
|
||||
// sich die Spalten wie in Excel (unabhängig, notfalls scrollt die Tabelle).
|
||||
// sich die Spalten wie in Excel (unabhaengig, notfalls scrollt die Tabelle).
|
||||
setWidths((old) => {
|
||||
const next = { ...old };
|
||||
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
|
||||
return next;
|
||||
});
|
||||
resizing.current = { key: c.key, startX: e.clientX, startW };
|
||||
resizing.current = { key: c.key, breite: widthOf(c), zeigerX: e.clientX, letzterX: e.clientX };
|
||||
rollTimer.current = window.setInterval(anwenden, 30);
|
||||
window.addEventListener("pointermove", onResizeMove);
|
||||
window.addEventListener("pointerup", endResize);
|
||||
}
|
||||
|
||||
function anwenden() {
|
||||
const r = resizing.current; if (!r) return;
|
||||
let delta = r.zeigerX - r.letzterX;
|
||||
r.letzterX = r.zeigerX;
|
||||
|
||||
const el = wrapRef.current;
|
||||
let amRand = false;
|
||||
if (el) {
|
||||
const kasten = el.getBoundingClientRect();
|
||||
if (r.zeigerX > kasten.right - RAND) { delta += SCHRITT; amRand = true; }
|
||||
else if (r.zeigerX < kasten.left + RAND) delta -= SCHRITT;
|
||||
}
|
||||
if (!delta) return;
|
||||
|
||||
const neu = Math.max(60, r.breite + delta);
|
||||
if (neu === r.breite) return;
|
||||
r.breite = neu;
|
||||
setWidths((old) => ({ ...old, [r.key]: Math.round(neu) }));
|
||||
// Mitlaufen, damit der Anfasser unter dem Zeiger bleibt, waehrend die
|
||||
// Tabelle nach rechts waechst.
|
||||
if (amRand && el) requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
|
||||
}
|
||||
|
||||
function onResizeMove(e) {
|
||||
const r = resizing.current; if (!r) return;
|
||||
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
|
||||
setWidths((old) => ({ ...old, [r.key]: w }));
|
||||
r.zeigerX = e.clientX;
|
||||
anwenden();
|
||||
}
|
||||
|
||||
function endResize() {
|
||||
resizing.current = null;
|
||||
if (rollTimer.current) { window.clearInterval(rollTimer.current); rollTimer.current = null; }
|
||||
window.removeEventListener("pointermove", onResizeMove);
|
||||
window.removeEventListener("pointerup", endResize);
|
||||
}
|
||||
|
||||
// Beim Verlassen der Seite mitten im Ziehen bliebe sonst ein Taktgeber laufen.
|
||||
useEffect(() => () => {
|
||||
if (rollTimer.current) window.clearInterval(rollTimer.current);
|
||||
}, []);
|
||||
|
||||
// --- Reorder (HTML5 Drag) ---
|
||||
const dragKey = useRef(null);
|
||||
const [dropKey, setDropKey] = useState(null);
|
||||
@@ -319,7 +446,9 @@ export default function DataTable({
|
||||
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
|
||||
setOrder(columns.map((c) => c.key));
|
||||
setWidths({});
|
||||
setHidden(new Set());
|
||||
// „Zuruecksetzen" heisst Auslieferungszustand, nicht „alles einblenden":
|
||||
// Spalten mit defaultHidden verschwinden wieder.
|
||||
setzeHidden(new Set(columns.filter((c) => c.defaultHidden).map((c) => c.key)));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -329,7 +458,7 @@ export default function DataTable({
|
||||
onClick={() => setFilterShown((v) => !v)}>
|
||||
<Icon name="search" size={14} />Filter
|
||||
</button>
|
||||
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
|
||||
<button type="button" className="btn sm ghost" onClick={() => setzeFilters({})}>Filter löschen</button>
|
||||
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
|
||||
onClick={() => setChooserOpen((o) => !o)}>
|
||||
<Icon name="settings" size={14} />Spalten
|
||||
@@ -385,7 +514,7 @@ export default function DataTable({
|
||||
hasText={Boolean(c.filterText)}
|
||||
open={openFilter === c.key}
|
||||
onToggleOpen={setOpenFilter}
|
||||
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
|
||||
onChange={(f) => setzeFilters((old) => ({ ...old, [c.key]: f }))}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { api, authorizedObjectUrl } from "../api";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "./Icon";
|
||||
import Zubehoer from "./Zubehoer";
|
||||
import { REASONS } from "./ObjektBestand";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { locationPathById } from "../locationPath";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
import { itemDeepLink } from "../qr";
|
||||
import {
|
||||
VerleihAktion,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
VerleihText,
|
||||
verleihRowClass,
|
||||
} from "./Verleih";
|
||||
import {
|
||||
VerwendungAktion,
|
||||
VerwendungHistorie,
|
||||
} from "./Verwendung";
|
||||
|
||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
||||
|
||||
@@ -48,6 +54,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
const [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
||||
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
|
||||
// Aufgeklappte Stücke. Die Zeile zeigt nur, was man im Alltag braucht
|
||||
// (wo liegt es, ist es verliehen) – Kaufdaten und Belege auf Wunsch.
|
||||
const [offen, setOffen] = useState(() => new Set());
|
||||
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
|
||||
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
||||
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
|
||||
@@ -254,6 +263,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
|
||||
const ortName = (id) => locationPathById(id, locations);
|
||||
|
||||
function klappe(id) {
|
||||
setOffen((alt) => {
|
||||
const neu = new Set(alt);
|
||||
if (neu.has(id)) neu.delete(id); else neu.add(id);
|
||||
return neu;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
@@ -271,9 +288,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
|
||||
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
|
||||
<th>Notiz</th><th></th>
|
||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -284,44 +299,15 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||
<td data-label="Lagerort">
|
||||
{isAdmin ? (
|
||||
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
||||
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
|
||||
<option value="">– ohne –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={it.location_id ?? ""} locations={locations}
|
||||
onChange={(id) => patchItem(it, { location_id: id === "" ? null : id })} />
|
||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
||||
<td data-label="Gekauft am">
|
||||
{isAdmin ? (
|
||||
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
|
||||
) : <span>{it.acquired_on || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Garantie bis">
|
||||
{isAdmin ? (
|
||||
<input key={it.warranty_until || "none"} type="date"
|
||||
defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
|
||||
) : <span>{it.warranty_until || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Gekauft bei">
|
||||
{isAdmin ? (
|
||||
<select value={it.shop_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
||||
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– unbekannt –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
) : <span>{it.shop_name || "–"}</span>}
|
||||
</td>
|
||||
<td data-label="Notiz">
|
||||
{isAdmin ? (
|
||||
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" style={{ marginTop: 0, minWidth: 120 }}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
|
||||
) : <span>{it.note || "–"}</span>}
|
||||
</td>
|
||||
{/* cell-row statt field-inline: letzteres bricht in Tabellen um
|
||||
(styles.css) und stellte die Symbole uebereinander. */}
|
||||
<td className="num">
|
||||
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
|
||||
<div className="cell-row" style={{ justifyContent: "flex-end" }}>
|
||||
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
|
||||
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
|
||||
onError={onError} />
|
||||
@@ -337,19 +323,62 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn-icon"
|
||||
title={offen.has(it.id) ? "Details zuklappen" : "Kaufdaten, Preis, Belege, Verleih, Verwendung"}
|
||||
aria-expanded={offen.has(it.id)}
|
||||
onClick={() => klappe(it.id)}>
|
||||
<Icon name="chevron" size={18} className={offen.has(it.id) ? "" : "rot-90"} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{offen.has(it.id) && (
|
||||
<tr className="einzel-detailrow">
|
||||
<td colSpan={9}>
|
||||
<td colSpan={5}>
|
||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
||||
<div className="row" style={{ margin: 0 }}>
|
||||
<label className="grow" style={{ margin: 0 }}>Gekauft am
|
||||
<input type="date" defaultValue={it.acquired_on || ""} disabled={!isAdmin}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
|
||||
</label>
|
||||
<label className="grow" style={{ margin: 0 }}>Garantie bis
|
||||
<input key={it.warranty_until || "none"} type="date" disabled={!isAdmin}
|
||||
defaultValue={it.warranty_until || ""}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
|
||||
</label>
|
||||
</div>
|
||||
<label style={{ margin: 0 }}>Gekauft bei
|
||||
<select value={it.shop_id ?? ""} disabled={!isAdmin}
|
||||
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
||||
<option value="">– unbekannt –</option>
|
||||
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ margin: 0 }}>Notiz
|
||||
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" disabled={!isAdmin}
|
||||
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
|
||||
</label>
|
||||
<div>
|
||||
<span className="muted small">Verleih</span>
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<VerleihHistorie item={it} onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
{it.verwendung_moeglich && (
|
||||
<div>
|
||||
<span className="muted small">Verwendung</span>
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<VerwendungAktion item={it} onChanged={nachAktion}
|
||||
onError={onError} />
|
||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||
<VerwendungHistorie item={it} onError={onError} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Zubehoer item={it} isAdmin={isAdmin}
|
||||
onChanged={nachAktion} onError={onError} />
|
||||
<label style={{ margin: 0 }}>Kaufpreis
|
||||
<div className="field-inline">
|
||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
||||
@@ -428,9 +457,10 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{items.length === 0 && <tr><td colSpan={9} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||
{items.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Einzelstücke.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -456,11 +486,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
||||
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
|
||||
</label>
|
||||
<label className="grow">Lagerort
|
||||
<select value={addForm.location_id}
|
||||
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
|
||||
<option value="">– ohne –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={addForm.location_id} locations={locations}
|
||||
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
|
||||
</label>
|
||||
<label className="grow">Gekauft bei
|
||||
<select value={addForm.shop_id}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
/**
|
||||
* Gespeicherte Zusammenstellungen für den Etikettenexport.
|
||||
*
|
||||
* Ersetzt die beiden früheren Abschnitte mit festen Spalten. Der Gewinn sind
|
||||
* nicht die Vorlagen an sich, sondern die frei wählbaren Spalten: Erst dadurch
|
||||
* kommen eigene Felder wie eine Kabellänge oder die Kapazität einer Powerbank
|
||||
* überhaupt aufs Etikett.
|
||||
*
|
||||
* Die CSV baut der Server – dort liegen die Feldwerte, und dort lässt sich das
|
||||
* Ergebnis prüfen.
|
||||
*/
|
||||
|
||||
const ARTEN = [
|
||||
{ wert: "items", label: "Einzelstücke", hinweis: "Ein Etikett je Stück, mit UID und QR." },
|
||||
{ wert: "products", label: "Artikel", hinweis: "Ein Etikett je Artikel statt je Stück." },
|
||||
{ wert: "locations", label: "Lagerorte", hinweis: "Ein Etikett je Lagerort, mit dessen QR." },
|
||||
];
|
||||
|
||||
const TRENNZEICHEN = [
|
||||
{ wert: ",", label: "Komma" },
|
||||
{ wert: ";", label: "Semikolon" },
|
||||
{ wert: "\t", label: "Tabulator" },
|
||||
];
|
||||
|
||||
export default function EtikettenVorlagen({ onError }) {
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
const [vorlagen, setVorlagen] = useState(null);
|
||||
const [offen, setOffen] = useState(null); // id der bearbeiteten Vorlage
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
try { setVorlagen(await api.etikettenVorlagen()); }
|
||||
catch (err) { onError(err.message); setVorlagen([]); }
|
||||
}, [onError]);
|
||||
|
||||
useEffect(() => { laden(); }, [laden]);
|
||||
|
||||
async function anlegen() {
|
||||
const name = await confirm({
|
||||
title: "Neue Vorlage",
|
||||
message: "Wie soll sie heißen? Zum Beispiel „Kabelexport“.",
|
||||
confirmLabel: "Anlegen",
|
||||
prompt: true,
|
||||
});
|
||||
if (!name || typeof name !== "string") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const neu = await api.etikettenVorlageAnlegen({
|
||||
name: name.trim(),
|
||||
art: "items",
|
||||
spalten: ["qr", "uid", "produkt"],
|
||||
kategorie_ids: [],
|
||||
trennzeichen: ",",
|
||||
});
|
||||
await laden();
|
||||
setOffen(neu.id);
|
||||
toast(`Vorlage „${neu.name}“ angelegt.`);
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function loeschen(v) {
|
||||
const ok = await confirm({
|
||||
title: `Vorlage „${v.name}“ löschen?`,
|
||||
message: "Die hinterlegte P-touch-Datei geht mit verloren.",
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.etikettenVorlageLoeschen(v.id);
|
||||
if (offen === v.id) setOffen(null);
|
||||
await laden();
|
||||
} catch (err) { onError(err.message); }
|
||||
}
|
||||
|
||||
async function csv(v) {
|
||||
setBusy(true);
|
||||
try { await api.etikettenCsv(v.id, v.name); }
|
||||
catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="download" /><h2>Etiketten-Vorlagen</h2>
|
||||
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
|
||||
disabled={busy} onClick={anlegen}>
|
||||
<Icon name="plus" size={14} />Neue Vorlage
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eine Vorlage hält fest, worauf sie sich bezieht, welche Spalten in welcher
|
||||
Reihenfolge exportiert werden und wie gefiltert wird – samt eigener Felder.
|
||||
Dazu lässt sich die P-touch-Datei (.lbx) hinterlegen.
|
||||
</p>
|
||||
|
||||
{vorlagen == null && <div className="muted">Lädt…</div>}
|
||||
{vorlagen && vorlagen.length === 0 && (
|
||||
<p className="muted small">Noch keine Vorlage.</p>
|
||||
)}
|
||||
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-3)" }}>
|
||||
{(vorlagen || []).map((v) => (
|
||||
<li key={v.id} style={{ flexDirection: "column", alignItems: "stretch" }}>
|
||||
<div className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
{/* Die ganze Zeile klappt auf und zu, nicht nur ein kleines
|
||||
Symbol am Rand – und derselbe Griff schliesst wieder. */}
|
||||
<button type="button" className="zeilen-schalter"
|
||||
style={{ flex: 1, minWidth: 0, textAlign: "left" }}
|
||||
aria-expanded={offen === v.id}
|
||||
onClick={() => setOffen(offen === v.id ? null : v.id)}>
|
||||
<Icon name="chevron" size={16}
|
||||
className={offen === v.id ? "" : "rot-90"} />{" "}
|
||||
<span className="strong">{v.name}</span>{" "}
|
||||
<span className="muted small">
|
||||
{ARTEN.find((a) => a.wert === v.art)?.label || v.art}
|
||||
{" · "}{v.spalten.length} Spalten
|
||||
{/* Dass eine Vorlage nur einen Teil ausgibt, soll man sehen,
|
||||
ohne sie aufzuklappen. */}
|
||||
{v.feldfilter?.length ? " · gefiltert" : ""}
|
||||
{v.datei_name ? " · mit Vorlage" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="btn sm" disabled={busy}
|
||||
onClick={() => csv(v)}>
|
||||
<Icon name="download" size={14} />CSV
|
||||
</button>
|
||||
{v.datei_name && (
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={() => api.etikettenDatei(v.id, v.datei_name).catch((e) => onError(e.message))}>
|
||||
<Icon name="download" size={14} />.lbx
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn-icon danger" title="Löschen"
|
||||
onClick={() => loeschen(v)}>
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{offen === v.id && (
|
||||
<VorlageEditor vorlage={v} onGespeichert={laden} onError={onError} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten einer einzelnen Vorlage. */
|
||||
/**
|
||||
* Die Eingabe einer Filterbedingung, passend zur Art des Feldes.
|
||||
*
|
||||
* Ohne das waere jede Bedingung ein nacktes Textfeld: Man muesste wissen, dass
|
||||
* ein Haken intern „true" heisst und wie eine Auswahl genau geschrieben ist.
|
||||
* Ein Vertipper faende dann stillschweigend nichts – der unangenehmste
|
||||
* Fehlschlag, weil die CSV einfach leer herauskommt.
|
||||
*/
|
||||
function FilterEingabe({ feld, bedingung, onAendern }) {
|
||||
const typ = feld?.typ || "text";
|
||||
|
||||
if (typ === "boolean") {
|
||||
return (
|
||||
<label className="check-inline" style={{ margin: 0 }}>
|
||||
<input type="checkbox" checked={bedingung.wert === "true"}
|
||||
onChange={(e) => onAendern({ op: "=", wert: e.target.checked ? "true" : "false" })} />
|
||||
<span className="muted small">{bedingung.wert === "true" ? "ja" : "nein"}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (typ === "select") {
|
||||
return (
|
||||
<>
|
||||
<select value={bedingung.op} style={{ width: 110 }}
|
||||
onChange={(e) => onAendern({ op: e.target.value })}>
|
||||
<option value="=">ist gleich</option>
|
||||
<option value="!=">ist nicht gleich</option>
|
||||
</select>
|
||||
<select value={bedingung.wert} style={{ width: 120 }}
|
||||
onChange={(e) => onAendern({ wert: e.target.value })}>
|
||||
{(feld.optionen || []).map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (typ === "number") {
|
||||
return (
|
||||
<>
|
||||
<select value={bedingung.op} style={{ width: 110 }}
|
||||
onChange={(e) => onAendern({ op: e.target.value })}>
|
||||
<option value=">=">ist größer =</option>
|
||||
<option value="=">ist =</option>
|
||||
<option value="<=">ist kleiner =</option>
|
||||
</select>
|
||||
<input value={bedingung.wert} inputMode="decimal" style={{ width: 90 }}
|
||||
onChange={(e) => onAendern({ wert: e.target.value })} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Text, mehrzeiliger Text und Datum: freie Eingabe, leer = ohne Wert.
|
||||
return (
|
||||
<input value={bedingung.wert} placeholder="ohne Wert" style={{ width: 120 }}
|
||||
onChange={(e) => onAendern({ wert: e.target.value })} />
|
||||
);
|
||||
}
|
||||
|
||||
function VorlageEditor({ vorlage, onGespeichert, onError }) {
|
||||
const toast = useToast();
|
||||
const [art, setArt] = useState(vorlage.art);
|
||||
const [spalten, setSpalten] = useState(vorlage.spalten);
|
||||
const [katIds, setKatIds] = useState(vorlage.kategorie_ids);
|
||||
const [trenn, setTrenn] = useState(vorlage.trennzeichen);
|
||||
const [feldfilter, setFeldfilter] = useState(vorlage.feldfilter || []);
|
||||
const [moeglich, setMoeglich] = useState([]);
|
||||
const [kategorien, setKategorien] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.listCategories().then(setKategorien).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Welche Spalten es gibt, hängt von Bezugsart UND Kategorien ab – die eigenen
|
||||
// Felder kommen schließlich von dort.
|
||||
useEffect(() => {
|
||||
let aktiv = true;
|
||||
api.etikettenSpalten(art, katIds)
|
||||
.then((s) => { if (aktiv) setMoeglich(s); })
|
||||
.catch((err) => onError(err.message));
|
||||
return () => { aktiv = false; };
|
||||
}, [art, katIds, onError]);
|
||||
|
||||
const titelVon = (schluessel) =>
|
||||
moeglich.find((m) => m.schluessel === schluessel)?.titel || schluessel;
|
||||
const offen = moeglich.filter((m) => !spalten.includes(m.schluessel));
|
||||
|
||||
function verschieben(index, richtung) {
|
||||
const ziel = index + richtung;
|
||||
if (ziel < 0 || ziel >= spalten.length) return;
|
||||
const kopie = [...spalten];
|
||||
[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]];
|
||||
setSpalten(kopie);
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.etikettenVorlageAendern(vorlage.id, {
|
||||
art, spalten, kategorie_ids: katIds, trennzeichen: trenn, feldfilter,
|
||||
});
|
||||
await onGespeichert();
|
||||
toast("Vorlage gespeichert.");
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function dateiWaehlen(e) {
|
||||
const datei = e.target.files?.[0];
|
||||
e.target.value = ""; // damit dieselbe Datei erneut gewählt werden kann
|
||||
if (!datei) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.etikettenDateiHochladen(vorlage.id, datei);
|
||||
await onGespeichert();
|
||||
toast("P-touch-Vorlage hinterlegt.");
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Einzelstuecke gibt es nur bei Gegenstaenden; Artikel kann es in beiden
|
||||
// Welten geben, dort also nicht einschraenken.
|
||||
const baum = asTree(kategorien).filter(
|
||||
(c) => art === "products" || c.tracking === "object",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div className="row">
|
||||
<label className="grow" style={{ maxWidth: 260 }}>
|
||||
Bezugsart
|
||||
<select value={art} onChange={(e) => { setArt(e.target.value); setSpalten([]); }}>
|
||||
{ARTEN.map((a) => <option key={a.wert} value={a.wert}>{a.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ maxWidth: 200 }}>
|
||||
Trennzeichen
|
||||
<select value={trenn} onChange={(e) => setTrenn(e.target.value)}>
|
||||
{TRENNZEICHEN.map((t) => (
|
||||
<option key={t.wert} value={t.wert}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small mt-0">{ARTEN.find((a) => a.wert === art)?.hinweis}</p>
|
||||
|
||||
{art !== "locations" && (
|
||||
<>
|
||||
<span className="muted small">Kategorien (leer = alle)</span>
|
||||
<div className="kat-check-liste" style={{ marginTop: "var(--sp-1)" }}>
|
||||
{baum.map((c) => (
|
||||
<label key={c.id} className="check-inline"
|
||||
style={{ margin: 0, paddingLeft: (c.depth || 0) * 16 }}>
|
||||
<input type="checkbox" checked={katIds.includes(c.id)}
|
||||
onChange={(e) => setKatIds(e.target.checked
|
||||
? [...katIds, c.id]
|
||||
: katIds.filter((k) => k !== c.id))} />
|
||||
<span>{c.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eigene Felder hängen am Artikel, nicht am einzelnen Stück – alle Stücke
|
||||
eines Artikels tragen denselben Wert.
|
||||
</p>
|
||||
|
||||
<span className="muted small" style={{ display: "block", marginTop: "var(--sp-3)" }}>
|
||||
Nur Zeilen mit bestimmten Feldwerten (alle Bedingungen müssen zutreffen)
|
||||
</span>
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{feldfilter.map((f, i) => {
|
||||
const feld = moeglich.find((m) => m.schluessel === `feld:${f.feld_id}`);
|
||||
const aendern = (teil) => setFeldfilter(feldfilter.map((x, j) =>
|
||||
(j === i ? { ...x, ...teil } : x)));
|
||||
return (
|
||||
<li key={f.feld_id}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(`feld:${f.feld_id}`)}</span>
|
||||
<FilterEingabe feld={feld} bedingung={f} onAendern={aendern} />
|
||||
<button type="button" className="btn-icon danger" title="Bedingung entfernen"
|
||||
onClick={() => setFeldfilter(feldfilter.filter((_, j) => j !== i))}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<select value="" style={{ marginTop: "var(--sp-1)" }}
|
||||
onChange={(e) => {
|
||||
const id = Number(e.target.value.slice("feld:".length));
|
||||
if (!id) return;
|
||||
// Ein Feld nur einmal: zwei Bedingungen darauf koennten sich nie
|
||||
// beide erfuellen.
|
||||
const feld = moeglich.find((m) => m.schluessel === e.target.value);
|
||||
// Ja/Nein startet als gesetzter Haken, eine Auswahl auf ihrem
|
||||
// ersten Wert - so steht nie eine Bedingung da, die nichts trifft.
|
||||
const start = feld?.typ === "boolean" ? "true"
|
||||
: feld?.typ === "select" ? (feld.optionen?.[0] || "") : "";
|
||||
setFeldfilter([...feldfilter.filter((x) => x.feld_id !== id),
|
||||
{ feld_id: id, op: "=", wert: start }]);
|
||||
}}>
|
||||
<option value="">+ Bedingung auf einem eigenen Feld …</option>
|
||||
{moeglich.filter((m) => m.schluessel.startsWith("feld:")).map((m) => (
|
||||
<option key={m.schluessel} value={m.schluessel}>{m.titel}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="muted small">
|
||||
Bei Textfeldern genau so eingeben, wie es im Feld steht;{" "}
|
||||
<strong>leer heißt dort: ohne Wert</strong> – also alle, bei denen
|
||||
das Feld nicht ausgefüllt ist. Größer/kleiner vergleicht als Zahl;
|
||||
Zeilen ohne Wert treffen dabei nie.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid-2" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<div>
|
||||
<span className="muted small">Spalten (in dieser Reihenfolge)</span>
|
||||
{spalten.length === 0 && (
|
||||
<p className="muted small">Noch keine gewählt – es greifen dann die Standardspalten.</p>
|
||||
)}
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{spalten.map((s, i) => (
|
||||
<li key={s}>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(s)}</span>
|
||||
<button type="button" className="btn-icon" title="Nach oben"
|
||||
disabled={i === 0} onClick={() => verschieben(i, -1)}>↑</button>
|
||||
<button type="button" className="btn-icon" title="Nach unten"
|
||||
disabled={i === spalten.length - 1} onClick={() => verschieben(i, 1)}>↓</button>
|
||||
<button type="button" className="btn-icon danger" title="Entfernen"
|
||||
onClick={() => setSpalten(spalten.filter((x) => x !== s))}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted small">Verfügbar</span>
|
||||
{offen.length === 0 && <p className="muted small">Alle Spalten sind gewählt.</p>}
|
||||
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
|
||||
{offen.map((m) => (
|
||||
<li key={m.schluessel}>
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={() => setSpalten([...spalten, m.schluessel])}>
|
||||
<Icon name="plus" size={14} />{m.titel}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: "var(--sp-3)", alignItems: "center" }}>
|
||||
<button type="button" className="btn primary" disabled={busy} onClick={speichern}>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</button>
|
||||
<label style={{ margin: 0 }}>
|
||||
<span className="muted small">P-touch-Vorlage (.lbx)</span>
|
||||
<input type="file" accept=".lbx" onChange={dateiWaehlen} disabled={busy} />
|
||||
</label>
|
||||
{vorlage.datei_name && (
|
||||
<button type="button" className="btn sm ghost"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.etikettenDateiEntfernen(vorlage.id);
|
||||
await onGespeichert();
|
||||
} catch (err) { onError(err.message); }
|
||||
}}>
|
||||
<Icon name="trash" size={14} />{vorlage.datei_name} entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Eine .lbx merkt sich den Pfad zu ihrer Datenquelle. Auf einem fremden
|
||||
Rechner muss die CSV in P-touch einmal neu verknüpft werden – die Datei
|
||||
dabeizuhaben erspart trotzdem das Suchen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,8 @@ const PATHS = {
|
||||
</>
|
||||
),
|
||||
plus: <path d="M5 12h14M12 5v14" />,
|
||||
// Aufklappen. Zeigt nach unten; zugeklappt dreht die Zelle es nach rechts.
|
||||
chevron: <path d="m6 9 6 6 6-6" />,
|
||||
trash: (
|
||||
<>
|
||||
<path d="M3 6h18" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import Icon from "./Icon";
|
||||
import { locationOptions } from "../locationPath";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
|
||||
// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell
|
||||
// ist er location_id = null; im <select> braucht es einen Wert, deshalb hier ein
|
||||
@@ -70,9 +71,9 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
||||
{rows.map((r, i) => (
|
||||
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
||||
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
|
||||
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
|
||||
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={r.location_id} locations={locations}
|
||||
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
|
||||
onChange={(id) => setRow(i, { location_id: id })} />
|
||||
</label>
|
||||
<label style={{ margin: 0, width: 130 }}>
|
||||
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import CategorySelect from "./CategorySelect";
|
||||
import { asTree } from "../categoryTree";
|
||||
|
||||
/**
|
||||
* Lagerort-Auswahl als aufklappbarer Baum – dieselbe Darstellung wie bei den
|
||||
* Kategorien, denn Lagerorte sind genauso verschachtelt (Hedingen → Kallax →
|
||||
* Fach Oben Links → Oben). Vorher stand überall ein flaches <select>, in dem
|
||||
* jede Zeile den ganzen Pfad ausschrieb; Geschwister standen dadurch nur
|
||||
* zufällig beieinander.
|
||||
*
|
||||
* `CategorySelect` kann beides – seine IDs dürfen Zeichenketten sein, und
|
||||
* Lagerort-Codes sind welche. Hier kommt nur der Baumaufbau dazu und die
|
||||
* Umrechnung "" ↔ null, damit die Aufrufstellen weiter mit dem leeren String
|
||||
* für „kein Ort" arbeiten wie beim <select> zuvor.
|
||||
*/
|
||||
export default function LocationSelect({
|
||||
value,
|
||||
onChange,
|
||||
locations,
|
||||
rootLabel = "– ohne –",
|
||||
extraOptions = [],
|
||||
showRoot = true,
|
||||
disabled = false,
|
||||
title,
|
||||
}) {
|
||||
return (
|
||||
<CategorySelect
|
||||
value={value === "" || value == null ? null : value}
|
||||
onChange={(id) => onChange(id == null ? "" : id)}
|
||||
// Nach Namen sortieren, bevor der Baum entsteht: Der Server sortiert
|
||||
// zeichenweise, dort stuende „Fach oben Rechts" hinter „Fach Unten
|
||||
// Rechts". asTree uebernimmt die Reihenfolge der Geschwister unveraendert.
|
||||
nodes={asTree([...(locations || [])].sort((a, b) => a.name.localeCompare(b.name, "de")))}
|
||||
rootLabel={rootLabel}
|
||||
extraOptions={extraOptions}
|
||||
showRoot={showRoot}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NAV_AUSBLENDBAR } from "../nav";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
/**
|
||||
* Häkchenliste für die Seitenleiste – an zwei Stellen benutzt: in den
|
||||
* Einstellungen für die Vorgabe des Administrators, auf der Kontoseite für die
|
||||
* eigene Auswahl.
|
||||
*
|
||||
* Angehakt heisst **sichtbar**. Das ist herum, wie man es erwartet, auch wenn
|
||||
* der Server das Gegenteil speichert (die Liste der versteckten Pfade).
|
||||
*
|
||||
* `gesperrt` sind Einträge, die der Administrator schon ausgeblendet hat: Sie
|
||||
* stehen abgehakt und unveränderlich da, denn die eigene Auswahl addiert nur –
|
||||
* sie nimmt nichts weg.
|
||||
*/
|
||||
export default function NavigationAuswahl({ versteckt, gesperrt = [], onChange }) {
|
||||
const { isAdmin } = useAuth();
|
||||
const aus = new Set(versteckt);
|
||||
const fest = new Set(gesperrt);
|
||||
// Stammdaten und System sieht ohnehin nur ein Administrator.
|
||||
const eintraege = NAV_AUSBLENDBAR.filter(
|
||||
(e) => isAdmin || !["stammdaten", "system"].includes(e.gruppe),
|
||||
);
|
||||
|
||||
function umschalten(to, sichtbar) {
|
||||
const neu = new Set(aus);
|
||||
if (sichtbar) neu.delete(to);
|
||||
else neu.add(to);
|
||||
onChange([...neu]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nav-auswahl">
|
||||
{eintraege.map((e) => {
|
||||
const blockiert = fest.has(e.to);
|
||||
return (
|
||||
<label key={e.to} className="check-inline"
|
||||
title={blockiert ? "Vom Administrator ausgeblendet" : undefined}>
|
||||
<input type="checkbox" disabled={blockiert}
|
||||
checked={!blockiert && !aus.has(e.to)}
|
||||
onChange={(ev) => umschalten(e.to, ev.target.checked)} />
|
||||
<span className={blockiert ? "muted" : ""}>{e.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "./Icon";
|
||||
import { fmt } from "../units";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { locationPathById } from "../locationPath";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
|
||||
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
|
||||
export const REASONS = [
|
||||
@@ -182,11 +183,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
||||
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
|
||||
<label className="grow">
|
||||
Lagerort
|
||||
<select value={addForm.location_id}
|
||||
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
|
||||
<option value="">– ohne Lagerort –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={addForm.location_id} locations={locations}
|
||||
rootLabel="– ohne Lagerort –"
|
||||
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
|
||||
</label>
|
||||
<label style={{ width: 120 }}>
|
||||
Menge
|
||||
@@ -208,16 +207,14 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">Von
|
||||
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
|
||||
<option value="">– ohne Lagerort –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={move.from} locations={locations}
|
||||
rootLabel="– ohne Lagerort –"
|
||||
onChange={(id) => setMove({ ...move, from: id })} />
|
||||
</label>
|
||||
<label className="grow">Nach
|
||||
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
|
||||
<option value="">– ohne Lagerort –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={move.to} locations={locations}
|
||||
rootLabel="– ohne Lagerort –"
|
||||
onChange={(id) => setMove({ ...move, to: id })} />
|
||||
</label>
|
||||
<label style={{ width: 110 }}>Menge
|
||||
<input type="number" step="any" min="0" value={move.quantity}
|
||||
@@ -237,11 +234,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
||||
</div>
|
||||
<div className="row">
|
||||
<label className="grow">Lagerort
|
||||
<select value={remove.location_id}
|
||||
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
|
||||
<option value="">– ohne Lagerort –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={remove.location_id} locations={locations}
|
||||
rootLabel="– ohne Lagerort –"
|
||||
onChange={(id) => setRemove({ ...remove, location_id: id })} />
|
||||
</label>
|
||||
<label style={{ width: 110 }}>Menge
|
||||
<input type="number" step="any" min="0" value={remove.quantity}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { fmt } from "../units";
|
||||
import { locationOptions, locationPathById } from "../locationPath";
|
||||
import { locationPathById } from "../locationPath";
|
||||
import LocationSelect from "./LocationSelect";
|
||||
|
||||
/**
|
||||
* Charge aufteilen: einen Teil der Menge (in Artikeleinheiten – Packungen/Stück/g)
|
||||
@@ -55,10 +56,8 @@ export default function SplitLotDialog({ lot, factor, labelFor, locations, onClo
|
||||
</label>
|
||||
<label>
|
||||
nach Lagerort
|
||||
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
|
||||
<option value="">– ohne Lagerort –</option>
|
||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<LocationSelect value={locId} locations={locations}
|
||||
rootLabel="– ohne Lagerort –" onChange={setLocId} />
|
||||
</label>
|
||||
{rest != null && !zielGleich && (
|
||||
<p className="muted small mt-0">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { buildUnitOptions, fmt, gebinde, unitShort } from "../units";
|
||||
|
||||
/**
|
||||
* Teilmenge aus einer Charge verbrauchen – „ich habe 100 ml von dieser Milch
|
||||
* genommen", „2 von 6 Brötchen gegessen", „2 EL Mehl".
|
||||
*
|
||||
* Eingetippt wird die **verbrauchte** Menge, nicht der Rest: Das ist die Zahl,
|
||||
* die man kennt. War die Charge noch zu, wird sie dabei angebrochen — eine
|
||||
* Packung spaltet sich ab, bekommt ihr „zu verbrauchen bis" und der Rest bleibt
|
||||
* unangetastet mit seinem eigenen MHD. Die Rückfrage dazu steht unten und ist
|
||||
* abwählbar, falls man nur eine Buchung nachtragen will.
|
||||
*
|
||||
* Die Einheitenauswahl holt neben den verwalteten Einheiten auch die Hausmaße
|
||||
* der Kategorie — „EL" taucht nur auf, wenn dafür eine Menge hinterlegt ist,
|
||||
* denn ein Esslöffel Mehl und ein Esslöffel Zucker sind verschieden schwer.
|
||||
*/
|
||||
export default function VerbrauchDialog({ lot, onClose, onDone, onError }) {
|
||||
const [produkt, setProdukt] = useState(null);
|
||||
const [einheiten, setEinheiten] = useState([]);
|
||||
const [masse, setMasse] = useState([]);
|
||||
const [menge, setMenge] = useState("");
|
||||
const [einheit, setEinheit] = useState("");
|
||||
const [notiz, setNotiz] = useState("");
|
||||
const [anbrechen, setAnbrechen] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [laedt, setLaedt] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let abgebrochen = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [p, us, ms] = await Promise.all([
|
||||
api.getProduct(lot.product_id),
|
||||
api.listUnits(),
|
||||
api.productMeasures(lot.product_id),
|
||||
]);
|
||||
if (abgebrochen) return;
|
||||
setProdukt(p);
|
||||
setEinheiten(buildUnitOptions(p, us));
|
||||
setMasse(ms);
|
||||
// Vorbelegung: die Basiseinheit. Beim Verbrauchen ist das die
|
||||
// naheliegende Angabe („100 ml"), nicht die ganze Packung.
|
||||
setEinheit(unitStandard(p, us));
|
||||
} catch (err) {
|
||||
onError?.(err.message);
|
||||
onClose();
|
||||
} finally {
|
||||
if (!abgebrochen) setLaedt(false);
|
||||
}
|
||||
})();
|
||||
return () => { abgebrochen = true; };
|
||||
}, [lot.product_id]);
|
||||
|
||||
const zahl = Number(String(menge).trim().replace(",", "."));
|
||||
const gueltig = menge !== "" && Number.isFinite(zahl) && zahl > 0;
|
||||
const offen = !!lot.opened_on;
|
||||
|
||||
async function submit() {
|
||||
if (!gueltig || busy || !einheit) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const antwort = await api.consumeLot(lot.id, {
|
||||
quantity: zahl,
|
||||
unit: einheit,
|
||||
note: notiz.trim() || null,
|
||||
auto_open: offen ? false : anbrechen,
|
||||
});
|
||||
onClose();
|
||||
await onDone(antwort);
|
||||
} catch (err) {
|
||||
onError?.(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const basis = unitShort(lot.base_unit);
|
||||
const restText = lot.package_size > 0
|
||||
? `${fmt(lot.quantity / lot.package_size)} ${gebinde(lot.quantity / lot.package_size, lot.package_label)} (${fmt(lot.quantity)} ${basis})`
|
||||
: `${fmt(lot.quantity)} ${basis}`;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" role="dialog" aria-modal="true">
|
||||
<div className="modal-head"><Icon name="checkout" size={18} /><h2>Verbrauchen</h2></div>
|
||||
<p className="modal-body mt-0">
|
||||
{lot.product_name}: aktuell {restText}
|
||||
{offen && lot.opened_quantity
|
||||
? ` — angebrochen, ursprünglich ${fmt(lot.opened_quantity)} ${basis}.`
|
||||
: "."}
|
||||
</p>
|
||||
|
||||
{laedt && <p className="modal-body muted small">Einheiten werden geladen…</p>}
|
||||
|
||||
{!laedt && (
|
||||
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
|
||||
<div className="field-inline" style={{ marginBottom: 0 }}>
|
||||
<label style={{ margin: 0 }}>
|
||||
Verbrauchte Menge
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input type="number" step="any" min="0" value={menge} autoFocus
|
||||
onChange={(e) => setMenge(e.target.value)} placeholder="z.B. 100" />
|
||||
</label>
|
||||
<label style={{ margin: 0 }}>
|
||||
Einheit
|
||||
<select value={einheit} onChange={(e) => setEinheit(e.target.value)}>
|
||||
{einheiten.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
{masse.length > 0 && (
|
||||
<optgroup label="Hausmaße">
|
||||
{masse.map((m) => (
|
||||
<option key={`m${m.measure_id}`} value={m.name}>
|
||||
{m.short ? `${m.name} (${m.short})` : m.name}
|
||||
{` ≈ ${fmt(m.amount)} ${unitShort(m.base)}`}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{masse.length === 0 && produkt && (
|
||||
<p className="muted small mt-0">
|
||||
Für die Kategorie dieses Artikels sind keine Hausmaße hinterlegt –
|
||||
„2 EL" lässt sich deshalb noch nicht buchen. Mengen dafür stehen
|
||||
unter Stammdaten ▸ Maße.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!offen && (
|
||||
<label className="check-inline" style={{ margin: 0 }}>
|
||||
<input type="checkbox" checked={anbrechen}
|
||||
onChange={(e) => setAnbrechen(e.target.checked)} />
|
||||
<span>
|
||||
Packung als angebrochen führen
|
||||
<span className="muted small" style={{ display: "block" }}>
|
||||
Eine Packung wird abgespalten und bekommt ein „zu verbrauchen
|
||||
bis"; der Rest behält sein MHD. Ohne Haken wird nur die Menge
|
||||
abgebucht.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label style={{ margin: 0 }}>
|
||||
Notiz (freiwillig)
|
||||
<input value={notiz} maxLength={255} placeholder="z.B. für Pfannkuchen"
|
||||
onChange={(e) => setNotiz(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-pair">
|
||||
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
|
||||
<button type="button" className="btn primary" disabled={!gueltig || busy || laedt}
|
||||
onClick={submit}>
|
||||
<Icon name="checkout" size={16} />Verbrauchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Basiseinheit des Artikels als Vorauswahl – beim Verbrauchen zählt die kleine
|
||||
// Menge, nicht das Gebinde.
|
||||
function unitStandard(produkt, units) {
|
||||
const basis = (units || []).find(
|
||||
(u) => u.factor === 1 && u.kind === produkt.kind,
|
||||
);
|
||||
return basis ? basis.name : (produkt.package_size ? "package" : "");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useToast } from "../toast";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { daysUntil, relativeExpiry } from "../units";
|
||||
|
||||
/**
|
||||
@@ -18,8 +19,10 @@ export function FristBadge({ loan, warnTage = 7 }) {
|
||||
if (!loan || !loan.due_on) return null;
|
||||
const tage = daysUntil(loan.due_on);
|
||||
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
|
||||
// Bei Ueberfaelligkeit die Tage ausschreiben: „vor 1 Woche" (relativeExpiry)
|
||||
// liest sich als Zeitpunkt, gemeint ist aber eine Dauer.
|
||||
const text = loan.days_overdue
|
||||
? `überfällig (${relativeExpiry(-loan.days_overdue)})`
|
||||
? `${loan.days_overdue} ${loan.days_overdue === 1 ? "Tag" : "Tage"} überfällig`
|
||||
: `zurück ${relativeExpiry(tage)}`;
|
||||
return art
|
||||
? <span className={`badge ${art}`}>{text}</span>
|
||||
@@ -56,6 +59,14 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(LEER);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Zubehör geht nur mit, wenn es angehakt ist. Vorausgewählt sind die Teile,
|
||||
// die gerade im Haus sind – ein schon anderweitig verliehenes kann nicht mit.
|
||||
const verfuegbaresZubehoer = (item.zubehoer || []).filter((t) => !t.verliehen);
|
||||
const [mit, setMit] = useState(() => verfuegbaresZubehoer.map((t) => t.id));
|
||||
|
||||
function umschalten(id) {
|
||||
setMit((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
|
||||
}
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
@@ -66,6 +77,7 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
borrower: form.borrower.trim(),
|
||||
reason: form.reason.trim() || null,
|
||||
due_on: form.due_on || null,
|
||||
mit_zubehoer: mit,
|
||||
});
|
||||
setForm(LEER);
|
||||
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
|
||||
@@ -98,6 +110,19 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
{verfuegbaresZubehoer.length > 0 && (
|
||||
<fieldset className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
|
||||
<legend className="small strong">Zubehör mitgeben?</legend>
|
||||
{verfuegbaresZubehoer.map((t) => (
|
||||
<label key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
|
||||
<input type="checkbox" checked={mit.includes(t.id)}
|
||||
onChange={() => umschalten(t.id)} />
|
||||
<span>{t.product_name || "Einzelstück"}</span>
|
||||
<span className="muted small">{t.uid}</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<p className="muted small">
|
||||
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
|
||||
verliehen zählt es aber nicht mehr zum Bestand.
|
||||
@@ -115,29 +140,45 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
||||
*/
|
||||
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function zurueck() {
|
||||
// Mitverliehenes Zubehör kommt zusammen mit dem Gerät zurück – sonst bliebe
|
||||
// das Netzteil ewig „verliehen“, weil niemand mehr danach sucht.
|
||||
const teile = (item.zubehoer || []).filter((t) => t.verliehen);
|
||||
const ok = await confirm({
|
||||
title: `„${item.uid}“ zurücknehmen?`,
|
||||
message: teile.length
|
||||
? `Von ${item.loan.borrower}. Das mitverliehene Zubehör kommt mit zurück: `
|
||||
+ teile.map((t) => t.uid).join(", ") + "."
|
||||
: `Von ${item.loan.borrower}.`,
|
||||
confirmLabel: "Zurücknehmen",
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.returnItem(item.id);
|
||||
await api.returnItem(item.id, { mit_zubehoer: teile.map((t) => t.id) });
|
||||
toast(`${item.uid} ist zurück.`);
|
||||
if (onChanged) await onChanged();
|
||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Bewusst mit Text statt als nacktes Symbol: In einer breiten Tabelle war der
|
||||
// Knopf sonst ein unbeschriftetes Bildchen ganz rechts, und das title-Attribut
|
||||
// erreicht auf einem Touchgerät niemanden.
|
||||
if (item.loan) {
|
||||
return (
|
||||
<button type="button" className="btn-icon" disabled={busy}
|
||||
title={`Zurücknehmen (von ${item.loan.borrower})`} onClick={zurueck}>
|
||||
<Icon name="checkin" size={size} />
|
||||
<button type="button" className="btn sm" disabled={busy} onClick={zurueck}
|
||||
aria-label={`„${item.uid}“ von ${item.loan.borrower} zurücknehmen`}>
|
||||
<Icon name="checkin" size={size} />{busy ? "Moment…" : "Zurücknehmen"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className="btn-icon" title="Verleihen"
|
||||
onClick={() => onLend(item)}>
|
||||
<Icon name="loan" size={size} />
|
||||
<button type="button" className="btn sm" onClick={() => onLend(item)}
|
||||
aria-label={`„${item.uid}“ verleihen`}>
|
||||
<Icon name="loan" size={size} />Verleihen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "./Icon";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
/**
|
||||
* Verwendungsstatus eines Einzelstücks – Anzeige und Umstellen, an drei Stellen
|
||||
* benutzt: in der Einzelstück-Tabelle eines Artikels, in der Liste aller
|
||||
* Einzelstücke und auf der Scan-Zielseite (/i/<uid>).
|
||||
*
|
||||
* Woran ist das Stück gerade? Der Lagerort sagt nur, wohin es gehört. Gedacht
|
||||
* für eine Garderobe mit eingenähten NFC-Tags, aber bewusst allgemein: ein Auto
|
||||
* in der Waschanlage ist genauso „in Reinigung".
|
||||
*
|
||||
* Erscheint nur bei Kategorien, die den Status führen (`verwendung_moeglich`).
|
||||
* „Verfügbar" ist kein gespeicherter Wert, sondern das Fehlen eines laufenden
|
||||
* Zeitraums – deshalb ist der Status hier überall `null`.
|
||||
*/
|
||||
|
||||
/** Die vier Zustände in der Reihenfolge, in der sie als Knöpfe erscheinen. */
|
||||
export const ZUSTAENDE = [
|
||||
{ status: null, label: "Verfügbar" },
|
||||
{ status: "in_use", label: "In Verwendung" },
|
||||
{ status: "cleaning", label: "In Reinigung" },
|
||||
{ status: "repair", label: "In Reparatur" },
|
||||
];
|
||||
|
||||
const LABEL = { in_use: "In Verwendung", cleaning: "In Reinigung", repair: "In Reparatur" };
|
||||
// Rot = da muss jemand ran, Gelb = ist unterwegs, schlicht = in Benutzung.
|
||||
const ART = { cleaning: "warn", repair: "danger" };
|
||||
|
||||
export function verwendungLabel(status) {
|
||||
return LABEL[status] || "Verfügbar";
|
||||
}
|
||||
|
||||
/** Zeitpunkt kurz: „14.03.2026, 18:42". Anders als bei Daten steht hier ein
|
||||
* vollständiger Zeitstempel – ein Hemd wechselt mehrmals am Tag. */
|
||||
function zeitpunkt(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** „seit heute" / „seit 3 Tagen" – die Frage im Alltag ist die Dauer. */
|
||||
function seit(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||
if (tage <= 0) return "seit heute";
|
||||
return `seit ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
|
||||
}
|
||||
|
||||
/** Der aktuelle Zustand als Plakette – für Tabellenspalten. */
|
||||
export function VerwendungBadge({ item }) {
|
||||
if (!item?.verwendung_moeglich) return <span className="muted">–</span>;
|
||||
const status = item.verwendung?.status || null;
|
||||
if (!status) return <span className="muted">Verfügbar</span>;
|
||||
const art = ART[status];
|
||||
return (
|
||||
<span className={art ? `badge ${art}` : "badge"} title={seit(item.verwendung.started_at)}>
|
||||
{verwendungLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die vier Knöpfe, der aktuelle hervorgehoben. Ein Griff genügt – gedacht für
|
||||
* „gerade aus der Wäsche geholt, ist wieder im Schrank" direkt nach dem Scannen.
|
||||
*/
|
||||
export function VerwendungAktion({ item, onChanged, onError, size = "sm" }) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!item?.verwendung_moeglich) return null;
|
||||
const aktuell = item.verwendung?.status || null;
|
||||
|
||||
async function setze(status) {
|
||||
if (status === aktuell) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setzeVerwendung(item.id, status);
|
||||
toast(`${item.uid}: ${verwendungLabel(status)}`);
|
||||
await onChanged?.();
|
||||
} catch (err) { onError?.(err.message); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
|
||||
{ZUSTAENDE.map(({ status, label }) => (
|
||||
<button key={status || "frei"} type="button" disabled={busy}
|
||||
className={`btn ${size} ${status === aktuell ? "primary" : "ghost"}`}
|
||||
aria-pressed={status === aktuell}
|
||||
onClick={() => setze(status)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verwendungs-Verlauf eines Stücks (neueste zuerst), bei Bedarf nachgeladen. */
|
||||
export function VerwendungHistorie({ item, onError }) {
|
||||
const [zeilen, setZeilen] = useState(null);
|
||||
const [offen, setOffen] = useState(false);
|
||||
if (!item?.verwendung_moeglich) return null;
|
||||
|
||||
async function laden() {
|
||||
setOffen(true);
|
||||
try { setZeilen(await api.itemVerwendungen(item.id)); }
|
||||
catch (err) { onError?.(err.message); }
|
||||
}
|
||||
|
||||
if (!offen) {
|
||||
return (
|
||||
<button type="button" className="btn sm ghost" onClick={laden}>
|
||||
<Icon name="history" size={14} />Verwendungs-Verlauf
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (zeilen == null) return <span className="muted small">Wird geladen…</span>;
|
||||
if (zeilen.length === 0) {
|
||||
return <span className="muted small">War noch nie in Verwendung.</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="clean-list">
|
||||
{zeilen.map((v) => (
|
||||
<li key={v.id} className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
|
||||
<span className="strong">{verwendungLabel(v.status)}</span>
|
||||
<span className="muted small">
|
||||
{zeitpunkt(v.started_at)}
|
||||
{v.ended_at ? ` – ${zeitpunkt(v.ended_at)}` : " – läuft"}
|
||||
{v.username ? ` · ${v.username}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user