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:
@@ -39,6 +39,95 @@ from .services.group_codes import backfill as backfill_group_codes
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_locations_to_code(conn) -> None:
|
||||||
|
"""Einmalige Umstellung: Lagerort-ID von fortlaufender Zahl auf 10-Zeichen-Code.
|
||||||
|
|
||||||
|
Läuft nur, solange ``locations.id`` noch eine Integer-Spalte ist – danach ist
|
||||||
|
die Umstellung erledigt und der Block überspringt sich selbst. Alles passiert
|
||||||
|
innerhalb der umgebenden Transaktion: Bricht ein Schritt ab, wird komplett
|
||||||
|
zurückgerollt und die Datenbank bleibt auf dem alten (funktionierenden) Stand.
|
||||||
|
"""
|
||||||
|
id_type = conn.execute(text(
|
||||||
|
"SELECT data_type FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'locations' AND column_name = 'id'"
|
||||||
|
)).scalar()
|
||||||
|
if id_type not in ("integer", "bigint", "smallint"):
|
||||||
|
return # frische Installation (schon VARCHAR) oder bereits umgestellt
|
||||||
|
|
||||||
|
from .models import generate_location_code
|
||||||
|
|
||||||
|
# 1. Jedem bestehenden Lagerort einen eindeutigen Code geben (id bleibt vorerst).
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD COLUMN IF NOT EXISTS code VARCHAR(10)"))
|
||||||
|
vergeben: set[str] = set()
|
||||||
|
for (lid,) in conn.execute(text("SELECT id FROM locations")).all():
|
||||||
|
code = generate_location_code()
|
||||||
|
while code in vergeben:
|
||||||
|
code = generate_location_code()
|
||||||
|
vergeben.add(code)
|
||||||
|
conn.execute(text("UPDATE locations SET code = :c WHERE id = :i"), {"c": code, "i": lid})
|
||||||
|
|
||||||
|
# 2. Fremdschlüssel-Spalten (Integer) auf den Code umziehen. Beim DROP COLUMN
|
||||||
|
# fallen die alten FK-/Unique-Constraints automatisch mit weg.
|
||||||
|
kinder = [
|
||||||
|
# (Tabelle, ON DELETE, NOT NULL danach, Unique-Constraint danach)
|
||||||
|
("lots", "SET NULL", False, None),
|
||||||
|
("movements", "SET NULL", False, None),
|
||||||
|
("items", "SET NULL", False, None),
|
||||||
|
("product_location_min_stock", "CASCADE", True, ("uq_prod_loc_min", "product_id")),
|
||||||
|
("group_location_min_stock", "CASCADE", True, ("uq_group_loc_min", "group_id")),
|
||||||
|
]
|
||||||
|
vorhanden = []
|
||||||
|
for tabelle, ond, nn, uq in kinder:
|
||||||
|
spalte_da = conn.execute(text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :t AND column_name = 'location_id'"
|
||||||
|
), {"t": tabelle}).scalar()
|
||||||
|
if spalte_da is None:
|
||||||
|
continue # Spalte existiert (noch) nicht – nichts umzuziehen
|
||||||
|
vorhanden.append((tabelle, ond, nn, uq))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} ADD COLUMN location_code VARCHAR(10)"))
|
||||||
|
conn.execute(text(
|
||||||
|
f"UPDATE {tabelle} t SET location_code = l.code "
|
||||||
|
f"FROM locations l WHERE t.location_id = l.id"
|
||||||
|
))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} DROP COLUMN location_id"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} RENAME COLUMN location_code TO location_id"))
|
||||||
|
|
||||||
|
# 2b. Selbstverweis parent_id ebenso auf den Code umziehen.
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD COLUMN parent_code VARCHAR(10)"))
|
||||||
|
conn.execute(text(
|
||||||
|
"UPDATE locations c SET parent_code = p.code FROM locations p WHERE c.parent_id = p.id"
|
||||||
|
))
|
||||||
|
conn.execute(text("ALTER TABLE locations DROP COLUMN parent_id"))
|
||||||
|
|
||||||
|
# 3. Integer-PK durch den Code ersetzen (DROP COLUMN zieht PK und Sequenz mit).
|
||||||
|
conn.execute(text("ALTER TABLE locations DROP COLUMN id"))
|
||||||
|
conn.execute(text("ALTER TABLE locations RENAME COLUMN code TO id"))
|
||||||
|
conn.execute(text("ALTER TABLE locations ALTER COLUMN id SET NOT NULL"))
|
||||||
|
conn.execute(text("ALTER TABLE locations ADD PRIMARY KEY (id)"))
|
||||||
|
conn.execute(text("ALTER TABLE locations RENAME COLUMN parent_code TO parent_id"))
|
||||||
|
conn.execute(text(
|
||||||
|
"ALTER TABLE locations ADD CONSTRAINT locations_parent_id_fkey "
|
||||||
|
"FOREIGN KEY (parent_id) REFERENCES locations(id) ON DELETE SET NULL"
|
||||||
|
))
|
||||||
|
|
||||||
|
# 4. Fremdschlüssel (und Pflicht-/Unique-Bedingungen) neu setzen – jetzt auf den Code.
|
||||||
|
for tabelle, ond, nn, uq in vorhanden:
|
||||||
|
if nn:
|
||||||
|
# Verwaiste Mindestbestände (Lagerort gelöscht) vorher entfernen.
|
||||||
|
conn.execute(text(f"DELETE FROM {tabelle} WHERE location_id IS NULL"))
|
||||||
|
conn.execute(text(f"ALTER TABLE {tabelle} ALTER COLUMN location_id SET NOT NULL"))
|
||||||
|
conn.execute(text(
|
||||||
|
f"ALTER TABLE {tabelle} ADD CONSTRAINT {tabelle}_location_id_fkey "
|
||||||
|
f"FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE {ond}"
|
||||||
|
))
|
||||||
|
if uq is not None:
|
||||||
|
name, andere = uq
|
||||||
|
conn.execute(text(
|
||||||
|
f"ALTER TABLE {tabelle} ADD CONSTRAINT {name} UNIQUE ({andere}, location_id)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _ensure_schema() -> None:
|
def _ensure_schema() -> None:
|
||||||
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
|
||||||
|
|
||||||
@@ -79,7 +168,9 @@ def _ensure_schema() -> None:
|
|||||||
"REFERENCES shops(id) ON DELETE SET NULL",
|
"REFERENCES shops(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
|
||||||
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
|
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
|
||||||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
|
# location_id ist ein Lagerort-Code (VARCHAR(10)), siehe
|
||||||
|
# _migrate_locations_to_code – deshalb hier gleich als VARCHAR anlegen.
|
||||||
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id VARCHAR(10) "
|
||||||
"REFERENCES locations(id) ON DELETE SET NULL",
|
"REFERENCES locations(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
|
||||||
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
|
||||||
@@ -90,6 +181,9 @@ def _ensure_schema() -> None:
|
|||||||
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
|
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
|
||||||
|
# damit die folgenden ADD-COLUMN-Verweise auf die neue VARCHAR-id passen.
|
||||||
|
_migrate_locations_to_code(conn)
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
conn.execute(text(stmt))
|
conn.execute(text(stmt))
|
||||||
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import secrets
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@@ -136,14 +137,28 @@ class Group(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
|
||||||
|
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
|
||||||
|
# praktisch nie, und der Code IST zugleich der Inhalt des QR /l/<code>.
|
||||||
|
# Alphabet ohne 0/O/1/I/L – wie bei den Einzelstück-UIDs gut ablesbar.
|
||||||
|
_LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_location_code() -> str:
|
||||||
|
"""Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar)."""
|
||||||
|
return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10))
|
||||||
|
|
||||||
|
|
||||||
class Location(Base):
|
class Location(Base):
|
||||||
__tablename__ = "locations"
|
__tablename__ = "locations"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[str] = mapped_column(
|
||||||
|
String(10), primary_key=True, default=generate_location_code
|
||||||
|
)
|
||||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
||||||
parent_id: Mapped[int | None] = mapped_column(
|
parent_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,8 +327,8 @@ class Lot(Base):
|
|||||||
String(8), nullable=False, default=DatePrecision.day.value,
|
String(8), nullable=False, default=DatePrecision.day.value,
|
||||||
server_default=DatePrecision.day.value,
|
server_default=DatePrecision.day.value,
|
||||||
)
|
)
|
||||||
location_id: Mapped[int | None] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
@@ -339,8 +354,8 @@ class Movement(Base):
|
|||||||
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
|
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
|
||||||
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
|
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
|
||||||
location_id: Mapped[int | None] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
|
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
|
||||||
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
|
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
|
||||||
@@ -527,8 +542,8 @@ class Item(Base):
|
|||||||
product_id: Mapped[int] = mapped_column(
|
product_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
location_id: Mapped[int | None] = mapped_column(
|
location_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
shop_id: Mapped[int | None] = mapped_column(
|
shop_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
|
||||||
@@ -583,8 +598,8 @@ class ProductLocationMinStock(Base):
|
|||||||
product_id: Mapped[int] = mapped_column(
|
product_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
location_id: Mapped[int] = mapped_column(
|
location_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
|
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
|
||||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
@@ -603,8 +618,8 @@ class GroupLocationMinStock(Base):
|
|||||||
group_id: Mapped[int] = mapped_column(
|
group_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
location_id: Mapped[int] = mapped_column(
|
location_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ def _item_or_404(db: Session, item_id: int) -> Item:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _check_refs(db: Session, shop_id: int | None, location_id: int | None) -> None:
|
def _check_refs(db: Session, shop_id: int | None, location_id: str | None) -> None:
|
||||||
if shop_id is not None and db.get(Shop, shop_id) is None:
|
if shop_id is not None and db.get(Shop, shop_id) is None:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
|
||||||
if location_id is not None and db.get(Location, location_id) is None:
|
if location_id is not None and db.get(Location, location_id) is None:
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ from ..schemas import LocationCreate, LocationOut, LocationUpdate
|
|||||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||||
|
|
||||||
|
|
||||||
def _descendant_ids(db: Session, location_id: int) -> set[int]:
|
def _descendant_ids(db: Session, location_id: str) -> set[str]:
|
||||||
"""Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen, sonst
|
"""Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen, sonst
|
||||||
entstünde ein Ring."""
|
entstünde ein Ring."""
|
||||||
result: set[int] = set()
|
result: set[str] = set()
|
||||||
stack = [location_id]
|
stack = [location_id]
|
||||||
while stack:
|
while stack:
|
||||||
cur = stack.pop()
|
cur = stack.pop()
|
||||||
@@ -46,7 +46,7 @@ def create_location(
|
|||||||
|
|
||||||
@router.patch("/{location_id}", response_model=LocationOut)
|
@router.patch("/{location_id}", response_model=LocationOut)
|
||||||
def update_location(
|
def update_location(
|
||||||
location_id: int,
|
location_id: str,
|
||||||
payload: LocationUpdate,
|
payload: LocationUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
@@ -93,7 +93,7 @@ def update_location(
|
|||||||
|
|
||||||
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_location(
|
def delete_location(
|
||||||
location_id: int,
|
location_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(require_admin),
|
_: User = Depends(require_admin),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -111,13 +111,13 @@ class UserUpdate(BaseModel):
|
|||||||
# ---- Groups ----
|
# ---- Groups ----
|
||||||
class LocationMinStockIn(BaseModel):
|
class LocationMinStockIn(BaseModel):
|
||||||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
|
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
|
||||||
location_id: int
|
location_id: str
|
||||||
min_stock: float = Field(ge=0)
|
min_stock: float = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class LocationMinStockOut(BaseModel):
|
class LocationMinStockOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
location_id: int
|
location_id: str
|
||||||
location_name: str | None = None
|
location_name: str | None = None
|
||||||
min_stock: float
|
min_stock: float
|
||||||
|
|
||||||
@@ -239,19 +239,19 @@ class FieldDefinitionOut(BaseModel):
|
|||||||
# ---- Locations ----
|
# ---- Locations ----
|
||||||
class LocationOut(BaseModel):
|
class LocationOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: str
|
||||||
name: str
|
name: str
|
||||||
parent_id: int | None = None
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationCreate(BaseModel):
|
class LocationCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=120)
|
name: str = Field(min_length=1, max_length=120)
|
||||||
parent_id: int | None = None
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LocationUpdate(BaseModel):
|
class LocationUpdate(BaseModel):
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
parent_id: int | None = None
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Gebinde (Packung, Glas, …) ----
|
# ---- Gebinde (Packung, Glas, …) ----
|
||||||
@@ -409,7 +409,7 @@ class CheckInRequest(BaseModel):
|
|||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -430,7 +430,7 @@ class LotOut(BaseModel):
|
|||||||
quantity: float
|
quantity: float
|
||||||
best_before: date | None
|
best_before: date | None
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None
|
location_id: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ class LotUpdate(BaseModel):
|
|||||||
quantity: float | None = Field(default=None, gt=0)
|
quantity: float | None = Field(default=None, gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
best_before_precision: DatePrecision | None = None
|
best_before_precision: DatePrecision | None = None
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CheckInResponse(BaseModel):
|
class CheckInResponse(BaseModel):
|
||||||
@@ -452,7 +452,7 @@ class CheckInLine(BaseModel):
|
|||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
best_before: date | None = None
|
best_before: date | None = None
|
||||||
best_before_precision: DatePrecision = DatePrecision.day
|
best_before_precision: DatePrecision = DatePrecision.day
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BatchCheckInRequest(BaseModel):
|
class BatchCheckInRequest(BaseModel):
|
||||||
@@ -479,8 +479,8 @@ class RelocateRequest(BaseModel):
|
|||||||
product_id: int | None = None
|
product_id: int | None = None
|
||||||
barcode: str | None = None
|
barcode: str | None = None
|
||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
from_location_id: int | None = None
|
from_location_id: str | None = None
|
||||||
to_location_id: int | None = None
|
to_location_id: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -489,7 +489,7 @@ class RemoveRequest(BaseModel):
|
|||||||
product_id: int | None = None
|
product_id: int | None = None
|
||||||
barcode: str | None = None
|
barcode: str | None = None
|
||||||
quantity: float = Field(gt=0)
|
quantity: float = Field(gt=0)
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
reason: RemovalReason
|
reason: RemovalReason
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@ class RemovalStat(BaseModel):
|
|||||||
class RemovalHistoryItem(BaseModel):
|
class RemovalHistoryItem(BaseModel):
|
||||||
reason: RemovalReason
|
reason: RemovalReason
|
||||||
quantity: float
|
quantity: float
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
location_name: str | None = None
|
location_name: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
@@ -524,7 +524,7 @@ class RemovalSummary(BaseModel):
|
|||||||
class ItemCreate(BaseModel):
|
class ItemCreate(BaseModel):
|
||||||
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||||||
count: int = Field(default=1, ge=1, le=200)
|
count: int = Field(default=1, ge=1, le=200)
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
acquired_on: date | None = None
|
acquired_on: date | None = None
|
||||||
warranty_until: date | None = None
|
warranty_until: date | None = None
|
||||||
@@ -534,7 +534,7 @@ class ItemCreate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ItemUpdate(BaseModel):
|
class ItemUpdate(BaseModel):
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
acquired_on: date | None = None
|
acquired_on: date | None = None
|
||||||
warranty_until: date | None = None
|
warranty_until: date | None = None
|
||||||
@@ -578,7 +578,7 @@ class ItemOut(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
uid: str
|
uid: str
|
||||||
product_id: int
|
product_id: int
|
||||||
location_id: int | None = None
|
location_id: str | None = None
|
||||||
location_name: str | None = None
|
location_name: str | None = None
|
||||||
shop_id: int | None = None
|
shop_id: int | None = None
|
||||||
shop_name: str | None = None
|
shop_name: str | None = None
|
||||||
@@ -652,7 +652,7 @@ class LocationNeedGroup(BaseModel):
|
|||||||
|
|
||||||
class LocationNeeds(BaseModel):
|
class LocationNeeds(BaseModel):
|
||||||
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
||||||
location_id: int
|
location_id: str
|
||||||
location_name: str
|
location_name: str
|
||||||
products: list[LocationNeedProduct] = []
|
products: list[LocationNeedProduct] = []
|
||||||
groups: list[LocationNeedGroup] = []
|
groups: list[LocationNeedGroup] = []
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ from .fields import options_list
|
|||||||
|
|
||||||
FORMAT_KEY = "vorrania_master_data"
|
FORMAT_KEY = "vorrania_master_data"
|
||||||
FORMAT_VERSION = 1
|
FORMAT_VERSION = 1
|
||||||
_TABLES = ("units", "package_types", "categories", "field_definitions", "locations")
|
# Nur Tabellen mit fortlaufender Integer-id brauchen die Sequenz-Korrektur.
|
||||||
|
# Lagerorte tragen einen zufälligen Code als id – dort gibt es keine Sequenz.
|
||||||
|
_TABLES = ("units", "package_types", "categories", "field_definitions")
|
||||||
|
|
||||||
|
|
||||||
def export_master_data(db: Session) -> dict:
|
def export_master_data(db: Session) -> dict:
|
||||||
@@ -46,7 +48,7 @@ def export_master_data(db: Session) -> dict:
|
|||||||
],
|
],
|
||||||
"locations": [
|
"locations": [
|
||||||
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
|
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
|
||||||
for l in db.query(Location).order_by(Location.id).all()
|
for l in db.query(Location).order_by(Location.name).all()
|
||||||
],
|
],
|
||||||
"units": [
|
"units": [
|
||||||
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,
|
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class StockError(ValueError):
|
|||||||
# oben (check_in/check_out) bleibt davon unberührt.
|
# oben (check_in/check_out) bleibt davon unberührt.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _object_lot(db: Session, product_id: int, location_id: int | None) -> Lot | None:
|
def _object_lot(db: Session, product_id: int, location_id: str | None) -> Lot | None:
|
||||||
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
|
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
|
||||||
query = db.query(Lot).filter(
|
query = db.query(Lot).filter(
|
||||||
Lot.product_id == product_id, Lot.best_before.is_(None)
|
Lot.product_id == product_id, Lot.best_before.is_(None)
|
||||||
@@ -50,7 +50,7 @@ def object_add(
|
|||||||
db: Session,
|
db: Session,
|
||||||
product: Product,
|
product: Product,
|
||||||
quantity: float,
|
quantity: float,
|
||||||
location_id: int | None,
|
location_id: str | None,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
) -> Lot:
|
) -> Lot:
|
||||||
@@ -86,7 +86,7 @@ def object_remove(
|
|||||||
db: Session,
|
db: Session,
|
||||||
product: Product,
|
product: Product,
|
||||||
quantity: float,
|
quantity: float,
|
||||||
location_id: int | None,
|
location_id: str | None,
|
||||||
reason: str,
|
reason: str,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
@@ -121,8 +121,8 @@ def object_relocate(
|
|||||||
db: Session,
|
db: Session,
|
||||||
product: Product,
|
product: Product,
|
||||||
quantity: float,
|
quantity: float,
|
||||||
from_location_id: int | None,
|
from_location_id: str | None,
|
||||||
to_location_id: int | None,
|
to_location_id: str | None,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -215,7 +215,7 @@ def current_stock(db: Session, product_id: int) -> float:
|
|||||||
return float(sum(q for (q,) in total))
|
return float(sum(q for (q,) in total))
|
||||||
|
|
||||||
|
|
||||||
def location_stock_base(db: Session, product: Product, location_id: int) -> float:
|
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||||||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||||||
@@ -235,9 +235,9 @@ def location_stock_base(db: Session, product: Product, location_id: int) -> floa
|
|||||||
return float(sum(q for (q,) in total))
|
return float(sum(q for (q,) in total))
|
||||||
|
|
||||||
|
|
||||||
def descendant_location_ids(db: Session, location_id: int) -> set[int]:
|
def descendant_location_ids(db: Session, location_id: str) -> set[str]:
|
||||||
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
|
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
|
||||||
result: set[int] = set()
|
result: set[str] = set()
|
||||||
stack = [location_id]
|
stack = [location_id]
|
||||||
while stack:
|
while stack:
|
||||||
cur = stack.pop()
|
cur = stack.pop()
|
||||||
@@ -248,7 +248,7 @@ def descendant_location_ids(db: Session, location_id: int) -> set[int]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def location_subtree_stock_base(db: Session, product: Product, location_id: int) -> float:
|
def location_subtree_stock_base(db: Session, product: Product, location_id: str) -> float:
|
||||||
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
|
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
|
||||||
|
|
||||||
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
|
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
|
||||||
@@ -264,7 +264,7 @@ def check_in(
|
|||||||
quantity: float,
|
quantity: float,
|
||||||
unit: str,
|
unit: str,
|
||||||
best_before: date | None,
|
best_before: date | None,
|
||||||
location_id: int | None,
|
location_id: str | None,
|
||||||
user: User | None,
|
user: User | None,
|
||||||
note: str | None = None,
|
note: str | None = None,
|
||||||
best_before_precision: str | None = DatePrecision.day.value,
|
best_before_precision: str | None = DatePrecision.day.value,
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ def _orte(db):
|
|||||||
return keller, regal, fach
|
return keller, regal, fach
|
||||||
|
|
||||||
|
|
||||||
|
def test_neuer_ort_bekommt_10_zeichen_code_als_id(db):
|
||||||
|
ort = Location(name="Speisekammer")
|
||||||
|
db.add(ort)
|
||||||
|
db.commit()
|
||||||
|
# Statt fortlaufender Zahl trägt der Lagerort einen zufälligen Code – das
|
||||||
|
# macht Sicherung/Import zwischen zwei Instanzen konfliktfrei.
|
||||||
|
assert isinstance(ort.id, str)
|
||||||
|
assert len(ort.id) == 10
|
||||||
|
assert set(ort.id) <= set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
||||||
|
|
||||||
|
|
||||||
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
|
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
|
||||||
keller, regal, _ = _orte(db)
|
keller, regal, _ = _orte(db)
|
||||||
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
|
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
|
||||||
@@ -67,5 +78,5 @@ def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin):
|
|||||||
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
|
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
|
||||||
_, _, fach = _orte(db)
|
_, _, fach = _orte(db)
|
||||||
with pytest.raises(HTTPException) as ex:
|
with pytest.raises(HTTPException) as ex:
|
||||||
update_location(fach.id, LocationUpdate(parent_id=99999), db=db, _=admin)
|
update_location(fach.id, LocationUpdate(parent_id="ZZZZZZZZZZ"), db=db, _=admin)
|
||||||
assert ex.value.status_code == 404
|
assert ex.value.status_code == 404
|
||||||
|
|||||||
@@ -435,19 +435,19 @@ actor APIClient {
|
|||||||
return try await send(request, as: StorageLocation.self)
|
return try await send(request, as: StorageLocation.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameLocation(id: Int, name: String) async throws -> StorageLocation {
|
func renameLocation(id: String, name: String) async throws -> StorageLocation {
|
||||||
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||||
try jsonBody(&request, RenameRequest(name: name))
|
try jsonBody(&request, RenameRequest(name: name))
|
||||||
return try await send(request, as: StorageLocation.self)
|
return try await send(request, as: StorageLocation.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateLocation(id: Int, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
func updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
|
||||||
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
var request = try makeRequest("/locations/\(id)", method: "PATCH")
|
||||||
try jsonBody(&request, payload)
|
try jsonBody(&request, payload)
|
||||||
return try await send(request, as: StorageLocation.self)
|
return try await send(request, as: StorageLocation.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteLocation(id: Int) async throws {
|
func deleteLocation(id: String) async throws {
|
||||||
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
|
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,17 +58,18 @@ struct AssignScanView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ code: String) async {
|
private func handle(_ code: String) async {
|
||||||
// Lagerort-QR (…/l/<ID>)?
|
// Lagerort-QR (…/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
|
||||||
if let r = code.range(of: "/l/") {
|
if let r = code.range(of: "/l/") {
|
||||||
let idStr = String(code[r.upperBound...])
|
let locCode = String(code[r.upperBound...])
|
||||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
if let locId = Int(idStr) {
|
.uppercased()
|
||||||
|
if !locCode.isEmpty {
|
||||||
guard let it = pending else {
|
guard let it = pending else {
|
||||||
error = "Erst ein Einzelstück scannen."; status = nil; return
|
error = "Erst ein Einzelstück scannen."; status = nil; return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
|
||||||
locationId: locId, shopId: it.shopId,
|
locationId: locCode, shopId: it.shopId,
|
||||||
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
|
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
|
||||||
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
|
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
|
||||||
error = nil
|
error = nil
|
||||||
|
|||||||
@@ -153,10 +153,18 @@ struct CheckInFormView: View {
|
|||||||
/// Charge, fuer die gerade das MHD abgescannt wird.
|
/// Charge, fuer die gerade das MHD abgescannt wird.
|
||||||
@State private var scanLineId: LineRef?
|
@State private var scanLineId: LineRef?
|
||||||
@State private var unit: String = ""
|
@State private var unit: String = ""
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
|
// Lagerort per QR (…/l/<Code>) setzen, statt in der Liste zu suchen.
|
||||||
|
@State private var locScanShown = false
|
||||||
|
@State private var locScanPaused = false
|
||||||
|
@State private var locTorchOn = false
|
||||||
|
@State private var locTorchLocked = false
|
||||||
|
@State private var locScanError: String?
|
||||||
|
@State private var locScanHinweis: String?
|
||||||
|
|
||||||
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
/// Einheiten der passenden Art plus das Gebinde des Artikels.
|
||||||
private var unitOptions: [UnitOption] {
|
private var unitOptions: [UnitOption] {
|
||||||
var options = units
|
var options = units
|
||||||
@@ -189,13 +197,31 @@ struct CheckInFormView: View {
|
|||||||
Picker("Einheit", selection: $unit) {
|
Picker("Einheit", selection: $unit) {
|
||||||
ForEach(unitOptions) { Text($0.label).tag($0.value) }
|
ForEach(unitOptions) { Text($0.label).tag($0.value) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !locations.isEmpty {
|
if !locations.isEmpty {
|
||||||
|
Section {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– keiner –").tag(Int?.none)
|
Text("– keiner –").tag(String?.none)
|
||||||
ForEach(locations) { location in
|
ForEach(locations) { location in
|
||||||
Text(location.name).tag(Int?.some(location.id))
|
Text(location.name).tag(String?.some(location.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
locScanError = nil
|
||||||
|
locScanPaused = false
|
||||||
|
locScanShown = true
|
||||||
|
} label: {
|
||||||
|
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
|
||||||
|
}
|
||||||
|
if let locScanHinweis {
|
||||||
|
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption).foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Lagerort")
|
||||||
|
} footer: {
|
||||||
|
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +307,7 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
// Gibt es ein Gebinde, ist das die naheliegende Eingabe.
|
||||||
unit = hasPackage ? UnitOption.packageValue : product.unitName
|
unit = hasPackage ? UnitOption.packageValue : product.unitName
|
||||||
@@ -289,6 +316,60 @@ struct CheckInFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Lagerort scannen
|
||||||
|
|
||||||
|
private var locationScannerCover: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
|
||||||
|
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if let locScanError {
|
||||||
|
Text(locScanError)
|
||||||
|
.font(.callout).foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
.background(.red, in: Capsule())
|
||||||
|
}
|
||||||
|
Text("QR am Regal/Fach vor die Kamera halten")
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
}
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.navigationTitle("Lagerort scannen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { locScanShown = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { locScanPaused = false; locScanError = nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aus einem gescannten Lagerort-QR (…/l/<Code>) den Zielort setzen.
|
||||||
|
private func handleLocationScan(_ code: String) async {
|
||||||
|
guard let r = code.range(of: "/l/") else {
|
||||||
|
locScanError = "Das ist kein Lagerort-QR."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ziel = String(code[r.upperBound...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
|
||||||
|
.uppercased()
|
||||||
|
if let treffer = locations.first(where: { $0.id == ziel }) {
|
||||||
|
locationId = treffer.id
|
||||||
|
locScanHinweis = treffer.name
|
||||||
|
locScanShown = false
|
||||||
|
} else {
|
||||||
|
locScanError = "Dieser Lagerort ist hier nicht bekannt."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func format(_ value: Double) -> String {
|
private func format(_ value: Double) -> String {
|
||||||
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import SwiftUI
|
|||||||
struct CheckInView: View {
|
struct CheckInView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Kamera bewusst nur auf Wunsch: „Einlagern“ öffnet ein Formular, kein Sucher.
|
||||||
|
@State private var scanShown = false
|
||||||
@State private var paused = false
|
@State private var paused = false
|
||||||
@State private var torchOn = false
|
@State private var torchOn = false
|
||||||
@State private var torchLocked = false
|
@State private var torchLocked = false
|
||||||
|
|
||||||
@State private var status: String?
|
@State private var status: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
|
|
||||||
@@ -19,68 +22,103 @@ struct CheckInView: View {
|
|||||||
@State private var unknownCode: String?
|
@State private var unknownCode: String?
|
||||||
@State private var manualCodeShown = false
|
@State private var manualCodeShown = false
|
||||||
@State private var manualCode = ""
|
@State private var manualCode = ""
|
||||||
|
|
||||||
|
// Manuelle Artikelsuche (statt Scanzwang).
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var results: [Product] = []
|
||||||
|
@State private var searching = false
|
||||||
|
|
||||||
@State private var units: [Unit] = []
|
@State private var units: [Unit] = []
|
||||||
@State private var locations: [StorageLocation] = []
|
@State private var locations: [StorageLocation] = []
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der
|
Form {
|
||||||
// Sucher ueber die ganze Flaeche wirkte erschlagend.
|
if let status { Section { banner(status, color: .green) } }
|
||||||
ScrollView {
|
if let error { Section { banner(error, color: .red) } }
|
||||||
VStack(spacing: 14) {
|
|
||||||
ScannerView(onCode: { code in Task { await resolve(code) } },
|
|
||||||
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
|
||||||
.aspectRatio(4.0 / 3.0, contentMode: .fit)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
Text("Barcode vor die Kamera halten")
|
Section {
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
if let status { banner(status, color: .green) }
|
|
||||||
if let error { banner(error, color: .red) }
|
|
||||||
|
|
||||||
if let suggestion {
|
|
||||||
suggestionCard(suggestion)
|
|
||||||
} else if let unknownCode {
|
|
||||||
unknownCard(unknownCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Button {
|
Button {
|
||||||
paused = true
|
error = nil; status = nil
|
||||||
manualCodeShown = true
|
scanShown = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("EAN manuell", systemImage: "keyboard")
|
Label("EAN scannen", systemImage: "barcode.viewfinder")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
manualCode = ""
|
||||||
|
manualCodeShown = true
|
||||||
|
} label: {
|
||||||
|
Label("EAN von Hand eingeben", systemImage: "keyboard")
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
|
||||||
product = created
|
product = created
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Artikel anlegen", systemImage: "plus")
|
Label("Neuen Artikel anlegen", systemImage: "plus")
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
} header: {
|
||||||
|
Text("Neu erfassen")
|
||||||
|
} footer: {
|
||||||
|
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ – kein Zwang.")
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
|
if let suggestion {
|
||||||
|
suggestionSection(suggestion)
|
||||||
|
} else if let unknownCode {
|
||||||
|
unknownSection(unknownCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||||
|
TextField("Name oder Marke …", text: $query)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
if !query.isEmpty {
|
||||||
|
Button { query = "" } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if searching {
|
||||||
|
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
ForEach(results) { p in
|
||||||
|
Button {
|
||||||
|
error = nil; status = nil
|
||||||
|
product = p
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(p.name).foregroundStyle(.primary)
|
||||||
|
let unter = [p.brand, "Bestand: \(bestandText(p))"]
|
||||||
|
.compactMap { $0 }.joined(separator: " · ")
|
||||||
|
Text(unter).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
|
||||||
|
Text("Kein Artikel gefunden – oben neu anlegen.")
|
||||||
|
.foregroundStyle(.secondary).font(.callout)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Vorhandenen Artikel wählen")
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Einlagern")
|
.navigationTitle("Einlagern")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
|
||||||
Button("Fertig") { dismiss() }
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
units = (try? await APIClient.shared.units()) ?? []
|
units = (try? await APIClient.shared.units()) ?? []
|
||||||
locations = (try? await APIClient.shared.locations()) ?? []
|
locations = (try? await APIClient.shared.locations()) ?? []
|
||||||
}
|
}
|
||||||
|
.task(id: query) { await search() }
|
||||||
|
.fullScreenCover(isPresented: $scanShown) { scannerCover }
|
||||||
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
.alert("EAN eingeben", isPresented: $manualCodeShown) {
|
||||||
TextField("z.B. 8076809572569", text: $manualCode)
|
TextField("z.B. 8076809572569", text: $manualCode)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
@@ -89,22 +127,49 @@ struct CheckInView: View {
|
|||||||
manualCode = ""
|
manualCode = ""
|
||||||
Task { await resolve(code) }
|
Task { await resolve(code) }
|
||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) { paused = false }
|
Button("Abbrechen", role: .cancel) { }
|
||||||
}
|
}
|
||||||
.sheet(item: $product) { item in
|
.sheet(item: $product) { item in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
CheckInFormView(product: item, units: units, locations: locations) { message in
|
CheckInFormView(product: item, units: units, locations: locations) { message in
|
||||||
status = message
|
status = message
|
||||||
product = nil
|
product = nil
|
||||||
paused = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
|
.sheet(item: $scannedItem) { it in
|
||||||
NavigationStack { ItemEditView(item: it) }
|
NavigationStack { ItemEditView(item: it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Scanner (nur auf Wunsch)
|
||||||
|
|
||||||
|
private var scannerCover: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScannerView(onCode: { code in Task { await resolve(code) } },
|
||||||
|
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Text("Barcode vor die Kamera halten")
|
||||||
|
.font(.callout)
|
||||||
|
.padding(10)
|
||||||
|
.background(.ultraThinMaterial, in: Capsule())
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.navigationTitle("EAN scannen")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Abbrechen") { scanShown = false }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
TorchButton(isOn: $torchOn, locked: $torchLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { paused = false }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Bausteine
|
// MARK: - Bausteine
|
||||||
|
|
||||||
private func banner(_ text: String, color: Color) -> some View {
|
private func banner(_ text: String, color: Color) -> some View {
|
||||||
@@ -115,7 +180,14 @@ struct CheckInView: View {
|
|||||||
.background(color.opacity(0.9))
|
.background(color.opacity(0.9))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.padding(.horizontal)
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bestandText(_ p: Product) -> String {
|
||||||
|
let wert = p.stockInArticleUnits
|
||||||
|
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
|
||||||
|
return "\(zahl) \(p.articleUnitLabel)"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
|
||||||
@@ -141,13 +213,11 @@ struct CheckInView: View {
|
|||||||
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
Text(detail).font(.caption2).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(Color.accentColor.opacity(0.15))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View {
|
@ViewBuilder
|
||||||
|
private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
|
||||||
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(item.name).font(.headline)
|
Text(item.name).font(.headline)
|
||||||
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
|
||||||
@@ -167,13 +237,14 @@ struct CheckInView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.padding()
|
} header: {
|
||||||
.background(.thinMaterial)
|
Text("Vorschlag zum Scan")
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unknownCard(_ code: String) -> some View {
|
@ViewBuilder
|
||||||
|
private func unknownSection(_ code: String) -> some View {
|
||||||
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Unbekannter Code \(code)").font(.headline)
|
Text("Unbekannter Code \(code)").font(.headline)
|
||||||
Text("Weder im Katalog noch in Open Food / Products Facts.")
|
Text("Weder im Katalog noch in Open Food / Products Facts.")
|
||||||
@@ -191,14 +262,24 @@ struct CheckInView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.padding()
|
} header: {
|
||||||
.background(.thinMaterial)
|
Text("Zum Scan")
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
}
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Logik
|
// MARK: - Logik
|
||||||
|
|
||||||
|
private func search() async {
|
||||||
|
let q = query.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard q.count >= 2 else { results = []; searching = false; return }
|
||||||
|
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
|
||||||
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
searching = true
|
||||||
|
defer { searching = false }
|
||||||
|
results = (try? await APIClient.shared.searchProducts(q)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
private func resolve(_ code: String) async {
|
private func resolve(_ code: String) async {
|
||||||
paused = true
|
paused = true
|
||||||
error = nil
|
error = nil
|
||||||
@@ -213,6 +294,7 @@ struct CheckInView: View {
|
|||||||
.uppercased()
|
.uppercased()
|
||||||
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
|
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
|
||||||
scannedItem = item
|
scannedItem = item
|
||||||
|
scanShown = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,9 +310,10 @@ struct CheckInView: View {
|
|||||||
} else {
|
} else {
|
||||||
unknownCode = code
|
unknownCode = code
|
||||||
}
|
}
|
||||||
|
scanShown = false
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
paused = false
|
scanShown = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ struct ItemEditView: View {
|
|||||||
@State private var locations: [StorageLocation] = []
|
@State private var locations: [StorageLocation] = []
|
||||||
@State private var shops: [ShopItem] = []
|
@State private var shops: [ShopItem] = []
|
||||||
@State private var photo: Data?
|
@State private var photo: Data?
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var shopId: Int?
|
@State private var shopId: Int?
|
||||||
@State private var hasAcquired = false
|
@State private var hasAcquired = false
|
||||||
@State private var acquired = Date()
|
@State private var acquired = Date()
|
||||||
@@ -138,8 +138,8 @@ struct ItemEditView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– ohne –").tag(Int?.none)
|
Text("– ohne –").tag(String?.none)
|
||||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||||
}
|
}
|
||||||
Picker("Gekauft bei", selection: $shopId) {
|
Picker("Gekauft bei", selection: $shopId) {
|
||||||
Text("– unbekannt –").tag(Int?.none)
|
Text("– unbekannt –").tag(Int?.none)
|
||||||
@@ -406,7 +406,7 @@ struct ItemAddSheet: View {
|
|||||||
@State private var count = 1
|
@State private var count = 1
|
||||||
@State private var locations: [StorageLocation] = []
|
@State private var locations: [StorageLocation] = []
|
||||||
@State private var shops: [ShopItem] = []
|
@State private var shops: [ShopItem] = []
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var shopId: Int?
|
@State private var shopId: Int?
|
||||||
@State private var hasAcquired = false
|
@State private var hasAcquired = false
|
||||||
@State private var acquired = Date()
|
@State private var acquired = Date()
|
||||||
@@ -430,8 +430,8 @@ struct ItemAddSheet: View {
|
|||||||
Section {
|
Section {
|
||||||
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
|
||||||
Picker("Lagerort", selection: $locationId) {
|
Picker("Lagerort", selection: $locationId) {
|
||||||
Text("– ohne –").tag(Int?.none)
|
Text("– ohne –").tag(String?.none)
|
||||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||||
}
|
}
|
||||||
Picker("Gekauft bei", selection: $shopId) {
|
Picker("Gekauft bei", selection: $shopId) {
|
||||||
Text("– unbekannt –").tag(Int?.none)
|
Text("– unbekannt –").tag(Int?.none)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
let title: String
|
let title: String
|
||||||
let singular: String
|
let singular: String
|
||||||
let load: () async throws -> [Item]
|
let load: () async throws -> [Item]
|
||||||
let delete: (Int) async throws -> Void
|
let delete: (Item.ID) async throws -> Void
|
||||||
/// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ).
|
/// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ).
|
||||||
let badge: (Item) -> String?
|
let badge: (Item) -> String?
|
||||||
/// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter).
|
/// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter).
|
||||||
@@ -190,7 +190,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
|
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
|
||||||
|
|
||||||
@State private var items: [Item] = []
|
@State private var items: [Item] = []
|
||||||
@State private var collapsed: Set<Int> = []
|
@State private var collapsed: Set<Item.ID> = []
|
||||||
@State private var busy = true
|
@State private var busy = true
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var editing: Item?
|
@State private var editing: Item?
|
||||||
@@ -200,7 +200,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
private struct Node: Identifiable {
|
private struct Node: Identifiable {
|
||||||
let item: Item
|
let item: Item
|
||||||
let depth: Int
|
let depth: Int
|
||||||
var id: Int { item.id }
|
var id: Item.ID { item.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shown: [Item] { items.filter(include) }
|
private var shown: [Item] { items.filter(include) }
|
||||||
@@ -221,11 +221,11 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private func childCount(_ id: Int) -> Int { shown.filter { $0.parentId == id }.count }
|
private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
|
||||||
|
|
||||||
private var visible: [Node] {
|
private var visible: [Node] {
|
||||||
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
|
||||||
func hidden(_ id: Int) -> Bool {
|
func hidden(_ id: Item.ID) -> Bool {
|
||||||
var p = parent[id] ?? nil
|
var p = parent[id] ?? nil
|
||||||
while let cur = p {
|
while let cur = p {
|
||||||
if collapsed.contains(cur) { return true }
|
if collapsed.contains(cur) { return true }
|
||||||
@@ -324,7 +324,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
|
|||||||
.padding(.leading, CGFloat(node.depth) * 16)
|
.padding(.leading, CGFloat(node.depth) * 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggle(_ id: Int) {
|
private func toggle(_ id: Item.ID) {
|
||||||
if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) }
|
if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +441,7 @@ struct LocationEditor: View {
|
|||||||
let done: () -> Void
|
let done: () -> Void
|
||||||
|
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var parentId: Int?
|
@State private var parentId: String?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
|
|
||||||
@@ -467,9 +467,9 @@ struct LocationEditor: View {
|
|||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Picker("Übergeordnet", selection: $parentId) {
|
Picker("Übergeordnet", selection: $parentId) {
|
||||||
Text("– oberste Ebene –").tag(Int?.none)
|
Text("– oberste Ebene –").tag(String?.none)
|
||||||
ForEach(parentOptions) { loc in
|
ForEach(parentOptions) { loc in
|
||||||
Text(LocationEditor.pfad(loc, in: all)).tag(Int?.some(loc.id))
|
Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -513,8 +513,8 @@ struct LocationEditor: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen.
|
/// Alle Unterorte (rekursiv) – als Ziel beim Umhängen ausgeschlossen.
|
||||||
private static func descendants(of id: Int, in all: [StorageLocation]) -> Set<Int> {
|
private static func descendants(of id: String, in all: [StorageLocation]) -> Set<String> {
|
||||||
var result: Set<Int> = []
|
var result: Set<String> = []
|
||||||
var stack = [id]
|
var stack = [id]
|
||||||
while let cur = stack.popLast() {
|
while let cur = stack.popLast() {
|
||||||
for kid in all where kid.parentId == cur {
|
for kid in all where kid.parentId == cur {
|
||||||
|
|||||||
@@ -134,10 +134,10 @@ struct Product: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
|
||||||
struct LocationMinStock: Codable, Hashable, Identifiable {
|
struct LocationMinStock: Codable, Hashable, Identifiable {
|
||||||
let locationId: Int
|
let locationId: String
|
||||||
let locationName: String?
|
let locationName: String?
|
||||||
let minStock: Double
|
let minStock: Double
|
||||||
var id: Int { locationId }
|
var id: String { locationId }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case minStock = "min_stock"
|
case minStock = "min_stock"
|
||||||
@@ -148,7 +148,7 @@ struct LocationMinStock: Codable, Hashable, Identifiable {
|
|||||||
|
|
||||||
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
|
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
|
||||||
struct LocationMinStockIn: Codable {
|
struct LocationMinStockIn: Codable {
|
||||||
let locationId: Int
|
let locationId: String
|
||||||
let minStock: Double
|
let minStock: Double
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
@@ -217,7 +217,7 @@ struct Lot: Codable, Identifiable, Hashable {
|
|||||||
let quantity: Double
|
let quantity: Double
|
||||||
let bestBefore: String?
|
let bestBefore: String?
|
||||||
let bestBeforePrecision: String?
|
let bestBeforePrecision: String?
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, quantity
|
case id, quantity
|
||||||
@@ -229,9 +229,9 @@ struct Lot: Codable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct StorageLocation: Codable, Identifiable, Hashable {
|
struct StorageLocation: Codable, Identifiable, Hashable {
|
||||||
let id: Int
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let parentId: Int?
|
let parentId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name
|
case id, name
|
||||||
@@ -243,7 +243,7 @@ struct CheckInLine: Codable {
|
|||||||
let quantity: Double
|
let quantity: Double
|
||||||
let bestBefore: String?
|
let bestBefore: String?
|
||||||
let bestBeforePrecision: String
|
let bestBeforePrecision: String
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity
|
case quantity
|
||||||
@@ -391,12 +391,12 @@ struct LocationNeedGroup: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct LocationNeeds: Codable, Identifiable {
|
struct LocationNeeds: Codable, Identifiable {
|
||||||
let locationId: Int
|
let locationId: String
|
||||||
let locationName: String
|
let locationName: String
|
||||||
let products: [LocationNeedProduct]
|
let products: [LocationNeedProduct]
|
||||||
let groups: [LocationNeedGroup]
|
let groups: [LocationNeedGroup]
|
||||||
|
|
||||||
var id: Int { locationId }
|
var id: String { locationId }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case products, groups
|
case products, groups
|
||||||
@@ -531,9 +531,9 @@ struct CategoryItem: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
/// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache
|
/// Gemeinsame Form baumartiger Stammdaten (Kategorien, Lagerorte): eine flache
|
||||||
/// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt.
|
/// Liste mit Eltern-Verweis, aus der sich der Baum aufbauen lässt.
|
||||||
protocol TreeItem: Identifiable where ID == Int {
|
protocol TreeItem: Identifiable {
|
||||||
var name: String { get }
|
var name: String { get }
|
||||||
var parentId: Int? { get }
|
var parentId: ID? { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension CategoryItem: TreeItem {}
|
extension CategoryItem: TreeItem {}
|
||||||
@@ -580,7 +580,7 @@ struct ObjectCheckInRequest: Codable {
|
|||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let unit: String
|
let unit: String
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity, unit
|
case quantity, unit
|
||||||
@@ -592,8 +592,8 @@ struct ObjectCheckInRequest: Codable {
|
|||||||
struct RelocateRequest: Codable {
|
struct RelocateRequest: Codable {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let fromLocationId: Int?
|
let fromLocationId: String?
|
||||||
let toLocationId: Int?
|
let toLocationId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case quantity
|
case quantity
|
||||||
@@ -606,7 +606,7 @@ struct RelocateRequest: Codable {
|
|||||||
struct RemoveRequest: Codable {
|
struct RemoveRequest: Codable {
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
let reason: String
|
let reason: String
|
||||||
let note: String?
|
let note: String?
|
||||||
|
|
||||||
@@ -627,7 +627,7 @@ struct RemovalStat: Codable, Identifiable, Hashable {
|
|||||||
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
|
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
|
||||||
let reason: String
|
let reason: String
|
||||||
let quantity: Double
|
let quantity: Double
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
let locationName: String?
|
let locationName: String?
|
||||||
let note: String?
|
let note: String?
|
||||||
let username: String?
|
let username: String?
|
||||||
@@ -681,7 +681,7 @@ struct Item: Codable, Identifiable, Hashable {
|
|||||||
let id: Int
|
let id: Int
|
||||||
let uid: String
|
let uid: String
|
||||||
let productId: Int
|
let productId: Int
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
let locationName: String?
|
let locationName: String?
|
||||||
let shopId: Int?
|
let shopId: Int?
|
||||||
let shopName: String?
|
let shopName: String?
|
||||||
@@ -757,7 +757,7 @@ struct ItemDocumentUpload: Codable {
|
|||||||
|
|
||||||
struct ItemCreateRequest: Codable {
|
struct ItemCreateRequest: Codable {
|
||||||
let count: Int
|
let count: Int
|
||||||
let locationId: Int?
|
let locationId: String?
|
||||||
let shopId: Int?
|
let shopId: Int?
|
||||||
let acquiredOn: String?
|
let acquiredOn: String?
|
||||||
let warrantyUntil: String?
|
let warrantyUntil: String?
|
||||||
@@ -776,7 +776,7 @@ struct ItemCreateRequest: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct ItemUpdateRequest: Encodable {
|
struct ItemUpdateRequest: Encodable {
|
||||||
var locationId: Int?
|
var locationId: String?
|
||||||
var shopId: Int?
|
var shopId: Int?
|
||||||
var acquiredOn: String?
|
var acquiredOn: String?
|
||||||
var warrantyUntil: String?
|
var warrantyUntil: String?
|
||||||
@@ -996,7 +996,7 @@ struct PackageType: Codable, Identifiable, Hashable {
|
|||||||
|
|
||||||
struct NewLocationRequest: Codable {
|
struct NewLocationRequest: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
let parentId: Int?
|
let parentId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
@@ -1008,7 +1008,7 @@ struct NewLocationRequest: Codable {
|
|||||||
/// (auch `null` = oberste Ebene), damit „auf oberste Ebene holen" ankommt.
|
/// (auch `null` = oberste Ebene), damit „auf oberste Ebene holen" ankommt.
|
||||||
struct LocationUpdateRequest: Codable {
|
struct LocationUpdateRequest: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
let parentId: Int?
|
let parentId: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
|
|||||||
@@ -15,19 +15,19 @@ struct ObjectStockSection: View {
|
|||||||
|
|
||||||
enum StockSheet: Identifiable {
|
enum StockSheet: Identifiable {
|
||||||
case add
|
case add
|
||||||
case relocate(from: Int?)
|
case relocate(from: String?)
|
||||||
case remove(loc: Int?)
|
case remove(loc: String?)
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .add: return "add"
|
case .add: return "add"
|
||||||
case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")"
|
case .relocate(let f): return "relocate-\(f ?? "none")"
|
||||||
case .remove(let l): return "remove-\(l.map(String.init) ?? "none")"
|
case .remove(let l): return "remove-\(l ?? "none")"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
|
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
|
||||||
private func ortName(_ id: Int?) -> String {
|
private func ortName(_ id: String?) -> String {
|
||||||
guard let id else { return "Ohne Lagerort" }
|
guard let id else { return "Ohne Lagerort" }
|
||||||
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
|
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
|
||||||
}
|
}
|
||||||
@@ -170,12 +170,12 @@ struct ObjectFieldRow: View {
|
|||||||
private struct LocationPicker: View {
|
private struct LocationPicker: View {
|
||||||
let title: String
|
let title: String
|
||||||
let locations: [StorageLocation]
|
let locations: [StorageLocation]
|
||||||
@Binding var selection: Int?
|
@Binding var selection: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Picker(title, selection: $selection) {
|
Picker(title, selection: $selection) {
|
||||||
Text("– ohne Lagerort –").tag(Int?.none)
|
Text("– ohne Lagerort –").tag(String?.none)
|
||||||
ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) }
|
ForEach(locations) { loc in Text(loc.name).tag(String?.some(loc.id)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ struct ObjectAddSheet: View {
|
|||||||
var perform: () async -> Void
|
var perform: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var quantity = ""
|
@State private var quantity = ""
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -229,12 +229,12 @@ struct ObjectAddSheet: View {
|
|||||||
struct ObjectRelocateSheet: View {
|
struct ObjectRelocateSheet: View {
|
||||||
let product: Product
|
let product: Product
|
||||||
let locations: [StorageLocation]
|
let locations: [StorageLocation]
|
||||||
let initialFrom: Int?
|
let initialFrom: String?
|
||||||
var perform: () async -> Void
|
var perform: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var fromId: Int?
|
@State private var fromId: String?
|
||||||
@State private var toId: Int?
|
@State private var toId: String?
|
||||||
@State private var quantity = ""
|
@State private var quantity = ""
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@@ -277,11 +277,11 @@ struct ObjectRemoveSheet: View {
|
|||||||
let product: Product
|
let product: Product
|
||||||
let locations: [StorageLocation]
|
let locations: [StorageLocation]
|
||||||
let einheit: String
|
let einheit: String
|
||||||
let initialLoc: Int?
|
let initialLoc: String?
|
||||||
var perform: () async -> Void
|
var perform: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var locationId: Int?
|
@State private var locationId: String?
|
||||||
@State private var quantity = ""
|
@State private var quantity = ""
|
||||||
@State private var reason = "broken"
|
@State private var reason = "broken"
|
||||||
@State private var note = ""
|
@State private var note = ""
|
||||||
|
|||||||
@@ -424,7 +424,7 @@ struct ProductLocationMinView: View {
|
|||||||
|
|
||||||
private struct MinRow: Identifiable {
|
private struct MinRow: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
var locationId: Int?
|
var locationId: String?
|
||||||
var amount: String
|
var amount: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,8 +442,8 @@ struct ProductLocationMinView: View {
|
|||||||
ForEach($rows) { $row in
|
ForEach($rows) { $row in
|
||||||
HStack {
|
HStack {
|
||||||
Picker("Lagerort", selection: $row.locationId) {
|
Picker("Lagerort", selection: $row.locationId) {
|
||||||
Text("– wählen –").tag(Int?.none)
|
Text("– wählen –").tag(String?.none)
|
||||||
ForEach(locations) { l in Text(l.name).tag(Int?.some(l.id)) }
|
ForEach(locations) { l in Text(l.name).tag(String?.some(l.id)) }
|
||||||
}
|
}
|
||||||
TextField("Menge", text: $row.amount)
|
TextField("Menge", text: $row.amount)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
@@ -488,7 +488,7 @@ struct ProductLocationMinView: View {
|
|||||||
busy = true
|
busy = true
|
||||||
defer { busy = false }
|
defer { busy = false }
|
||||||
var list: [LocationMinStockIn] = []
|
var list: [LocationMinStockIn] = []
|
||||||
var gesehen: Set<Int> = []
|
var gesehen: Set<String> = []
|
||||||
for row in rows {
|
for row in rows {
|
||||||
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
|
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
|
||||||
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
const cents = centsFrom(addForm.price);
|
const cents = centsFrom(addForm.price);
|
||||||
const created = await api.createItems(product.id, {
|
const created = await api.createItems(product.id, {
|
||||||
count: Number(addForm.count) || 1,
|
count: Number(addForm.count) || 1,
|
||||||
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
|
location_id: addForm.location_id === "" ? null : addForm.location_id,
|
||||||
shop_id: shopId,
|
shop_id: shopId,
|
||||||
acquired_on: addForm.acquired_on || null,
|
acquired_on: addForm.acquired_on || null,
|
||||||
warranty_until: addForm.warranty_until || null,
|
warranty_until: addForm.warranty_until || null,
|
||||||
@@ -269,7 +269,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
|
||||||
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : Number(e.target.value) })}>
|
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
|
||||||
<option value="">– ohne –</option>
|
<option value="">– ohne –</option>
|
||||||
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
|
|||||||
try {
|
try {
|
||||||
const list = rows
|
const list = rows
|
||||||
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
|
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
|
||||||
.map((r) => ({ location_id: Number(r.location_id), min_stock: Number(r.min_stock) }));
|
.map((r) => ({ location_id: r.location_id, min_stock: Number(r.min_stock) }));
|
||||||
await onSave(list);
|
await onSave(list);
|
||||||
setOk(true);
|
setOk(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
|||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity: menge,
|
quantity: menge,
|
||||||
unit: einheit,
|
unit: einheit,
|
||||||
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
|
location_id: addForm.location_id === "" ? null : addForm.location_id,
|
||||||
});
|
});
|
||||||
setAddForm({ location_id: addForm.location_id, quantity: "" });
|
setAddForm({ location_id: addForm.location_id, quantity: "" });
|
||||||
await nachAktion();
|
await nachAktion();
|
||||||
@@ -93,8 +93,8 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
|||||||
await api.relocateStock({
|
await api.relocateStock({
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity: menge,
|
quantity: menge,
|
||||||
from_location_id: move.from === "" ? null : Number(move.from),
|
from_location_id: move.from === "" ? null : move.from,
|
||||||
to_location_id: move.to === "" ? null : Number(move.to),
|
to_location_id: move.to === "" ? null : move.to,
|
||||||
});
|
});
|
||||||
setMove(null);
|
setMove(null);
|
||||||
toast("Umgelagert.");
|
toast("Umgelagert.");
|
||||||
@@ -110,7 +110,7 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
|
|||||||
await api.removeStock({
|
await api.removeStock({
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity: menge,
|
quantity: menge,
|
||||||
location_id: remove.location_id === "" ? null : Number(remove.location_id),
|
location_id: remove.location_id === "" ? null : remove.location_id,
|
||||||
reason: remove.reason,
|
reason: remove.reason,
|
||||||
note: remove.note || null,
|
note: remove.note || null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function CheckIn() {
|
|||||||
best_before:
|
best_before:
|
||||||
(precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null,
|
(precision === "month" ? fromMonthInput(l.best_before) : l.best_before) || null,
|
||||||
best_before_precision: precision,
|
best_before_precision: precision,
|
||||||
location_id: locationId === "" ? null : Number(locationId),
|
location_id: locationId === "" ? null : locationId,
|
||||||
}));
|
}));
|
||||||
if (payloadLines.length === 0) {
|
if (payloadLines.length === 0) {
|
||||||
setError("Bitte mindestens eine Menge angeben.");
|
setError("Bitte mindestens eine Menge angeben.");
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function LocationResolve() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Lagerort {name || `#${id}`}</h1>
|
<h1>Lagerort {name || id}</h1>
|
||||||
<div className="sub">
|
<div className="sub">
|
||||||
{error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."}
|
{error || "Diesen QR-Code in der App scannen (Ort zuordnen), um Einzelstücke hier einzuordnen."}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function Locations() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.createLocation({ name, parent_id: parentId === "" ? null : Number(parentId) });
|
await api.createLocation({ name, parent_id: parentId === "" ? null : parentId });
|
||||||
setName("");
|
setName("");
|
||||||
// Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte
|
// Übergeordneten Ort absichtlich stehen lassen – beim Anlegen vieler Orte
|
||||||
// unter demselben Elternteil spart das jedes Mal die Neuauswahl.
|
// unter demselben Elternteil spart das jedes Mal die Neuauswahl.
|
||||||
@@ -56,7 +56,7 @@ export default function Locations() {
|
|||||||
try {
|
try {
|
||||||
await api.updateLocation(editId, {
|
await api.updateLocation(editId, {
|
||||||
name: editName.trim(),
|
name: editName.trim(),
|
||||||
parent_id: editParent == null ? null : Number(editParent),
|
parent_id: editParent == null ? null : editParent,
|
||||||
});
|
});
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
load();
|
load();
|
||||||
@@ -141,7 +141,7 @@ export default function Locations() {
|
|||||||
<label className="grow">
|
<label className="grow">
|
||||||
Übergeordneter Lagerort (optional)
|
Übergeordneter Lagerort (optional)
|
||||||
<CategorySelect
|
<CategorySelect
|
||||||
value={parentId === "" ? null : Number(parentId)}
|
value={parentId === "" ? null : parentId}
|
||||||
nodes={ordered}
|
nodes={ordered}
|
||||||
rootLabel="– keiner (oberste Ebene) –"
|
rootLabel="– keiner (oberste Ebene) –"
|
||||||
onChange={(id) => setParentId(id == null ? "" : String(id))}
|
onChange={(id) => setParentId(id == null ? "" : String(id))}
|
||||||
|
|||||||
Reference in New Issue
Block a user