Переглянути джерело

fix(spoolbuddy): write the Spoolman spool to the tag in Spoolman mode

Built-in spools are kept after switching to Spoolman. One with the same id
as the Spoolman spool was written to the tag and linked to it instead. The
tag write now takes the spool only from the inventory in use.
maziggy 6 днів тому
батько
коміт
7616b65463

+ 0 - 16
.basedpyright/baseline.json

@@ -1441,22 +1441,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportAssignmentType",
-                "range": {
-                    "startColumn": 13,
-                    "endColumn": 76,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 19,
-                    "endColumn": 37,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportAttributeAccessIssue",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -163,6 +163,7 @@ 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
+- **SpoolBuddy writes the Spoolman spool to the tag in Spoolman mode** — Built-in spools are kept after switching to Spoolman. When one of them had the same id as the Spoolman spool being written, SpoolBuddy wrote the built-in spool's material, colour and weight to the tag and linked the tag to that hidden spool, while reporting success. The Spoolman spool never got the tag, so scanning it later found nothing. Writing a tag now uses only the inventory in use.
 - **Budget held by prints that ended is released** — Before a print without a user (authentication off, or its user deleted while it printed) was charged to its cost centre, it kept its budget reservation for good, so the cost centre showed that much less budget left. At the next start, reservations of prints that have finished or were removed from the queue are released. Only the reserved amount is freed; charges already booked don't change.
 - **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes.
 - **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for.

+ 11 - 10
backend/app/api/routes/spoolbuddy.py

@@ -642,7 +642,12 @@ async def nfc_write_tag(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
-    """Queue an NFC tag write command for a SpoolBuddy device."""
+    """Queue an NFC tag write command for a SpoolBuddy device.
+
+    The spool comes from the inventory in use: Spoolman when it is enabled,
+    otherwise Bambuddy's own. Built-in spools are kept after switching to
+    Spoolman, so one with the same id must not stand in for the Spoolman spool.
+    """
     from backend.app.models.spool import Spool
     from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
 
@@ -652,22 +657,18 @@ async def nfc_write_tag(
     if not device:
         raise HTTPException(status_code=404, detail="Device not registered")
 
-    # Try local DB first
-    result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
-    spool = result.scalar_one_or_none()
+    sm_client = await _get_spoolman_client_or_none(db)
 
     nfc_warnings: list[str] = []
-    if spool:
+    if sm_client is None:
+        spool = (await db.execute(select(Spool).where(Spool.id == req.spool_id))).scalar_one_or_none()
+        if spool is None:
+            raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
         data_origin = "local"
     else:
-        # Local DB miss — fall back to Spoolman when enabled
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 
-        sm_client = await _get_spoolman_client_or_none(db)
-        if sm_client is None:
-            raise HTTPException(status_code=404, detail="Spool not found")
-
         async with _translate_spoolbuddy_errors():
             sm_spool = await sm_client.get_spool(req.spool_id)
 

+ 31 - 3
backend/tests/integration/test_spoolbuddy.py

@@ -2153,7 +2153,7 @@ def _full_spoolman_spool(spool_id: int) -> dict:
 
 
 class TestNfcWriteTagSpoolman:
-    """nfc/write-tag falls back to Spoolman when local DB has no matching spool."""
+    """nfc/write-tag encodes the Spoolman spool in Spoolman mode."""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -2221,10 +2221,10 @@ class TestNfcWriteTagSpoolman:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_404_when_neither_local_nor_spoolman(
+    async def test_404_when_spoolman_has_no_such_spool(
         self, async_client: AsyncClient, device_factory, spoolman_settings
     ):
-        """404 returned when spool is missing from both local DB and Spoolman."""
+        """404 returned when the spool is missing from Spoolman."""
         await device_factory(device_id="sb-miss")
         mock_client = _mock_spoolman_client()
         mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found"))
@@ -2246,6 +2246,34 @@ class TestNfcWriteTagSpoolman:
 
         assert resp.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_spool_wins_over_a_leftover_built_in_spool(
+        self, async_client: AsyncClient, device_factory, db_session, spool_factory, spoolman_settings
+    ):
+        """Built-in spools are kept after switching to Spoolman; one with the
+        same id as the Spoolman spool must not be the one written to the tag."""
+        import json as _json
+
+        leftover = await spool_factory(material="PETG")
+        device = await device_factory(device_id="sb-leftover")
+        mock_client = _mock_spoolman_client()
+        mock_client.get_spool = AsyncMock(return_value=_full_spoolman_spool(leftover.id))
+
+        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)),
+        ):
+            resp = await async_client.post(
+                f"{API}/nfc/write-tag",
+                json={"device_id": "sb-leftover", "spool_id": leftover.id},
+            )
+
+        assert resp.status_code == 200, resp.text
+        mock_client.get_spool.assert_called_once_with(leftover.id)
+        await db_session.refresh(device)
+        assert _json.loads(device.pending_write_payload)["data_origin"] == "spoolman"
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory):