Lagerort-IDs sind jetzt ein zufaelliger 10-Zeichen-Code (wie die Einzelstueck-UIDs) statt einer fortlaufenden Zahl - so kollidiert die Stammdaten-Sicherung zwischen zwei Instanzen praktisch nie mehr, und der Code ist zugleich der Inhalt des QR /l/<code>. Alle Fremdschluessel (lots, movements, items, Mindestbestaende, parent_id) ziehen mit; die Umstellung laeuft einmalig und transaktional beim Serverstart (_migrate_locations_to_code) und rollt bei Fehlern komplett zurueck. Vor dem Deploy ein DB-Backup machen. iOS-Einlagern oeffnet nicht mehr sofort die Kamera, sondern ein Formular mit Artikelsuche; die Kamera kommt erst per Button. Im Formular laesst sich der Lagerort zusaetzlich per /l/-QR scannen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
259 lines
11 KiB
Python
259 lines
11 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,
|
||
locations,
|
||
maintenance,
|
||
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
|
||
|
||
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 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",
|
||
# 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)",
|
||
]
|
||
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)
|
||
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(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)
|