Просмотр исходного кода

fix(inventory): stop popping the unknown-tag modal for slots with no RFID

      The 7cb905a follow-up mounted the global unknown-tag modal listener, which
      turned an existing always-on broadcast for no-tag slots from a silent no-op
      into a perpetual popup loop — every push for a slot with a generic
      non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
      created a fresh ghost spool with an empty tag.

      - main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
        no prompt; the slot stays unassigned until a real tag is read.
      - inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
        usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
        spool by re-confirming a queued prompt.
      - test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
        string).
maziggy 2 месяцев назад
Родитель
Сommit
8b72b305a3

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

@@ -2457,6 +2457,14 @@ async def create_spool_from_slot(
     if not tray or not tray.get("tray_type"):
         raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
 
+    # Guard against ghost-spool creation: a slot without any RFID tag has no
+    # stable identity, so creating an inventory row would just duplicate on
+    # every confirm and never re-link to the physical spool.
+    from backend.app.services.spool_tag_matcher import is_valid_tag
+
+    if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
+        raise HTTPException(status_code=400, detail="Slot has no RFID tag")
+
     spool = await create_spool_from_tray(db, tray)
     await auto_assign_spool(
         req.printer_id,

+ 7 - 0
backend/app/api/routes/spoolman.py

@@ -1207,6 +1207,13 @@ async def create_spool_from_slot(
     if not tray:
         raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
 
+    # Same ghost-spool guard as the inventory route: no tag → no stable
+    # identity → confirm would just create a fresh Spoolman row per push.
+    from backend.app.services.spool_tag_matcher import is_valid_tag
+
+    if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
+        raise HTTPException(status_code=400, detail="Slot has no RFID tag")
+
     sync_result = await client.sync_ams_tray(
         tray,
         printer.name,

+ 3 - 13
backend/app/main.py

@@ -1819,19 +1819,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 tray_sub_brands=tray.get("tray_sub_brands"),
                                 tray_count=len(ams_unit.get("tray", [])),
                             )
-                        else:
-                            # No tag at all — let user choose from inventory
-                            await _broadcast_unknown_tag(
-                                printer_id=printer_id,
-                                ams_id=ams_id,
-                                tray_id=tray_id,
-                                tag_uid="",
-                                tray_uuid="",
-                                tray_type=tray.get("tray_type"),
-                                tray_color=tray.get("tray_color"),
-                                tray_sub_brands=tray.get("tray_sub_brands"),
-                                tray_count=len(ams_unit.get("tray", [])),
-                            )
+                        # No-tag slots (generic non-RFID filament) are left alone:
+                        # nothing to identify, prompting "+ Add" would just create
+                        # ghost spools with empty tags on every confirm.
     except Exception as e:
         logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
 

+ 69 - 0
backend/tests/integration/test_inventory_from_slot_no_tag.py

@@ -0,0 +1,69 @@
+"""No-tag guard on POST /api/v1/inventory/spools/from-slot.
+
+A slot that has tray_type set but no RFID tag (generic third-party PLA the
+user manually configured on the printer panel) has no stable identity, so
+auto-creating an inventory row for it would just duplicate on every confirm
+without ever re-linking to the physical spool. Verify the endpoint refuses
+that case with 400.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _mock_status_for_tray(ams_id: int, tray_id: int, tray: dict) -> MagicMock:
+    status = MagicMock()
+    status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
+    return status
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_from_slot_rejects_no_tag(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(name="X1C-no-tag")
+
+    no_tag_tray = {
+        "tray_type": "PLA",
+        "tray_color": "FF0000FF",
+        "tag_uid": "0000000000000000",
+        "tray_uuid": "00000000000000000000000000000000",
+    }
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        return_value=_mock_status_for_tray(0, 1, no_tag_tray),
+    ):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/from-slot",
+            json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+        )
+
+    assert resp.status_code == 400
+    assert "RFID tag" in resp.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_from_slot_rejects_empty_tag_strings(async_client: AsyncClient, printer_factory):
+    """Empty strings (not zero-filled) must also be refused."""
+    printer = await printer_factory(name="X1C-empty-tag")
+
+    empty_tag_tray = {
+        "tray_type": "PETG",
+        "tray_color": "00FF00FF",
+        "tag_uid": "",
+        "tray_uuid": "",
+    }
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        return_value=_mock_status_for_tray(0, 2, empty_tag_tray),
+    ):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/from-slot",
+            json={"printer_id": printer.id, "ams_id": 0, "tray_id": 2},
+        )
+
+    assert resp.status_code == 400