Jelajahi Sumber

feat: auto-link untagged inventory spools on AMS insert (#538)

  When a Bambu Lab spool is detected in the AMS but no tag match exists,
  check for an untagged inventory spool with matching material/subtype/color
  before creating a new entry. Links the RFID tag to the existing spool
  (data_origin="rfid_linked") to prevent duplicate inventory entries.
maziggy 2 bulan lalu
induk
melakukan
dd75c8f0c9

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 ## [0.2.3b1] - Unreleased
 
 
 ### New Features
 ### New Features
+- **Auto-Link Untagged Inventory Spools on AMS Insert** ([#538](https://github.com/maziggy/bambuddy/issues/538)) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.
 - **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, and gcode files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
 - **External Folder Mounting for File Manager** ([#124](https://github.com/maziggy/bambuddy/issues/124)) — Host directories (NAS shares, USB drives, network storage) can now be mounted into the File Manager without copying files. Click "Link External" to point at a Docker bind-mounted path. Files are indexed into the database on scan but accessed directly from their original location — nothing is copied. Supports read-only mode (default, blocks uploads/moves/deletes), hidden file filtering, and automatic thumbnail extraction for 3MF, STL, and gcode files. External folders show a distinct icon and info bar with a rescan button. Deleting an external folder only removes the database index, never the actual files. Requested by @S1N4X.
 
 
 ### Fixed
 ### Fixed

+ 9 - 2
backend/app/main.py

@@ -775,9 +775,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
             from backend.app.services.spool_tag_matcher import (
             from backend.app.services.spool_tag_matcher import (
                 auto_assign_spool,
                 auto_assign_spool,
                 create_spool_from_tray,
                 create_spool_from_tray,
+                find_matching_untagged_spool,
                 get_spool_by_tag,
                 get_spool_by_tag,
                 is_bambu_tag,
                 is_bambu_tag,
                 is_valid_tag,
                 is_valid_tag,
+                link_tag_to_inventory_spool,
             )
             )
 
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
             _spoolman_on = await get_setting(db, "spoolman_enabled")
@@ -833,10 +835,15 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             continue
                             continue
 
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
-                            # BL spool with RFID tag: auto-match or auto-create
+                            # BL spool with RFID tag: auto-match → inventory match → auto-create
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
                             spool = await get_spool_by_tag(db, tag_uid, tray_uuid)
                             if not spool:
                             if not spool:
-                                spool = await create_spool_from_tray(db, tray)
+                                # Try matching an untagged inventory spool (same material/color)
+                                spool = await find_matching_untagged_spool(db, tray)
+                                if spool:
+                                    await link_tag_to_inventory_spool(db, spool, tray)
+                                else:
+                                    spool = await create_spool_from_tray(db, tray)
                             await auto_assign_spool(
                             await auto_assign_spool(
                                 printer_id,
                                 printer_id,
                                 ams_id,
                                 ams_id,

+ 98 - 0
backend/app/services/spool_tag_matcher.py

@@ -165,6 +165,104 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     return spool
     return spool
 
 
 
 
+async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
+    """Find an existing untagged inventory spool matching brand/material/color.
+
+    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
+    that hasn't been linked to a tag yet. Returns the oldest match (FIFO).
+    """
+    tray_type = tray_data.get("tray_type", "")
+    tray_sub_brands = tray_data.get("tray_sub_brands", "")
+    tray_color = tray_data.get("tray_color", "")  # RRGGBBAA
+
+    if not tray_type or not tray_color:
+        return None
+
+    # Parse material the same way create_spool_from_tray does
+    material = tray_type
+    subtype = None
+    if tray_sub_brands and " " in tray_sub_brands:
+        parts = tray_sub_brands.split(" ", 1)
+        if parts[0].upper() == material.upper():
+            subtype = parts[1]
+        else:
+            material = tray_sub_brands
+    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
+        material = tray_sub_brands
+
+    # Build query: active spools with no tag, matching brand + material + color
+    query = (
+        select(Spool)
+        .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
+        .where(
+            Spool.archived_at.is_(None),
+            Spool.tag_uid.is_(None),
+            Spool.tray_uuid.is_(None),
+            func.upper(Spool.material) == material.upper(),
+            func.upper(Spool.rgba) == tray_color.upper(),
+        )
+    )
+
+    # Match subtype if parsed (e.g. "Basic", "Matte")
+    if subtype:
+        query = query.where(func.upper(Spool.subtype) == subtype.upper())
+    else:
+        query = query.where(Spool.subtype.is_(None))
+
+    # FIFO: oldest spool first (user likely added in purchase order)
+    query = query.order_by(Spool.created_at.asc()).limit(1)
+
+    result = await db.execute(query)
+    spool = result.scalar_one_or_none()
+
+    if spool:
+        logger.info(
+            "Found matching untagged spool %d: %s %s %s (rgba=%s)",
+            spool.id,
+            spool.brand or "",
+            spool.material,
+            spool.color_name or "",
+            spool.rgba or "",
+        )
+
+    return spool
+
+
+async def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data: dict) -> None:
+    """Link RFID tag data from AMS tray to an existing inventory spool."""
+    tag_uid = tray_data.get("tag_uid", "")
+    tray_uuid = tray_data.get("tray_uuid", "")
+    tray_info_idx = tray_data.get("tray_info_idx", "")
+
+    if tag_uid and tag_uid != ZERO_TAG_UID:
+        spool.tag_uid = tag_uid
+    if tray_uuid and tray_uuid != ZERO_TRAY_UUID:
+        spool.tray_uuid = tray_uuid
+    spool.data_origin = "rfid_linked"
+    spool.tag_type = "bambulab"
+
+    # Update slicer preset if not already set
+    if tray_info_idx and not spool.slicer_filament:
+        spool.slicer_filament = tray_info_idx
+        try:
+            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+            name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
+            if name and not spool.slicer_filament_name:
+                spool.slicer_filament_name = name
+        except Exception:
+            pass
+
+    await db.flush()
+    logger.info(
+        "Linked RFID tag to existing spool %d (tag=%s uuid=%s origin=rfid_linked)",
+        spool.id,
+        spool.tag_uid or "",
+        spool.tray_uuid or "",
+    )
+
+
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
 async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
     """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.
 
 

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

@@ -8,9 +8,11 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.services.spool_tag_matcher import (
 from backend.app.services.spool_tag_matcher import (
     auto_assign_spool,
     auto_assign_spool,
     create_spool_from_tray,
     create_spool_from_tray,
+    find_matching_untagged_spool,
     get_spool_by_tag,
     get_spool_by_tag,
     is_bambu_tag,
     is_bambu_tag,
     is_valid_tag,
     is_valid_tag,
+    link_tag_to_inventory_spool,
 )
 )
 
 
 # -- helpers -----------------------------------------------------------------
 # -- helpers -----------------------------------------------------------------
@@ -352,3 +354,283 @@ async def test_auto_assign_no_greenlet_error_existing_spool(db_session, printer_
 
 
     assert assignment is not None
     assert assignment is not None
     assert assignment.spool_id == found.id
     assert assignment.spool_id == found.id
+
+
+# -- find_matching_untagged_spool -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_exact_match(db_session):
+    """Finds an untagged spool with matching material, subtype, and color."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        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_skips_tagged(db_session):
+    """Spools that already have a tag_uid are not matched."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        tag_uid="1122334455667788",
+    )
+    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_skips_uuid_tagged(db_session):
+    """Spools that already have a tray_uuid are not matched."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        tray_uuid="AABBCCDD11223344AABBCCDD11223344",
+    )
+    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_skips_archived(db_session):
+    """Archived spools are not matched."""
+    from datetime import datetime
+
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        archived_at=datetime.now(),
+    )
+    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_wrong_material(db_session):
+    """Material mismatch returns None."""
+    spool = Spool(
+        material="PETG",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        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_wrong_color(db_session):
+    """Color (rgba) mismatch returns None."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FF0000FF",
+        brand="Bambu Lab",
+        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_wrong_subtype(db_session):
+    """Subtype mismatch returns None (PLA Matte vs PLA Basic)."""
+    spool = Spool(
+        material="PLA",
+        subtype="Matte",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        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_fifo(db_session):
+    """When multiple match, returns the oldest (FIFO)."""
+    import asyncio
+
+    spool_old = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool_old)
+    await db_session.flush()
+
+    # Small delay to ensure different created_at
+    await asyncio.sleep(0.05)
+
+    spool_new = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool_new)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert found.id == spool_old.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_case_insensitive(db_session):
+    """Matching is case-insensitive for material and rgba."""
+    spool = Spool(
+        material="pla",
+        subtype="basic",
+        rgba="ffffffff",
+        brand="Bambu Lab",
+        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_no_subtype(db_session):
+    """Tray without subtype matches spool without subtype."""
+    tray = {**SAMPLE_TRAY, "tray_sub_brands": "PLA", "tray_type": "PLA"}
+    spool = Spool(
+        material="PLA",
+        subtype=None,
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+
+    found = await find_matching_untagged_spool(db_session, tray)
+    assert found is not None
+    assert found.id == spool.id
+
+
+@pytest.mark.asyncio
+async def test_find_matching_untagged_spool_relationships_loaded(db_session):
+    """Matched spool has k_profiles and assignments eagerly loaded."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    db_session.expire(spool)
+
+    found = await find_matching_untagged_spool(db_session, SAMPLE_TRAY)
+    assert found is not None
+    assert _relationship_is_loaded(found, "k_profiles")
+    assert _relationship_is_loaded(found, "assignments")
+
+
+# -- link_tag_to_inventory_spool -------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_link_tag_to_inventory_spool(db_session):
+    """Links RFID tag data to an existing spool."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+    )
+    db_session.add(spool)
+    await db_session.flush()
+
+    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert spool.tag_uid == "AABBCCDD11223344"
+    assert spool.tray_uuid == "AABBCCDD11223344AABBCCDD11223344"
+    assert spool.data_origin == "rfid_linked"
+    assert spool.tag_type == "bambulab"
+    assert spool.slicer_filament == "GFL99"
+
+
+@pytest.mark.asyncio
+async def test_link_tag_preserves_existing_slicer_filament(db_session):
+    """Does not overwrite an existing slicer_filament preset."""
+    spool = Spool(
+        material="PLA",
+        subtype="Basic",
+        rgba="FFFFFFFF",
+        brand="Bambu Lab",
+        label_weight=1000,
+        core_weight=250,
+        slicer_filament="CUSTOM01",
+        slicer_filament_name="My Custom PLA",
+    )
+    db_session.add(spool)
+    await db_session.flush()
+
+    await link_tag_to_inventory_spool(db_session, spool, SAMPLE_TRAY)
+    await db_session.commit()
+
+    assert spool.slicer_filament == "CUSTOM01"
+    assert spool.slicer_filament_name == "My Custom PLA"