Преглед изворни кода

fix(inventory): one structured 409 for a tag another spool holds (issue #3110)

The two tag-link routes answered the same conflict differently. The
built-in one said "Tag UID already linked to another active spool" and
named nobody -- while holding the conflicting spool row it had just
loaded -- and Spoolman mode named the spool inside a different English
sentence. Neither was machine-readable, so a client had to parse prose
to learn which spool to look at, and could only do it in one mode.

Both now raise one shared constructor: code tag_already_linked, the
holder's id, and which identifier collided. That is the detail shape
insufficient_filament and printer_connection_failed already use, so
ApiError parses it with no frontend change.

Two active spools can carry one tag -- no unique index on either
column, no conflict check on PATCH /spools/{id}, and /spools/bulk
copies one payload including the tag into every row it creates -- and
the lookup read that with scalar_one_or_none(), which raises on two
rows. The exception escaped into the auth middleware's fail-closed
handler, so the caller was told the authentication service was
unavailable. Both lookups are now ordered and take the first row, as
get_spool_by_tag earlier in the same file always has.

Naming the lowest id means the Spoolman scan reads every row where it
used to stop at its first match, so it now reads extra.tag defensively:
that field is edited outside Bambuddy, and a single null further down
the list would otherwise take the request down in place of the 409.

The kiosk reads the new code: a refused link showed a flat "Failed to
assign spool" and now names the spool holding the tag, reusing the
inventory.tagAlreadyLinked key that no code referenced.
maziggy пре 4 дана
родитељ
комит
0db028f9e6

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
CHANGELOG.md


+ 30 - 8
backend/app/api/routes/inventory.py

@@ -67,6 +67,7 @@ from backend.app.services.spool_csv import (
 )
 from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
+from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     filament_id_to_setting_id,
@@ -2111,7 +2112,12 @@ async def link_tag_to_spool(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Link an RFID tag_uid/tray_uuid to an existing spool."""
+    """Link an RFID tag_uid/tray_uuid to an existing spool.
+
+    A tag another active spool already carries is refused with the shared
+    ``tag_already_linked`` 409, which names that spool so a caller can offer
+    to move the tag instead of only reporting that it is taken (#3110).
+    """
     result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
     if not spool:
@@ -2125,17 +2131,30 @@ async def link_tag_to_spool(
     _validate_tag_input(data.tag_uid, normalized_tag_uid, "tag_uid")
     _validate_tag_input(data.tray_uuid, normalized_tray_uuid, "tray_uuid", exact_len=32)
 
-    # Check for conflicts: tag already linked to another active spool
+    # Check for conflicts: tag already linked to another active spool.
+    #
+    # Ordered, and read with first() rather than scalar_one_or_none(), because
+    # two active spools really can carry one tag: neither column has a unique
+    # index, PATCH /spools/{id} writes them with no conflict check, and
+    # POST /spools/bulk copies a single payload -- tag included -- into every
+    # row it creates. scalar_one_or_none() answered that with MultipleResultsFound,
+    # which escapes into the auth middleware's fail-closed handler and reaches
+    # the caller as 503 "Authentication service temporarily unavailable" -- a
+    # wrong answer pointing at the wrong subsystem, where a 409 was owed
+    # (#3110). get_spool_by_tag above already resolves duplicates this way.
     if normalized_tag_uid:
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tag_uid) == normalized_tag_uid,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
             )
+            .order_by(Spool.id)
         )
-        if conflict.scalar_one_or_none():
-            raise HTTPException(409, "Tag UID already linked to another active spool")
+        holder = conflict.scalars().first()
+        if holder:
+            raise tag_already_linked("tag_uid", holder.id)
         # Auto-clear from archived spools (tag recycling)
         archived_with_tag = await db.execute(
             select(Spool).where(
@@ -2149,14 +2168,17 @@ async def link_tag_to_spool(
 
     if normalized_tray_uuid:
         conflict = await db.execute(
-            select(Spool).where(
+            select(Spool)
+            .where(
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,
                 Spool.id != spool_id,
                 Spool.archived_at.is_(None),
             )
+            .order_by(Spool.id)
         )
-        if conflict.scalar_one_or_none():
-            raise HTTPException(409, "Tray UUID already linked to another active spool")
+        holder = conflict.scalars().first()
+        if holder:
+            raise tag_already_linked("tray_uuid", holder.id)
         archived_with_uuid = await db.execute(
             select(Spool).where(
                 func.upper(Spool.tray_uuid) == normalized_tray_uuid,

+ 33 - 8
backend/app/api/routes/spoolman_inventory.py

@@ -62,6 +62,7 @@ from backend.app.services.spoolman import (
     init_spoolman_client,
 )
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+from backend.app.services.tag_conflict import tag_already_linked
 from backend.app.utils.color_utils import spoolman_color_hex
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -1154,6 +1155,18 @@ async def sync_spool_weight(
     return {"status": "ok", "weight_used": weight_used}
 
 
+def _extra_tag(spool: dict) -> str:
+    """The tag stored in a Spoolman spool's ``extra``, normalised for comparison.
+
+    Anything that is not a string reads as no tag. ``extra`` is free-form and
+    edited outside Bambuddy, and ``.get("tag", "")`` does not default a key
+    that is present and null -- that returns None, which has no ``.strip``.
+    """
+    extra = spool.get("extra")
+    raw = extra.get("tag") if isinstance(extra, dict) else None
+    return raw.strip('"').upper() if isinstance(raw, str) else ""
+
+
 @router.patch("/spools/{spool_id}/tag")
 async def link_tag_to_spoolman_spool(
     *,
@@ -1165,8 +1178,10 @@ async def link_tag_to_spoolman_spool(
     """Write an NFC tag UID or Bambu tray UUID into Spoolman's extra.tag for a spool.
 
     tray_uuid takes precedence over tag_uid when both are supplied.
-    Returns 409 if another spool already carries the same tag.
     Uses extra_lock to serialise against concurrent extra-field writes.
+
+    A tag another active spool already carries is refused with the shared
+    ``tag_already_linked`` 409, identical to the built-in route's (#3110).
     """
     client = await _get_client(db)
     tag = (data.tray_uuid or data.tag_uid).upper()
@@ -1174,15 +1189,25 @@ async def link_tag_to_spoolman_spool(
 
     async with client.extra_lock(spool_id):
         # Duplicate check: scan all spools for the same tag on a different spool.
+        # Sorted, because Spoolman has no unique constraint on extra.tag either,
+        # and a caller offered whichever row the scan happened to reach first
+        # could not tell two holders apart. The built-in route names the lowest
+        # id for the same reason (#3110).
+        #
+        # Sorting means every row is read, where the old loop stopped at its
+        # first match, so one malformed row after the holder must not be able
+        # to take the whole request down: _extra_tag refuses a non-string, and
+        # a row without an integer id cannot be named and so is not treated as
+        # a holder. Bambuddy only ever writes a JSON string here; a third party
+        # editing extra.tag in Spoolman is what puts anything else in reach.
         async with _translate_spoolman_errors():
             all_spools = await client.get_all_spools()
-        for s in all_spools:
-            s_tag = (s.get("extra") or {}).get("tag", "")
-            if s_tag.strip('"').upper() == tag and s.get("id") != spool_id:
-                raise HTTPException(
-                    status_code=409,
-                    detail=f"Tag is already assigned to spool {s['id']}",
-                )
+        holders = sorted(
+            (s for s in all_spools if _extra_tag(s) == tag and isinstance(s.get("id"), int) and s["id"] != spool_id),
+            key=lambda s: s["id"],
+        )
+        if holders:
+            raise tag_already_linked("tray_uuid" if data.tray_uuid else "tag_uid", holders[0]["id"])
 
         # Re-fetch inside the lock so cur_extra reflects any concurrent update.
         async with _translate_spoolman_errors():

+ 40 - 0
backend/app/services/tag_conflict.py

@@ -0,0 +1,40 @@
+"""The one answer both inventory modes give when a tag is already taken.
+
+Linking an RFID tag lives in two routes -- ``inventory.py`` for the built-in
+inventory and ``spoolman_inventory.py`` for Spoolman mode -- and they used to
+refuse a duplicate with two different sentences, only one of which named the
+spool holding the tag (#3110). A client cannot act on prose, so both now raise
+the structured detail built here.
+"""
+
+from __future__ import annotations
+
+from typing import Literal
+
+from fastapi import HTTPException
+
+# Which identifier collided: separate columns on a built-in spool, separate
+# lengths inside Spoolman's ``extra.tag``. A client that offers to move the tag
+# needs to know which of the two it is moving.
+TagField = Literal["tag_uid", "tray_uuid"]
+
+_FIELD_LABELS: dict[TagField, str] = {"tag_uid": "Tag UID", "tray_uuid": "Tray UUID"}
+
+
+def tag_already_linked(field: TagField, holder_id: int) -> HTTPException:
+    """409 naming the active spool that already carries this tag.
+
+    The frontend renders the user-facing message via i18n on ``code``;
+    ``message`` is an English fallback for non-UI clients (curl / scripts).
+    ``holder_id`` is what lets a caller offer to move the tag rather than only
+    report that it is taken.
+    """
+    return HTTPException(
+        status_code=409,
+        detail={
+            "code": "tag_already_linked",
+            "message": f"{_FIELD_LABELS[field]} is already linked to spool {holder_id}",
+            "spool_id": holder_id,
+            "field": field,
+        },
+    )

+ 155 - 0
backend/tests/integration/test_inventory_link_tag.py

@@ -0,0 +1,155 @@
+"""Conflict handling on PATCH /api/v1/inventory/spools/{id}/link-tag (#3110).
+
+The route loaded the conflicting spool row and then threw it away, refusing
+with a bare "already linked to another active spool" -- so a client could not
+tell which spool to look at, and could not offer to move the tag. It also read
+that row with ``scalar_one_or_none()``, which raises ``MultipleResultsFound``
+when two active spools carry one tag. Nothing prevents that duplicate: no
+unique index, no conflict check on PATCH /spools/{id}, and POST /spools/bulk
+copies one tag into every row it creates.
+
+That exception escapes the route into the auth middleware's fail-closed
+``except Exception`` (main.py:9685), so the caller does not even get a 500 --
+they get 503 "Authentication service temporarily unavailable" for a request
+that has nothing to do with auth. The middleware is right to fail closed
+(GHSA-6mf4-q26m-47pv); the route is what must not raise.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+TAG = "AABBCCDD"
+TRAY_UUID = "AABBCCDDEEFF0011AABBCCDDEEFF0011"
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    async def _create_spool(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Devil Design",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create_spool
+
+
+class TestLinkTagNamesTheHolder:
+    """The 409 carries the id the route already had in hand."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_uid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
+        holder = await spool_factory(tag_uid=TAG)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["code"] == "tag_already_linked"
+        assert detail["spool_id"] == holder.id
+        assert detail["field"] == "tag_uid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_conflict_names_the_spool_holding_it(self, async_client: AsyncClient, spool_factory):
+        holder = await spool_factory(tray_uuid=TRAY_UUID)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["spool_id"] == holder.id
+        # Which identifier collided, so a client knows what it would be moving.
+        assert detail["field"] == "tray_uuid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_free_tag_still_links(self, async_client: AsyncClient, spool_factory):
+        """Regression guard: the conflict rewrite must not refuse a clean link."""
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 200
+        assert resp.json()["tag_uid"] == TAG
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archived_holder_still_yields_the_tag(self, async_client: AsyncClient, spool_factory):
+        """Regression guard: tag recycling off archived spools is untouched."""
+        archived = await spool_factory(tag_uid=TAG, archived_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 200
+        reread = await async_client.get(f"/api/v1/inventory/spools/{archived.id}")
+        assert reread.json()["tag_uid"] is None
+
+
+class TestLinkTagDuplicateHolders:
+    """Two active spools on one tag is a 409 naming the lowest id, not a crash."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_tag_uid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
+        first = await spool_factory(tag_uid=TAG)
+        await spool_factory(tag_uid=TAG)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == first.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_tray_uuid_holders_yield_a_409_not_a_crash(self, async_client: AsyncClient, spool_factory):
+        first = await spool_factory(tray_uuid=TRAY_UUID)
+        await spool_factory(tray_uuid=TRAY_UUID)
+        target = await spool_factory()
+
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tray_uuid": TRAY_UUID})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == first.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_create_is_one_route_to_that_duplicate(self, async_client: AsyncClient, spool_factory):
+        """POST /spools/bulk copies a single payload -- tag included -- N times.
+
+        Reached through the API rather than the fixture, so the duplicate is
+        shown to be a state the app itself produces, not one only a test can
+        stage.
+        """
+        created = await async_client.post(
+            "/api/v1/inventory/spools/bulk",
+            json={"quantity": 2, "spool": {"material": "PLA", "label_weight": 1000, "tag_uid": TAG}},
+        )
+        assert created.status_code in (200, 201)
+        ids = sorted(s["id"] for s in created.json())
+        assert len(ids) == 2
+
+        target = await spool_factory()
+        resp = await async_client.patch(f"/api/v1/inventory/spools/{target.id}/link-tag", json={"tag_uid": TAG})
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == ids[0]

+ 113 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -2083,6 +2083,119 @@ class TestLinkTagDuplicate:
         detail = resp.json()["detail"]
         assert "42" in str(detail)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_409_is_the_same_structured_detail_as_the_built_in_route(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """#3110: one shape for both inventory modes, not two prose sentences.
+
+        The built-in route said "already linked to another active spool" and
+        named nobody; this one named the spool but only inside a sentence. A
+        client had to parse prose, and a different sentence per mode.
+        """
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        detail = resp.json()["detail"]
+        assert detail["code"] == "tag_already_linked"
+        assert detail["spool_id"] == 42
+        assert detail["field"] == "tray_uuid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_field_follows_the_precedence_the_tag_itself_uses(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """tray_uuid wins over tag_uid when both are sent, so `field` says so."""
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": '"AABBCCDDEEFF0011"'}}
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tag_uid": "AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["field"] == "tag_uid"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_duplicate_holders_yield_the_lowest_id(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Spoolman has no unique constraint on extra.tag either.
+
+        Whichever row the scan reached first was an arbitrary answer; the
+        built-in route names the lowest id, so this one does too.
+        """
+        tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 77, "extra": {"tag": tag}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_malformed_row_after_the_holder_does_not_sink_the_request(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """extra is free-form and edited outside Bambuddy.
+
+        Naming the lowest id means reading every row, where the old loop
+        stopped at its first match -- so a row whose extra.tag is a JSON null
+        (which .get("tag", "") hands back as None, not the default) sits
+        between the caller and their 409 in a way it never used to.
+        """
+        tag = '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 42, "extra": {"tag": tag}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 58, "extra": []},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/99/tag",
+            json={"tray_uuid": "AABBCCDDEEFF0011AABBCCDDEEFF0011"},
+        )
+
+        assert resp.status_code == 409
+        assert resp.json()["detail"]["spool_id"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_malformed_row_is_not_itself_read_as_a_holder(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """A link with no real conflict still succeeds past those rows."""
+        mock_spoolman_client.get_all_spools.return_value = [
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 55, "extra": {"tag": None}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 56, "extra": {"tag": 12345}},
+            {**SAMPLE_SPOOLMAN_SPOOL, "id": 57, "extra": None},
+        ]
+
+        resp = await async_client.patch(
+            "/api/v1/spoolman/inventory/spools/42/tag",
+            json={"tag_uid": "AABBCCDD112233"},
+        )
+
+        assert resp.status_code == 200
+        mock_spoolman_client.update_spool_full.assert_called_once()
+
 
 class TestSpoolmanInventoryUpdateCoreWeight:
     """core_weight is accepted for schema parity but not persisted — any value should be accepted."""

+ 36 - 0
backend/tests/unit/services/test_tag_conflict.py

@@ -0,0 +1,36 @@
+"""Unit tests for the shared tag-conflict 409 (#3110)."""
+
+from backend.app.services.tag_conflict import tag_already_linked
+
+
+class TestTagAlreadyLinked:
+    """Both inventory modes refuse a taken tag through this one constructor.
+
+    They used to answer the same situation with two different sentences, only
+    one of which named the spool holding the tag, and neither machine-readable.
+    """
+
+    def test_carries_the_holder_id_a_client_needs_to_offer_a_move(self):
+        exc = tag_already_linked("tag_uid", 42)
+
+        assert exc.status_code == 409
+        assert exc.detail["code"] == "tag_already_linked"
+        assert exc.detail["spool_id"] == 42
+        assert exc.detail["field"] == "tag_uid"
+
+    def test_names_which_identifier_collided(self):
+        # A client that offers to move the tag has to know which of the two
+        # columns it is moving; the id alone does not say.
+        assert tag_already_linked("tray_uuid", 7).detail["field"] == "tray_uuid"
+
+    def test_the_english_message_names_the_spool_for_non_ui_clients(self):
+        # curl and scripts never reach the i18n layer, so `message` has to
+        # stand on its own -- the old built-in sentence said only "another
+        # active spool" and dropped the id it had already loaded.
+        assert tag_already_linked("tag_uid", 42).detail["message"] == "Tag UID is already linked to spool 42"
+        assert tag_already_linked("tray_uuid", 42).detail["message"] == "Tray UUID is already linked to spool 42"
+
+    def test_both_fields_produce_the_same_code(self):
+        # One code, so the frontend needs one i18n key rather than branching
+        # on which endpoint answered.
+        assert tag_already_linked("tag_uid", 1).detail["code"] == tag_already_linked("tray_uuid", 2).detail["code"]

+ 100 - 3
frontend/src/__tests__/pages/SpoolBuddyDashboard.test.tsx

@@ -42,13 +42,37 @@ vi.mock('../../api/client', () => ({
   spoolbuddyApi: {
     getDevices: vi.fn().mockResolvedValue([]),
   },
+  // Real class, not a stub: the link handler branches on `instanceof ApiError`
+  // to decide whether a failure carries a structured code (#3110).
+  ApiError: class ApiError extends Error {
+    status: number;
+    code: string | null;
+    detail: Record<string, unknown> | null;
+    constructor(message: string, status: number, code: string | null = null, detail: Record<string, unknown> | null = null) {
+      super(message);
+      this.name = 'ApiError';
+      this.status = status;
+      this.code = code;
+      this.detail = detail;
+    }
+  },
 }));
 
+// Hoisted so the react-i18next factory can reach it: what the toast shows is
+// only half the contract -- the other half is that the spool id reaches the
+// interpolation bag (#3110), and the key-as-text mock cannot show that.
+const i18nSpy = vi.hoisted(() => ({ t: vi.fn() }));
+
 vi.mock('react-i18next', () => ({
   useTranslation: () => ({
-    // Mirrors i18next's (key, defaultValue, options) signature with simple
-    // {{var}} interpolation so tests can assert on the rendered text.
-    t: (key: string, fallback?: string, options?: Record<string, unknown>) => {
+    // Mirrors i18next's overloaded (key, defaultValue?, options?) signature --
+    // including the (key, options) form, where the second argument is the
+    // interpolation bag and there is no default value -- with simple {{var}}
+    // interpolation so tests can assert on the rendered text.
+    t: (key: string, fallbackOrOptions?: string | Record<string, unknown>, maybeOptions?: Record<string, unknown>) => {
+      i18nSpy.t(key, fallbackOrOptions, maybeOptions);
+      const fallback = typeof fallbackOrOptions === 'string' ? fallbackOrOptions : undefined;
+      const options = typeof fallbackOrOptions === 'object' ? fallbackOrOptions : maybeOptions;
       const text = fallback ?? key;
       if (!options) return text;
       return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => String(options[k] ?? ''));
@@ -438,6 +462,79 @@ describe('SpoolBuddyDashboard', () => {
       });
     });
 
+    it('names the spool holding the tag when Spoolman refuses the link (#3110)', async () => {
+      const { api, ApiError } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+      ]);
+      (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
+        new ApiError('Tray UUID is already linked to spool 42', 409, 'tag_already_linked', {
+          code: 'tag_already_linked',
+          message: 'Tray UUID is already linked to spool 42',
+          spool_id: 42,
+          field: 'tray_uuid',
+        }),
+      );
+
+      renderPage({
+        unknownTagUid: 'AABB1122334455FF',
+        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
+      });
+
+      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
+      fireEvent.click(linkBtn);
+      fireEvent.click(await waitFor(() => screen.getByText('Orange')));
+      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith('inventory.tagAlreadyLinked', 'error');
+      });
+      // The operator can only walk to the other spool if the id is in the
+      // sentence, so assert it reached the interpolation bag rather than
+      // trusting the key-as-text mock's output.
+      expect(i18nSpy.t).toHaveBeenCalledWith('inventory.tagAlreadyLinked', { id: 42 }, undefined);
+    });
+
+    it('keeps the generic toast for a link failure that carries no code', async () => {
+      const { api, ApiError } = await import('../../api/client');
+      (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'off',
+        spoolman_disable_weight_sync: 'false',
+        spoolman_report_partial_usage: 'false',
+      });
+      (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([
+        { id: 30, material: 'TPU', brand: 'Bambu', tag_uid: null, tray_uuid: null, archived_at: null, color_name: 'Orange', rgba: 'FF6600FF', subtype: null, label_weight: 1000, core_weight: 250, weight_used: 0 },
+      ]);
+      // A 409 from something other than a tag conflict, and a plain-string
+      // detail, must not be dressed up as one.
+      (api.linkTagToSpoolmanSpool as ReturnType<typeof vi.fn>).mockRejectedValue(
+        new ApiError('Spoolman unavailable', 503),
+      );
+
+      renderPage({
+        unknownTagUid: 'AABB1122334455FF',
+        unknownTrayUuid: 'DEADBEEFDEADBEEFDEADBEEFDEADBEEF',
+      });
+
+      const linkBtn = await waitFor(() => screen.getByText('Assign Spool'));
+      fireEvent.click(linkBtn);
+      fireEvent.click(await waitFor(() => screen.getByText('Orange')));
+      fireEvent.click(await waitFor(() => screen.getByText('Link Tag')));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith('spoolman.linkFailed', 'error');
+      });
+    });
+
     it('clears justLinkedSpool and shows new UnknownTagCard when a different tag is placed', async () => {
       const { api } = await import('../../api/client');
       (api.getSpoolmanSettings as ReturnType<typeof vi.fn>).mockResolvedValue({

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -4923,7 +4923,7 @@ export default {
     linkToSpool: 'Mit Spule verknüpfen',
     tagLinked: 'Tag mit Spule verknüpft',
     tagLinkFailed: 'Tag-Verknüpfung fehlgeschlagen',
-    tagAlreadyLinked: 'Tag bereits mit anderer Spule verknüpft',
+    tagAlreadyLinked: 'Tag bereits mit Spule #{{id}} verknüpft',
     unknownTag: 'Unbekannter RFID-Tag erkannt',
     // Verbrauchshistorie
     usageHistory: 'Verbrauchshistorie',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -4968,7 +4968,7 @@ export default {
     linkToSpool: 'Link to Spool',
     tagLinked: 'Tag linked to spool',
     tagLinkFailed: 'Failed to link tag',
-    tagAlreadyLinked: 'Tag already linked to another spool',
+    tagAlreadyLinked: 'Tag already linked to spool #{{id}}',
     unknownTag: 'Unknown RFID tag detected',
     // Usage history
     usageHistory: 'Usage History',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -4930,7 +4930,7 @@ export default {
     linkToSpool: 'Vincular a bobina',
     tagLinked: 'Etiqueta vinculada a la bobina',
     tagLinkFailed: 'Error al vincular la etiqueta',
-    tagAlreadyLinked: 'La etiqueta ya está vinculada a otra bobina',
+    tagAlreadyLinked: 'La etiqueta ya está vinculada a la bobina #{{id}}',
     unknownTag: 'Se detectó una etiqueta RFID desconocida',
     // Usage history
     usageHistory: 'Historial de uso',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -4912,7 +4912,7 @@ export default {
     linkToSpool: 'Lier à une Bobine',
     tagLinked: 'Tag lié à la bobine',
     tagLinkFailed: 'Échec lien tag',
-    tagAlreadyLinked: 'Tag déjà lié à une autre bobine',
+    tagAlreadyLinked: 'Tag déjà lié à la bobine #{{id}}',
     unknownTag: 'Tag RFID inconnu détecté',
     // Usage history
     usageHistory: 'Historique de Consommation',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -4911,7 +4911,7 @@ export default {
     linkToSpool: 'Collega a bobina',
     tagLinked: 'Tag collegato alla bobina',
     tagLinkFailed: 'Impossibile collegare il tag',
-    tagAlreadyLinked: 'Tag già collegato a un\'altra bobina',
+    tagAlreadyLinked: 'Tag già collegato alla bobina #{{id}}',
     unknownTag: 'Tag RFID sconosciuto rilevato',
     // Usage history
     usageHistory: 'Cronologia utilizzo',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -4923,7 +4923,7 @@ export default {
     linkToSpool: 'スプールにリンク',
     tagLinked: 'タグがスプールにリンクされました',
     tagLinkFailed: 'タグのリンクに失敗しました',
-    tagAlreadyLinked: 'タグは既に別のスプールにリンクされています',
+    tagAlreadyLinked: 'タグは既にスプール #{{id}} にリンクされています',
     unknownTag: '不明なRFIDタグが検出されました',
     // Usage history
     usageHistory: '使用履歴',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -4690,7 +4690,7 @@ export default {
     linkToSpool: '스풀에 연결',
     tagLinked: '태그가 스풀에 연결됨',
     tagLinkFailed: '태그 연결 실패',
-    tagAlreadyLinked: '태그가 이미 다른 스풀에 연결됨',
+    tagAlreadyLinked: '태그가 이미 스풀 #{{id}}에 연결됨',
     unknownTag: '알 수 없는 RFID 태그 감지됨',
     usageHistory: '사용 기록',
     noUsageHistory: '사용 기록 없음',

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -4968,7 +4968,7 @@ export default {
     linkToSpool: 'Koppelen aan spoel',
     tagLinked: 'Tag aan spoel gekoppeld',
     tagLinkFailed: 'Tag koppelen mislukt',
-    tagAlreadyLinked: 'Tag is al aan een andere spoel gekoppeld',
+    tagAlreadyLinked: 'Tag is al aan spoel #{{id}} gekoppeld',
     unknownTag: 'Onbekende RFID-tag gedetecteerd',
     // Usage history
     usageHistory: 'Verbruiksgeschiedenis',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4911,7 +4911,7 @@ export default {
     linkToSpool: 'Vincular ao Carretel',
     tagLinked: 'Tag vinculada ao carretel',
     tagLinkFailed: 'Falha ao vincular tag',
-    tagAlreadyLinked: 'Tag já vinculada a outro carretel',
+    tagAlreadyLinked: 'Tag já vinculada ao carretel #{{id}}',
     unknownTag: 'Tag RFID desconhecida detectada',
     // Usage history
     usageHistory: 'Histórico de Uso',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -4690,7 +4690,7 @@ export default {
     linkToSpool: "Связать с катушкой",
     tagLinked: "Метка связана с катушкой",
     tagLinkFailed: "Не удалось связать метку",
-    tagAlreadyLinked: "Метка уже связана с другой катушкой",
+    tagAlreadyLinked: 'Метка уже связана с катушкой #{{id}}',
     unknownTag: "Обнаружена неизвестная RFID-метка",
     usageHistory: "История расхода",
     noUsageHistory: "Расход пока не зарегистрирован",

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -4902,7 +4902,7 @@ export default {
     linkToSpool: 'Makaraya Bağla',
     tagLinked: 'Etiket makaraya bağlandı',
     tagLinkFailed: 'Etiket bağlanamadı',
-    tagAlreadyLinked: 'Etiket zaten başka bir makaraya bağlı',
+    tagAlreadyLinked: 'Etiket zaten #{{id}} numaralı makaraya bağlı',
     unknownTag: 'Bilinmeyen RFID etiketi algılandı',
     usageHistory: 'Kullanım Geçmişi',
     noUsageHistory: 'Henüz kullanım kaydedilmedi',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -4965,7 +4965,7 @@ export default {
     linkToSpool: "Прив’язати до котушки",
     tagLinked: "Тег пов’язано з котушкою",
     tagLinkFailed: "Не вдалося зв’язати тег",
-    tagAlreadyLinked: "Тег уже пов’язано з іншою котушкою",
+    tagAlreadyLinked: 'Тег уже пов’язано з котушкою #{{id}}',
     unknownTag: "Виявлено невідомий тег RFID.",
     // Usage history
     usageHistory: "Історія використання",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4917,7 +4917,7 @@ export default {
     linkToSpool: '链接到耗材',
     tagLinked: '标签已链接到耗材',
     tagLinkFailed: '链接标签失败',
-    tagAlreadyLinked: '标签已链接到其他耗材',
+    tagAlreadyLinked: '标签已链接到耗材 #{{id}}',
     unknownTag: '检测到未知 RFID 标签',
     // Usage history
     usageHistory: '使用历史',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4917,7 +4917,7 @@ export default {
     linkToSpool: '連結到耗材',
     tagLinked: '標籤已連結到耗材',
     tagLinkFailed: '連結標籤失敗',
-    tagAlreadyLinked: '標籤已連結到其他耗材',
+    tagAlreadyLinked: '標籤已連結到耗材 #{{id}}',
     unknownTag: '偵測到未知 RFID 標籤',
     // Usage history
     usageHistory: '使用歷史',

+ 13 - 2
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -3,7 +3,7 @@ import { useOutletContext } from 'react-router-dom';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
-import { api, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
+import { api, ApiError, type InventorySpool, type Printer, type PrinterStatus } from '../../api/client';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { useToast } from '../../contexts/ToastContext';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
@@ -390,7 +390,18 @@ export function SpoolBuddyDashboard() {
       refetchSpools();
     } catch (e) {
       console.error('Failed to link tag:', e);
-      showToast(t('spoolman.linkFailed'), 'error');
+      // The tag is already on another spool -- name it, so the operator can
+      // walk to that spool instead of retrying a scan that cannot succeed.
+      // Both inventory modes answer this with the same structured 409 (#3110);
+      // every other failure keeps the generic toast.
+      const holder =
+        e instanceof ApiError && e.status === 409 && e.code === 'tag_already_linked'
+          ? e.detail?.spool_id
+          : undefined;
+      showToast(
+        typeof holder === 'number' ? t('inventory.tagAlreadyLinked', { id: holder }) : t('spoolman.linkFailed'),
+        'error',
+      );
     } finally {
       setShowLinkModal(false);
     }

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-BlnLA2nI.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B9X0fIlJ.js"></script>
+    <script type="module" crossorigin src="/assets/index-BlnLA2nI.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Неке датотеке нису приказане због велике количине промена