Просмотр исходного кода

feat(inventory): scan-to-location QR assignment (#1606)

Samed Yüksel 2 дней назад
Родитель
Сommit
96c90de41b
45 измененных файлов с 4218 добавлено и 264 удалено
  1. 88 34
      backend/app/api/routes/inventory.py
  2. 123 28
      backend/app/api/routes/spoolman_inventory.py
  3. 3 0
      backend/app/schemas/spool.py
  4. 23 0
      backend/app/services/ams_assignment_lock.py
  5. 652 0
      backend/tests/integration/test_assign_move_one_slot_per_spool.py
  6. 7 0
      frontend/package-lock.json
  7. 1 0
      frontend/package.json
  8. 3 4
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  9. 337 0
      frontend/src/__tests__/components/AssignSpoolModalQrScan.test.tsx
  10. 473 0
      frontend/src/__tests__/components/QrAssignTargetModal.test.tsx
  11. 283 0
      frontend/src/__tests__/components/QrSpoolScanner.test.tsx
  12. 3 0
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  13. 4 4
      frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx
  14. 23 0
      frontend/src/__tests__/utils.tsx
  15. 60 0
      frontend/src/__tests__/utils/assignMismatch.test.ts
  16. 72 0
      frontend/src/__tests__/utils/qrAssignTarget.test.ts
  17. 175 0
      frontend/src/__tests__/utils/qrDecode.test.ts
  18. 5 2
      frontend/src/api/client.ts
  19. 204 186
      frontend/src/components/AssignSpoolModal.tsx
  20. 38 0
      frontend/src/components/MaterialMismatchConfirm.tsx
  21. 561 0
      frontend/src/components/QrAssignTargetModal.tsx
  22. 281 0
      frontend/src/components/QrSpoolScanner.tsx
  23. 5 0
      frontend/src/hooks/useWebSocket.ts
  24. 27 0
      frontend/src/i18n/locales/de.ts
  25. 27 0
      frontend/src/i18n/locales/en.ts
  26. 27 0
      frontend/src/i18n/locales/es.ts
  27. 27 0
      frontend/src/i18n/locales/fr.ts
  28. 27 0
      frontend/src/i18n/locales/it.ts
  29. 27 0
      frontend/src/i18n/locales/ja.ts
  30. 27 0
      frontend/src/i18n/locales/ko.ts
  31. 27 0
      frontend/src/i18n/locales/nl.ts
  32. 27 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 27 0
      frontend/src/i18n/locales/ru.ts
  34. 27 0
      frontend/src/i18n/locales/sv.ts
  35. 27 0
      frontend/src/i18n/locales/tr.ts
  36. 27 0
      frontend/src/i18n/locales/uk.ts
  37. 27 0
      frontend/src/i18n/locales/zh-CN.ts
  38. 27 0
      frontend/src/i18n/locales/zh-TW.ts
  39. 17 1
      frontend/src/pages/InventoryPage.tsx
  40. 4 1
      frontend/src/pages/PrintersPage.tsx
  41. 4 3
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  42. 20 1
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  43. 123 0
      frontend/src/utils/assignMismatch.ts
  44. 109 0
      frontend/src/utils/qrAssignTarget.ts
  45. 112 0
      frontend/src/utils/qrDecode.ts

+ 88 - 34
backend/app/api/routes/inventory.py

@@ -1,5 +1,6 @@
 import json
 import json
 import logging
 import logging
+from contextlib import AsyncExitStack
 from datetime import date, datetime, timezone
 from datetime import date, datetime, timezone
 
 
 import httpx
 import httpx
@@ -69,7 +70,7 @@ from backend.app.schemas.supplier import (
     SupplierUpdate,
     SupplierUpdate,
 )
 )
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
-from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
+from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
     DUPLICATE_LOCATION_NAME,
@@ -2424,6 +2425,12 @@ async def list_assignments(
     return responses
     return responses
 
 
 
 
+async def _printers_holding_spool(db: AsyncSession, spool_id: int, printer_scope: PrinterScope) -> set[int]:
+    """The printers in the caller's scope with a slot assigned to the spool."""
+    held = await db.execute(select(SpoolAssignment.printer_id).where(SpoolAssignment.spool_id == spool_id).distinct())
+    return {pid for pid in held.scalars() if printer_scope.allows(pid)}
+
+
 @router.post("/assignments", response_model=SpoolAssignmentResponse)
 @router.post("/assignments", response_model=SpoolAssignmentResponse)
 async def assign_spool(
 async def assign_spool(
     data: SpoolAssignmentCreate,
     data: SpoolAssignmentCreate,
@@ -2502,34 +2509,80 @@ async def assign_spool(
 
 
     # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
     # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
     # the AMS change handler writes the printer's slots with.
     # the AMS change handler writes the printer's slots with.
-    async with get_ams_assignment_lock(data.printer_id):
-        existing = await db.execute(
-            select(SpoolAssignment).where(
-                SpoolAssignment.printer_id == data.printer_id,
-                SpoolAssignment.ams_id == data.ams_id,
-                SpoolAssignment.tray_id == data.tray_id,
+    #
+    # A move (#1574) also takes the spool out of every other slot it holds, in
+    # the same commit, so a failed move leaves it where it was instead of in no
+    # slot. Moves of one spool take turns (get_spool_move_lock): two at once
+    # would each read where the spool is before the other committed, and both
+    # assign it. The slots' printers are locked as well, in id order so two
+    # moves can't take the same two locks the other way round. Which printers
+    # those are is read again once the locks are held: a move served first, a
+    # plain assign or an AMS change may have put the spool on one more, and
+    # then this one lets go and locks the larger set. A slot on a printer
+    # outside the caller's scope stays assigned, as unassigning leaves it
+    # (#1727).
+    locked_printer_ids = {data.printer_id}
+    if data.move:
+        locked_printer_ids |= await _printers_holding_spool(db, data.spool_id, printer_scope)
+    vacated: list[tuple[int, int, int]] = []
+    while True:
+        async with AsyncExitStack() as locks:
+            if data.move:
+                await locks.enter_async_context(get_spool_move_lock("inventory", data.spool_id))
+            for pid in sorted(locked_printer_ids):
+                await locks.enter_async_context(get_ams_assignment_lock(pid))
+            if data.move:
+                holding = await _printers_holding_spool(db, data.spool_id, printer_scope)
+                if not holding <= locked_printer_ids:
+                    locked_printer_ids |= holding
+                    continue
+                others = await db.execute(
+                    select(SpoolAssignment).where(
+                        SpoolAssignment.spool_id == data.spool_id,
+                        SpoolAssignment.printer_id.in_(locked_printer_ids),
+                    )
+                )
+                for row in others.scalars().all():
+                    if (row.printer_id, row.ams_id, row.tray_id) != (data.printer_id, data.ams_id, data.tray_id):
+                        vacated.append((row.printer_id, row.ams_id, row.tray_id))
+                        await db.delete(row)
+            existing = await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == data.printer_id,
+                    SpoolAssignment.ams_id == data.ams_id,
+                    SpoolAssignment.tray_id == data.tray_id,
+                )
             )
             )
-        )
-        old = existing.scalar_one_or_none()
-        if old:
-            await db.delete(old)
-            await db.flush()
-
-        assignment = SpoolAssignment(
-            spool_id=data.spool_id,
-            printer_id=data.printer_id,
-            ams_id=data.ams_id,
-            tray_id=data.tray_id,
-            fingerprint_color=fingerprint_color,
-            fingerprint_type=fingerprint_type,
-        )
-        db.add(assignment)
-        await spool_swaps.record_assignment(
-            db, data.printer_id, data.ams_id, data.tray_id, data.spool_id, spool_swaps.INVENTORY
-        )
-        await db.commit()
+            old = existing.scalar_one_or_none()
+            if old:
+                await db.delete(old)
+                await db.flush()
+
+            assignment = SpoolAssignment(
+                spool_id=data.spool_id,
+                printer_id=data.printer_id,
+                ams_id=data.ams_id,
+                tray_id=data.tray_id,
+                fingerprint_color=fingerprint_color,
+                fingerprint_type=fingerprint_type,
+            )
+            db.add(assignment)
+            await spool_swaps.record_assignment(
+                db, data.printer_id, data.ams_id, data.tray_id, data.spool_id, spool_swaps.INVENTORY
+            )
+            await db.commit()
+        break
     await db.refresh(assignment)
     await db.refresh(assignment)
     slot_unlink_grace.forget_slot(data.printer_id, data.ams_id, data.tray_id)
     slot_unlink_grace.forget_slot(data.printer_id, data.ams_id, data.tray_id)
+    if vacated:
+        logger.info(
+            "Moved spool %d to printer %d AMS%d-T%d from %s",
+            spool.id,
+            data.printer_id,
+            data.ams_id,
+            data.tray_id,
+            ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
+        )
 
 
     # 4. Auto-configure AMS slot via MQTT.
     # 4. Auto-configure AMS slot via MQTT.
     #
     #
@@ -2645,14 +2698,15 @@ async def assign_spool(
             data.tray_id,
             data.tray_id,
         )
         )
 
 
-    await ws_manager.broadcast(
-        {
-            "type": "spool_assignment_changed",
-            "printer_id": data.printer_id,
-            "ams_id": data.ams_id,
-            "tray_id": data.tray_id,
-        }
-    )
+    for printer_id, ams_id, tray_id in [*vacated, (data.printer_id, data.ams_id, data.tray_id)]:
+        await ws_manager.broadcast(
+            {
+                "type": "spool_assignment_changed",
+                "printer_id": printer_id,
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+            }
+        )
 
 
     return response
     return response
 
 

+ 123 - 28
backend/app/api/routes/spoolman_inventory.py

@@ -12,13 +12,13 @@ import json
 import logging
 import logging
 import re
 import re
 import time
 import time
-from contextlib import asynccontextmanager
+from contextlib import asynccontextmanager, nullcontext
 from datetime import datetime
 from datetime import datetime
 
 
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi.responses import JSONResponse
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel, Field, field_validator, model_validator
 from pydantic import BaseModel, Field, field_validator, model_validator
-from sqlalchemy import delete, select, text
+from sqlalchemy import delete, select, text, tuple_
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
@@ -56,6 +56,7 @@ from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
+from backend.app.services.ams_assignment_lock import get_spool_move_lock
 from backend.app.services.location_service import (
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
     enrich_spool_dicts_with_location_id,
     maybe_sync_spoolman_locations,
     maybe_sync_spoolman_locations,
@@ -183,6 +184,47 @@ async def _clear_stale_slot_fallback_tag_links(
     )
     )
 
 
 
 
+async def _clear_vacated_slot_fallback_tag(
+    client: SpoolmanClient,
+    db: AsyncSession,
+    *,
+    spool_id: int,
+    spool: dict,
+    vacated: list[tuple[int, int, int]],
+) -> str | None:
+    """Clear the spool's extra.tag when it is the fallback tag of a slot it leaves (#1574).
+
+    A spool linked to a slot without RFID carries that slot's fallback tag, and
+    the tag lookup keeps finding it for the old slot after it moved. A real RFID
+    or any other tag is the spool's own and stays. Returns the tag it cleared,
+    or None. Raises when Spoolman refuses, before anything local has changed,
+    so the spool stays where it was.
+    """
+    tag = str((spool.get("extra") or {}).get("tag") or "").strip('"').upper()
+    if not tag or not vacated:
+        return None
+    serials = dict(
+        (await db.execute(select(Printer.id, Printer.serial_number).where(Printer.id.in_({p for p, _, _ in vacated}))))
+        .tuples()
+        .all()
+    )
+    if not any(
+        tag == get_fallback_spool_tag_for_slot(serials.get(printer_id) or "", ams_id, tray_id)
+        for printer_id, ams_id, tray_id in vacated
+    ):
+        return None
+    try:
+        await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
+    except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as exc:
+        logger.warning("Could not clear the old slot's fallback tag from Spoolman spool %s: %s", spool_id, exc)
+        raise HTTPException(
+            status_code=503 if isinstance(exc, SpoolmanUnavailableError) else 502,
+            detail="Spoolman did not clear the spool's tag for its old slot, so the spool was not moved",
+        ) from exc
+    logger.info("Cleared the old slot's fallback tag from Spoolman spool %s before moving it", spool_id)
+    return tag
+
+
 async def _get_client(db: AsyncSession) -> SpoolmanClient:
 async def _get_client(db: AsyncSession) -> SpoolmanClient:
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     result = await db.execute(select(Settings))
     result = await db.execute(select(Settings))
@@ -458,6 +500,9 @@ class SpoolSlotAssignmentRequest(BaseModel):
     printer_id: int = Field(..., gt=0)
     printer_id: int = Field(..., gt=0)
     ams_id: SlotAmsId
     ams_id: SlotAmsId
     tray_id: SlotTrayId
     tray_id: SlotTrayId
+    # The spool also leaves any other slot it is assigned to, in the same
+    # transaction (#1574): a scanned spool is the one in the user's hand.
+    move: bool = False
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -1851,33 +1896,83 @@ async def assign_spoolman_slot(
     if spool.get("archived"):
     if spool.get("archived"):
         raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
         raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
 
-    # Spool confirmed in Spoolman — upsert into local slot-assignment table
-    # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
-    try:
-        await db.execute(
-            text(
-                "INSERT INTO spoolman_slot_assignments"
-                " (printer_id, ams_id, tray_id, spoolman_spool_id)"
-                " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
-                " ON CONFLICT(printer_id, ams_id, tray_id)"
-                " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
-            ),
-            {
-                "printer_id": body.printer_id,
-                "ams_id": body.ams_id,
-                "tray_id": body.tray_id,
-                "spool_id": body.spoolman_spool_id,
-            },
-        )
-        await spool_swaps.record_assignment(
-            db, body.printer_id, body.ams_id, body.tray_id, body.spoolman_spool_id, spool_swaps.SPOOLMAN
+    # A move (#1574) takes the spool out of every other slot it holds, in the
+    # same commit as the upsert below, so a failed move leaves it where it was
+    # instead of in no slot. Its fallback tag for a slot it leaves is cleared
+    # first. Moves of one spool take turns (get_spool_move_lock): on PostgreSQL
+    # a move's DELETE doesn't see the slot another move inserts and commits
+    # after it began, and both left the spool assigned. A slot on a printer
+    # outside the caller's scope stays assigned, as unassigning leaves it
+    # (#1727).
+    target = (body.printer_id, body.ams_id, body.tray_id)
+    slot_columns = (SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id)
+    others = [SpoolmanSlotAssignment.spoolman_spool_id == body.spoolman_spool_id, tuple_(*slot_columns) != target]
+    if (in_scope := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
+        others.append(in_scope)
+    vacated: list[tuple[int, int, int]] = []
+    cleared_tag: str | None = None
+    async with get_spool_move_lock("spoolman", body.spoolman_spool_id) if body.move else nullcontext():
+        if body.move:
+            vacated = list((await db.execute(select(*slot_columns).where(*others))).tuples())
+            cleared_tag = await _clear_vacated_slot_fallback_tag(
+                client, db, spool_id=body.spoolman_spool_id, spool=spool, vacated=vacated
+            )
+
+        # Spool confirmed in Spoolman — upsert into local slot-assignment table
+        # assigned_at is intentionally not refreshed on re-assign (original timestamp preserved)
+        try:
+            if body.move:
+                # Every other slot of the spool, not only those read above: a
+                # link or the AMS sync may have given it one more meanwhile.
+                result = await db.execute(delete(SpoolmanSlotAssignment).where(*others).returning(*slot_columns))
+                vacated = list(result.tuples())
+            await db.execute(
+                text(
+                    "INSERT INTO spoolman_slot_assignments"
+                    " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                    " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                    " ON CONFLICT(printer_id, ams_id, tray_id)"
+                    " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+                ),
+                {
+                    "printer_id": body.printer_id,
+                    "ams_id": body.ams_id,
+                    "tray_id": body.tray_id,
+                    "spool_id": body.spoolman_spool_id,
+                },
+            )
+            await spool_swaps.record_assignment(
+                db, body.printer_id, body.ams_id, body.tray_id, body.spoolman_spool_id, spool_swaps.SPOOLMAN
+            )
+            await db.commit()
+            slot_unlink_grace.forget_slot(body.printer_id, body.ams_id, body.tray_id)
+        except Exception as exc:
+            await db.rollback()
+            logger.error("Failed to persist slot assignment: %s", exc)
+            if cleared_tag:
+                # The spool stays in its old slot, so it gets that slot's tag back.
+                try:
+                    await client.merge_spool_extra(body.spoolman_spool_id, {"tag": json.dumps(cleared_tag)})
+                except (SpoolmanClientError, SpoolmanUnavailableError, SpoolmanNotFoundError) as restore_exc:
+                    logger.warning(
+                        "Could not put back the fallback tag of Spoolman spool %s: %s",
+                        body.spoolman_spool_id,
+                        restore_exc,
+                    )
+            raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
+    if vacated:
+        logger.info(
+            "Moved Spoolman spool %d to printer %d AMS%d-T%d from %s",
+            body.spoolman_spool_id,
+            body.printer_id,
+            body.ams_id,
+            body.tray_id,
+            ", ".join(f"printer {p} AMS{a}-T{t}" for p, a, t in vacated),
         )
         )
-        await db.commit()
-        slot_unlink_grace.forget_slot(body.printer_id, body.ams_id, body.tray_id)
-    except Exception as exc:
-        await db.rollback()
-        logger.error("Failed to persist slot assignment: %s", exc)
-        raise HTTPException(status_code=500, detail="Failed to save slot assignment") from exc
+    # Other tabs and the SpoolBuddy kiosk follow slot changes over the
+    # websocket, as the built-in assign announces them: the slots the spool
+    # left, and the one it went to.
+    await _announce_freed_slots([*vacated, target])
 
 
     # #1457: clear stale fallback-tag links on OTHER spools still bound to this
     # #1457: clear stale fallback-tag links on OTHER spools still bound to this
     # slot. Without this, a non-RFID slot's deterministic fallback tag stays
     # slot. Without this, a non-RFID slot's deterministic fallback tag stays

+ 3 - 0
backend/app/schemas/spool.py

@@ -337,6 +337,9 @@ class SpoolAssignmentCreate(BaseModel):
     printer_id: int
     printer_id: int
     ams_id: int
     ams_id: int
     tray_id: int
     tray_id: int
+    # The spool also leaves any other slot it is assigned to, in the same
+    # transaction (#1574): a scanned spool is the one in the user's hand.
+    move: bool = False
 
 
 
 
 class SpoolAssignmentResponse(BaseModel):
 class SpoolAssignmentResponse(BaseModel):

+ 23 - 0
backend/app/services/ams_assignment_lock.py

@@ -22,11 +22,15 @@ waits for that write lock.
 Scope is intentionally narrow: only the DB-mutating blocks are inside the lock.
 Scope is intentionally narrow: only the DB-mutating blocks are inside the lock.
 The Spoolman sync block in on_ams_change stays concurrent because it's
 The Spoolman sync block in on_ams_change stays concurrent because it's
 network-bound and idempotent.
 network-bound and idempotent.
+
+A move of a spool (#1574) also takes a per-spool lock, before any printer lock;
+see get_spool_move_lock.
 """
 """
 
 
 import asyncio
 import asyncio
 
 
 ams_assignment_locks: dict[int, asyncio.Lock] = {}
 ams_assignment_locks: dict[int, asyncio.Lock] = {}
+spool_move_locks: dict[tuple[str, int], asyncio.Lock] = {}
 
 
 
 
 def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
 def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
@@ -36,3 +40,22 @@ def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
         lock = asyncio.Lock()
         lock = asyncio.Lock()
         ams_assignment_locks[printer_id] = lock
         ams_assignment_locks[printer_id] = lock
     return lock
     return lock
+
+
+def get_spool_move_lock(inventory: str, spool_id: int) -> asyncio.Lock:
+    """Return the lock moves of one spool take turns on, creating it on first use.
+
+    A move takes the spool out of every slot it holds, and which slots those are
+    is only known once read. Two moves of one spool at once each read them
+    before the other committed, and both assigned it: the printer locks keep
+    them apart only where they share a printer, and on PostgreSQL a DELETE
+    doesn't see the row another transaction commits after the DELETE began.
+    ``inventory`` ("inventory" or "spoolman") keeps the two id spaces apart.
+    Taken before any printer lock and before the move's first write.
+    """
+    key = (inventory, spool_id)
+    lock = spool_move_locks.get(key)
+    if lock is None:
+        lock = asyncio.Lock()
+        spool_move_locks[key] = lock
+    return lock

+ 652 - 0
backend/tests/integration/test_assign_move_one_slot_per_spool.py

@@ -0,0 +1,652 @@
+"""A move assigns a spool to a slot and takes it out of its other slots in one commit (#1574).
+
+The QR scan assigns the spool in the user's hand, which is in one slot at most.
+The scan flow used to unassign the old slot and assign the new one in separate
+requests, so a failure between them left the spool in no slot. With ``move``
+both assign routes do it in the transaction that writes the new slot:
+
+* the spool's other slots are cleared and the target's occupant replaced;
+* a failure changes nothing;
+* two moves of the same spool at once take turns and leave it in one slot,
+  wherever it was before;
+* a slot on a printer outside the caller's scope stays assigned (#1727);
+* in Spoolman mode, a fallback tag the spool carries for a slot it leaves is
+  cleared first, and a refusal from Spoolman stops the move; RFID and other
+  tags stay; the slots it left and the one it went to are announced.
+
+Without ``move`` the routes behave as before.
+"""
+
+import asyncio
+import json
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import event, select
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from backend.app.core.auth import get_printer_scope_if_auth_enabled
+from backend.app.core.database import Base, _set_sqlite_pragmas
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.main import app
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.ams_assignment_lock import (
+    ams_assignment_locks,
+    get_ams_assignment_lock,
+    get_spool_move_lock,
+    spool_move_locks,
+)
+from backend.app.services.spoolman import SpoolmanUnavailableError
+from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _fresh_locks():
+    """A contended lock binds to its test's event loop; printer and spool ids repeat."""
+    ams_assignment_locks.clear()
+    spool_move_locks.clear()
+    yield
+    ams_assignment_locks.clear()
+    spool_move_locks.clear()
+
+
+async def _until_queued(lock: asyncio.Lock, requests: int) -> None:
+    """Return once that many requests wait for the lock."""
+    async with asyncio.timeout(5):
+        while len(lock._waiters or ()) < requests:
+            await asyncio.sleep(0.01)
+
+
+@pytest.fixture
+def scope_limited_to():
+    """Run requests as a caller who can only see the given printers."""
+
+    def _limit(*printer_ids: int) -> None:
+        app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(
+            printer_ids=frozenset(printer_ids)
+        )
+
+    yield _limit
+    app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
+
+
+async def _commit_fails(self):
+    raise RuntimeError("database went away")
+
+
+# ---------------------------------------------------------------------------
+# Built-in inventory: POST /inventory/assignments
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def offline_printers():
+    """No printer is connected, so the assign stores the row and skips MQTT."""
+    ws = MagicMock()
+    ws.broadcast = AsyncMock()
+    with (
+        patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=None)),
+        patch("backend.app.services.printer_manager.printer_manager.get_client", MagicMock(return_value=None)),
+        patch("backend.app.api.routes.inventory.ws_manager", ws),
+    ):
+        yield ws
+
+
+async def _spool(db: AsyncSession, **kwargs) -> Spool:
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0, **kwargs)
+    db.add(spool)
+    await db.commit()
+    await db.refresh(spool)
+    return spool
+
+
+async def _assign_row(db: AsyncSession, spool_id: int, printer_id: int, ams_id: int, tray_id: int) -> None:
+    db.add(SpoolAssignment(spool_id=spool_id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
+    await db.commit()
+
+
+async def _slots(db: AsyncSession) -> set[tuple[int, int, int, int]]:
+    """Every built-in assignment as (spool, printer, ams, tray), as the database has it now."""
+    rows = (await db.execute(select(SpoolAssignment).execution_options(populate_existing=True))).scalars().all()
+    return {(r.spool_id, r.printer_id, r.ams_id, r.tray_id) for r in rows}
+
+
+def _assign(spool_id: int, printer_id: int, ams_id: int, tray_id: int, **extra) -> dict:
+    return {"spool_id": spool_id, "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, **extra}
+
+
+class TestBuiltInMove:
+    async def test_move_takes_the_spool_out_of_its_old_slot(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        printer = await printer_factory()
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, printer.id, 0, 1)
+
+        response = await async_client.post(
+            "/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(spool.id, printer.id, 1, 0)}
+
+    async def test_move_takes_the_spool_out_of_another_printers_slot(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, there.id, 2, 3)
+
+        response = await async_client.post(
+            "/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(spool.id, here.id, 0, 0)}
+        # Both slots changed, so both are announced.
+        announced = {
+            (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
+            for c in offline_printers.broadcast.await_args_list
+            if c.args[0]["type"] == "spool_assignment_changed"
+        }
+        assert announced == {(there.id, 2, 3), (here.id, 0, 0)}
+
+    async def test_move_replaces_the_target_slots_spool(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        printer = await printer_factory()
+        moving = await _spool(db_session)
+        occupant = await _spool(db_session)
+        await _assign_row(db_session, moving.id, printer.id, 0, 1)
+        await _assign_row(db_session, occupant.id, printer.id, 0, 2)
+
+        response = await async_client.post(
+            "/api/v1/inventory/assignments", json=_assign(moving.id, printer.id, 0, 2, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(moving.id, printer.id, 0, 2)}
+
+    async def test_without_move_the_old_slot_stays(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        """A plain assign is per slot, as before: the "Show all spools" path relies on it."""
+        printer = await printer_factory()
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, printer.id, 0, 1)
+
+        response = await async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0))
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(spool.id, printer.id, 0, 1), (spool.id, printer.id, 1, 0)}
+
+    async def test_a_slot_outside_the_callers_scope_stays(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers, scope_limited_to
+    ):
+        mine = await printer_factory(name="Mine")
+        hidden = await printer_factory(name="Hidden")
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, hidden.id, 0, 0)
+        scope_limited_to(mine.id)
+
+        response = await async_client.post(
+            "/api/v1/inventory/assignments", json=_assign(spool.id, mine.id, 0, 1, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(spool.id, hidden.id, 0, 0), (spool.id, mine.id, 0, 1)}
+
+    async def test_a_failed_move_leaves_the_spool_where_it_was(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        printer = await printer_factory()
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, printer.id, 0, 1)
+
+        with patch.object(AsyncSession, "commit", _commit_fails), pytest.raises(RuntimeError):
+            await async_client.post(
+                "/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0, move=True)
+            )
+
+        assert await _slots(db_session) == {(spool.id, printer.id, 0, 1)}
+
+    async def test_move_waits_for_the_lock_of_the_printer_it_leaves(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        """The old slot's row is written too, so its printer's lock is taken (SA-5)."""
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        spool = await _spool(db_session)
+        await _assign_row(db_session, spool.id, there.id, 0, 0)
+
+        lock = get_ams_assignment_lock(there.id)
+        await lock.acquire()
+        try:
+            task = asyncio.ensure_future(
+                async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True))
+            )
+            await asyncio.sleep(0.3)
+            assert not task.done(), "the move cleared the old slot without waiting for its printer's lock"
+        finally:
+            lock.release()
+        response = await asyncio.wait_for(task, 10)
+
+        assert response.status_code == 200, response.text
+
+    async def test_a_slot_the_spool_gained_while_the_move_waited_is_cleared_too(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers
+    ):
+        """The printers to lock are read again once the locks are held: here a
+        plain assign put the spool on a printer the move had not locked."""
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        spool = await _spool(db_session)
+
+        lock = get_ams_assignment_lock(here.id)
+        await lock.acquire()
+        try:
+            move = asyncio.ensure_future(
+                async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True))
+            )
+            await _until_queued(lock, 1)
+            await _assign_row(db_session, spool.id, there.id, 1, 1)
+        finally:
+            lock.release()
+        response = await asyncio.wait_for(move, 10)
+
+        assert response.status_code == 200, response.text
+        assert await _slots(db_session) == {(spool.id, here.id, 0, 0)}
+
+
+class TestBuiltInConcurrentMoves:
+    @pytest.fixture
+    async def test_engine(self, test_engine, tmp_path):
+        """Two requests at once need a connection each, as the app's pool gives
+        them. The suite's in-memory SQLite database is a single connection,
+        shared by every session, and one request's commit there fails while the
+        other's statement is open ("SQL statements in progress"). On SQLite
+        these tests get a database file instead; PostgreSQL pools already."""
+        if test_engine.dialect.name != "sqlite":
+            yield test_engine
+            return
+        engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'moves.db'}")
+        event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+        yield engine
+        await engine.dispose()
+
+    @pytest.mark.parametrize("starts_in_a_slot", [True, False], ids=["from-a-slot", "from-no-slot"])
+    async def test_two_moves_of_the_same_spool_leave_it_in_one_slot(
+        self, async_client: AsyncClient, db_session, printer_factory, offline_printers, starts_in_a_slot
+    ):
+        """#1606 review: the printers to lock were read before the locks were
+        taken, so the move served second missed where the first put the spool.
+        A spool in no slot was worse: each move locked only its own target's
+        printer, and both assigned it. Moves of one spool now take turns."""
+        start = await printer_factory(name="Start")
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        spool = await _spool(db_session)
+        if starts_in_a_slot:
+            await _assign_row(db_session, spool.id, start.id, 0, 0)
+
+        lock = get_spool_move_lock("inventory", spool.id)
+        await lock.acquire()
+        try:
+            moves = [
+                asyncio.ensure_future(
+                    async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, pid, 0, 0, move=True))
+                )
+                for pid in (here.id, there.id)
+            ]
+            # Both have read where the spool is and wait for their turn.
+            await _until_queued(lock, 2)
+        finally:
+            lock.release()
+        responses = await asyncio.wait_for(asyncio.gather(*moves), 10)
+
+        assert [r.status_code for r in responses] == [200, 200]
+        slots = await _slots(db_session)
+        assert len(slots) == 1, slots
+
+
+# ---------------------------------------------------------------------------
+# Spoolman: POST /spoolman/inventory/slot-assignments
+# ---------------------------------------------------------------------------
+
+SPOOLMAN_SPOOL_ID = 10
+
+
+def _spoolman_spool(tag: str | None = None) -> dict:
+    return {
+        "id": SPOOLMAN_SPOOL_ID,
+        "filament": {"id": 1, "name": "PLA Basic", "material": "PLA", "color_hex": "FF0000", "weight": 1000},
+        "remaining_weight": 800.0,
+        "used_weight": 200.0,
+        "location": None,
+        "comment": None,
+        "registered": "2024-01-01T00:00:00+00:00",
+        "archived": False,
+        "extra": {} if tag is None else {"tag": json.dumps(tag)},
+    }
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def spoolman():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=_spoolman_spool())
+    # The target slot's stale-tag cleanup (#1457) lists the spools; none here.
+    client.get_spools = AsyncMock(return_value=[])
+    client.merge_spool_extra = AsyncMock(return_value={"id": SPOOLMAN_SPOOL_ID, "extra": {}})
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman_inventory.printer_manager.get_client", MagicMock(return_value=None)),
+    ):
+        yield client
+
+
+@pytest.fixture
+def spoolman_ws():
+    """What the Spoolman routes announce over the websocket."""
+    ws = MagicMock()
+    ws.broadcast = AsyncMock()
+    with patch("backend.app.api.routes.spoolman_inventory.ws_manager", ws):
+        yield ws
+
+
+def _announced_slots(ws) -> set[tuple[int, int, int]]:
+    return {
+        (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
+        for c in ws.broadcast.await_args_list
+        if c.args[0].get("type") == "spool_assignment_changed"
+    }
+
+
+async def _spoolman_row(db: AsyncSession, printer_id: int, ams_id: int, tray_id: int) -> None:
+    db.add(
+        SpoolmanSlotAssignment(
+            printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spoolman_spool_id=SPOOLMAN_SPOOL_ID
+        )
+    )
+    await db.commit()
+
+
+async def _spoolman_slots(db: AsyncSession) -> set[tuple[int, int, int, int]]:
+    """Every Spoolman slot assignment as (spool, printer, ams, tray), as the database has it now."""
+    rows = (await db.execute(select(SpoolmanSlotAssignment).execution_options(populate_existing=True))).scalars().all()
+    return {(r.spoolman_spool_id, r.printer_id, r.ams_id, r.tray_id) for r in rows}
+
+
+def _spoolman_assign(printer_id: int, ams_id: int, tray_id: int, **extra) -> dict:
+    return {
+        "spoolman_spool_id": SPOOLMAN_SPOOL_ID,
+        "printer_id": printer_id,
+        "ams_id": ams_id,
+        "tray_id": tray_id,
+        **extra,
+    }
+
+
+def _tag_writes(client) -> list[str]:
+    """The extra.tag values written to the moved spool, in order."""
+    return [
+        json.loads(c.args[1]["tag"])
+        for c in client.merge_spool_extra.await_args_list
+        if c.args[0] == SPOOLMAN_SPOOL_ID and "tag" in c.args[1]
+    ]
+
+
+class TestSpoolmanMove:
+    async def test_move_takes_the_spool_out_of_its_old_slot(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        await _spoolman_row(db_session, there.id, 1, 2)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, here.id, 0, 0)}
+        assert _tag_writes(spoolman) == []
+
+    async def test_without_move_the_old_slot_stays(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 0, 2)
+        )
+
+        assert response.status_code == 200, response.text
+        assert await _spoolman_slots(db_session) == {
+            (SPOOLMAN_SPOOL_ID, printer.id, 0, 1),
+            (SPOOLMAN_SPOOL_ID, printer.id, 0, 2),
+        }
+
+    async def test_the_old_slots_fallback_tag_is_cleared(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        """Linked to a slot without RFID, the spool carries that slot's fallback tag.
+        Left on it, the tag lookup would find the spool for its old slot again."""
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+        spoolman.get_spool.return_value = _spoolman_spool(
+            tag=get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
+        )
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _tag_writes(spoolman) == [""]
+        assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 1, 0)}
+
+    @pytest.mark.parametrize(
+        "tag",
+        [
+            pytest.param("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", id="rfid-tray-uuid"),
+            pytest.param("05CC1E0F00000100", id="rfid-tag-uid"),
+        ],
+    )
+    async def test_a_tag_of_the_spool_itself_stays(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, tag
+    ):
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+        spoolman.get_spool.return_value = _spoolman_spool(tag=tag)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _tag_writes(spoolman) == []
+
+    async def test_the_fallback_tag_of_a_slot_it_does_not_leave_stays(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        """Only the slots the spool leaves are matched -- here its tag is the target's own."""
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+        spoolman.get_spool.return_value = _spoolman_spool(
+            tag=get_fallback_spool_tag_for_slot(printer.serial_number, 1, 0)
+        )
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _tag_writes(spoolman) == []
+
+    async def test_a_refused_tag_cleanup_keeps_the_old_assignment(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+        spoolman.get_spool.return_value = _spoolman_spool(
+            tag=get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
+        )
+        spoolman.merge_spool_extra.side_effect = SpoolmanUnavailableError("down")
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
+        )
+
+        assert response.status_code == 503
+        assert "not moved" in response.json()["detail"]
+        assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 0, 1)}
+
+    async def test_a_failed_save_puts_the_cleared_tag_back(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        printer = await printer_factory()
+        await _spoolman_row(db_session, printer.id, 0, 1)
+        old_tag = get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
+        spoolman.get_spool.return_value = _spoolman_spool(tag=old_tag)
+
+        with patch.object(AsyncSession, "commit", _commit_fails):
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
+            )
+
+        assert response.status_code == 500
+        assert _tag_writes(spoolman) == ["", old_tag]
+        assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 0, 1)}
+
+    async def test_a_slot_outside_the_callers_scope_stays(
+        self,
+        async_client: AsyncClient,
+        db_session,
+        printer_factory,
+        spoolman_settings,
+        spoolman,
+        scope_limited_to,
+    ):
+        mine = await printer_factory(name="Mine")
+        hidden = await printer_factory(name="Hidden")
+        await _spoolman_row(db_session, hidden.id, 0, 0)
+        # Even its fallback tag: that slot is not the caller's to change.
+        spoolman.get_spool.return_value = _spoolman_spool(
+            tag=get_fallback_spool_tag_for_slot(hidden.serial_number, 0, 0)
+        )
+        scope_limited_to(mine.id)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(mine.id, 0, 1, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _tag_writes(spoolman) == []
+        assert await _spoolman_slots(db_session) == {
+            (SPOOLMAN_SPOOL_ID, hidden.id, 0, 0),
+            (SPOOLMAN_SPOOL_ID, mine.id, 0, 1),
+        }
+
+    async def test_a_move_announces_the_slots_it_left_and_the_one_it_went_to(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
+    ):
+        """#1606 review: other tabs and the SpoolBuddy kiosk kept the spool in its old slot."""
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        await _spoolman_row(db_session, there.id, 1, 2)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _announced_slots(spoolman_ws) == {(there.id, 1, 2), (here.id, 0, 0)}
+
+    async def test_a_plain_assign_announces_its_slot(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
+    ):
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 0, 3)
+        )
+
+        assert response.status_code == 200, response.text
+        assert _announced_slots(spoolman_ws) == {(printer.id, 0, 3)}
+
+    async def test_a_move_also_clears_a_slot_the_spool_gained_meanwhile(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
+    ):
+        """Between this move reading the spool's slots and deleting them,
+        something else (a link, the AMS sync) gave it another slot: that one
+        goes too."""
+        from backend.app.api.routes import spoolman_inventory
+
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        await _spoolman_row(db_session, there.id, 0, 0)
+        clear_tag = spoolman_inventory._clear_vacated_slot_fallback_tag
+
+        async def another_slot_is_committed(*args, **kwargs):
+            await _spoolman_row(db_session, there.id, 1, 1)
+            return await clear_tag(*args, **kwargs)
+
+        with patch.object(spoolman_inventory, "_clear_vacated_slot_fallback_tag", another_slot_is_committed):
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
+            )
+
+        assert response.status_code == 200, response.text
+        assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, here.id, 0, 0)}
+        assert _announced_slots(spoolman_ws) == {(there.id, 0, 0), (there.id, 1, 1), (here.id, 0, 0)}
+
+    async def test_two_moves_of_the_same_spool_take_turns(
+        self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
+    ):
+        """#1606 review: on PostgreSQL a move's DELETE missed the slot another
+        move of the spool inserted and committed after the DELETE began, and the
+        spool ended in both. SQLite's single writer hid it."""
+        start = await printer_factory(name="Start")
+        here = await printer_factory(name="Here")
+        there = await printer_factory(name="There")
+        await _spoolman_row(db_session, start.id, 0, 0)
+
+        lock = get_spool_move_lock("spoolman", SPOOLMAN_SPOOL_ID)
+        await lock.acquire()
+        try:
+            moves = [
+                asyncio.ensure_future(
+                    async_client.post(
+                        "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(pid, 0, 0, move=True)
+                    )
+                )
+                for pid in (here.id, there.id)
+            ]
+            await _until_queued(lock, 2)
+        finally:
+            lock.release()
+        responses = await asyncio.wait_for(asyncio.gather(*moves), 10)
+
+        assert [r.status_code for r in responses] == [200, 200]
+        slots = await _spoolman_slots(db_session)
+        assert len(slots) == 1, slots

