Forráskód Böngészése

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

Samed Yüksel 1 napja
szülő
commit
96c90de41b
45 módosított fájl, 4218 hozzáadás és 264 törlés
  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 logging
+from contextlib import AsyncExitStack
 from datetime import date, datetime, timezone
 
 import httpx
@@ -69,7 +70,7 @@ from backend.app.schemas.supplier import (
     SupplierUpdate,
 )
 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.location_service import (
     DUPLICATE_LOCATION_NAME,
@@ -2424,6 +2425,12 @@ async def list_assignments(
     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)
 async def assign_spool(
     data: SpoolAssignmentCreate,
@@ -2502,34 +2509,80 @@ async def assign_spool(
 
     # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
     # 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)
     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.
     #
@@ -2645,14 +2698,15 @@ async def assign_spool(
             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
 

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

@@ -12,13 +12,13 @@ import json
 import logging
 import re
 import time
-from contextlib import asynccontextmanager
+from contextlib import asynccontextmanager, nullcontext
 from datetime import datetime
 
 from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response
 from fastapi.responses import JSONResponse
 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.ext.asyncio import AsyncSession
 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.supplier import SpoolSupplierLinkInput
 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 (
     enrich_spool_dicts_with_location_id,
     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:
     """Return a validated Spoolman client (URL checked, health-checked) or raise an HTTP error."""
     result = await db.execute(select(Settings))
@@ -458,6 +500,9 @@ class SpoolSlotAssignmentRequest(BaseModel):
     printer_id: int = Field(..., gt=0)
     ams_id: SlotAmsId
     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"):
         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
     # 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
     ams_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):

+ 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.
 The Spoolman sync block in on_ams_change stays concurrent because it's
 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
 
 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:
@@ -36,3 +40,22 @@ def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
         lock = asyncio.Lock()
         ams_assignment_locks[printer_id] = 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",
         "i18next": "25.6.3",
         "i18next-browser-languagedetector": "^8.2.0",
+        "jsqr": "^1.4.0",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
         "mdast-util-gfm-footnote": "^2.1.0",
@@ -5838,6 +5839,12 @@
         "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": {
       "version": "3.10.1",
       "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",

+ 1 - 0
frontend/package.json

@@ -34,6 +34,7 @@
     "dompurify": "^3.4.17",
     "i18next": "25.6.3",
     "i18next-browser-languagedetector": "^8.2.0",
+    "jsqr": "^1.4.0",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.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} />);
 
+    // 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(() => {
-      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

+ 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,
       // in both modes; an open dialog kept the previous spool's figures.
       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.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,
     // whatever the viewport is.
     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 { vi } from 'vitest';
 import { render } from '@testing-library/react';
 import type { RenderOptions } from '@testing-library/react';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -79,3 +80,25 @@ export { createTestQueryClient };
  * Helper to wait for async operations.
  */
 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) =>
     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', {
       method: 'POST',
       body: JSON.stringify(data),
@@ -7413,7 +7415,8 @@ export const api = {
       method: 'PATCH',
       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', {
       method: 'POST',
       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 { 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 type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
-import { ConfirmModal } from './ConfirmModal';
+import { MaterialMismatchConfirm } from './MaterialMismatchConfirm';
+import { QrSpoolScanner } from './QrSpoolScanner';
 import { useToast } from '../contexts/ToastContext';
+import { findMaterialMismatch, normalizeValue, stripProfileQualifier, type MaterialMismatch } from '../utils/assignMismatch';
 import { exactSpoolIdQuery, filterSpoolsByQuery } from '../utils/inventorySearch';
 import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
+import { parseSpoolQr, scannedSpoolId } from '../utils/qrAssignTarget';
 import { useUiSettings } from '../hooks/useUiSettings';
 
 interface AssignSpoolModalProps {
@@ -23,10 +26,20 @@ interface AssignSpoolModalProps {
     profile?: string;
     color: 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;
 }
 
+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) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -39,27 +52,22 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     setSelectedSpoolmanSpoolId(null);
   }, [disableFiltering]);
   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(() => {
     if (isOpen) {
       setDisableFiltering(false);
+      setScanning(false);
+      setScanError(null);
+      scanInFlightRef.current = false;
     }
   }, [isOpen]);
 
@@ -76,7 +84,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   // explicitly excluded client-side because the backend rejects archived
   // assignments with HTTP 400 anyway, so listing them would only let the
   // user click a button that fails.
-  const { data: spools, isLoading } = useQuery({
+  const { data: spools, isLoading, isFetching: spoolsFetching } = useQuery({
     queryKey: ['inventory-spools', 'assign-modal'],
     queryFn: () => api.getSpools(true),
     enabled: isOpen && !spoolmanEnabled,
@@ -135,13 +143,15 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   };
 
   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
       queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
         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);
         return filtered;
@@ -161,76 +171,47 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
         ? 'inventory.assignPendingInsert'
         : 'inventory.assignSuccess';
       showToast(t(toastKey), 'success');
-      setShowMismatchConfirm(false);
-      setPendingAssignId(null);
-      setMismatchDetails(null);
+      setMismatch(null);
       onClose();
     },
     onError: (error: Error) => {
       showToast(`${t('inventory.assignFailed')}: ${error.message}`, 'error');
+      scanInFlightRef.current = false; // a scan can try again
     },
   });
 
   const assignSpoolmanMutation = useMutation({
-    mutationFn: (spoolmanSpoolId: number) =>
+    mutationFn: ({ spoolmanSpoolId, move }: { spoolmanSpoolId: number; move?: boolean }) =>
       api.assignSpoolmanSlot({
         spoolman_spool_id: spoolmanSpoolId,
         printer_id: printerId,
         ams_id: amsId,
         tray_id: trayId,
+        ...(move && { move }),
       }),
-    onSuccess: () => {
+    onSuccess: (_data, { move }) => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       // Prefix match: also the all-printers list this dialog filters
       // "assigned elsewhere" by.
       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();
       showToast(t('inventory.assignSuccess'), 'success');
       onClose();
     },
     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;
 
   // 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 });
   }
 
+  // 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 = () => {
     if (selectedSpoolmanSpoolId !== null) {
-      assignSpoolmanMutation.mutate(selectedSpoolmanSpoolId);
+      assignSpoolmanMutation.mutate({ spoolmanSpoolId: selectedSpoolmanSpoolId });
       return;
     }
     if (!selectedSpoolId) return;
@@ -320,51 +312,81 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       showToast(t('inventory.assignFailed'), 'error');
       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 (
     <>
       <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 */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <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>
           <button
             onClick={onClose}
@@ -390,7 +414,8 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
 
         {/* Content */}
         <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 && (
             <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>
@@ -401,22 +426,55 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                     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>
               </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>
           )}
 
-          {/* 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>
 
           {/* Spool list */}
@@ -562,9 +620,21 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
               </>
             )}
           </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 items-center gap-2">
             <input
