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.
This commit is contained in:
maziggy
2026-04-25 12:49:00 +02:00
parent 35edc036bd
commit 568835c586
3 changed files with 202 additions and 9 deletions
@@ -690,6 +690,174 @@ async def test_find_matching_untagged_spool_relationships_loaded(db_session):
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 -------------------------------------------