+ 7 - 0
frontend/package-lock.json

@@ -24,6 +24,7 @@
         "dompurify": "^3.4.17",
         "dompurify": "^3.4.17",
         "i18next": "25.6.3",
         "i18next": "25.6.3",
         "i18next-browser-languagedetector": "^8.2.0",
         "i18next-browser-languagedetector": "^8.2.0",
+        "jsqr": "^1.4.0",
         "jszip": "^3.10.1",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
         "lucide-react": "^0.555.0",
         "mdast-util-gfm-footnote": "^2.1.0",
         "mdast-util-gfm-footnote": "^2.1.0",
@@ -5838,6 +5839,12 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
+    "node_modules/jsqr": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
+      "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
+      "license": "Apache-2.0"
+    },
     "node_modules/jszip": {
     "node_modules/jszip": {
       "version": "3.10.1",
       "version": "3.10.1",
       "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
       "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",

+ 1 - 0
frontend/package.json

@@ -34,6 +34,7 @@
     "dompurify": "^3.4.17",
     "dompurify": "^3.4.17",
     "i18next": "25.6.3",
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
     "i18next-browser-languagedetector": "^8.2.0",
+    "jsqr": "^1.4.0",
     "jszip": "^3.10.1",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
     "lucide-react": "^0.555.0",
     "mdast-util-gfm-footnote": "^2.1.0",
     "mdast-util-gfm-footnote": "^2.1.0",

+ 3 - 4
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -166,12 +166,11 @@ describe('AssignSpoolModal', () => {
 
 
     render(<AssignSpoolModal {...defaultProps} />);
     render(<AssignSpoolModal {...defaultProps} />);
 
 
+    // Spool 1 (assigned to current slot) should still be in the list for
+    // re-assignment — its card, not the "Assigned" line above the list.
     await waitFor(() => {
     await waitFor(() => {
-      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Polymaker/ })).toBeInTheDocument();
     });
     });
-
-    // Spool 1 (assigned to current slot) should still be visible for re-assignment
-    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
   });
   });
 
 
   // Empty-state premise reworked for #1133: BL spools no longer trigger
   // Empty-state premise reworked for #1133: BL spools no longer trigger

+ 337 - 0
frontend/src/__tests__/components/AssignSpoolModalQrScan.test.tsx

@@ -0,0 +1,337 @@
+/**
+ * Scanning a spool's QR from the shared Assign Spool dialog (#1574 review).
+ *
+ * Opened from a slot, the dialog already knows the target, so a scan assigns
+ * straight to it: the slot tuple the dialog was opened with is the one sent,
+ * the scanned spool moves there from any other slot, the dialog closes only
+ * once the assign succeeded, and Back returns to the picker.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { frames, render, stubLiveCamera } from '../utils';
+import { AssignSpoolModal } from '../../components/AssignSpoolModal';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn(),
+    getAssignments: vi.fn(),
+    assignSpool: vi.fn(),
+    assignSpoolmanSlot: vi.fn(),
+    getSpoolmanInventorySpools: vi.fn(),
+    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    refreshPrinterStatus: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
+
+function spool(id: number, extra: Record<string, unknown> = {}) {
+  return {
+    id,
+    material: 'PLA',
+    subtype: 'Basic',
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    weight_used: 0,
+    tag_uid: null,
+    tray_uuid: null,
+    slicer_filament_name: 'PLA',
+    archived_at: null,
+    ...extra,
+  };
+}
+
+const baseProps = {
+  isOpen: true,
+  printerId: 7,
+  amsId: 128,
+  trayId: 0,
+  trayInfo: { type: 'PLA', material: 'PLA', color: 'FF0000', location: 'AMS HT A' },
+};
+
+/** jsdom has no camera, so a scan goes through the photo fallback with a stubbed decoder. */
+function scan(rawValue: string) {
+  vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+  vi.stubGlobal(
+    'BarcodeDetector',
+    class {
+      detect = vi.fn().mockResolvedValue([{ rawValue }]);
+    },
+  );
+  const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+  fireEvent.change(input, { target: { files: [new File(['qr'], 'qr.jpg', { type: 'image/jpeg' })] } });
+}
+
+async function openScanner() {
+  await userEvent.click(await screen.findByRole('button', { name: /scan a spool qr/i }));
+  await screen.findByRole('button', { name: /take photo/i });
+}
+
+describe('AssignSpoolModal QR scan', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocked.getSpools.mockResolvedValue([spool(3), spool(4, { material: 'PETG', subtype: 'HF' }), spool(5, { archived_at: '2026-01-01T00:00:00Z' })]);
+    mocked.getAssignments.mockResolvedValue([]);
+    mocked.getSettings.mockResolvedValue({});
+    mocked.refreshPrinterStatus.mockResolvedValue({ status: 'ok' });
+    mocked.getSpoolmanSlotAssignments.mockResolvedValue([]);
+    mocked.getSpoolmanInventorySpools.mockResolvedValue([]);
+    mocked.assignSpool.mockResolvedValue({ id: 1, spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0 });
+    mocked.assignSpoolmanSlot.mockResolvedValue({});
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.restoreAllMocks();
+  });
+
+  it('Spoolman: a refused scan is not sent again while its label stays in front of the camera (#1606 review)', async () => {
+    mocked.assignSpoolmanSlot.mockRejectedValue(new Error('Spoolman server is not reachable'));
+    stubLiveCamera('web+spoolman:s-12');
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} spoolmanEnabled />);
+    await openScanner();
+
+    await waitFor(() => expect(mocked.assignSpoolmanSlot).toHaveBeenCalledTimes(1), { timeout: 3000 });
+    await frames(4);
+
+    expect(mocked.assignSpoolmanSlot).toHaveBeenCalledTimes(1);
+    expect(onClose).not.toHaveBeenCalled();
+    // Why, in the scanner: a toast would sit under the dialog, and the kiosk shows none.
+    const why = screen.getByText('Failed to assign spool: Spoolman server is not reachable');
+    expect(screen.getByTestId('toast-viewport')).not.toContainElement(why);
+  });
+
+  it('a scan waits for the spool list rather than turning down a spool it has not loaded yet (#1606 review)', async () => {
+    let listed: (spools: unknown) => void = () => {};
+    mocked.getSpools.mockImplementation(() => new Promise((resolve) => { listed = resolve; }));
+    stubLiveCamera('https://bambuddy.local/inventory?spool=3');
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+    await openScanner();
+
+    await frames(2);
+    expect(screen.queryByText(/There is no spool #3/)).not.toBeInTheDocument();
+    listed([spool(3)]);
+
+    await waitFor(
+      () => expect(mocked.assignSpool).toHaveBeenCalledWith({ spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0, move: true }),
+      { timeout: 3000 },
+    );
+  });
+
+  it('Cancel on the material question sticks while the label stays in front of the camera (#1606 review)', async () => {
+    stubLiveCamera('https://bambuddy.local/inventory?spool=4'); // PETG, for a PLA slot
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+    await openScanner();
+
+    await screen.findByText('Material mismatch', {}, { timeout: 3000 });
+    await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+    await frames(4);
+
+    expect(screen.queryByText('Material mismatch')).not.toBeInTheDocument();
+    expect(mocked.assignSpool).not.toHaveBeenCalled();
+  });
+
+  it('opens the scanner in the dialog, for the slot it was opened from', async () => {
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+    await openScanner();
+
+    expect(screen.getByRole('heading', { name: 'Scan a spool QR' })).toBeInTheDocument();
+    // The slot stays in view; there is no target to pick.
+    expect(screen.getByText('(AMS HT A)')).toBeInTheDocument();
+    expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
+  });
+
+  it('assigns the scanned spool to that slot, moving it, and closes only once that succeeded', async () => {
+    let finish: (value: unknown) => void = () => {};
+    mocked.assignSpool.mockImplementation(() => new Promise((resolve) => { finish = resolve; }));
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} />);
+    await openScanner();
+
+    scan('https://bambuddy.local/inventory?spool=3');
+
+    await waitFor(() =>
+      expect(mocked.assignSpool).toHaveBeenCalledWith({ spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0, move: true }),
+    );
+    expect(onClose).not.toHaveBeenCalled();
+    finish({ id: 1, spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0 });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('takes a spool that is in another slot — the picker would not offer it, the scan moves it', async () => {
+    mocked.getAssignments.mockResolvedValue([{ id: 9, spool_id: 3, printer_id: 2, ams_id: 0, tray_id: 1 }]);
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} />);
+    await openScanner();
+
+    scan('3'); // a bare id reads as this inventory's spool too
+
+    await waitFor(() =>
+      expect(mocked.assignSpool).toHaveBeenCalledWith({ spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0, move: true }),
+    );
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('stays on the scanner when the assign fails, and the next scan is sent', async () => {
+    mocked.assignSpool.mockRejectedValueOnce(new Error('Printer not found'));
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} />);
+    await openScanner();
+
+    scan('https://bambuddy.local/inventory?spool=3');
+    await waitFor(() => expect(mocked.assignSpool).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect(screen.getByRole('button', { name: /take photo/i })).toBeEnabled());
+    expect(onClose).not.toHaveBeenCalled();
+
+    scan('https://bambuddy.local/inventory?spool=3');
+    await waitFor(() => expect(mocked.assignSpool).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('Back returns to the picker without closing the dialog', async () => {
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} />);
+    await openScanner();
+
+    await userEvent.click(screen.getByRole('button', { name: /back/i }));
+
+    expect(await screen.findByRole('heading', { name: 'Assign Spool' })).toBeInTheDocument();
+    expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
+    expect(onClose).not.toHaveBeenCalled();
+    expect(mocked.assignSpool).not.toHaveBeenCalled();
+  });
+
+  it.each([
+    ['web+spoolman:s-3', /That is a Spoolman label/],
+    ['https://example.com/', /That QR code is not a spool label/],
+    ['https://bambuddy.local/inventory?spool=99', /There is no spool #99/],
+    ['https://bambuddy.local/inventory?spool=5', /Spool #5 is archived/],
+  ])('built-in: %s is turned down with a reason, and nothing is sent', async (payload, message) => {
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} />);
+    await openScanner();
+
+    scan(payload);
+
+    expect(await screen.findByText(message)).toBeInTheDocument();
+    expect(mocked.assignSpool).not.toHaveBeenCalled();
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('asks before assigning a scanned spool of another material, then moves it', async () => {
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+    await openScanner();
+
+    scan('https://bambuddy.local/inventory?spool=4');
+
+    expect(await screen.findByText('Material mismatch')).toBeInTheDocument();
+    expect(mocked.assignSpool).not.toHaveBeenCalled();
+    await userEvent.click(screen.getByRole('button', { name: 'Assign Anyway' }));
+    await waitFor(() =>
+      expect(mocked.assignSpool).toHaveBeenCalledWith({ spool_id: 4, printer_id: 7, ams_id: 128, tray_id: 0, move: true }),
+    );
+  });
+
+  it('Spoolman: a Spoolman label assigns that Spoolman spool to the slot, moving it', async () => {
+    const onClose = vi.fn();
+    render(<AssignSpoolModal {...baseProps} onClose={onClose} spoolmanEnabled />);
+    await openScanner();
+
+    scan('web+spoolman:s-12');
+
+    await waitFor(() =>
+      expect(mocked.assignSpoolmanSlot).toHaveBeenCalledWith({
+        spoolman_spool_id: 12,
+        printer_id: 7,
+        ams_id: 128,
+        tray_id: 0,
+        move: true,
+      }),
+    );
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it.each([
+    ['empty', 'physical' as const],
+    ['holding a spool it cannot identify', 'reset' as const],
+  ])('a slot reporting no material (%s) has nothing to mismatch: picked or scanned, no question', async (_kind, emptyKind) => {
+    const user = userEvent.setup();
+    const slot = { ...baseProps, trayInfo: { type: '', color: '', location: 'AMS HT A', emptyKind } };
+    const { unmount } = render(<AssignSpoolModal {...slot} onClose={vi.fn()} />);
+
+    await user.click(await screen.findByRole('button', { name: /Polymaker PETG HF/ }));
+    await user.click(screen.getByRole('button', { name: /^assign spool$/i }));
+    await waitFor(() => expect(mocked.assignSpool).toHaveBeenCalledTimes(1));
+    unmount();
+
+    render(<AssignSpoolModal {...slot} onClose={vi.fn()} />);
+    await openScanner();
+    scan('https://bambuddy.local/inventory?spool=4'); // PETG
+    await waitFor(() => expect(mocked.assignSpool).toHaveBeenCalledTimes(2));
+    expect(screen.queryByText('Material mismatch')).not.toBeInTheDocument();
+  });
+
+  it('a spool picked from the list is assigned as before, without move', async () => {
+    const user = userEvent.setup();
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+
+    await user.click(await screen.findByRole('button', { name: /Polymaker PLA Basic/ }));
+    await user.click(screen.getByRole('button', { name: /^assign spool$/i }));
+
+    await waitFor(() =>
+      expect(mocked.assignSpool).toHaveBeenCalledWith({ spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0 }),
+    );
+  });
+});
+
+describe('AssignSpoolModal slot context', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    mocked.getSpools.mockResolvedValue([spool(3)]);
+    mocked.getAssignments.mockResolvedValue([]);
+    mocked.getSettings.mockResolvedValue({});
+    mocked.getSpoolmanSlotAssignments.mockResolvedValue([]);
+    mocked.getSpoolmanInventorySpools.mockResolvedValue([]);
+  });
+
+  it('shows the spool assigned to the slot now', async () => {
+    mocked.getAssignments.mockResolvedValue([{ id: 1, spool_id: 3, printer_id: 7, ams_id: 128, tray_id: 0 }]);
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} />);
+
+    expect(await screen.findByText('#3 Polymaker PLA Basic')).toBeInTheDocument();
+    expect(screen.getByText(/Assigned:/)).toBeInTheDocument();
+  });
+
+  it('Spoolman: shows the Spoolman spool assigned to the slot now', async () => {
+    mocked.getSpoolmanSlotAssignments.mockResolvedValue([
+      { printer_id: 7, printer_name: 'H2C', ams_id: 128, tray_id: 0, spoolman_spool_id: 12, ams_label: null },
+    ]);
+    mocked.getSpoolmanInventorySpools.mockResolvedValue([spool(12, { brand: 'Elegoo' })]);
+    render(<AssignSpoolModal {...baseProps} onClose={vi.fn()} spoolmanEnabled />);
+
+    expect(await screen.findByText('#12 Elegoo PLA Basic')).toBeInTheDocument();
+  });
+
+  it.each([
+    ['physical' as const, 'Empty slot'],
+    ['reset' as const, 'No filament assigned'],
+    [undefined, 'No filament assigned'],
+  ])('a slot reporting no filament (%s) reads "%s" — empty only when the firmware says so', async (emptyKind, label) => {
+    render(
+      <AssignSpoolModal
+        {...baseProps}
+        onClose={vi.fn()}
+        trayInfo={{ type: '', color: '', location: 'AMS HT A', emptyKind }}
+      />,
+    );
+
+    expect(await screen.findByText(label)).toBeInTheDocument();
+  });
+});

