Jelajahi Sumber

fix(inventory): deleting a spool frees and announces its slots; scale, AMS and manual weight syncs and a finished print's usage refresh other tabs and the printer cards; a deleted printer's spool assignments go with it on SQLite; a pre-assigned slot the printer could not be told about is retried; CSV last_used is stored in UTC

maziggy 3 hari lalu
induk
melakukan
5edbe7586d

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ 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
+- **Inventory views stay current, and spools follow their printers and slots** — Five cases:
+  - **Deleting a spool** left it shown in its AMS slot in other open tabs, and **a weight from the SpoolBuddy scale, the automatic AMS sync or "Sync Weights from AMS"** reached other open tabs and the printer cards only when they next refreshed. These changes are now announced like any other inventory change, and the printer cards also show the weight a finished print leaves straight away.
+  - **Deleting a printer** on SQLite (the default database) left its spools assigned to it, shown as loaded on a printer that no longer exists. Their slots are now freed, and assignments left behind by earlier deletes are cleared at the next start.
+  - **A spool assigned to an empty slot from SpoolBuddy** that the printer could not be told about when filament went in (for example while it was reconnecting) was marked done anyway and never configured. It is now tried again on the next AMS change.
+  - **A spool CSV import** stored a `last_used` time with a timezone offset as if it were UTC on SQLite; it is now converted.
 - **Filament matching, preheat temperatures and colour names follow the plate's own filaments** — Three cases:
   - **An "Any model" job with a filament override** waited forever when the printers held an equivalent filament under another name (PA-CF loaded for a PA12-CF override). The type check already counted it as the same filament, but the colour check that picks the printer did not.
   - **Preheating a plate of a multi-filament project** heated the bed for the hottest filament of the whole project: a PLA plate in a project that also holds PETG was preheated, and kept warm between jobs, at the PETG temperature. Preheat and keep-warm now heat the bed for the filaments of the plate the job prints.

+ 11 - 2
backend/app/api/routes/inventory.py

@@ -1686,18 +1686,23 @@ async def delete_spool(
     if not spool:
         raise HTTPException(404, "Spool not found")
 
+    freed = await _release_slots(db, [spool.id])
     await db.delete(spool)
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"status": "deleted"}
 
 
 async def _release_slots(db: AsyncSession, spool_ids: list[int]) -> list[tuple[int, int, int]]:
-    """Unassign archived spools from their AMS slots; returns the freed slots.
+    """Unassign archived or deleted spools from their AMS slots; returns the
+    freed slots.
 
     Assigning refuses an archived spool and nothing downstream checks the flag,
     so a slot left pointing at one would keep showing it and charge it the next
