diff --git a/backend/app/main.py b/backend/app/main.py index 6e6fa00..dcb3c53 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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. diff --git a/backend/app/models.py b/backend/app/models.py index 5958011..88bb734 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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/. +# 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) diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index c7d3323..080fb35 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -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: diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py index 16d0b7a..a39a1ff 100644 --- a/backend/app/routers/locations.py +++ b/backend/app/routers/locations.py @@ -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: diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 831a76a..c2b14e2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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] = [] diff --git a/backend/app/services/master_data.py b/backend/app/services/master_data.py index 9905e7c..22fc559 100644 --- a/backend/app/services/master_data.py +++ b/backend/app/services/master_data.py @@ -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, diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 8b9aeba..2975d50 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -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, diff --git a/backend/tests/test_locations.py b/backend/tests/test_locations.py index 65f0bde..8f51ec2 100644 --- a/backend/tests/test_locations.py +++ b/backend/tests/test_locations.py @@ -31,6 +31,17 @@ def _orte(db): 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): keller, regal, _ = _orte(db) 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): _, _, fach = _orte(db) 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 diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 172f554..8aa0121 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -435,19 +435,19 @@ actor APIClient { 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") try jsonBody(&request, RenameRequest(name: name)) 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") try jsonBody(&request, payload) 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")) } diff --git a/ios/Sources/AssignScanView.swift b/ios/Sources/AssignScanView.swift index ce446ce..e2fb98a 100644 --- a/ios/Sources/AssignScanView.swift +++ b/ios/Sources/AssignScanView.swift @@ -58,17 +58,18 @@ struct AssignScanView: View { } private func handle(_ code: String) async { - // Lagerort-QR (…/l/)? + // Lagerort-QR (…/l/)? Der Code ist eine 10-stellige Zeichenkette. if let r = code.range(of: "/l/") { - let idStr = String(code[r.upperBound...]) + let locCode = String(code[r.upperBound...]) .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")) - if let locId = Int(idStr) { + .uppercased() + if !locCode.isEmpty { guard let it = pending else { error = "Erst ein Einzelstück scannen."; status = nil; return } do { _ = 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)) status = "\(it.uid) zugeordnet. Nächstes Stück scannen." error = nil diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index 2c35fb2..4d9854e 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -153,10 +153,18 @@ struct CheckInFormView: View { /// Charge, fuer die gerade das MHD abgescannt wird. @State private var scanLineId: LineRef? @State private var unit: String = "" - @State private var locationId: Int? + @State private var locationId: String? @State private var busy = false @State private var error: String? + // Lagerort per QR (…/l/) 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. private var unitOptions: [UnitOption] { var options = units @@ -189,13 +197,31 @@ struct CheckInFormView: View { Picker("Einheit", selection: $unit) { ForEach(unitOptions) { Text($0.label).tag($0.value) } } - if !locations.isEmpty { + } + + if !locations.isEmpty { + Section { Picker("Lagerort", selection: $locationId) { - Text("– keiner –").tag(Int?.none) + Text("– keiner –").tag(String?.none) 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 { // Gibt es ein Gebinde, ist das die naheliegende Eingabe. 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/) 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 { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index d4213e8..472f9c8 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -3,9 +3,12 @@ import SwiftUI struct CheckInView: View { @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 torchOn = false @State private var torchLocked = false + @State private var status: String? @State private var error: String? @@ -19,68 +22,103 @@ struct CheckInView: View { @State private var unknownCode: String? @State private var manualCodeShown = false @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 locations: [StorageLocation] = [] var body: some View { - // Kamera bewusst nur als Feld oben statt bildschirmfuellend - der - // Sucher ueber die ganze Flaeche wirkte erschlagend. - ScrollView { - 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) + Form { + if let status { Section { banner(status, color: .green) } } + if let error { Section { banner(error, color: .red) } } - Text("Barcode vor die Kamera halten") - .font(.caption).foregroundStyle(.secondary) + Section { + Button { + error = nil; status = nil + scanShown = true + } label: { + Label("EAN scannen", systemImage: "barcode.viewfinder") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) - 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) + Button { + manualCode = "" + manualCodeShown = true + } label: { + Label("EAN von Hand eingeben", systemImage: "keyboard") } - VStack(spacing: 10) { - Button { - paused = true - manualCodeShown = true - } label: { - Label("EAN manuell", systemImage: "keyboard") - .frame(maxWidth: .infinity) + NavigationLink { + ProductFormView(prefillBarcode: nil, groupId: nil) { created in + product = created } - .buttonStyle(.borderedProminent) - - NavigationLink { - ProductFormView(prefillBarcode: nil, groupId: nil) { created in - product = created - } - } label: { - Label("Artikel anlegen", systemImage: "plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) + } label: { + Label("Neuen Artikel anlegen", systemImage: "plus") } - .padding(.horizontal) + } header: { + Text("Neu erfassen") + } footer: { + Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.") + } + + 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") .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button("Fertig") { dismiss() } - } - ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) } + ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } } .task { units = (try? await APIClient.shared.units()) ?? [] locations = (try? await APIClient.shared.locations()) ?? [] } + .task(id: query) { await search() } + .fullScreenCover(isPresented: $scanShown) { scannerCover } .alert("EAN eingeben", isPresented: $manualCodeShown) { TextField("z.B. 8076809572569", text: $manualCode) .keyboardType(.numberPad) @@ -89,22 +127,49 @@ struct CheckInView: View { manualCode = "" Task { await resolve(code) } } - Button("Abbrechen", role: .cancel) { paused = false } + Button("Abbrechen", role: .cancel) { } } .sheet(item: $product) { item in NavigationStack { CheckInFormView(product: item, units: units, locations: locations) { message in status = message product = nil - paused = false } } } - .sheet(item: $scannedItem, onDismiss: { paused = false }) { it in + .sheet(item: $scannedItem) { it in 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 private func banner(_ text: String, color: Color) -> some View { @@ -115,7 +180,14 @@ struct CheckInView: View { .background(color.opacity(0.9)) .foregroundStyle(.white) .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. @@ -141,64 +213,73 @@ struct CheckInView: View { 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 { - VStack(alignment: .leading, spacing: 8) { - Text(item.name).font(.headline) - Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) - .font(.caption).foregroundStyle(.secondary) - Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.") - .font(.caption2).foregroundStyle(.secondary) - groupNote() - NavigationLink { - ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, - categoryId: suggestedCategoryId, suggestion: item) { created in - suggestion = nil - product = created + @ViewBuilder + private func suggestionSection(_ item: LookupResult.Suggestion) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(item.name).font(.headline) + Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) + .font(.caption).foregroundStyle(.secondary) + Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.") + .font(.caption2).foregroundStyle(.secondary) + groupNote() + NavigationLink { + ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, + categoryId: suggestedCategoryId, suggestion: item) { created in + suggestion = nil + product = created + } + } label: { + Label("Anlegen & einlagern", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) } - } label: { - Label("Anlegen & einlagern", systemImage: "plus.circle.fill") - .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) + } header: { + Text("Vorschlag zum Scan") } - .padding() - .background(.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal) } - private func unknownCard(_ code: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - Text("Unbekannter Code \(code)").font(.headline) - Text("Weder im Katalog noch in Open Food / Products Facts.") - .font(.caption).foregroundStyle(.secondary) - groupNote() - NavigationLink { - ProductFormView(prefillBarcode: code, groupId: suggestedGroupId, - categoryId: suggestedCategoryId) { created in - unknownCode = nil - product = created + @ViewBuilder + private func unknownSection(_ code: String) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text("Unbekannter Code \(code)").font(.headline) + Text("Weder im Katalog noch in Open Food / Products Facts.") + .font(.caption).foregroundStyle(.secondary) + groupNote() + NavigationLink { + ProductFormView(prefillBarcode: code, groupId: suggestedGroupId, + categoryId: suggestedCategoryId) { created in + unknownCode = nil + product = created + } + } label: { + Label("Artikel anlegen", systemImage: "plus") + .frame(maxWidth: .infinity) } - } label: { - Label("Artikel anlegen", systemImage: "plus") - .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) } - .buttonStyle(.borderedProminent) + } header: { + Text("Zum Scan") } - .padding() - .background(.thinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal) } // 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 { paused = true error = nil @@ -213,6 +294,7 @@ struct CheckInView: View { .uppercased() if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) { scannedItem = item + scanShown = false return } } @@ -228,9 +310,10 @@ struct CheckInView: View { } else { unknownCode = code } + scanShown = false } catch { self.error = error.localizedDescription - paused = false + scanShown = false } } } diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 2e8c642..44bf50d 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -92,7 +92,7 @@ struct ItemEditView: View { @State private var locations: [StorageLocation] = [] @State private var shops: [ShopItem] = [] @State private var photo: Data? - @State private var locationId: Int? + @State private var locationId: String? @State private var shopId: Int? @State private var hasAcquired = false @State private var acquired = Date() @@ -138,8 +138,8 @@ struct ItemEditView: View { Section { Picker("Lagerort", selection: $locationId) { - Text("– ohne –").tag(Int?.none) - ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + Text("– ohne –").tag(String?.none) + ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) } } Picker("Gekauft bei", selection: $shopId) { Text("– unbekannt –").tag(Int?.none) @@ -406,7 +406,7 @@ struct ItemAddSheet: View { @State private var count = 1 @State private var locations: [StorageLocation] = [] @State private var shops: [ShopItem] = [] - @State private var locationId: Int? + @State private var locationId: String? @State private var shopId: Int? @State private var hasAcquired = false @State private var acquired = Date() @@ -430,8 +430,8 @@ struct ItemAddSheet: View { Section { Stepper("Anzahl: \(count)", value: $count, in: 1...200) Picker("Lagerort", selection: $locationId) { - Text("– ohne –").tag(Int?.none) - ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + Text("– ohne –").tag(String?.none) + ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) } } Picker("Gekauft bei", selection: $shopId) { Text("– unbekannt –").tag(Int?.none) diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index fc73c03..92a537a 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -181,7 +181,7 @@ struct TreeMasterView: View { let title: String let singular: String 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). let badge: (Item) -> String? /// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter). @@ -190,7 +190,7 @@ struct TreeMasterView: View { @ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor @State private var items: [Item] = [] - @State private var collapsed: Set = [] + @State private var collapsed: Set = [] @State private var busy = true @State private var error: String? @State private var editing: Item? @@ -200,7 +200,7 @@ struct TreeMasterView: View { private struct Node: Identifiable { let item: Item let depth: Int - var id: Int { item.id } + var id: Item.ID { item.id } } private var shown: [Item] { items.filter(include) } @@ -221,11 +221,11 @@ struct TreeMasterView: View { 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] { 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 while let cur = p { if collapsed.contains(cur) { return true } @@ -324,7 +324,7 @@ struct TreeMasterView: View { .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) } } @@ -441,7 +441,7 @@ struct LocationEditor: View { let done: () -> Void @State private var name: String - @State private var parentId: Int? + @State private var parentId: String? @State private var error: String? @State private var busy = false @@ -467,9 +467,9 @@ struct LocationEditor: View { } Section { Picker("Übergeordnet", selection: $parentId) { - Text("– oberste Ebene –").tag(Int?.none) + Text("– oberste Ebene –").tag(String?.none) 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: { @@ -513,8 +513,8 @@ struct LocationEditor: View { } /// Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen. - private static func descendants(of id: Int, in all: [StorageLocation]) -> Set { - var result: Set = [] + private static func descendants(of id: String, in all: [StorageLocation]) -> Set { + var result: Set = [] var stack = [id] while let cur = stack.popLast() { for kid in all where kid.parentId == cur { diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 562a660..9f33e95 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -134,10 +134,10 @@ struct Product: Codable, Identifiable, Hashable { /// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige). struct LocationMinStock: Codable, Hashable, Identifiable { - let locationId: Int + let locationId: String let locationName: String? let minStock: Double - var id: Int { locationId } + var id: String { locationId } enum CodingKeys: String, CodingKey { case minStock = "min_stock" @@ -148,7 +148,7 @@ struct LocationMinStock: Codable, Hashable, Identifiable { /// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten). struct LocationMinStockIn: Codable { - let locationId: Int + let locationId: String let minStock: Double enum CodingKeys: String, CodingKey { @@ -217,7 +217,7 @@ struct Lot: Codable, Identifiable, Hashable { let quantity: Double let bestBefore: String? let bestBeforePrecision: String? - let locationId: Int? + let locationId: String? enum CodingKeys: String, CodingKey { case id, quantity @@ -229,9 +229,9 @@ struct Lot: Codable, Identifiable, Hashable { } struct StorageLocation: Codable, Identifiable, Hashable { - let id: Int + let id: String let name: String - let parentId: Int? + let parentId: String? enum CodingKeys: String, CodingKey { case id, name @@ -243,7 +243,7 @@ struct CheckInLine: Codable { let quantity: Double let bestBefore: String? let bestBeforePrecision: String - let locationId: Int? + let locationId: String? enum CodingKeys: String, CodingKey { case quantity @@ -391,12 +391,12 @@ struct LocationNeedGroup: Codable, Identifiable { } struct LocationNeeds: Codable, Identifiable { - let locationId: Int + let locationId: String let locationName: String let products: [LocationNeedProduct] let groups: [LocationNeedGroup] - var id: Int { locationId } + var id: String { locationId } enum CodingKeys: String, CodingKey { case products, groups @@ -531,9 +531,9 @@ struct CategoryItem: Codable, Identifiable, Hashable { /// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache /// 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 parentId: Int? { get } + var parentId: ID? { get } } extension CategoryItem: TreeItem {} @@ -580,7 +580,7 @@ struct ObjectCheckInRequest: Codable { let productId: Int let quantity: Double let unit: String - let locationId: Int? + let locationId: String? enum CodingKeys: String, CodingKey { case quantity, unit @@ -592,8 +592,8 @@ struct ObjectCheckInRequest: Codable { struct RelocateRequest: Codable { let productId: Int let quantity: Double - let fromLocationId: Int? - let toLocationId: Int? + let fromLocationId: String? + let toLocationId: String? enum CodingKeys: String, CodingKey { case quantity @@ -606,7 +606,7 @@ struct RelocateRequest: Codable { struct RemoveRequest: Codable { let productId: Int let quantity: Double - let locationId: Int? + let locationId: String? let reason: String let note: String? @@ -627,7 +627,7 @@ struct RemovalStat: Codable, Identifiable, Hashable { struct RemovalHistoryItem: Codable, Identifiable, Hashable { let reason: String let quantity: Double - let locationId: Int? + let locationId: String? let locationName: String? let note: String? let username: String? @@ -681,7 +681,7 @@ struct Item: Codable, Identifiable, Hashable { let id: Int let uid: String let productId: Int - let locationId: Int? + let locationId: String? let locationName: String? let shopId: Int? let shopName: String? @@ -757,7 +757,7 @@ struct ItemDocumentUpload: Codable { struct ItemCreateRequest: Codable { let count: Int - let locationId: Int? + let locationId: String? let shopId: Int? let acquiredOn: String? let warrantyUntil: String? @@ -776,7 +776,7 @@ struct ItemCreateRequest: Codable { } struct ItemUpdateRequest: Encodable { - var locationId: Int? + var locationId: String? var shopId: Int? var acquiredOn: String? var warrantyUntil: String? @@ -996,7 +996,7 @@ struct PackageType: Codable, Identifiable, Hashable { struct NewLocationRequest: Codable { let name: String - let parentId: Int? + let parentId: String? enum CodingKeys: String, CodingKey { case name @@ -1008,7 +1008,7 @@ struct NewLocationRequest: Codable { /// (auch `null` = oberste Ebene), damit „auf oberste Ebene holen" ankommt. struct LocationUpdateRequest: Codable { let name: String - let parentId: Int? + let parentId: String? enum CodingKeys: String, CodingKey { case name diff --git a/ios/Sources/ObjectStockView.swift b/ios/Sources/ObjectStockView.swift index 13aa4b5..8a521d6 100644 --- a/ios/Sources/ObjectStockView.swift +++ b/ios/Sources/ObjectStockView.swift @@ -15,19 +15,19 @@ struct ObjectStockSection: View { enum StockSheet: Identifiable { case add - case relocate(from: Int?) - case remove(loc: Int?) + case relocate(from: String?) + case remove(loc: String?) var id: String { switch self { case .add: return "add" - case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")" - case .remove(let l): return "remove-\(l.map(String.init) ?? "none")" + case .relocate(let f): return "relocate-\(f ?? "none")" + case .remove(let l): return "remove-\(l ?? "none")" } } } 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" } return locations.first { $0.id == id }?.name ?? "Ort \(id)" } @@ -170,12 +170,12 @@ struct ObjectFieldRow: View { private struct LocationPicker: View { let title: String let locations: [StorageLocation] - @Binding var selection: Int? + @Binding var selection: String? var body: some View { Picker(title, selection: $selection) { - Text("– ohne Lagerort –").tag(Int?.none) - ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) } + Text("– ohne Lagerort –").tag(String?.none) + ForEach(locations) { loc in Text(loc.name).tag(String?.some(loc.id)) } } } } @@ -188,7 +188,7 @@ struct ObjectAddSheet: View { var perform: () async -> Void @Environment(\.dismiss) private var dismiss - @State private var locationId: Int? + @State private var locationId: String? @State private var quantity = "" @State private var busy = false @State private var error: String? @@ -229,12 +229,12 @@ struct ObjectAddSheet: View { struct ObjectRelocateSheet: View { let product: Product let locations: [StorageLocation] - let initialFrom: Int? + let initialFrom: String? var perform: () async -> Void @Environment(\.dismiss) private var dismiss - @State private var fromId: Int? - @State private var toId: Int? + @State private var fromId: String? + @State private var toId: String? @State private var quantity = "" @State private var busy = false @State private var error: String? @@ -277,11 +277,11 @@ struct ObjectRemoveSheet: View { let product: Product let locations: [StorageLocation] let einheit: String - let initialLoc: Int? + let initialLoc: String? var perform: () async -> Void @Environment(\.dismiss) private var dismiss - @State private var locationId: Int? + @State private var locationId: String? @State private var quantity = "" @State private var reason = "broken" @State private var note = "" diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 6646740..f6e87eb 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -424,7 +424,7 @@ struct ProductLocationMinView: View { private struct MinRow: Identifiable { let id = UUID() - var locationId: Int? + var locationId: String? var amount: String } @@ -442,8 +442,8 @@ struct ProductLocationMinView: View { ForEach($rows) { $row in HStack { Picker("Lagerort", selection: $row.locationId) { - Text("– wählen –").tag(Int?.none) - ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) } + Text("– wählen –").tag(String?.none) + ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) } } TextField("Menge", text: $row.amount) .keyboardType(.decimalPad) @@ -488,7 +488,7 @@ struct ProductLocationMinView: View { busy = true defer { busy = false } var list: [LocationMinStockIn] = [] - var gesehen: Set = [] + var gesehen: Set = [] for row in rows { guard let loc = row.locationId, !gesehen.contains(loc) else { continue } let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0 diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index f13edb7..2fd8069 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -100,7 +100,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh const cents = centsFrom(addForm.price); const created = await api.createItems(product.id, { 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, acquired_on: addForm.acquired_on || null, warranty_until: addForm.warranty_until || null, @@ -269,7 +269,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh {isAdmin ? ( diff --git a/web/src/components/LocationMinStock.jsx b/web/src/components/LocationMinStock.jsx index f3601ef..21ec50a 100644 --- a/web/src/components/LocationMinStock.jsx +++ b/web/src/components/LocationMinStock.jsx @@ -35,7 +35,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel = try { const list = rows .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); setOk(true); } catch (err) { diff --git a/web/src/components/ObjektBestand.jsx b/web/src/components/ObjektBestand.jsx index abef4e3..81076be 100644 --- a/web/src/components/ObjektBestand.jsx +++ b/web/src/components/ObjektBestand.jsx @@ -56,7 +56,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha product_id: product.id, quantity: menge, 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: "" }); await nachAktion(); @@ -93,8 +93,8 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha await api.relocateStock({ product_id: product.id, quantity: menge, - from_location_id: move.from === "" ? null : Number(move.from), - to_location_id: move.to === "" ? null : Number(move.to), + from_location_id: move.from === "" ? null : move.from, + to_location_id: move.to === "" ? null : move.to, }); setMove(null); toast("Umgelagert."); @@ -110,7 +110,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha await api.removeStock({ product_id: product.id, quantity: menge, - location_id: remove.location_id === "" ? null : Number(remove.location_id), + location_id: remove.location_id === "" ? null : remove.location_id, reason: remove.reason, note: remove.note || null, }); diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index ee96b28..f6ccb92 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -237,7 +237,7 @@ export default function CheckIn() { best_before: (precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null, best_before_precision: precision, - location_id: locationId === "" ? null : Number(locationId), + location_id: locationId === "" ? null : locationId, })); if (payloadLines.length === 0) { setError("Bitte mindestens eine Menge angeben."); diff --git a/web/src/pages/LocationResolve.jsx b/web/src/pages/LocationResolve.jsx index 5cf3cb8..39dc2e1 100644 --- a/web/src/pages/LocationResolve.jsx +++ b/web/src/pages/LocationResolve.jsx @@ -26,7 +26,7 @@ export default function LocationResolve() {
-

Lagerort {name || `#${id}`}

+

Lagerort {name || id}

{error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."}
diff --git a/web/src/pages/Locations.jsx b/web/src/pages/Locations.jsx index 22adc73..10762ac 100644 --- a/web/src/pages/Locations.jsx +++ b/web/src/pages/Locations.jsx @@ -30,7 +30,7 @@ export default function Locations() { e.preventDefault(); setError(null); try { - await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) }); + await api.createLocation({ name, parent_id: parentId === "" ? null : parentId }); setName(""); // Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte // unter demselben Elternteil spart das jedes Mal die Neuauswahl. @@ -56,7 +56,7 @@ export default function Locations() { try { await api.updateLocation(editId, { name: editName.trim(), - parent_id: editParent == null ? null : Number(editParent), + parent_id: editParent == null ? null : editParent, }); setEditId(null); load(); @@ -141,7 +141,7 @@ export default function Locations() {