Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht. Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund: 250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und liefe damit gegen die Bestandspruefung beim Auslagern. Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit (GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet - weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck, und Runden wuerde stumm etwas anderes buchen als angegeben. WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in _gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren. Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit verrechnet. Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen schmalen Vertrag. 18 neue Tests. Dass test_wrong_kind_rejected und test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist selbst der Beleg: ohne Bruecke aendert sich nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
13 KiB
Python
295 lines
13 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
|
||
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",
|
||
]
|
||
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(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)
|