Quellcode durchsuchen

fix(spoolman): a location delete is refused while Spoolman can't be read; labels print for archived spools; archived spools are not assigned or linked to slots; slot requests outside the AMS ranges are refused before Spoolman is written; bulk counter resets report the spools that failed

maziggy vor 2 Tagen
Ursprung
Commit
c2bc41a5ef

+ 6 - 0
CHANGELOG.md

@@ -164,6 +164,12 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman inventory: five requests refused or reported correctly** —
+  - **Deleting a storage location while Spoolman was unreachable** counted its spools from the built-in inventory, found none, and deleted a location Spoolman spools still used. The delete is now refused until Spoolman answers.
+  - **Printing labels for an archived Spoolman spool** failed the whole batch with "not found", as Spoolman leaves archived spools out of its plain spool list.
+  - **An archived Spoolman spool could be assigned to an AMS slot** (or put on one by a tag link) and then charged the next print's usage. It is now refused, as in the built-in inventory.
+  - **A slot assignment or tag link for an AMS number no printer has** failed with a server error, and the link had already written the tag to Spoolman. Such a request is now refused before anything is written.
+  - **Resetting the "Total Consumed" counters** reported success even when some or all of them failed. The result now says how many failed, as the other bulk actions do.
 - **A Git backup restore keeps "Reset Total Consumed"** — The backup did not include the point each spool's "Total Consumed" counter was reset at, so a restore brought every reset counter back to its full lifetime figure. It is now backed up and restored; a backup made before this keeps the reset points the spools already have.
 - **A finished print's filament is charged once** — Two cases in the built-in inventory:
   - **An AMS update right as a print finished** could raise the spool's used weight from the AMS remain% while the print's exact usage was still being worked out, and that usage was then added on top: the print was charged twice. The AMS sync now waits until the print is charged, as it does while the print runs.

+ 14 - 3
backend/app/api/routes/inventory.py

@@ -641,14 +641,24 @@ async def _spool_counts_for_locations(
     db: AsyncSession,
     locations: list[Location],
     settings: dict[str, str],
+    *,
+    strict: bool = False,
 ) -> dict[int, int]:
+    """Spools at each location. In Spoolman mode they are Spoolman's; when it
+    can't be read the built-in counts are shown instead, which ``strict``
+    refuses with a 503 (SM-C13): the built-in table holds no Spoolman spools,
+    so a location still in use would count as empty and could be deleted."""
     if _spoolman_is_enabled(settings):
         client = await _ensure_spoolman_client(settings)
+        if not client and strict:
+            raise HTTPException(status_code=503, detail="Spoolman is unavailable")
         if client:
             try:
                 spools = await client.get_all_spools(allow_archived=False)
-            except Exception:
+            except Exception as exc:
                 logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
+                if strict:
+                    raise HTTPException(status_code=503, detail="Spoolman is unavailable") from exc
             else:
                 # Use the canonical key helper so this matches what the
                 # migration backfill, Location.name_key, and every other