+ 473 - 0
frontend/src/__tests__/components/QrAssignTargetModal.test.tsx

@@ -0,0 +1,473 @@
+/**
+ * Tests for the scan-to-location dialog (#1574): picking a target, and what a
+ * scan does. jsdom has no camera, so scans come either from the photo fallback
+ * or from a stubbed live camera, with the platform decoder stubbed to read a
+ * given payload.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, fireEvent, frames, render, stubLiveCamera, waitFor } from '../utils';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { QrAssignTargetModal } from '../../components/QrAssignTargetModal';
+
+function noop() {}
+
+/** A1 holds PLA, so a spool of another material makes the dialog ask. */
+function slotA1HoldsPla() {
+  server.use(
+    http.get('/api/v1/printers/1/status', () =>
+      HttpResponse.json({
+        ams: [
+          {
+            id: 0,
+            is_ams_ht: false,
+            tray: [
+              { id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: 'FF0000FF', remain: 50, state: 11, exists: true },
+              { id: 1 },
+              { id: 2 },
+              { id: 3 },
+            ],
+          },
+        ],
+        vt_tray: [],
+      }),
+    ),
+  );
+}
+
+function inventorySpool(id: number, extra: Record<string, unknown> = {}) {
+  return { id, material: 'PLA', subtype: 'Basic', brand: 'Polymaker', archived_at: null, slicer_filament_name: null, ...extra };
+}
+
+/**
+ * jsdom has no camera, so a scan goes through the photo fallback: the platform
+ * decoder is stubbed to read `rawValue` off whatever photo is picked.
+ */
+function stubPhotoDecoder(rawValue: string) {
+  vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+  vi.stubGlobal(
+    'BarcodeDetector',
+    class {
+      detect = vi.fn().mockResolvedValue([{ rawValue }]);
+    },
+  );
+}
+
+function pickPhoto() {
+  const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+  fireEvent.change(input, { target: { files: [new File(['qr'], 'qr.jpg', { type: 'image/jpeg' })] } });
+}
+
+async function openScanStepForSlotA1() {
+  fireEvent.click(await screen.findByRole('button', { name: /^A1\b/ }));
+  fireEvent.click(screen.getByRole('button', { name: /set target & scan/i }));
+  await screen.findByRole('button', { name: /take photo/i });
+}
+
+describe('QrAssignTargetModal', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'X1C' }])),
+      http.get('/api/v1/printers/1/status', () =>
+        HttpResponse.json({
+          ams: [{ id: 0, is_ams_ht: false, tray: [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }] }],
+          vt_tray: [],
+        }),
+      ),
+      http.get('/api/v1/inventory/locations', () =>
+        HttpResponse.json([
+          { id: 7, name: 'Shelf A', identifier: null, spool_count: 2, created_at: '', updated_at: '' },
+        ]),
+      ),
+    );
+  });
+
+  it('gates the scan button until an AMS slot is chosen, then opens the photo step', async () => {
+    render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+    // Slots render from the printer status (formatSlotLabel(0,0,..) => "A1"),
+    // each with what is in it after the label.
+    const slotA1 = await screen.findByRole('button', { name: /^A1\b/ });
+
+    const startBtn = screen.getByRole('button', { name: /set target & scan/i });
+    expect(startBtn).toBeDisabled();
+
+    fireEvent.click(slotA1);
+    expect(startBtn).toBeEnabled();
+
+    fireEvent.click(startBtn);
+
+    // Photo step: header switches, the Take-photo action and target chip show.
+    expect(await screen.findByText('Scan a spool QR')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument();
+    expect(screen.getByText(/Target: X1C · A1/)).toBeInTheDocument();
+  });
+
+  it('storage tab: scan button enables only after a structured location is picked', async () => {
+    render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /storage/i }));
+
+    const startBtn = screen.getByRole('button', { name: /set target & scan/i });
+    expect(startBtn).toBeDisabled();
+
+    // Options come from the Locations catalog, not free text.
+    const select = await screen.findByRole('combobox');
+    expect(await screen.findByRole('option', { name: 'Shelf A' })).toBeInTheDocument();
+
+    fireEvent.change(select, { target: { value: '7' } });
+    expect(startBtn).toBeEnabled();
+
+    fireEvent.click(startBtn);
+    expect(await screen.findByText(/Target: Shelf A/)).toBeInTheDocument();
+  });
+
+  it('storage tab: creating a location picks it immediately, without waiting for a refetch', async () => {
+    server.use(
+      http.post('/api/v1/inventory/locations', () =>
+        HttpResponse.json({ id: 9, name: 'Bin 7', identifier: null, spool_count: 0, created_at: '', updated_at: '' }),
+      ),
+    );
+    render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /storage/i }));
+    await screen.findByRole('option', { name: 'Shelf A' });
+
+    const startBtn = screen.getByRole('button', { name: /set target & scan/i });
+    expect(startBtn).toBeDisabled();
+
+    fireEvent.change(screen.getByPlaceholderText(/Shelf A, Drawer 1/i), { target: { value: 'Bin 7' } });
+    fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
+
+    // The created location is selected straight away and resolves by id.
+    await waitFor(() => expect(startBtn).toBeEnabled());
+    fireEvent.click(startBtn);
+    expect(await screen.findByText(/Target: Bin 7/)).toBeInTheDocument();
+  });
+
+  it("slot tiles say what is in each slot by the printer card's rules, never Empty for a present spool", async () => {
+    server.use(
+      http.get('/api/v1/printers/1/status', () =>
+        HttpResponse.json({
+          ams: [
+            {
+              id: 0,
+              is_ams_ht: false,
+              tray: [
+                // loaded, with what is left on it
+                { id: 0, tray_type: 'PLA', tray_color: 'FF0000FF', remain: 80, state: 11, exists: true },
+                // the firmware says no spool is there
+                { id: 1, tray_type: '', tray_color: null, remain: -1, state: 11, exists: false },
+                // a spool without RFID: present, no filament data — "?", not Empty
+                { id: 2, tray_type: '', tray_color: null, remain: -1, state: 11, exists: true },
+                // no presence data: state 9/10 reads empty...
+                { id: 3, tray_type: '', tray_color: null, remain: -1, state: 10, exists: null },
+              ],
+            },
+          ],
+          vt_tray: [
+            // ...anything else reads unknown, on an external slot too
+            { id: 254, tray_type: '', tray_color: null, remain: -1, state: 3, exists: null },
+            { id: 255, tray_type: '', tray_color: null, remain: -1, state: 11, exists: true },
+          ],
+        }),
+      ),
+    );
+    render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+    expect(await screen.findByRole('button', { name: 'A1 PLA 80%' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'A2 Empty' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'A3 ?' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'A4 Empty' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Ext-L ?' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Ext-R ?' })).toBeInTheDocument();
+  });
+
+  describe('assigning a scanned spool to a slot', () => {
+    let unassigned: string[];
+
+    beforeEach(() => {
+      unassigned = [];
+      server.use(
+        http.delete('/api/v1/inventory/assignments/:printerId/:amsId/:trayId', ({ request }) => {
+          unassigned.push(request.url);
+          return HttpResponse.json({ status: 'deleted' });
+        }),
+        http.delete('/api/v1/spoolman/inventory/slot-assignments/:spoolId', ({ request }) => {
+          unassigned.push(request.url);
+          return HttpResponse.json({ id: 0 });
+        }),
+        // The built-in inventory looks a scanned spool up before an AMS assign.
+        http.get('/api/v1/inventory/spools/:id', ({ params }) => HttpResponse.json(inventorySpool(Number(params.id)))),
+      );
+    });
+
+    afterEach(() => {
+      vi.unstubAllGlobals();
+      vi.restoreAllMocks();
+    });
+
+    it('built-in: one request moves the spool, with no unassign before or after (#1574)', async () => {
+      let body: unknown = null;
+      server.use(
+        http.post('/api/v1/inventory/assignments', async ({ request }) => {
+          body = await request.json();
+          return HttpResponse.json({ id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0 });
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+      expect(body).toEqual({ spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0, move: true });
+      expect(unassigned).toEqual([]);
+    });
+
+    it('Spoolman: one request moves the spool, with no unassign before it (#1574)', async () => {
+      let body: unknown = null;
+      server.use(
+        http.post('/api/v1/spoolman/inventory/slot-assignments', async ({ request }) => {
+          body = await request.json();
+          return HttpResponse.json({ id: 42 });
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+      expect(body).toEqual({ spoolman_spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0, move: true });
+      expect(unassigned).toEqual([]);
+    });
+
+    it("built-in: a Spoolman label is turned down, not read as one of the inventory's spools", async () => {
+      let requested = false;
+      server.use(
+        http.post('/api/v1/inventory/assignments', () => {
+          requested = true;
+          return HttpResponse.json({});
+        }),
+      );
+      stubPhotoDecoder('web+spoolman:s-5');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      expect(await screen.findByText(/That is a Spoolman label/)).toBeInTheDocument();
+      expect(requested).toBe(false);
+      expect(onClose).not.toHaveBeenCalled();
+    });
+
+    it("Spoolman: Spoolman's own label assigns that Spoolman spool", async () => {
+      let body: unknown = null;
+      server.use(
+        http.post('/api/v1/spoolman/inventory/slot-assignments', async ({ request }) => {
+          body = await request.json();
+          return HttpResponse.json({ id: 5 });
+        }),
+      );
+      stubPhotoDecoder('https://spoolman.local/spool/show/5');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+      expect(body).toEqual({ spoolman_spool_id: 5, printer_id: 1, ams_id: 0, tray_id: 0, move: true });
+    });
+
+    it('a failed move keeps the scan step open', async () => {
+      let posts = 0;
+      server.use(
+        http.post('/api/v1/inventory/assignments', () => {
+          posts += 1;
+          return HttpResponse.json({ detail: 'Printer not found' }, { status: 404 });
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      await waitFor(() => expect(posts).toBe(1));
+      // The take-photo button is disabled while the request runs, so it being
+      // enabled again means the failed request has settled.
+      await waitFor(() => expect(screen.getByRole('button', { name: /take photo/i })).toBeEnabled());
+      expect(onClose).not.toHaveBeenCalled();
+      expect(screen.getByText('Scan a spool QR')).toBeInTheDocument();
+      // Why, in the dialog: a toast would sit under its backdrop, and the kiosk shows none.
+      const why = screen.getByText('Could not assign to X1C · A1: Printer not found');
+      expect(screen.getByTestId('toast-viewport')).not.toContainElement(why);
+    });
+
+    it('built-in: closing the dialog while the spool is looked up assigns nothing (#1606 review)', async () => {
+      let lookedUp = false;
+      let answer: () => void = () => {};
+      let posts = 0;
+      server.use(
+        http.get('/api/v1/inventory/spools/:id', async () => {
+          lookedUp = true;
+          await new Promise<void>((resolve) => { answer = resolve; });
+          return HttpResponse.json(inventorySpool(42));
+        }),
+        http.post('/api/v1/inventory/assignments', () => {
+          posts += 1;
+          return HttpResponse.json({ id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0 });
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      // Both pages that open the dialog unmount it when it closes.
+      const { unmount } = render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+      await waitFor(() => expect(lookedUp).toBe(true));
+      unmount();
+      answer();
+      await new Promise((r) => setTimeout(r, 100));
+
+      expect(posts).toBe(0);
+    });
+
+    it('a refused assign is not sent again while its label stays in front of the camera (#1606 review)', async () => {
+      let posts = 0;
+      server.use(
+        http.post('/api/v1/spoolman/inventory/slot-assignments', () => {
+          posts += 1;
+          return HttpResponse.json({ detail: 'Cannot assign an archived spool' }, { status: 400 });
+        }),
+      );
+      stubLiveCamera('https://bambuddy.local/inventory?spool=42');
+      render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode />);
+
+      await openScanStepForSlotA1();
+      await waitFor(() => expect(posts).toBe(1), { timeout: 3000 });
+      await frames(4);
+
+      expect(posts).toBe(1);
+      // Asked for, it goes again.
+      fireEvent.click(await screen.findByRole('button', { name: /retry/i }));
+      await waitFor(() => expect(posts).toBe(2), { timeout: 3000 });
+    });
+
+    it('built-in: a spool of another material asks first, and Assign Anyway moves it', async () => {
+      slotA1HoldsPla();
+      let body: unknown = null;
+      server.use(
+        http.get('/api/v1/inventory/spools/:id', () => HttpResponse.json(inventorySpool(42, { material: 'PETG', subtype: 'HF' }))),
+        http.post('/api/v1/inventory/assignments', async ({ request }) => {
+          body = await request.json();
+          return HttpResponse.json({ id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0 });
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      expect(await screen.findByText('Material mismatch')).toBeInTheDocument();
+      expect(screen.getByText(/"PETG" does not match the tray material "PLA"/)).toBeInTheDocument();
+      expect(body).toBeNull();
+      fireEvent.click(screen.getByRole('button', { name: 'Assign Anyway' }));
+
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+      expect(body).toEqual({ spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0, move: true });
+    });
+
+    it('built-in: Cancel on the material question sticks while the label stays in view (#1606 review)', async () => {
+      slotA1HoldsPla();
+      let posts = 0;
+      server.use(
+        http.get('/api/v1/inventory/spools/:id', () => HttpResponse.json(inventorySpool(42, { material: 'PETG' }))),
+        http.post('/api/v1/inventory/assignments', () => {
+          posts += 1;
+          return HttpResponse.json({});
+        }),
+      );
+      stubLiveCamera('https://bambuddy.local/inventory?spool=42');
+      render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      await screen.findByText('Material mismatch', {}, { timeout: 3000 });
+      // The question's own Cancel comes after the dialog's in the page.
+      const cancels = screen.getAllByRole('button', { name: 'Cancel' });
+      fireEvent.click(cancels[cancels.length - 1]);
+      await frames(4);
+
+      expect(screen.queryByText('Material mismatch')).not.toBeInTheDocument();
+      expect(posts).toBe(0);
+    });
+
+    it('built-in: with filament warnings off, a spool of another material is assigned without asking', async () => {
+      slotA1HoldsPla();
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ disable_filament_warnings: true })),
+        http.get('/api/v1/inventory/spools/:id', () => HttpResponse.json(inventorySpool(42, { material: 'PETG' }))),
+        http.post('/api/v1/inventory/assignments', () =>
+          HttpResponse.json({ id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0 }),
+        ),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      const onClose = vi.fn();
+      render(<QrAssignTargetModal isOpen onClose={onClose} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+      expect(screen.queryByText('Material mismatch')).not.toBeInTheDocument();
+    });
+
+    it('built-in: a slot that will configure when the spool goes in says so (#1680)', async () => {
+      server.use(
+        http.post('/api/v1/inventory/assignments', () =>
+          HttpResponse.json({ id: 1, spool_id: 42, printer_id: 1, ams_id: 0, tray_id: 0, pending_config: true }),
+        ),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      expect(await screen.findByText('Assigned. Slot will configure when you insert the spool.')).toBeInTheDocument();
+    });
+
+    it.each([
+      ['archived', () => HttpResponse.json(inventorySpool(42, { archived_at: '2026-01-01T00:00:00Z' })), /Spool #42 is archived/],
+      ['missing', () => HttpResponse.json({ detail: 'Spool not found' }, { status: 404 }), /There is no spool #42/],
+    ])('built-in: a %s spool says so, and nothing is assigned', async (_kind, reply, message) => {
+      let posts = 0;
+      server.use(
+        http.get('/api/v1/inventory/spools/:id', reply),
+        http.post('/api/v1/inventory/assignments', () => {
+          posts += 1;
+          return HttpResponse.json({});
+        }),
+      );
+      stubPhotoDecoder('https://bambuddy.local/inventory?spool=42');
+      render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+      await openScanStepForSlotA1();
+      pickPhoto();
+
+      expect(await screen.findByText(message)).toBeInTheDocument();
+      expect(posts).toBe(0);
+    });
+  });
+});

+ 283 - 0
frontend/src/__tests__/components/QrSpoolScanner.test.tsx

@@ -0,0 +1,283 @@
+/**
+ * The shared spool QR scanner (#1574): its live loop keeps scanning after a
+ * frame or a callback fails, hands a label over once while it stays in view
+ * (other codes read in between or not), says why when there's no camera,
+ * releases the camera when it closes, a photo that comes back while the
+ * caller is busy is handed over once it's free, and a photo still decoding
+ * when it closes is not handed to a caller that moved on.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { fireEvent, frames, render, screen, waitFor } from '../utils';
+
+const jsQR = vi.hoisted(() => vi.fn());
+vi.mock('jsqr', () => ({ default: jsQR }));
+
+import { QrSpoolScanner } from '../../components/QrSpoolScanner';
+
+const PAYLOAD = 'https://bambuddy.local/inventory?spool=9';
+const OTHER_PAYLOAD = 'https://bambuddy.local/inventory?spool=10';
+
+function readableContext() {
+  return {
+    drawImage: vi.fn(),
+    getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
+  };
+}
+
+describe('QrSpoolScanner', () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    vi.restoreAllMocks();
+    jsQR.mockReset();
+  });
+
+  describe('live camera', () => {
+    let track: { stop: ReturnType<typeof vi.fn>; applyConstraints: ReturnType<typeof vi.fn> };
+    let ctx: ReturnType<typeof readableContext>;
+
+    beforeEach(() => {
+      track = { stop: vi.fn(), applyConstraints: vi.fn().mockResolvedValue(undefined) };
+      const stream = { getTracks: () => [track], getVideoTracks: () => [track] };
+      vi.stubGlobal('navigator', { ...navigator, mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) } });
+      vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
+      vi.spyOn(HTMLMediaElement.prototype, 'readyState', 'get').mockReturnValue(4); // HAVE_ENOUGH_DATA
+      vi.spyOn(HTMLVideoElement.prototype, 'videoWidth', 'get').mockReturnValue(640);
+      vi.spyOn(HTMLVideoElement.prototype, 'videoHeight', 'get').mockReturnValue(480);
+      ctx = readableContext();
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => ctx as unknown as RenderingContext);
+      jsQR.mockReturnValue({ data: PAYLOAD });
+    });
+
+    it('keeps scanning after a frame that could not be read and a callback that threw', async () => {
+      // No platform decoder: frames go through the canvas and jsQR.
+      vi.stubGlobal('BarcodeDetector', undefined);
+      ctx.drawImage.mockImplementationOnce(() => {
+        throw new DOMException('no frame yet', 'InvalidStateError');
+      });
+      const onDecode = vi.fn().mockImplementationOnce(() => {
+        throw new Error('the caller failed');
+      });
+
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      // Frame 1 can't be drawn, frame 2's callback throws, frame 3 brings the
+      // same label again: the caller never had it.
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
+      expect(onDecode.mock.calls).toEqual([[PAYLOAD], [PAYLOAD]]);
+    });
+
+    it('hands a label over once while it stays in view, a different one at once, the same again on Retry', async () => {
+      vi.stubGlobal('BarcodeDetector', undefined);
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error="Spool #9 is archived." />);
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1), { timeout: 3000 });
+      await frames(3);
+      // The caller turned it down; the label staying in front of the camera is not sent again.
+      expect(onDecode).toHaveBeenCalledTimes(1);
+
+      await screen.findByRole('button', { name: /retry/i }).then((retry) => fireEvent.click(retry));
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
+
+      jsQR.mockReturnValue({ data: OTHER_PAYLOAD });
+      await waitFor(() => expect(onDecode).toHaveBeenLastCalledWith(OTHER_PAYLOAD), { timeout: 3000 });
+      expect(onDecode).toHaveBeenCalledTimes(3);
+    });
+
+    it('hands a label over again once it was out of view a moment: showing it again is asking', async () => {
+      vi.stubGlobal('BarcodeDetector', undefined);
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1), { timeout: 3000 });
+
+      jsQR.mockReturnValue(null); // taken away...
+      await frames(9);
+      jsQR.mockReturnValue({ data: PAYLOAD }); // ...and shown again
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
+    });
+
+    it('does not hand a label over again while another code in view is read in between', async () => {
+      // The label and, next to it, the maker's QR on the spool: frames read one, then the other.
+      vi.stubGlobal('BarcodeDetector', undefined);
+      let read = 0;
+      jsQR.mockImplementation(() => ({ data: read++ % 2 === 0 ? PAYLOAD : OTHER_PAYLOAD }));
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2), { timeout: 3000 });
+      await frames(4);
+
+      expect(onDecode.mock.calls).toEqual([[PAYLOAD], [OTHER_PAYLOAD]]);
+    });
+
+    it('offers no Retry while the caller is busy with the label', async () => {
+      vi.stubGlobal('BarcodeDetector', undefined);
+      const onDecode = vi.fn();
+      const { rerender } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+      await waitFor(() => expect(onDecode).toHaveBeenCalled(), { timeout: 3000 });
+
+      rerender(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
+
+      expect(screen.queryByRole('button', { name: /retry/i })).not.toBeInTheDocument();
+    });
+
+    it('says why there is no live view when the camera cannot be opened', async () => {
+      vi.stubGlobal('navigator', {
+        ...navigator,
+        mediaDevices: { getUserMedia: vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError')) },
+      });
+      render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
+
+      expect(await screen.findByText(/No camera could be opened here/)).toBeInTheDocument();
+      expect(document.querySelector('video')).toBeNull();
+    });
+
+    it('scans with jsQR where the platform decoder cannot be built for QR codes', async () => {
+      vi.stubGlobal(
+        'BarcodeDetector',
+        class {
+          constructor() {
+            throw new TypeError('qr_code is not supported');
+          }
+        },
+      );
+      const onDecode = vi.fn();
+
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledWith(PAYLOAD), { timeout: 3000 });
+    });
+
+    it('scans nothing while the caller is busy', async () => {
+      vi.stubGlobal('BarcodeDetector', undefined);
+      const onDecode = vi.fn();
+
+      render(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
+
+      await new Promise((r) => setTimeout(r, 500));
+      expect(onDecode).not.toHaveBeenCalled();
+    });
+
+    it('stops the camera when it closes', async () => {
+      vi.stubGlobal('BarcodeDetector', undefined);
+      const { unmount } = render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
+      await waitFor(() => expect(HTMLMediaElement.prototype.play).toHaveBeenCalled());
+
+      unmount();
+
+      expect(track.stop).toHaveBeenCalled();
+    });
+  });
+
+  describe('photo fallback', () => {
+    function pickPhoto() {
+      const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+      fireEvent.change(input, { target: { files: [new File(['qr'], 'qr.jpg', { type: 'image/jpeg' })] } });
+    }
+
+    beforeEach(() => {
+      // No camera in jsdom: the live view hides itself and the photo path is all there is.
+      vi.stubGlobal('BarcodeDetector', undefined);
+      vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
+        () => readableContext() as unknown as RenderingContext,
+      );
+    });
+
+    it('hands a decoded photo to the caller', async () => {
+      vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+      jsQR.mockReturnValue({ data: PAYLOAD });
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      pickPhoto();
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledWith(PAYLOAD));
+    });
+
+    it('hands over every photo, even of the label it just handed over: taking one is asking', async () => {
+      vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+      jsQR.mockReturnValue({ data: PAYLOAD });
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      pickPhoto();
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1));
+      pickPhoto();
+
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(2));
+    });
+
+    it('hands over a photo once the caller is free, when it turned busy while the photo was taken', async () => {
+      vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+      jsQR.mockReturnValue({ data: PAYLOAD });
+      const onDecode = vi.fn();
+      const { rerender } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      // The button was free when tapped; the caller turned busy (a list
+      // refetching as the page got focus back) before the photo came back.
+      rerender(<QrSpoolScanner onDecode={onDecode} busy error={null} />);
+      pickPhoto();
+      await new Promise((r) => setTimeout(r, 50));
+      expect(onDecode).not.toHaveBeenCalled();
+
+      rerender(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+      await waitFor(() => expect(onDecode).toHaveBeenCalledTimes(1));
+      expect(onDecode).toHaveBeenCalledWith(PAYLOAD);
+    });
+
+    it('says the live camera needs HTTPS when the page is not a secure origin', async () => {
+      vi.stubGlobal('isSecureContext', false);
+      render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
+
+      expect(await screen.findByText(/needs Bambuddy opened over HTTPS/)).toBeInTheDocument();
+      // The photo fallback is still there.
+      expect(screen.getByRole('button', { name: /take photo/i })).toBeEnabled();
+    });
+
+    it('on a secure origin without camera access, says no camera could be opened, not to use HTTPS', async () => {
+      // jsdom offers no camera at all, as an old or embedded browser on HTTPS might.
+      vi.stubGlobal('isSecureContext', true);
+      render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error={null} />);
+
+      expect(await screen.findByText(/No camera could be opened here/)).toBeInTheDocument();
+      expect(screen.queryByText(/needs Bambuddy opened over HTTPS/)).not.toBeInTheDocument();
+    });
+
+    it('says so when the photo has no QR code', async () => {
+      vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue({ width: 10, height: 10, close: vi.fn() }));
+      jsQR.mockReturnValue(null);
+      const onDecode = vi.fn();
+      render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      pickPhoto();
+
+      expect(await screen.findByText(/No QR code found in the photo/)).toBeInTheDocument();
+      expect(onDecode).not.toHaveBeenCalled();
+    });
+
+    it('drops a photo that finishes decoding after the scanner closed', async () => {
+      let open: (bitmap: unknown) => void = () => {};
+      vi.stubGlobal(
+        'createImageBitmap',
+        vi.fn(() => new Promise((resolve) => { open = resolve; })),
+      );
+      jsQR.mockReturnValue({ data: PAYLOAD });
+      const onDecode = vi.fn();
+      const { unmount } = render(<QrSpoolScanner onDecode={onDecode} busy={false} error={null} />);
+
+      pickPhoto();
+      unmount();
+      open({ width: 10, height: 10, close: vi.fn() });
+      await new Promise((r) => setTimeout(r, 50));
+
+      expect(onDecode).not.toHaveBeenCalled();
+    });
+
+    it("shows the caller's message about the last scan", () => {
+      render(<QrSpoolScanner onDecode={vi.fn()} busy={false} error="That QR code is not a spool label." />);
+      expect(screen.getByText('That QR code is not a spool label.')).toBeInTheDocument();
+    });
+  });
+});

+ 3 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -632,6 +632,9 @@ describe('useWebSocket hook', () => {
       // The print dialog prices each slot and shows its grams from this one,
       // The print dialog prices each slot and shows its grams from this one,
       // in both modes; an open dialog kept the previous spool's figures.
       // in both modes; an open dialog kept the previous spool's figures.
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['printer-inventory-remain'] });
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['printer-inventory-remain'] });
+      // A Spoolman move clears the old slot's fallback tag; the slot cards in
+      // other tabs and on the kiosk read the tag -> spool map (#1574).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['linked-spools'] });
 
 
       vi.useRealTimers();
       vi.useRealTimers();
       vi.unstubAllGlobals();
       vi.unstubAllGlobals();

+ 4 - 4
frontend/src/__tests__/pages/InventoryPageHeaderLayout.test.tsx

@@ -70,9 +70,9 @@ describe('InventoryPage — header layout', () => {
     // Without this the group is as wide as all its buttons laid end to end,
     // Without this the group is as wide as all its buttons laid end to end,
     // whatever the viewport is.
     // whatever the viewport is.
     expect(group.className).toContain('flex-wrap');
     expect(group.className).toContain('flex-wrap');
-    // All six actions (CSV import/export, Locations, Suppliers (#2988),
-    // labels, add) stay in that one group -- wrapping them is the fix,
-    // hiding any of them is not.
-    expect(group.querySelectorAll('button')).toHaveLength(6);
+    // All seven actions (CSV import/export, Locations, Suppliers (#2988),
+    // labels, assign by QR (#1574), add) stay in that one group -- wrapping
+    // them is the fix, hiding any of them is not.
+    expect(group.querySelectorAll('button')).toHaveLength(7);
   });
   });
 });
 });

+ 23 - 0
frontend/src/__tests__/utils.tsx

@@ -3,6 +3,7 @@
  */
  */
 
 
 import React from 'react';
 import React from 'react';
+import { vi } from 'vitest';
 import { render } from '@testing-library/react';
 import { render } from '@testing-library/react';
 import type { RenderOptions } from '@testing-library/react';
 import type { RenderOptions } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -79,3 +80,25 @@ export { createTestQueryClient };
  * Helper to wait for async operations.
  * Helper to wait for async operations.
  */
  */
 export const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 0));
 export const waitForAsync = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+/**
+ * The spool QR scanner's live camera, pointed at a label reading `rawValue` for
+ * as long as the test runs: a camera that opens, a video with frames, and the
+ * platform decoder reading that label off each one.
+ */
+export function stubLiveCamera(rawValue: string) {
+  const track = { stop: vi.fn(), applyConstraints: vi.fn().mockResolvedValue(undefined) };
+  const stream = { getTracks: () => [track], getVideoTracks: () => [track] };
+  vi.stubGlobal('navigator', { ...navigator, mediaDevices: { getUserMedia: vi.fn().mockResolvedValue(stream) } });
+  vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
+  vi.spyOn(HTMLMediaElement.prototype, 'readyState', 'get').mockReturnValue(4); // HAVE_ENOUGH_DATA
+  vi.stubGlobal(
+    'BarcodeDetector',
+    class {
+      detect = vi.fn().mockResolvedValue([{ rawValue }]);
+    },
+  );
+}
+
+/** Let the spool QR scanner's live loop (a decode every 200 ms) run a few more frames. */
+export const frames = (n: number) => new Promise((resolve) => setTimeout(resolve, 250 * n));

+ 60 - 0
frontend/src/__tests__/utils/assignMismatch.test.ts

@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import type { TFunction } from 'i18next';
+import { findMaterialMismatch, materialMismatchMessage } from '../../utils/assignMismatch';
+
+const pla = { type: 'PLA', material: 'PLA', profile: 'Bambu PLA Basic' };
+
+describe('findMaterialMismatch', () => {
+  it('has nothing to ask for the same material, whatever the profile', () => {
+    expect(findMaterialMismatch({ material: 'pla', slicer_filament_name: 'Generic PLA' }, pla)).toBeNull();
+  });
+
+  it('asks about a partial match, with the profiles when they differ too', () => {
+    expect(findMaterialMismatch({ material: 'PLA', slicer_filament_name: 'Bambu PLA Basic @BBL X1C' }, { type: 'PLA Silk' })).toMatchObject({
+      type: 'partial_profile',
+      spoolMaterial: 'PLA',
+      trayMaterial: 'PLA Silk',
+    });
+    expect(findMaterialMismatch({ material: 'PLA', slicer_filament_name: 'PLA Silk' }, { type: 'PLA Silk' })).toMatchObject({
+      type: 'partial',
+    });
+  });
+
+  it('asks about another material', () => {
+    expect(findMaterialMismatch({ material: 'PETG', slicer_filament_name: 'Generic PETG' }, pla)).toEqual({
+      type: 'material_profile',
+      spoolMaterial: 'PETG',
+      trayMaterial: 'PLA',
+      spoolProfile: 'Generic PETG',
+      trayProfile: 'Bambu PLA Basic',
+    });
+  });
+
+  it('has nothing to compare against a slot that reports no material', () => {
+    expect(findMaterialMismatch({ material: 'PETG' }, { type: '' })).toBeNull();
+    expect(findMaterialMismatch({ material: 'PETG' }, { type: '  ', profile: 'Bambu PLA Basic' })).toBeNull();
+  });
+});
+
+describe('materialMismatchMessage', () => {
+  const t = ((key: string, vars?: Record<string, string>) => `${key}${vars ? JSON.stringify(vars) : ''}`) as unknown as TFunction;
+
+  it('puts the material warning, the profile one when it applies, and the reconfigure note together', () => {
+    const mismatch = findMaterialMismatch({ material: 'PETG', slicer_filament_name: 'Generic PETG' }, pla)!;
+    const message = materialMismatchMessage(t, mismatch, 'AMS-A Slot 1').split('\n\n');
+
+    expect(message).toHaveLength(3);
+    expect(message[0]).toContain('inventory.assignMismatchMessage');
+    expect(message[0]).toContain('"location":"AMS-A Slot 1"');
+    expect(message[1]).toContain('inventory.assignProfileMismatchMessage');
+    expect(message[2]).toBe('inventory.assignReconfigureNote');
+  });
+
+  it('leaves the profile line out when only the material differs', () => {
+    const mismatch = findMaterialMismatch({ material: 'PLA', slicer_filament_name: 'PLA Silk' }, { type: 'PLA Silk' })!;
+    const message = materialMismatchMessage(t, mismatch, 'Ext').split('\n\n');
+
+    expect(message).toHaveLength(2);
+    expect(message[0]).toContain('inventory.assignPartialMismatchMessage');
+  });
+});

+ 72 - 0
frontend/src/__tests__/utils/qrAssignTarget.test.ts

@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { parseSpoolQr, scannedSpoolId } from '../../utils/qrAssignTarget';
+
+const bambuddy = (id: number) => ({ source: 'bambuddy', id });
+const spoolman = (id: number) => ({ source: 'spoolman', id });
+
+describe('parseSpoolQr', () => {
+  it('reads a Bambuddy label deeplink (any host/scheme)', () => {
+    expect(parseSpoolQr('https://bambuddy.example.com/inventory?spool=42')).toEqual(bambuddy(42));
+    expect(parseSpoolQr('http://raspi:8094/inventory?spool=1')).toEqual(bambuddy(1));
+    // extra params, order-independent
+    expect(parseSpoolQr('https://host/inventory?foo=bar&spool=7&x=9')).toEqual(bambuddy(7));
+    // a hash router carries the query in the fragment
+    expect(parseSpoolQr('https://host/#/inventory?spool=8')).toEqual(bambuddy(8));
+  });
+
+  it('accepts a bare query fragment or bare number as a Bambuddy label', () => {
+    expect(parseSpoolQr('?spool=12')).toEqual(bambuddy(12));
+    expect(parseSpoolQr('spool=5')).toEqual(bambuddy(5));
+    expect(parseSpoolQr('216')).toEqual(bambuddy(216));
+    expect(parseSpoolQr('  216  ')).toEqual(bambuddy(216));
+  });
+
+  it("reads Spoolman's own labels as Spoolman spools", () => {
+    expect(parseSpoolQr('web+spoolman:s-42')).toEqual(spoolman(42));
+    expect(parseSpoolQr('WEB+SPOOLMAN:S-7')).toEqual(spoolman(7));
+    expect(parseSpoolQr('https://spoolman.local:7912/spool/show/42')).toEqual(spoolman(42));
+    expect(parseSpoolQr('https://spoolman.local/spool/show/42/')).toEqual(spoolman(42));
+    // Spoolman served under a path prefix, or printed without a scheme
+    expect(parseSpoolQr('https://home.local/spoolman/spool/show/9?x=1')).toEqual(spoolman(9));
+    expect(parseSpoolQr('spoolman.local/spool/show/3')).toEqual(spoolman(3));
+  });
+
+  it('turns down a malformed Spoolman label instead of reading a spool= in it', () => {
+    expect(parseSpoolQr('web+spoolman:s-abc?spool=5')).toBeNull();
+    expect(parseSpoolQr('web+spoolman:s-12x')).toBeNull();
+    expect(parseSpoolQr('web+spoolman:s-0')).toBeNull();
+    expect(parseSpoolQr('web+spoolman:f-3')).toBeNull(); // a filament, not a spool
+    expect(parseSpoolQr('web+spoolman:spool=5')).toBeNull();
+    expect(parseSpoolQr('https://spoolman.local/spool/show/abc?spool=5')).toBeNull();
+    expect(parseSpoolQr('https://spoolman.local/spool/show/?spool=5')).toBeNull();
+    expect(parseSpoolQr('https://spoolman.local/spool/show/12/edit')).toBeNull();
+  });
+
+  it('rejects non-spool / invalid payloads', () => {
+    expect(parseSpoolQr('')).toBeNull();
+    expect(parseSpoolQr('https://example.com/')).toBeNull();
+    expect(parseSpoolQr('https://host/inventory?spool=0')).toBeNull();
+    expect(parseSpoolQr('https://host/inventory?spool=-3')).toBeNull();
+    expect(parseSpoolQr('https://host/inventory?spool=1.5')).toBeNull();
+    expect(parseSpoolQr('https://host/inventory?spool=abc')).toBeNull();
+    expect(parseSpoolQr('https://host/inventory?spool=12abc')).toBeNull();
+    expect(parseSpoolQr('99999999999999999999')).toBeNull();
+    expect(parseSpoolQr('random text')).toBeNull();
+  });
+
+  it('does not match a substring like "spoolman=3"', () => {
+    expect(parseSpoolQr('https://host/x?spoolman=3')).toBeNull();
+  });
+});
+
+describe('scannedSpoolId', () => {
+  it('takes a Bambuddy label in either inventory', () => {
+    expect(scannedSpoolId({ source: 'bambuddy', id: 4 }, false)).toBe(4);
+    expect(scannedSpoolId({ source: 'bambuddy', id: 4 }, true)).toBe(4);
+  });
+
+  it('takes a Spoolman label only when the inventory is Spoolman', () => {
+    expect(scannedSpoolId({ source: 'spoolman', id: 4 }, true)).toBe(4);
+    expect(scannedSpoolId({ source: 'spoolman', id: 4 }, false)).toBeNull();
+  });
+});

+ 175 - 0
frontend/src/__tests__/utils/qrDecode.test.ts

@@ -0,0 +1,175 @@
+/**
+ * The scanner's decoding never throws for a frame or photo it can't read, and
+ * falls back to jsQR when the platform decoder is missing, can't be built or
+ * fails (#1574 review).
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+
+const jsQR = vi.hoisted(() => vi.fn());
+vi.mock('jsqr', () => ({ default: jsQR }));
+
+import { createFrameDecoder, createQrDetector, decodeCanvas, decodeImageBlob } from '../../utils/qrDecode';
+
+function readableContext() {
+  return {
+    drawImage: vi.fn(),
+    getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4) })),
+  };
+}
+
+function canvasWith(getContext: () => unknown): HTMLCanvasElement {
+  const canvas = document.createElement('canvas');
+  canvas.getContext = vi.fn(getContext) as unknown as HTMLCanvasElement['getContext'];
+  return canvas;
+}
+
+const video = { videoWidth: 640, videoHeight: 480 } as unknown as HTMLVideoElement;
+
+function stubDetector(detect: () => Promise<{ rawValue: string }[]>) {
+  const detectSpy = vi.fn(detect);
+  vi.stubGlobal(
+    'BarcodeDetector',
+    class {
+      detect = detectSpy;
+    },
+  );
+  return detectSpy;
+}
+
+function stubUnbuildableDetector() {
+  vi.stubGlobal(
+    'BarcodeDetector',
+    class {
+      constructor() {
+        throw new TypeError("Failed to construct 'BarcodeDetector': qr_code is not supported");
+      }
+    },
+  );
+}
+
+afterEach(() => {
+  vi.unstubAllGlobals();
+  vi.restoreAllMocks();
+  jsQR.mockReset();
+});
+
+describe('createQrDetector', () => {
+  it('is null where the platform has no BarcodeDetector', () => {
+    vi.stubGlobal('BarcodeDetector', undefined);
+    expect(createQrDetector()).toBeNull();
+  });
+
+  it('is null where the platform decoder cannot read QR codes', () => {
+    stubUnbuildableDetector();
+    expect(createQrDetector()).toBeNull();
+  });
+
+  it('asks the platform decoder for QR codes', () => {
+    const made: unknown[] = [];
+    vi.stubGlobal(
+      'BarcodeDetector',
+      class {
+        constructor(opts: unknown) {
+          made.push(opts);
+        }
+      },
+    );
+    expect(createQrDetector()).not.toBeNull();
+    expect(made).toEqual([{ formats: ['qr_code'] }]);
+  });
+});
+
+describe('decodeCanvas', () => {
+  it("returns jsQR's reading of the frame", () => {
+    jsQR.mockReturnValue({ data: 'spool=3' });
+    expect(decodeCanvas(canvasWith(readableContext), video, 4, 4, 'dontInvert')).toBe('spool=3');
+  });
+
+  it('is undefined, not a throw, when the canvas cannot be read', () => {
+    expect(decodeCanvas(canvasWith(() => { throw new Error('not implemented'); }), video, 4, 4, 'dontInvert')).toBeUndefined();
+    expect(decodeCanvas(canvasWith(() => null), video, 4, 4, 'dontInvert')).toBeUndefined();
+    const ctx = readableContext();
+    ctx.drawImage.mockImplementation(() => {
+      throw new DOMException('no frame yet', 'InvalidStateError');
+    });
+    expect(decodeCanvas(canvasWith(() => ctx), video, 4, 4, 'dontInvert')).toBeUndefined();
+  });
+
+  it('skips a frame with no size', () => {
+    const canvas = canvasWith(readableContext);
+    expect(decodeCanvas(canvas, video, 0, 0, 'dontInvert')).toBeUndefined();
+    expect(canvas.getContext).not.toHaveBeenCalled();
+  });
+});
+
+describe('createFrameDecoder', () => {
+  it("uses the platform decoder's reading", async () => {
+    stubDetector(async () => [{ rawValue: 'spool=1' }]);
+    const decode = createFrameDecoder();
+    expect(await decode(video, canvasWith(readableContext))).toBe('spool=1');
+    expect(jsQR).not.toHaveBeenCalled();
+  });
+
+  it('drops a platform decoder that rejects, for jsQR on that frame and the next', async () => {
+    const detect = stubDetector(async () => {
+      throw new DOMException('decode failed', 'OperationError');
+    });
+    jsQR.mockReturnValue({ data: 'spool=2' });
+    const decode = createFrameDecoder();
+    const canvas = canvasWith(readableContext);
+
+    expect(await decode(video, canvas)).toBe('spool=2');
+    expect(await decode(video, canvas)).toBe('spool=2');
+    expect(detect).toHaveBeenCalledTimes(1);
+  });
+
+  it('uses jsQR when the platform decoder cannot be built', async () => {
+    stubUnbuildableDetector();
+    jsQR.mockReturnValue({ data: 'spool=4' });
+    const decode = createFrameDecoder();
+    expect(await decode(video, canvasWith(readableContext))).toBe('spool=4');
+  });
+
+  it('resolves rather than rejects when nothing can read the frame', async () => {
+    stubDetector(async () => {
+      throw new Error('boom');
+    });
+    const decode = createFrameDecoder();
+    await expect(decode(video, canvasWith(() => { throw new Error('no canvas'); }))).resolves.toBeUndefined();
+  });
+});
+
+describe('decodeImageBlob', () => {
+  const photo = new Blob(['qr'], { type: 'image/jpeg' });
+
+  function stubBitmap() {
+    const bitmap = { width: 3000, height: 2000, close: vi.fn() };
+    vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(bitmap));
+    vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
+      () => readableContext() as unknown as RenderingContext,
+    );
+    return bitmap;
+  }
+
+  it('falls back to jsQR when the platform decoder cannot be built', async () => {
+    const bitmap = stubBitmap();
+    stubUnbuildableDetector();
+    jsQR.mockReturnValue({ data: 'spool=5' });
+
+    expect(await decodeImageBlob(photo)).toBe('spool=5');
+    // Downscaled to 1600px on the long side before jsQR reads it.
+    expect(jsQR).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 1600, 1067, { inversionAttempts: 'attemptBoth' });
+    expect(bitmap.close).toHaveBeenCalled();
+  });
+
+  it('falls back to jsQR when the platform decoder rejects the photo', async () => {
+    stubBitmap();
+    stubDetector(async () => {
+      throw new DOMException('decode failed', 'OperationError');
+    });
+    jsQR.mockReturnValue({ data: 'spool=6' });
+
+    expect(await decodeImageBlob(photo)).toBe('spool=6');
+  });
+});

+ 5 - 2
frontend/src/api/client.ts

@@ -7185,7 +7185,9 @@ export const api = {
     ),
     ),
   getAssignments: (printerId?: number) =>
   getAssignments: (printerId?: number) =>
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
-  assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
+  // move: the spool also leaves any other slot it is in, in the same
+  // transaction (#1574) -- for a scanned spool, the one in the user's hand.
+  assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number; move?: boolean }) =>
     request<SpoolAssignment>('/inventory/assignments', {
     request<SpoolAssignment>('/inventory/assignments', {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify(data),
       body: JSON.stringify(data),
@@ -7413,7 +7415,8 @@ export const api = {
       method: 'PATCH',
       method: 'PATCH',
       body: JSON.stringify({ weight_grams: weightGrams }),
       body: JSON.stringify({ weight_grams: weightGrams }),
     }),
     }),
-  assignSpoolmanSlot: (data: { spoolman_spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
+  // move: as assignSpool's (#1574).
+  assignSpoolmanSlot: (data: { spoolman_spool_id: number; printer_id: number; ams_id: number; tray_id: number; move?: boolean }) =>
     request<InventorySpool>('/spoolman/inventory/slot-assignments', {
     request<InventorySpool>('/spoolman/inventory/slot-assignments', {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify(data),
       body: JSON.stringify(data),

+ 204 - 186
frontend/src/components/AssignSpoolModal.tsx

@@ -1,14 +1,17 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { X, Loader2, Package, Search } from 'lucide-react';
+import { X, Loader2, Package, Search, QrCode, ArrowLeft } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
-import { ConfirmModal } from './ConfirmModal';
+import { MaterialMismatchConfirm } from './MaterialMismatchConfirm';
+import { QrSpoolScanner } from './QrSpoolScanner';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { findMaterialMismatch, normalizeValue, stripProfileQualifier, type MaterialMismatch } from '../utils/assignMismatch';
 import { exactSpoolIdQuery, filterSpoolsByQuery } from '../utils/inventorySearch';
 import { exactSpoolIdQuery, filterSpoolsByQuery } from '../utils/inventorySearch';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
+import { parseSpoolQr, scannedSpoolId } from '../utils/qrAssignTarget';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { useUiSettings } from '../hooks/useUiSettings';
 
 
 interface AssignSpoolModalProps {
 interface AssignSpoolModalProps {
@@ -23,10 +26,20 @@ interface AssignSpoolModalProps {
     profile?: string;
     profile?: string;
     color: string;
     color: string;
     location: string;
     location: string;
+    /**
+     * For a slot reporting no filament: getEmptySlotKind's answer. Only
+     * 'physical' (the firmware says no spool) reads as an empty slot; anything
+     * else may be a spool it can't identify (#2527).
+     */
+    emptyKind?: 'physical' | 'reset' | null;
   };
   };
   spoolmanEnabled?: boolean;
   spoolmanEnabled?: boolean;
 }
 }
 
 
+function spoolSummary(spool: InventorySpool): string {
+  return [spool.brand, spool.material, spool.subtype].filter(Boolean).join(' ');
+}
+
 export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) {
 export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, trayInfo, spoolmanEnabled }: AssignSpoolModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -39,27 +52,22 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     setSelectedSpoolmanSpoolId(null);
     setSelectedSpoolmanSpoolId(null);
   }, [disableFiltering]);
   }, [disableFiltering]);
   const [searchFilter, setSearchFilter] = useState('');
   const [searchFilter, setSearchFilter] = useState('');
-  const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
-  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
-  // Profile-only mismatch no longer triggers the popup — the backend's
-  // `apply_spool_to_slot_via_mqtt` pushes the spool's slicer profile to the
-  // AMS slot on every assign anyway, so warning the user about a profile
-  // delta then "fixing" it during the same action was friction without
-  // benefit (#1552). Material mismatch still warns because the firmware can
-  // refuse a print when type doesn't match; combined material+profile
-  // mismatches keep the profile detail in the same popup as the material
-  // warning.
-  const [mismatchDetails, setMismatchDetails] = useState<{
-    type: 'material' | 'partial' | 'material_profile' | 'partial_profile';
-    spoolMaterial: string;
-    trayMaterial: string;
-    spoolProfile?: string;
-    trayProfile?: string;
-  } | null>(null);
+  // A pick waiting on the material-mismatch confirm, with what
+  // findMaterialMismatch found. `move` for a scanned spool (#1574): the one in
+  // the user's hand leaves any other slot it is in.
+  const [mismatch, setMismatch] = useState<{ spoolId: number; move: boolean; details: MaterialMismatch } | null>(null);
+  // Scanning a spool's QR instead of picking it (#1574), into this same slot.
+  const [scanning, setScanning] = useState(false);
+  const [scanError, setScanError] = useState<string | null>(null);
+  // Synchronous in-flight guard: a mutation's isPending only updates next render.
+  const scanInFlightRef = useRef(false);
 
 
   useEffect(() => {
   useEffect(() => {
     if (isOpen) {
     if (isOpen) {
       setDisableFiltering(false);
       setDisableFiltering(false);
+      setScanning(false);
+      setScanError(null);
+      scanInFlightRef.current = false;
     }
     }
   }, [isOpen]);
   }, [isOpen]);
 
 
@@ -76,7 +84,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   // explicitly excluded client-side because the backend rejects archived
   // explicitly excluded client-side because the backend rejects archived
   // assignments with HTTP 400 anyway, so listing them would only let the
   // assignments with HTTP 400 anyway, so listing them would only let the
   // user click a button that fails.
   // user click a button that fails.
-  const { data: spools, isLoading } = useQuery({
+  const { data: spools, isLoading, isFetching: spoolsFetching } = useQuery({
     queryKey: ['inventory-spools', 'assign-modal'],
     queryKey: ['inventory-spools', 'assign-modal'],
     queryFn: () => api.getSpools(true),
     queryFn: () => api.getSpools(true),
     enabled: isOpen && !spoolmanEnabled,
     enabled: isOpen && !spoolmanEnabled,
@@ -135,13 +143,15 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   };
   };
 
 
   const assignMutation = useMutation({
   const assignMutation = useMutation({
-    mutationFn: (spoolId: number) =>
-      api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
-    onSuccess: (newAssignment) => {
+    mutationFn: ({ spoolId, move }: { spoolId: number; move?: boolean }) =>
+      api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId, ...(move && { move }) }),
+    onSuccess: (newAssignment, { move }) => {
       // Immediately update cache so UI reflects the new assignment without waiting for refetch
       // Immediately update cache so UI reflects the new assignment without waiting for refetch
       queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
       queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
         const filtered = (old || []).filter(a =>
         const filtered = (old || []).filter(a =>
-          !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
+          !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId) &&
+          // A move took the spool out of its other slots as well.
+          !(move && a.spool_id === newAssignment.spool_id)
         );
         );
         filtered.push(newAssignment);
         filtered.push(newAssignment);
         return filtered;
         return filtered;
@@ -161,76 +171,47 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
         ? 'inventory.assignPendingInsert'
         ? 'inventory.assignPendingInsert'
         : 'inventory.assignSuccess';
         : 'inventory.assignSuccess';
       showToast(t(toastKey), 'success');
       showToast(t(toastKey), 'success');
-      setShowMismatchConfirm(false);
-      setPendingAssignId(null);
-      setMismatchDetails(null);
+      setMismatch(null);
       onClose();
       onClose();
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
       showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
       showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
+      scanInFlightRef.current = false; // a scan can try again
     },
     },
   });
   });
 
 
   const assignSpoolmanMutation = useMutation({
   const assignSpoolmanMutation = useMutation({
-    mutationFn: (spoolmanSpoolId: number) =>
+    mutationFn: ({ spoolmanSpoolId, move }: { spoolmanSpoolId: number; move?: boolean }) =>
       api.assignSpoolmanSlot({
       api.assignSpoolmanSlot({
         spoolman_spool_id: spoolmanSpoolId,
         spoolman_spool_id: spoolmanSpoolId,
         printer_id: printerId,
         printer_id: printerId,
         ams_id: amsId,
         ams_id: amsId,
         tray_id: trayId,
         tray_id: trayId,
+        ...(move && { move }),
       }),
       }),
-    onSuccess: () => {
+    onSuccess: (_data, { move }) => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       // Prefix match: also the all-printers list this dialog filters
       // Prefix match: also the all-printers list this dialog filters
       // "assigned elsewhere" by.
       // "assigned elsewhere" by.
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+      // A move clears the fallback tag linking the spool to its old slot.
+      if (move) queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
       nudgePrinterRepublish();
       nudgePrinterRepublish();
       showToast(t('inventory.assignSuccess'), 'success');
       showToast(t('inventory.assignSuccess'), 'success');
       onClose();
       onClose();
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
-      showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
+      const message = `${t('inventory.assignFailed')}: ${error.message}`;
+      // A scan says why in the scanner: a toast sits under this dialog's
+      // backdrop, the SpoolBuddy kiosk shows none, and the scanner doesn't send
+      // a label again while it stays in view. (A built-in assign's error shows
+      // below the dialog's buttons.)
+      if (scanInFlightRef.current) setScanError(message);
+      else showToast(message, 'error');
+      scanInFlightRef.current = false; // a scan can try again
     },
     },
   });
   });
 
 
