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() 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: def _ensure_schema() -> None:
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen. """Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
@@ -79,7 +168,9 @@ def _ensure_schema() -> None:
"REFERENCES shops(id) ON DELETE SET NULL", "REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)", "ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund. # 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", "REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)", "ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt. # 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)", "ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
] ]
with engine.begin() as conn: 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: for stmt in stmts:
conn.execute(text(stmt)) conn.execute(text(stmt))
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer. # Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
import secrets
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import ( 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): class Location(Base):
__tablename__ = "locations" __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) name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat. # Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True 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, String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value, server_default=DatePrecision.day.value,
) )
location_id: Mapped[int | None] = mapped_column( location_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
) )
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) 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) note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den # Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben. # Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
location_id: Mapped[int | None] = mapped_column( location_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
) )
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog. # Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp). # Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
@@ -527,8 +542,8 @@ class Item(Base):
product_id: Mapped[int] = mapped_column( product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False ForeignKey("products.id", ondelete="CASCADE"), nullable=False
) )
location_id: Mapped[int | None] = mapped_column( location_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
) )
shop_id: Mapped[int | None] = mapped_column( shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
@@ -583,8 +598,8 @@ class ProductLocationMinStock(Base):
product_id: Mapped[int] = mapped_column( product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
) )
location_id: Mapped[int] = mapped_column( location_id: Mapped[str] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
) )
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt. # In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
min_stock: Mapped[float] = mapped_column(Float, nullable=False) min_stock: Mapped[float] = mapped_column(Float, nullable=False)
@@ -603,8 +618,8 @@ class GroupLocationMinStock(Base):
group_id: Mapped[int] = mapped_column( group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
) )
location_id: Mapped[int] = mapped_column( location_id: Mapped[str] = mapped_column(
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
) )
min_stock: Mapped[float] = mapped_column(Float, 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 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: if shop_id is not None and db.get(Shop, shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
if location_id is not None and db.get(Location, location_id) is None: 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"]) 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 """Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen, sonst
entstünde ein Ring.""" entstünde ein Ring."""
result: set[int] = set() result: set[str] = set()
stack = [location_id] stack = [location_id]
while stack: while stack:
cur = stack.pop() cur = stack.pop()
@@ -46,7 +46,7 @@ def create_location(
@router.patch("/{location_id}", response_model=LocationOut) @router.patch("/{location_id}", response_model=LocationOut)
def update_location( def update_location(
location_id: int, location_id: str,
payload: LocationUpdate, payload: LocationUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
@@ -93,7 +93,7 @@ def update_location(
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location( def delete_location(
location_id: int, location_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> None: ) -> None:

View File

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

View File

@@ -26,7 +26,9 @@ from .fields import options_list
FORMAT_KEY = "vorrania_master_data" FORMAT_KEY = "vorrania_master_data"
FORMAT_VERSION = 1 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: def export_master_data(db: Session) -> dict:
@@ -46,7 +48,7 @@ def export_master_data(db: Session) -> dict:
], ],
"locations": [ "locations": [
{"id": l.id, "name": l.name, "parent_id": l.parent_id} {"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": [ "units": [
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor, {"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. # 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.""" """Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
query = db.query(Lot).filter( query = db.query(Lot).filter(
Lot.product_id == product_id, Lot.best_before.is_(None) Lot.product_id == product_id, Lot.best_before.is_(None)
@@ -50,7 +50,7 @@ def object_add(
db: Session, db: Session,
product: Product, product: Product,
quantity: float, quantity: float,
location_id: int | None, location_id: str | None,
user: User | None, user: User | None,
note: str | None = None, note: str | None = None,
) -> Lot: ) -> Lot:
@@ -86,7 +86,7 @@ def object_remove(
db: Session, db: Session,
product: Product, product: Product,
quantity: float, quantity: float,
location_id: int | None, location_id: str | None,
reason: str, reason: str,
user: User | None, user: User | None,
note: str | None = None, note: str | None = None,
@@ -121,8 +121,8 @@ def object_relocate(
db: Session, db: Session,
product: Product, product: Product,
quantity: float, quantity: float,
from_location_id: int | None, from_location_id: str | None,
to_location_id: int | None, to_location_id: str | None,
user: User | None, user: User | None,
note: str | None = None, note: str | None = None,
) -> None: ) -> None:
@@ -215,7 +215,7 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total)) 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). """Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des 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)) 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.""" """Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
result: set[int] = set() result: set[str] = set()
stack = [location_id] stack = [location_id]
while stack: while stack:
cur = stack.pop() cur = stack.pop()
@@ -248,7 +248,7 @@ def descendant_location_ids(db: Session, location_id: int) -> set[int]:
return result 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). """Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
@@ -264,7 +264,7 @@ def check_in(
quantity: float, quantity: float,
unit: str, unit: str,
best_before: date | None, best_before: date | None,
location_id: int | None, location_id: str | None,
user: User | None, user: User | None,
note: str | None = None, note: str | None = None,
best_before_precision: str | None = DatePrecision.day.value, best_before_precision: str | None = DatePrecision.day.value,

View File

@@ -31,6 +31,17 @@ def _orte(db):
return keller, regal, fach return keller, regal, fach
def test_neuer_ort_bekommt_10_zeichen_code_als_id(db):
ort = Location(name="Speisekammer")
db.add(ort)
db.commit()
# Statt fortlaufender Zahl trägt der Lagerort einen zufälligen Code das
# macht Sicherung/Import zwischen zwei Instanzen konfliktfrei.
assert isinstance(ort.id, str)
assert len(ort.id) == 10
assert set(ort.id) <= set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin): def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
keller, regal, _ = _orte(db) keller, regal, _ = _orte(db)
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin) out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
@@ -67,5 +78,5 @@ def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin):
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin): def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
_, _, fach = _orte(db) _, _, fach = _orte(db)
with pytest.raises(HTTPException) as ex: with pytest.raises(HTTPException) as ex:
update_location(fach.id, LocationUpdate(parent_id=99999), db=db, _=admin) update_location(fach.id, LocationUpdate(parent_id="ZZZZZZZZZZ"), db=db, _=admin)
assert ex.value.status_code == 404 assert ex.value.status_code == 404

View File

@@ -435,19 +435,19 @@ actor APIClient {
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func renameLocation(id: Int, name: String) async throws -> StorageLocation { func renameLocation(id: String, name: String) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH") var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name)) try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func updateLocation(id: Int, _ payload: LocationUpdateRequest) async throws -> StorageLocation { func updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH") var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, payload) try jsonBody(&request, payload)
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func deleteLocation(id: Int) async throws { func deleteLocation(id: String) async throws {
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
} }

View File

@@ -58,17 +58,18 @@ struct AssignScanView: View {
} }
private func handle(_ code: String) async { private func handle(_ code: String) async {
// Lagerort-QR (/l/<ID>)? // Lagerort-QR (/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
if let r = code.range(of: "/l/") { if let r = code.range(of: "/l/") {
let idStr = String(code[r.upperBound...]) let locCode = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) .trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
if let locId = Int(idStr) { .uppercased()
if !locCode.isEmpty {
guard let it = pending else { guard let it = pending else {
error = "Erst ein Einzelstück scannen."; status = nil; return error = "Erst ein Einzelstück scannen."; status = nil; return
} }
do { do {
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest( _ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
locationId: locId, shopId: it.shopId, locationId: locCode, shopId: it.shopId,
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note)) acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
status = "\(it.uid) zugeordnet. Nächstes Stück scannen." status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
error = nil error = nil

View File

@@ -153,10 +153,18 @@ struct CheckInFormView: View {
/// Charge, fuer die gerade das MHD abgescannt wird. /// Charge, fuer die gerade das MHD abgescannt wird.
@State private var scanLineId: LineRef? @State private var scanLineId: LineRef?
@State private var unit: String = "" @State private var unit: String = ""
@State private var locationId: Int? @State private var locationId: String?
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
// Lagerort per QR (/l/<Code>) setzen, statt in der Liste zu suchen.
@State private var locScanShown = false
@State private var locScanPaused = false
@State private var locTorchOn = false
@State private var locTorchLocked = false
@State private var locScanError: String?
@State private var locScanHinweis: String?
/// Einheiten der passenden Art plus das Gebinde des Artikels. /// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] { private var unitOptions: [UnitOption] {
var options = units var options = units
@@ -189,13 +197,31 @@ struct CheckInFormView: View {
Picker("Einheit", selection: $unit) { Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) } ForEach(unitOptions) { Text($0.label).tag($0.value) }
} }
}
if !locations.isEmpty { if !locations.isEmpty {
Section {
Picker("Lagerort", selection: $locationId) { Picker("Lagerort", selection: $locationId) {
Text(" keiner ").tag(Int?.none) Text(" keiner ").tag(String?.none)
ForEach(locations) { location in ForEach(locations) { location in
Text(location.name).tag(Int?.some(location.id)) Text(location.name).tag(String?.some(location.id))
} }
} }
Button {
locScanError = nil
locScanPaused = false
locScanShown = true
} label: {
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
}
if let locScanHinweis {
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
} header: {
Text("Lagerort")
} footer: {
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
} }
} }
@@ -281,6 +307,7 @@ struct CheckInFormView: View {
} }
} }
} }
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
.onAppear { .onAppear {
// Gibt es ein Gebinde, ist das die naheliegende Eingabe. // Gibt es ein Gebinde, ist das die naheliegende Eingabe.
unit = hasPackage ? UnitOption.packageValue : product.unitName unit = hasPackage ? UnitOption.packageValue : product.unitName
@@ -289,6 +316,60 @@ struct CheckInFormView: View {
} }
} }
// MARK: - Lagerort scannen
private var locationScannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let locScanError {
Text(locScanError)
.font(.callout).foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.red, in: Capsule())
}
Text("QR am Regal/Fach vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
.padding(.bottom, 24)
}
.navigationTitle("Lagerort scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { locScanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
}
}
}
.onAppear { locScanPaused = false; locScanError = nil }
}
/// Aus einem gescannten Lagerort-QR (/l/<Code>) den Zielort setzen.
private func handleLocationScan(_ code: String) async {
guard let r = code.range(of: "/l/") else {
locScanError = "Das ist kein Lagerort-QR."
return
}
let ziel = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if let treffer = locations.first(where: { $0.id == ziel }) {
locationId = treffer.id
locScanHinweis = treffer.name
locScanShown = false
} else {
locScanError = "Dieser Lagerort ist hier nicht bekannt."
}
}
private func format(_ value: Double) -> String { private func format(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
} }

View File

@@ -3,9 +3,12 @@ import SwiftUI
struct CheckInView: View { struct CheckInView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Kamera bewusst nur auf Wunsch: Einlagern öffnet ein Formular, kein Sucher.
@State private var scanShown = false
@State private var paused = false @State private var paused = false
@State private var torchOn = false @State private var torchOn = false
@State private var torchLocked = false @State private var torchLocked = false
@State private var status: String? @State private var status: String?
@State private var error: String? @State private var error: String?
@@ -19,68 +22,103 @@ struct CheckInView: View {
@State private var unknownCode: String? @State private var unknownCode: String?
@State private var manualCodeShown = false @State private var manualCodeShown = false
@State private var manualCode = "" @State private var manualCode = ""
// Manuelle Artikelsuche (statt Scanzwang).
@State private var query = ""
@State private var results: [Product] = []
@State private var searching = false
@State private var units: [Unit] = [] @State private var units: [Unit] = []
@State private var locations: [StorageLocation] = [] @State private var locations: [StorageLocation] = []
var body: some View { var body: some View {
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der Form {
// Sucher ueber die ganze Flaeche wirkte erschlagend. if let status { Section { banner(status, color: .green) } }
ScrollView { if let error { Section { banner(error, color: .red) } }
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten") Section {
.font(.caption).foregroundStyle(.secondary)
if let status { banner(status, color: .green) }
if let error { banner(error, color: .red) }
if let suggestion {
suggestionCard(suggestion)
} else if let unknownCode {
unknownCard(unknownCode)
}
VStack(spacing: 10) {
Button { Button {
paused = true error = nil; status = nil
manualCodeShown = true scanShown = true
} label: { } label: {
Label("EAN manuell", systemImage: "keyboard") Label("EAN scannen", systemImage: "barcode.viewfinder")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
Button {
manualCode = ""
manualCodeShown = true
} label: {
Label("EAN von Hand eingeben", systemImage: "keyboard")
}
NavigationLink { NavigationLink {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in ProductFormView(prefillBarcode: nil, groupId: nil) { created in
product = created product = created
} }
} label: { } label: {
Label("Artikel anlegen", systemImage: "plus") Label("Neuen Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
} }
.buttonStyle(.bordered) } header: {
Text("Neu erfassen")
} footer: {
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ kein Zwang.")
} }
.padding(.horizontal)
if let suggestion {
suggestionSection(suggestion)
} else if let unknownCode {
unknownSection(unknownCode)
}
Section {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Name oder Marke …", text: $query)
.autocorrectionDisabled()
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
if searching {
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
}
ForEach(results) { p in
Button {
error = nil; status = nil
product = p
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
let unter = [p.brand, "Bestand: \(bestandText(p))"]
.compactMap { $0 }.joined(separator: " · ")
Text(unter).font(.caption).foregroundStyle(.secondary)
}
}
}
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
Text("Kein Artikel gefunden oben neu anlegen.")
.foregroundStyle(.secondary).font(.callout)
}
} header: {
Text("Vorhandenen Artikel wählen")
} }
.padding(.vertical)
} }
.navigationTitle("Einlagern") .navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
Button("Fertig") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
} }
.task { .task {
units = (try? await APIClient.shared.units()) ?? [] units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? [] locations = (try? await APIClient.shared.locations()) ?? []
} }
.task(id: query) { await search() }
.fullScreenCover(isPresented: $scanShown) { scannerCover }
.alert("EAN eingeben", isPresented: $manualCodeShown) { .alert("EAN eingeben", isPresented: $manualCodeShown) {
TextField("z.B. 8076809572569", text: $manualCode) TextField("z.B. 8076809572569", text: $manualCode)
.keyboardType(.numberPad) .keyboardType(.numberPad)
@@ -89,22 +127,49 @@ struct CheckInView: View {
manualCode = "" manualCode = ""
Task { await resolve(code) } Task { await resolve(code) }
} }
Button("Abbrechen", role: .cancel) { paused = false } Button("Abbrechen", role: .cancel) { }
} }
.sheet(item: $product) { item in .sheet(item: $product) { item in
NavigationStack { NavigationStack {
CheckInFormView(product: item, units: units, locations: locations) { message in CheckInFormView(product: item, units: units, locations: locations) { message in
status = message status = message
product = nil product = nil
paused = false
} }
} }
} }
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in .sheet(item: $scannedItem) { it in
NavigationStack { ItemEditView(item: it) } NavigationStack { ItemEditView(item: it) }
} }
} }
// MARK: - Scanner (nur auf Wunsch)
private var scannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
Text("Barcode vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
.padding(.bottom, 24)
}
.navigationTitle("EAN scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { scanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $torchOn, locked: $torchLocked)
}
}
}
.onAppear { paused = false }
}
// MARK: - Bausteine // MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View { private func banner(_ text: String, color: Color) -> some View {
@@ -115,7 +180,14 @@ struct CheckInView: View {
.background(color.opacity(0.9)) .background(color.opacity(0.9))
.foregroundStyle(.white) .foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.padding(.horizontal) .listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
private func bestandText(_ p: Product) -> String {
let wert = p.stockInArticleUnits
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
return "\(zahl) \(p.articleUnitLabel)"
} }
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum. /// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
@@ -141,13 +213,11 @@ struct CheckInView: View {
Text(detail).font(.caption2).foregroundStyle(.secondary) Text(detail).font(.caption2).foregroundStyle(.secondary)
} }
} }
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
} }
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View { @ViewBuilder
private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
Section {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(item.name).font(.headline) Text(item.name).font(.headline)
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
@@ -167,13 +237,14 @@ struct CheckInView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
.padding() } header: {
.background(.thinMaterial) Text("Vorschlag zum Scan")
.clipShape(RoundedRectangle(cornerRadius: 12)) }
.padding(.horizontal)
} }
private func unknownCard(_ code: String) -> some View { @ViewBuilder
private func unknownSection(_ code: String) -> some View {
Section {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Unbekannter Code \(code)").font(.headline) Text("Unbekannter Code \(code)").font(.headline)
Text("Weder im Katalog noch in Open Food / Products Facts.") Text("Weder im Katalog noch in Open Food / Products Facts.")
@@ -191,14 +262,24 @@ struct CheckInView: View {
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }
.padding() } header: {
.background(.thinMaterial) Text("Zum Scan")
.clipShape(RoundedRectangle(cornerRadius: 12)) }
.padding(.horizontal)
} }
// MARK: - Logik // MARK: - Logik
private func search() async {
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else { results = []; searching = false; return }
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
try? await Task.sleep(nanoseconds: 250_000_000)
if Task.isCancelled { return }
searching = true
defer { searching = false }
results = (try? await APIClient.shared.searchProducts(q)) ?? []
}
private func resolve(_ code: String) async { private func resolve(_ code: String) async {
paused = true paused = true
error = nil error = nil
@@ -213,6 +294,7 @@ struct CheckInView: View {
.uppercased() .uppercased()
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = item scannedItem = item
scanShown = false
return return
} }
} }
@@ -228,9 +310,10 @@ struct CheckInView: View {
} else { } else {
unknownCode = code unknownCode = code
} }
scanShown = false
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
paused = false scanShown = false
} }
} }
} }

View File

@@ -92,7 +92,7 @@ struct ItemEditView: View {
@State private var locations: [StorageLocation] = [] @State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = [] @State private var shops: [ShopItem] = []
@State private var photo: Data? @State private var photo: Data?
@State private var locationId: Int? @State private var locationId: String?
@State private var shopId: Int? @State private var shopId: Int?
@State private var hasAcquired = false @State private var hasAcquired = false
@State private var acquired = Date() @State private var acquired = Date()
@@ -138,8 +138,8 @@ struct ItemEditView: View {
Section { Section {
Picker("Lagerort", selection: $locationId) { Picker("Lagerort", selection: $locationId) {
Text(" ohne ").tag(Int?.none) Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
} }
Picker("Gekauft bei", selection: $shopId) { Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none) Text(" unbekannt ").tag(Int?.none)
@@ -406,7 +406,7 @@ struct ItemAddSheet: View {
@State private var count = 1 @State private var count = 1
@State private var locations: [StorageLocation] = [] @State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = [] @State private var shops: [ShopItem] = []
@State private var locationId: Int? @State private var locationId: String?
@State private var shopId: Int? @State private var shopId: Int?
@State private var hasAcquired = false @State private var hasAcquired = false
@State private var acquired = Date() @State private var acquired = Date()
@@ -430,8 +430,8 @@ struct ItemAddSheet: View {
Section { Section {
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(Int?.none) Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
} }
Picker("Gekauft bei", selection: $shopId) { Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none) Text(" unbekannt ").tag(Int?.none)

View File

@@ -181,7 +181,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
let title: String let title: String
let singular: String let singular: String
let load: () async throws -> [Item] let load: () async throws -> [Item]
let delete: (Int) async throws -> Void let delete: (Item.ID) async throws -> Void
/// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ). /// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ).
let badge: (Item) -> String? let badge: (Item) -> String?
/// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter). /// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter).
@@ -190,7 +190,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor @ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
@State private var items: [Item] = [] @State private var items: [Item] = []
@State private var collapsed: Set<Int> = [] @State private var collapsed: Set<Item.ID> = []
@State private var busy = true @State private var busy = true
@State private var error: String? @State private var error: String?
@State private var editing: Item? @State private var editing: Item?
@@ -200,7 +200,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
private struct Node: Identifiable { private struct Node: Identifiable {
let item: Item let item: Item
let depth: Int let depth: Int
var id: Int { item.id } var id: Item.ID { item.id }
} }
private var shown: [Item] { items.filter(include) } private var shown: [Item] { items.filter(include) }
@@ -221,11 +221,11 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
return result return result
} }
private func childCount(_ id: Int) -> 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: [Node] {
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) }) let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
func hidden(_ id: Int) -> Bool { func hidden(_ id: Item.ID) -> Bool {
var p = parent[id] ?? nil var p = parent[id] ?? nil
while let cur = p { while let cur = p {
if collapsed.contains(cur) { return true } if collapsed.contains(cur) { return true }
@@ -324,7 +324,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
.padding(.leading, CGFloat(node.depth) * 16) .padding(.leading, CGFloat(node.depth) * 16)
} }
private func toggle(_ id: Int) { private func toggle(_ id: Item.ID) {
if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) } if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) }
} }
@@ -441,7 +441,7 @@ struct LocationEditor: View {
let done: () -> Void let done: () -> Void
@State private var name: String @State private var name: String
@State private var parentId: Int? @State private var parentId: String?
@State private var error: String? @State private var error: String?
@State private var busy = false @State private var busy = false
@@ -467,9 +467,9 @@ struct LocationEditor: View {
} }
Section { Section {
Picker("Übergeordnet", selection: $parentId) { Picker("Übergeordnet", selection: $parentId) {
Text(" oberste Ebene ").tag(Int?.none) Text(" oberste Ebene ").tag(String?.none)
ForEach(parentOptions) { loc in ForEach(parentOptions) { loc in
Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id)) Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
} }
} }
} header: { } header: {
@@ -513,8 +513,8 @@ struct LocationEditor: View {
} }
/// Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen. /// Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen.
private static func descendants(of id: Int, in all: [StorageLocation]) -> Set<Int> { private static func descendants(of id: String, in all: [StorageLocation]) -> Set<String> {
var result: Set<Int> = [] var result: Set<String> = []
var stack = [id] var stack = [id]
while let cur = stack.popLast() { while let cur = stack.popLast() {
for kid in all where kid.parentId == cur { for kid in all where kid.parentId == cur {

View File

@@ -134,10 +134,10 @@ struct Product: Codable, Identifiable, Hashable {
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige). /// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
struct LocationMinStock: Codable, Hashable, Identifiable { struct LocationMinStock: Codable, Hashable, Identifiable {
let locationId: Int let locationId: String
let locationName: String? let locationName: String?
let minStock: Double let minStock: Double
var id: Int { locationId } var id: String { locationId }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case minStock = "min_stock" case minStock = "min_stock"
@@ -148,7 +148,7 @@ struct LocationMinStock: Codable, Hashable, Identifiable {
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten). /// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
struct LocationMinStockIn: Codable { struct LocationMinStockIn: Codable {
let locationId: Int let locationId: String
let minStock: Double let minStock: Double
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -217,7 +217,7 @@ struct Lot: Codable, Identifiable, Hashable {
let quantity: Double let quantity: Double
let bestBefore: String? let bestBefore: String?
let bestBeforePrecision: String? let bestBeforePrecision: String?
let locationId: Int? let locationId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, quantity case id, quantity
@@ -229,9 +229,9 @@ struct Lot: Codable, Identifiable, Hashable {
} }
struct StorageLocation: Codable, Identifiable, Hashable { struct StorageLocation: Codable, Identifiable, Hashable {
let id: Int let id: String
let name: String let name: String
let parentId: Int? let parentId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name case id, name
@@ -243,7 +243,7 @@ struct CheckInLine: Codable {
let quantity: Double let quantity: Double
let bestBefore: String? let bestBefore: String?
let bestBeforePrecision: String let bestBeforePrecision: String
let locationId: Int? let locationId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case quantity case quantity
@@ -391,12 +391,12 @@ struct LocationNeedGroup: Codable, Identifiable {
} }
struct LocationNeeds: Codable, Identifiable { struct LocationNeeds: Codable, Identifiable {
let locationId: Int let locationId: String
let locationName: String let locationName: String
let products: [LocationNeedProduct] let products: [LocationNeedProduct]
let groups: [LocationNeedGroup] let groups: [LocationNeedGroup]
var id: Int { locationId } var id: String { locationId }
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case products, groups case products, groups
@@ -531,9 +531,9 @@ struct CategoryItem: Codable, Identifiable, Hashable {
/// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache /// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache
/// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt. /// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt.
protocol TreeItem: Identifiable where ID == Int { protocol TreeItem: Identifiable {
var name: String { get } var name: String { get }
var parentId: Int? { get } var parentId: ID? { get }
} }
extension CategoryItem: TreeItem {} extension CategoryItem: TreeItem {}
@@ -580,7 +580,7 @@ struct ObjectCheckInRequest: Codable {
let productId: Int let productId: Int
let quantity: Double let quantity: Double
let unit: String let unit: String
let locationId: Int? let locationId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case quantity, unit case quantity, unit
@@ -592,8 +592,8 @@ struct ObjectCheckInRequest: Codable {
struct RelocateRequest: Codable { struct RelocateRequest: Codable {
let productId: Int let productId: Int
let quantity: Double let quantity: Double
let fromLocationId: Int? let fromLocationId: String?
let toLocationId: Int? let toLocationId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case quantity case quantity
@@ -606,7 +606,7 @@ struct RelocateRequest: Codable {
struct RemoveRequest: Codable { struct RemoveRequest: Codable {
let productId: Int let productId: Int
let quantity: Double let quantity: Double
let locationId: Int? let locationId: String?
let reason: String let reason: String
let note: String? let note: String?
@@ -627,7 +627,7 @@ struct RemovalStat: Codable, Identifiable, Hashable {
struct RemovalHistoryItem: Codable, Identifiable, Hashable { struct RemovalHistoryItem: Codable, Identifiable, Hashable {
let reason: String let reason: String
let quantity: Double let quantity: Double
let locationId: Int? let locationId: String?
let locationName: String? let locationName: String?
let note: String? let note: String?
let username: String? let username: String?
@@ -681,7 +681,7 @@ struct Item: Codable, Identifiable, Hashable {
let id: Int let id: Int
let uid: String let uid: String
let productId: Int let productId: Int
let locationId: Int? let locationId: String?
let locationName: String? let locationName: String?
let shopId: Int? let shopId: Int?
let shopName: String? let shopName: String?
@@ -757,7 +757,7 @@ struct ItemDocumentUpload: Codable {
struct ItemCreateRequest: Codable { struct ItemCreateRequest: Codable {
let count: Int let count: Int
let locationId: Int? let locationId: String?
let shopId: Int? let shopId: Int?
let acquiredOn: String? let acquiredOn: String?
let warrantyUntil: String? let warrantyUntil: String?
@@ -776,7 +776,7 @@ struct ItemCreateRequest: Codable {
} }
struct ItemUpdateRequest: Encodable { struct ItemUpdateRequest: Encodable {
var locationId: Int? var locationId: String?
var shopId: Int? var shopId: Int?
var acquiredOn: String? var acquiredOn: String?
var warrantyUntil: String? var warrantyUntil: String?
@@ -996,7 +996,7 @@ struct PackageType: Codable, Identifiable, Hashable {
struct NewLocationRequest: Codable { struct NewLocationRequest: Codable {
let name: String let name: String
let parentId: Int? let parentId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
@@ -1008,7 +1008,7 @@ struct NewLocationRequest: Codable {
/// (auch `null` = oberste Ebene), damit auf oberste Ebene holen" ankommt. /// (auch `null` = oberste Ebene), damit auf oberste Ebene holen" ankommt.
struct LocationUpdateRequest: Codable { struct LocationUpdateRequest: Codable {
let name: String let name: String
let parentId: Int? let parentId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name

View File

@@ -15,19 +15,19 @@ struct ObjectStockSection: View {
enum StockSheet: Identifiable { enum StockSheet: Identifiable {
case add case add
case relocate(from: Int?) case relocate(from: String?)
case remove(loc: Int?) case remove(loc: String?)
var id: String { var id: String {
switch self { switch self {
case .add: return "add" case .add: return "add"
case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")" case .relocate(let f): return "relocate-\(f ?? "none")"
case .remove(let l): return "remove-\(l.map(String.init) ?? "none")" case .remove(let l): return "remove-\(l ?? "none")"
} }
} }
} }
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName } private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
private func ortName(_ id: Int?) -> String { private func ortName(_ id: String?) -> String {
guard let id else { return "Ohne Lagerort" } guard let id else { return "Ohne Lagerort" }
return locations.first { $0.id == id }?.name ?? "Ort \(id)" return locations.first { $0.id == id }?.name ?? "Ort \(id)"
} }
@@ -170,12 +170,12 @@ struct ObjectFieldRow: View {
private struct LocationPicker: View { private struct LocationPicker: View {
let title: String let title: String
let locations: [StorageLocation] let locations: [StorageLocation]
@Binding var selection: Int? @Binding var selection: String?
var body: some View { var body: some View {
Picker(title, selection: $selection) { Picker(title, selection: $selection) {
Text(" ohne Lagerort ").tag(Int?.none) Text(" ohne Lagerort ").tag(String?.none)
ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) } ForEach(locations) { loc in Text(loc.name).tag(String?.some(loc.id)) }
} }
} }
} }
@@ -188,7 +188,7 @@ struct ObjectAddSheet: View {
var perform: () async -> Void var perform: () async -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var locationId: Int? @State private var locationId: String?
@State private var quantity = "" @State private var quantity = ""
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
@@ -229,12 +229,12 @@ struct ObjectAddSheet: View {
struct ObjectRelocateSheet: View { struct ObjectRelocateSheet: View {
let product: Product let product: Product
let locations: [StorageLocation] let locations: [StorageLocation]
let initialFrom: Int? let initialFrom: String?
var perform: () async -> Void var perform: () async -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var fromId: Int? @State private var fromId: String?
@State private var toId: Int? @State private var toId: String?
@State private var quantity = "" @State private var quantity = ""
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
@@ -277,11 +277,11 @@ struct ObjectRemoveSheet: View {
let product: Product let product: Product
let locations: [StorageLocation] let locations: [StorageLocation]
let einheit: String let einheit: String
let initialLoc: Int? let initialLoc: String?
var perform: () async -> Void var perform: () async -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var locationId: Int? @State private var locationId: String?
@State private var quantity = "" @State private var quantity = ""
@State private var reason = "broken" @State private var reason = "broken"
@State private var note = "" @State private var note = ""

View File

@@ -424,7 +424,7 @@ struct ProductLocationMinView: View {
private struct MinRow: Identifiable { private struct MinRow: Identifiable {
let id = UUID() let id = UUID()
var locationId: Int? var locationId: String?
var amount: String var amount: String
} }
@@ -442,8 +442,8 @@ struct ProductLocationMinView: View {
ForEach($rows) { $row in ForEach($rows) { $row in
HStack { HStack {
Picker("Lagerort", selection: $row.locationId) { Picker("Lagerort", selection: $row.locationId) {
Text(" wählen ").tag(Int?.none) Text(" wählen ").tag(String?.none)
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
} }
TextField("Menge", text: $row.amount) TextField("Menge", text: $row.amount)
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
@@ -488,7 +488,7 @@ struct ProductLocationMinView: View {
busy = true busy = true
defer { busy = false } defer { busy = false }
var list: [LocationMinStockIn] = [] var list: [LocationMinStockIn] = []
var gesehen: Set<Int> = [] var gesehen: Set<String> = []
for row in rows { for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue } guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0 let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0

View File

@@ -100,7 +100,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const cents = centsFrom(addForm.price); const cents = centsFrom(addForm.price);
const created = await api.createItems(product.id, { const created = await api.createItems(product.id, {
count: Number(addForm.count) || 1, count: Number(addForm.count) || 1,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id), location_id: addForm.location_id === "" ? null : addForm.location_id,
shop_id: shopId, shop_id: shopId,
acquired_on: addForm.acquired_on || null, acquired_on: addForm.acquired_on || null,
warranty_until: addForm.warranty_until || null, warranty_until: addForm.warranty_until || null,
@@ -269,7 +269,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<td data-label="Lagerort"> <td data-label="Lagerort">
{isAdmin ? ( {isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }} <select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : Number(e.target.value) })}> onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
<option value=""> ohne </option> <option value=""> ohne </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)} {locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select> </select>

View File

@@ -35,7 +35,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
try { try {
const list = rows const list = rows
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0) .filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
.map((r) => ({ location_id: Number(r.location_id), min_stock: Number(r.min_stock) })); .map((r) => ({ location_id: r.location_id, min_stock: Number(r.min_stock) }));
await onSave(list); await onSave(list);
setOk(true); setOk(true);
} catch (err) { } catch (err) {

View File

@@ -56,7 +56,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
product_id: product.id, product_id: product.id,
quantity: menge, quantity: menge,
unit: einheit, unit: einheit,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id), location_id: addForm.location_id === "" ? null : addForm.location_id,
}); });
setAddForm({ location_id: addForm.location_id, quantity: "" }); setAddForm({ location_id: addForm.location_id, quantity: "" });
await nachAktion(); await nachAktion();
@@ -93,8 +93,8 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
await api.relocateStock({ await api.relocateStock({
product_id: product.id, product_id: product.id,
quantity: menge, quantity: menge,
from_location_id: move.from === "" ? null : Number(move.from), from_location_id: move.from === "" ? null : move.from,
to_location_id: move.to === "" ? null : Number(move.to), to_location_id: move.to === "" ? null : move.to,
}); });
setMove(null); setMove(null);
toast("Umgelagert."); toast("Umgelagert.");
@@ -110,7 +110,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
await api.removeStock({ await api.removeStock({
product_id: product.id, product_id: product.id,
quantity: menge, quantity: menge,
location_id: remove.location_id === "" ? null : Number(remove.location_id), location_id: remove.location_id === "" ? null : remove.location_id,
reason: remove.reason, reason: remove.reason,
note: remove.note || null, note: remove.note || null,
}); });

View File

@@ -237,7 +237,7 @@ export default function CheckIn() {
best_before: best_before:
(precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null, (precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null,
best_before_precision: precision, best_before_precision: precision,
location_id: locationId === "" ? null : Number(locationId), location_id: locationId === "" ? null : locationId,
})); }));
if (payloadLines.length === 0) { if (payloadLines.length === 0) {
setError("Bitte mindestens eine Menge angeben."); setError("Bitte mindestens eine Menge angeben.");

View File

@@ -26,7 +26,7 @@ export default function LocationResolve() {
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Lagerort {name || `#${id}`}</h1> <h1>Lagerort {name || id}</h1>
<div className="sub"> <div className="sub">
{error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."} {error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."}
</div> </div>

View File

@@ -30,7 +30,7 @@ export default function Locations() {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
try { try {
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) }); await api.createLocation({ name, parent_id: parentId === "" ? null : parentId });
setName(""); setName("");
// Übergeordneten Ort absichtlich stehen lassen beim Anlegen vieler Orte // Übergeordneten Ort absichtlich stehen lassen beim Anlegen vieler Orte
// unter demselben Elternteil spart das jedes Mal die Neuauswahl. // unter demselben Elternteil spart das jedes Mal die Neuauswahl.
@@ -56,7 +56,7 @@ export default function Locations() {
try { try {
await api.updateLocation(editId, { await api.updateLocation(editId, {
name: editName.trim(), name: editName.trim(),
parent_id: editParent == null ? null : Number(editParent), parent_id: editParent == null ? null : editParent,
}); });
setEditId(null); setEditId(null);
load(); load();
@@ -141,7 +141,7 @@ export default function Locations() {
<label className="grow"> <label className="grow">
Übergeordneter Lagerort (optional) Übergeordneter Lagerort (optional)
<CategorySelect <CategorySelect
value={parentId === "" ? null : Number(parentId)} value={parentId === "" ? null : parentId}
nodes={ordered} nodes={ordered}
rootLabel=" keiner (oberste Ebene) " rootLabel=" keiner (oberste Ebene) "
onChange={(id) => setParentId(id == null ? "" : String(id))} onChange={(id) => setParentId(id == null ? "" : String(id))}