@@ -600,6 +670,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
             </Button>
           </div>
         </div>
+        )}
 
 
         {assignMutation.isError && (
@@ -611,70 +682,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       </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
         // its slot rows live under their own query keys.
         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;
 
       case 'spool_assignment_verified': {

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

@@ -5320,6 +5320,33 @@ export default {
     spoolDeleted: 'Spule gelöscht',
     deepLinkSpoolNotFound: 'Spule nicht gefunden',
     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',
     spoolRestored: 'Spule wiederhergestellt',
     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',
     deepLinkSpoolNotFound: 'Spool not found',
     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',
     spoolRestored: 'Spool restored',
     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',
     deepLinkSpoolNotFound: 'Bobina no encontrada',
     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',
     spoolRestored: 'Bobina restaurada',
     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',
     deepLinkSpoolNotFound: 'Bobine introuvable',
     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',
     spoolRestored: 'Bobine restaurée',
     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',
     deepLinkSpoolNotFound: 'Bobina non trovata',
     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',
     spoolRestored: 'Bobina ripristinata',
     kProfileSaveFailed: 'Impossibile salvare le impostazioni del profilo K',

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

@@ -5319,6 +5319,33 @@ export default {
     spoolDeleted: 'スプールを削除しました',
     deepLinkSpoolNotFound: 'スプールが見つかりません',
     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: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
     kProfileSaveFailed: 'Kプロファイル設定を保存できませんでした',

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

@@ -5104,6 +5104,33 @@ export default {
     spoolDeleted: '스풀 삭제됨',
     deepLinkSpoolNotFound: '스풀을 찾을 수 없음',
     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: '스풀 보관됨',
     spoolRestored: '스풀 복원됨',
     kProfileSaveFailed: 'K-프로필 설정을 저장할 수 없습니다',

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

@@ -5361,6 +5361,33 @@ export default {
     spoolDeleted: 'Spoel verwijderd',
     deepLinkSpoolNotFound: 'Spoel niet gevonden',
     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',
     spoolRestored: 'Spoel hersteld',
     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',
     deepLinkSpoolNotFound: 'Carretel não encontrado',
     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',
     spoolRestored: 'Carretel restaurado',
     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: "Катушка удалена",
     deepLinkSpoolNotFound: "Катушка не найдена",
     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: "Катушка архивирована",
     spoolRestored: "Катушка восстановлена",
     kProfileSaveFailed: "Не удалось сохранить настройки K-профиля",

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

@@ -5359,6 +5359,33 @@ errors: {
     spoolDeleted: 'Rulle borttagen',
     deepLinkSpoolNotFound: 'Rulle hittades ej',
     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',
     spoolRestored: 'Rulle återställd',
     kProfileSaveFailed: 'K-profilinställningar kunde inte sparas',

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

@@ -5306,6 +5306,33 @@ export default {
     spoolDeleted: 'Makara silindi',
     deepLinkSpoolNotFound: 'Makara bulunamadı',
     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',
     spoolRestored: 'Makara geri yüklendi',
     kProfileSaveFailed: 'K-profili ayarları kaydedilemedi',

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

@@ -5356,6 +5356,33 @@ export default {
     spoolDeleted: "Котушку видалено",
     deepLinkSpoolNotFound: "Котушку не знайдено",
     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: "Котушку заархівовано",
     spoolRestored: "Котушку відновлено",
     kProfileSaveFailed: "Не вдалося зберегти налаштування K-профілю",

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

@@ -5311,6 +5311,33 @@ export default {
     spoolDeleted: '耗材已删除',
     deepLinkSpoolNotFound: '未找到耗材',
     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: '耗材已归档',
     spoolRestored: '耗材已恢复',
     kProfileSaveFailed: 'K值配置文件设置无法保存',

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

@@ -5311,6 +5311,33 @@ export default {
     spoolDeleted: '耗材已刪除',
     deepLinkSpoolNotFound: '找不到耗材',
     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: '耗材已歸檔',
     spoolRestored: '耗材已恢復',
     kProfileSaveFailed: 'K值設定檔設定無法儲存',

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

@@ -7,7 +7,7 @@ import {
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw, TrendingUp, Lock, Copy, Eraser, MapPin,
-  Upload, Download, Store,
+  Upload, Download, Store, QrCode,
 } from 'lucide-react';
 import { ForecastPanel } from '../components/ForecastPanel';
 import { api, spoolbuddyApi, ApiError } from '../api/client';
@@ -17,6 +17,7 @@ import { FilamentSwatch } from '../components/FilamentSwatch';
 import { describeHASensorReading, iconForHASensor } from '../utils/haSensorDisplay';
 import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import {SpoolFormModal, type SpoolFormMode} from '../components/SpoolFormModal';
+import { QrAssignTargetModal } from '../components/QrAssignTargetModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { LabelTemplatePickerModal } from '../components/LabelTemplatePickerModal';
@@ -666,6 +667,9 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
   const [searchParams, setSearchParams] = useSearchParams();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null; mode: SpoolFormMode } | null>(null);
   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<
     | { type: 'delete' | 'archive' | 'reset-consumed-counter'; spoolId: number }
     | { type: 'reset-all-consumed-counters' }
@@ -1676,6 +1680,10 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
             <Printer className="w-4 h-4" />
             {t('inventory.labels.printLabels', 'Print labels…')}
           </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' })}>
             <Plus className="w-4 h-4" />
             {t('inventory.addSpool')}
@@ -1683,6 +1691,14 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         </div>
       </div>
 
+      {qrTargetModalOpen && (
+        <QrAssignTargetModal
+          isOpen={qrTargetModalOpen}
+          onClose={() => setQrTargetModalOpen(false)}
+          spoolmanMode={spoolmanMode}
+        />
+      )}
+
       {/* Stats Bar */}
       {stats && !isLoading && (
         <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;
     amsId: 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);
   const [configureSlotModal, setConfigureSlotModal] = useState<{
     amsId: number;
@@ -5824,6 +5824,7 @@ function PrinterCard({
                                             profile: '',
                                             color: '',
                                             location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                            emptyKind,
                                           },
                                         })}
                                       >
@@ -6227,6 +6228,7 @@ function PrinterCard({
                                         profile: '',
                                         color: '',
                                         location: getAmsLabel(ams.id),
+                                        emptyKind,
                                       },
                                     })}
                                   >
@@ -6504,6 +6506,7 @@ function PrinterCard({
                                           profile: '',
                                           color: '',
                                           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 { api } 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 { mapModelCode } from '../../utils/printerModel';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
@@ -273,7 +273,7 @@ export function SpoolBuddyAmsPage() {
     printerId: number;
     amsId: 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);
 
   // Link spool modal state (Spoolman)
@@ -432,7 +432,7 @@ export function SpoolBuddyAmsPage() {
 
   const openAssignFromPicker = useCallback(() => {
     if (!slotActionPicker || !selectedPrinterId) return;
-    const { amsId, trayId, trayType, trayColor, location } = slotActionPicker;
+    const { amsId, trayId, tray, trayType, trayColor, location } = slotActionPicker;
     setSlotActionPicker(null);
     setAssignSpoolModal({
       printerId: selectedPrinterId,
@@ -443,6 +443,7 @@ export function SpoolBuddyAmsPage() {
         material: trayType,
         color: trayColor?.slice(0, 6) || '',
         location,
+        emptyKind: getEmptySlotKind(tray),
       },
     });
   }, [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 { useTranslation } from 'react-i18next';
 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 type { InventorySpool } from '../../api/client';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
@@ -13,6 +13,7 @@ import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
+import { QrAssignTargetModal } from '../../components/QrAssignTargetModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { useToast } from '../../contexts/ToastContext';
 
@@ -92,6 +93,7 @@ export function SpoolBuddyInventoryPage() {
   const [filterMode, setFilterMode] = useState<FilterMode>('all');
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [showAssignAmsModal, setShowAssignAmsModal] = useState(false);
+  const [qrTargetModalOpen, setQrTargetModalOpen] = useState(false);
 
   const { data: spoolmanSettings } = useQuery({
     queryKey: ['spoolman-settings'],
@@ -186,6 +188,15 @@ export function SpoolBuddyInventoryPage() {
     <div className="h-full flex flex-col">
       {/* Search + filter pills */}
       <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 */}
         <div className="relative">
           <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>
   );
 }

+ 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');
+  };
+}