Browse Source

fix(#918): RFID auto-match handles Quick-Add and rejects non-Bambu brands

  `find_matching_untagged_spool` is supposed to attach an incoming Bambu
  RFID UUID to a pre-existing manually-logged spool of the same
  material/color so users who log inventory before scanning don't end up
  with duplicate rows. Two bugs meant it almost never worked for the
  actual reporting workflow:

  1. Subtype filter was strict. AMS reports `tray_sub_brands="PLA Basic"`
     → matcher required `Spool.subtype = 'Basic'` exactly. The form's
     Quick-Add mode only requires `material`, so bulk-logged rows have
     `subtype=NULL` and were always excluded → duplicate on first AMS
     read.
  2. Brand wasn't filtered. The docstring claimed brand was matched but
     the WHERE clause didn't include it, so a same-color Polymaker (or
     any non-Bambu) untagged row could acquire a Bambu UUID — silent
     data corruption.

  Fix in the same query: subtype prefers exact match but accepts NULL as
  fallback (CASE in ORDER BY ensures exact wins when both exist); brand
  restricted to NULL or LOWER(brand) LIKE '%bambu%' (covers 'Bambu',
  'Bambu Lab', 'BambuLab', 'bambu lab' — the spellings users actually
  type).

  6 regression tests added in test_spool_tag_matcher.py.
maziggy 1 month ago
parent
commit
568835c586

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 33 - 9
backend/app/services/spool_tag_matcher.py

@@ -2,7 +2,7 @@
 
 
 import logging
 import logging
 
 
-from sqlalchemy import func, or_, select
+from sqlalchemy import case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -191,11 +191,22 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
 
 
 
 
 async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
 async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
-    """Find an existing untagged inventory spool matching brand/material/color.
+    """Find an existing untagged Bambu inventory spool matching material/color.
 
 
     When a Bambu Lab spool is detected in the AMS but no tag match exists,
     When a Bambu Lab spool is detected in the AMS but no tag match exists,
     check if the user has a manually-added spool with the same properties
     check if the user has a manually-added spool with the same properties
-    that hasn't been linked to a tag yet. Returns the oldest match (FIFO).
+    that hasn't been linked to a tag yet. Returns the best match (#918):
+
+    - **Brand**: only consider spools whose brand is unspecified or contains
+      "bambu" (case-insensitive — covers both "Bambu" and "Bambu Lab" as
+      stored by the form's brand dropdown). This prevents a same-color
+      Polymaker / generic spool from accidentally attracting a Bambu UUID.
+    - **Subtype**: prefer an exact match (e.g. AMS "Basic" → spool subtype
+      "Basic"), but fall back to a NULL-subtype spool — the form's Quick Add
+      mode leaves subtype empty, so bulk-logged spools rely on this fallback
+      to attract their RFID tag instead of duplicating on first AMS read.
+    - **FIFO** within each preference group (user likely logged in purchase
+      order).
     """
     """
     tray_type = tray_data.get("tray_type", "")
     tray_type = tray_data.get("tray_type", "")
     tray_sub_brands = tray_data.get("tray_sub_brands", "")
     tray_sub_brands = tray_data.get("tray_sub_brands", "")
@@ -229,7 +240,7 @@ async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spo
         elif color_code and color_code[0] == "T":
         elif color_code and color_code[0] == "T":
             subtype = "Tri Color"
             subtype = "Tri Color"
 
 
-    # Build query: active spools with no tag, matching brand + material + color
+    # Active, untagged spools matching material + color + Bambu-or-unset brand.
     query = (
     query = (
         select(Spool)
         select(Spool)
         .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
         .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
@@ -239,17 +250,30 @@ async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spo
             Spool.tray_uuid.is_(None),
             Spool.tray_uuid.is_(None),
             func.upper(Spool.material) == material.upper(),
             func.upper(Spool.material) == material.upper(),
             func.upper(Spool.rgba) == tray_color.upper(),
             func.upper(Spool.rgba) == tray_color.upper(),
+            or_(
+                Spool.brand.is_(None),
+                func.lower(Spool.brand).like("%bambu%"),
+            ),
         )
         )
     )
     )
 
 
-    # Match subtype if parsed (e.g. "Basic", "Matte")
     if subtype:
     if subtype:
-        query = query.where(func.upper(Spool.subtype) == subtype.upper())
+        # Exact subtype OR NULL fallback. The CASE in ORDER BY ensures an
+        # exact-subtype row beats a NULL-subtype row when both exist; FIFO
+        # within each group.
+        query = query.where(
+            or_(
+                func.upper(Spool.subtype) == subtype.upper(),
+                Spool.subtype.is_(None),
+            )
+        ).order_by(
+            case((func.upper(Spool.subtype) == subtype.upper(), 0), else_=1),
+            Spool.created_at.asc(),
+        )
     else:
     else:
