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
|
- **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst
|
||||||
gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt
|
gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt
|
||||||
daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional).
|
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
|
### In der iOS-App zusätzlich
|
||||||
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
|
||||||
|
|||||||
+11
-1
@@ -12,7 +12,10 @@ class Settings(BaseSettings):
|
|||||||
# Auth / JWT
|
# Auth / JWT
|
||||||
jwt_secret: str = "change-me-in-production"
|
jwt_secret: str = "change-me-in-production"
|
||||||
jwt_algorithm: str = "HS256"
|
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
|
# First admin, created on startup if no users exist
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
@@ -32,6 +35,13 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Behaviour
|
# Behaviour
|
||||||
expiry_warning_days_default: int = 7
|
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 (comma separated); "*" allows all
|
||||||
cors_origins: str = "*"
|
cors_origins: str = "*"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .services.conversion import (
|
|||||||
zweit_faktor,
|
zweit_faktor,
|
||||||
)
|
)
|
||||||
from .services.loans import nicht_verliehen
|
from .services.loans import nicht_verliehen
|
||||||
|
from .services.vererbung import frist_herkunft
|
||||||
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||||||
from .services.stock import current_stock, location_subtree_stock_base
|
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:
|
def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||||
out = ProductOut.model_validate(product)
|
out = ProductOut.model_validate(product)
|
||||||
out.stock = current_stock(db, product.id)
|
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 = (
|
out.expired_count = (
|
||||||
db.query(Lot)
|
db.query(Lot)
|
||||||
.filter(
|
.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.
|
"""Loest die Sitzung auf – bevorzugt ueber die Benutzer-ID.
|
||||||
|
|
||||||
Der Benutzername ist Anzeige und darf sich aendern; die ID nicht. Deshalb
|
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."""
|
"""Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token."""
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
user = _nutzer_aus_payload(db, payload)
|
user = nutzer_aus_payload(db, payload)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise _credentials_exc
|
raise _credentials_exc
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -12,25 +12,34 @@ from .routers import (
|
|||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
etiketten,
|
||||||
field_definitions,
|
field_definitions,
|
||||||
groups,
|
groups,
|
||||||
items,
|
items,
|
||||||
loans,
|
loans,
|
||||||
locations,
|
locations,
|
||||||
maintenance,
|
maintenance,
|
||||||
|
mealie,
|
||||||
|
measures,
|
||||||
|
navigation,
|
||||||
oidc,
|
oidc,
|
||||||
package_types,
|
package_types,
|
||||||
products,
|
products,
|
||||||
|
recipes,
|
||||||
settings as settings_router,
|
settings as settings_router,
|
||||||
|
shopping,
|
||||||
shops,
|
shops,
|
||||||
stock,
|
stock,
|
||||||
transfer,
|
transfer,
|
||||||
units,
|
units,
|
||||||
users,
|
users,
|
||||||
|
verwendung,
|
||||||
views,
|
views,
|
||||||
)
|
)
|
||||||
from .seed import (
|
from .seed import (
|
||||||
ensure_builtin_categories,
|
ensure_builtin_categories,
|
||||||
|
ensure_builtin_measures,
|
||||||
|
ensure_builtin_etiketten_vorlagen,
|
||||||
ensure_builtin_package_types,
|
ensure_builtin_package_types,
|
||||||
ensure_builtin_units,
|
ensure_builtin_units,
|
||||||
ensure_example_object_categories,
|
ensure_example_object_categories,
|
||||||
@@ -222,6 +231,27 @@ def _ensure_schema() -> None:
|
|||||||
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
||||||
"WHERE email IS NOT NULL",
|
"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:
|
with engine.begin() as conn:
|
||||||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||||
@@ -250,7 +280,9 @@ async def lifespan(app: FastAPI):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ensure_builtin_units(db)
|
ensure_builtin_units(db)
|
||||||
|
ensure_builtin_measures(db)
|
||||||
ensure_builtin_package_types(db)
|
ensure_builtin_package_types(db)
|
||||||
|
ensure_builtin_etiketten_vorlagen(db)
|
||||||
ensure_builtin_categories(db)
|
ensure_builtin_categories(db)
|
||||||
ensure_example_object_categories(db)
|
ensure_example_object_categories(db)
|
||||||
ensure_first_admin(db)
|
ensure_first_admin(db)
|
||||||
@@ -287,6 +319,7 @@ def health() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(etiketten.router)
|
||||||
app.include_router(oidc.router)
|
app.include_router(oidc.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(products.router)
|
app.include_router(products.router)
|
||||||
@@ -296,14 +329,22 @@ app.include_router(groups.router)
|
|||||||
app.include_router(units.router)
|
app.include_router(units.router)
|
||||||
app.include_router(package_types.router)
|
app.include_router(package_types.router)
|
||||||
app.include_router(views.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(transfer.router)
|
||||||
app.include_router(api_tokens.router)
|
app.include_router(api_tokens.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
app.include_router(branding.router)
|
app.include_router(branding.router)
|
||||||
app.include_router(categories.router)
|
app.include_router(categories.router)
|
||||||
app.include_router(maintenance.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(dashboard.router)
|
||||||
app.include_router(shops.router)
|
app.include_router(shops.router)
|
||||||
app.include_router(field_definitions.router)
|
app.include_router(field_definitions.router)
|
||||||
app.include_router(items.router)
|
app.include_router(items.router)
|
||||||
app.include_router(loans.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
|
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):
|
class Unit(Base):
|
||||||
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
||||||
|
|
||||||
@@ -349,6 +367,19 @@ class Category(Base):
|
|||||||
default=CategoryTracking.object.value,
|
default=CategoryTracking.object.value,
|
||||||
server_default=CategoryTracking.food.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):
|
class Product(Base):
|
||||||
@@ -385,6 +416,11 @@ class Product(Base):
|
|||||||
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
|
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
secondary_amount: 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
|
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
|
||||||
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
|
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
|
||||||
date_precision: Mapped[str] = mapped_column(
|
date_precision: Mapped[str] = mapped_column(
|
||||||
@@ -511,10 +547,35 @@ class Lot(Base):
|
|||||||
location_id: Mapped[str | None] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
product: Mapped[Product] = relationship(back_populates="lots")
|
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):
|
class Movement(Base):
|
||||||
__tablename__ = "movements"
|
__tablename__ = "movements"
|
||||||
@@ -561,6 +622,81 @@ class PackageType(Base):
|
|||||||
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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):
|
class ProductImage(Base):
|
||||||
"""Lokale Kopie des Artikelbilds.
|
"""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):
|
class Setting(Base):
|
||||||
__tablename__ = "settings"
|
__tablename__ = "settings"
|
||||||
|
|
||||||
@@ -644,6 +860,152 @@ class Setting(Base):
|
|||||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
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):
|
class Shop(Base):
|
||||||
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
|
"""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).
|
# entsteht; Währung getrennt (z.B. CHF, EUR).
|
||||||
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
currency: Mapped[str | None] = mapped_column(String(3), 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
product: Mapped[Product] = relationship()
|
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()
|
location: Mapped[Location | None] = relationship()
|
||||||
shop: Mapped[Shop | None] = relationship()
|
shop: Mapped[Shop | None] = relationship()
|
||||||
documents: Mapped[list["ItemDocument"]] = relationship(
|
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||||
@@ -756,6 +1139,12 @@ class Item(Base):
|
|||||||
back_populates="item", cascade="all, delete-orphan",
|
back_populates="item", cascade="all, delete-orphan",
|
||||||
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
|
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):
|
class ItemDocument(Base):
|
||||||
@@ -828,6 +1217,59 @@ class ItemLoan(Base):
|
|||||||
user: Mapped[User | None] = relationship()
|
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):
|
class ProductLocationMinStock(Base):
|
||||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from fastapi.security import OAuth2PasswordRequestForm
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
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 ..models import User
|
||||||
from ..schemas import Token, UserOut
|
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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -21,10 +22,42 @@ def login(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Benutzername oder Passwort falsch",
|
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)
|
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)
|
@router.get("/me", response_model=UserOut)
|
||||||
def me(current_user: User = Depends(get_current_user)) -> User:
|
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||||
return current_user
|
return current_user
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from ..database import get_db
|
|||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import (
|
from ..models import (
|
||||||
Category,
|
Category,
|
||||||
|
CategoryMeasure,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
Product,
|
Product,
|
||||||
@@ -20,6 +21,7 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
||||||
from ..services.fields import effective_field_definitions
|
from ..services.fields import effective_field_definitions
|
||||||
|
from ..services.vererbung import geerbte_kategorie_frist
|
||||||
from .field_definitions import to_out as field_to_out
|
from .field_definitions import to_out as field_to_out
|
||||||
|
|
||||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
@@ -53,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut:
|
|||||||
out.product_count = (
|
out.product_count = (
|
||||||
db.query(Product).filter(Product.category_id == category.id).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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +85,18 @@ def create_category(
|
|||||||
tracking = parent.tracking
|
tracking = parent.tracking
|
||||||
else:
|
else:
|
||||||
tracking = CategoryTracking.object.value
|
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.add(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(category)
|
db.refresh(category)
|
||||||
@@ -119,6 +133,19 @@ def update_category(
|
|||||||
data["tracking"] = CategoryTracking(data["tracking"]).value
|
data["tracking"] = CategoryTracking(data["tracking"]).value
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(category, field, value)
|
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.commit()
|
||||||
db.refresh(category)
|
db.refresh(category)
|
||||||
return _to_out(db, category)
|
return _to_out(db, category)
|
||||||
@@ -156,6 +183,11 @@ def delete_category(
|
|||||||
db.query(FieldDefinition).filter(
|
db.query(FieldDefinition).filter(
|
||||||
FieldDefinition.category_id == category_id
|
FieldDefinition.category_id == category_id
|
||||||
).delete(synchronize_session=False)
|
).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.delete(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
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.loans import nicht_verliehen
|
||||||
from ..services.stock import current_stock
|
from ..services.stock import current_stock
|
||||||
from .settings import get_expiry_warning_days
|
from .settings import get_expiry_warning_days
|
||||||
|
from ..services import einkaufsliste
|
||||||
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
@@ -283,14 +284,28 @@ def set_enforced(
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Auswertungen
|
# Auswertungen
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str:
|
def _zustand(
|
||||||
"""Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung."""
|
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:
|
if best_before is None:
|
||||||
return "no_date"
|
return "no_date"
|
||||||
if best_before < heute:
|
|
||||||
return "expired"
|
|
||||||
if (best_before - heute).days <= warnfrist:
|
|
||||||
return "soon"
|
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
@@ -321,7 +336,10 @@ def _bestand_beitraege(
|
|||||||
continue
|
continue
|
||||||
faktor, _label = article_unit(product)
|
faktor, _label = article_unit(product)
|
||||||
menge = lot.quantity / (faktor or 1.0)
|
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():
|
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
||||||
# Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit.
|
# 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 –
|
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
|
||||||
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
|
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
|
||||||
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
|
# 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(
|
return DashboardStats(
|
||||||
products_in_stock=len(bestand_produkte),
|
products_in_stock=len(bestand_produkte),
|
||||||
@@ -374,7 +401,7 @@ def expiry_split(
|
|||||||
) -> ExpirySplit:
|
) -> ExpirySplit:
|
||||||
heute = date.today()
|
heute = date.today()
|
||||||
warnfrist = get_expiry_warning_days(db)
|
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):
|
for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
summen[zustand] += menge
|
summen[zustand] += menge
|
||||||
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
|
||||||
@@ -417,7 +444,10 @@ def by_category(
|
|||||||
rollup_cache[cid] = ziel
|
rollup_cache[cid] = ziel
|
||||||
return 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)
|
eimer: dict[int | None, dict] = defaultdict(leer)
|
||||||
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
|
||||||
topf = eimer[rollup(product.category_id)]
|
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 import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
@@ -17,6 +17,7 @@ from ..models import (
|
|||||||
Item,
|
Item,
|
||||||
ItemDocument,
|
ItemDocument,
|
||||||
ItemLoan,
|
ItemLoan,
|
||||||
|
ItemUsage,
|
||||||
Location,
|
Location,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
@@ -31,6 +32,7 @@ from ..schemas import (
|
|||||||
ItemOut,
|
ItemOut,
|
||||||
ItemRemove,
|
ItemRemove,
|
||||||
ItemUpdate,
|
ItemUpdate,
|
||||||
|
ZubehoerZuordnen,
|
||||||
)
|
)
|
||||||
from ..services.items import generate_uid, item_to_out
|
from ..services.items import generate_uid, item_to_out
|
||||||
from ..services.warranty import (
|
from ..services.warranty import (
|
||||||
@@ -56,6 +58,18 @@ def _product_or_404(db: Session, product_id: int) -> Product:
|
|||||||
return 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:
|
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||||
item = db.get(Item, item_id)
|
item = db.get(Item, item_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -89,7 +103,7 @@ def create_items(
|
|||||||
product_id: int,
|
product_id: int,
|
||||||
payload: ItemCreate,
|
payload: ItemCreate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> list[ItemOut]:
|
) -> list[ItemOut]:
|
||||||
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
product = _product_or_404(db, product_id)
|
product = _product_or_404(db, product_id)
|
||||||
@@ -109,6 +123,23 @@ def create_items(
|
|||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
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)
|
created.append(item)
|
||||||
db.commit()
|
db.commit()
|
||||||
for item in created:
|
for item in created:
|
||||||
@@ -136,6 +167,10 @@ def list_all_items(
|
|||||||
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
|
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
|
||||||
# verliehenem Stueck einzeln nach (N+1).
|
# verliehenem Stueck einzeln nach (N+1).
|
||||||
joinedload(Item.loans).joinedload(ItemLoan.user),
|
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)
|
.order_by(Item.id)
|
||||||
.all()
|
.all()
|
||||||
@@ -191,6 +226,7 @@ def remove_item(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
|
||||||
item = _item_or_404(db, item_id)
|
item = _item_or_404(db, item_id)
|
||||||
|
_zubehoer_freigeben(item)
|
||||||
db.add(
|
db.add(
|
||||||
Movement(
|
Movement(
|
||||||
product_id=item.product_id,
|
product_id=item.product_id,
|
||||||
@@ -215,7 +251,83 @@ def delete_item(
|
|||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Einzelstück ohne Grund löschen (Korrektur)."""
|
"""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()
|
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(
|
@router.post(
|
||||||
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
||||||
)
|
)
|
||||||
@@ -81,6 +103,27 @@ def lend_item(
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
)
|
)
|
||||||
db.add(loan)
|
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.commit()
|
||||||
db.refresh(loan)
|
db.refresh(loan)
|
||||||
return loan_to_out(loan)
|
return loan_to_out(loan)
|
||||||
@@ -103,6 +146,16 @@ def return_item(
|
|||||||
returned_on = payload.returned_on or date.today()
|
returned_on = payload.returned_on or date.today()
|
||||||
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
||||||
loan.returned_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.commit()
|
||||||
db.refresh(loan)
|
db.refresh(loan)
|
||||||
return loan_to_out(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,
|
Token,
|
||||||
)
|
)
|
||||||
from ..security import create_access_token
|
from ..security import create_access_token
|
||||||
|
from .settings import get_session_minutes
|
||||||
from ..services import oidc as dienst
|
from ..services import oidc as dienst
|
||||||
from ..services import oidc_state
|
from ..services import oidc_state
|
||||||
|
|
||||||
@@ -63,8 +64,12 @@ def _provider_oder_404(slug: str) -> OidcProvider:
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
def _token_fuer(user: User) -> Token:
|
def _token_fuer(user: User, db: Session) -> Token:
|
||||||
token = create_access_token(user_id=user.id, role=user.role.value)
|
# 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)
|
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)
|
logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
|
||||||
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
|
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)
|
logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username)
|
||||||
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
|
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
|
||||||
# an den Server geschickt und landen damit auch nicht in den nginx-Logs.
|
# 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
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler
|
||||||
|
|
||||||
logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username)
|
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:
|
except dienst.OidcFehler as fehler:
|
||||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from 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)
|
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)
|
@router.post("/pending/{code}/verknuepfen", response_model=Token)
|
||||||
@@ -417,4 +422,4 @@ def offene_anmeldung_verknuepfen(
|
|||||||
)
|
)
|
||||||
raise HTTPException(code_http, str(fehler)) from fehler
|
raise HTTPException(code_http, str(fehler)) from fehler
|
||||||
logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username)
|
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,
|
Category,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
Group,
|
Group,
|
||||||
|
Item,
|
||||||
Location,
|
Location,
|
||||||
|
Lot,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
Product,
|
Product,
|
||||||
@@ -55,45 +57,17 @@ from .settings import get_receipt_match_threshold
|
|||||||
from ..services.conversion import ConversionError, resolve_product_unit
|
from ..services.conversion import ConversionError, resolve_product_unit
|
||||||
from ..services.fields import FieldError, apply_field_values
|
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.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.min_stock import schreibe_ueberall
|
||||||
from ..services.stock import removal_stats
|
from ..services.stock import removal_stats
|
||||||
|
|
||||||
router = APIRouter(prefix="/products", tags=["products"])
|
router = APIRouter(prefix="/products", tags=["products"])
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen.
|
||||||
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen.
|
# Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie
|
||||||
# ---------------------------------------------------------------------------
|
# genauso braucht.
|
||||||
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
|
_normalisieren = normalisieren
|
||||||
|
_match_score = match_score
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/match", response_model=list[MatchLine])
|
@router.post("/match", response_model=list[MatchLine])
|
||||||
@@ -141,7 +115,12 @@ def list_products(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
) -> list[ProductOut]:
|
) -> 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
|
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
|
||||||
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
|
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.field_values),
|
||||||
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
|
||||||
)
|
)
|
||||||
if q:
|
|
||||||
like = f"%{q}%"
|
|
||||||
query = query.filter(Product.name.ilike(like))
|
|
||||||
if category_id == 0:
|
if category_id == 0:
|
||||||
query = query.filter(Product.category_id.is_(None))
|
query = query.filter(Product.category_id.is_(None))
|
||||||
elif category_id is not None:
|
elif category_id is not None:
|
||||||
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
|
||||||
products = query.order_by(Product.name).all()
|
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)
|
return products_to_out_bulk(db, products)
|
||||||
|
|
||||||
|
|
||||||
@@ -680,9 +669,28 @@ def delete_product(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> None:
|
) -> 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)
|
product = db.get(Product, product_id)
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
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)
|
detach_group_code(db, product)
|
||||||
db.delete(product)
|
db.delete(product)
|
||||||
db.commit()
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
@@ -6,6 +6,7 @@ from ..database import get_db
|
|||||||
from ..deps import get_current_user, require_admin
|
from ..deps import get_current_user, require_admin
|
||||||
from ..models import Setting, User
|
from ..models import Setting, User
|
||||||
from ..schemas import SettingOut
|
from ..schemas import SettingOut
|
||||||
|
from ..services.vererbung import globale_oeffnungsfrist
|
||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -14,6 +15,16 @@ EXPIRY_WARNING_KEY = "expiry_warning_days"
|
|||||||
# Treffer vorgeschlagen wird.
|
# Treffer vorgeschlagen wird.
|
||||||
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
|
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
|
||||||
RECEIPT_THRESHOLD_DEFAULT = 45
|
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:
|
def get_expiry_warning_days(db: Session) -> int:
|
||||||
@@ -37,6 +48,31 @@ def get_receipt_match_threshold(db: Session) -> int:
|
|||||||
return RECEIPT_THRESHOLD_DEFAULT
|
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])
|
@router.get("", response_model=list[SettingOut])
|
||||||
def list_settings(
|
def list_settings(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
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 = {r.key: r.value for r in rows}
|
||||||
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
||||||
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(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()]
|
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +93,28 @@ def set_setting(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> SettingOut:
|
) -> 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)
|
row = db.get(Setting, key)
|
||||||
if row is None:
|
if row is None:
|
||||||
row = Setting(key=key, value=value)
|
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 (
|
from ..schemas import (
|
||||||
BatchCheckInRequest,
|
BatchCheckInRequest,
|
||||||
BatchCheckInResponse,
|
BatchCheckInResponse,
|
||||||
|
BulkLotCheckout,
|
||||||
|
BulkLotDelete,
|
||||||
BulkLotLocation,
|
BulkLotLocation,
|
||||||
CheckInRequest,
|
CheckInRequest,
|
||||||
CheckInResponse,
|
CheckInResponse,
|
||||||
CheckOutRequest,
|
CheckOutRequest,
|
||||||
CheckOutResponse,
|
CheckOutResponse,
|
||||||
|
LotConsume,
|
||||||
|
LotConsumeOut,
|
||||||
|
LotOpen,
|
||||||
LotOut,
|
LotOut,
|
||||||
LotRow,
|
LotRow,
|
||||||
LotSplit,
|
LotSplit,
|
||||||
@@ -25,6 +30,8 @@ from ..services.conversion import ConversionError, display_unit_info
|
|||||||
from ..services.dates import clean_precision, normalize_best_before
|
from ..services.dates import clean_precision, normalize_best_before
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
StockError,
|
StockError,
|
||||||
|
abspalten,
|
||||||
|
anbrechen,
|
||||||
check_in,
|
check_in,
|
||||||
check_out,
|
check_out,
|
||||||
check_out_lot,
|
check_out_lot,
|
||||||
@@ -33,6 +40,7 @@ from ..services.stock import (
|
|||||||
object_add,
|
object_add,
|
||||||
object_relocate,
|
object_relocate,
|
||||||
object_remove,
|
object_remove,
|
||||||
|
verbrauchen,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(tags=["stock"])
|
router = APIRouter(tags=["stock"])
|
||||||
@@ -272,6 +280,11 @@ def list_all_lots(
|
|||||||
unit_name=name, unit_factor=factor,
|
unit_name=name, unit_factor=factor,
|
||||||
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
|
||||||
location_id=lot.location_id, created_at=lot.created_at,
|
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
|
return rows
|
||||||
|
|
||||||
@@ -307,6 +320,91 @@ def bulk_lot_location(
|
|||||||
return n
|
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)
|
@router.post("/lots/{lot_id}/split", response_model=LotOut)
|
||||||
def split_lot(
|
def split_lot(
|
||||||
lot_id: int,
|
lot_id: int,
|
||||||
@@ -321,32 +419,16 @@ def split_lot(
|
|||||||
lot = db.get(Lot, lot_id)
|
lot = db.get(Lot, lot_id)
|
||||||
if lot is None:
|
if lot is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
|
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:
|
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")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||||
|
|
||||||
lot.quantity -= payload.quantity
|
# Die eigentliche Arbeit macht der Dienst; hier gilt weiter die strengere
|
||||||
neu = Lot(
|
# Variante: nur echt weniger als die Gesamtmenge, und nur an einen ANDEREN
|
||||||
product_id=lot.product_id,
|
# Ort. Das Anbrechen benutzt dieselbe Funktion mit gelockerten Regeln.
|
||||||
quantity=payload.quantity,
|
try:
|
||||||
best_before=lot.best_before,
|
neu = abspalten(db, lot, payload.quantity, payload.location_id)
|
||||||
best_before_precision=lot.best_before_precision,
|
except StockError as fehler:
|
||||||
location_id=payload.location_id,
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
|
||||||
)
|
|
||||||
db.add(neu)
|
|
||||||
db.flush()
|
|
||||||
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
|
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
|
||||||
survivor = consolidate_lot_group(
|
survivor = consolidate_lot_group(
|
||||||
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
|
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
|
||||||
@@ -356,6 +438,83 @@ def split_lot(
|
|||||||
return survivor
|
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)
|
@router.patch("/lots/{lot_id}", response_model=LotOut)
|
||||||
def update_lot(
|
def update_lot(
|
||||||
lot_id: int,
|
lot_id: int,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ werden ergänzt – es wird nie etwas gelöscht oder überschrieben.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
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 import gruppen as gruppen_graph
|
||||||
from ..services.group_codes import sync as sync_group_code
|
from ..services.group_codes import sync as sync_group_code
|
||||||
from ..services.min_stock import lies_ueberall, schreibe_ueberall
|
from ..services.min_stock import lies_ueberall, schreibe_ueberall
|
||||||
|
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen, konfiguration_schreiben
|
||||||
from ..models import (
|
from ..models import (
|
||||||
BaseUnit,
|
BaseUnit,
|
||||||
Category,
|
Category,
|
||||||
CategoryTracking,
|
CategoryTracking,
|
||||||
DatePrecision,
|
DatePrecision,
|
||||||
|
EtikettenVorlage,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
Group,
|
Group,
|
||||||
GroupLocationMinStock,
|
GroupLocationMinStock,
|
||||||
@@ -166,10 +169,11 @@ def export_backup_json(
|
|||||||
loc_name = {loc.id: loc.name for loc in locations}
|
loc_name = {loc.id: loc.name for loc in locations}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
# v4: Etiketten-Vorlagen samt hinterlegter P-touch-Datei.
|
||||||
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
|
# 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.
|
# lesbar – ihnen fehlen diese Listen einfach.
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
|
||||||
"units": [
|
"units": [
|
||||||
@@ -196,7 +200,11 @@ def export_backup_json(
|
|||||||
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
|
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
|
||||||
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
|
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
|
||||||
"categories": [
|
"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()
|
for c in db.query(Category).order_by(Category.id).all()
|
||||||
],
|
],
|
||||||
# Bezugsquellen (nur für Gegenstände).
|
# 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": lot.best_before.isoformat() if lot.best_before else None,
|
||||||
"best_before_precision": lot.best_before_precision,
|
"best_before_precision": lot.best_before_precision,
|
||||||
"location": loc_name.get(lot.location_id),
|
"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
|
for lot in lots
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data["etiketten_vorlagen"] = _vorlagen_exportieren(db)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps(data, ensure_ascii=False, indent=2),
|
content=json.dumps(data, ensure_ascii=False, indent=2),
|
||||||
media_type="application/json; charset=utf-8",
|
media_type="application/json; charset=utf-8",
|
||||||
@@ -340,6 +355,27 @@ def export_labels(
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Import (nur additiv)
|
# 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:
|
def _num(value) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -435,6 +471,146 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None:
|
|||||||
return loc
|
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:
|
def _category_path(db: Session, category: Category | None) -> str:
|
||||||
"""Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte.
|
"""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"):
|
if cat is not None and entry.get("tracking"):
|
||||||
cat.tracking = entry["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", []):
|
for entry in data.get("shops", []):
|
||||||
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
|
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -883,6 +1062,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
best_before=best_before,
|
best_before=best_before,
|
||||||
best_before_precision=precision,
|
best_before_precision=precision,
|
||||||
location_id=location.id if location else None,
|
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.add(lot)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -901,9 +1083,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
|
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
|
||||||
|
|
||||||
|
vorlagen_created = _vorlagen_importieren(
|
||||||
|
db, data.get("etiketten_vorlagen", []), errors
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
|
"etiketten_vorlagen_created": vorlagen_created,
|
||||||
"units_created": units_created,
|
"units_created": units_created,
|
||||||
"products_created": len(created_products),
|
"products_created": len(created_products),
|
||||||
"products_cleared": len(cleared),
|
"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 typing import Callable, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -41,7 +42,9 @@ from ..services.conversion import (
|
|||||||
display_unit_info,
|
display_unit_info,
|
||||||
group_min_context,
|
group_min_context,
|
||||||
)
|
)
|
||||||
|
from ..services import einkaufsliste
|
||||||
from ..services import gruppen as gruppen_graph
|
from ..services import gruppen as gruppen_graph
|
||||||
|
from .shopping import eintrag_out
|
||||||
from ..services.stock import (
|
from ..services.stock import (
|
||||||
current_stock,
|
current_stock,
|
||||||
descendant_location_ids,
|
descendant_location_ids,
|
||||||
@@ -194,6 +197,14 @@ def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
|
|||||||
def shopping_list(
|
def shopping_list(
|
||||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||||
) -> list[ShoppingItem]:
|
) -> 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)
|
return einkaufsliste_artikel(db)
|
||||||
|
|
||||||
|
|
||||||
@@ -468,13 +479,20 @@ def shopping_list_by_location(
|
|||||||
def shopping_list_all(
|
def shopping_list_all(
|
||||||
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
db: Session = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
) -> ShoppingListAll:
|
) -> ShoppingListAll:
|
||||||
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
|
"""Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe
|
||||||
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
|
(Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die
|
||||||
Erspart drei getrennte Abfragen."""
|
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(
|
return ShoppingListAll(
|
||||||
products=shopping_list(db, user),
|
products=shopping_list(db, user),
|
||||||
groups=group_shopping_list(db, user),
|
groups=group_shopping_list(db, user),
|
||||||
by_location=shopping_list_by_location(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()
|
today = date.today()
|
||||||
threshold = today + timedelta(days=days)
|
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 = (
|
lots = (
|
||||||
db.query(Lot)
|
db.query(Lot)
|
||||||
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
|
.filter(
|
||||||
.order_by(Lot.best_before)
|
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()
|
.all()
|
||||||
)
|
)
|
||||||
result: list[ExpiringItem] = []
|
result: list[ExpiringItem] = []
|
||||||
for lot in lots:
|
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
|
product = lot.product
|
||||||
unit_name, unit_factor = display_unit_info(product)
|
unit_name, unit_factor = display_unit_info(product)
|
||||||
result.append(
|
result.append(
|
||||||
@@ -550,13 +580,23 @@ def expiring(
|
|||||||
base_unit=product.base_unit,
|
base_unit=product.base_unit,
|
||||||
best_before=lot.best_before,
|
best_before=lot.best_before,
|
||||||
best_before_precision=lot.best_before_precision,
|
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_size=product.package_size,
|
||||||
package_label=product.package_label,
|
package_label=product.package_label,
|
||||||
unit_name=unit_name,
|
unit_name=unit_name,
|
||||||
unit_factor=unit_factor,
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+463
-3
@@ -12,6 +12,7 @@ from .models import (
|
|||||||
RemovalReason,
|
RemovalReason,
|
||||||
Role,
|
Role,
|
||||||
UnitKind,
|
UnitKind,
|
||||||
|
UsageStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -275,6 +276,16 @@ class CategoryOut(BaseModel):
|
|||||||
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
||||||
tracking: CategoryTracking = CategoryTracking.food
|
tracking: CategoryTracking = CategoryTracking.food
|
||||||
product_count: int = 0
|
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):
|
class CategoryCreate(BaseModel):
|
||||||
@@ -282,12 +293,21 @@ class CategoryCreate(BaseModel):
|
|||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
||||||
tracking: CategoryTracking | None = None
|
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):
|
class CategoryUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
parent_id: int | None = None
|
parent_id: int | None = None
|
||||||
tracking: CategoryTracking | 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) ----
|
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||||||
@@ -403,6 +423,9 @@ class ProductBase(BaseModel):
|
|||||||
secondary_base: BaseUnit | None = None
|
secondary_base: BaseUnit | None = None
|
||||||
secondary_count: float | None = Field(default=None, gt=0)
|
secondary_count: float | None = Field(default=None, gt=0)
|
||||||
secondary_amount: 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).
|
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||||||
date_precision: DatePrecision = DatePrecision.day
|
date_precision: DatePrecision = DatePrecision.day
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
@@ -442,6 +465,9 @@ class ProductUpdate(BaseModel):
|
|||||||
secondary_base: BaseUnit | None = None
|
secondary_base: BaseUnit | None = None
|
||||||
secondary_count: float | None = Field(default=None, gt=0)
|
secondary_count: float | None = Field(default=None, gt=0)
|
||||||
secondary_amount: 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
|
date_precision: DatePrecision | None = None
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
@@ -470,6 +496,12 @@ class ProductOut(BaseModel):
|
|||||||
secondary_base: BaseUnit | None = None
|
secondary_base: BaseUnit | None = None
|
||||||
secondary_count: float | None = None
|
secondary_count: float | None = None
|
||||||
secondary_amount: 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 –
|
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
|
||||||
# damit die Oberflächen nicht selbst dividieren müssen.
|
# damit die Oberflächen nicht selbst dividieren müssen.
|
||||||
secondary_factor: float | None = None
|
secondary_factor: float | None = None
|
||||||
@@ -574,6 +606,11 @@ class LotOut(BaseModel):
|
|||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: str | None
|
location_id: str | None
|
||||||
created_at: datetime
|
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):
|
class LotUpdate(BaseModel):
|
||||||
@@ -601,6 +638,9 @@ class LotRow(BaseModel):
|
|||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: str | None = None
|
location_id: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
opened_on: date | None = None
|
||||||
|
consume_by: date | None = None
|
||||||
|
opened_quantity: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class BulkLotLocation(BaseModel):
|
class BulkLotLocation(BaseModel):
|
||||||
@@ -609,6 +649,25 @@ class BulkLotLocation(BaseModel):
|
|||||||
location_id: str | None = None
|
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):
|
class LotSplit(BaseModel):
|
||||||
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
||||||
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
|
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
|
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 ----
|
# ---- Kassenzettel-Abgleich ----
|
||||||
class MatchRequest(BaseModel):
|
class MatchRequest(BaseModel):
|
||||||
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
||||||
@@ -778,6 +1026,9 @@ class LoanCreate(BaseModel):
|
|||||||
lent_on: date | None = None
|
lent_on: date | None = None
|
||||||
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||||||
due_on: date | None = None
|
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):
|
class LoanUpdate(BaseModel):
|
||||||
@@ -792,6 +1043,9 @@ class LoanUpdate(BaseModel):
|
|||||||
class LoanReturn(BaseModel):
|
class LoanReturn(BaseModel):
|
||||||
"""Rückgabe buchen. Leeres Datum = heute."""
|
"""Rückgabe buchen. Leeres Datum = heute."""
|
||||||
returned_on: date | None = None
|
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):
|
class LoanOut(BaseModel):
|
||||||
@@ -818,6 +1072,52 @@ class LoanOut(BaseModel):
|
|||||||
location_name: str | None = None
|
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):
|
class ItemOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
@@ -842,6 +1142,104 @@ class ItemOut(BaseModel):
|
|||||||
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
||||||
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
||||||
loan: LoanOut | None = None
|
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 ----
|
# ---- Views ----
|
||||||
@@ -879,8 +1277,17 @@ class ExpiringItem(BaseModel):
|
|||||||
product_name: str
|
product_name: str
|
||||||
quantity: float # in Basiseinheiten
|
quantity: float # in Basiseinheiten
|
||||||
base_unit: BaseUnit
|
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
|
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
|
days_left: int
|
||||||
# Für die Anzeige in Artikeleinheiten:
|
# Für die Anzeige in Artikeleinheiten:
|
||||||
package_size: float | None = None
|
package_size: float | None = None
|
||||||
@@ -933,12 +1340,61 @@ class LocationNeeds(BaseModel):
|
|||||||
groups: list[LocationNeedGroup] = []
|
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):
|
class ShoppingListAll(BaseModel):
|
||||||
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
|
"""Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die
|
||||||
die Oberfläche zusammenführt."""
|
eigenen Einträge und die dauerhaft gesetzten Haken."""
|
||||||
products: list[ShoppingItem] = []
|
products: list[ShoppingItem] = []
|
||||||
groups: list[GroupShoppingItem] = []
|
groups: list[GroupShoppingItem] = []
|
||||||
by_location: list[LocationNeeds] = []
|
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):
|
class LocationContentEntry(BaseModel):
|
||||||
@@ -1036,6 +1492,9 @@ class ExpirySplit(BaseModel):
|
|||||||
ok: float
|
ok: float
|
||||||
soon: float
|
soon: float
|
||||||
expired: 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"
|
no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
|
||||||
|
|
||||||
|
|
||||||
@@ -1046,6 +1505,7 @@ class CategoryShare(BaseModel):
|
|||||||
ok: float
|
ok: float
|
||||||
soon: float
|
soon: float
|
||||||
expired: float
|
expired: float
|
||||||
|
opened: float = 0.0
|
||||||
no_date: float
|
no_date: float
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -31,7 +31,9 @@ def verify_password(password: str, password_hash: str | None) -> bool:
|
|||||||
return False
|
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.
|
"""Stellt ein Sitzungs-Token aus, das den Benutzer ueber seine ID benennt.
|
||||||
|
|
||||||
Frueher stand hier der Benutzername. Damit war der Name die Kennung der
|
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`` =
|
``uid`` steht zusaetzlich zu ``sub`` drin, damit sich alte Token (``sub`` =
|
||||||
Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst
|
Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst
|
||||||
schlicht "42" heissen.
|
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)
|
minuten = settings.jwt_expire_minutes if gueltig_minuten is None else gueltig_minuten
|
||||||
payload = {"sub": str(user_id), "uid": user_id, "role": role, "exp": expire}
|
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)
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
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 .security import hash_password
|
||||||
from .services.fields import slugify
|
from .services.fields import slugify
|
||||||
|
|
||||||
@@ -62,6 +73,30 @@ def ensure_builtin_units(db: Session) -> None:
|
|||||||
db.commit()
|
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
|
# Startliste für den Überblick über den Vorrat. Zweistufig, damit die
|
||||||
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
|
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
|
||||||
# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen.
|
# 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.add(admin)
|
||||||
db.commit()
|
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
|
return menge * anzahl / zweit
|
||||||
|
|
||||||
|
|
||||||
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
|
def to_base(
|
||||||
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
|
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:
|
if quantity <= 0:
|
||||||
raise ConversionError("Menge muss größer als 0 sein")
|
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)
|
unit = find_unit(db, t)
|
||||||
if unit is None:
|
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}")
|
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
|
||||||
if unit.kind != kind_of_product(product):
|
if unit.kind != kind_of_product(product):
|
||||||
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
|
# 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
|
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]:
|
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
|
||||||
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
|
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
|
||||||
unit = db.get(Unit, unit_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 []
|
return [str(o) for o in data] if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
def _ancestry(db: Session, category_id: int) -> list[Category]:
|
def ancestry(db: Session, category_id: int) -> list[Category]:
|
||||||
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
|
"""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] = []
|
chain: list[Category] = []
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
cid: int | None = category_id
|
cid: int | None = category_id
|
||||||
@@ -67,7 +72,7 @@ def effective_field_definitions(
|
|||||||
if category_id is None:
|
if category_id is None:
|
||||||
return []
|
return []
|
||||||
result: list[tuple[FieldDefinition, bool]] = []
|
result: list[tuple[FieldDefinition, bool]] = []
|
||||||
for cat in _ancestry(db, category_id):
|
for cat in ancestry(db, category_id):
|
||||||
defs = (
|
defs = (
|
||||||
db.query(FieldDefinition)
|
db.query(FieldDefinition)
|
||||||
.filter(FieldDefinition.category_id == cat.id)
|
.filter(FieldDefinition.category_id == cat.id)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import secrets
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import Item, ItemLoan
|
from ..models import Item, ItemLoan, ItemUsage
|
||||||
from ..schemas import ItemOut
|
from ..schemas import ItemKurz, ItemOut, VerwendungOut
|
||||||
from .loans import loan_to_out
|
from .loans import loan_to_out
|
||||||
|
|
||||||
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
# 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
|
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:
|
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 = ItemOut.model_validate(item)
|
||||||
out.location_name = item.location.name if item.location else None
|
out.location_name = item.location.name if item.location else None
|
||||||
out.shop_name = item.shop.name if item.shop 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
|
out.category_name = kategorie.name if kategorie else None
|
||||||
loan = _offener(item)
|
loan = _offener(item)
|
||||||
out.loan = loan_to_out(loan) if loan is not None else None
|
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
|
return out
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ def export_master_data(db: Session) -> dict:
|
|||||||
return {
|
return {
|
||||||
FORMAT_KEY: FORMAT_VERSION,
|
FORMAT_KEY: FORMAT_VERSION,
|
||||||
"categories": [
|
"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()
|
for c in db.query(Category).order_by(Category.id).all()
|
||||||
],
|
],
|
||||||
"field_definitions": [
|
"field_definitions": [
|
||||||
@@ -152,6 +153,7 @@ def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
|
|||||||
for row in cats:
|
for row in cats:
|
||||||
upsert(Category, row, "categories", lambda r: {
|
upsert(Category, row, "categories", lambda r: {
|
||||||
"name": r["name"], "tracking": r.get("tracking") or "food",
|
"name": r["name"], "tracking": r.get("tracking") or "food",
|
||||||
|
"usage_tracking": bool(r.get("usage_tracking", False)),
|
||||||
}, is_tree=True)
|
}, is_tree=True)
|
||||||
set_parents(Category, cats)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from sqlalchemy import asc, func
|
from sqlalchemy import asc, func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -18,7 +18,7 @@ from ..models import (
|
|||||||
Product,
|
Product,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from .conversion import to_base
|
from .conversion import article_unit, to_base
|
||||||
from .dates import clean_precision, normalize_best_before
|
from .dates import clean_precision, normalize_best_before
|
||||||
from .loans import nicht_verliehen
|
from .loans import nicht_verliehen
|
||||||
|
|
||||||
@@ -414,10 +414,17 @@ def consolidate_lot_group(
|
|||||||
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
|
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
|
||||||
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
|
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
|
||||||
erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch.
|
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(
|
query = db.query(Lot).filter(
|
||||||
Lot.product_id == product_id,
|
Lot.product_id == product_id,
|
||||||
Lot.best_before_precision == best_before_precision,
|
Lot.best_before_precision == best_before_precision,
|
||||||
|
Lot.opened_on.is_(None),
|
||||||
)
|
)
|
||||||
query = (
|
query = (
|
||||||
query.filter(Lot.best_before.is_(None))
|
query.filter(Lot.best_before.is_(None))
|
||||||
@@ -452,6 +459,8 @@ def consolidate_duplicate_lots(db: Session) -> int:
|
|||||||
db.query(
|
db.query(
|
||||||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
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(
|
.group_by(
|
||||||
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
|
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_precision == precision,
|
||||||
Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before,
|
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.location_id.is_(None) if location_id is None else Lot.location_id == location_id,
|
||||||
|
Lot.opened_on.is_(None),
|
||||||
)
|
)
|
||||||
.scalar()
|
.scalar()
|
||||||
)
|
)
|
||||||
@@ -487,7 +497,8 @@ def check_out_lot(
|
|||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
|
"""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:
|
if needed > lot.quantity + 1e-9:
|
||||||
raise StockError(
|
raise StockError(
|
||||||
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
|
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
|
||||||
@@ -512,19 +523,143 @@ def check_out_lot(
|
|||||||
return affected
|
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]:
|
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 = (
|
lots = (
|
||||||
db.query(Lot)
|
db.query(Lot)
|
||||||
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
||||||
.order_by(asc(Lot.created_at))
|
.order_by(asc(Lot.created_at))
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
|
# In Python sortieren, damit es über SQLite und Postgres identisch arbeitet.
|
||||||
# es über SQLite und Postgres identisch funktioniert.
|
|
||||||
return sorted(
|
return sorted(
|
||||||
lots,
|
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.
|
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
||||||
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
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)
|
available = current_stock(db, product.id)
|
||||||
if needed > available + 1e-9:
|
if needed > available + 1e-9:
|
||||||
raise StockError(
|
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.database import Base
|
||||||
from app.models import BaseUnit, Product
|
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()
|
@pytest.fixture()
|
||||||
@@ -19,6 +19,7 @@ def db():
|
|||||||
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
session = TestingSession()
|
session = TestingSession()
|
||||||
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
|
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
|
||||||
|
ensure_builtin_measures(session) # Hausmaße (EL, TL, Tasse …) – ohne Mengen
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
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()
|
db.commit()
|
||||||
|
|
||||||
daten = json.loads(_sicherung(db, user))
|
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"]}
|
nach_name = {g["name"]: g for g in daten["groups"]}
|
||||||
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
|
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
|
||||||
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
|
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,
|
Item,
|
||||||
ItemDocument,
|
ItemDocument,
|
||||||
Location,
|
Location,
|
||||||
|
Movement,
|
||||||
|
MovementType,
|
||||||
Product,
|
Product,
|
||||||
Role,
|
Role,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
|
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
|
||||||
from app.schemas import ItemCreate
|
from app.schemas import ItemCreate
|
||||||
|
from app.services.stock import current_stock
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -39,7 +42,7 @@ def _product(db):
|
|||||||
def test_item_kaufpreis_round_trip(db, admin):
|
def test_item_kaufpreis_round_trip(db, admin):
|
||||||
product = _product(db)
|
product = _product(db)
|
||||||
out = create_items(
|
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].price_cents == 49900
|
||||||
assert out[0].currency == "CHF"
|
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):
|
def test_item_out_listet_belege(db, admin):
|
||||||
product = _product(db)
|
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",
|
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
|
||||||
content_type="application/pdf", data=b"%PDF-1.4 x"))
|
content_type="application/pdf", data=b"%PDF-1.4 x"))
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -68,7 +71,7 @@ def test_list_all_items_angereichert(db, admin):
|
|||||||
ort = Location(name="Keller")
|
ort = Location(name="Keller")
|
||||||
db.add(ort)
|
db.add(ort)
|
||||||
db.commit()
|
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)
|
alle = list_all_items(db=db, _=admin)
|
||||||
assert len(alle) == 2
|
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('a"b\r\nc.pdf') == "abc.pdf"
|
||||||
assert _safe_filename(" ") == "beleg"
|
assert _safe_filename(" ") == "beleg"
|
||||||
assert _safe_filename(None) == "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(
|
return create_items(
|
||||||
product.id,
|
product.id,
|
||||||
ItemCreate(count=1, location_id=(ort.id if ort else None)),
|
ItemCreate(count=1, location_id=(ort.id if ort else None)),
|
||||||
db=db, _=admin,
|
db=db, user=admin,
|
||||||
)[0]
|
)[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
|
POSTGRES_DB=vorrania
|
||||||
|
|
||||||
# Sicherheit – im Betrieb unbedingt ändern!
|
# 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
|
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)
|
# Erster Admin-Benutzer (wird nur beim allerersten Start angelegt)
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_PASSWORD=changeme
|
||||||
|
|||||||
@@ -131,6 +131,21 @@ actor APIClient {
|
|||||||
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
|
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.
|
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
|
||||||
func oidcProviders() async throws -> [OidcProviderResponse] {
|
func oidcProviders() async throws -> [OidcProviderResponse] {
|
||||||
let (daten, antwort) = try await URLSession.shared.data(
|
let (daten, antwort) = try await URLSession.shared.data(
|
||||||
@@ -201,6 +216,12 @@ actor APIClient {
|
|||||||
return try await send(request, as: Product.self)
|
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).
|
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
|
||||||
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
|
||||||
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
|
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)
|
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.
|
/// QR-Auflösung: UID → Einzelstück.
|
||||||
func itemByUid(uid: String) async throws -> Item {
|
func itemByUid(uid: String) async throws -> Item {
|
||||||
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
|
||||||
@@ -278,6 +305,21 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Verleih von Einzelstuecken
|
// 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.
|
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
|
||||||
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
|
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
|
||||||
var request = try makeRequest("/items/\(id)/loan", method: "POST")
|
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)
|
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
|
/// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren
|
||||||
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
|
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
|
||||||
func loans(dueIn: Int? = nil) async throws -> [Loan] {
|
func loans(dueIn: Int? = nil) async throws -> [Loan] {
|
||||||
@@ -383,6 +444,22 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Listen
|
// 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] {
|
func shoppingList() async throws -> [ShoppingItem] {
|
||||||
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
|
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
|
||||||
}
|
}
|
||||||
@@ -425,6 +502,39 @@ actor APIClient {
|
|||||||
return try await send(request, as: StockResponse.self)
|
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 {
|
func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot {
|
||||||
var request = try makeRequest("/lots/\(id)", method: "PATCH")
|
var request = try makeRequest("/lots/\(id)", method: "PATCH")
|
||||||
try jsonBody(&request, payload)
|
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 {
|
var body: some View {
|
||||||
Picker("Kategorie", selection: $selection) {
|
Picker("Kategorie", selection: $selection) {
|
||||||
Text("– keine –").tag(Int?.none)
|
Text("– keine –").tag(Int?.none)
|
||||||
ForEach(flattened(), id: \.item.id) { eintrag in
|
ForEach(baum(gefiltert)) { eintrag in
|
||||||
Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name)
|
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
|
||||||
.tag(Int?.some(eintrag.item.id))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,23 +23,6 @@ struct CategoryPicker: View {
|
|||||||
guard let tracking else { return categories }
|
guard let tracking else { return categories }
|
||||||
return categories.filter { $0.tracking == tracking }
|
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
|
/// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ struct CheckInFormView: View {
|
|||||||
Section {
|
Section {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– keiner –").tag(String?.none)
|
Text("– keiner –").tag(String?.none)
|
||||||
ForEach(locations) { location in
|
ForEach(baum(locations)) { eintrag in
|
||||||
Text(location.path(in: locations)).tag(String?.some(location.id))
|
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ struct CheckInView: View {
|
|||||||
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
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 {
|
NavigationLink {
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||||
product = created
|
product = created
|
||||||
@@ -675,7 +691,11 @@ struct KassenzettelPruefenView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Menu {
|
Menu {
|
||||||
Button("– ohne –") { z.wrappedValue.ortId = nil }
|
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: {
|
||||||
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
|
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ struct CheckOutView: View {
|
|||||||
@State private var manualCodeShown = false
|
@State private var manualCodeShown = false
|
||||||
@State private var manualCode = ""
|
@State private var manualCode = ""
|
||||||
@State private var searchShown = false
|
@State private var searchShown = false
|
||||||
|
/// Per NFC gelesenes Einzelstueck – Auslagern laeuft dort ueber „Entfernen".
|
||||||
|
@State private var scannedItem: Item?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Kamera als Feld oben, siehe CheckInView.
|
// Kamera als Feld oben, siehe CheckInView.
|
||||||
@@ -48,6 +50,25 @@ struct CheckOutView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.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)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -70,6 +91,9 @@ struct CheckOutView: View {
|
|||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) { paused = false }
|
Button("Abbrechen", role: .cancel) { paused = false }
|
||||||
}
|
}
|
||||||
|
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
|
||||||
|
NavigationStack { ItemEditView(item: it) }
|
||||||
|
}
|
||||||
.sheet(isPresented: $searchShown) {
|
.sheet(isPresented: $searchShown) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ProductSearchView { picked in
|
ProductSearchView { picked in
|
||||||
|
|||||||
@@ -226,18 +226,33 @@ struct ShoppingCard: View {
|
|||||||
let stand: Int
|
let stand: Int
|
||||||
|
|
||||||
@State private var items: [ShoppingItem] = []
|
@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?
|
@State private var fehler: String?
|
||||||
|
|
||||||
|
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let fehler {
|
if let fehler {
|
||||||
Text(fehler).font(.caption).foregroundStyle(.red)
|
Text(fehler).font(.caption).foregroundStyle(.red)
|
||||||
} else if items.isEmpty {
|
} else if items.isEmpty && offene.isEmpty {
|
||||||
Text("Alle Mindestbestände sind gedeckt.")
|
Text("Alle Mindestbestände sind gedeckt.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
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 {
|
HStack {
|
||||||
Text(item.name).font(.callout)
|
Text(item.name).font(.callout)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -252,7 +267,11 @@ struct ShoppingCard: View {
|
|||||||
}
|
}
|
||||||
.task(id: stand) {
|
.task(id: stand) {
|
||||||
do {
|
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
|
fehler = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.fehler = error.localizedDescription
|
self.fehler = error.localizedDescription
|
||||||
@@ -291,7 +310,7 @@ struct ExpiryCard: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(item.productName).font(.callout)
|
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)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ struct GroupEditor: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Picker("Ort", selection: row.locationId) {
|
Picker("Ort", selection: row.locationId) {
|
||||||
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
||||||
ForEach(locations) { l in
|
ForEach(baum(locations)) { eintrag in
|
||||||
Text(l.path(in: locations)).tag(String?.some(l.id))
|
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextField("Menge", text: row.amount)
|
TextField("Menge", text: row.amount)
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ struct ItemListView: View {
|
|||||||
@State private var locationFilter: String? // Lagerort-ID
|
@State private var locationFilter: String? // Lagerort-ID
|
||||||
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
|
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
|
||||||
@State private var verleihFilter: VerleihFilter = .alle
|
@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
|
/// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die
|
||||||
/// Frage, die man im Alltag an diese Liste stellt.
|
/// Frage, die man im Alltag an diese Liste stellt.
|
||||||
@@ -28,6 +50,8 @@ struct ItemListView: View {
|
|||||||
}
|
}
|
||||||
@State private var busy = true
|
@State private var busy = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
|
||||||
|
@State private var zurueckFuer: Item?
|
||||||
|
|
||||||
private struct TreeEntry: Identifiable {
|
private struct TreeEntry: Identifiable {
|
||||||
let item: CategoryItem
|
let item: CategoryItem
|
||||||
@@ -95,6 +119,16 @@ struct ItemListView: View {
|
|||||||
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
|
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
|
||||||
case .imHaus: if it.loan != nil { 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 {
|
if !q.isEmpty {
|
||||||
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
let heu = [it.productName, it.uid, it.note, it.productBrand]
|
||||||
.compactMap { $0 }.joined(separator: " ").lowercased()
|
.compactMap { $0 }.joined(separator: " ").lowercased()
|
||||||
@@ -106,6 +140,7 @@ struct ItemListView: View {
|
|||||||
|
|
||||||
private var hatFilter: Bool {
|
private var hatFilter: Bool {
|
||||||
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
|
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
|
||||||
|
|| verwendungFilter != .alle
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -118,6 +153,23 @@ struct ItemListView: View {
|
|||||||
NavigationLink { ItemEditView(item: it) } label: {
|
NavigationLink { ItemEditView(item: it) } label: {
|
||||||
ItemRow(item: it, locations: locations)
|
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")
|
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
|
||||||
@@ -128,11 +180,39 @@ struct ItemListView: View {
|
|||||||
.refreshable { await load() }
|
.refreshable { await load() }
|
||||||
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
|
||||||
.onAppear { Task { await load() } }
|
.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 } })) {
|
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||||
Button("OK", role: .cancel) {}
|
Button("OK", role: .cancel) {}
|
||||||
} message: { Text(error ?? "") }
|
} 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 {
|
private var filterMenu: some View {
|
||||||
Menu {
|
Menu {
|
||||||
Menu("Lagerort") {
|
Menu("Lagerort") {
|
||||||
@@ -146,6 +226,11 @@ struct ItemListView: View {
|
|||||||
check(f.label, verleihFilter == f) { verleihFilter = f }
|
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 {
|
if !filterKategorien.isEmpty {
|
||||||
Menu("Kategorie") {
|
Menu("Kategorie") {
|
||||||
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
check("Alle", categoryFilter == nil) { categoryFilter = nil }
|
||||||
@@ -157,15 +242,18 @@ struct ItemListView: View {
|
|||||||
if hatFilter {
|
if hatFilter {
|
||||||
Divider()
|
Divider()
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
locationFilter = nil; categoryFilter = nil; verleihFilter = .alle
|
locationFilter = nil; categoryFilter = nil
|
||||||
|
verleihFilter = .alle; verwendungFilter = .alle
|
||||||
} label: {
|
} label: {
|
||||||
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
Label("Filter zurücksetzen", systemImage: "xmark.circle")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: hatFilter
|
Label(hatFilter ? verleihFilter.label : "Filter",
|
||||||
|
systemImage: hatFilter
|
||||||
? "line.3.horizontal.decrease.circle.fill"
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
: "line.3.horizontal.decrease.circle")
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +302,13 @@ struct ItemRow: View {
|
|||||||
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
|
||||||
.lineLimit(2)
|
.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()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|||||||
+430
-15
@@ -20,6 +20,41 @@ private func anzeigeDatum(_ iso: String?) -> String {
|
|||||||
return f.string(from: d)
|
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".
|
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
|
||||||
/// Ueberfaelliges kommt beim Aufrufer rot daher.
|
/// Ueberfaelliges kommt beim Aufrufer rot daher.
|
||||||
func verleihZeile(_ loan: Loan) -> String {
|
func verleihZeile(_ loan: Loan) -> String {
|
||||||
@@ -78,9 +113,14 @@ struct ProductItemsSection: View {
|
|||||||
var onChanged: () async -> Void
|
var onChanged: () async -> Void
|
||||||
/// Bis zur Liste schließen (für „Anlegen & fertig").
|
/// Bis zur Liste schließen (für „Anlegen & fertig").
|
||||||
var onFinish: (() -> Void)? = nil
|
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 items: [Item] = []
|
||||||
@State private var showAdd = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section("Einzelstücke (\(items.count))") {
|
Section("Einzelstücke (\(items.count))") {
|
||||||
@@ -97,17 +137,15 @@ struct ProductItemsSection: View {
|
|||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
|
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Button { showAdd = true } label: {
|
Button { anlegen = true } label: {
|
||||||
Label("Einzelstücke anlegen", systemImage: "plus")
|
Label("Einzelstücke anlegen", systemImage: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task(id: product.id) { await reload() }
|
.task(id: "\(product.id)-\(nachladen)") { await reload() }
|
||||||
.sheet(isPresented: $showAdd) {
|
// Kein .sheet hier. Aus einem Abschnitt heraus gezeigt, schloss es sich
|
||||||
NavigationStack {
|
// beim ERSTEN Oeffnen sofort wieder und hielt erst beim zweiten Versuch;
|
||||||
ItemAddSheet(productId: product.id, onDone: { await refresh() },
|
// ProductDetailView hat die Mengen-Blaetter aus genau diesem Grund
|
||||||
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
|
// laengst auf die Form-Ebene gelegt. Hier stand es nur nie nach.
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func untertitel(_ it: Item) -> String {
|
private func untertitel(_ it: Item) -> String {
|
||||||
@@ -223,6 +261,13 @@ struct ItemEditView: View {
|
|||||||
@State private var previewURL: URL?
|
@State private var previewURL: URL?
|
||||||
@State private var locScanShown = false
|
@State private var locScanShown = false
|
||||||
@State private var nfcShown = 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.
|
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
|
||||||
@State private var loan: Loan?
|
@State private var loan: Loan?
|
||||||
@State private var loanHistory: [Loan] = []
|
@State private var loanHistory: [Loan] = []
|
||||||
@@ -232,6 +277,17 @@ struct ItemEditView: View {
|
|||||||
@State private var hasDue = false
|
@State private var hasDue = false
|
||||||
@State private var due = Date()
|
@State private var due = Date()
|
||||||
@State private var busyLoan = false
|
@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 {
|
var body: some View {
|
||||||
Form {
|
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
|
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
|
||||||
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
|
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
|
||||||
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
|
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
|
||||||
@@ -279,7 +368,7 @@ struct ItemEditView: View {
|
|||||||
LabeledContent("Zurück bis",
|
LabeledContent("Zurück bis",
|
||||||
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
|
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")
|
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
|
||||||
}
|
}
|
||||||
.disabled(busyLoan)
|
.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 {
|
Section {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– ohne –").tag(String?.none)
|
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
|
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
|
||||||
// des Einzelstücks. Praktisch z.B. bei Kleidung.
|
// des Einzelstücks. Praktisch z.B. bei Kleidung.
|
||||||
@@ -400,6 +523,25 @@ struct ItemEditView: View {
|
|||||||
.sheet(isPresented: $nfcShown) {
|
.sheet(isPresented: $nfcShown) {
|
||||||
NavigationStack { NFCSchreibenView(item: item) }
|
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) {
|
.sheet(isPresented: $showRemove) {
|
||||||
NavigationStack { removeSheet }
|
NavigationStack { removeSheet }
|
||||||
}
|
}
|
||||||
@@ -445,6 +587,29 @@ struct ItemEditView: View {
|
|||||||
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
|
||||||
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
|
+ "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) } }
|
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
|
||||||
Section {
|
Section {
|
||||||
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
|
||||||
@@ -481,6 +646,9 @@ struct ItemEditView: View {
|
|||||||
hasDue = false
|
hasDue = false
|
||||||
due = Date()
|
due = Date()
|
||||||
error = nil
|
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
|
showLend = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,23 +658,68 @@ struct ItemEditView: View {
|
|||||||
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
|
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
|
||||||
borrower: borrower.trimmingCharacters(in: .whitespaces),
|
borrower: borrower.trimmingCharacters(in: .whitespaces),
|
||||||
reason: loanReason.isEmpty ? nil : loanReason,
|
reason: loanReason.isEmpty ? nil : loanReason,
|
||||||
dueOn: hasDue ? dateToString(due) : nil))
|
dueOn: hasDue ? dateToString(due) : nil,
|
||||||
|
mitZubehoer: Array(mitVerleihen)))
|
||||||
showLend = false
|
showLend = false
|
||||||
await ladeVerleihe()
|
await ladeVerleihe()
|
||||||
|
await ladeZubehoer()
|
||||||
onChanged?()
|
onChanged?()
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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 {
|
private func nimmZurueck() async {
|
||||||
busyLoan = true; defer { busyLoan = false }; error = nil
|
busyLoan = true; defer { busyLoan = false }; error = nil
|
||||||
do {
|
do {
|
||||||
_ = try await APIClient.shared.returnItem(id: item.id)
|
_ = try await APIClient.shared.returnItem(
|
||||||
|
id: item.id,
|
||||||
|
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
|
||||||
|
)
|
||||||
loan = nil
|
loan = nil
|
||||||
await ladeVerleihe()
|
await ladeVerleihe()
|
||||||
|
await ladeZubehoer()
|
||||||
onChanged?()
|
onChanged?()
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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
|
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
|
||||||
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
|
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
|
||||||
private func ladeVerleihe() async {
|
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.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
|
||||||
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
|
||||||
loan = item.loan
|
loan = item.loan
|
||||||
|
teile = item.teile
|
||||||
|
gehoertZu = item.gehoertZu
|
||||||
|
uebernimm(item.verwendung)
|
||||||
await ladeVerleihe()
|
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 {
|
private func save() async {
|
||||||
@@ -641,6 +885,73 @@ struct ItemEditView: View {
|
|||||||
|
|
||||||
// MARK: - Anlegen
|
// 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 {
|
struct ItemAddSheet: View {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
var onDone: () async -> Void
|
var onDone: () async -> Void
|
||||||
@@ -671,6 +982,12 @@ struct ItemAddSheet: View {
|
|||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var locScanShown = false
|
@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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
@@ -678,7 +995,7 @@ struct ItemAddSheet: View {
|
|||||||
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– ohne –").tag(String?.none)
|
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: {
|
Button { locScanShown = true } label: {
|
||||||
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
|
||||||
@@ -733,6 +1050,22 @@ struct ItemAddSheet: View {
|
|||||||
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
|
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
|
||||||
.disabled(busy)
|
.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")
|
.navigationTitle("Einzelstücke anlegen")
|
||||||
@@ -766,6 +1099,28 @@ struct ItemAddSheet: View {
|
|||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
shops = (try? await APIClient.shared.shops()) ?? []
|
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
|
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
|
||||||
@@ -797,7 +1152,7 @@ struct ItemAddSheet: View {
|
|||||||
: "Übernommen: \(teile.joined(separator: ", "))."
|
: "Ü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
|
busy = true; defer { busy = false }; error = nil
|
||||||
do {
|
do {
|
||||||
var sid = shopId
|
var sid = shopId
|
||||||
@@ -819,12 +1174,72 @@ struct ItemAddSheet: View {
|
|||||||
itemId: first.id, data: data,
|
itemId: first.id, data: data,
|
||||||
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
|
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()
|
await onDone()
|
||||||
if finish, let onFinish { onFinish() } else { dismiss() }
|
if finish, let onFinish { onFinish() } else { dismiss() }
|
||||||
} catch { self.error = error.localizedDescription }
|
} 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
|
// MARK: - Verleih-Verlauf
|
||||||
|
|
||||||
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
|
/// 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 items: [ShoppingItem] = []
|
||||||
@State private var groups: [GroupShoppingItem] = []
|
@State private var groups: [GroupShoppingItem] = []
|
||||||
@State private var byLocation: [LocationNeeds] = []
|
@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 busy = true
|
||||||
@State private var error: String?
|
@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 {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
if let error {
|
if let error {
|
||||||
Section { Text(error).foregroundStyle(.red).font(.callout) }
|
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 {
|
if !items.isEmpty {
|
||||||
Section("Artikel unter Mindestbestand") {
|
Section("Artikel unter Mindestbestand") {
|
||||||
ForEach(items) { item in
|
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.")
|
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -114,13 +140,32 @@ struct ShoppingListView: View {
|
|||||||
.task { await load() }
|
.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 {
|
private func load() async {
|
||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
do {
|
do {
|
||||||
items = try await APIClient.shared.shoppingList()
|
// EIN Aufruf statt drei - und der einzige, der die selbst
|
||||||
groups = try await APIClient.shared.shoppingGroups()
|
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
|
||||||
byLocation = try await APIClient.shared.shoppingByLocation()
|
// 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
|
error = nil
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
@@ -146,7 +191,7 @@ struct ExpiringView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.productName)
|
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)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -362,6 +407,10 @@ struct ProductListView: View {
|
|||||||
@State private var verwaltungFilter: String?
|
@State private var verwaltungFilter: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var showNew = 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 {
|
enum SortMode: String, CaseIterable, Identifiable {
|
||||||
case nameAsc = "Name A–Z"
|
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 {
|
NavigationStack {
|
||||||
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
|
||||||
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
|
// vorbelegen. Nach dem Anlegen schließt sich das Formular, die
|
||||||
// Liste wird aufgefrischt.
|
// Liste wird aufgefrischt – und bei Gegenständen geht es gleich
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
|
// weiter: zu den Einzelstücken, oder zur Menge je Lagerort.
|
||||||
|
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
|
||||||
|
angelegt = created
|
||||||
showNew = false
|
showNew = false
|
||||||
Task { await load() }
|
Task { await load() }
|
||||||
}
|
}
|
||||||
@@ -504,6 +560,7 @@ struct ProductListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.nachAnlegen($nachAnlegenFuer)
|
||||||
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
|
||||||
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
|
||||||
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
|
// 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 {
|
if let size = product.packageSize, size > 0 {
|
||||||
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
|
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
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,3 +657,430 @@ struct ProductRow: View {
|
|||||||
product.stockLevel == .below ? .red : .orange
|
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))
|
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||||
.padding(.horizontal)
|
.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)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ struct LookupScanView: View {
|
|||||||
} onFehler: { meldung in
|
} onFehler: { meldung in
|
||||||
error = meldung
|
error = meldung
|
||||||
}
|
}
|
||||||
|
// Unauffaellig unter der Kamerakarte uebersieht man ihn sonst.
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
Text(error).font(.callout).foregroundStyle(.red)
|
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 addShown = false
|
||||||
@State private var pendingDeletion: Item?
|
@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) }
|
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 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) })
|
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
||||||
func hidden(_ id: Item.ID) -> Bool {
|
func hidden(_ id: Item.ID) -> Bool {
|
||||||
var p = parent[id] ?? nil
|
var p = parent[id] ?? nil
|
||||||
@@ -233,7 +211,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return nodes.filter { !hidden($0.item.id) }
|
return baum(shown).filter { !hidden($0.item.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -286,7 +264,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func row(_ node: Node) -> some View {
|
private func row(_ node: Baumzeile<Item>) -> some View {
|
||||||
let kinder = childCount(node.item.id)
|
let kinder = childCount(node.item.id)
|
||||||
let zu = collapsed.contains(node.item.id)
|
let zu = collapsed.contains(node.item.id)
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -321,7 +299,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.leading, CGFloat(node.depth) * 16)
|
.padding(.leading, CGFloat(node.tiefe) * 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggle(_ id: Item.ID) {
|
private func toggle(_ id: Item.ID) {
|
||||||
@@ -468,8 +446,8 @@ struct LocationEditor: View {
|
|||||||
Section {
|
Section {
|
||||||
Picker("Übergeordnet", selection: $parentId) {
|
Picker("Übergeordnet", selection: $parentId) {
|
||||||
Text("– oberste Ebene –").tag(String?.none)
|
Text("– oberste Ebene –").tag(String?.none)
|
||||||
ForEach(parentOptions) { loc in
|
ForEach(baum(parentOptions)) { eintrag in
|
||||||
Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
|
Text(eintrag.label).tag(String?.some(eintrag.item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -523,19 +501,6 @@ struct LocationEditor: View {
|
|||||||
}
|
}
|
||||||
return result
|
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 {
|
struct UnitsView: View {
|
||||||
@@ -792,6 +757,7 @@ struct CategoryEditor: View {
|
|||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var tracking: String
|
@State private var tracking: String
|
||||||
|
@State private var usageTracking: Bool
|
||||||
@State private var parentId: Int? // nur beim Anlegen
|
@State private var parentId: Int? // nur beim Anlegen
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@@ -802,6 +768,7 @@ struct CategoryEditor: View {
|
|||||||
self.done = done
|
self.done = done
|
||||||
_name = State(initialValue: item?.name ?? "")
|
_name = State(initialValue: item?.name ?? "")
|
||||||
_tracking = State(initialValue: item?.tracking ?? "object")
|
_tracking = State(initialValue: item?.tracking ?? "object")
|
||||||
|
_usageTracking = State(initialValue: item?.fuehrtVerwendung ?? false)
|
||||||
_parentId = State(initialValue: nil)
|
_parentId = State(initialValue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,8 +787,8 @@ struct CategoryEditor: View {
|
|||||||
Section {
|
Section {
|
||||||
Picker("Übergeordnet", selection: $parentId) {
|
Picker("Übergeordnet", selection: $parentId) {
|
||||||
Text("– oberste Ebene –").tag(Int?.none)
|
Text("– oberste Ebene –").tag(Int?.none)
|
||||||
ForEach(all) { c in
|
ForEach(baum(all)) { eintrag in
|
||||||
Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id))
|
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} 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 {
|
if let item {
|
||||||
Section {
|
Section {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -882,13 +857,17 @@ struct CategoryEditor: View {
|
|||||||
do {
|
do {
|
||||||
if let item {
|
if let item {
|
||||||
_ = try await APIClient.shared.updateCategory(
|
_ = try await APIClient.shared.updateCategory(
|
||||||
id: item.id, CategoryUpdateRequest(name: n, tracking: tracking))
|
id: item.id,
|
||||||
|
CategoryUpdateRequest(name: n, tracking: tracking,
|
||||||
|
usageTracking: usageTracking))
|
||||||
} else {
|
} else {
|
||||||
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
|
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
|
||||||
// gewählte Art.
|
// gewählte Art.
|
||||||
_ = try await APIClient.shared.createCategory(
|
_ = try await APIClient.shared.createCategory(
|
||||||
NewCategoryRequest(name: n, parentId: parentId,
|
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()
|
done()
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -896,17 +875,6 @@ struct CategoryEditor: View {
|
|||||||
self.error = error.localizedDescription
|
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
|
// MARK: - Eigene Felder je Kategorie
|
||||||
|
|||||||
+363
-3
@@ -344,6 +344,14 @@ struct Lot: Codable, Identifiable, Hashable {
|
|||||||
let bestBefore: String?
|
let bestBefore: String?
|
||||||
let bestBeforePrecision: String?
|
let bestBeforePrecision: String?
|
||||||
let locationId: 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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, quantity
|
case id, quantity
|
||||||
@@ -351,9 +359,121 @@ struct Lot: Codable, Identifiable, Hashable {
|
|||||||
case bestBefore = "best_before"
|
case bestBefore = "best_before"
|
||||||
case bestBeforePrecision = "best_before_precision"
|
case bestBeforePrecision = "best_before_precision"
|
||||||
case locationId = "location_id"
|
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 {
|
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -482,6 +602,59 @@ struct NewProductRequest: Codable {
|
|||||||
|
|
||||||
// MARK: - Listen (Einkaufsliste, bald ablaufend)
|
// 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 {
|
struct ShoppingItem: Codable, Identifiable {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let name: String
|
let name: String
|
||||||
@@ -617,8 +790,19 @@ struct ExpiringItem: Codable, Identifiable {
|
|||||||
let productName: String
|
let productName: String
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let baseUnit: String
|
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 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 daysLeft: Int
|
||||||
let packageSize: Double?
|
let packageSize: Double?
|
||||||
let packageLabel: String?
|
let packageLabel: String?
|
||||||
@@ -627,6 +811,17 @@ struct ExpiringItem: Codable, Identifiable {
|
|||||||
|
|
||||||
var id: Int { lotId }
|
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
|
/// Restlaufzeit als kurzer Text. „0 Tage" ergibt keinen Sinn - am Ablauftag
|
||||||
/// selbst steht „heute". Karte und Liste nutzen dieselbe Formulierung.
|
/// selbst steht „heute". Karte und Liste nutzen dieselbe Formulierung.
|
||||||
var restlaufzeitText: String {
|
var restlaufzeitText: String {
|
||||||
@@ -644,6 +839,10 @@ struct ExpiringItem: Codable, Identifiable {
|
|||||||
case baseUnit = "base_unit"
|
case baseUnit = "base_unit"
|
||||||
case bestBefore = "best_before"
|
case bestBefore = "best_before"
|
||||||
case bestBeforePrecision = "best_before_precision"
|
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 daysLeft = "days_left"
|
||||||
case packageSize = "package_size"
|
case packageSize = "package_size"
|
||||||
case packageLabel = "package_label"
|
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 {
|
struct LotUpdateRequest: Encodable {
|
||||||
var quantity: Double?
|
var quantity: Double?
|
||||||
var bestBefore: String?
|
var bestBefore: String?
|
||||||
@@ -864,13 +1079,18 @@ struct CategoryItem: Codable, Identifiable, Hashable {
|
|||||||
let name: String
|
let name: String
|
||||||
let parentId: Int?
|
let parentId: Int?
|
||||||
let tracking: String?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, tracking
|
case id, name, tracking
|
||||||
case parentId = "parent_id"
|
case parentId = "parent_id"
|
||||||
|
case usageTracking = "usage_tracking"
|
||||||
}
|
}
|
||||||
|
|
||||||
var isObject: Bool { tracking == "object" }
|
var isObject: Bool { tracking == "object" }
|
||||||
|
var fuehrtVerwendung: Bool { usageTracking ?? false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hierarchische Stammdaten
|
// MARK: - Hierarchische Stammdaten
|
||||||
@@ -1051,6 +1271,73 @@ struct ItemDocument: Codable, Identifiable, Hashable {
|
|||||||
///
|
///
|
||||||
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
|
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
|
||||||
/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig.
|
/// 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 {
|
struct Loan: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let itemId: Int
|
let itemId: Int
|
||||||
@@ -1094,18 +1381,55 @@ struct LoanCreateRequest: Encodable {
|
|||||||
var reason: String? = nil
|
var reason: String? = nil
|
||||||
var lentOn: String? = nil
|
var lentOn: String? = nil
|
||||||
var dueOn: 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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case borrower, reason
|
case borrower, reason
|
||||||
case lentOn = "lent_on"
|
case lentOn = "lent_on"
|
||||||
case dueOn = "due_on"
|
case dueOn = "due_on"
|
||||||
|
case mitZubehoer = "mit_zubehoer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoanReturnRequest: Encodable {
|
struct LoanReturnRequest: Encodable {
|
||||||
var returnedOn: String? = nil
|
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 {
|
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 offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
|
||||||
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
|
||||||
let loan: Loan?
|
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 }
|
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 {
|
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 productId = "product_id"
|
||||||
case locationId = "location_id"
|
case locationId = "location_id"
|
||||||
case locationName = "location_name"
|
case locationName = "location_name"
|
||||||
@@ -1515,16 +1865,26 @@ struct NewCategoryRequest: Codable {
|
|||||||
let name: String
|
let name: String
|
||||||
let parentId: Int?
|
let parentId: Int?
|
||||||
var tracking: String? = nil
|
var tracking: String? = nil
|
||||||
|
/// nil = erbt vom Elternteil bzw. neue Oberkategorie = aus.
|
||||||
|
var usageTracking: Bool? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name, tracking
|
case name, tracking
|
||||||
case parentId = "parent_id"
|
case parentId = "parent_id"
|
||||||
|
case usageTracking = "usage_tracking"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CategoryUpdateRequest: Codable {
|
struct CategoryUpdateRequest: Codable {
|
||||||
let name: String?
|
let name: String?
|
||||||
let tracking: 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 {
|
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 {
|
private enum Auftrag {
|
||||||
case lesen
|
case lesen
|
||||||
case schreiben(nutzlast: String, sperren: Bool)
|
case schreiben(nutzlast: String, sperren: Bool)
|
||||||
@@ -155,8 +197,10 @@ final class NFCDienst: NSObject {
|
|||||||
if let meldung {
|
if let meldung {
|
||||||
session?.alertMessage = meldung
|
session?.alertMessage = meldung
|
||||||
session?.invalidate()
|
session?.invalidate()
|
||||||
} else if case .failure = ergebnis {
|
} else if case let .failure(fehler) = ergebnis {
|
||||||
session?.invalidate()
|
// 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
|
session = nil
|
||||||
fortsetzung.resume(with: ergebnis)
|
fortsetzung.resume(with: ergebnis)
|
||||||
@@ -166,7 +210,7 @@ final class NFCDienst: NSObject {
|
|||||||
private func lesen(_ tag: NFCNDEFTag) {
|
private func lesen(_ tag: NFCNDEFTag) {
|
||||||
tag.readNDEF { nachricht, fehler in
|
tag.readNDEF { nachricht, fehler in
|
||||||
if let fehler {
|
if let fehler {
|
||||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let text = nachricht?.records.compactMap(Self.text(aus:)).first, !text.isEmpty else {
|
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
|
tag.queryNDEFStatus { status, kapazitaet, fehler in
|
||||||
if let fehler {
|
if let fehler {
|
||||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch status {
|
switch status {
|
||||||
@@ -210,7 +254,7 @@ final class NFCDienst: NSObject {
|
|||||||
|
|
||||||
tag.writeNDEF(nachricht) { fehler in
|
tag.writeNDEF(nachricht) { fehler in
|
||||||
if let fehler {
|
if let fehler {
|
||||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard sperren else {
|
guard sperren else {
|
||||||
@@ -224,8 +268,8 @@ final class NFCDienst: NSObject {
|
|||||||
// Das gehört gesagt, statt den ganzen Vorgang als
|
// Das gehört gesagt, statt den ganzen Vorgang als
|
||||||
// gescheitert auszugeben.
|
// gescheitert auszugeben.
|
||||||
self.fertig(.failure(Fehler.system(
|
self.fertig(.failure(Fehler.system(
|
||||||
"Tag beschrieben, aber das Sperren ist fehlgeschlagen: "
|
"Tag beschrieben, Sperren fehlgeschlagen: "
|
||||||
+ fehler.localizedDescription)))
|
+ Self.kurz(fehler))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
AudioServicesPlaySystemSound(1057)
|
AudioServicesPlaySystemSound(1057)
|
||||||
@@ -274,7 +318,7 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
|
|||||||
}
|
}
|
||||||
session.connect(to: ersteser) { fehler in
|
session.connect(to: ersteser) { fehler in
|
||||||
if let fehler {
|
if let fehler {
|
||||||
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
|
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch self.auftrag {
|
switch self.auftrag {
|
||||||
@@ -293,6 +337,6 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
|
|||||||
let abbruch = (error as? NFCReaderError)?.code
|
let abbruch = (error as? NFCReaderError)?.code
|
||||||
== .readerSessionInvalidationErrorUserCanceled
|
== .readerSessionInvalidationErrorUserCanceled
|
||||||
self.session = nil
|
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>`.
|
/// steht – `vorrania://i/<UID>`.
|
||||||
struct NFCSchreibenView: View {
|
struct NFCSchreibenView: View {
|
||||||
let item: Item
|
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
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@@ -28,30 +33,40 @@ struct NFCSchreibenView: View {
|
|||||||
Text("Auf das Tag wird geschrieben: \(nutzlast)")
|
Text("Auf das Tag wird geschrieben: \(nutzlast)")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
// Nach dem Erfolg hat das Tag seinen Inhalt. Knopf und Schalter
|
||||||
Toggle("Tag dauerhaft schreibschützen", isOn: $sperren)
|
// stehenzulassen hiesse, es noch einmal beschreiben zu wollen.
|
||||||
} footer: {
|
if !erfolg {
|
||||||
if sperren {
|
Section {
|
||||||
Text("Das lässt sich nicht rückgängig machen. Das Tag gehört danach "
|
Toggle("Tag dauerhaft schreibschützen", isOn: $sperren)
|
||||||
+ "für immer zu diesem Einzelstück und kann nicht neu beschrieben werden.")
|
} footer: {
|
||||||
.foregroundStyle(.orange)
|
if sperren {
|
||||||
} else {
|
Text("Das lässt sich nicht rückgängig machen. Das Tag gehört danach "
|
||||||
Text("Ohne Schreibschutz kannst du das Tag später einem anderen "
|
+ "für immer zu diesem Einzelstück und kann nicht neu beschrieben werden.")
|
||||||
+ "Einzelstück zuweisen.")
|
.foregroundStyle(.orange)
|
||||||
|
} else {
|
||||||
|
Text("Ohne Schreibschutz kannst du das Tag später einem anderen "
|
||||||
|
+ "Einzelstück zuweisen.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button(laeuft ? "Tag anhalten…" : "Tag beschreiben") {
|
Button(laeuft ? "Tag anhalten…" : "Tag beschreiben") {
|
||||||
Task { await schreiben() }
|
Task { await schreiben() }
|
||||||
|
}
|
||||||
|
.disabled(laeuft)
|
||||||
}
|
}
|
||||||
.disabled(laeuft)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if erfolg {
|
if erfolg {
|
||||||
Section {
|
Section {
|
||||||
Label("Tag beschrieben.", systemImage: "checkmark.circle")
|
Label(sperren ? "Tag beschrieben und gesperrt." : "Tag beschrieben.",
|
||||||
|
systemImage: "checkmark.circle")
|
||||||
.foregroundStyle(.green)
|
.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 {
|
if let fehler {
|
||||||
@@ -62,7 +77,11 @@ struct NFCSchreibenView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("NFC-Tag")
|
.navigationTitle("NFC-Tag")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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 {
|
private func schreiben() async {
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ enum ExpiryPlanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for item in snapshot.expiring {
|
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)
|
let mhdTag = calendar.startOfDay(for: mhd)
|
||||||
|
|
||||||
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
|
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ private struct LocationPicker: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(title, selection: $selection) {
|
Picker(title, selection: $selection) {
|
||||||
Text("– ohne Lagerort –").tag(String?.none)
|
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
|
let product: Product
|
||||||
|
|
||||||
@EnvironmentObject private var display: DisplaySettings
|
@EnvironmentObject private var display: DisplaySettings
|
||||||
|
@EnvironmentObject private var session: Session
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var current: Product
|
@State private var current: Product
|
||||||
@State private var showLeaveConfirm = false
|
@State private var showLeaveConfirm = false
|
||||||
|
@State private var pendingDelete = false
|
||||||
@State private var lots: [Lot] = []
|
@State private var lots: [Lot] = []
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -44,6 +46,9 @@ struct ProductDetailView: View {
|
|||||||
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
|
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
|
||||||
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
|
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
|
||||||
@State private var objectSheet: ObjectStockSheet?
|
@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?
|
@State private var objectRemovals: RemovalSummary?
|
||||||
|
|
||||||
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
|
||||||
@@ -96,7 +101,9 @@ struct ProductDetailView: View {
|
|||||||
|
|
||||||
if current.isObject && current.isIndividual {
|
if current.isObject && current.isIndividual {
|
||||||
ProductItemsSection(product: current, onChanged: { await reload() },
|
ProductItemsSection(product: current, onChanged: { await reload() },
|
||||||
onFinish: { dismiss() })
|
onFinish: { dismiss() },
|
||||||
|
nachladen: itemsNachladen,
|
||||||
|
anlegen: $itemsAnlegen)
|
||||||
} else if current.isObject && !current.isBulk {
|
} else if current.isObject && !current.isBulk {
|
||||||
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
|
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
|
||||||
ObjectStockSection(product: current, locations: locations, lots: lots,
|
ObjectStockSection(product: current, locations: locations, lots: lots,
|
||||||
@@ -284,7 +291,15 @@ struct ProductDetailView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
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
|
// Ohne den Lagerort sehen zwei Chargen mit
|
||||||
// gleichem MHD identisch aus – er ist hier
|
// gleichem MHD identisch aus – er ist hier
|
||||||
// oft das einzige Unterscheidungsmerkmal.
|
// oft das einzige Unterscheidungsmerkmal.
|
||||||
@@ -313,6 +328,14 @@ struct ProductDetailView: View {
|
|||||||
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
|
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)
|
.navigationTitle(current.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -331,6 +354,15 @@ struct ProductDetailView: View {
|
|||||||
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
|
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
|
||||||
+ "Foto und Lagerort-Bedarf werden sofort 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
|
.sheet(item: $editLot) { lot in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LotEditView(lot: lot, product: current, locations: locations) {
|
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
|
// Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst
|
||||||
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
|
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
|
||||||
.sheet(item: $objectSheet) { welche in
|
.sheet(item: $objectSheet) { welche in
|
||||||
@@ -598,6 +642,25 @@ struct ProductDetailView: View {
|
|||||||
}
|
}
|
||||||
await reload()
|
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 –
|
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht –
|
||||||
@@ -680,7 +743,7 @@ struct LotEditView: View {
|
|||||||
Section("Lagerort") {
|
Section("Lagerort") {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– ohne –").tag(String?.none)
|
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.
|
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
|
||||||
Button { locScanShown = true } label: {
|
Button { locScanShown = true } label: {
|
||||||
@@ -792,7 +855,7 @@ struct ProductLocationMinView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Picker("Ort", selection: $row.locationId) {
|
Picker("Ort", selection: $row.locationId) {
|
||||||
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
|
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)
|
TextField("Menge", text: $row.amount)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ struct RootView: View {
|
|||||||
if session.isLoggedIn {
|
if session.isLoggedIn {
|
||||||
await display.load()
|
await display.load()
|
||||||
await session.refreshMe()
|
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
|
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
|
||||||
@@ -117,6 +120,10 @@ struct ScanTabView: View {
|
|||||||
@EnvironmentObject private var router: Router
|
@EnvironmentObject private var router: Router
|
||||||
@EnvironmentObject private var session: Session
|
@EnvironmentObject private var session: Session
|
||||||
@State private var showNew = 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?
|
||||||
@State private var showKassenzettel = false
|
@State private var showKassenzettel = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -174,13 +181,20 @@ struct ScanTabView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.navigationTitle("Scannen")
|
.navigationTitle("Scannen")
|
||||||
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
|
||||||
.sheet(isPresented: $showNew) {
|
.sheet(isPresented: $showNew, onDismiss: {
|
||||||
|
if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) {
|
||||||
|
nachAnlegenFuer = angelegt
|
||||||
|
}
|
||||||
|
angelegt = nil
|
||||||
|
}) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
|
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||||
|
angelegt = created
|
||||||
showNew = false
|
showNew = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.nachAnlegen($nachAnlegenFuer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +223,12 @@ struct ListsTabView: View {
|
|||||||
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
|
||||||
systemImage: "tag")
|
systemImage: "tag")
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
ChargenListView()
|
||||||
|
} label: {
|
||||||
|
ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen",
|
||||||
|
systemImage: "shippingbox.and.arrow.backward")
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ShoppingListView()
|
ShoppingListView()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -224,6 +224,24 @@ final class Session: ObservableObject {
|
|||||||
// kurzer Netzausfall einen nicht aus der App wirft.
|
// 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.
|
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
|
||||||
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
|
||||||
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.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 */; };
|
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
|
||||||
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
|
||||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +117,7 @@
|
|||||||
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
378F1B2DE567B62169E84426 /* APIClient.swift */,
|
||||||
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
|
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
|
||||||
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
|
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
|
||||||
|
85F4CDB145DD84793DFC90F1 /* Baum.swift */,
|
||||||
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
|
||||||
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
|
||||||
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
|
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
|
||||||
@@ -253,6 +256,7 @@
|
|||||||
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
|
||||||
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
|
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
|
||||||
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
|
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
|
||||||
|
93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */,
|
||||||
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
|
||||||
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
|
||||||
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
|
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
|
||||||
@@ -367,7 +371,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
@@ -390,7 +394,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ targets:
|
|||||||
# MARKETING_VERSION ist die sichtbare Version (App Store),
|
# MARKETING_VERSION ist die sichtbare Version (App Store),
|
||||||
# CURRENT_PROJECT_VERSION die Build-Nummer: muss bei jedem Upload
|
# CURRENT_PROJECT_VERSION die Build-Nummer: muss bei jedem Upload
|
||||||
# innerhalb derselben Version steigen.
|
# innerhalb derselben Version steigen.
|
||||||
MARKETING_VERSION: "1.1"
|
MARKETING_VERSION: "1.2"
|
||||||
CURRENT_PROJECT_VERSION: "2"
|
CURRENT_PROJECT_VERSION: "2"
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
SWIFT_VERSION: "5.9"
|
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 { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { useAuth } from "./auth";
|
import { useAuth } from "./auth";
|
||||||
|
import { gruppenPfade } from "./nav";
|
||||||
import Icon from "./components/Icon";
|
import Icon from "./components/Icon";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import BrandMark from "./components/BrandMark";
|
import BrandMark from "./components/BrandMark";
|
||||||
@@ -23,17 +24,39 @@ import ItemResolve from "./pages/ItemResolve";
|
|||||||
import LocationResolve from "./pages/LocationResolve";
|
import LocationResolve from "./pages/LocationResolve";
|
||||||
import Locations from "./pages/Locations";
|
import Locations from "./pages/Locations";
|
||||||
import PackageTypes from "./pages/PackageTypes";
|
import PackageTypes from "./pages/PackageTypes";
|
||||||
|
import Masse from "./pages/Masse";
|
||||||
import Units from "./pages/Units";
|
import Units from "./pages/Units";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import ShoppingList from "./pages/ShoppingList";
|
import ShoppingList from "./pages/ShoppingList";
|
||||||
|
import Rezepte from "./pages/Rezepte";
|
||||||
import History from "./pages/History";
|
import History from "./pages/History";
|
||||||
|
import Verliehen from "./pages/Verliehen";
|
||||||
import Transfer from "./pages/Transfer";
|
import Transfer from "./pages/Transfer";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import Konto from "./pages/Konto";
|
import Konto from "./pages/Konto";
|
||||||
|
|
||||||
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
|
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 }) {
|
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 (
|
return (
|
||||||
<NavLink to={to} end={end} className={navClass}>
|
<NavLink to={to} end={end} className={navClass}>
|
||||||
<Icon name={icon} size={17} />
|
<Icon name={icon} size={17} />
|
||||||
@@ -45,7 +68,34 @@ function NavItem({ to, icon, label, end }) {
|
|||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const [dashboards, setDashboards] = useState([]);
|
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.
|
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
|
||||||
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
|
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
|
||||||
@@ -59,6 +109,24 @@ function Sidebar() {
|
|||||||
return () => window.removeEventListener("dashboards-geaendert", laden);
|
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() {
|
function handleLogout() {
|
||||||
logout();
|
logout();
|
||||||
navigate("/login");
|
navigate("/login");
|
||||||
@@ -69,36 +137,64 @@ function Sidebar() {
|
|||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand">
|
||||||
<BrandMark />
|
<BrandMark />
|
||||||
</div>
|
</div>
|
||||||
|
<VersteckteNav.Provider value={versteckt}>
|
||||||
<nav className="sidebar-nav">
|
<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
|
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
|
||||||
waere sie nur eine Verdopplung von "Übersicht". */}
|
waere sie nur eine Verdopplung von "Übersicht". */}
|
||||||
{dashboards.length > 1 && dashboards.map((d, index) => (
|
{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")}>
|
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
|
||||||
<span className="label">{d.name}</span>
|
<span className="label">{d.name}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
|
||||||
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
|
||||||
<NavItem to="/lebensmittel" icon="food" label="Lebensmittel" />
|
{/* Auf schmalen Bildschirmen wird die Leiste waagerecht; dort gibt es
|
||||||
<NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
|
keine Abschnitte (styles.css) und die Eintraege stehen flach
|
||||||
<NavItem to="/items" icon="tag" label="Einzelstücke" />
|
nebeneinander – zuklappen loest dort kein Platzproblem. */}
|
||||||
<NavItem to="/chargen" icon="box" label="Chargen" />
|
{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="/shopping" icon="cart" label="Einkaufsliste" />
|
||||||
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
|
||||||
|
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
|
||||||
<NavItem to="/history" icon="history" label="Verlauf" />
|
<NavItem to="/history" icon="history" label="Verlauf" />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
|
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
|
||||||
gepflegt – deshalb alle zusammen hier, wie in der iOS-App. */}
|
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="/categories" icon="package" label="Kategorien" />
|
||||||
<NavItem to="/groups" icon="tag" label="Gruppen" />
|
<NavItem to="/groups" icon="tag" label="Gruppen" />
|
||||||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||||||
<NavItem to="/shops" icon="cart" label="Shops" />
|
<NavItem to="/shops" icon="cart" label="Shops" />
|
||||||
<NavItem to="/units" icon="box" label="Einheiten" />
|
<NavItem to="/units" icon="box" label="Einheiten" />
|
||||||
<NavItem to="/package-types" icon="package" label="Gebinde" />
|
<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>
|
<div className="nav-section">System</div>
|
||||||
<NavItem to="/users" icon="users" label="Benutzer" />
|
<NavItem to="/users" icon="users" label="Benutzer" />
|
||||||
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
<NavItem to="/transfer" icon="history" label="Import / Export" />
|
||||||
@@ -106,6 +202,7 @@ function Sidebar() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
</VersteckteNav.Provider>
|
||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
|
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
|
||||||
<NavLink to="/konto" className="sidebar-user" title="Mein 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 (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
|
||||||
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
|
||||||
if (!user) return <Navigate to="/login" replace />;
|
if (!user) return <Navigate to="/login" replace />;
|
||||||
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
|
if (adminOnly && !isAdmin) return <Navigate to="/home" replace />;
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -156,8 +253,14 @@ function Protected({ children, adminOnly = false, wide = false }) {
|
|||||||
<div className={wide ? "content content--wide" : "content"}>
|
<div className={wide ? "content content--wide" : "content"}>
|
||||||
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
|
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
|
||||||
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
|
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
|
||||||
zurueck, sonst haenge man nach einem Fehler dauerhaft fest. */}
|
zurueck, sonst haenge man nach einem Fehler dauerhaft fest.
|
||||||
<ErrorBoundary key={pfad} bereich="Diese Seite">{children}</ErrorBoundary>
|
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,10 +274,19 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
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" 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="/checkin" element={<Protected><CheckIn /></Protected>} />
|
||||||
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
|
||||||
<Route path="/lebensmittel" element={<Protected wide><Products fixedType="food" /></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="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
|
||||||
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
|
||||||
<Route path="/shopping" element={<Protected><ShoppingList /></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>} />
|
<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>} />
|
<Route path="/history" element={<Protected><History /></Protected>} />
|
||||||
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
|
||||||
<Route path="/konto" element={<Protected><Konto /></Protected>} />
|
<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="/locations" element={<Protected adminOnly><Locations /></Protected>} />
|
||||||
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
|
||||||
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></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="/users" element={<Protected adminOnly><Users /></Protected>} />
|
||||||
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||||
@@ -206,7 +324,7 @@ export default function App() {
|
|||||||
<Route path="/auth/callback" element={<OidcCallback />} />
|
<Route path="/auth/callback" element={<OidcCallback />} />
|
||||||
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
|
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
|
||||||
<Route path="/sso-neu" element={<SsoNeu />} />
|
<Route path="/sso-neu" element={<SsoNeu />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+136
@@ -154,6 +154,10 @@ export const api = {
|
|||||||
login: (username, password) =>
|
login: (username, password) =>
|
||||||
request("/auth/login", { method: "POST", form: { username, password } }),
|
request("/auth/login", { method: "POST", form: { username, password } }),
|
||||||
me: () => request("/auth/me"),
|
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
|
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
|
||||||
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
|
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
|
||||||
// und es taucht kein SSO-Knopf auf.
|
// und es taucht kein SSO-Knopf auf.
|
||||||
@@ -232,6 +236,18 @@ export const api = {
|
|||||||
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
|
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
|
||||||
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
|
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
|
||||||
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
|
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
|
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
|
||||||
// in so vielen Tagen ansteht (überfällige sind immer dabei).
|
// in so vielen Tagen ansteht (überfällige sind immer dabei).
|
||||||
loans: ({ openOnly = true, dueIn } = {}) => {
|
loans: ({ openOnly = true, dueIn } = {}) => {
|
||||||
@@ -260,9 +276,47 @@ export const api = {
|
|||||||
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
|
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
|
||||||
bulkLotLocation: (lotIds, locationId) =>
|
bulkLotLocation: (lotIds, locationId) =>
|
||||||
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
|
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" }),
|
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
||||||
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
||||||
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
|
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).
|
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
|
||||||
setProductLocationMinStock: (id, list) =>
|
setProductLocationMinStock: (id, list) =>
|
||||||
@@ -277,6 +331,21 @@ export const api = {
|
|||||||
|
|
||||||
// Views
|
// Views
|
||||||
shoppingList: () => request("/shopping-list"),
|
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"),
|
groupShoppingList: () => request("/shopping-list/groups"),
|
||||||
shoppingByLocation: () => request("/shopping-list/by-location"),
|
shoppingByLocation: () => request("/shopping-list/by-location"),
|
||||||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||||||
@@ -353,6 +422,44 @@ export const api = {
|
|||||||
exportJson: () =>
|
exportJson: () =>
|
||||||
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
||||||
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
|
// 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) =>
|
labelRows: (categoryIds) =>
|
||||||
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
|
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
|
||||||
importStock: (file, mode = "add") => {
|
importStock: (file, mode = "add") => {
|
||||||
@@ -394,6 +501,35 @@ export const api = {
|
|||||||
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
|
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
|
||||||
|
|
||||||
// Einstellungen
|
// 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"),
|
listSettings: () => request("/settings"),
|
||||||
setSetting: (key, value) =>
|
setSetting: (key, value) =>
|
||||||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ export function AuthProvider({ children }) {
|
|||||||
// meldet einen ab.
|
// meldet einen ab.
|
||||||
const [startFehler, setStartFehler] = useState(null);
|
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() {
|
async function pruefen() {
|
||||||
setStartFehler(null);
|
setStartFehler(null);
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
@@ -21,6 +37,7 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setUser(await api.me());
|
setUser(await api.me());
|
||||||
|
await sitzungVerlaengern();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
|
||||||
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
|
// 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" }
|
// 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.
|
// für Filter. Ihr Wert wird unverändert an onChange gereicht.
|
||||||
extraOptions = [],
|
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,
|
disabled = false,
|
||||||
|
title,
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [collapsed, setCollapsed] = useState(() => new Set());
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
||||||
@@ -123,6 +127,7 @@ export default function CategorySelect({
|
|||||||
ref={btnRef}
|
ref={btnRef}
|
||||||
className="tree-select-btn"
|
className="tree-select-btn"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
@@ -138,14 +143,16 @@ export default function CategorySelect({
|
|||||||
className="tree-pop"
|
className="tree-pop"
|
||||||
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
|
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
|
||||||
>
|
>
|
||||||
<button
|
{showRoot && (
|
||||||
type="button"
|
<button
|
||||||
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
type="button"
|
||||||
style={{ paddingLeft: 12 }}
|
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
|
||||||
onClick={() => pick(null)}
|
style={{ paddingLeft: 12 }}
|
||||||
>
|
onClick={() => pick(null)}
|
||||||
{rootLabel}
|
>
|
||||||
</button>
|
{rootLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{extraOptions.map((o) => (
|
{extraOptions.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={String(o.value)}
|
key={String(o.value)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { Skeleton } from "./Skeleton";
|
import { Skeleton } from "./Skeleton";
|
||||||
|
import { leseTabelle, schreibeTabelle } from "../urlState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
* 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),
|
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
|
||||||
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
|
* 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
|
* `tree` (optional): { column, idOf(row), parentIdOf(row) } – zeigt die Zeilen als
|
||||||
* ein-/ausklappbaren Baum in der Spalte `column`. Sobald gefiltert oder sortiert
|
* 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 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 [order, setOrder] = useState(() => {
|
||||||
const p = loadPersisted(id);
|
const p = loadPersisted(id);
|
||||||
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
|
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
|
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
|
||||||
// gewaehlte Reihenfolge soll nicht umspringen.
|
// gewaehlte Reihenfolge soll nicht umspringen.
|
||||||
const schluessel = columns.map((c) => c.key).join("|");
|
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(() => {
|
useEffect(() => {
|
||||||
setOrder((alt) => {
|
const fehlend = columns.map((c) => c.key).filter((k) => !ordnungRef.current.includes(k));
|
||||||
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
if (!fehlend.length) return;
|
||||||
return fehlend.length ? [...alt, ...fehlend] : alt;
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [schluessel]);
|
}, [schluessel]);
|
||||||
|
|
||||||
@@ -61,6 +88,7 @@ export default function DataTable({
|
|||||||
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
|
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
|
||||||
// Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets.
|
// Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets.
|
||||||
const [filters, setFilters] = useState(() => {
|
const [filters, setFilters] = useState(() => {
|
||||||
|
if (vonUrl) return vonUrl.filters;
|
||||||
const saved = loadPersisted(id).filters || {};
|
const saved = loadPersisted(id).filters || {};
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [k, v] of Object.entries(saved)) {
|
for (const [k, v] of Object.entries(saved)) {
|
||||||
@@ -70,28 +98,84 @@ export default function DataTable({
|
|||||||
return out;
|
return out;
|
||||||
}); // { key: { text, values:Set|null } }
|
}); // { key: { text, values:Set|null } }
|
||||||
const [sort, setSort] = useState(() => {
|
const [sort, setSort] = useState(() => {
|
||||||
|
if (vonUrl) return vonUrl.sort;
|
||||||
const s = loadPersisted(id).sort;
|
const s = loadPersisted(id).sort;
|
||||||
return s && s.key && colByKey[s.key] ? s : null;
|
return s && s.key && colByKey[s.key] ? s : null;
|
||||||
}); // { key, dir }
|
}); // { key, dir }
|
||||||
const [filterShown, setFilterShown] = useState(true);
|
const [filterShown, setFilterShown] = useState(true);
|
||||||
const [openFilter, setOpenFilter] = useState(null);
|
const [openFilter, setOpenFilter] = useState(null);
|
||||||
const [collapsed, setCollapsed] = useState(() => new Set());
|
const [collapsed, setCollapsed] = useState(() => new Set());
|
||||||
const [hidden, setHidden] = useState(
|
const [hidden, setHidden] = useState(() => (vonUrl
|
||||||
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
|
? vonUrl.hidden
|
||||||
);
|
: new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k]))));
|
||||||
const [chooserOpen, setChooserOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein Filter);
|
const bisher = loadPersisted(id);
|
||||||
// Sets werden zu Arrays serialisiert.
|
const neu = { order, widths };
|
||||||
const filtersSer = {};
|
if (vomBenutzer.current) {
|
||||||
for (const [k, v] of Object.entries(filters)) {
|
vomBenutzer.current = false;
|
||||||
if (!v || (!v.text && v.values == null)) continue;
|
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein
|
||||||
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
|
// 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]);
|
}, [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.
|
// Containerbreite messen, damit die grow-Spalte den Rest füllt.
|
||||||
const wrapRef = useRef(null);
|
const wrapRef = useRef(null);
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
@@ -135,7 +219,7 @@ export default function DataTable({
|
|||||||
}, [chooserOpen]);
|
}, [chooserOpen]);
|
||||||
|
|
||||||
function toggleHidden(key) {
|
function toggleHidden(key) {
|
||||||
setHidden((s) => {
|
setzeHidden((s) => {
|
||||||
const n = new Set(s);
|
const n = new Set(s);
|
||||||
if (n.has(key)) { n.delete(key); return n; }
|
if (n.has(key)) { n.delete(key); return n; }
|
||||||
// Mindestens eine Spalte sichtbar lassen.
|
// Mindestens eine Spalte sichtbar lassen.
|
||||||
@@ -254,7 +338,7 @@ export default function DataTable({
|
|||||||
|
|
||||||
function toggleSort(c) {
|
function toggleSort(c) {
|
||||||
if (!c.sortValue && !c.filterText) return;
|
if (!c.sortValue && !c.filterText) return;
|
||||||
setSort((s) => {
|
setzeSort((s) => {
|
||||||
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
|
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
|
||||||
if (s.dir === "asc") return { key: c.key, dir: "desc" };
|
if (s.dir === "asc") return { key: c.key, dir: "desc" };
|
||||||
return null;
|
return null;
|
||||||
@@ -265,34 +349,77 @@ export default function DataTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Resize (Pointer) ---
|
// --- 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 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) {
|
function startResize(e, c) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
const startW = widthOf(c);
|
|
||||||
// Die flexible (grow-)Spalte auf ihre aktuelle Pixelbreite einfrieren. Sonst
|
// 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
|
// 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) => {
|
setWidths((old) => {
|
||||||
const next = { ...old };
|
const next = { ...old };
|
||||||
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
|
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
|
||||||
return next;
|
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("pointermove", onResizeMove);
|
||||||
window.addEventListener("pointerup", endResize);
|
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) {
|
function onResizeMove(e) {
|
||||||
const r = resizing.current; if (!r) return;
|
const r = resizing.current; if (!r) return;
|
||||||
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
|
r.zeigerX = e.clientX;
|
||||||
setWidths((old) => ({ ...old, [r.key]: w }));
|
anwenden();
|
||||||
}
|
}
|
||||||
|
|
||||||
function endResize() {
|
function endResize() {
|
||||||
resizing.current = null;
|
resizing.current = null;
|
||||||
|
if (rollTimer.current) { window.clearInterval(rollTimer.current); rollTimer.current = null; }
|
||||||
window.removeEventListener("pointermove", onResizeMove);
|
window.removeEventListener("pointermove", onResizeMove);
|
||||||
window.removeEventListener("pointerup", endResize);
|
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) ---
|
// --- Reorder (HTML5 Drag) ---
|
||||||
const dragKey = useRef(null);
|
const dragKey = useRef(null);
|
||||||
const [dropKey, setDropKey] = useState(null);
|
const [dropKey, setDropKey] = useState(null);
|
||||||
@@ -319,7 +446,9 @@ export default function DataTable({
|
|||||||
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
|
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
|
||||||
setOrder(columns.map((c) => c.key));
|
setOrder(columns.map((c) => c.key));
|
||||||
setWidths({});
|
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 (
|
return (
|
||||||
@@ -329,7 +458,7 @@ export default function DataTable({
|
|||||||
onClick={() => setFilterShown((v) => !v)}>
|
onClick={() => setFilterShown((v) => !v)}>
|
||||||
<Icon name="search" size={14} />Filter
|
<Icon name="search" size={14} />Filter
|
||||||
</button>
|
</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"}`}
|
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
|
||||||
onClick={() => setChooserOpen((o) => !o)}>
|
onClick={() => setChooserOpen((o) => !o)}>
|
||||||
<Icon name="settings" size={14} />Spalten
|
<Icon name="settings" size={14} />Spalten
|
||||||
@@ -385,7 +514,7 @@ export default function DataTable({
|
|||||||
hasText={Boolean(c.filterText)}
|
hasText={Boolean(c.filterText)}
|
||||||
open={openFilter === c.key}
|
open={openFilter === c.key}
|
||||||
onToggleOpen={setOpenFilter}
|
onToggleOpen={setOpenFilter}
|
||||||
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
|
onChange={(f) => setzeFilters((old) => ({ ...old, [c.key]: f }))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { api, authorizedObjectUrl } from "../api";
|
|||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import Zubehoer from "./Zubehoer";
|
||||||
import { REASONS } from "./ObjektBestand";
|
import { REASONS } from "./ObjektBestand";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationPathById } from "../locationPath";
|
||||||
|
import LocationSelect from "./LocationSelect";
|
||||||
import { itemDeepLink } from "../qr";
|
import { itemDeepLink } from "../qr";
|
||||||
import {
|
import {
|
||||||
VerleihAktion,
|
VerleihAktion,
|
||||||
@@ -14,6 +16,10 @@ import {
|
|||||||
VerleihText,
|
VerleihText,
|
||||||
verleihRowClass,
|
verleihRowClass,
|
||||||
} from "./Verleih";
|
} from "./Verleih";
|
||||||
|
import {
|
||||||
|
VerwendungAktion,
|
||||||
|
VerwendungHistorie,
|
||||||
|
} from "./Verwendung";
|
||||||
|
|
||||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
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 [addForm, setAddForm] = useState(EMPTY_ADD);
|
||||||
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
const [remove, setRemove] = useState(null); // { item, reason, note } | null
|
||||||
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
|
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 [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
|
||||||
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
|
||||||
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
|
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);
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
@@ -271,9 +288,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
|
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th><th></th>
|
||||||
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
|
|
||||||
<th>Notiz</th><th></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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="UID" className="strong nowrap">{it.uid}</td>
|
||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
<LocationSelect value={it.location_id ?? ""} locations={locations}
|
||||||
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
|
onChange={(id) => patchItem(it, { location_id: id === "" ? null : id })} />
|
||||||
<option value="">– ohne –</option>
|
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
) : <span>{ortName(it.location_id) || "–"}</span>}
|
) : <span>{ortName(it.location_id) || "–"}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
|
||||||
<td data-label="Gekauft am">
|
{/* cell-row statt field-inline: letzteres bricht in Tabellen um
|
||||||
{isAdmin ? (
|
(styles.css) und stellte die Symbole uebereinander. */}
|
||||||
<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>
|
|
||||||
<td className="num">
|
<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. */}
|
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
|
||||||
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
|
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
|
||||||
onError={onError} />
|
onError={onError} />
|
||||||
@@ -337,19 +323,62 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{offen.has(it.id) && (
|
||||||
<tr className="einzel-detailrow">
|
<tr className="einzel-detailrow">
|
||||||
<td colSpan={9}>
|
<td colSpan={5}>
|
||||||
<div style={{ display: "grid", gap: "var(--sp-4)",
|
<div style={{ display: "grid", gap: "var(--sp-4)",
|
||||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
|
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>
|
<div>
|
||||||
<span className="muted small">Verleih</span>
|
<span className="muted small">Verleih</span>
|
||||||
<div style={{ marginTop: "var(--sp-2)" }}>
|
<div style={{ marginTop: "var(--sp-2)" }}>
|
||||||
<VerleihHistorie item={it} onError={onError} />
|
<VerleihHistorie item={it} onError={onError} />
|
||||||
</div>
|
</div>
|
||||||
</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
|
<label style={{ margin: 0 }}>Kaufpreis
|
||||||
<div className="field-inline">
|
<div className="field-inline">
|
||||||
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
)}
|
||||||
</Fragment>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,11 +486,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
|
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">Lagerort
|
<label className="grow">Lagerort
|
||||||
<select value={addForm.location_id}
|
<LocationSelect value={addForm.location_id} locations={locations}
|
||||||
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
|
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
|
||||||
<option value="">– ohne –</option>
|
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">Gekauft bei
|
<label className="grow">Gekauft bei
|
||||||
<select value={addForm.shop_id}
|
<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" />,
|
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: (
|
trash: (
|
||||||
<>
|
<>
|
||||||
<path d="M3 6h18" />
|
<path d="M3 6h18" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { locationOptions } from "../locationPath";
|
import { locationOptions } from "../locationPath";
|
||||||
|
import LocationSelect from "./LocationSelect";
|
||||||
|
|
||||||
// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell
|
// „Ü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
|
// 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) => (
|
{rows.map((r, i) => (
|
||||||
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
|
||||||
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 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 })}>
|
<LocationSelect value={r.location_id} locations={locations}
|
||||||
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
|
||||||
</select>
|
onChange={(id) => setRow(i, { location_id: id })} />
|
||||||
</label>
|
</label>
|
||||||
<label style={{ margin: 0, width: 130 }}>
|
<label style={{ margin: 0, width: 130 }}>
|
||||||
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
|
<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 { useToast } from "../toast";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { fmt } from "../units";
|
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).
|
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
|
||||||
export const REASONS = [
|
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)" }}>
|
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Lagerort
|
Lagerort
|
||||||
<select value={addForm.location_id}
|
<LocationSelect value={addForm.location_id} locations={locations}
|
||||||
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
|
rootLabel="– ohne Lagerort –"
|
||||||
<option value="">– ohne Lagerort –</option>
|
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label style={{ width: 120 }}>
|
<label style={{ width: 120 }}>
|
||||||
Menge
|
Menge
|
||||||
@@ -208,16 +207,14 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
|||||||
</div>
|
</div>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">Von
|
<label className="grow">Von
|
||||||
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
|
<LocationSelect value={move.from} locations={locations}
|
||||||
<option value="">– ohne Lagerort –</option>
|
rootLabel="– ohne Lagerort –"
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
onChange={(id) => setMove({ ...move, from: id })} />
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="grow">Nach
|
<label className="grow">Nach
|
||||||
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
|
<LocationSelect value={move.to} locations={locations}
|
||||||
<option value="">– ohne Lagerort –</option>
|
rootLabel="– ohne Lagerort –"
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
onChange={(id) => setMove({ ...move, to: id })} />
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label style={{ width: 110 }}>Menge
|
<label style={{ width: 110 }}>Menge
|
||||||
<input type="number" step="any" min="0" value={move.quantity}
|
<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>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">Lagerort
|
<label className="grow">Lagerort
|
||||||
<select value={remove.location_id}
|
<LocationSelect value={remove.location_id} locations={locations}
|
||||||
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
|
rootLabel="– ohne Lagerort –"
|
||||||
<option value="">– ohne Lagerort –</option>
|
onChange={(id) => setRemove({ ...remove, location_id: id })} />
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label style={{ width: 110 }}>Menge
|
<label style={{ width: 110 }}>Menge
|
||||||
<input type="number" step="any" min="0" value={remove.quantity}
|
<input type="number" step="any" min="0" value={remove.quantity}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from "react";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { fmt } from "../units";
|
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)
|
* 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>
|
||||||
<label>
|
<label>
|
||||||
nach Lagerort
|
nach Lagerort
|
||||||
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
|
<LocationSelect value={locId} locations={locations}
|
||||||
<option value="">– ohne Lagerort –</option>
|
rootLabel="– ohne Lagerort –" onChange={setLocId} />
|
||||||
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
{rest != null && !zielGleich && (
|
{rest != null && !zielGleich && (
|
||||||
<p className="muted small mt-0">
|
<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 { api } from "../api";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { useConfirm } from "../confirm";
|
||||||
import { daysUntil, relativeExpiry } from "../units";
|
import { daysUntil, relativeExpiry } from "../units";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,8 +19,10 @@ export function FristBadge({ loan, warnTage = 7 }) {
|
|||||||
if (!loan || !loan.due_on) return null;
|
if (!loan || !loan.due_on) return null;
|
||||||
const tage = daysUntil(loan.due_on);
|
const tage = daysUntil(loan.due_on);
|
||||||
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
|
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
|
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)}`;
|
: `zurück ${relativeExpiry(tage)}`;
|
||||||
return art
|
return art
|
||||||
? <span className={`badge ${art}`}>{text}</span>
|
? <span className={`badge ${art}`}>{text}</span>
|
||||||
@@ -56,6 +59,14 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [form, setForm] = useState(LEER);
|
const [form, setForm] = useState(LEER);
|
||||||
const [busy, setBusy] = useState(false);
|
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) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -66,6 +77,7 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
|
|||||||
borrower: form.borrower.trim(),
|
borrower: form.borrower.trim(),
|
||||||
reason: form.reason.trim() || null,
|
reason: form.reason.trim() || null,
|
||||||
due_on: form.due_on || null,
|
due_on: form.due_on || null,
|
||||||
|
mit_zubehoer: mit,
|
||||||
});
|
});
|
||||||
setForm(LEER);
|
setForm(LEER);
|
||||||
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
|
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 })} />
|
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<p className="muted small">
|
||||||
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
|
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
|
||||||
verliehen zählt es aber nicht mehr zum Bestand.
|
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 }) {
|
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
async function zurueck() {
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.returnItem(item.id);
|
await api.returnItem(item.id, { mit_zubehoer: teile.map((t) => t.id) });
|
||||||
toast(`${item.uid} ist zurück.`);
|
toast(`${item.uid} ist zurück.`);
|
||||||
if (onChanged) await onChanged();
|
if (onChanged) await onChanged();
|
||||||
} catch (err) { onError(err.message); } finally { setBusy(false); }
|
} 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) {
|
if (item.loan) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className="btn-icon" disabled={busy}
|
<button type="button" className="btn sm" disabled={busy} onClick={zurueck}
|
||||||
title={`Zurücknehmen (von ${item.loan.borrower})`} onClick={zurueck}>
|
aria-label={`„${item.uid}“ von ${item.loan.borrower} zurücknehmen`}>
|
||||||
<Icon name="checkin" size={size} />
|
<Icon name="checkin" size={size} />{busy ? "Moment…" : "Zurücknehmen"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button type="button" className="btn-icon" title="Verleihen"
|
<button type="button" className="btn sm" onClick={() => onLend(item)}
|
||||||
onClick={() => onLend(item)}>
|
aria-label={`„${item.uid}“ verleihen`}>
|
||||||
<Icon name="loan" size={size} />
|
<Icon name="loan" size={size} />Verleihen
|
||||||
</button>
|
</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