maziggy пре 2 месеци
родитељ
комит
282aefc564

+ 20 - 141
backend/app/api/routes/inventory.py

@@ -38,6 +38,7 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
     ImportPreview,
@@ -66,27 +67,6 @@ _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
 # FilamentColors.xyz API
 FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
 
-# Generic Bambu filament IDs by material — fallback when no specific
-# preset is resolvable. Keep aligned with the inline table in
-# apply_spool_to_slot_via_mqtt below; both paths must produce the same
-# value for a given material.
-_GENERIC_FILAMENT_IDS: dict[str, str] = {
-    "PLA": "GFL99",
-    "PETG": "GFG99",
-    "ABS": "GFB99",
-    "ASA": "GFB98",
-    "PC": "GFC99",
-    "PA": "GFN99",
-    "NYLON": "GFN99",
-    "TPU": "GFU99",
-    "PVA": "GFS99",
-    "HIPS": "GFS98",
-    "PLA-CF": "GFL98",
-    "PETG-CF": "GFG98",
-    "PA-CF": "GFN98",
-    "PETG HF": "GFG96",
-}
-
 
 async def apply_spool_to_slot_via_mqtt(
     *,
@@ -129,125 +109,24 @@ async def apply_spool_to_slot_via_mqtt(
     )
     tray_color = spool.rgba or "FFFFFFFF"
 
-    _generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
-
-    tray_info_idx = ""
-    setting_id = ""
-    sf = spool.slicer_filament or ""
-
-    if sf:
-        base_sf = sf.split("_")[0] if "_" in sf else sf
-        # Cloud-side preset IDs in three known shapes:
-        #   GFS…   — Bambu official cloud preset
-        #   PFUS…  — cloud user-created preset
-        #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's
-        #            "(Custom)" Bambu Lab H2D variant, #1648)
-        # All three need a cloud-detail lookup to extract the underlying
-        # filament_id; without it the raw cloud id ends up in tray_info_idx
-        # and the printer's calibration table can't resolve it.
-        if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
-            setting_id = base_sf
-            try:
-                from backend.app.api.routes.cloud import build_authenticated_cloud
-
-                cloud = await build_authenticated_cloud(db, current_user)
-                if cloud is not None and cloud.is_authenticated:
-                    try:
-                        detail = await cloud.get_setting_detail(base_sf)
-                        if detail.get("filament_id"):
-                            tray_info_idx = detail["filament_id"]
-                            cloud_name = detail.get("name", "")
-                            if cloud_name:
-                                tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                        elif detail.get("base_id"):
-                            bid = detail["base_id"].split("_")[0]
-                            if bid.startswith("GFS") and len(bid) >= 5:
-                                tray_info_idx = f"GF{bid[3:]}"
-                            else:
-                                tray_info_idx = bid
-                    finally:
-                        await cloud.close()
-                elif cloud is not None:
-                    await cloud.close()
-            except Exception as e:
-                logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
-
-            if not tray_info_idx:
-                tray_info_idx, setting_id = normalize_slicer_filament(sf)
-        elif base_sf.startswith("GF"):
-            tray_info_idx, setting_id = normalize_slicer_filament(sf)
-        else:
-            try:
-                local_id = int(sf)
-                from backend.app.models.local_preset import LocalPreset as LP
-
-                lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
-                lp = lp_result.scalar_one_or_none()
-                if lp:
-                    # Local preset's setting JSON carries the printer-recognized
-                    # filament_id (e.g. "P4d64437") — use that directly so the
-                    # slicer can resolve the specific preset. Falls through to
-                    # generic material id only when the JSON doesn't carry one.
-                    lp_filament_id = ""
-                    if lp.setting:
-                        try:
-                            setting_data = json.loads(lp.setting)
-                            raw_fid = setting_data.get("filament_id")
-                            if isinstance(raw_fid, str) and raw_fid:
-                                lp_filament_id = raw_fid
-                        except (json.JSONDecodeError, AttributeError):
-                            pass
-                    if lp_filament_id:
-                        tray_info_idx = lp_filament_id
-                        setting_id = filament_id_to_setting_id(lp_filament_id)
-                    else:
-                        mat = (spool.material or lp.filament_type or "").upper().strip()
-                        tray_info_idx = (
-                            _GENERIC_FILAMENT_IDS.get(mat)
-                            or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
-                            or ""
-                        )
-                    if lp.name:
-                        tray_sub_brands = lp.name.split("@")[0].strip()
-            except (ValueError, TypeError):
-                tray_info_idx, setting_id = normalize_slicer_filament(sf)
-
-    if tray_info_idx and spool.slicer_filament_name:
-        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
-
-        expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
-        if expected_name and expected_name != spool.slicer_filament_name:
-            for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
-                if fname == spool.slicer_filament_name:
-                    tray_info_idx = fid
-                    setting_id = filament_id_to_setting_id(fid)
-                    break
+    _generic_id_values = _GENERIC_ID_VALUES
+    _known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
 
-    # Defend against tray_info_idx values the slicer cannot resolve. Three
-    # shapes leak through and must be discarded so the generic-material
-    # fallback below can rescue the slot:
-    #   1. Literal material names ("PLA", "PETG-CF") that pass through
-    #      normalize_slicer_filament unchanged when the spool's slicer_filament
-    #      is free-text rather than a real preset ID.
-    #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
-    #      by the slicer as tray_info_idx (the printer's calibration table
-    #      indexes by filament_id, and a PFUS isn't one). This normally gets
-    #      realigned to a P-prefix local id via printer_kp lookup, but the
-    #      replay path in main.py.on_ams_change passes current_user=None,
-    #      which skips cloud auth and leaves the raw PFUS in tray_info_idx —
-    #      overwriting the correctly-configured slot from the original assign.
-    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
-    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
-    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
-    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
-    _known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
-    if tray_info_idx and (
-        tray_info_idx.upper() in _known_materials
-        or tray_info_idx.startswith("PFUS")
-        or tray_info_idx.startswith("PFCN")
-    ):
-        tray_info_idx = ""
-        setting_id = ""
+    # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
+    # the Spoolman-mode route via this helper (#1713). The helper handles
+    # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
+    # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
+    # sanitization. When it returns an empty tray_info_idx the local
+    # current-tray-state + generic-material fallback below rescues the slot.
+    tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+        db=db,
+        current_user=current_user,
+        slicer_filament=spool.slicer_filament,
+        slicer_filament_name=spool.slicer_filament_name,
+        material=spool.material,
+    )
+    if sub_brand_override:
+        tray_sub_brands = sub_brand_override
 
     if not tray_info_idx:
         if (
@@ -263,8 +142,8 @@ async def apply_spool_to_slot_via_mqtt(
         elif tray_type:
             material = tray_type.upper().strip()
             generic = (
-                _GENERIC_FILAMENT_IDS.get(material)
-                or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                GENERIC_FILAMENT_IDS.get(material)
+                or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
                 or ""
             )
             if generic:

+ 39 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -43,6 +43,7 @@ from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -55,6 +56,7 @@ from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_sl
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
+    filament_id_to_setting_id,
     normalize_slicer_filament,
 )
 
@@ -1221,7 +1223,7 @@ async def sync_spoolman_ams_weights(
 async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ) -> dict:
     """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
 
@@ -1306,13 +1308,43 @@ async def assign_spoolman_slot(
             if len(tray_color) == 6:
                 tray_color = tray_color + "FF"
 
-            material_upper = tray_type.upper().strip()
-            tray_info_idx = (
-                GENERIC_FILAMENT_IDS.get(material_upper)
-                or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                or ""
+            # #1713: resolve the spool's stored slicer_filament reference
+            # (cloud preset, local preset, GF-prefix builtin, or numeric
+            # LocalPreset id) to the printer-side tray_info_idx + setting_id.
+            # Previously the Spoolman path dropped slicer_filament on the
+            # floor and only the generic-material fallback fired; the user-
+            # configured profile never reached the printer. Shared with the
+            # internal-mode route via the same helper so the two flows can't
+            # drift again.
+            tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+                db=db,
+                current_user=current_user,
+                slicer_filament=mapped.get("slicer_filament"),
+                slicer_filament_name=mapped.get("slicer_filament_name"),
+                material=tray_type,
             )
-            setting_id = ""
+            if sub_brand_override:
+                tray_sub_brands = sub_brand_override
+
+            material_upper = tray_type.upper().strip()
+            # Fall back to generic-material id when slicer_filament is empty
+            # or the resolver discarded an unresolvable value. Matches the
+            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
+            if not tray_info_idx:
+                tray_info_idx = (
+                    GENERIC_FILAMENT_IDS.get(material_upper)
+                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or ""
+                )
+
+            # Ensure setting_id is always derivable from tray_info_idx. The
+            # local-preset path can leave it empty when the LP's setting JSON
+            # has no filament_id and falls through to the generic material id;
+            # without this fallback the slicer gets a half-configured slot
+            # (filament id without setting id) and the slot detail modal
+            # renders empty fields. Same pattern as the internal-mode tail.
+            if tray_info_idx and not setting_id:
+                setting_id = filament_id_to_setting_id(tray_info_idx)
 
             temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
             temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]

+ 205 - 0
backend/app/services/slicer_filament_resolver.py

@@ -0,0 +1,205 @@
+"""Shared spool ``slicer_filament`` → ``(tray_info_idx, setting_id)`` resolver.
+
+The internal-inventory and Spoolman-inventory routes both need to translate
+a spool's stored slicer-preset reference (cloud preset ID / local preset ID /
+GF-prefix Bambu filament ID / free-text material name) into the two MQTT
+fields ``ams_filament_setting`` consumes: the printer-side ``tray_info_idx``
+(filament_id) and the slicer-side ``setting_id``. The two routes were drifting
+in lockstep before #1713 — internal mode resolved everything, Spoolman mode
+silently dropped slicer_filament on the floor and only the generic-material
+fallback fired. This module is the single chokepoint so the two flows can't
+diverge again.
+
+Resolver outcomes:
+
+- Returns ``("", "", None)`` when ``slicer_filament`` is empty, unresolvable,
+  or sanitised away as a slicer-rejected value (literal material name,
+  PFUS / PFCN cloud setting_id). The caller is responsible for the
+  generic-material fallback when this happens.
+- Returns ``(tray_info_idx, setting_id, sub_brand_override)`` otherwise.
+  The third element is non-empty when a cloud-detail lookup or a local-
+  preset name provides a more specific brand label than the spool's own
+  ``"<brand> <material> <subtype>"`` concatenation — the caller should
+  prefer it over its computed default.
+
+The resolver is async because the GFS / PFUS / PFCN branches need cloud
+authentication and the local-preset branch reads ``LocalPreset`` from the
+DB. Pass ``current_user=None`` to skip cloud auth (the on_ams_change
+replay path uses this); cloud-prefix presets then fall back to a static
+``normalize_slicer_filament`` parse, which is correct when the slot was
+already configured by an earlier authenticated assign and the printer's
+calibration table preserves the real filament_id.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.user import User
+from backend.app.utils.filament_ids import (
+    GENERIC_FILAMENT_IDS,
+    MATERIAL_TEMPS,
+    filament_id_to_setting_id,
+    normalize_slicer_filament,
+)
+
+logger = logging.getLogger(__name__)
+
+_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
+
+
+async def resolve_slicer_filament(
+    *,
+    db: AsyncSession,
+    current_user: User | None,
+    slicer_filament: str | None,
+    slicer_filament_name: str | None,
+    material: str | None,
+) -> tuple[str, str, str | None]:
+    """Resolve a spool's slicer-preset reference to printer-side ids.
+
+    ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
+    ``"PFUS990b6e19965353"``, ``"38"`` for a numeric LocalPreset id, or
+    free-text). May be empty or None — returns the empty tuple in that case.
+
+    ``slicer_filament_name``: optional builtin-name realignment hint. When
+    set and the resolved tray_info_idx maps to a different builtin name,
+    the resolver swaps to the builtin whose name matches (e.g. user picked
+    "Bambu PLA Matte" but the cloud lookup landed on "Bambu PLA Basic").
+
+    ``material``: spool material string for the local-preset fallback
+    branch when the LocalPreset's setting JSON doesn't carry a filament_id.
+
+    Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
+    when nothing resolved. ``sub_brand_override`` is non-None when a more
+    specific brand label is available (cloud detail name or local preset
+    name); ``None`` means the caller should use its own default.
+    """
+    sf = (slicer_filament or "").strip()
+    if not sf:
+        return ("", "", None)
+
+    tray_info_idx = ""
+    setting_id = ""
+    sub_brand_override: str | None = None
+
+    base_sf = sf.split("_")[0] if "_" in sf else sf
+
+    # Cloud-side preset IDs in three known shapes:
+    #   GFS…   — Bambu official cloud preset
+    #   PFUS…  — cloud user-created preset
+    #   PFCN…  — cloud shared / partner preset (e.g. Polymaker's "(Custom)"
+    #            Bambu Lab H2D variant, #1648)
+    # All three need a cloud-detail lookup to extract the underlying
+    # filament_id; without it the raw cloud id ends up in tray_info_idx
+    # and the printer's calibration table can't resolve it.
+    if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
+        setting_id = base_sf
+        try:
+            from backend.app.api.routes.cloud import build_authenticated_cloud
+
+            cloud = await build_authenticated_cloud(db, current_user)
+            if cloud is not None and cloud.is_authenticated:
+                try:
+                    detail = await cloud.get_setting_detail(base_sf)
+                    if detail.get("filament_id"):
+                        tray_info_idx = detail["filament_id"]
+                        cloud_name = detail.get("name", "")
+                        if cloud_name:
+                            sub_brand_override = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                    elif detail.get("base_id"):
+                        bid = detail["base_id"].split("_")[0]
+                        if bid.startswith("GFS") and len(bid) >= 5:
+                            tray_info_idx = f"GF{bid[3:]}"
+                        else:
+                            tray_info_idx = bid
+                finally:
+                    await cloud.close()
+            elif cloud is not None:
+                await cloud.close()
+        except Exception as e:
+            logger.warning("Slicer-filament resolve: cloud lookup failed for %r: %s", sf, e)
+
+        if not tray_info_idx:
+            tray_info_idx, setting_id = normalize_slicer_filament(sf)
+    elif base_sf.startswith("GF"):
+        tray_info_idx, setting_id = normalize_slicer_filament(sf)
+    else:
+        try:
+            local_id = int(sf)
+            from backend.app.models.local_preset import LocalPreset as LP
+
+            lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
+            lp = lp_result.scalar_one_or_none()
+            if lp:
+                # Local preset's setting JSON carries the printer-recognized
+                # filament_id (e.g. "P4d64437") — use that directly so the
+                # slicer can resolve the specific preset. Falls through to
+                # generic material id only when the JSON doesn't carry one.
+                lp_filament_id = ""
+                if lp.setting:
+                    try:
+                        setting_data = json.loads(lp.setting)
+                        raw_fid = setting_data.get("filament_id")
+                        if isinstance(raw_fid, str) and raw_fid:
+                            lp_filament_id = raw_fid
+                    except (json.JSONDecodeError, AttributeError):
+                        pass
+                if lp_filament_id:
+                    tray_info_idx = lp_filament_id
+                    setting_id = filament_id_to_setting_id(lp_filament_id)
+                else:
+                    mat = (material or lp.filament_type or "").upper().strip()
+                    tray_info_idx = (
+                        GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
+                    )
+                if lp.name:
+                    sub_brand_override = lp.name.split("@")[0].strip()
+        except (ValueError, TypeError):
+            tray_info_idx, setting_id = normalize_slicer_filament(sf)
+
+    # Realign tray_info_idx to a builtin whose name matches slicer_filament_name
+    # when the current resolution lands on a builtin with a different name
+    # (e.g. cloud detail returned PLA Basic but the spool was labelled PLA Matte).
+    if tray_info_idx and slicer_filament_name:
+        from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+        expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
+        if expected_name and expected_name != slicer_filament_name:
+            for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
+                if fname == slicer_filament_name:
+                    tray_info_idx = fid
+                    setting_id = filament_id_to_setting_id(fid)
+                    break
+
+    # Defend against tray_info_idx values the slicer cannot resolve. Three
+    # shapes leak through and must be discarded so the caller's generic-
+    # material fallback can rescue the slot:
+    #   1. Literal material names ("PLA", "PETG-CF") that pass through
+    #      normalize_slicer_filament unchanged when the spool's slicer_filament
+    #      is free-text rather than a real preset ID.
+    #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
+    #      by the slicer as tray_info_idx (the printer's calibration table
+    #      indexes by filament_id, and a PFUS isn't one). This normally gets
+    #      realigned to a P-prefix local id via the caller's printer_kp
+    #      lookup, but on the replay path in main.py.on_ams_change
+    #      current_user=None skips cloud auth and leaves the raw PFUS in
+    #      tray_info_idx — overwriting the correctly-configured slot from
+    #      the original assign.
+    #   3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
+    #      "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
+    # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
+    # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
+    if tray_info_idx and (
+        tray_info_idx.upper() in _KNOWN_MATERIALS
+        or tray_info_idx.startswith("PFUS")
+        or tray_info_idx.startswith("PFCN")
+    ):
+        tray_info_idx = ""
+        setting_id = ""
+
+    return (tray_info_idx, setting_id, sub_brand_override)

