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()
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user