Lagerorte per 10-Zeichen-Code statt fortlaufender ID; iOS-Einlagern ohne Kamerazwang

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>
This commit is contained in:
Scarriffle
2026-07-27 12:15:25 +02:00
parent 6e48cd0a7a
commit d518b4aa23
23 changed files with 507 additions and 220 deletions

View File

@@ -39,6 +39,95 @@ 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.
@@ -79,7 +168,9 @@ def _ensure_schema() -> None:
"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.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
# 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.
@@ -90,6 +181,9 @@ def _ensure_schema() -> None:
"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.

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import enum
import secrets
from datetime import date, datetime, timezone
from sqlalchemy import (
@@ -136,14 +137,28 @@ class Group(Base):
)
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
# praktisch nie, und der Code IST zugleich der Inhalt des QR /l/<code>.
# Alphabet ohne 0/O/1/I/L wie bei den Einzelstück-UIDs gut ablesbar.
_LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_location_code() -> str:
"""Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar)."""
return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10))
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
id: Mapped[str] = mapped_column(
String(10), primary_key=True, default=generate_location_code
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
parent_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
parent_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
@@ -312,8 +327,8 @@ class Lot(Base):
String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value,
)
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -339,8 +354,8 @@ class Movement(Base):
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
@@ -527,8 +542,8 @@ class Item(Base):
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
@@ -583,8 +598,8 @@ class ProductLocationMinStock(Base):
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[int] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
location_id: Mapped[str] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
@@ -603,8 +618,8 @@ class GroupLocationMinStock(Base):
group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[int] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
location_id: Mapped[str] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
min_stock: Mapped[float] = mapped_column(Float, nullable=False)

View File