-  // --- Material/profile mismatch logic ---
-  const normalizeValue = (value: string | undefined | null) =>
-    (value ?? '').trim().toUpperCase();
-
-  const checkMaterialMatch = (
-    spoolMaterial: string | undefined | null,
-    trayMaterial: string | undefined | null
-  ): 'exact' | 'partial' | 'none' => {
-    const normalizedSpool = normalizeValue(spoolMaterial);
-    const normalizedTray = normalizeValue(trayMaterial);
-
-    if (!normalizedSpool || !normalizedTray) return 'none';
-    if (normalizedSpool === normalizedTray) return 'exact';
-    if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
-      return 'partial';
-    }
-
-    return 'none';
-  };
-
-  // Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after
-  // `@` (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"), while the tray's
-  // profile is typically the bare base name. Strip the qualifier before comparing so identical
-  // base profiles don't trigger a mismatch warning (#1047).
-  const stripProfileQualifier = (value: string) => value.split('@')[0].trim();
-
-  const checkProfileMatch = (
-    spoolProfile: string | undefined | null,
-    trayProfile: string | undefined | null
-  ): boolean => {
-    const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile));
-    const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile));
-
-    if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
-
-    return normalizedSpoolProfile === normalizedTrayProfile;
-  };
-
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
   // Filter out spools already assigned to other slots
   // Filter out spools already assigned to other slots
