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

Lagerort-IDs sind jetzt ein zufaelliger 10-Zeichen-Code (wie die Einzelstueck-UIDs) statt einer fortlaufenden Zahl - so kollidiert die Stammdaten-Sicherung zwischen zwei Instanzen praktisch nie mehr, und der Code ist zugleich der Inhalt des QR /l/<code>. Alle Fremdschluessel (lots, movements, items, Mindestbestaende, parent_id) ziehen mit; die Umstellung laeuft einmalig und transaktional beim Serverstart (_migrate_locations_to_code) und rollt bei Fehlern komplett zurueck. Vor dem Deploy ein DB-Backup machen.

iOS-Einlagern oeffnet nicht mehr sofort die Kamera, sondern ein Formular mit Artikelsuche; die Kamera kommt erst per Button. Im Formular laesst sich der Lagerort zusaetzlich per /l/-QR scannen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 12:15:25 +02:00
parent 6e48cd0a7a
commit d518b4aa23
23 changed files with 507 additions and 220 deletions

View File

@@ -39,6 +39,95 @@ from .services.group_codes import backfill as backfill_group_codes
settings = get_settings()
def _migrate_locations_to_code(conn) -> None:
"""Einmalige Umstellung: Lagerort-ID von fortlaufender Zahl auf 10-Zeichen-Code.
Läuft nur, solange ``locations.id`` noch eine Integer-Spalte ist danach ist
die Umstellung erledigt und der Block überspringt sich selbst. Alles passiert
innerhalb der umgebenden Transaktion: Bricht ein Schritt ab, wird komplett
zurückgerollt und die Datenbank bleibt auf dem alten (funktionierenden) Stand.
"""
id_type = conn.execute(text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = 'locations' AND column_name = 'id'"
)).scalar()
if id_type not in ("integer", "bigint", "smallint"):
return # frische Installation (schon VARCHAR) oder bereits umgestellt
from .models import generate_location_code
# 1. Jedem bestehenden Lagerort einen eindeutigen Code geben (id bleibt vorerst).
conn.execute(text("ALTER TABLE locations ADD COLUMN IF NOT EXISTS code VARCHAR(10)"))
vergeben: set[str] = set()
for (lid,) in conn.execute(text("SELECT id FROM locations")).all():
code = generate_location_code()
while code in vergeben:
code = generate_location_code()
vergeben.add(code)
conn.execute(text("UPDATE locations SET code = :c WHERE id = :i"), {"c": code, "i": lid})
# 2. Fremdschlüssel-Spalten (Integer) auf den Code umziehen. Beim DROP COLUMN
# fallen die alten FK-/Unique-Constraints automatisch mit weg.
kinder = [
# (Tabelle, ON DELETE, NOT NULL danach, Unique-Constraint danach)
("lots", "SET NULL", False, None),
("movements", "SET NULL", False, None),
("items", "SET NULL", False, None),
("product_location_min_stock", "CASCADE", True, ("uq_prod_loc_min", "product_id")),
("group_location_min_stock", "CASCADE", True, ("uq_group_loc_min", "group_id")),
]
vorhanden = []
for tabelle, ond, nn, uq in kinder:
spalte_da = conn.execute(text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :t AND column_name = 'location_id'"
), {"t": tabelle}).scalar()
if spalte_da is None:
continue # Spalte existiert (noch) nicht nichts umzuziehen
vorhanden.append((tabelle, ond, nn, uq))
conn.execute(text(f"ALTER TABLE {tabelle} ADD COLUMN location_code VARCHAR(10)"))
conn.execute(text(
f"UPDATE {tabelle} t SET location_code = l.code "
f"FROM locations l WHERE t.location_id = l.id"
))
conn.execute(text(f"ALTER TABLE {tabelle} DROP COLUMN location_id"))
conn.execute(text(f"ALTER TABLE {tabelle} RENAME COLUMN location_code TO location_id"))
# 2b. Selbstverweis parent_id ebenso auf den Code umziehen.
conn.execute(text("ALTER TABLE locations ADD COLUMN parent_code VARCHAR(10)"))
conn.execute(text(
"UPDATE locations c SET parent_code = p.code FROM locations p WHERE c.parent_id = p.id"
))
conn.execute(text("ALTER TABLE locations DROP COLUMN parent_id"))
# 3. Integer-PK durch den Code ersetzen (DROP COLUMN zieht PK und Sequenz mit).
conn.execute(text("ALTER TABLE locations DROP COLUMN id"))
conn.execute(text("ALTER TABLE locations RENAME COLUMN code TO id"))
conn.execute(text("ALTER TABLE locations ALTER COLUMN id SET NOT NULL"))
conn.execute(text("ALTER TABLE locations ADD PRIMARY KEY (id)"))
conn.execute(text("ALTER TABLE locations RENAME COLUMN parent_code TO parent_id"))
conn.execute(text(
"ALTER TABLE locations ADD CONSTRAINT locations_parent_id_fkey "
"FOREIGN KEY (parent_id) REFERENCES locations(id) ON DELETE SET NULL"
))
# 4. Fremdschlüssel (und Pflicht-/Unique-Bedingungen) neu setzen jetzt auf den Code.
for tabelle, ond, nn, uq in vorhanden:
if nn:
# Verwaiste Mindestbestände (Lagerort gelöscht) vorher entfernen.
conn.execute(text(f"DELETE FROM {tabelle} WHERE location_id IS NULL"))
conn.execute(text(f"ALTER TABLE {tabelle} ALTER COLUMN location_id SET NOT NULL"))
conn.execute(text(
f"ALTER TABLE {tabelle} ADD CONSTRAINT {tabelle}_location_id_fkey "
f"FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE {ond}"
))
if uq is not None:
name, andere = uq
conn.execute(text(
f"ALTER TABLE {tabelle} ADD CONSTRAINT {name} UNIQUE ({andere}, location_id)"
))
def _ensure_schema() -> None:
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
@@ -79,7 +168,9 @@ def _ensure_schema() -> None:
"REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
# location_id ist ein Lagerort-Code (VARCHAR(10)), siehe
# _migrate_locations_to_code deshalb hier gleich als VARCHAR anlegen.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id VARCHAR(10) "
"REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
@@ -90,6 +181,9 @@ def _ensure_schema() -> None:
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
]
with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
# damit die folgenden ADD-COLUMN-Verweise auf die neue VARCHAR-id passen.
_migrate_locations_to_code(conn)
for stmt in stmts:
conn.execute(text(stmt))
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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"))
}