@@ -62,7 +62,7 @@ def _item_or_404(db: Session, item_id: int) -> Item:
return item
def _check_refs(db: Session, shop_id: int | None, location_id: int | None) -> None:
def _check_refs(db: Session, shop_id: int | None, location_id: str | None) -> None:
if shop_id is not None and db.get(Shop, shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
if location_id is not None and db.get(Location, location_id) is None:

View File

@@ -10,10 +10,10 @@ from ..schemas import LocationCreate, LocationOut, LocationUpdate
router = APIRouter(prefix="/locations", tags=["locations"])
def _descendant_ids(db: Session, location_id: int) -> set[int]:
def _descendant_ids(db: Session, location_id: str) -> set[str]:
"""Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen, sonst
entstünde ein Ring."""
result: set[int] = set()
result: set[str] = set()
stack = [location_id]
while stack:
cur = stack.pop()
@@ -46,7 +46,7 @@ def create_location(
@router.patch("/{location_id}", response_model=LocationOut)
def update_location(
location_id: int,
location_id: str,
payload: LocationUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
@@ -93,7 +93,7 @@ def update_location(
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location(
location_id: int,
location_id: str,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:

View File

@@ -111,13 +111,13 @@ class UserUpdate(BaseModel):
# ---- Groups ----
class LocationMinStockIn(BaseModel):
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
location_id: int
location_id: str
min_stock: float = Field(ge=0)
class LocationMinStockOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
location_id: int
location_id: str
location_name: str | None = None
min_stock: float
@@ -239,19 +239,19 @@ class FieldDefinitionOut(BaseModel):
# ---- Locations ----
class LocationOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
id: str
name: str
parent_id: int | None = None
parent_id: str | None = None
class LocationCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None
parent_id: str | None = None
class LocationUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None
parent_id: str | None = None
# ---- Gebinde (Packung, Glas, …) ----
@@ -409,7 +409,7 @@ class CheckInRequest(BaseModel):
best_before: date | None = None
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None
location_id: str | None = None
note: str | None = None
@@ -430,7 +430,7 @@ class LotOut(BaseModel):
quantity: float
best_before: date | None
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None
location_id: str | None
created_at: datetime
@@ -439,7 +439,7 @@ class LotUpdate(BaseModel):
quantity: float | None = Field(default=None, gt=0)
best_before: date | None = None
best_before_precision: DatePrecision | None = None
location_id: int | None = None
location_id: str | None = None
class CheckInResponse(BaseModel):
@@ -452,7 +452,7 @@ class CheckInLine(BaseModel):
quantity: float = Field(gt=0)
best_before: date | None = None
best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None
location_id: str | None = None
class BatchCheckInRequest(BaseModel):
@@ -479,8 +479,8 @@ class RelocateRequest(BaseModel):
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
from_location_id: int | None = None
to_location_id: int | None = None
from_location_id: str | None = None
to_location_id: str | None = None
note: str | None = None
@@ -489,7 +489,7 @@ class RemoveRequest(BaseModel):
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
location_id: int | None = None
location_id: str | None = None
reason: RemovalReason
note: str | None = None
@@ -508,7 +508,7 @@ class RemovalStat(BaseModel):
class RemovalHistoryItem(BaseModel):
reason: RemovalReason
quantity: float
location_id: int | None = None
location_id: str | None = None
location_name: str | None = None
note: str | None = None
username: str | None = None
@@ -524,7 +524,7 @@ class RemovalSummary(BaseModel):
class ItemCreate(BaseModel):
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
count: int = Field(default=1, ge=1, le=200)
location_id: int | None = None
location_id: str | None = None
shop_id: int | None = None
acquired_on: date | None = None
warranty_until: date | None = None
@@ -534,7 +534,7 @@ class ItemCreate(BaseModel):
class ItemUpdate(BaseModel):
location_id: int | None = None
location_id: str | None = None
shop_id: int | None = None
acquired_on: date | None = None
warranty_until: date | None = None
@@ -578,7 +578,7 @@ class ItemOut(BaseModel):
id: int
uid: str
product_id: int
location_id: int | None = None
location_id: str | None = None
location_name: str | None = None
shop_id: int | None = None
shop_name: str | None = None
@@ -652,7 +652,7 @@ class LocationNeedGroup(BaseModel):
class LocationNeeds(BaseModel):
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
location_id: int
location_id: str
location_name: str
products: list[LocationNeedProduct] = []
groups: list[LocationNeedGroup] = []

View File

@@ -26,7 +26,9 @@ from .fields import options_list
FORMAT_KEY = "vorrania_master_data"
FORMAT_VERSION = 1
_TABLES = ("units", "package_types", "categories", "field_definitions", "locations")
# Nur Tabellen mit fortlaufender Integer-id brauchen die Sequenz-Korrektur.
# Lagerorte tragen einen zufälligen Code als id dort gibt es keine Sequenz.
_TABLES = ("units", "package_types", "categories", "field_definitions")
def export_master_data(db: Session) -> dict:
@@ -46,7 +48,7 @@ def export_master_data(db: Session) -> dict:
],
"locations": [
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
for l in db.query(Location).order_by(Location.id).all()
for l in db.query(Location).order_by(Location.name).all()
],
"units": [
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,

View File

@@ -34,7 +34,7 @@ class StockError(ValueError):
# oben (check_in/check_out) bleibt davon unberührt.
# ---------------------------------------------------------------------------
def _object_lot(db: Session, product_id: int, location_id: int | None) -> Lot | None:
def _object_lot(db: Session, product_id: int, location_id: str | None) -> Lot | None:
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
query = db.query(Lot).filter(
Lot.product_id == product_id, Lot.best_before.is_(None)
@@ -50,7 +50,7 @@ def object_add(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
location_id: str | None,
user: User | None,
note: str | None = None,
) -> Lot:
@@ -86,7 +86,7 @@ def object_remove(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
location_id: str | None,
reason: str,
user: User | None,
note: str | None = None,
@@ -121,8 +121,8 @@ def object_relocate(
db: Session,
product: Product,
quantity: float,
from_location_id: int | None,
to_location_id: int | None,
from_location_id: str | None,
to_location_id: str | None,
user: User | None,
note: str | None = None,
) -> None:
@@ -215,7 +215,7 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total))
def location_stock_base(db: Session, product: Product, location_id: int) -> float:
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
@@ -235,9 +235,9 @@ def location_stock_base(db: Session, product: Product, location_id: int) -> floa
return float(sum(q for (q,) in total))
def descendant_location_ids(db: Session, location_id: int) -> set[int]:
def descendant_location_ids(db: Session, location_id: str) -> set[str]:
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
result: set[int] = set()
result: set[str] = set()
stack = [location_id]
while stack:
cur = stack.pop()
@@ -248,7 +248,7 @@ def descendant_location_ids(db: Session, location_id: int) -> set[int]:
return result
def location_subtree_stock_base(db: Session, product: Product, location_id: int) -> float:
def location_subtree_stock_base(db: Session, product: Product, location_id: str) -> float:
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
@@ -264,7 +264,7 @@ def check_in(
quantity: float,
unit: str,
best_before: date | None,
location_id: int | None,
location_id: str | None,
user: User | None,
note: str | None = None,
best_before_precision: str | None = DatePrecision.day.value,