@@ -791,7 +801,7 @@ async def delete_location(
         raise HTTPException(status_code=404, detail="Location not found")
 
     settings = await _load_settings_map(db)
-    counts = await _spool_counts_for_locations(db, [location], settings)
+    counts = await _spool_counts_for_locations(db, [location], settings, strict=True)
     if counts.get(location.id, 0) > 0:
         raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
 
@@ -1820,7 +1830,8 @@ async def bulk_reset_spool_consumed_counter(
         spool.weight_used_baseline = spool.weight_used or 0
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": len(spools)}
+    found = {spool.id for spool in spools}
+    return {"reset": len(spools), "not_found": [sid for sid in spool_ids if sid not in found]}
 
 
 class BulkUpdateRequest(BaseModel):

+ 4 - 5
backend/app/api/routes/labels.py

@@ -268,10 +268,9 @@ async def _local_label_data(spool_ids: list[int], request: Request, db: AsyncSes
 
 
 async def _spoolman_label_data(spool_ids: list[int], request: Request, db: AsyncSession) -> list[LabelData]:
-    """The Spoolman client doesn't expose a per-id endpoint, so this fetches the
-    full spool list and filters in-memory. For typical libraries (~50 spools)
-    that's negligible; for very large libraries this is the trade-off until
-    Spoolman gains a bulk filter.
+    """Fetches the full spool list once and filters in-memory, rather than one
+    request per id. Archived spools included: Spoolman leaves them out of its
+    plain list, and the Inventory page can show and select them (SM-C12).
     """
     spoolman_on = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
     if not spoolman_on:
@@ -282,7 +281,7 @@ async def _spoolman_label_data(spool_ids: list[int], request: Request, db: Async
         raise HTTPException(503, "Spoolman not reachable")
 
     try:
-        all_spools = await client.get_spools()
+        all_spools = await client.get_all_spools(allow_archived=True)
     except Exception as exc:
         logger.warning("Spoolman fetch failed during label render: %s", exc)
         raise HTTPException(502, "Failed to fetch spools from Spoolman") from exc

+ 15 - 2
backend/app/api/routes/spoolman.py

@@ -26,6 +26,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
 from backend.app.services import slot_unlink_grace
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.printer_manager import PrinterManager, printer_manager
@@ -850,8 +851,8 @@ class LinkSpoolRequest(BaseModel):
     tray_uuid: str | None = None
     tag_uid: str | None = None
     printer_id: int | None = None
-    ams_id: int | None = None
-    tray_id: int | None = None
+    ams_id: SlotAmsId | None = None
+    tray_id: SlotTrayId | None = None
 
 
 @router.post("/spools/{spool_id}/link")
@@ -903,6 +904,18 @@ async def link_spool(
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
         printer_context = (request.printer_id, request.ams_id, request.tray_id)
+        # An archived spool on a slot would be charged the next print's usage;
+        # refused before anything is written, as the slot assignment does (SSA-8).
+        try:
+            linked = await client.get_spool(spool_id)
+        except SpoolmanNotFoundError:
+            raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
+        except SpoolmanClientError:
+            raise HTTPException(status_code=502, detail="Spoolman rejected the request")
+        except SpoolmanUnavailableError:
+            raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+        if linked.get("archived"):
+            raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
 
     try:
         # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The

+ 14 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -53,7 +53,7 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
-from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import slot_unlink_grace
 from backend.app.services.location_service import (
@@ -456,9 +456,8 @@ class SpoolTagLinkRequest(BaseModel):
 class SpoolSlotAssignmentRequest(BaseModel):
     spoolman_spool_id: int = Field(..., gt=0)
     printer_id: int = Field(..., gt=0)
-    # ams_id 0–7 for physical AMS units; 255 = external/virtual spool extruder slot
-    ams_id: int = Field(..., ge=0, le=255)
-    tray_id: int = Field(..., ge=0, le=3)
+    ams_id: SlotAmsId
+    tray_id: SlotTrayId
 
 
 # ---------------------------------------------------------------------------
@@ -1280,8 +1279,10 @@ async def bulk_reset_spool_consumed_counter(
 
     Caller passes an explicit list of IDs — no "reset all" shortcut, since
     a typo on a wildcard would wipe the entire inventory's tracking.
-    Returns the count of spools successfully reset; individual failures are
-    logged but do not abort the batch.
+    Returns the count of spools reset and, like the other bulk actions, the
+    ones Spoolman refused in ``errors``: an individual failure does not abort
+    the batch, and a batch where every reset failed is not reported as a
+    success (FO-C8).
     """
     spool_ids = payload.get("spool_ids")
     if not isinstance(spool_ids, list) or not spool_ids:
@@ -1291,6 +1292,7 @@ async def bulk_reset_spool_consumed_counter(
 
     client = await _get_client(db)
     reset_count = 0
+    errors: list[dict] = []
     for spool_id in spool_ids:
         try:
             async with _translate_spoolman_errors():
@@ -1298,9 +1300,10 @@ async def bulk_reset_spool_consumed_counter(
             reset_count += 1
         except HTTPException as exc:
             logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
+            errors.append({"id": spool_id, "status": exc.status_code, "detail": exc.detail})
     if reset_count:
         await ws_manager.broadcast({"type": "inventory_changed"})
-    return {"reset": reset_count}
+    return {"reset": reset_count, "errors": errors}
 
 
 @router.patch("/spools/{spool_id}/weight")
@@ -1788,6 +1791,10 @@ async def assign_spoolman_slot(
     # This prevents ghost rows pointing at non-existent spool IDs.
     async with _translate_spoolman_errors():
         spool = await client.get_spool(body.spoolman_spool_id)
+    # As the built-in inventory refuses it: an archived spool on a slot would
+    # be charged the next print's usage (SSA-8).
+    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)

+ 17 - 1
backend/app/schemas/spoolman.py

@@ -1,4 +1,20 @@
-from pydantic import BaseModel, Field, model_validator
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field, model_validator
+
+
+def _a_slot_ams_id(ams_id: int) -> int:
+    """The ams_ids spoolman_slot_assignments' CHECK admits: AMS units 0-7,
+    AMS-HT 128-191, the external slot 255. Checked on the request, so a write
+    to Spoolman that goes with the slot row is never made for a row the
+    database then refuses (SSA-9)."""
+    if 0 <= ams_id <= 7 or 128 <= ams_id <= 191 or ams_id == 255:
+        return ams_id
+    raise ValueError("ams_id must be 0-7 (AMS), 128-191 (AMS-HT) or 255 (external)")
+
+
+SlotAmsId = Annotated[int, AfterValidator(_a_slot_ams_id)]
+SlotTrayId = Annotated[int, Field(ge=0, le=3)]
 
 
 class SpoolmanFilamentPatch(BaseModel):

+ 28 - 0
backend/tests/integration/test_labels.py

@@ -216,6 +216,7 @@ class TestSpoolmanLabels:
         mock_client = MagicMock()
         mock_client.is_connected = True
         mock_client.get_spools = AsyncMock(return_value=[spoolman_spool])
+        mock_client.get_all_spools = AsyncMock(return_value=[spoolman_spool])
 
         with patch(
             "backend.app.api.routes.labels.get_spoolman_client",
@@ -243,6 +244,7 @@ class TestSpoolmanLabels:
         mock_client = MagicMock()
         mock_client.is_connected = True
         mock_client.get_spools = AsyncMock(return_value=[{"id": 1, "filament": {"name": "X", "material": "PLA"}}])
+        mock_client.get_all_spools = mock_client.get_spools
 
         with patch(
             "backend.app.api.routes.labels.get_spoolman_client",
@@ -255,6 +257,31 @@ class TestSpoolmanLabels:
         assert resp.status_code == 404
         assert "99" in resp.text
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_renders_an_archived_spool(self, async_client: AsyncClient, db_session: AsyncSession):
+        """SM-C12: Spoolman leaves archived spools out of its plain list, so the
+        whole batch failed with a 404 when one was selected."""
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        await db_session.commit()
+        active = {"id": 1, "filament": {"name": "X", "material": "PLA"}}
+        archived = {"id": 2, "archived": True, "filament": {"name": "Y", "material": "PETG"}}
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.get_spools = AsyncMock(return_value=[active])
+        mock_client.get_all_spools = AsyncMock(
+            side_effect=lambda allow_archived=False: [active, archived] if allow_archived else [active]
+        )
+
+        with patch("backend.app.api.routes.labels.get_spoolman_client", AsyncMock(return_value=mock_client)):
+            resp = await async_client.post(
+                "/api/v1/spoolman/labels", json={"spool_ids": [1, 2], "template": "box_62x29"}
+            )
+
+        assert resp.status_code == 200, resp.text
+
 
 # ── Validation cross-cutting ─────────────────────────────────────────────────
 
@@ -308,6 +335,7 @@ def _spoolman_client(spools: list[dict]) -> MagicMock:
     client = MagicMock()
     client.is_connected = True
     client.get_spools = AsyncMock(return_value=spools)
+    client.get_all_spools = AsyncMock(return_value=spools)
     return client
 
 

+ 48 - 0
backend/tests/integration/test_locations_api.py

@@ -187,3 +187,51 @@ async def test_locations_routes_require_auth_when_enabled(async_client: AsyncCli
     # DELETE /locations/{id} — write-gated
     delete_resp = await async_client.delete("/api/v1/inventory/locations/99999")
     assert delete_resp.status_code == 401, delete_resp.text
+
+
+async def _spoolman_mode(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.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("spoolman", ["unreachable", "no client"])
+async def test_spoolman_mode_refuses_a_delete_it_cannot_count_for(async_client: AsyncClient, db_session, spoolman):
+    """SM-C13: the count fell back to the built-in table, which holds no
+    Spoolman spools, so a location Spoolman spools still use was deleted."""
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    await _spoolman_mode(db_session)
+    loc = (await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})).json()
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(side_effect=ConnectionError("refused"))
+
+    with patch(
+        "backend.app.api.routes.inventory._ensure_spoolman_client",
+        AsyncMock(return_value=client if spoolman == "unreachable" else None),
+    ):
+        resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+
+    assert resp.status_code == 503, resp.text
+    names = [row["name"] for row in (await async_client.get("/api/v1/inventory/locations")).json()]
+    assert "Shelf A" in names
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spoolman_mode_deletes_a_location_no_spool_uses(async_client: AsyncClient, db_session):
+    from unittest.mock import AsyncMock, MagicMock, patch
+
+    await _spoolman_mode(db_session)
+    loc = (await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})).json()
+    client = MagicMock()
+    client.get_all_spools = AsyncMock(return_value=[{"id": 1, "location": "Shelf B"}])
+
+    with patch("backend.app.api.routes.inventory._ensure_spoolman_client", AsyncMock(return_value=client)):
+        resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+
+    assert resp.status_code == 200, resp.text

+ 14 - 1
backend/tests/integration/test_spool_reset_usage.py

@@ -159,7 +159,7 @@ class TestBulkResetSpoolUsage:
         )
 
         assert response.status_code == 200
-        assert response.json() == {"reset": 2}
+        assert response.json() == {"reset": 2, "not_found": []}
 
         # The endpoint commits via its own session — expire our session so the
         # next read pulls fresh values rather than the cached pre-reset state.
@@ -173,6 +173,19 @@ class TestBulkResetSpoolUsage:
         assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
         assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
+        """As the other bulk actions do, so the page can tell a partial reset (FO-C8)."""
+        spool = await spool_factory(weight_used=100.0)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-consumed-counter-bulk",
+            json={"spool_ids": [spool.id, 99999]},
+        )
+
+        assert response.json() == {"reset": 1, "not_found": [99999]}
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):

+ 4 - 0
backend/tests/integration/test_spoolman_api.py

@@ -488,6 +488,8 @@ class TestSpoolmanAPI:
         mock_spoolman_client.merge_spool_extra = AsyncMock(
             return_value={"id": 5, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
+        # A link onto a slot checks the spool is not archived first.
+        mock_spoolman_client.get_spool = AsyncMock(return_value={"id": 5, "archived": False, "extra": {}})
         printer = await printer_factory()
 
         response = await async_client.post(
@@ -607,6 +609,8 @@ class TestSpoolmanAPI:
         mock_spoolman_client.merge_spool_extra = AsyncMock(
             return_value={"id": 7, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
         )
+        # A link onto a slot checks the spool is not archived first.
+        mock_spoolman_client.get_spool = AsyncMock(return_value={"id": 7, "archived": False, "extra": {}})
         printer = await printer_factory()
 
         # First link to create the slot assignment

+ 31 - 1
backend/tests/integration/test_spoolman_inventory_api.py

@@ -709,9 +709,39 @@ class TestSpoolmanInventoryCRUD:
         )
 
         assert response.status_code == 200
-        assert response.json() == {"reset": 3}
+        assert response.json() == {"reset": 3, "errors": []}
         assert mock_spoolman_client.reset_spool_consumed_counter.call_count == 3
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_reports_the_spools_it_could_not_reset(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """FO-C8: every failure was swallowed into {"reset": 0}, which the page
+        showed as a green "Counter reset for 0 spool(s)"."""
+        from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
+
+        def reset(spool_id):
+            if spool_id == 2:
+                raise SpoolmanNotFoundError("gone")
+            if spool_id == 3:
+                raise SpoolmanUnavailableError("down")
+
+        mock_spoolman_client.reset_spool_consumed_counter = AsyncMock(side_effect=reset)
+
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-consumed-counter-bulk",
+            json={"spool_ids": [1, 2, 3]},
+        )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["reset"] == 1
+        assert [(e["id"], e["status"]) for e in body["errors"]] == [(2, 404), (3, 503)]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_reset_rejects_empty_list(

+ 88 - 0
backend/tests/integration/test_spoolman_slot_requests.py

@@ -0,0 +1,88 @@
+"""Spoolman slot requests the database would refuse are refused up front (SSA-8, SSA-9).
+
+An ams_id the table's CHECK does not admit (it allows AMS units 0-7, AMS-HT
+128-191 and the external slot 255) came back as a 500. The link route also
+wrote the spool's tag to Spoolman before the local insert failed, leaving a
+half-applied link. And an archived Spoolman spool could be assigned to a slot,
+then charged the next print's usage; the built-in inventory refuses that.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.tests.integration.test_spoolman_assignment_verification import (  # noqa: F401
+    SPOOL,
+    _spoolman_client,
+    printer_id,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _assign(async_client, printer_id, spool=SPOOL, **slot):
+    client = _spoolman_client()
+    client.get_spool = AsyncMock(return_value=spool)
+    with (
+        patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
+        patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm,
+    ):
+        pm.get_client = MagicMock(return_value=None)
+        pm.get_status = MagicMock(return_value=None)
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/slot-assignments",
+            json={"spoolman_spool_id": 10, "printer_id": printer_id, **{"ams_id": 0, "tray_id": 0, **slot}},
+        )
+    return response
+
+
+@pytest.mark.parametrize("ams_id", [8, 50, 127, 192, 254])
+async def test_an_ams_id_no_slot_has_is_refused(async_client: AsyncClient, printer_id, ams_id):
+    assert (await _assign(async_client, printer_id, ams_id=ams_id)).status_code == 422
+
+
+@pytest.mark.parametrize("ams_id", [0, 7, 128, 191, 255])
+async def test_every_real_slot_is_accepted(async_client: AsyncClient, printer_id, ams_id):
+    response = await _assign(async_client, printer_id, ams_id=ams_id)
+
+    assert response.status_code == 200, response.text
+
+
+async def test_an_archived_spool_is_refused(async_client: AsyncClient, printer_id):
+    response = await _assign(async_client, printer_id, spool={**SPOOL, "archived": True})
+
+    assert response.status_code == 400, response.text
+
+
+@pytest.mark.parametrize(("ams_id", "tray_id"), [(50, 0), (0, 4)])
+async def test_a_link_to_no_real_slot_writes_nothing_to_spoolman(
+    async_client: AsyncClient, printer_id, ams_id, tray_id
+):
+    client = _spoolman_client()
+    with patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/10/link",
+            json={
+                "tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                "printer_id": printer_id,
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+            },
+        )
+
+    assert response.status_code == 422, response.text
+    client.merge_spool_extra.assert_not_awaited()
+
+
+async def test_a_link_puts_no_archived_spool_on_a_slot(async_client: AsyncClient, printer_id):
+    client = _spoolman_client()
+    client.get_spool = AsyncMock(return_value={**SPOOL, "archived": True})
+    with patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=client)):
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/10/link",
+            json={"spool_tag": "A1B2C3D4E5F6A1B2", "printer_id": printer_id, "ams_id": 0, "tray_id": 0},
+        )
+
+    assert response.status_code == 400, response.text
+    client.merge_spool_extra.assert_not_awaited()

+ 39 - 1
frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx

@@ -17,7 +17,8 @@
  *      to un-archive it first.
  */
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import InventoryPageRouter from '../../pages/InventoryPage';
 import { http, HttpResponse } from 'msw';
@@ -206,3 +207,40 @@ describe('InventoryPage — Total Consumed includes archived (#1390 follow-up)',
     });
   });
 });
+
+// FO-C8: a reset of every counter where some or all failed (Spoolman refusing
+// them, spools deleted meanwhile) was shown as a green "Counter reset for N".
+describe('InventoryPage — resetting all counters says what failed', () => {
+  const resetAll = async (response: Record<string, unknown>) => {
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({ spoolman_enabled: 'false' })),
+      http.post('/api/v1/inventory/spools/reset-consumed-counter-bulk', () => HttpResponse.json(response)),
+    );
+    const user = userEvent.setup();
+    render(<InventoryPageRouter />);
+    await user.click(await screen.findByRole('button', { name: 'Reset all counters' }));
+    const modal = (await screen.findByText(/Reset the consumed-grams counter to 0 on all/)).closest('.fixed');
+    await user.click(within(modal as HTMLElement).getByRole('button', { name: 'Reset counter' }));
+  };
+
+  it('says so when none was reset', async () => {
+    await resetAll({ reset: 0, not_found: [1, 2] });
+
+    expect(await screen.findByText('No counter was reset: all 2 failed')).toBeInTheDocument();
+  });
+
+  it('says how many failed when some were', async () => {
+    await resetAll({ reset: 1, errors: [{ id: 2, status: 503, detail: 'down' }] });
+
+    expect(await screen.findByText('Counter reset for 1 spool(s), 1 failed')).toBeInTheDocument();
+  });
+
+  it('confirms a full reset as before', async () => {
+    await resetAll({ reset: 2, not_found: [] });
+
+    expect(await screen.findByText('Counter reset for 2 spool(s)')).toBeInTheDocument();
+  });
+});

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

@@ -7014,7 +7014,7 @@ export const api = {
   resetSpoolConsumedCounter: (id: number) =>
     request<InventorySpool>(`/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
   bulkResetSpoolConsumedCounter: (spoolIds: number[]) =>
-    request<{ reset: number }>(`/inventory/spools/reset-consumed-counter-bulk`, {
+    request<{ reset: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }>(`/inventory/spools/reset-consumed-counter-bulk`, {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),
@@ -7250,7 +7250,7 @@ export const api = {
   resetSpoolmanInventorySpoolConsumedCounter: (id: number) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
   bulkResetSpoolmanInventorySpoolConsumedCounter: (spoolIds: number[]) =>
-    request<{ reset: number }>(`/spoolman/inventory/spools/reset-consumed-counter-bulk`, {
+    request<{ reset: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/reset-consumed-counter-bulk`, {
       method: 'POST',
       body: JSON.stringify({ spool_ids: spoolIds }),
     }),

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

@@ -5002,6 +5002,8 @@ export default {
       updateFailed: 'Sammelaktualisierung fehlgeschlagen',
       updatePartial: '{{ok}} Spulen aktualisiert, {{failed}} fehlgeschlagen',
       updateAllFailed: 'Alle {{count}} Aktualisierungen fehlgeschlagen — Auswahl bleibt erhalten zum erneuten Versuch',
+      resetPartial: 'Zähler für {{ok}} Spule(n) zurückgesetzt, {{failed}} fehlgeschlagen',
+      resetAllFailed: 'Kein Zähler zurückgesetzt: alle {{count}} fehlgeschlagen',
       deleteSuccess: '{{count}} Spulen gelöscht',
       deleteFailed: 'Sammellöschung fehlgeschlagen',
       deletePartial: '{{ok}} Spulen gelöscht, {{failed}} fehlgeschlagen',

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

@@ -5040,6 +5040,8 @@ export default {
       updateFailed: 'Bulk update failed',
       updatePartial: '{{ok}} spools updated, {{failed}} failed',
       updateAllFailed: 'All {{count}} spool updates failed — selection kept so you can retry',
+      resetPartial: 'Counter reset for {{ok}} spool(s), {{failed}} failed',
+      resetAllFailed: 'No counter was reset: all {{count}} failed',
       deleteSuccess: '{{count}} spools deleted',
       deleteFailed: 'Bulk delete failed',
       deletePartial: '{{ok}} spools deleted, {{failed}} failed',

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

@@ -5004,6 +5004,8 @@ export default {
       updateFailed: 'Actualización masiva fallida',
       updatePartial: '{{ok}} bobinas actualizadas, {{failed}} fallidas',
       updateAllFailed: 'Las {{count}} actualizaciones fallaron — la selección se mantiene para reintentar',
+      resetPartial: 'Contador restablecido en {{ok}} bobina(s), {{failed}} fallidas',
+      resetAllFailed: 'No se restableció ningún contador: las {{count}} fallaron',
       deleteSuccess: '{{count}} bobinas eliminadas',
       deleteFailed: 'Eliminación masiva fallida',
       deletePartial: '{{ok}} bobinas eliminadas, {{failed}} fallidas',

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

@@ -4990,6 +4990,8 @@ export default {
       updateFailed: 'Mise à jour groupée échouée',
       updatePartial: '{{ok}} bobines mises à jour, {{failed}} échouées',
       updateAllFailed: 'Les {{count}} mises à jour ont échoué — la sélection est conservée pour réessayer',
+      resetPartial: 'Compteur réinitialisé pour {{ok}} bobine(s), {{failed}} en échec',
+      resetAllFailed: 'Aucun compteur réinitialisé : les {{count}} ont échoué',
       deleteSuccess: '{{count}} bobines supprimées',
       deleteFailed: 'Suppression groupée échouée',
       deletePartial: '{{ok}} bobines supprimées, {{failed}} échouées',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: 'Aggiornamento in blocco fallito',
       updatePartial: '{{ok}} bobine aggiornate, {{failed}} fallite',
       updateAllFailed: 'Tutti i {{count}} aggiornamenti sono falliti — la selezione viene mantenuta per riprovare',
+      resetPartial: 'Contatore azzerato per {{ok}} bobina/e, {{failed}} non riuscite',
+      resetAllFailed: 'Nessun contatore azzerato: tutte le {{count}} operazioni sono fallite',
       deleteSuccess: '{{count}} bobine eliminate',
       deleteFailed: 'Eliminazione in blocco fallita',
       deletePartial: '{{ok}} bobine eliminate, {{failed}} fallite',

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

@@ -5002,6 +5002,8 @@ export default {
       updateFailed: '一括更新に失敗しました',
       updatePartial: '{{ok}} 件更新、{{failed}} 件失敗',
       updateAllFailed: '{{count}} 件すべての更新に失敗しました — 再試行のため選択は維持されます',
+      resetPartial: '{{ok}} 件のカウンターをリセット、{{failed}} 件失敗',
+      resetAllFailed: 'カウンターはリセットされませんでした: {{count}} 件すべて失敗',
       deleteSuccess: '{{count}} 件のスプールを削除しました',
       deleteFailed: '一括削除に失敗しました',
       deletePartial: '{{ok}} 件削除、{{failed}} 件失敗',

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

@@ -4790,6 +4790,8 @@ export default {
       updateFailed: '일괄 업데이트 실패',
       updatePartial: '{{ok}}개 업데이트, {{failed}}개 실패',
       updateAllFailed: '{{count}}개의 업데이트가 모두 실패했습니다 — 재시도를 위해 선택이 유지됩니다',
+      resetPartial: '{{ok}}개 카운터 초기화, {{failed}}개 실패',
+      resetAllFailed: '초기화된 카운터 없음: {{count}}개 모두 실패',
       deleteSuccess: '{{count}}개의 스풀 삭제됨',
       deleteFailed: '일괄 삭제 실패',
       deletePartial: '{{ok}}개 삭제, {{failed}}개 실패',

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

@@ -5038,6 +5038,8 @@ export default {
       updateFailed: 'Bulkupdate mislukt',
       updatePartial: '{{ok}} spoelen bijgewerkt, {{failed}} mislukt',
       updateAllFailed: 'Alle {{count}} spoelupdates zijn mislukt — selectie blijft behouden zodat je opnieuw kunt proberen',
+      resetPartial: 'Teller gereset voor {{ok}} spoel(en), {{failed}} mislukt',
+      resetAllFailed: 'Geen teller gereset: alle {{count}} mislukt',
       deleteSuccess: '{{count}} spoelen verwijderd',
       deleteFailed: 'Bulkverwijdering mislukt',
       deletePartial: '{{ok}} spoelen verwijderd, {{failed}} mislukt',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: 'Atualização em massa falhou',
       updatePartial: '{{ok}} carretéis atualizados, {{failed}} falharam',
       updateAllFailed: 'Todas as {{count}} atualizações falharam — a seleção é mantida para nova tentativa',
+      resetPartial: 'Contador zerado em {{ok}} carretel(is), {{failed}} falharam',
+      resetAllFailed: 'Nenhum contador foi zerado: todos os {{count}} falharam',
       deleteSuccess: '{{count}} carretéis excluídos',
       deleteFailed: 'Exclusão em massa falhou',
       deletePartial: '{{ok}} carretéis excluídos, {{failed}} falharam',

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

@@ -4780,6 +4780,8 @@ export default {
       updateFailed: "Ошибка массового обновления",
       updatePartial: "Обновлено: {{ok}}, с ошибкой: {{failed}}",
       updateAllFailed: "Не удалось обновить все {{count}} катушек. Выделение сохранено для повторной попытки.",
+      resetPartial: "Счётчик сброшен: {{ok}}, с ошибкой: {{failed}}",
+      resetAllFailed: "Ни один счётчик не сброшен: все {{count}} с ошибкой",
       deleteSuccess: "Удалено катушек: {{count}}",
       deleteFailed: "Ошибка массового удаления",
       deletePartial: "Удалено: {{ok}}, с ошибкой: {{failed}}",

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

@@ -5037,6 +5037,8 @@ errors: {
       updateFailed: 'Kunde inte uppdatera',
       updatePartial: '{{ok}} rullar uppdaterade, {{failed}} misslyckades',
       updateAllFailed: 'Alla {{count}} rulluppdateringar misslyckades — val bevaras så att du kan försöka igen',
+      resetPartial: 'Räknaren nollställd för {{ok}} rulle/rullar, {{failed}} misslyckades',
+      resetAllFailed: 'Ingen räknare nollställdes: alla {{count}} misslyckades',
       deleteSuccess: '{{count}} rullar borttagna',
       deleteFailed: 'Kunde inte ta bort',
       deletePartial: '{{ok}} rullar borttagna, {{failed}} misslyckades',

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

@@ -4991,6 +4991,8 @@ export default {
       updateFailed: 'Toplu güncelleme başarısız',
       updatePartial: '{{ok}} makara güncellendi, {{failed}} başarısız',
       updateAllFailed: '{{count}} güncellemenin tümü başarısız oldu — yeniden denemek için seçim korundu',
+      resetPartial: '{{ok}} makaranın sayacı sıfırlandı, {{failed}} başarısız',
+      resetAllFailed: 'Hiçbir sayaç sıfırlanmadı: {{count}} işlemin tümü başarısız',
       deleteSuccess: '{{count}} makara silindi',
       deleteFailed: 'Toplu silme başarısız',
       deletePartial: '{{ok}} makara silindi, {{failed}} başarısız',

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

@@ -5034,6 +5034,8 @@ export default {
       updateFailed: "Помилка масового оновлення",
       updatePartial: "Оновлено: {{ok}}; не вдалося оновити: {{failed}}",
       updateAllFailed: "Не вдалося оновити жодну з вибраних котушок ({{count}}). Вибір збережено для повторної спроби.",
+      resetPartial: "Лічильник скинуто: {{ok}}; не вдалося: {{failed}}",
+      resetAllFailed: "Жоден лічильник не скинуто: усі {{count}} з помилкою",
       deleteSuccess: "Котушок видалено: {{count}}",
       deleteFailed: "Не вдалося масово видалити",
       deletePartial: "Видалено: {{ok}}; не вдалося видалити: {{failed}}",

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: '批量更新失败',
       updatePartial: '已更新 {{ok}} 个,{{failed}} 个失败',
       updateAllFailed: '{{count}} 个料盘更新全部失败 — 已保留选择以便重试',
+      resetPartial: '已重置 {{ok}} 个料盘的计数器,{{failed}} 个失败',
+      resetAllFailed: '未重置任何计数器:{{count}} 个全部失败',
       deleteSuccess: '已删除 {{count}} 个料盘',
       deleteFailed: '批量删除失败',
       deletePartial: '已删除 {{ok}} 个,{{failed}} 个失败',

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

@@ -4989,6 +4989,8 @@ export default {
       updateFailed: '批次更新失敗',
       updatePartial: '已更新 {{ok}} 個,{{failed}} 個失敗',
       updateAllFailed: '{{count}} 個料盤更新全部失敗 — 已保留選擇以便重試',
+      resetPartial: '已重設 {{ok}} 個料盤的計數器,{{failed}} 個失敗',
+      resetAllFailed: '未重設任何計數器:{{count}} 個全部失敗',
       deleteSuccess: '已刪除 {{count}} 個料盤',
       deleteFailed: '批次刪除失敗',
       deletePartial: '已刪除 {{ok}} 個,{{failed}} 個失敗',

+ 19 - 8
frontend/src/pages/InventoryPage.tsx

@@ -966,6 +966,13 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     },
   });
 
+  // Helper: count items that didn't succeed across the two response shapes
+  // (internal mode returns not_found, Spoolman returns errors[]). When the
+  // success count is 0 OR any failures occurred, surface that to the user
+  // instead of the silent green-toast-and-clear flow the first cut shipped.
+  const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
+    (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
+
   const bulkResetConsumedCounterMutation = useMutation({
     mutationFn: (ids: number[]) =>
       spoolmanMode
@@ -973,7 +980,18 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         : api.bulkResetSpoolConsumedCounter(ids),
     onSuccess: (data) => {
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
-      showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
+      // As the other bulk actions: a reset that failed for some or all spools
+      // is not a success (FO-C8).
+      const failed = failedCount(data);
+      if (data.reset === 0 && failed > 0) {
+        showToast(t('inventory.bulk.resetAllFailed', { count: failed }), 'error');
+        return; // keep the selection so the user can retry
+      }
+      if (failed > 0) {
+        showToast(t('inventory.bulk.resetPartial', { ok: data.reset, failed }), 'warning');
+      } else {
+        showToast(t('inventory.allConsumedCountersReset', { count: data.reset }), 'success');
+      }
       // Close any open bulk-confirm modal + clear selection so the toolbar
       // collapses after the action — matches the other three bulk mutations
       // and stops the confirm dialog from lingering after Reset.
@@ -987,13 +1005,6 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
 
   // Bulk action mutations (#1795). Each invalidates the same query keys as
   // the per-spool equivalents so the table refreshes with the new state.
-  // Helper: count items that didn't succeed across the two response shapes
-  // (internal mode returns not_found, Spoolman returns errors[]). When the
-  // success count is 0 OR any failures occurred, surface that to the user
-  // instead of the silent green-toast-and-clear flow the first cut shipped.
-  const failedCount = (data: { not_found?: number[]; errors?: Array<{ id: number }> }): number =>
-    (data.not_found?.length ?? 0) + (data.errors?.length ?? 0);
-
   const bulkUpdateMutation = useMutation({
     mutationFn: async ({ ids, update }: { ids: number[]; update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>> }): Promise<{ updated: number; not_found?: number[]; errors?: Array<{ id: number; status: number; detail: string }> }> => {
       if (spoolmanMode) return api.bulkUpdateSpoolmanInventorySpools(ids, update);