+ 169 - 0
backend/tests/integration/test_spoolman_slot_assignment_mqtt.py

@@ -6,6 +6,7 @@ Covers:
   - MQTT failure does NOT roll back the slot assignment
 """
 
+import json
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -868,3 +869,171 @@ class TestAssignSpoolmanSlotKProfileRealignment:
         # extruder mismatch was hard-skipped pre-fix; now used as fallback
         cs_kwargs = mqtt_mock.extrusion_cali_sel.call_args[1]
         assert cs_kwargs["cali_idx"] == 42
+
+
+# ---- #1713: slicer_filament resolved into tray_info_idx + setting_id --------
+#
+# Before this fix the Spoolman-mode assign route ignored the spool's stored
+# slicer_filament (the user's configured Bambu Studio / Orca filament profile)
+# and only filled tray_info_idx from the generic-material fallback. The user
+# saw ams_filament_setting publish with tray_info_idx=GFL99 / setting_id=""
+# even though they had assigned a real profile to the spool, and had to
+# manually re-configure each slot through the printer card. The internal-mode
+# route did the resolution correctly via _apply_spool_to_slot_inner; the
+# Spoolman route was never ported.
+#
+# These tests pin the parity: an assign of a Spoolman spool whose
+# bambu_slicer_filament extra-field points at a real preset must publish that
+# preset's tray_info_idx + setting_id, not the generic-material bucket.
+
+
+class TestSlicerFilamentResolutionParity:
+    """#1713: Spoolman-mode assign honours the spool's configured slicer
+    filament profile, matching internal-mode behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_gf_prefix_slicer_filament_resolves_to_tray_info_idx(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        """GF-prefix Bambu official preset (e.g. ``GFA01``) routes straight
+        through ``normalize_slicer_filament`` — the simplest path and the
+        most common shape for users who picked their preset in the slicer."""
+        mock_spoolman_client.get_spool = AsyncMock(
+            return_value={**SAMPLE_SPOOL, "extra": {"bambu_slicer_filament": '"GFA01"'}}
+        )
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 0,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        assert call_kwargs["tray_info_idx"] == "GFA01", (
+            "Pre-fix: dropped slicer_filament and published GFL99 generic-PLA bucket. "
+            "Post-fix: must publish the actual preset id."
+        )
+        assert call_kwargs["setting_id"].startswith("GFSA01"), (
+            "setting_id must be derived from the resolved filament_id, not left empty as the pre-fix path did."
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_local_preset_int_id_resolves_to_filament_id_from_json(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client, db_session
+    ):
+        """#1713 regression: shaddowlink's exact case. Spool's slicer_filament
+        is the integer id of a LocalPreset whose setting JSON carries the
+        printer-side ``filament_id`` (e.g. ``P20bd830``). The publish must
+        carry that filament_id + its derived setting_id — not the generic
+        material bucket.
+
+        From his support bundle:
+          11:33:01 — assign_spoolman_slot published tray_info_idx=GFL99 (BUG)
+          11:33:13 — user manually fired /printers/.../configure with
+                     tray_info_idx=P20bd830, setting_id=PFUS3822acb73c88cc
+        """
+        from backend.app.models.local_preset import LocalPreset
+
+        lp = LocalPreset(
+            name="AMOLEN PLA Silk @0.4 nozzle",
+            preset_type="filament",
+            filament_type="PLA",
+            setting=json.dumps({"filament_id": "P20bd830"}),
+        )
+        db_session.add(lp)
+        await db_session.commit()
+        await db_session.refresh(lp)
+
+        # Spoolman spool whose bambu_slicer_filament points at this LocalPreset
+        # by integer id (the shape the inventory UI persists when the user
+        # picks a local preset in the filament dropdown).
+        mock_spoolman_client.get_spool = AsyncMock(
+            return_value={
+                **SAMPLE_SPOOL,
+                "extra": {"bambu_slicer_filament": json.dumps(str(lp.id))},
+            }
+        )
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 255,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        # Pre-fix the publish here was tray_info_idx="GFL99", setting_id="".
+        assert call_kwargs["tray_info_idx"] == "P20bd830"
+        assert call_kwargs["setting_id"], "setting_id must not be empty post-fix"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_slicer_filament_still_falls_back_to_generic_material(
+        self, async_client: AsyncClient, slot_settings, test_printer, mock_spoolman_client
+    ):
+        """Spools without a configured slicer_filament must still get the
+        generic-material fallback so the slot is at least minimally
+        configured. Guards against the resolver path swallowing the empty
+        case and leaving tray_info_idx empty."""
+        # extra dict has no bambu_slicer_filament key
+        mock_spoolman_client.get_spool = AsyncMock(return_value={**SAMPLE_SPOOL, "extra": {}})
+
+        mqtt_mock = MagicMock()
+        mqtt_mock.ams_set_filament_setting = MagicMock()
+        mqtt_mock.extrusion_cali_sel = MagicMock()
+        mqtt_mock.printer_state = None
+
+        with patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock:
+            pm_mock.get_client = MagicMock(return_value=mqtt_mock)
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(
+                "/api/v1/spoolman/inventory/slot-assignments",
+                json={
+                    "spoolman_spool_id": 10,
+                    "printer_id": test_printer.id,
+                    "ams_id": 0,
+                    "tray_id": 0,
+                },
+            )
+
+        assert response.status_code == 200
+        call_kwargs = mqtt_mock.ams_set_filament_setting.call_args[1]
+        # PLA → GFL99 (the generic-PLA bucket from GENERIC_FILAMENT_IDS).
+        assert call_kwargs["tray_info_idx"] == "GFL99"
+        # The generic-fallback path must STILL produce a non-empty setting_id
+        # (matches the internal-mode tail). Pre-fix this was "".
+        assert call_kwargs["setting_id"], (
+            "Even on the generic-material fallback, setting_id must be "
+            "filament_id_to_setting_id-derived so the slot detail modal "
+            "doesn't render with empty fields."
+        )