Der Lagerort bleibt beim Verleihen stehen - er sagt weiter, wohin das Stueck nach der Rueckgabe zurueckgehoert. Ein Lagerort-Wert "verliehen" haette diese Information vernichtet. Stattdessen eine eigene Tabelle item_loans mit Entleiher und Grund als Freitext, Verleihdatum und einem optionalen Rueckgabedatum: "kurz mal mitgenommen" soll nicht an einem Datumszwang scheitern. Zurueckgegebene Vorgaenge bleiben stehen, damit die Tabelle gleichzeitig die Verleih-Historie je Stueck ist. Ein Teilindex auf die offenen Vorgaenge haelt fest, dass ein Stueck nicht zweimal gleichzeitig verliehen sein kann - wie bei den "Ueberall"-Mindestbestaenden, weil Postgres NULLs in UNIQUE als verschieden zaehlt. Verliehen heisst nicht im Bestand: Ein Stueck, das nicht im Haus ist, zaehlt nirgends mehr mit - weder am Lagerort noch im Gesamtbestand. Entschieden wird das an einer Stelle (services/loans.py::nicht_verliehen), die alle fuenf Zaehlungen benutzen. Sonst behauptete "Was liegt hier?" weiterhin, die Kamera liege im Regal. Folge: Faellt ein verliehenes Stueck unter den Mindestbestand, erscheint der Artikel auf der Einkaufsliste. Verleihen und Zuruecknehmen darf jeder angemeldete Benutzer - es ist eine Buchung wie Ein- und Auslagern, keine Stammdatenaenderung - und wird mit dem Konto protokolliert. Nur das Loeschen eines Vorgangs bleibt Admins vorbehalten, weil es Historie vernichtet. Bewusst keine Movement-Bewegung je Verleih: Der Verlauf rechnet den Bestand rueckwaerts aus vorzeichenbehafteten Bewegungen, ein Abgang ohne Gegenbuchung wuerde diese Kurve verfaelschen. GET /loans?due_in= liefert die faelligen Verleihe und ist damit schon die Datenquelle fuer die spaetere Rueckgabe-Erinnerung der App. Oberflaeche in Web und App fehlt noch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
310 lines
14 KiB
Python
310 lines
14 KiB
Python
from contextlib import asynccontextmanager
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from sqlalchemy import text
|
||
|
||
from .config import get_settings
|
||
from .database import Base, SessionLocal, engine
|
||
from .routers import (
|
||
api_tokens,
|
||
auth,
|
||
branding,
|
||
categories,
|
||
dashboard,
|
||
field_definitions,
|
||
groups,
|
||
items,
|
||
loans,
|
||
locations,
|
||
maintenance,
|
||
oidc,
|
||
package_types,
|
||
products,
|
||
settings as settings_router,
|
||
shops,
|
||
stock,
|
||
transfer,
|
||
units,
|
||
users,
|
||
views,
|
||
)
|
||
from .seed import (
|
||
ensure_builtin_categories,
|
||
ensure_builtin_package_types,
|
||
ensure_builtin_units,
|
||
ensure_example_object_categories,
|
||
ensure_first_admin,
|
||
)
|
||
from .services.group_codes import backfill as backfill_group_codes
|
||
from .services.min_stock import migriere_mindestbestaende
|
||
from .services.stock import consolidate_duplicate_lots
|
||
|
||
settings = get_settings()
|
||
|
||
|
||
def _migrate_locations_to_code(conn) -> None:
|
||
"""Einmalige Umstellung: Lagerort-ID von fortlaufender Zahl auf 10-Zeichen-Code.
|
||
|
||
Läuft nur, solange ``locations.id`` noch eine Integer-Spalte ist – danach ist
|
||
die Umstellung erledigt und der Block überspringt sich selbst. Alles passiert
|
||
innerhalb der umgebenden Transaktion: Bricht ein Schritt ab, wird komplett
|
||
zurückgerollt und die Datenbank bleibt auf dem alten (funktionierenden) Stand.
|
||
"""
|
||
id_type = conn.execute(text(
|
||
"SELECT data_type FROM information_schema.columns "
|
||
"WHERE table_name = 'locations' AND column_name = 'id'"
|
||
)).scalar()
|
||
if id_type not in ("integer", "bigint", "smallint"):
|
||
return # frische Installation (schon VARCHAR) oder bereits umgestellt
|
||
|
||
from .models import generate_location_code
|
||
|
||
# 1. Jedem bestehenden Lagerort einen eindeutigen Code geben (id bleibt vorerst).
|
||
conn.execute(text("ALTER TABLE locations ADD COLUMN IF NOT EXISTS code VARCHAR(10)"))
|
||
vergeben: set[str] = set()
|
||
for (lid,) in conn.execute(text("SELECT id FROM locations")).all():
|
||
code = generate_location_code()
|
||
while code in vergeben:
|
||
code = generate_location_code()
|
||
vergeben.add(code)
|
||
conn.execute(text("UPDATE locations SET code = :c WHERE id = :i"), {"c": code, "i": lid})
|
||
|
||
# 2. Fremdschlüssel-Spalten (Integer) auf den Code umziehen. Beim DROP COLUMN
|
||
# fallen die alten FK-/Unique-Constraints automatisch mit weg.
|
||
kinder = [
|
||
# (Tabelle, ON DELETE, NOT NULL danach, Unique-Constraint danach)
|
||
("lots", "SET NULL", False, None),
|
||
("movements", "SET NULL", False, None),
|
||
("items", "SET NULL", False, None),
|
||
("product_location_min_stock", "CASCADE", True, ("uq_prod_loc_min", "product_id")),
|
||
("group_location_min_stock", "CASCADE", True, ("uq_group_loc_min", "group_id")),
|
||
]
|
||
vorhanden = []
|
||
for tabelle, ond, nn, uq in kinder:
|
||
spalte_da = conn.execute(text(
|
||
"SELECT 1 FROM information_schema.columns "
|
||
"WHERE table_name = :t AND column_name = 'location_id'"
|
||
), {"t": tabelle}).scalar()
|
||
if spalte_da is None:
|
||
continue # Spalte existiert (noch) nicht – nichts umzuziehen
|
||
vorhanden.append((tabelle, ond, nn, uq))
|
||
conn.execute(text(f"ALTER TABLE {tabelle} ADD COLUMN location_code VARCHAR(10)"))
|
||
conn.execute(text(
|
||
f"UPDATE {tabelle} t SET location_code = l.code "
|
||
f"FROM locations l WHERE t.location_id = l.id"
|
||
))
|
||
conn.execute(text(f"ALTER TABLE {tabelle} DROP COLUMN location_id"))
|
||
conn.execute(text(f"ALTER TABLE {tabelle} RENAME COLUMN location_code TO location_id"))
|
||
|
||
# 2b. Selbstverweis parent_id ebenso auf den Code umziehen.
|
||
conn.execute(text("ALTER TABLE locations ADD COLUMN parent_code VARCHAR(10)"))
|
||
conn.execute(text(
|
||
"UPDATE locations c SET parent_code = p.code FROM locations p WHERE c.parent_id = p.id"
|
||
))
|
||
conn.execute(text("ALTER TABLE locations DROP COLUMN parent_id"))
|
||
|
||
# 3. Integer-PK durch den Code ersetzen (DROP COLUMN zieht PK und Sequenz mit).
|
||
conn.execute(text("ALTER TABLE locations DROP COLUMN id"))
|
||
conn.execute(text("ALTER TABLE locations RENAME COLUMN code TO id"))
|
||
conn.execute(text("ALTER TABLE locations ALTER COLUMN id SET NOT NULL"))
|
||
conn.execute(text("ALTER TABLE locations ADD PRIMARY KEY (id)"))
|
||
conn.execute(text("ALTER TABLE locations RENAME COLUMN parent_code TO parent_id"))
|
||
conn.execute(text(
|
||
"ALTER TABLE locations ADD CONSTRAINT locations_parent_id_fkey "
|
||
"FOREIGN KEY (parent_id) REFERENCES locations(id) ON DELETE SET NULL"
|
||
))
|
||
|
||
# 4. Fremdschlüssel (und Pflicht-/Unique-Bedingungen) neu setzen – jetzt auf den Code.
|
||
for tabelle, ond, nn, uq in vorhanden:
|
||
if nn:
|
||
# Verwaiste Mindestbestände (Lagerort gelöscht) vorher entfernen.
|
||
conn.execute(text(f"DELETE FROM {tabelle} WHERE location_id IS NULL"))
|
||
conn.execute(text(f"ALTER TABLE {tabelle} ALTER COLUMN location_id SET NOT NULL"))
|
||
conn.execute(text(
|
||
f"ALTER TABLE {tabelle} ADD CONSTRAINT {tabelle}_location_id_fkey "
|
||
f"FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE {ond}"
|
||
))
|
||
if uq is not None:
|
||
name, andere = uq
|
||
conn.execute(text(
|
||
f"ALTER TABLE {tabelle} ADD CONSTRAINT {name} UNIQUE ({andere}, location_id)"
|
||
))
|
||
|
||
|
||
def _ensure_schema() -> None:
|
||
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
||
|
||
create_all() legt nur fehlende Tabellen an, ändert aber keine bestehenden.
|
||
Auf Postgres holen wir neue nullable-Spalten per ADD COLUMN IF NOT EXISTS nach,
|
||
damit vorhandene Installationen ihre Daten behalten. Auf SQLite (Tests) sind
|
||
die Spalten bereits durch create_all vorhanden.
|
||
"""
|
||
if engine.dialect.name != "postgresql":
|
||
return
|
||
stmts = [
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS display_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_size DOUBLE PRECISION",
|
||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||
"NOT NULL DEFAULT FALSE",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||
"REFERENCES units(id) ON DELETE SET NULL",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||
"NOT NULL DEFAULT FALSE",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS date_precision VARCHAR(8) "
|
||
"NOT NULL DEFAULT 'day'",
|
||
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) "
|
||
"NOT NULL DEFAULT 'day'",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER "
|
||
"REFERENCES categories(id) ON DELETE SET NULL",
|
||
# Mehrere Dashboards je Benutzer: Name und Reihenfolge kommen dazu.
|
||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS name VARCHAR(80) "
|
||
"NOT NULL DEFAULT 'Übersicht'",
|
||
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
|
||
"NOT NULL DEFAULT 0",
|
||
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
|
||
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
|
||
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
|
||
"NOT NULL DEFAULT 'food'",
|
||
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
|
||
"REFERENCES shops(id) ON DELETE SET NULL",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
|
||
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
|
||
# location_id ist ein Lagerort-Code (VARCHAR(10)), siehe
|
||
# _migrate_locations_to_code – deshalb hier gleich als VARCHAR anlegen.
|
||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id VARCHAR(10) "
|
||
"REFERENCES locations(id) ON DELETE SET NULL",
|
||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
||
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
|
||
"NOT NULL DEFAULT FALSE",
|
||
# Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel führen (Chargen).
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS bulk BOOLEAN "
|
||
"NOT NULL DEFAULT FALSE",
|
||
# Zeitpunkt der letzten Änderung an den Stammdaten (für „Zuletzt geändert").
|
||
# Nullable anlegen und Altbestände auf created_at zurücksetzen; neue Zeilen
|
||
# füllt die ORM-Spalte (default/onupdate).
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ",
|
||
"UPDATE products SET updated_at = created_at WHERE updated_at IS NULL",
|
||
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
|
||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
|
||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
||
# Mindestbestände hängen nur noch an Lagerorten; location_id NULL ist
|
||
# „Überall". DROP NOT NULL ist auf einer bereits nullable Spalte ein
|
||
# No-Op, der Aufruf also wiederholbar.
|
||
"ALTER TABLE product_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
|
||
"ALTER TABLE group_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
|
||
# Postgres zählt NULLs in UNIQUE als verschieden – die vorhandene
|
||
# Beschränkung verhindert also keine zwei „Überall"-Zeilen. Teilindex.
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_prod_ueberall_min "
|
||
"ON product_location_min_stock (product_id) WHERE location_id IS NULL",
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min "
|
||
"ON group_location_min_stock (group_id) WHERE location_id IS NULL",
|
||
# Zweiteinheit am Artikel („3 Stück ≙ 250 g") – Brücke zwischen den
|
||
# Einheiten-Arten. Leer = wie bisher, strikt getrennt.
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_base VARCHAR(16)",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_count DOUBLE PRECISION",
|
||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_amount DOUBLE PRECISION",
|
||
# Vorschaubild (Briefmarkengröße) neben dem Original. Bestehende Bilder
|
||
# bekommen es beim ersten Abruf; deshalb nullbar und ohne Umzug.
|
||
"ALTER TABLE product_images ADD COLUMN IF NOT EXISTS thumb BYTEA",
|
||
# Single Sign-on: E-Mail am Benutzer (nur fuer die Erstverknuepfung) und
|
||
# ein Passwort-Hash, der bei reinen SSO-Konten leer bleiben darf.
|
||
# DROP NOT NULL ist auf einer bereits nullbaren Spalte ein No-Op, der
|
||
# Aufruf also wiederholbar.
|
||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
|
||
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
|
||
"WHERE email IS NOT NULL",
|
||
]
|
||
with engine.begin() as conn:
|
||
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||
# damit die folgenden ADD-COLUMN-Verweise auf die neue VARCHAR-id passen.
|
||
_migrate_locations_to_code(conn)
|
||
for stmt in stmts:
|
||
conn.execute(text(stmt))
|
||
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
||
# Der Name der Beschraenkung haengt davon ab, wie die Tabelle entstanden
|
||
# ist, deshalb wird er nachgeschlagen statt geraten.
|
||
namen = conn.execute(
|
||
text(
|
||
"SELECT conname FROM pg_constraint "
|
||
"WHERE conrelid = 'dashboard_layouts'::regclass AND contype = 'u'"
|
||
)
|
||
).scalars().all()
|
||
for name in namen:
|
||
conn.execute(text(f'ALTER TABLE dashboard_layouts DROP CONSTRAINT "{name}"'))
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
|
||
Base.metadata.create_all(bind=engine)
|
||
_ensure_schema()
|
||
db = SessionLocal()
|
||
try:
|
||
ensure_builtin_units(db)
|
||
ensure_builtin_package_types(db)
|
||
ensure_builtin_categories(db)
|
||
ensure_example_object_categories(db)
|
||
ensure_first_admin(db)
|
||
# Codes bestehender Gruppen-Zuordnungen nachziehen.
|
||
backfill_group_codes(db)
|
||
# Gesamt-Mindestbestände zu „Überall"-Zeilen machen und alle Werte auf
|
||
# Basiseinheiten umstellen (einmalig, mit Merker in den Einstellungen).
|
||
migriere_mindestbestaende(db)
|
||
# Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort)
|
||
# einmalig zusammenfassen – ab jetzt geschieht das beim Umlagern selbst.
|
||
consolidate_duplicate_lots(db)
|
||
finally:
|
||
db.close()
|
||
yield
|
||
|
||
|
||
app = FastAPI(title="Vorrania – Lebensmittel-Lagerverwaltung", version="1.0.0", lifespan=lifespan)
|
||
|
||
origins = ["*"] if settings.cors_origins.strip() == "*" else [
|
||
o.strip() for o in settings.cors_origins.split(",") if o.strip()
|
||
]
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
@app.get("/health", tags=["meta"])
|
||
def health() -> dict:
|
||
return {"status": "ok"}
|
||
|
||
|
||
app.include_router(auth.router)
|
||
app.include_router(oidc.router)
|
||
app.include_router(users.router)
|
||
app.include_router(products.router)
|
||
app.include_router(stock.router)
|
||
app.include_router(locations.router)
|
||
app.include_router(groups.router)
|
||
app.include_router(units.router)
|
||
app.include_router(package_types.router)
|
||
app.include_router(views.router)
|
||
app.include_router(transfer.router)
|
||
app.include_router(api_tokens.router)
|
||
app.include_router(settings_router.router)
|
||
app.include_router(branding.router)
|
||
app.include_router(categories.router)
|
||
app.include_router(maintenance.router)
|
||
app.include_router(dashboard.router)
|
||
app.include_router(shops.router)
|
||
app.include_router(field_definitions.router)
|
||
app.include_router(items.router)
|
||
app.include_router(loans.router)
|