-        query = query.where(Spool.subtype.is_(None))
+        query = query.where(Spool.subtype.is_(None)).order_by(Spool.created_at.asc())
 
 
-    # FIFO: oldest spool first (user likely added in purchase order)
-    query = query.order_by(Spool.created_at.asc()).limit(1)
+    query = query.limit(1)
 
 
     result = await db.execute(query)
     result = await db.execute(query)
     spool = result.scalar_one_or_none()
     spool = result.scalar_one_or_none()

+ 168 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -690,6 +690,174 @@ async def test_find_matching_untagged_spool_relationships_loaded(db_session):
     assert _relationship_is_loaded(found, "assignments")
     assert _relationship_is_loaded(found, "assignments")
 
 
 
 
+# -- find_matching_untagged_spool: #918 regressions ------------------------
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_null_subtype_fallback(db_session):
+    """#918: Quick-Add spool (subtype=NULL) matches when AMS reports a subtype.
+
+    The form's Quick-Add mode only requires `material`, so bulk-logged spools
+    have subtype=NULL. Before the fix, the strict `subtype = 'Basic'` filter
+    excluded these rows and the system created duplicates on first AMS read.
+    """
+    spool = Spool(
+        material="PLA",
+        subtype=None,  # Quick-Add bulk entry
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    # Tray reports "PLA Basic" → subtype parsed as "Basic"
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_prefers_exact_subtype_over_null(db_session):
+    """#918: When both an exact-subtype and a NULL-subtype row match, exact wins.
+
+    The NULL fallback exists only as a backstop for Quick-Add bulk-logged
+    spools — if the user did the work to record subtype="Basic", it must
+    take precedence over a vague "PLA" record, even if the latter is older.
+    """
+    import asyncio
+
+    null_spool = Spool(
+        material="PLA",
+        subtype=None,  # Older but vague — should NOT win
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(null_spool)
+    await db_session.flush()
+
+    await asyncio.sleep(0.05)
+
+    exact_spool = Spool(
+        material="PLA",
+        subtype="Basic",  # Newer but specific — should win
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(exact_spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == exact_spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_rejects_non_bambu_brand(db_session):
+    """#918: A same-color non-Bambu spool must NOT attract a Bambu UUID.
+
+    Without the brand filter, a Polymaker untagged spool of matching
+    material/color would silently acquire a Bambu RFID UUID, leaving the
+    user with brand="Polymaker" but a Bambu Lab tray UUID — corrupt data.
+    """
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Polymaker",  # NOT Bambu — must be rejected
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is None
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_accepts_null_brand(db_session):
+    """#918: Quick-Add spools with brand=NULL still match a Bambu RFID read.
+
+    Quick-Add doesn't require brand, so a user bulk-logging Bambu spools may
+    leave it empty. The matcher allows NULL brand because the alternative
+    (forcing every Quick-Add spool to be tagged "Bambu") is the exact
+    friction the auto-matcher exists to remove.
+    """
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand=None,  # Quick-Add left brand blank
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_accepts_bambu_brand_variants(db_session):
+    """#918: Both 'Bambu' (form dropdown) and 'Bambu Lab' (catalog) match.
+
+    DEFAULT_BRANDS in the form lists 'Bambu'; the catalog uses 'Bambu Lab'.
+    Users can pick either. The fuzzy %bambu% LIKE handles both, plus
+    'BambuLab', 'bambu lab', etc.
+    """
+    for brand_value in ("Bambu", "Bambu Lab", "BambuLab", "bambu lab"):
+        spool = Spool(
+            material="PLA",
+            subtype="Basic",
+            rgba="FFFFFFFF",
+            brand=brand_value,
+            label_weight=1000,
+            core_weight=250,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+
+        found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+        assert found is not None, f"brand={brand_value!r} should match"
+        assert found.id == spool.id
+
+        # Clean up so the next iteration starts fresh.
+        await db_session.delete(spool)
+        await db_session.commit()
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_null_subtype_with_null_brand(db_session):
+    """#918: Pure Quick-Add row (brand=NULL, subtype=NULL) matches.
+
+    This is the exact scenario from Arn0uDz's report: 20 spools logged via
+    Quick Add, then placed in the AMS one at a time. Before the fix every
+    insertion duplicated; after the fix the first matching row is reused.
+    """
+    spool = Spool(
+        material="PLA",
+        subtype=None,
+        rgba="FFFFFFFF",
+        brand=None,
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool.id
+
+
 # -- link_tag_to_inventory_spool -------------------------------------------
 # -- link_tag_to_inventory_spool -------------------------------------------
 
 
 
 

Some files were not shown because too many files changed in this diff