-    print's usage. The caller commits, then announces the slots.
+    print's usage. A deleted spool's slots go with it, but other tabs refresh
+    their slot views only when the slots are announced. The caller commits,
+    then announces the slots.
     """
     if not spool_ids:
         return []
@@ -1881,11 +1886,13 @@ async def bulk_delete_spools(
     spools = list(result.scalars().all())
     found_ids = {s.id for s in spools}
     not_found = [sid for sid in payload.ids if sid not in found_ids]
+    freed = await _release_slots(db, list(found_ids))
     for spool in spools:
         await db.delete(spool)
     await db.commit()
     if spools:
         await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"deleted": len(spools), "not_found": not_found}
 
 
@@ -2911,6 +2918,8 @@ async def sync_weights_from_ams(
             skipped += 1
 
     await db.commit()
+    if synced:
+        await ws_manager.broadcast({"type": "inventory_changed"})
     return {"synced": synced, "skipped": skipped, "printing": printing}
 
 

+ 2 - 0
backend/app/api/routes/spoolbuddy.py

@@ -1061,6 +1061,7 @@ async def update_spool_weight(
         # (as a hand-set one, see update_spool).
         spool.weight_locked = True
         await db.commit()
+        await ws_manager.broadcast({"type": "inventory_changed"})
         logger.info(
             "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
             spool.id,
@@ -1089,6 +1090,7 @@ async def update_spool_weight(
 
     async with _translate_spoolbuddy_errors():
         await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+    await ws_manager.broadcast({"type": "inventory_changed"})
 
     weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(

+ 2 - 0
backend/app/core/sqlite_fk.py

@@ -90,6 +90,8 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("active_print_spoolman", "printer_id"),
         ("pipeline_jobs", "assigned_printer_id"),
         ("slicer_pipelines", "target_printer_id"),
+        # A deleted printer's spools would stay "assigned" to it (SA-1).
+        ("spool_assignment", "printer_id"),
         # smart_plugs: a printer delete now deletes its plugs; their energy
         # history would pass to the next plug given the id.
         ("smart_plug_energy_snapshots", "plug_id"),

+ 27 - 8
backend/app/main.py

@@ -2605,12 +2605,18 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                         cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
                     )
                     if not fp_type.strip() and loaded and assignment.spool:
+                        # The fingerprint ends this replay. Not recorded when nothing
+                        # could be sent (no MQTT client while the printer reconnects),
+                        # so the next AMS change tries again. An attempt that raised is
+                        # recorded as before: it may have sent part of the commands and
+                        # would raise again on every change, remain% steps included.
+                        configured: bool | None = False
                         try:
                             from backend.app.api.routes.inventory import (
                                 apply_spool_to_slot_via_mqtt,
                             )
 
-                            await apply_spool_to_slot_via_mqtt(
+                            configured = await apply_spool_to_slot_via_mqtt(
                                 db=db,
                                 current_user=None,
                                 spool=assignment.spool,
@@ -2621,23 +2627,35 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                                 current_tray_type=cur_type,
                                 current_tray_sub_brands=current_tray.get("tray_sub_brands", ""),
                             )
-                            logger.info(
-                                "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",
+                        except Exception:
+                            logger.exception(
+                                "Pre-config apply failed for spool %d on printer %d AMS%d-T%d",
                                 assignment.spool_id,
                                 printer_id,
                                 assignment.ams_id,
                                 assignment.tray_id,
                             )
-                        except Exception:
-                            logger.exception(
-                                "Pre-config apply failed for spool %d on printer %d AMS%d-T%d",
+                            configured = None
+                        if configured is False:
+                            logger.warning(
+                                "Pre-config not sent for spool %d on printer %d AMS%d-T%d (no MQTT client); "
+                                "retrying on the next AMS change",
                                 assignment.spool_id,
                                 printer_id,
                                 assignment.ams_id,
                                 assignment.tray_id,
                             )
-                        assignment.fingerprint_color = cur_color
-                        assignment.fingerprint_type = cur_type
+                        else:
+                            if configured:
+                                logger.info(
+                                    "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",
+                                    assignment.spool_id,
+                                    printer_id,
+                                    assignment.ams_id,
+                                    assignment.tray_id,
+                                )
+                            assignment.fingerprint_color = cur_color
+                            assignment.fingerprint_type = cur_type
                         continue
 
                     if not _colors_similar(cur_color, fp_color) or cur_type.upper() != fp_type.upper():
@@ -3046,6 +3064,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         )
                                         existing_assignment.spool.weight_used = new_used
                                         await db.commit()
+                                        await ws_manager.broadcast({"type": "inventory_changed"})
 
                             # A K-profile the slot lost (the default K after a reset or
                             # power cycle) is put back by kprofile_drift on the next idle

+ 9 - 2
backend/app/services/spool_csv.py

@@ -14,7 +14,7 @@ only the rows that came back `valid`.
 
 import csv
 import io
-from datetime import datetime
+from datetime import datetime, timezone
 
 from pydantic import BaseModel, ValidationError
 from sqlalchemy import select
@@ -179,6 +179,10 @@ def _parse_datetime(value: str) -> datetime | None:
 
     Accepts what `datetime.isoformat()` produces (what export writes) plus a
     trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
+
+    Returned as naive UTC, as the column holds it: SQLite would keep the wall
+    time of a value with an offset and drop the offset. A value without one is
+    taken as UTC, which is how export writes it.
     """
     raw = value.strip()
     if not raw:
@@ -186,9 +190,12 @@ def _parse_datetime(value: str) -> datetime | None:
     if raw.endswith("Z"):
         raw = raw[:-1] + "+00:00"
     try:
-        return datetime.fromisoformat(raw)
+        parsed = datetime.fromisoformat(raw)
     except ValueError:
         return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
 
 
 async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:

+ 16 - 1
backend/tests/integration/test_ams_remain_sync_while_printing.py

@@ -23,7 +23,9 @@ def _no_sessions():
     _active_sessions.clear()
 
 
-async def _weight_after_ams_push(test_engine, db_session, printer_factory, state: str) -> float:
+async def _weight_after_ams_push(
+    test_engine, db_session, printer_factory, state: str, sent: list[str] | None = None
+) -> float:
     from backend.app.main import on_ams_change
 
     printer = await printer_factory(name="X1C")
@@ -67,6 +69,9 @@ async def _weight_after_ams_push(test_engine, db_session, printer_factory, state
 
         await on_ams_change(printer.id, ams_data)
 
+    if sent is not None:
+        sent.extend(c.args[0]["type"] for c in mock_ws.broadcast.call_args_list)
+
     db_session.expire_all()
     return (await db_session.get(Spool, spool_id)).weight_used
 
@@ -82,3 +87,13 @@ async def test_no_sync_while_printing_without_a_tracker_session(test_engine, db_
 @pytest.mark.integration
 async def test_syncs_when_idle(test_engine, db_session, printer_factory):
     assert await _weight_after_ams_push(test_engine, db_session, printer_factory, "IDLE") == 500
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_weight_is_announced(test_engine, db_session, printer_factory):
+    """WU-9: other open tabs refresh weights only on inventory_changed."""
+    sent: list[str] = []
+
+    assert await _weight_after_ams_push(test_engine, db_session, printer_factory, "IDLE", sent) == 500
+    assert "inventory_changed" in sent

+ 42 - 0
backend/tests/integration/test_inventory_ams_weight_sync_lock.py

@@ -133,3 +133,45 @@ async def test_a_disconnected_printers_last_report_is_not_used(async_client: Asy
     spool = await db_session.get(Spool, spool_id)
     await db_session.refresh(spool)
     assert spool.weight_used == 100
+
+
+def _broadcast_types(ws) -> list[str]:
+    return [c.args[0]["type"] for c in ws.broadcast.call_args_list]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_weight_is_announced(async_client: AsyncClient, db_session, printer_factory):
+    """WU-9: other open tabs refresh only on inventory_changed."""
+    from unittest.mock import AsyncMock
+
+    await _assigned_spool(db_session, printer_factory, locked=False)
+
+    with patch("backend.app.api.routes.inventory.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        await _sync(async_client, _printer("IDLE"))
+
+    assert "inventory_changed" in _broadcast_types(ws)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_weighed_spool_is_announced(async_client: AsyncClient, db_session):
+    from unittest.mock import AsyncMock
+
+    db_session.add(
+        SpoolBuddyDevice(
+            device_id="sb-1", hostname="spoolbuddy-1", ip_address="10.0.0.1", last_seen=datetime.now(timezone.utc)
+        )
+    )
+    spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=100)
+    db_session.add(spool)
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        await async_client.post(
+            "/api/v1/spoolbuddy/scale/update-spool-weight", json={"spool_id": spool.id, "weight_grams": 600}
+        )
+
+    assert "inventory_changed" in _broadcast_types(ws)

+ 17 - 0
backend/tests/integration/test_inventory_csv.py

@@ -433,6 +433,23 @@ class TestInventoryCsvUsageColumns:
         assert spool.last_used is not None
         assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
 
+    @pytest.mark.parametrize(
+        ("written", "stored"),
+        [("2026-10-01T10:00:00+02:00", "2026-10-01 08:00:00"), ("2026-10-01T10:00:00", "2026-10-01 10:00:00")],
+    )
+    async def test_import_stores_last_used_in_utc(
+        self, async_client: AsyncClient, db_session: AsyncSession, written, stored
+    ):
+        """LU-1: an offset was dropped on SQLite, keeping the wall time. A time
+        without one is UTC, as export writes it."""
+        csv_text = f"material,color_name,rgba,last_used\nPLA,White,ffffffff,{written}\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        spool = (await db_session.execute(select(Spool))).scalars().one()
+        assert str(spool.last_used.replace(tzinfo=None)) == stored
+
     async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
         csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
 

+ 71 - 0
backend/tests/integration/test_preconfig_replay_failure.py

@@ -0,0 +1,71 @@
+"""A pre-assigned slot the printer could not be told about is retried (SA-3).
+
+SpoolBuddy assigns a spool to an empty slot with an empty fingerprint; the
+slot is configured on the printer once filament goes in. The fingerprint was
+recorded even when nothing could be sent (no MQTT client while the printer
+reconnects), and a recorded fingerprint ends the replay, so the slot was never
+configured.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.tests.integration.test_inventory_assign import _make_mock_status, spool_factory  # noqa: F401
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply: AsyncMock):
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory(name="H2D")
+    spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+    assignment = SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=2, tray_id=3)
+    db_session.add(assignment)
+    await db_session.commit()
+    ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
+    status = _make_mock_status(ams_data=ams_data)
+
+    with (
+        patch("backend.app.api.routes.inventory.apply_spool_to_slot_via_mqtt", apply),
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
+        pm.get_status.return_value = status
+        pm.get_client.return_value = MagicMock()
+        pm.get_model.return_value = "H2D"
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        await on_ams_change(printer.id, ams_data)
+
+    await db_session.refresh(assignment)
+    apply.assert_awaited_once()
+    return assignment.fingerprint_type, assignment.fingerprint_color
+
+
+async def test_a_slot_the_printer_could_not_be_told_about_is_retried(
+    async_client, db_session, printer_factory, spool_factory
+):
+    """No MQTT client (reconnecting): nothing was sent."""
+    apply = AsyncMock(return_value=False)
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == (None, None)
+
+
+async def test_a_configuration_that_raised_is_not_retried(async_client, db_session, printer_factory, spool_factory):
+    """It may have sent part of the commands, and would raise again on every
+    AMS change, mid-print included (remain% steps count as changes)."""
+    apply = AsyncMock(side_effect=RuntimeError("preset lookup failed"))
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == ("PLA", "FF0000FF")
+
+
+async def test_a_configured_slot_records_its_fingerprint(async_client, db_session, printer_factory, spool_factory):
+    apply = AsyncMock(return_value=True)
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == ("PLA", "FF0000FF")

+ 23 - 0
backend/tests/integration/test_spool_archive_unassigns.py

@@ -69,6 +69,29 @@ async def test_bulk_archiving_unassigns_every_spool(async_client: AsyncClient, d
     assert await _built_in_slots(db_session) == []
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("bulk", [False, True])
+async def test_deleting_a_spool_announces_the_freed_slots(async_client: AsyncClient, db_session, printer_factory, bulk):
+    """SA-4: other tabs refresh slot views only on spool_assignment_changed;
+    inventory_changed alone left the deleted spool shown in its slot."""
+    ids = await _assigned_built_in_spools(db_session, printer_factory, count=2)
+
+    with patch("backend.app.api.routes.inventory.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        if bulk:
+            response = await async_client.post("/api/v1/inventory/spools/bulk-delete", json={"ids": ids})
+        else:
+            response = await async_client.delete(f"/api/v1/inventory/spools/{ids[0]}")
+
+    assert response.status_code == 200
+    freed = sorted(
+        c.args[0]["tray_id"] for c in ws.broadcast.call_args_list if c.args[0]["type"] == "spool_assignment_changed"
+    )
+    assert freed == ([0, 1] if bulk else [0])
+    assert [a.tray_id for a in await _built_in_slots(db_session)] == ([] if bulk else [1])
+
+
 @pytest.fixture
 async def spoolman_slot(db_session, printer_factory):
     from backend.app.models.settings import Settings

+ 19 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -1759,6 +1759,25 @@ class TestUpdateSpoolWeightSpoolman:
         assert data["weight_used"] == pytest.approx(446.0)
         mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_mode_announces_the_weight(self, async_client: AsyncClient, spoolman_settings):
+        """WU-9: other open Inventory tabs refresh only on inventory_changed."""
+        sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
+        mock_client = _mock_spoolman_client()
+        mock_client.get_spool = AsyncMock(return_value=sm_spool)
+        mock_client.update_spool = AsyncMock(return_value=sm_spool)
+
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws,
+        ):
+            ws.broadcast = AsyncMock()
+            await async_client.post(f"{API}/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 750})
+
+        assert {"type": "inventory_changed"} in [c.args[0] for c in ws.broadcast.call_args_list]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):

+ 24 - 0
backend/tests/integration/test_sqlite_dangling_references.py

@@ -310,3 +310,27 @@ async def test_the_archive_purge_skips_an_archive_that_is_being_printed(
     reloaded = await _reload(db_session, PrintArchive, archive.id)
     assert reloaded is not None and reloaded.deleted_at is None
     assert await _reload(db_session, PrintQueueItem, item_id) is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_deleting_a_printer_unassigns_its_spools(async_client: AsyncClient, db_session, printer_factory):
+    """SA-1: the spools of a deleted printer stayed "assigned" to it on SQLite."""
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    printer = await printer_factory()
+    kept_printer = await printer_factory(name="Kept")
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0)
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=kept_printer.id, ams_id=0, tray_id=1))
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/printers/{printer.id}")
+
+    assert response.status_code == 200, response.text
+    rows = (await db_session.execute(select(SpoolAssignment).execution_options(populate_existing=True))).scalars()
+    assert [row.printer_id for row in rows] == [kept_printer.id]
+    assert await _reload(db_session, Spool, spool.id) is not None

+ 30 - 1
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -512,8 +512,9 @@ describe('useWebSocket hook', () => {
         ws.simulateMessage({ type: 'inventory_changed' });
       });
 
+      // 3 s debounce, then one key every 500 ms.
       await act(async () => {
-        vi.advanceTimersByTime(5000);
+        vi.advanceTimersByTime(6000);
       });
 
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
@@ -524,6 +525,34 @@ describe('useWebSocket hook', () => {
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] });
       // The per-material-number aggregate is derived from the same rows (#2870).
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
+      // A weight synced or weighed elsewhere reaches printer cards through the slot rows (WU-9).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
+
+    it("refreshes the slot rows when a print's usage is logged", async () => {
+      // The printer card reads a spool's remaining weight from the slot rows;
+      // a finished print is the most common weight change.
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({ type: 'spool_usage_logged', data: {} });
+      });
+      await act(async () => {
+        vi.advanceTimersByTime(6000);
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

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

@@ -488,6 +488,8 @@ export function useWebSocket() {
         debouncedInvalidate(inventorySuppliersQueryKey[0]);
         // The per-material-number aggregate is derived from the same rows (#2870).
         debouncedInvalidate('material-number-stats');
+        // Printer cards and slot views read a spool's weight from the slot rows.
+        debouncedInvalidate('spool-assignments');
         break;
 
       case 'spool_assignment_changed':
@@ -535,8 +537,10 @@ export function useWebSocket() {
         break;
 
       case 'spool_usage_logged':
-        // Filament consumption recorded - refresh spool data
+        // Filament consumption recorded - refresh spool data, and the slot rows
+        // printer cards read a spool's remaining weight from.
         debouncedInvalidate('inventory-spools');
+        debouncedInvalidate('spool-assignments');
         break;
 
       case 'unknown_tag': {