View File

@@ -58,17 +58,18 @@ struct AssignScanView: View {
}
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/") {
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

View File

@@ -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/<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.
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/<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 {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
}

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -181,7 +181,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: 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<Item: TreeItem, Header: View, Editor: View>: View {
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
@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 error: String?
@State private var editing: Item?
@@ -200,7 +200,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: 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<Item: TreeItem, Header: View, Editor: View>: 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<Item: TreeItem, Header: View, Editor: View>: 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<Int> {
var result: Set<Int> = []
private static func descendants(of id: String, in all: [StorageLocation]) -> Set<String> {
var result: Set<String> = []
var stack = [id]
while let cur = stack.popLast() {
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).
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

View File

@@ -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 = ""

View File

@@ -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<Int> = []
var gesehen: Set<String> = []
for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
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 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
<td data-label="Lagerort">
{isAdmin ? (
<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>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>

View File

@@ -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) {

View File

@@ -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,
});

View File

@@ -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.");

View File

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

View File

@@ -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() {
<label className="grow">
Übergeordneter Lagerort (optional)
<CategorySelect
value={parentId === "" ? null : Number(parentId)}
value={parentId === "" ? null : parentId}
nodes={ordered}
rootLabel=" keiner (oberste Ebene) "
onChange={(id) => setParentId(id == null ? "" : String(id))}