@@ -309,9 +290,20 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
         : t('inventory.spoolIdInOtherSlot', { id: exactSpoolId });
         : t('inventory.spoolIdInOtherSlot', { id: exactSpoolId });
   }
   }
 
 
+  // Assign a built-in spool, asking first when its material doesn't match the
+  // slot's. Shared by the picker and the QR scan.
+  const requestAssign = (spool: InventorySpool, move: boolean) => {
+    const details = !settings?.disable_filament_warnings && trayInfo ? findMaterialMismatch(spool, trayInfo) : null;
+    if (details) {
+      setMismatch({ spoolId: spool.id, move, details });
+      return;
+    }
+    assignMutation.mutate({ spoolId: spool.id, move });
+  };
+
   const handleAssign = () => {
   const handleAssign = () => {
     if (selectedSpoolmanSpoolId !== null) {
     if (selectedSpoolmanSpoolId !== null) {
-      assignSpoolmanMutation.mutate(selectedSpoolmanSpoolId);
+      assignSpoolmanMutation.mutate({ spoolmanSpoolId: selectedSpoolmanSpoolId });
       return;
       return;
     }
     }
     if (!selectedSpoolId) return;
     if (!selectedSpoolId) return;
@@ -320,51 +312,81 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       showToast(t('inventory.assignFailed'), 'error');
       showToast(t('inventory.assignFailed'), 'error');
       return;
       return;
     }
     }
+    requestAssign(selectedSpool, false);
+  };
 
 
-    if (!settings?.disable_filament_warnings && trayInfo) {
-      const trayMaterial = trayInfo.material || trayInfo.type;
-      const materialMatchResult = checkMaterialMatch(selectedSpool.material, trayMaterial);
-      const spoolProfile = selectedSpool.slicer_filament_name || selectedSpool.slicer_filament;
-      const trayProfile = trayInfo.profile || trayInfo.type;
-      const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
-
-      // Only material-bearing mismatches warn — profile-only deltas are
-      // silently resolved by the backend's AMS reconfigure on every assign
-      // (#1552).
-      if (materialMatchResult !== 'exact') {
-        let mismatchType: 'material' | 'partial' | 'material_profile' | 'partial_profile';
-        if (materialMatchResult === 'none' && !profileMatches) {
-          mismatchType = 'material_profile';
-        } else if (materialMatchResult === 'partial' && !profileMatches) {
-          mismatchType = 'partial_profile';
-        } else if (materialMatchResult === 'none') {
-          mismatchType = 'material';
-        } else {
-          mismatchType = 'partial';
-        }
+  const handleConfirmMismatch = () => {
+    if (!mismatch) return;
+    assignMutation.mutate({ spoolId: mismatch.spoolId, move: mismatch.move });
+    setMismatch(null);
+  };
+
+  const cancelMismatch = () => {
+    setMismatch(null);
+    scanInFlightRef.current = false; // back to scanning, if that's where it came from
+  };
 
 
-        setPendingAssignId(selectedSpoolId);
-        setMismatchDetails({
-          type: mismatchType,
-          spoolMaterial: selectedSpool.material || '',
-          trayMaterial: trayMaterial || '',
-          spoolProfile: spoolProfile || undefined,
-          trayProfile: trayProfile || undefined,
-        });
-        setShowMismatchConfirm(true);
-        return;
-      }
+  // A scanned label (#1574) goes to this slot — the one the dialog was opened
+  // for, so there is no target to pick. The spool is the one in the user's
+  // hand, so it moves here from any other slot; the picker's "assigned
+  // elsewhere" filter doesn't apply. The dialog closes when the assign succeeds.
+  const handleScan = (text: string) => {
+    if (scanInFlightRef.current || assignMutation.isPending || assignSpoolmanMutation.isPending) return;
+    const scanned = parseSpoolQr(text);
+    if (!scanned) {
+      setScanError(t('inventory.qrAssign.invalidQr'));
+      return;
+    }
+    const id = scannedSpoolId(scanned, !!spoolmanEnabled);
+    if (id === null) {
+      setScanError(t('inventory.qrAssign.spoolmanLabel'));
+      return;
     }
     }
-    assignMutation.mutate(selectedSpoolId);
+    if (spoolmanEnabled) {
+      // Spoolman's spool list here leaves archived spools out, so the backend,
+      // which looks the spool up itself, is the one to refuse a missing or
+      // archived spool.
+      setScanError(null);
+      scanInFlightRef.current = true;
+      assignSpoolmanMutation.mutate({ spoolmanSpoolId: id, move: true });
+      return;
+    }
+    const spool = spools?.find((s: InventorySpool) => s.id === id);
+    if (!spool) {
+      setScanError(t('inventory.spoolIdNotFound', { id }));
+      return;
+    }
+    if (spool.archived_at) {
+      setScanError(t('inventory.spoolIdArchived', { id }));
+      return;
+    }
+    setScanError(null);
+    scanInFlightRef.current = true;
+    requestAssign(spool, true);
   };
   };
 
 
-  const handleConfirmMismatch = () => {
-    if (!pendingAssignId) return;
-    assignMutation.mutate(pendingAssignId);
-    setShowMismatchConfirm(false);
-    setPendingAssignId(null);
+  const stopScanning = () => {
+    setScanning(false);
+    setScanError(null);
+    scanInFlightRef.current = false;
   };
   };
 
 
+  const isAssigning = assignMutation.isPending || assignSpoolmanMutation.isPending;
+
+  // What the slot holds now, for the context box: the spool assigned to it,
+  // else what the printer reports there.
+  const currentSpool: { id: number; spool?: InventorySpool } | null = (() => {
+    if (spoolmanEnabled) {
+      const row = allSpoolmanAssignments?.find(a => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId);
+      return row ? { id: row.spoolman_spool_id, spool: spoolmanSpools?.find(s => s.id === row.spoolman_spool_id) } : null;
+    }
+    const row = assignments?.find(a => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId);
+    return row ? { id: row.spool_id, spool: row.spool ?? spools?.find(s => s.id === row.spool_id) } : null;
+  })();
+  // No filament reported is an empty slot only when the firmware says so; a
+  // spool it can't identify reports none too (#2527).
+  const physicalLabel = trayInfo?.type || (trayInfo?.emptyKind === 'physical' ? t('ams.emptySlot') : t('ams.emptySlotReset'));
+
   return (
   return (
     <>
     <>
       <div className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center p-4 overflow-y-auto">
       <div className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center p-4 overflow-y-auto">
@@ -377,8 +399,10 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
         {/* Header */}
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
-            <Package className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">{t('inventory.assignSpool')}</h2>
+            {scanning ? <QrCode className="w-5 h-5 text-bambu-green" /> : <Package className="w-5 h-5 text-bambu-green" />}
+            <h2 className="text-lg font-semibold text-white">
+              {scanning ? t('inventory.qrAssign.scanTitle') : t('inventory.assignSpool')}
+            </h2>
           </div>
           </div>
           <button
           <button
             onClick={onClose}
             onClick={onClose}
@@ -390,7 +414,8 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
 
 
         {/* Content */}
         {/* Content */}
         <div className="p-4 space-y-4 overflow-y-auto">
         <div className="p-4 space-y-4 overflow-y-auto">
-          {/* Tray info */}
+          {/* Tray info: the slot being filled, what the printer reports in it,
+              and the spool assigned to it now. */}
           {trayInfo && (
           {trayInfo && (
             <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
             <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
               <p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
               <p className="text-xs text-bambu-gray mb-1">{t('inventory.selectSpool')}:</p>
@@ -401,22 +426,55 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                     style={getSwatchStyle(trayInfo.color)}
                     style={getSwatchStyle(trayInfo.color)}
                   />
                   />
                 )}
                 )}
-                <span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
+                <span className="text-white font-medium">{physicalLabel}</span>
                 <span className="text-bambu-gray">({trayInfo.location})</span>
                 <span className="text-bambu-gray">({trayInfo.location})</span>
               </div>
               </div>
+              {currentSpool && (
+                <p className="mt-1 text-xs text-bambu-gray truncate">
+                  {t('inventory.assigned')}:{' '}
+                  <span className="text-white">
+                    #{currentSpool.id}
+                    {currentSpool.spool ? ` ${spoolSummary(currentSpool.spool)}` : ''}
+                  </span>
+                </p>
+              )}
             </div>
             </div>
           )}
           )}
 
 
-          {/* Search filter */}
-          <div className="relative">
-            <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
-            <input
-              type="text"
-              value={searchFilter}
-              onChange={(e) => setSearchFilter(e.target.value)}
-              placeholder={t('inventory.searchSpools')}
-              className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
+          {scanning ? (
+            <QrSpoolScanner
+              onDecode={handleScan}
+              // A built-in scan is looked up in the spool list, so it waits
+              // while the list loads or refreshes: a spool missing from an old
+              // list would be turned down, and the scanner doesn't send a label
+              // again while it stays in view.
+              busy={isAssigning || mismatch !== null || spoolsFetching}
+              error={scanError}
             />
             />
+          ) : (
+          <>
+          {/* Search filter, and scanning the spool's QR instead (#1574) */}
+          <div className="flex flex-wrap gap-2">
+            <div className="relative flex-1 min-w-[12rem]">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+              <input
+                type="text"
+                value={searchFilter}
+                onChange={(e) => setSearchFilter(e.target.value)}
+                placeholder={t('inventory.searchSpools')}
+                className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray focus:outline-none focus:border-bambu-green"
+              />
+            </div>
+            <Button
+              variant="secondary"
+              onClick={() => {
+                setScanError(null);
+                setScanning(true);
+              }}
+            >
+              <QrCode className="w-4 h-4" />
+              <span className="whitespace-nowrap">{t('inventory.qrAssign.scanTitle')}</span>
+            </Button>
           </div>
           </div>
 
 
           {/* Spool list */}
           {/* Spool list */}
@@ -562,9 +620,21 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
               </>
               </>
             )}
             )}
           </div>
           </div>
+          </>
+          )}
         </div>
         </div>
 
 
-        {/* Footer with filtering toggle */}
+        {/* Footer: Back returns from the scan to the picker; the dialog itself
+            closes once an assign succeeds. */}
+        {scanning ? (
+          <div className="flex justify-start p-4 border-t border-bambu-dark-tertiary">
+            <Button variant="secondary" onClick={stopScanning} disabled={isAssigning}>
+              <ArrowLeft className="w-4 h-4" />
+              {t('inventory.qrAssign.back')}
+            </Button>
+          </div>
+        ) : (
+        /* Footer with filtering toggle */
         <div className="flex justify-between items-center p-4 border-t border-bambu-dark-tertiary">
         <div className="flex justify-between items-center p-4 border-t border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
           <div className="flex items-center gap-2">
             <input
             <input
@@ -600,6 +670,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
             </Button>
             </Button>
           </div>
           </div>
         </div>
         </div>
+        )}
 
 
 
 
         {assignMutation.isError && (
         {assignMutation.isError && (
@@ -611,70 +682,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       </div>
       </div>
       </div>
       </div>
 
 
-      {showMismatchConfirm && trayInfo && selectedSpoolId && mismatchDetails && (() => {
-        let message = '';
-
-        if (mismatchDetails.type === 'material') {
-          message = t('inventory.assignMismatchMessage', {
-            spoolMaterial: mismatchDetails.spoolMaterial,
-            trayMaterial: mismatchDetails.trayMaterial,
-            location: trayInfo.location,
-          });
-        } else if (mismatchDetails.type === 'partial') {
-          message = t('inventory.assignPartialMismatchMessage', {
-            spoolMaterial: mismatchDetails.spoolMaterial,
-            trayMaterial: mismatchDetails.trayMaterial,
-            location: trayInfo.location,
-          });
-        } else if (mismatchDetails.type === 'material_profile') {
-          message = `${t('inventory.assignMismatchMessage', {
-            spoolMaterial: mismatchDetails.spoolMaterial,
-            trayMaterial: mismatchDetails.trayMaterial,
-            location: trayInfo.location,
-          })}\n\n${t('inventory.assignProfileMismatchMessage', {
-            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
-            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
-            location: trayInfo.location,
-          })}`;
-        } else if (mismatchDetails.type === 'partial_profile') {
-          message = `${t('inventory.assignPartialMismatchMessage', {
-            spoolMaterial: mismatchDetails.spoolMaterial,
-            trayMaterial: mismatchDetails.trayMaterial,
-            location: trayInfo.location,
-          })}\n\n${t('inventory.assignProfileMismatchMessage', {
-            spoolProfile: mismatchDetails.spoolProfile || t('common.unknown'),
-            trayProfile: mismatchDetails.trayProfile || t('common.unknown'),
-            location: trayInfo.location,
-          })}`;
-        }
-
-        // Always tell the user the AMS slot is going to be reconfigured —
-        // the existing wording made "Assign Anyway" sound like the popup was
-        // a no-op confirmation, when the backend in fact pushes the spool's
-        // profile to the slot on every assign (#1552).
-        message = `${message}\n\n${t('inventory.assignReconfigureNote')}`;
-
-        return (
-          <ConfirmModal
-            title={t('inventory.assignMismatchTitle')}
-            message={message}
-            confirmText={t('inventory.assignMismatchConfirm')}
-            variant="warning"
-            // Sit above the AssignSpoolModal wrapper (z-[100], #1336) —
-            // without this the mismatch dialog is hidden behind its parent.
-            overlayZIndex="z-[110]"
-            isLoading={assignMutation.isPending}
-            onConfirm={handleConfirmMismatch}
-            onCancel={() => {
-              if (!assignMutation.isPending) {
-                setShowMismatchConfirm(false);
-                setPendingAssignId(null);
-                setMismatchDetails(null);
-              }
-            }}
-          />
-        );
-      })()}
+      {mismatch && trayInfo && (
+        <MaterialMismatchConfirm
+          mismatch={mismatch.details}
+          location={trayInfo.location}
+          isLoading={assignMutation.isPending}
+          onConfirm={handleConfirmMismatch}
+          onCancel={() => {
+            if (!assignMutation.isPending) cancelMismatch();
+          }}
+        />
+      )}
     </>
     </>
   );
   );
 }
 }

+ 38 - 0
frontend/src/components/MaterialMismatchConfirm.tsx

@@ -0,0 +1,38 @@
+import { useTranslation } from 'react-i18next';
+import { ConfirmModal } from './ConfirmModal';
+import { materialMismatchMessage, type MaterialMismatch } from '../utils/assignMismatch';
+
+/**
+ * The question asked before a spool goes into a slot of another material, as
+ * the Assign Spool dialog and the QR assignment ask it.
+ */
+export function MaterialMismatchConfirm({
+  mismatch,
+  location,
+  isLoading,
+  onConfirm,
+  onCancel,
+}: {
+  mismatch: MaterialMismatch;
+  /** The slot, as the question names it. */
+  location: string;
+  isLoading?: boolean;
+  onConfirm: () => void;
+  onCancel: () => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <ConfirmModal
+      title={t('inventory.assignMismatchTitle')}
+      message={materialMismatchMessage(t, mismatch, location)}
+      confirmText={t('inventory.assignMismatchConfirm')}
+      variant="warning"
+      // Above the assign dialogs' own z-[100] layer (#1336) — without this the
+      // question is hidden behind the dialog that asks it.
+      overlayZIndex="z-[110]"
+      isLoading={isLoading}
+      onConfirm={onConfirm}
+      onCancel={onCancel}
+    />
+  );
+}

+ 561 - 0
frontend/src/components/QrAssignTargetModal.tsx

@@ -0,0 +1,561 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Loader2, QrCode, Cpu, MapPin, ArrowLeft } from 'lucide-react';
+import { api, ApiError, type AMSTray } from '../api/client';
+import { Button } from './Button';
+import { MaterialMismatchConfirm } from './MaterialMismatchConfirm';
+import { QrSpoolScanner } from './QrSpoolScanner';
+import { useToast } from '../contexts/ToastContext';
+import { useUiSettings } from '../hooks/useUiSettings';
+import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
+import { findMaterialMismatch, type MaterialMismatch } from '../utils/assignMismatch';
+import { getSwatchStyle } from '../utils/colors';
+import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries';
+import { parseSpoolQr, scannedSpoolId, type AssignTarget } from '../utils/qrAssignTarget';
+
+type AmsTarget = Extract<AssignTarget, { kind: 'ams' }>;
+
+interface QrAssignTargetModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  spoolmanMode?: boolean;
+}
+
+type SlotOption = {
+  amsId: number;
+  trayId: number;
+  isHt: boolean;
+  isExternal: boolean;
+  label: string;
+  /** What the printer reports in the slot, for its tile. */
+  tray?: AMSTray;
+};
+
+/**
+ * What a slot's tile says is in it, by the printer card's rules
+ * (getEmptySlotKind): filament it reports, "?" for a spool it can't identify,
+ * and Empty only where it says no spool is there. A missing tray_type alone is
+ * not empty — a spool without RFID reads that way (#2527).
+ */
+function SlotContents({ tray }: { tray?: AMSTray }) {
+  const { t } = useTranslation();
+  const kind = getEmptySlotKind(tray);
+  if (kind === 'physical') return <span className="text-[10px] font-normal text-bambu-gray">{t('ams.slotEmpty')}</span>;
+  if (kind === 'reset' || !tray) return <span className="text-xs font-normal text-bambu-gray">{t('ams.slotUnconfigured')}</span>;
+  return (
+    <span className="flex flex-col items-center gap-0.5 max-w-full">
+      <span className="flex items-center gap-1 max-w-full">
+        {tray.tray_color && (
+          <span className="w-2.5 h-2.5 shrink-0 rounded-full border border-bambu-gray/50" style={getSwatchStyle(tray.tray_color)} />
+        )}
+        <span className="text-[10px] font-normal text-white truncate">{tray.tray_type}</span>
+      </span>
+      {/* Spaces keep the button's accessible name readable ("A1 PLA 80%"). */}
+      {tray.remain >= 0 && <> <span className="text-[10px] font-normal text-bambu-gray">{tray.remain}%</span></>}
+    </span>
+  );
+}
+
+const TABS = [
+  { key: 'ams' as const, Icon: Cpu, labelKey: 'inventory.qrAssign.tabAms' },
+  { key: 'storage' as const, Icon: MapPin, labelKey: 'inventory.qrAssign.tabStorage' },
+];
+
+function targetLocationLabel(target: AssignTarget): string {
+  return target.kind === 'ams'
+    ? target.printerName
+      ? `${target.printerName} · ${target.label}`
+      : target.label
+    : target.locationName;
+}
+
+function externalLabel(t: (k: string) => string, vtCount: number, trayId: number): string {
+  // Match PrintersPage: Ext-L / Ext-R when two external slots, "External" otherwise.
+  if (vtCount > 1) return t(trayId === 0 ? 'printers.extL' : 'printers.extR');
+  return t('printers.external');
+}
+
+export function QrAssignTargetModal({ isOpen, onClose, spoolmanMode = false }: QrAssignTargetModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const [step, setStep] = useState<'target' | 'scan'>('target');
+  const [tab, setTab] = useState<'ams' | 'storage'>('ams');
+  const [pickedPrinterId, setPickedPrinterId] = useState<number | null>(null);
+  const [selectedSlot, setSelectedSlot] = useState<SlotOption | null>(null);
+  // Name is kept next to the id so the target survives a locations refetch that
+  // hasn't landed yet (notably right after creating one inline).
+  const [pickedLocation, setPickedLocation] = useState<{ id: number; name: string } | null>(null);
+  const [newLocationName, setNewLocationName] = useState('');
+  const [creatingLocation, setCreatingLocation] = useState(false);
+  const [target, setTarget] = useState<AssignTarget | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  // Looking the scanned spool up before an AMS assign, and a material mismatch
+  // waiting on the confirm — as in the Assign Spool dialog.
+  const [checking, setChecking] = useState(false);
+  const [mismatch, setMismatch] = useState<{ spoolId: number; tg: AmsTarget; details: MaterialMismatch } | null>(null);
+  // Synchronous in-flight guard: mutation.isPending only updates next render.
+  const inFlightRef = useRef(false);
+  // Counts the dialog's closings and openings: a spool lookup still running
+  // when the dialog closed (Cancel, X, the backdrop) must not go on to assign
+  // the spool.
+  const openingRef = useRef(0);
+
+  useEffect(() => {
+    if (isOpen) {
+      setStep('target');
+      setSelectedSlot(null);
+      setPickedLocation(null);
+      setNewLocationName('');
+      setCreatingLocation(false);
+      setTarget(null);
+      setError(null);
+      setChecking(false);
+      setMismatch(null);
+      inFlightRef.current = false;
+    }
+    return () => {
+      openingRef.current += 1;
+    };
+  }, [isOpen]);
+
+  const { data: settings } = useUiSettings({ enabled: isOpen });
+
+  const { data: printers, isLoading: printersLoading } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+    enabled: isOpen,
+  });
+
+  // Structured Locations are the canonical storage model, so the storage tab is
+  // a picker over the same list the inventory Locations filter and LocationsModal
+  // use — a QR assignment lands in `location_id`, not the legacy free-text field.
+  const { data: locations = [], isLoading: locationsLoading } = useQuery({
+    queryKey: inventoryLocationsQueryKey,
+    queryFn: api.getLocations,
+    enabled: isOpen && step === 'target',
+  });
+
+  const effectivePrinterId = pickedPrinterId ?? printers?.[0]?.id ?? null;
+
+  const { data: status, isLoading: statusLoading } = useQuery({
+    queryKey: ['printerStatus', effectivePrinterId],
+    queryFn: () => api.getPrinterStatus(effectivePrinterId!),
+    enabled: isOpen && step === 'target' && tab === 'ams' && effectivePrinterId !== null,
+  });
+
+  const slots = useMemo<SlotOption[]>(() => {
+    if (!status) return [];
+    const out: SlotOption[] = [];
+    for (const ams of status.ams ?? []) {
+      for (const tray of ams.tray ?? []) {
+        out.push({
+          amsId: ams.id,
+          trayId: tray.id,
+          isHt: ams.is_ams_ht,
+          isExternal: false,
+          label: formatSlotLabel(ams.id, tray.id, ams.is_ams_ht, false),
+          tray,
+        });
+      }
+    }
+    // External (vt_tray) slots: backend keys them under ams_id 255 with
+    // tray_id = id - 254 (mirrors PrintersPage).
+    const externals = status.vt_tray ?? [];
+    for (const vt of externals) {
+      const trayId = (vt.id ?? 254) - 254;
+      out.push({ amsId: 255, trayId, isHt: false, isExternal: true, label: externalLabel(t, externals.length, trayId), tray: vt });
+    }
+    return out;
+  }, [status, t]);
+
+  const assignMutation = useMutation({
+    mutationFn: async ({ spoolId, tg }: { spoolId: number; tg: AssignTarget }): Promise<{ pendingConfig: boolean }> => {
+      if (tg.kind === 'ams') {
+        // A spool lives in one slot at a time. `move` has the backend take it
+        // out of its other slot(s) in the same transaction as the assign, so a
+        // failure leaves it where it was rather than in no slot.
+        if (spoolmanMode) {
+          await api.assignSpoolmanSlot({ spoolman_spool_id: spoolId, printer_id: tg.printerId, ams_id: tg.amsId, tray_id: tg.trayId, move: true });
+          return { pendingConfig: false };
+        }
+        const assignment = await api.assignSpool({ spool_id: spoolId, printer_id: tg.printerId, ams_id: tg.amsId, tray_id: tg.trayId, move: true });
+        return { pendingConfig: !!assignment.pending_config };
+      }
+      if (spoolmanMode) {
+        await api.updateSpoolmanInventorySpool(spoolId, { location_id: tg.locationId });
+      } else {
+        await api.updateSpool(spoolId, { location_id: tg.locationId });
+      }
+      return { pendingConfig: false };
+    },
+    onSuccess: ({ pendingConfig }, { tg }) => {
+      // Intentionally do NOT clear inFlightRef here: the modal closes right
+      // after, and leaving it set blocks any stale in-flight live-scan decode
+      // from firing a second assignment in the brief window before unmount.
+      // It's reset wholesale when the modal next opens (and in onError for retry).
+      //
+      // A slot that read empty at assign time is configured when the spool goes
+      // in (#1680); "Assigned" would read as if the slot were set up already.
+      showToast(
+        pendingConfig
+          ? t('inventory.assignPendingInsert')
+          : t(tg.kind === 'ams' ? 'inventory.qrAssign.assignSuccess' : 'inventory.qrAssign.storageSuccess', { location: targetLocationLabel(tg) }),
+        'success',
+      );
+      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      queryClient.invalidateQueries({ queryKey: spoolmanMode ? ['spoolman-inventory-spools'] : ['inventory-spools'] });
+      // Prefix match: the per-printer lists and the all-printers one.
+      queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+      if (tg.kind === 'ams') {
+        // #1414 parity: nudge the printer to republish so its card doesn't show
+        // stale tray state until the next poll. Best-effort.
+        api.refreshPrinterStatus(tg.printerId).catch(() => {});
+        queryClient.invalidateQueries({ queryKey: ['printerStatus', tg.printerId] });
+        // A Spoolman move clears the fallback tag linking the spool to its old slot.
+        if (spoolmanMode) queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
+      } else {
+        // The move changes per-location spool_count, which the Locations UI shows.
+        invalidateInventoryLocations(queryClient);
+      }
+      onClose();
+    },
+    onError: (error: Error, { tg }) => {
+      inFlightRef.current = false;
+      // Said in the dialog, which stays open: a toast sits under its backdrop,
+      // the SpoolBuddy kiosk shows none, and the scanner doesn't send a label
+      // again while it stays in view.
+      setError(`${t('inventory.qrAssign.assignFailed', { location: targetLocationLabel(tg) })}: ${error.message}`);
+    },
+  });
+
+  if (!isOpen) return null;
+
+  const printerName = printers?.find((p) => p.id === effectivePrinterId)?.name ?? '';
+  const canStart = tab === 'ams' ? selectedSlot !== null && effectivePrinterId !== null : pickedLocation !== null;
+  const isBusy = assignMutation.isPending || checking || mismatch !== null;
+
+  // A freshly created location may not be in the fetched list yet, so make sure
+  // the current pick is always offered.
+  const locationOptions =
+    pickedLocation && !locations.some((l) => l.id === pickedLocation.id)
+      ? [...locations, pickedLocation]
+      : locations;
+
+  // Create-and-pick, so the user doesn't have to leave the scan flow to add a
+  // shelf. Mirrors the spool form's inline location creation.
+  const handleCreateLocation = async () => {
+    const name = newLocationName.trim();
+    if (!name || creatingLocation) return;
+    setCreatingLocation(true);
+    try {
+      const created = await api.createLocation({ name });
+      invalidateInventoryLocations(queryClient);
+      setPickedLocation({ id: created.id, name: created.name });
+      setNewLocationName('');
+    } catch (err) {
+      showToast(err instanceof Error ? err.message : t('locations.saveFailed'), 'error');
+    } finally {
+      setCreatingLocation(false);
+    }
+  };
+
+  const handleStart = () => {
+    let tg: AssignTarget;
+    if (tab === 'ams') {
+      if (!selectedSlot || effectivePrinterId === null) return;
+      tg = {
+        kind: 'ams',
+        printerId: effectivePrinterId,
+        printerName,
+        amsId: selectedSlot.amsId,
+        trayId: selectedSlot.trayId,
+        isExternal: selectedSlot.isExternal,
+        label: selectedSlot.label,
+        filament: {
+          type: selectedSlot.tray?.tray_type || '',
+          profile: selectedSlot.tray?.tray_sub_brands || undefined,
+        },
+      };
+    } else {
+      if (!pickedLocation) return;
+      tg = { kind: 'storage', locationId: pickedLocation.id, locationName: pickedLocation.name };
+    }
+    setTarget(tg);
+    setError(null);
+    setStep('scan');
+  };
+
+  // A built-in spool going into an AMS slot is looked up first, as the Assign
+  // Spool dialog has it at hand: a missing or archived spool says so, and one
+  // whose material doesn't fit the slot asks before it is assigned. Spoolman
+  // spools go straight to the backend, which checks them, as in that dialog.
+  const checkAndAssign = async (spoolId: number, tg: AmsTarget) => {
+    const opening = openingRef.current;
+    setChecking(true);
+    try {
+      const spool = await api.getSpool(spoolId);
+      if (openingRef.current !== opening) return; // the dialog was closed meanwhile
+      if (spool.archived_at) {
+        setError(t('inventory.spoolIdArchived', { id: spoolId }));
+        inFlightRef.current = false;
+        return;
+      }
+      const details = settings?.disable_filament_warnings ? null : findMaterialMismatch(spool, tg.filament);
+      if (details) {
+        setMismatch({ spoolId, tg, details }); // still in flight until confirmed or cancelled
+        return;
+      }
+      assignMutation.mutate({ spoolId, tg });
+    } catch (err) {
+      if (openingRef.current !== opening) return;
+      setError(
+        err instanceof ApiError && err.status === 404
+          ? t('inventory.spoolIdNotFound', { id: spoolId })
+          : err instanceof Error
+            ? err.message
+            : t('inventory.assignFailed'),
+      );
+      inFlightRef.current = false;
+    } finally {
+      setChecking(false);
+    }
+  };
+
+  // Shared by the live scanner and the photo fallback: validate the decoded QR
+  // and fire the assignment (guarded so one scan can't assign twice).
+  const handleDecode = (text: string) => {
+    if (inFlightRef.current || isBusy || !target) return;
+    const scanned = parseSpoolQr(text);
+    if (!scanned) {
+      setError(t('inventory.qrAssign.invalidQr'));
+      return;
+    }
+    const id = scannedSpoolId(scanned, spoolmanMode);
+    if (id === null) {
+      setError(t('inventory.qrAssign.spoolmanLabel'));
+      return;
+    }
+    setError(null);
+    inFlightRef.current = true;
+    if (target.kind === 'ams' && !spoolmanMode) {
+      void checkAndAssign(id, target);
+      return;
+    }
+    assignMutation.mutate({ spoolId: id, tg: target });
+  };
+
+  const confirmMismatch = () => {
+    if (!mismatch) return;
+    setMismatch(null);
+    assignMutation.mutate({ spoolId: mismatch.spoolId, tg: mismatch.tg });
+  };
+
+  // The scanner keeps this label from coming straight back; another label, a
+  // photo or Retry starts over.
+  const cancelMismatch = () => {
+    setMismatch(null);
+    inFlightRef.current = false;
+  };
+
+  return (
+    <>
+    <div className="fixed inset-0 z-[100] flex items-start sm:items-center justify-center p-4 overflow-y-auto">
+      <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
+
+      <div className="relative w-full max-w-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] overflow-hidden flex flex-col my-auto">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <QrCode className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              {step === 'target' ? t('inventory.qrAssign.title') : t('inventory.qrAssign.scanTitle')}
+            </h2>
+          </div>
+          <button onClick={onClose} className="p-1 text-bambu-gray hover:text-white rounded transition-colors">
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4 overflow-y-auto">
+          {step === 'target' ? (
+            <>
+              <p className="text-sm text-bambu-gray">{t('inventory.qrAssign.description')}</p>
+
+              {/* Tabs */}
+              <div className="flex gap-2">
+                {TABS.map(({ key, Icon, labelKey }) => (
+                  <button
+                    key={key}
+                    onClick={() => setTab(key)}
+                    className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-medium border transition-colors ${
+                      tab === key
+                        ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
+                        : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
+                    }`}
+                  >
+                    <Icon className="w-4 h-4" />
+                    {t(labelKey)}
+                  </button>
+                ))}
+              </div>
+
+              {tab === 'ams' ? (
+                <div className="space-y-3">
+                  <div>
+                    <label htmlFor="qr-printer-select" className="block text-xs font-medium text-bambu-gray uppercase tracking-wide mb-1">
+                      {t('inventory.qrAssign.printer')}
+                    </label>
+                    <select
+                      id="qr-printer-select"
+                      value={effectivePrinterId ?? ''}
+                      onChange={(e) => {
+                        setPickedPrinterId(Number(e.target.value));
+                        setSelectedSlot(null);
+                      }}
+                      disabled={printersLoading || !printers?.length}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                    >
+                      {printers?.map((p) => (
+                        <option key={p.id} value={p.id}>
+                          {p.name}
+                        </option>
+                      ))}
+                    </select>
+                  </div>
+
+                  <div>
+                    <span className="block text-xs font-medium text-bambu-gray uppercase tracking-wide mb-1">
+                      {t('inventory.qrAssign.selectSlot')}
+                    </span>
+                    {statusLoading ? (
+                      <div className="flex justify-center py-6">
+                        <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+                      </div>
+                    ) : slots.length > 0 ? (
+                      <div className="grid grid-cols-4 gap-2">
+                        {slots.map((slot) => {
+                          const isSel = selectedSlot?.amsId === slot.amsId && selectedSlot?.trayId === slot.trayId;
+                          return (
+                            <button
+                              key={`${slot.amsId}-${slot.trayId}`}
+                              onClick={() => setSelectedSlot(slot)}
+                              className={`aspect-square min-w-0 px-1 flex flex-col items-center justify-center gap-1 rounded-lg border text-sm font-semibold transition-colors ${
+                                isSel
+                                  ? 'bg-bambu-green/20 border-bambu-green text-white'
+                                  : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
+                              }`}
+                            >
+                              <span>{slot.label}</span> <SlotContents tray={slot.tray} />
+                            </button>
+                          );
+                        })}
+                      </div>
+                    ) : (
+                      <p className="text-center py-6 text-sm text-bambu-gray">{t('inventory.qrAssign.noSlots')}</p>
+                    )}
+                  </div>
+                </div>
+              ) : (
+                <div>
+                  <label htmlFor="qr-storage-select" className="block text-xs font-medium text-bambu-gray uppercase tracking-wide mb-1">
+                    {t('inventory.qrAssign.storageLabel')}
+                  </label>
+                  {locationsLoading ? (
+                    <div className="flex justify-center py-6">
+                      <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+                    </div>
+                  ) : (
+                    <select
+                      id="qr-storage-select"
+                      value={pickedLocation?.id ?? ''}
+                      onChange={(e) => {
+                        const id = e.target.value ? Number(e.target.value) : null;
+                        const match = id === null ? null : locationOptions.find((l) => l.id === id);
+                        setPickedLocation(match ? { id: match.id, name: match.name } : null);
+                      }}
+                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                    >
+                      <option value="">{t('inventory.qrAssign.storagePick')}</option>
+                      {locationOptions.map((loc) => (
+                        <option key={loc.id} value={loc.id}>
+                          {loc.name}
+                        </option>
+                      ))}
+                    </select>
+                  )}
+
+                  {!locationsLoading && locationOptions.length === 0 && (
+                    <p className="mt-2 text-xs text-bambu-gray">{t('locations.empty')}</p>
+                  )}
+
+                  <div className="mt-2 flex gap-2">
+                    <input
+                      type="text"
+                      maxLength={255}
+                      value={newLocationName}
+                      onChange={(e) => setNewLocationName(e.target.value)}
+                      placeholder={t('locations.createPlaceholder')}
+                      className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+                    />
+                    <button
+                      type="button"
+                      onClick={handleCreateLocation}
+                      disabled={!newLocationName.trim() || creatingLocation}
+                      className="px-3 py-2 text-sm rounded-lg bg-bambu-dark-tertiary text-white hover:bg-bambu-gray-dark disabled:opacity-50"
+                    >
+                      {creatingLocation ? <Loader2 className="w-4 h-4 animate-spin" /> : t('locations.addShort')}
+                    </button>
+                  </div>
+                </div>
+              )}
+            </>
+          ) : (
+            <QrSpoolScanner
+              onDecode={handleDecode}
+              busy={isBusy}
+              error={error}
+              targetLabel={target ? targetLocationLabel(target) : undefined}
+            />
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-between gap-2 p-4 border-t border-bambu-dark-tertiary">
+          {step === 'scan' ? (
+            <Button variant="secondary" onClick={() => setStep('target')} disabled={isBusy}>
+              <ArrowLeft className="w-4 h-4" />
+              {t('inventory.qrAssign.back')}
+            </Button>
+          ) : (
+            <span />
+          )}
+          <div className="flex gap-2">
+            <Button variant="secondary" onClick={onClose}>
+              {t('common.cancel')}
+            </Button>
+            {step === 'target' && (
+              <Button onClick={handleStart} disabled={!canStart}>
+                <QrCode className="w-4 h-4" />
+                {t('inventory.qrAssign.startScan')}
+              </Button>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+
+    {mismatch && (
+      <MaterialMismatchConfirm
+        mismatch={mismatch.details}
+        location={targetLocationLabel(mismatch.tg)}
+        onConfirm={confirmMismatch}
+        onCancel={cancelMismatch}
+      />
+    )}
+    </>
+  );
+}

+ 281 - 0
frontend/src/components/QrSpoolScanner.tsx

@@ -0,0 +1,281 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader2, Camera, RotateCcw } from 'lucide-react';
+import { createFrameDecoder, decodeImageBlob } from '../utils/qrDecode';
+
+/** Why the live view is off: the page isn't a secure origin, or no camera could be opened. */
+type CameraIssue = 'insecure' | 'unavailable';
+
+/**
+ * Live frames in a row a label must go unread before it counts as taken out of
+ * view: frames are at least 200 ms apart, so over a second. A label in front of
+ * the camera is not read in every frame, and a missed frame or two mustn't send
+ * it again.
+ */
+const OUT_OF_VIEW_FRAMES = 6;
+
+/**
+ * Live in-page camera scanner (primary path). Continuously decodes the preview
+ * frame with BarcodeDetector (→ jsQR fallback) and reports each frame it read:
+ * the QR payload, or null for none. Fast for QRs the user can frame well; the
+ * photo-capture fallback covers small/hard ones this can't focus on. Callbacks
+ * go through refs so the camera starts once and survives re-renders.
+ */
+function LiveScanner({
+  onFrame,
+  onError,
+  paused,
+}: {
+  onFrame: (text: string | null) => void;
+  onError: (issue: CameraIssue) => void;
+  paused: boolean;
+}) {
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const onFrameRef = useRef(onFrame);
+  const onErrorRef = useRef(onError);
+  const pausedRef = useRef(paused);
+  onFrameRef.current = onFrame;
+  onErrorRef.current = onError;
+  pausedRef.current = paused;
+
+  useEffect(() => {
+    let cancelled = false;
+    let raf = 0;
+    let stream: MediaStream | null = null;
+    let busy = false;
+    let lastScan = 0;
+    const INTERVAL_MS = 200;
+    const canvas = document.createElement('canvas'); // reused across frames
+    const decodeFrame = createFrameDecoder();
+
+    const tick = () => {
+      if (cancelled) return;
+      raf = requestAnimationFrame(tick);
+      const video = videoRef.current;
+      const now = Date.now();
+      if (!video || pausedRef.current || busy || video.readyState < video.HAVE_ENOUGH_DATA) return;
+      if (now - lastScan < INTERVAL_MS) return;
+      lastScan = now;
+      busy = true;
+      void decodeFrame(video, canvas)
+        .then((value) => {
+          if (!cancelled) onFrameRef.current(value || null);
+        })
+        // Whatever happened to this frame, the next one gets decoded.
+        .catch(() => {})
+        .finally(() => {
+          busy = false;
+        });
+    };
+
+    const start = async () => {
+      if (!navigator.mediaDevices?.getUserMedia) {
+        // Browsers only offer the camera to a secure origin (HTTPS or localhost).
+        onErrorRef.current(window.isSecureContext ? 'unavailable' : 'insecure');
+        return;
+      }
+      try {
+        stream = await navigator.mediaDevices.getUserMedia({
+          video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } },
+          audio: false,
+        });
+        if (cancelled) {
+          stream.getTracks().forEach((tr) => tr.stop());
+          return;
+        }
+        try {
+          await stream.getVideoTracks()[0]?.applyConstraints({ advanced: [{ focusMode: 'continuous' }] as unknown as MediaTrackConstraintSet[] });
+        } catch {
+          /* unsupported */
+        }
+        const video = videoRef.current;
+        if (!video) return;
+        video.srcObject = stream;
+        await video.play().catch(() => {});
+        raf = requestAnimationFrame(tick);
+      } catch {
+        // Permission denied / no camera — the live view gives way to a hint;
+        // the photo-capture fallback covers both.
+        onErrorRef.current('unavailable');
+      }
+    };
+
+    void start();
+    return () => {
+      cancelled = true;
+      if (raf) cancelAnimationFrame(raf);
+      stream?.getTracks().forEach((tr) => tr.stop());
+    };
+  }, []);
+
+  return <video ref={videoRef} muted playsInline className="w-full aspect-square rounded-lg bg-black object-cover" />;
+}
+
+interface QrSpoolScannerProps {
+  /** A decoded QR payload, from the live view or a photo. The caller reads it and assigns. */
+  onDecode: (text: string) => void;
+  /** The caller is acting on a scan, or isn't ready for one: the live view pauses and both paths wait. */
+  busy: boolean;
+  /** The caller's message about the last scan, e.g. a QR that is no spool label. */
+  error: string | null;
+  /** Where the scanned spool will go, shown above the camera. */
+  targetLabel?: string;
+}
+
+/**
+ * Scan a spool's label QR (#1574): the live camera preview, and a native-camera
+ * photo for small or dense QRs the preview can't focus on. Photos are decoded in
+ * the browser and never uploaded. Shared by every dialog that assigns a scanned
+ * spool, so they scan the same way.
+ */
+export function QrSpoolScanner({ onDecode, busy, error, targetLabel }: QrSpoolScannerProps) {
+  const { t } = useTranslation();
+  const [cameraIssue, setCameraIssue] = useState<CameraIssue | null>(null);
+  const [decoding, setDecoding] = useState(false);
+  const [photoError, setPhotoError] = useState<string | null>(null);
+  const inputRef = useRef<HTMLInputElement>(null);
+  // The payloads handed over, each with the live frame it was last read in.
+  // The live view goes on reading a label in front of it, and the caller has
+  // had its say on that one: a scan it refused, or a confirm the user
+  // cancelled, isn't sent again frame after frame -- nor each time another
+  // code in view (the maker's QR, the label next to it) is read in between.
+  // Taken out of view a moment and shown again, photographed, or Retry, it is
+  // sent again. Frames only count while the live view runs, so the time the
+  // caller spends on a scan doesn't take a label out of view.
+  const handedOverRef = useRef(new Map<string, number>());
+  const frameRef = useRef(0);
+  const [holdingPayload, setHoldingPayload] = useState(false);
+  // A photo still decoding when the scanner closes (Back, Cancel) must not
+  // reach a caller that has moved on.
+  const mountedRef = useRef(true);
+  useEffect(() => {
+    mountedRef.current = true;
+    return () => {
+      mountedRef.current = false;
+    };
+  }, []);
+
+  // A photo taken while the caller was busy, waiting for it to be free. The
+  // native camera app is open a while, and the caller can turn busy meanwhile
+  // (a spool list refetching as the page gets focus back): the photo is
+  // handed over once it's free, not dropped.
+  const [waitingPhoto, setWaitingPhoto] = useState<string | null>(null);
+  const busyRef = useRef(busy);
+  busyRef.current = busy;
+
+  const isBusy = busy || decoding || waitingPhoto !== null;
+
+  const handOver = (text: string) => {
+    setPhotoError(null);
+    onDecode(text);
+    // Held only once the caller has it: had it thrown, the next frame offers
+    // the label again.
+    handedOverRef.current.set(text, frameRef.current);
+    setHoldingPayload(true);
+  };
+
+  const handleLiveFrame = (text: string | null) => {
+    const frame = ++frameRef.current;
+    if (!text) return;
+    const lastRead = handedOverRef.current.get(text);
+    if (lastRead !== undefined && frame - lastRead <= OUT_OF_VIEW_FRAMES) {
+      handedOverRef.current.set(text, frame); // still in front of the camera
+      return;
+    }
+    handOver(text);
+  };
+
+  const retry = () => {
+    handedOverRef.current.clear();
+    setHoldingPayload(false);
+  };
+
+  useEffect(() => {
+    if (waitingPhoto === null || busy) return;
+    setWaitingPhoto(null);
+    handOver(waitingPhoto);
+    // handOver is recreated each render; only the caller turning free matters.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [busy, waitingPhoto]);
+
+  const handlePhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    e.target.value = ''; // allow re-picking the same file; the File is GC'd after decode
+    // Only one photo at a time. The caller being busy now is no reason to
+    // drop one: the button was enabled when it was tapped.
+    if (!file || decoding || waitingPhoto !== null) return;
+    setPhotoError(null);
+    setDecoding(true);
+    try {
+      const text = await decodeImageBlob(file);
+      if (!mountedRef.current) return;
+      if (!text) setPhotoError(t('inventory.qrAssign.noQrInPhoto'));
+      else if (busyRef.current) setWaitingPhoto(text);
+      else handOver(text);
+    } catch {
+      if (mountedRef.current) setPhotoError(t('inventory.qrAssign.noQrInPhoto'));
+    } finally {
+      if (mountedRef.current) setDecoding(false);
+    }
+  };
+
+  const message = error ?? photoError;
+
+  return (
+    <div className="space-y-3">
+      {targetLabel && <p className="text-sm text-bambu-gray">{t('inventory.qrAssign.scanningTarget', { location: targetLabel })}</p>}
+
+      {/* Primary: live camera scan. When there is no camera to use, a hint
+          says why — the photo-capture fallback below covers every case.
+          Phone width at most, so the photo button stays in view in a wide
+          dialog too. */}
+      {cameraIssue ? (
+        <p className="text-xs text-amber-400 text-center">
+          {t(cameraIssue === 'insecure' ? 'inventory.qrAssign.cameraNeedsHttps' : 'inventory.qrAssign.cameraUnavailable')}
+        </p>
+      ) : (
+        <div className="relative mx-auto w-full max-w-sm">
+          <LiveScanner onFrame={handleLiveFrame} onError={setCameraIssue} paused={isBusy} />
+          <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
+            <div className="w-2/3 aspect-square border-2 border-bambu-green/70 rounded-xl" />
+          </div>
+          {isBusy && (
+            <div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          )}
+        </div>
+      )}
+
+      <p className="text-xs text-bambu-gray text-center">{t('inventory.qrAssign.scanInstruction')}</p>
+
+      {/* Fallback: capture a photo with the native camera (better focus). */}
+      <input ref={inputRef} type="file" accept="image/*" capture="environment" className="hidden" onChange={handlePhoto} />
+      <button
+        type="button"
+        onClick={() => inputRef.current?.click()}
+        disabled={isBusy}
+        className="w-full flex items-center justify-center gap-2 py-3 rounded-lg border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray transition-colors disabled:opacity-50"
+      >
+        {decoding ? <Loader2 className="w-4 h-4 animate-spin" /> : <Camera className="w-4 h-4" />}
+        <span className="text-sm font-medium">
+          {decoding ? t('inventory.qrAssign.decoding') : t('inventory.qrAssign.takePhoto')}
+        </span>
+      </button>
+
+      {message && <p className="text-xs text-red-400 text-center">{message}</p>}
+
+      {/* A label still in view was handed over already; scan it again on request. */}
+      {!cameraIssue && holdingPayload && !isBusy && (
+        <button
+          type="button"
+          onClick={retry}
+          className="mx-auto flex items-center gap-1.5 text-xs text-bambu-gray hover:text-white transition-colors"
+        >
+          <RotateCcw className="w-3.5 h-3.5" />
+          {t('common.retry')}
+        </button>
+      )}
+    </div>
+  );
+}

+ 5 - 0
frontend/src/hooks/useWebSocket.ts

@@ -495,6 +495,11 @@ export function useWebSocket() {
         // Both inventory modes: the Spoolman AMS sync raises this event too, and
         // Both inventory modes: the Spoolman AMS sync raises this event too, and
         // its slot rows live under their own query keys.
         // its slot rows live under their own query keys.
         invalidateSlotQueries();
         invalidateSlotQueries();
+        // A Spoolman move clears the spool's fallback tag for the slot it left
+        // (#1574). The slot cards fall back to the tag -> spool map once the
+        // slot row is gone, so a cached map kept the spool on its old slot in
+        // every tab but the one that moved it. Fetched in Spoolman mode only.
+        queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
         break;
         break;
 
 
       case 'spool_assignment_verified': {
       case 'spool_assignment_verified': {

+ 27 - 0
frontend/src/i18n/locales/de.ts

@@ -5320,6 +5320,33 @@ export default {
     spoolDeleted: 'Spule gelöscht',
     spoolDeleted: 'Spule gelöscht',
     deepLinkSpoolNotFound: 'Spule nicht gefunden',
     deepLinkSpoolNotFound: 'Spule nicht gefunden',
     deepLinkFetchFailed: 'Spule konnte nicht geladen werden — bitte erneut versuchen',
     deepLinkFetchFailed: 'Spule konnte nicht geladen werden — bitte erneut versuchen',
+    qrAssign: {
+      button: 'Per QR zuweisen',
+      title: 'Zuweisungsziel wählen',
+      description: 'Wähle ein Ziel und scanne dann einen Spulen-QR-Code, um sie dort zuzuweisen.',
+      tabAms: 'AMS-Steckplatz',
+      tabStorage: 'Lager',
+      printer: 'Drucker',
+      selectSlot: 'Steckplatz auswählen',
+      noSlots: 'Keine AMS-Steckplätze für diesen Drucker gefunden',
+      storageLabel: 'Lagerort',
+      storagePick: 'Lagerort auswählen',
+      startScan: 'Ziel festlegen & scannen',
+      scanTitle: 'Spulen-QR scannen',
+      scanInstruction: 'Richte die Kamera auf den Spulen-QR oder mach ein Foto, wenn er nicht scannt.',
+      scanningTarget: 'Ziel: {{location}}',
+      back: 'Zurück',
+      invalidQr: 'Dieser QR-Code ist kein Spulenetikett.',
+      spoolmanLabel: 'Das ist ein Spoolman-Etikett, aber das Inventar nutzt Spoolman nicht.',
+      takePhoto: 'Foto aufnehmen',
+      cameraNeedsHttps: 'Die Live-Kamera braucht Bambuddy über HTTPS. Mach stattdessen ein Foto.',
+      cameraUnavailable: 'Hier ließ sich keine Kamera öffnen. Mach stattdessen ein Foto.',
+      noQrInPhoto: 'Kein QR-Code im Foto gefunden — versuche es erneut.',
+      decoding: 'Wird gelesen…',
+      assignSuccess: 'Zugewiesen zu {{location}}',
+      storageSuccess: 'Verschoben nach {{location}}',
+      assignFailed: 'Zuweisung zu {{location}} fehlgeschlagen',
+    },
     spoolArchived: 'Spule archiviert',
     spoolArchived: 'Spule archiviert',
     spoolRestored: 'Spule wiederhergestellt',
     spoolRestored: 'Spule wiederhergestellt',
     kProfileSaveFailed: 'K-Profil-Einstellungen konnten nicht gespeichert werden',
     kProfileSaveFailed: 'K-Profil-Einstellungen konnten nicht gespeichert werden',

+ 27 - 0
frontend/src/i18n/locales/en.ts

@@ -5363,6 +5363,33 @@ export default {
     spoolDeleted: 'Spool deleted',
     spoolDeleted: 'Spool deleted',
     deepLinkSpoolNotFound: 'Spool not found',
     deepLinkSpoolNotFound: 'Spool not found',
     deepLinkFetchFailed: 'Could not load spool — try again',
     deepLinkFetchFailed: 'Could not load spool — try again',
+    qrAssign: {
+      button: 'Assign by QR',
+      title: 'Choose assignment target',
+      description: 'Pick a target, then scan a spool QR to assign it there.',
+      tabAms: 'AMS slot',
+      tabStorage: 'Storage',
+      printer: 'Printer',
+      selectSlot: 'Select a slot',
+      noSlots: 'No AMS slots found for this printer',
+      storageLabel: 'Storage location',
+      storagePick: 'Select a location',
+      startScan: 'Set target & scan',
+      scanTitle: 'Scan a spool QR',
+      scanInstruction: 'Point the camera at the spool QR, or take a photo if it does not scan.',
+      scanningTarget: 'Target: {{location}}',
+      back: 'Back',
+      invalidQr: 'That QR code is not a spool label.',
+      spoolmanLabel: 'That is a Spoolman label, but the inventory is not using Spoolman.',
+      takePhoto: 'Take photo',
+      cameraNeedsHttps: 'The live camera needs Bambuddy opened over HTTPS. Take a photo instead.',
+      cameraUnavailable: 'No camera could be opened here. Take a photo instead.',
+      noQrInPhoto: 'No QR code found in the photo — try again.',
+      decoding: 'Reading…',
+      assignSuccess: 'Assigned to {{location}}',
+      storageSuccess: 'Moved to {{location}}',
+      assignFailed: 'Could not assign to {{location}}',
+    },
     spoolArchived: 'Spool archived',
     spoolArchived: 'Spool archived',
     spoolRestored: 'Spool restored',
     spoolRestored: 'Spool restored',
     kProfileSaveFailed: 'K-profile settings could not be saved',
     kProfileSaveFailed: 'K-profile settings could not be saved',

+ 27 - 0
frontend/src/i18n/locales/es.ts

@@ -5323,6 +5323,33 @@ export default {
     spoolDeleted: 'Bobina eliminada',
     spoolDeleted: 'Bobina eliminada',
     deepLinkSpoolNotFound: 'Bobina no encontrada',
     deepLinkSpoolNotFound: 'Bobina no encontrada',
     deepLinkFetchFailed: 'No se pudo cargar la bobina — inténtelo de nuevo',
     deepLinkFetchFailed: 'No se pudo cargar la bobina — inténtelo de nuevo',
+    qrAssign: {
+      button: 'Asignar por QR',
+      title: 'Elegir destino de asignación',
+      description: 'Elige un destino y luego escanea el QR de una bobina para asignarla allí.',
+      tabAms: 'Ranura AMS',
+      tabStorage: 'Almacenamiento',
+      printer: 'Impresora',
+      selectSlot: 'Selecciona una ranura',
+      noSlots: 'No se encontraron ranuras AMS para esta impresora',
+      storageLabel: 'Ubicación de almacenamiento',
+      storagePick: 'Selecciona una ubicación',
+      startScan: 'Fijar destino y escanear',
+      scanTitle: 'Escanear QR de bobina',
+      scanInstruction: 'Apunta la cámara al QR de la bobina, o haz una foto si no se escanea.',
+      scanningTarget: 'Destino: {{location}}',
+      back: 'Atrás',
+      invalidQr: 'Ese código QR no es una etiqueta de bobina.',
+      spoolmanLabel: 'Es una etiqueta de Spoolman, pero el inventario no usa Spoolman.',
+      takePhoto: 'Hacer foto',
+      cameraNeedsHttps: 'La cámara en directo necesita Bambuddy abierto por HTTPS. Haz una foto en su lugar.',
+      cameraUnavailable: 'No se pudo abrir ninguna cámara aquí. Haz una foto en su lugar.',
+      noQrInPhoto: 'No se encontró ningún código QR en la foto — inténtalo de nuevo.',
+      decoding: 'Leyendo…',
+      assignSuccess: 'Asignada a {{location}}',
+      storageSuccess: 'Movida a {{location}}',
+      assignFailed: 'No se pudo asignar a {{location}}',
+    },
     spoolArchived: 'Bobina archivada',
     spoolArchived: 'Bobina archivada',
     spoolRestored: 'Bobina restaurada',
     spoolRestored: 'Bobina restaurada',
     kProfileSaveFailed: 'No se pudieron guardar los ajustes del perfil K',
     kProfileSaveFailed: 'No se pudieron guardar los ajustes del perfil K',

+ 27 - 0
frontend/src/i18n/locales/fr.ts

@@ -5306,6 +5306,33 @@ export default {
     spoolDeleted: 'Bobine supprimée',
     spoolDeleted: 'Bobine supprimée',
     deepLinkSpoolNotFound: 'Bobine introuvable',
     deepLinkSpoolNotFound: 'Bobine introuvable',
     deepLinkFetchFailed: 'Impossible de charger la bobine — réessayez',
     deepLinkFetchFailed: 'Impossible de charger la bobine — réessayez',
+    qrAssign: {
+      button: 'Affecter par QR',
+      title: 'Choisir la cible d’affectation',
+      description: 'Choisissez une cible, puis scannez le QR d’une bobine pour l’y affecter.',
+      tabAms: 'Emplacement AMS',
+      tabStorage: 'Stockage',
+      printer: 'Imprimante',
+      selectSlot: 'Sélectionnez un emplacement',
+      noSlots: 'Aucun emplacement AMS trouvé pour cette imprimante',
+      storageLabel: 'Emplacement de stockage',
+      storagePick: 'Sélectionnez un emplacement de stockage',
+      startScan: 'Définir la cible et scanner',
+      scanTitle: 'Scanner le QR d’une bobine',
+      scanInstruction: 'Pointez la caméra vers le QR de la bobine, ou prenez une photo s’il ne se scanne pas.',
+      scanningTarget: 'Cible : {{location}}',
+      back: 'Retour',
+      invalidQr: 'Ce QR code n’est pas une étiquette de bobine.',
+      spoolmanLabel: 'C’est une étiquette Spoolman, mais l’inventaire n’utilise pas Spoolman.',
+      takePhoto: 'Prendre une photo',
+      cameraNeedsHttps: 'La caméra en direct exige que Bambuddy soit ouvert en HTTPS. Prenez plutôt une photo.',
+      cameraUnavailable: 'Aucune caméra n’a pu être ouverte ici. Prenez plutôt une photo.',
+      noQrInPhoto: 'Aucun QR code trouvé sur la photo — réessayez.',
+      decoding: 'Lecture…',
+      assignSuccess: 'Affectée à {{location}}',
+      storageSuccess: 'Déplacée vers {{location}}',
+      assignFailed: 'Impossible d’affecter à {{location}}',
+    },
     spoolArchived: 'Bobine archivée',
     spoolArchived: 'Bobine archivée',
     spoolRestored: 'Bobine restaurée',
     spoolRestored: 'Bobine restaurée',
     kProfileSaveFailed: 'Les paramètres de profil K n\'ont pas pu être enregistrés',
     kProfileSaveFailed: 'Les paramètres de profil K n\'ont pas pu être enregistrés',

+ 27 - 0
frontend/src/i18n/locales/it.ts

@@ -5305,6 +5305,33 @@ export default {
     spoolDeleted: 'Bobina eliminata',
     spoolDeleted: 'Bobina eliminata',
     deepLinkSpoolNotFound: 'Bobina non trovata',
     deepLinkSpoolNotFound: 'Bobina non trovata',
     deepLinkFetchFailed: 'Impossibile caricare la bobina — riprova',
     deepLinkFetchFailed: 'Impossibile caricare la bobina — riprova',
+    qrAssign: {
+      button: 'Assegna tramite QR',
+      title: 'Scegli la destinazione di assegnazione',
+      description: 'Scegli una destinazione, poi scansiona il QR di una bobina per assegnarla lì.',
+      tabAms: 'Slot AMS',
+      tabStorage: 'Conservazione',
+      printer: 'Stampante',
+      selectSlot: 'Seleziona uno slot',
+      noSlots: 'Nessuno slot AMS trovato per questa stampante',
+      storageLabel: 'Posizione di conservazione',
+      storagePick: 'Seleziona una posizione',
+      startScan: 'Imposta destinazione e scansiona',
+      scanTitle: 'Scansiona il QR di una bobina',
+      scanInstruction: 'Inquadra il QR della bobina, oppure scatta una foto se non viene letto.',
+      scanningTarget: 'Destinazione: {{location}}',
+      back: 'Indietro',
+      invalidQr: 'Questo codice QR non è un’etichetta di bobina.',
+      spoolmanLabel: 'È un’etichetta di Spoolman, ma l’inventario non usa Spoolman.',
+      takePhoto: 'Scatta foto',
+      cameraNeedsHttps: 'La fotocamera dal vivo richiede Bambuddy aperto via HTTPS. Scatta invece una foto.',
+      cameraUnavailable: 'Non è stato possibile aprire una fotocamera qui. Scatta invece una foto.',
+      noQrInPhoto: 'Nessun codice QR trovato nella foto — riprova.',
+      decoding: 'Lettura…',
+      assignSuccess: 'Assegnata a {{location}}',
+      storageSuccess: 'Spostata in {{location}}',
+      assignFailed: 'Impossibile assegnare a {{location}}',
+    },
     spoolArchived: 'Bobina archiviata',
     spoolArchived: 'Bobina archiviata',
     spoolRestored: 'Bobina ripristinata',
     spoolRestored: 'Bobina ripristinata',
     kProfileSaveFailed: 'Impossibile salvare le impostazioni del profilo K',
     kProfileSaveFailed: 'Impossibile salvare le impostazioni del profilo K',

+ 27 - 0
frontend/src/i18n/locales/ja.ts

@@ -5319,6 +5319,33 @@ export default {
     spoolDeleted: 'スプールを削除しました',
     spoolDeleted: 'スプールを削除しました',
     deepLinkSpoolNotFound: 'スプールが見つかりません',
     deepLinkSpoolNotFound: 'スプールが見つかりません',
     deepLinkFetchFailed: 'スプールを読み込めませんでした — もう一度お試しください',
     deepLinkFetchFailed: 'スプールを読み込めませんでした — もう一度お試しください',
+    qrAssign: {
+      button: 'QRで割り当て',
+      title: '割り当て先を選択',
+      description: '割り当て先を選んでから、スプールのQRをスキャンしてそこに割り当てます。',
+      tabAms: 'AMSスロット',
+      tabStorage: '保管',
+      printer: 'プリンター',
+      selectSlot: 'スロットを選択',
+      noSlots: 'このプリンターのAMSスロットが見つかりません',
+      storageLabel: '保管場所',
+      storagePick: '保管場所を選択',
+      startScan: '割り当て先を設定してスキャン',
+      scanTitle: 'スプールのQRをスキャン',
+      scanInstruction: 'スプールのQRにカメラを向けてください。読み取れない場合は写真を撮ってください。',
+      scanningTarget: '割り当て先: {{location}}',
+      back: '戻る',
+      invalidQr: 'このQRコードはスプールラベルではありません。',
+      spoolmanLabel: 'これは Spoolman のラベルですが、在庫管理は Spoolman を使用していません。',
+      takePhoto: '写真を撮る',
+      cameraNeedsHttps: 'ライブカメラを使うには Bambuddy を HTTPS で開く必要があります。代わりに写真を撮ってください。',
+      cameraUnavailable: 'カメラを開けませんでした。代わりに写真を撮ってください。',
+      noQrInPhoto: '写真にQRコードが見つかりませんでした — もう一度お試しください。',
+      decoding: '読み取り中…',
+      assignSuccess: '{{location}} に割り当てました',
+      storageSuccess: '{{location}} に移動しました',
+      assignFailed: '{{location}} に割り当てできませんでした',
+    },
     spoolArchived: 'スプールをアーカイブしました',
     spoolArchived: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
     spoolRestored: 'スプールを復元しました',
     kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',
     kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',

+ 27 - 0
frontend/src/i18n/locales/ko.ts

@@ -5104,6 +5104,33 @@ export default {
     spoolDeleted: '스풀 삭제됨',
     spoolDeleted: '스풀 삭제됨',
     deepLinkSpoolNotFound: '스풀을 찾을 수 없음',
     deepLinkSpoolNotFound: '스풀을 찾을 수 없음',
     deepLinkFetchFailed: '스풀을 불러올 수 없음 — 다시 시도하세요',
     deepLinkFetchFailed: '스풀을 불러올 수 없음 — 다시 시도하세요',
+    qrAssign: {
+      button: 'QR로 할당',
+      title: '할당 대상 선택',
+      description: '대상을 먼저 선택한 다음 스풀 QR을 스캔하여 할당하세요.',
+      tabAms: 'AMS 슬롯',
+      tabStorage: '보관',
+      printer: '프린터',
+      selectSlot: '슬롯 선택',
+      noSlots: '이 프린터의 AMS 슬롯을 찾을 수 없음',
+      storageLabel: '보관 위치',
+      storagePick: '보관 위치 선택',
+      startScan: '대상 설정 후 스캔',
+      scanTitle: '스풀 QR 스캔',
+      scanInstruction: '카메라를 스풀 QR에 맞추세요. 스캔되지 않으면 사진을 찍으세요.',
+      scanningTarget: '대상: {{location}}',
+      back: '뒤로',
+      invalidQr: '이 QR 코드는 스풀 라벨이 아닙니다.',
+      spoolmanLabel: '이것은 Spoolman 라벨이지만, 현재 재고는 Spoolman을 사용하지 않습니다.',
+      takePhoto: '사진 찍기',
+      cameraNeedsHttps: '실시간 카메라를 쓰려면 Bambuddy를 HTTPS로 열어야 합니다. 대신 사진을 찍으세요.',
+      cameraUnavailable: '카메라를 열 수 없습니다. 대신 사진을 찍으세요.',
+      noQrInPhoto: '사진에서 QR 코드를 찾을 수 없음 — 다시 시도하세요.',
+      decoding: '읽는 중…',
+      assignSuccess: '{{location}}에 할당됨',
+      storageSuccess: '{{location}}(으)로 이동됨',
+      assignFailed: '{{location}}에 할당할 수 없음',
+    },
     spoolArchived: '스풀 보관됨',
     spoolArchived: '스풀 보관됨',
     spoolRestored: '스풀 복원됨',
     spoolRestored: '스풀 복원됨',
     kProfileSaveFailed: 'K-프로필 설정을 저장할 수 없습니다',
     kProfileSaveFailed: 'K-프로필 설정을 저장할 수 없습니다',

+ 27 - 0
frontend/src/i18n/locales/nl.ts

@@ -5361,6 +5361,33 @@ export default {
     spoolDeleted: 'Spoel verwijderd',
     spoolDeleted: 'Spoel verwijderd',
     deepLinkSpoolNotFound: 'Spoel niet gevonden',
     deepLinkSpoolNotFound: 'Spoel niet gevonden',
     deepLinkFetchFailed: 'Spoel kon niet worden geladen — probeer opnieuw',
     deepLinkFetchFailed: 'Spoel kon niet worden geladen — probeer opnieuw',
+    qrAssign: {
+      button: 'Toewijzen via QR',
+      title: 'Toewijzingsdoel kiezen',
+      description: 'Kies een doel en scan daarna de QR-code van een spoel om die daar toe te wijzen.',
+      tabAms: 'AMS-sleuf',
+      tabStorage: 'Opslag',
+      printer: 'Printer',
+      selectSlot: 'Selecteer een sleuf',
+      noSlots: 'Geen AMS-sleuven gevonden voor deze printer',
+      storageLabel: 'Opslaglocatie',
+      storagePick: 'Selecteer een locatie',
+      startScan: 'Doel instellen & scannen',
+      scanTitle: 'QR-code van spoel scannen',
+      scanInstruction: 'Richt de camera op de QR-code van de spoel, of maak een foto als scannen niet lukt.',
+      scanningTarget: 'Doel: {{location}}',
+      back: 'Terug',
+      invalidQr: 'Deze QR-code is geen spoellabel.',
+      spoolmanLabel: 'Dit is een Spoolman-label, maar de spoelvoorraad gebruikt Spoolman niet.',
+      takePhoto: 'Foto maken',
+      cameraNeedsHttps: 'De live camera werkt alleen als Bambuddy via HTTPS is geopend. Maak in plaats daarvan een foto.',
+      cameraUnavailable: 'Er kon hier geen camera worden geopend. Maak in plaats daarvan een foto.',
+      noQrInPhoto: 'Geen QR-code gevonden in de foto — probeer het opnieuw.',
+      decoding: 'Bezig met lezen…',
+      assignSuccess: 'Toegewezen aan {{location}}',
+      storageSuccess: 'Verplaatst naar {{location}}',
+      assignFailed: 'Toewijzen aan {{location}} mislukt',
+    },
     spoolArchived: 'Spoel gearchiveerd',
     spoolArchived: 'Spoel gearchiveerd',
     spoolRestored: 'Spoel hersteld',
     spoolRestored: 'Spoel hersteld',
     kProfileSaveFailed: 'K-profielinstellingen konden niet worden opgeslagen',
     kProfileSaveFailed: 'K-profielinstellingen konden niet worden opgeslagen',

+ 27 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -5305,6 +5305,33 @@ export default {
     spoolDeleted: 'Carretel excluído',
     spoolDeleted: 'Carretel excluído',
     deepLinkSpoolNotFound: 'Carretel não encontrado',
     deepLinkSpoolNotFound: 'Carretel não encontrado',
     deepLinkFetchFailed: 'Não foi possível carregar o carretel — tente novamente',
     deepLinkFetchFailed: 'Não foi possível carregar o carretel — tente novamente',
+    qrAssign: {
+      button: 'Atribuir por QR',
+      title: 'Escolher destino da atribuição',
+      description: 'Escolha um destino e depois escaneie o QR de um carretel para atribuí-lo ali.',
+      tabAms: 'Slot AMS',
+      tabStorage: 'Armazenamento',
+      printer: 'Impressora',
+      selectSlot: 'Selecione um slot',
+      noSlots: 'Nenhum slot AMS encontrado para esta impressora',
+      storageLabel: 'Local de armazenamento',
+      storagePick: 'Selecione um local',
+      startScan: 'Definir destino e escanear',
+      scanTitle: 'Escanear QR do carretel',
+      scanInstruction: 'Aponte a câmera para o QR do carretel, ou tire uma foto se não escanear.',
+      scanningTarget: 'Destino: {{location}}',
+      back: 'Voltar',
+      invalidQr: 'Esse QR code não é uma etiqueta de carretel.',
+      spoolmanLabel: 'É uma etiqueta do Spoolman, mas o inventário não usa o Spoolman.',
+      takePhoto: 'Tirar foto',
+      cameraNeedsHttps: 'A câmera ao vivo precisa do Bambuddy aberto por HTTPS. Tire uma foto em vez disso.',
+      cameraUnavailable: 'Não foi possível abrir nenhuma câmera aqui. Tire uma foto em vez disso.',
+      noQrInPhoto: 'Nenhum QR code encontrado na foto — tente novamente.',
+      decoding: 'Lendo…',
+      assignSuccess: 'Atribuído a {{location}}',
+      storageSuccess: 'Movido para {{location}}',
+      assignFailed: 'Não foi possível atribuir a {{location}}',
+    },
     spoolArchived: 'Carretel arquivado',
     spoolArchived: 'Carretel arquivado',
     spoolRestored: 'Carretel restaurado',
     spoolRestored: 'Carretel restaurado',
     kProfileSaveFailed: 'Não foi possível salvar as configurações do perfil K',
     kProfileSaveFailed: 'Não foi possível salvar as configurações do perfil K',

+ 27 - 0
frontend/src/i18n/locales/ru.ts

@@ -5095,6 +5095,33 @@ export default {
     spoolDeleted: "Катушка удалена",
     spoolDeleted: "Катушка удалена",
     deepLinkSpoolNotFound: "Катушка не найдена",
     deepLinkSpoolNotFound: "Катушка не найдена",
     deepLinkFetchFailed: "Не удалось загрузить катушку. Повторите попытку.",
     deepLinkFetchFailed: "Не удалось загрузить катушку. Повторите попытку.",
+    qrAssign: {
+      button: "Назначить по QR",
+      title: "Выберите цель назначения",
+      description: "Выберите цель, затем отсканируйте QR-код катушки, чтобы назначить её туда.",
+      tabAms: "Слот AMS",
+      tabStorage: "Хранение",
+      printer: "Принтер",
+      selectSlot: "Выберите слот",
+      noSlots: "Для этого принтера не найдено слотов AMS",
+      storageLabel: "Место хранения",
+      storagePick: "Выберите место",
+      startScan: "Задать цель и сканировать",
+      scanTitle: "Отсканируйте QR-код катушки",
+      scanInstruction: "Наведите камеру на QR-код катушки или сделайте фото, если он не сканируется.",
+      scanningTarget: "Цель: {{location}}",
+      back: "Назад",
+      invalidQr: "Этот QR-код не является этикеткой катушки.",
+      spoolmanLabel: 'Это этикетка Spoolman, а учёт катушек не использует Spoolman.',
+      takePhoto: "Сделать фото",
+      cameraNeedsHttps: 'Для камеры в реальном времени откройте Bambuddy по HTTPS. Или сделайте фото.',
+      cameraUnavailable: 'Не удалось открыть камеру. Сделайте фото вместо этого.',
+      noQrInPhoto: "QR-код на фото не найден — попробуйте снова.",
+      decoding: "Чтение…",
+      assignSuccess: "Назначено в {{location}}",
+      storageSuccess: "Перемещено в {{location}}",
+      assignFailed: "Не удалось назначить в {{location}}",
+    },
     spoolArchived: "Катушка архивирована",
     spoolArchived: "Катушка архивирована",
     spoolRestored: "Катушка восстановлена",
     spoolRestored: "Катушка восстановлена",
     kProfileSaveFailed: "Не удалось сохранить настройки K-профиля",
     kProfileSaveFailed: "Не удалось сохранить настройки K-профиля",

+ 27 - 0
frontend/src/i18n/locales/sv.ts

@@ -5359,6 +5359,33 @@ errors: {
     spoolDeleted: 'Rulle borttagen',
     spoolDeleted: 'Rulle borttagen',
     deepLinkSpoolNotFound: 'Rulle hittades ej',
     deepLinkSpoolNotFound: 'Rulle hittades ej',
     deepLinkFetchFailed: 'Kunde inte ladda rulle — försök igen',
     deepLinkFetchFailed: 'Kunde inte ladda rulle — försök igen',
+    qrAssign: {
+      button: 'Tilldela via QR',
+      title: 'Välj tilldelningsmål',
+      description: 'Välj ett mål och skanna sedan en rulles QR-kod för att tilldela den dit.',
+      tabAms: 'AMS-fack',
+      tabStorage: 'Lagring',
+      printer: 'Skrivare',
+      selectSlot: 'Välj ett fack',
+      noSlots: 'Inga AMS-fack hittades för denna skrivare',
+      storageLabel: 'Lagringsplats',
+      storagePick: 'Välj en plats',
+      startScan: 'Ange mål & skanna',
+      scanTitle: 'Skanna en rulles QR-kod',
+      scanInstruction: 'Rikta kameran mot rullens QR-kod, eller ta ett foto om den inte går att skanna.',
+      scanningTarget: 'Mål: {{location}}',
+      back: 'Tillbaka',
+      invalidQr: 'Den QR-koden är inte en rulletikett.',
+      spoolmanLabel: 'Det är en Spoolman-etikett, men inventariet använder inte Spoolman.',
+      takePhoto: 'Ta foto',
+      cameraNeedsHttps: 'Livekameran kräver att Bambuddy öppnas via HTTPS. Ta ett foto i stället.',
+      cameraUnavailable: 'Ingen kamera kunde öppnas här. Ta ett foto i stället.',
+      noQrInPhoto: 'Ingen QR-kod hittades i fotot — försök igen.',
+      decoding: 'Läser…',
+      assignSuccess: 'Tilldelad till {{location}}',
+      storageSuccess: 'Flyttad till {{location}}',
+      assignFailed: 'Kunde inte tilldela till {{location}}',
+    },
     spoolArchived: 'Rulle arkiverad',
     spoolArchived: 'Rulle arkiverad',
     spoolRestored: 'Rulle återställd',
     spoolRestored: 'Rulle återställd',
     kProfileSaveFailed: 'K-profilinställningar kunde inte sparas',
     kProfileSaveFailed: 'K-profilinställningar kunde inte sparas',

+ 27 - 0
frontend/src/i18n/locales/tr.ts

@@ -5306,6 +5306,33 @@ export default {
     spoolDeleted: 'Makara silindi',
     spoolDeleted: 'Makara silindi',
     deepLinkSpoolNotFound: 'Makara bulunamadı',
     deepLinkSpoolNotFound: 'Makara bulunamadı',
     deepLinkFetchFailed: 'Makara yüklenemedi — tekrar deneyin',
     deepLinkFetchFailed: 'Makara yüklenemedi — tekrar deneyin',
+    qrAssign: {
+      button: 'QR ile ata',
+      title: 'Atama hedefini seç',
+      description: 'Bir hedef seç, sonra makara QR kodunu okutarak oraya ata.',
+      tabAms: 'AMS yuvası',
+      tabStorage: 'Depo',
+      printer: 'Yazıcı',
+      selectSlot: 'Bir yuva seç',
+      noSlots: 'Bu yazıcı için AMS yuvası bulunamadı',
+      storageLabel: 'Depo konumu',
+      storagePick: 'Bir konum seç',
+      startScan: 'Hedefi ayarla ve tara',
+      scanTitle: 'Makara QR kodunu tara',
+      scanInstruction: 'Kamerayı makara QR koduna tut; okumazsa fotoğraf çek.',
+      scanningTarget: 'Hedef: {{location}}',
+      back: 'Geri',
+      invalidQr: 'Bu QR kodu bir makara etiketi değil.',
+      spoolmanLabel: 'Bu bir Spoolman etiketi, ancak envanter Spoolman kullanmıyor.',
+      takePhoto: 'Fotoğraf çek',
+      cameraNeedsHttps: 'Canlı kamera için Bambuddy\'nin HTTPS ile açılması gerekir. Bunun yerine fotoğraf çek.',
+      cameraUnavailable: 'Burada kamera açılamadı. Bunun yerine fotoğraf çek.',
+      noQrInPhoto: 'Fotoğrafta QR kodu bulunamadı — tekrar deneyin.',
+      decoding: 'Okunuyor…',
+      assignSuccess: '{{location}} konumuna atandı',
+      storageSuccess: '{{location}} konumuna taşındı',
+      assignFailed: '{{location}} konumuna atanamadı',
+    },
     spoolArchived: 'Makara arşivlendi',
     spoolArchived: 'Makara arşivlendi',
     spoolRestored: 'Makara geri yüklendi',
     spoolRestored: 'Makara geri yüklendi',
     kProfileSaveFailed: 'K-profili ayarları kaydedilemedi',
     kProfileSaveFailed: 'K-profili ayarları kaydedilemedi',

+ 27 - 0
frontend/src/i18n/locales/uk.ts

@@ -5356,6 +5356,33 @@ export default {
     spoolDeleted: "Котушку видалено",
     spoolDeleted: "Котушку видалено",
     deepLinkSpoolNotFound: "Котушку не знайдено",
     deepLinkSpoolNotFound: "Котушку не знайдено",
     deepLinkFetchFailed: "Не вдалося завантажити котушку — спробуйте ще раз",
     deepLinkFetchFailed: "Не вдалося завантажити котушку — спробуйте ще раз",
+    qrAssign: {
+      button: "Призначити за QR",
+      title: "Виберіть ціль призначення",
+      description: "Виберіть ціль, потім відскануйте QR-код котушки, щоб призначити її туди.",
+      tabAms: "Слот AMS",
+      tabStorage: "Зберігання",
+      printer: "Принтер",
+      selectSlot: "Виберіть слот",
+      noSlots: "Для цього принтера не знайдено слотів AMS",
+      storageLabel: "Місце зберігання",
+      storagePick: "Виберіть місце",
+      startScan: "Задати ціль і сканувати",
+      scanTitle: "Відскануйте QR-код котушки",
+      scanInstruction: "Наведіть камеру на QR-код котушки або зробіть фото, якщо він не сканується.",
+      scanningTarget: "Ціль: {{location}}",
+      back: "Назад",
+      invalidQr: "Цей QR-код не є етикеткою котушки.",
+      spoolmanLabel: 'Це етикетка Spoolman, а облік котушок не використовує Spoolman.',
+      takePhoto: "Зробити фото",
+      cameraNeedsHttps: 'Для камери в реальному часі відкрийте Bambuddy через HTTPS. Або зробіть фото.',
+      cameraUnavailable: 'Не вдалося відкрити камеру. Зробіть фото натомість.',
+      noQrInPhoto: "QR-код на фото не знайдено — спробуйте ще раз.",
+      decoding: "Читання…",
+      assignSuccess: "Призначено до {{location}}",
+      storageSuccess: "Переміщено до {{location}}",
+      assignFailed: "Не вдалося призначити до {{location}}",
+    },
     spoolArchived: "Котушку заархівовано",
     spoolArchived: "Котушку заархівовано",
     spoolRestored: "Котушку відновлено",
     spoolRestored: "Котушку відновлено",
     kProfileSaveFailed: "Не вдалося зберегти налаштування K-профілю",
     kProfileSaveFailed: "Не вдалося зберегти налаштування K-профілю",

+ 27 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -5311,6 +5311,33 @@ export default {
     spoolDeleted: '耗材已删除',
     spoolDeleted: '耗材已删除',
     deepLinkSpoolNotFound: '未找到耗材',
     deepLinkSpoolNotFound: '未找到耗材',
     deepLinkFetchFailed: '无法加载耗材 — 请重试',
     deepLinkFetchFailed: '无法加载耗材 — 请重试',
+    qrAssign: {
+      button: '通过二维码分配',
+      title: '选择分配目标',
+      description: '先选择目标,然后扫描耗材二维码即可将其分配到那里。',
+      tabAms: 'AMS 槽位',
+      tabStorage: '存储',
+      printer: '打印机',
+      selectSlot: '选择一个槽位',
+      noSlots: '未找到该打印机的 AMS 槽位',
+      storageLabel: '存储位置',
+      storagePick: '选择一个存储位置',
+      startScan: '设置目标并扫描',
+      scanTitle: '扫描耗材二维码',
+      scanInstruction: '将相机对准耗材二维码;若无法扫描,请拍照。',
+      scanningTarget: '目标:{{location}}',
+      back: '返回',
+      invalidQr: '该二维码不是耗材标签。',
+      spoolmanLabel: '这是 Spoolman 标签,但耗材库存未使用 Spoolman。',
+      takePhoto: '拍照',
+      cameraNeedsHttps: '实时相机需要通过 HTTPS 打开 Bambuddy。请改为拍照。',
+      cameraUnavailable: '无法打开相机。请改为拍照。',
+      noQrInPhoto: '照片中未找到二维码 — 请重试。',
+      decoding: '正在读取…',
+      assignSuccess: '已分配到 {{location}}',
+      storageSuccess: '已移动到 {{location}}',
+      assignFailed: '无法分配到 {{location}}',
+    },
     spoolArchived: '耗材已归档',
     spoolArchived: '耗材已归档',
     spoolRestored: '耗材已恢复',
     spoolRestored: '耗材已恢复',
     kProfileSaveFailed: 'K值配置文件设置无法保存',
     kProfileSaveFailed: 'K值配置文件设置无法保存',

+ 27 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -5311,6 +5311,33 @@ export default {
     spoolDeleted: '耗材已刪除',
     spoolDeleted: '耗材已刪除',
     deepLinkSpoolNotFound: '找不到耗材',
     deepLinkSpoolNotFound: '找不到耗材',
     deepLinkFetchFailed: '無法載入耗材 — 請重試',
     deepLinkFetchFailed: '無法載入耗材 — 請重試',
+    qrAssign: {
+      button: '透過 QR 碼指派',
+      title: '選擇指派目標',
+      description: '先選擇目標,然後掃描耗材 QR 碼即可將其指派到該處。',
+      tabAms: 'AMS 插槽',
+      tabStorage: '儲存',
+      printer: '印表機',
+      selectSlot: '選擇一個插槽',
+      noSlots: '找不到此印表機的 AMS 插槽',
+      storageLabel: '儲存位置',
+      storagePick: '選擇一個儲存位置',
+      startScan: '設定目標並掃描',
+      scanTitle: '掃描耗材 QR 碼',
+      scanInstruction: '將相機對準耗材 QR 碼;若無法掃描,請拍照。',
+      scanningTarget: '目標:{{location}}',
+      back: '返回',
+      invalidQr: '此 QR 碼不是耗材標籤。',
+      spoolmanLabel: '這是 Spoolman 標籤,但耗材庫存未使用 Spoolman。',
+      takePhoto: '拍照',
+      cameraNeedsHttps: '即時相機需要透過 HTTPS 開啟 Bambuddy。請改為拍照。',
+      cameraUnavailable: '無法開啟相機。請改為拍照。',
+      noQrInPhoto: '照片中找不到 QR 碼 — 請重試。',
+      decoding: '正在讀取…',
+      assignSuccess: '已指派到 {{location}}',
+      storageSuccess: '已移動到 {{location}}',
+      assignFailed: '無法指派到 {{location}}',
+    },
     spoolArchived: '耗材已歸檔',
     spoolArchived: '耗材已歸檔',
     spoolRestored: '耗材已恢復',
     spoolRestored: '耗材已恢復',
     kProfileSaveFailed: 'K值設定檔設定無法儲存',
     kProfileSaveFailed: 'K值設定檔設定無法儲存',

+ 17 - 1
frontend/src/pages/InventoryPage.tsx

@@ -7,7 +7,7 @@ import {
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
-  Upload, Download, Store,
+  Upload, Download, Store, QrCode,
 } from 'lucide-react';
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -17,6 +17,7 @@ import { FilamentSwatch } from '../components/FilamentSwatch';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
 import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
+import { QrAssignTargetModal } from '../components/QrAssignTargetModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
@@ -666,6 +667,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [searchParams, setSearchParams] = useSearchParams();
   const [searchParams, setSearchParams] = useSearchParams();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
   const deepLinkHandled = useRef(false);
   const deepLinkHandled = useRef(false);
+  // Scan-to-location (#1574): pick a target (AMS slot or storage), then scan a
+  // spool's QR with the in-page camera — handled entirely inside the modal.
+  const [qrTargetModalOpen, setQrTargetModalOpen] = useState(false);
   const [confirmAction, setConfirmAction] = useState<
   const [confirmAction, setConfirmAction] = useState<
     | { type: 'delete' | 'archive' | 'reset-consumed-counter'; spoolId: number }
     | { type: 'delete' | 'archive' | 'reset-consumed-counter'; spoolId: number }
     | { type: 'reset-all-consumed-counters' }
     | { type: 'reset-all-consumed-counters' }
@@ -1676,6 +1680,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
             <Printer className="w-4 h-4" />
             <Printer className="w-4 h-4" />
             {t('inventory.labels.printLabels', 'Print labels…')}
             {t('inventory.labels.printLabels', 'Print labels…')}
           </Button>
           </Button>
+          <Button variant="secondary" onClick={() => setQrTargetModalOpen(true)}>
+            <QrCode className="w-4 h-4" />
+            {t('inventory.qrAssign.button')}
+          </Button>
           <Button onClick={() => setFormModal({ spool: null, mode: 'create' })}>
           <Button onClick={() => setFormModal({ spool: null, mode: 'create' })}>
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             {t('inventory.addSpool')}
             {t('inventory.addSpool')}
@@ -1683,6 +1691,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         </div>
         </div>
       </div>
       </div>
 
 
+      {qrTargetModalOpen && (
+        <QrAssignTargetModal
+          isOpen={qrTargetModalOpen}
+          onClose={() => setQrTargetModalOpen(false)}
+          spoolmanMode={spoolmanMode}
+        />
+      )}
+
       {/* Stats Bar */}
       {/* Stats Bar */}
       {stats && !isLoading && (
       {stats && !isLoading && (
         <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
         <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">

+ 4 - 1
frontend/src/pages/PrintersPage.tsx

@@ -2245,7 +2245,7 @@ function PrinterCard({
     printerId: number;
     printerId: number;
     amsId: number;
     amsId: number;
     trayId: number;
     trayId: number;
-    trayInfo: { type: string; color: string; location: string; material?: string; profile?: string };
+    trayInfo: { type: string; color: string; location: string; material?: string; profile?: string; emptyKind?: 'physical' | 'reset' | null };
   } | null>(null);
   } | null>(null);
   const [configureSlotModal, setConfigureSlotModal] = useState<{
   const [configureSlotModal, setConfigureSlotModal] = useState<{
     amsId: number;
     amsId: number;
@@ -5824,6 +5824,7 @@ function PrinterCard({
                                             profile: '',
                                             profile: '',
                                             color: '',
                                             color: '',
                                             location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
                                             location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                            emptyKind,
                                           },
                                           },
                                         })}
                                         })}
                                       >
                                       >
@@ -6227,6 +6228,7 @@ function PrinterCard({
                                         profile: '',
                                         profile: '',
                                         color: '',
                                         color: '',
                                         location: getAmsLabel(ams.id),
                                         location: getAmsLabel(ams.id),
+                                        emptyKind,
                                       },
                                       },
                                     })}
                                     })}
                                   >
                                   >
@@ -6504,6 +6506,7 @@ function PrinterCard({
                                           profile: '',
                                           profile: '',
                                           color: '',
                                           color: '',
                                           location: `External Slot ${slotTrayId + 1}`,
                                           location: `External Slot ${slotTrayId + 1}`,
+                                          emptyKind,
                                         },
                                         },
                                       })}
                                       })}
                                     >
                                     >

+ 4 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -6,7 +6,7 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
-import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter, slotPresetDescribesTray } from '../../utils/amsHelpers';
+import { getGlobalTrayId, getEmptySlotKind, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter, slotPresetDescribesTray } from '../../utils/amsHelpers';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { mapModelCode } from '../../utils/printerModel';
 import { mapModelCode } from '../../utils/printerModel';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
@@ -273,7 +273,7 @@ export function SpoolBuddyAmsPage() {
     printerId: number;
     printerId: number;
     amsId: number;
     amsId: number;
     trayId: number;
     trayId: number;
-    trayInfo: { type: string; material?: string; profile?: string; color: string; location: string };
+    trayInfo: { type: string; material?: string; profile?: string; color: string; location: string; emptyKind?: 'physical' | 'reset' | null };
   } | null>(null);
   } | null>(null);
 
 
   // Link spool modal state (Spoolman)
   // Link spool modal state (Spoolman)
@@ -432,7 +432,7 @@ export function SpoolBuddyAmsPage() {
 
 
   const openAssignFromPicker = useCallback(() => {
   const openAssignFromPicker = useCallback(() => {
     if (!slotActionPicker || !selectedPrinterId) return;
     if (!slotActionPicker || !selectedPrinterId) return;
-    const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
+    const { amsId, trayId, tray, trayType, trayColor, location } = slotActionPicker;
     setSlotActionPicker(null);
     setSlotActionPicker(null);
     setAssignSpoolModal({
     setAssignSpoolModal({
       printerId: selectedPrinterId,
       printerId: selectedPrinterId,
@@ -443,6 +443,7 @@ export function SpoolBuddyAmsPage() {
         material: trayType,
         material: trayType,
         color: trayColor?.slice(0, 6) || '',
         color: trayColor?.slice(0, 6) || '',
         location,
         location,
+        emptyKind: getEmptySlotKind(tray),
       },
       },
     });
     });
   }, [slotActionPicker, selectedPrinterId]);
   }, [slotActionPicker, selectedPrinterId]);

+ 20 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useOutletContext } from 'react-router-dom';
 import { useOutletContext } from 'react-router-dom';
-import { Search, X, Package } from 'lucide-react';
+import { Search, X, Package, QrCode } from 'lucide-react';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import type { InventorySpool } from '../../api/client';
 import type { InventorySpool } from '../../api/client';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
@@ -13,6 +13,7 @@ import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
+import { QrAssignTargetModal } from '../../components/QrAssignTargetModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { useToast } from '../../contexts/ToastContext';
 import { useToast } from '../../contexts/ToastContext';
 
 
@@ -92,6 +93,7 @@ export function SpoolBuddyInventoryPage() {
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
   const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
+  const [qrTargetModalOpen, setQrTargetModalOpen] = useState(false);
 
 
   const { data: spoolmanSettings } = useQuery({
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
     queryKey: ['spoolman-settings'],
@@ -186,6 +188,15 @@ export function SpoolBuddyInventoryPage() {
     <div className="h-full flex flex-col">
     <div className="h-full flex flex-col">
       {/* Search + filter pills */}
       {/* Search + filter pills */}
       <div className="px-3 pt-3 pb-2 space-y-2.5">
       <div className="px-3 pt-3 pb-2 space-y-2.5">
+        {/* Scan-to-location (#1574) */}
+        <button
+          onClick={() => setQrTargetModalOpen(true)}
+          className="w-full flex items-center justify-center gap-2 py-2 bg-bambu-green/20 border border-bambu-green text-bambu-green rounded-lg text-sm font-medium hover:bg-bambu-green/30 transition-colors"
+        >
+          <QrCode className="w-4 h-4" />
+          {t('inventory.qrAssign.button')}
+        </button>
+
         {/* Search */}
         {/* Search */}
         <div className="relative">
         <div className="relative">
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
@@ -297,6 +308,14 @@ export function SpoolBuddyInventoryPage() {
           </>
           </>
         );
         );
       })()}
       })()}
+
+      {qrTargetModalOpen && (
+        <QrAssignTargetModal
+          isOpen={qrTargetModalOpen}
+          onClose={() => setQrTargetModalOpen(false)}
+          spoolmanMode={spoolmanMode}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 123 - 0
frontend/src/utils/assignMismatch.ts

@@ -0,0 +1,123 @@
+/**
+ * Does a spool fit the AMS slot it is being assigned to? The material check
+ * the Assign Spool dialog and the QR assignment both ask before assigning.
+ */
+
+import type { TFunction } from 'i18next';
+
+export const normalizeValue = (value: string | undefined | null) => (value ?? '').trim().toUpperCase();
+
+// Bambu Studio / OrcaSlicer profile names carry a printer/nozzle/variant qualifier after
+// `@` (e.g. "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)"), while the tray's
+// profile is typically the bare base name. Strip the qualifier before comparing so identical
+// base profiles don't trigger a mismatch warning (#1047).
+export const stripProfileQualifier = (value: string) => value.split('@')[0].trim();
+
+function checkMaterialMatch(
+  spoolMaterial: string | undefined | null,
+  trayMaterial: string | undefined | null,
+): 'exact' | 'partial' | 'none' {
+  const normalizedSpool = normalizeValue(spoolMaterial);
+  const normalizedTray = normalizeValue(trayMaterial);
+
+  if (!normalizedSpool || !normalizedTray) return 'none';
+  if (normalizedSpool === normalizedTray) return 'exact';
+  if (normalizedTray.includes(normalizedSpool) || normalizedSpool.includes(normalizedTray)) {
+    return 'partial';
+  }
+
+  return 'none';
+}
+
+function checkProfileMatch(spoolProfile: string | undefined | null, trayProfile: string | undefined | null): boolean {
+  const normalizedSpoolProfile = stripProfileQualifier(normalizeValue(spoolProfile));
+  const normalizedTrayProfile = stripProfileQualifier(normalizeValue(trayProfile));
+
+  if (!normalizedSpoolProfile || !normalizedTrayProfile) return false;
+
+  return normalizedSpoolProfile === normalizedTrayProfile;
+}
+
+export interface SlotFilament {
+  type: string;
+  material?: string;
+  profile?: string;
+}
+
+export interface MaterialMismatch {
+  type: 'material' | 'partial' | 'material_profile' | 'partial_profile';
+  spoolMaterial: string;
+  trayMaterial: string;
+  spoolProfile?: string;
+  trayProfile?: string;
+}
+
+/**
+ * The material mismatch to confirm before assigning `spool` to the slot, or
+ * null when there is none to ask about.
+ *
+ * Profile-only mismatch no longer triggers the popup — the backend's
+ * `apply_spool_to_slot_via_mqtt` pushes the spool's slicer profile to the AMS
+ * slot on every assign anyway, so warning the user about a profile delta then
+ * "fixing" it during the same action was friction without benefit (#1552).
+ * Material mismatch still warns because the firmware can refuse a print when
+ * type doesn't match; combined material+profile mismatches keep the profile
+ * detail in the same popup as the material warning.
+ *
+ * A slot reporting no material — empty, or a spool the printer can't identify
+ * — has nothing to mismatch: the question would read 'does not match the tray
+ * material ""'.
+ */
+export function findMaterialMismatch(
+  spool: { material?: string | null; slicer_filament_name?: string | null; slicer_filament?: string | null },
+  slot: SlotFilament,
+): MaterialMismatch | null {
+  const trayMaterial = slot.material || slot.type;
+  if (!normalizeValue(trayMaterial)) return null;
+  const materialMatchResult = checkMaterialMatch(spool.material, trayMaterial);
+  if (materialMatchResult === 'exact') return null;
+
+  const spoolProfile = spool.slicer_filament_name || spool.slicer_filament;
+  const trayProfile = slot.profile || slot.type;
+  const profileMatches = checkProfileMatch(spoolProfile, trayProfile);
+  let type: MaterialMismatch['type'];
+  if (materialMatchResult === 'none' && !profileMatches) {
+    type = 'material_profile';
+  } else if (materialMatchResult === 'partial' && !profileMatches) {
+    type = 'partial_profile';
+  } else if (materialMatchResult === 'none') {
+    type = 'material';
+  } else {
+    type = 'partial';
+  }
+  return {
+    type,
+    spoolMaterial: spool.material || '',
+    trayMaterial: trayMaterial || '',
+    spoolProfile: spoolProfile || undefined,
+    trayProfile: trayProfile || undefined,
+  };
+}
+
+/** The confirm's text for a mismatch at `location`. */
+export function materialMismatchMessage(t: TFunction, mismatch: MaterialMismatch, location: string): string {
+  const materials = { spoolMaterial: mismatch.spoolMaterial, trayMaterial: mismatch.trayMaterial, location };
+  const profiles = () =>
+    t('inventory.assignProfileMismatchMessage', {
+      spoolProfile: mismatch.spoolProfile || t('common.unknown'),
+      trayProfile: mismatch.trayProfile || t('common.unknown'),
+      location,
+    });
+  let message =
+    mismatch.type === 'material' || mismatch.type === 'material_profile'
+      ? t('inventory.assignMismatchMessage', materials)
+      : t('inventory.assignPartialMismatchMessage', materials);
+  if (mismatch.type === 'material_profile' || mismatch.type === 'partial_profile') {
+    message = `${message}\n\n${profiles()}`;
+  }
+  // Always tell the user the AMS slot is going to be reconfigured — the
+  // existing wording made "Assign Anyway" sound like the popup was a no-op
+  // confirmation, when the backend in fact pushes the spool's profile to the
+  // slot on every assign (#1552).
+  return `${message}\n\n${t('inventory.assignReconfigureNote')}`;
+}

+ 109 - 0
frontend/src/utils/qrAssignTarget.ts

@@ -0,0 +1,109 @@
+/**
+ * Scan-to-location assignment (#1574).
+ *
+ * The user picks a target (an AMS slot or a storage location), then scans a
+ * spool's label QR with the in-page camera. The scanned QR encodes a deeplink
+ * such as `https://<host>/inventory?spool=42`; we extract the spool id from it
+ * and assign that spool to the chosen target — no navigation, no edit modal.
+ */
+
+import type { SlotFilament } from './assignMismatch';
+
+export type AssignTarget =
+  | {
+      kind: 'ams';
+      printerId: number;
+      printerName: string;
+      amsId: number;
+      trayId: number;
+      isExternal: boolean;
+      /** Human-readable slot label, e.g. "A2", "HT-A", "Ext". */
+      label: string;
+      /** What the printer reported in the slot when it was picked, for the material check. */
+      filament: SlotFilament;
+    }
+  | {
+      kind: 'storage';
+      /** Structured Location id — the canonical storage model (`location_id`). */
+      locationId: number;
+      /** Location name, for the target chip and the success toast. */
+      locationName: string;
+    };
+
+/**
+ * The spool a scanned label names. A Bambuddy label (`…/inventory?spool=42`)
+ * carries the id of the inventory it was printed from — built-in or Spoolman,
+ * whichever Bambuddy was using. Spoolman's own labels carry a Spoolman id:
+ * `web+spoolman:s-42`, or `…/spool/show/42` when Spoolman prints URLs.
+ */
+export type ScannedSpool = { source: 'bambuddy' | 'spoolman'; id: number };
+
+function positiveId(text: string | null | undefined): number | null {
+  if (!text || !/^\d+$/.test(text)) return null;
+  const n = Number(text);
+  return Number.isSafeInteger(n) && n > 0 ? n : null;
+}
+
+/**
+ * Read a scanned QR payload. Returns null for anything that isn't a spool
+ * label — including 0, negatives, and non-spool QRs — so the caller can keep
+ * scanning instead of acting on garbage.
+ *
+ * A payload marked as Spoolman's (its scheme, or a /spool/show/ path) is read
+ * as a Spoolman label or not at all: `web+spoolman:s-x?spool=5` is not
+ * Bambuddy spool 5.
+ *
+ * A Bambuddy label is the deeplink the label printer emits (any host/scheme),
+ * a bare query fragment (`?spool=42` / `spool=42`), or a bare numeric string.
+ */
+export function parseSpoolQr(raw: string): ScannedSpool | null {
+  const text = (raw ?? '').trim();
+  if (!text) return null;
+
+  if (/^web\+spoolman:/i.test(text)) {
+    const id = positiveId(text.match(/^web\+spoolman:s-(\d+)$/i)?.[1]);
+    return id ? { source: 'spoolman', id } : null;
+  }
+  if (/\/spool\/show\//i.test(text)) {
+    let path = text;
+    try {
+      path = new URL(text).pathname;
+    } catch {
+      /* not a full URL — match the path as written */
+    }
+    const id = positiveId(path.match(/\/spool\/show\/(\d+)\/?$/i)?.[1]);
+    return id ? { source: 'spoolman', id } : null;
+  }
+
+  // Whole payload is just digits.
+  if (/^\d+$/.test(text)) {
+    const id = positiveId(text);
+    return id ? { source: 'bambuddy', id } : null;
+  }
+
+  // Try a real URL first (handles any host/scheme + extra params).
+  let spoolParam: string | null = null;
+  try {
+    spoolParam = new URL(text).searchParams.get('spool');
+  } catch {
+    /* not a full URL — handled by the regex fallback below */
+  }
+  // Fall back to a query-style match anywhere in the string — also covers a URL
+  // that parsed but carried `spool` in a hash fragment (e.g. a hash router).
+  if (!spoolParam) {
+    const m = text.match(/(?:[?&]|\b)spool=(\d+)/);
+    spoolParam = m ? m[1] : null;
+  }
+  const id = positiveId(spoolParam);
+  return id ? { source: 'bambuddy', id } : null;
+}
+
+/**
+ * The id to assign in the inventory in use, or null when the label is for an
+ * inventory that isn't: a Spoolman label means nothing to the built-in one. A
+ * Bambuddy label is taken in either mode, as it carries the id of whichever
+ * inventory printed it.
+ */
+export function scannedSpoolId(spool: ScannedSpool, spoolmanMode: boolean): number | null {
+  return spool.source === 'spoolman' && !spoolmanMode ? null : spool.id;
+}

+ 112 - 0
frontend/src/utils/qrDecode.ts

@@ -0,0 +1,112 @@
+/**
+ * QR decoding for the spool scanner (#1574): the platform BarcodeDetector where
+ * there is one that reads QR codes, jsQR otherwise.
+ *
+ * Nothing here throws or rejects for an unreadable frame or photo -- that is a
+ * frame or photo without a QR -- so a failure can't stop the live scan loop.
+ */
+
+import jsQR from 'jsqr';
+
+// BarcodeDetector (platform QR decoder, e.g. Android Chrome) isn't in the TS DOM
+// lib yet — minimal shape for the bit we use.
+interface DetectedBarcode {
+  rawValue: string;
+}
+export interface QrDetector {
+  detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
+}
+type BarcodeDetectorCtor = new (opts?: { formats?: string[] }) => QrDetector;
+
+/**
+ * The platform QR decoder, or null when there is none or it can't read QR
+ * codes: its constructor throws for a format the platform doesn't support.
+ */
+export function createQrDetector(): QrDetector | null {
+  const BD = (globalThis as unknown as { BarcodeDetector?: BarcodeDetectorCtor }).BarcodeDetector;
+  if (!BD) return null;
+  try {
+    return new BD({ formats: ['qr_code'] });
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Draw a frame/bitmap onto a canvas and decode a QR with jsQR. A canvas that
+ * can't be drawn or read (jsdom without the canvas package, a video with no
+ * frame yet) yields undefined. The caller owns the canvas so the live loop can
+ * reuse one across frames.
+ */
+export function decodeCanvas(
+  canvas: HTMLCanvasElement,
+  source: CanvasImageSource,
+  w: number,
+  h: number,
+  inversionAttempts: 'dontInvert' | 'attemptBoth',
+): string | undefined {
+  if (w <= 0 || h <= 0) return undefined;
+  try {
+    canvas.width = w;
+    canvas.height = h;
+    const ctx = canvas.getContext('2d', { willReadFrequently: true });
+    if (!ctx) return undefined;
+    ctx.drawImage(source, 0, 0, w, h);
+    const img = ctx.getImageData(0, 0, w, h);
+    return jsQR(img.data, w, h, { inversionAttempts })?.data ?? undefined;
+  } catch {
+    return undefined;
+  }
+}
+
+/**
+ * Decode a QR from a captured photo. The photo is taken with the phone's native
+ * camera (file input + capture) — which focuses far better than an in-page
+ * getUserMedia preview — so a small/dense spool QR resolves cleanly. Prefer the
+ * platform decoder; fall back to jsQR (downscaling huge stills for speed — a
+ * focused QR survives the downscale). Rejects only when the image itself can't
+ * be opened.
+ */
+export async function decodeImageBlob(blob: Blob): Promise<string | undefined> {
+  const bitmap = await createImageBitmap(blob, { imageOrientation: 'from-image' });
+  try {
+    const detector = createQrDetector();
+    if (detector) {
+      try {
+        const codes = await detector.detect(bitmap);
+        if (codes[0]?.rawValue) return codes[0].rawValue;
+      } catch {
+        /* the platform decoder failed on this image — jsQR gets a go */
+      }
+    }
+    const maxSide = 1600;
+    const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
+    const w = Math.max(1, Math.round(bitmap.width * scale));
+    const h = Math.max(1, Math.round(bitmap.height * scale));
+    return decodeCanvas(document.createElement('canvas'), bitmap, w, h, 'attemptBoth');
+  } finally {
+    bitmap.close?.();
+  }
+}
+
+/**
+ * A decoder for the live preview's frames. It starts on the platform decoder
+ * and drops it for jsQR the first time it throws or rejects. The returned
+ * promise never rejects, so the loop waiting on it always gets to its next
+ * frame.
+ */
+export function createFrameDecoder(): (video: HTMLVideoElement, canvas: HTMLCanvasElement) => Promise<string | undefined> {
+  let detector = createQrDetector();
+  return async (video, canvas) => {
+    if (detector) {
+      try {
+        const codes = await detector.detect(video);
+        return codes[0]?.rawValue;
+      } catch {
+        detector = null;
+      }
+    }
+    // 'dontInvert' (cheapest) for the hot loop; the photo path uses 'attemptBoth'.
+    return decodeCanvas(canvas, video, video.videoWidth, video.videoHeight, 'dontInvert');
+  };
+}