Преглед изворни кода

fix(inventory): record the mapped spool's material, not the sliced type (#2563)

A slot mapped to a different filament than it was sliced for (PLA slice
routed to the only loaded PETG slot) was logged under the sliced material
in the archive, Print Log and material stats, even though the correct spool
was debited. Once usage tracking resolves every used slot to a spool, adopt
the spool's material as the archive filament_type, exactly as the spool
colour is already adopted (#1494). All-or-nothing; both inventory backends;
flows through to the Print Log and stats. No schema/UI/i18n change.
maziggy пре 1 месец
родитељ
комит
001f2f120c

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 109 - 32
backend/app/services/spoolman_tracking.py

@@ -471,6 +471,7 @@ async def _report_spool_usage_for_slots(
     printer_serial: str = "",
     printer_id: int | None = None,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
@@ -511,10 +512,12 @@ async def _report_spool_usage_for_slots(
 
         spool_id_to_use: int | None = None
         resolution_path = ""
-        # color_hex of the resolved spool's filament, for the #1494 archive
-        # colour rewrite. The tag path already has the full spool object;
-        # the slot-assignment path only yields an id and is fetched below.
+        # color_hex + material of the resolved spool's filament, for the #1494
+        # archive colour rewrite and the #2563 type rewrite. The tag path
+        # already has the full spool object; the slot-assignment path only
+        # yields an id and is fetched below.
         spool_color_hex: str | None = None
+        spool_material: str | None = None
 
         spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
         if spool_tag:
@@ -523,6 +526,7 @@ async def _report_spool_usage_for_slots(
                 spool_id_to_use = spool["id"]
                 resolution_path = "tag"
                 spool_color_hex = (spool.get("filament") or {}).get("color_hex")
+                spool_material = (spool.get("filament") or {}).get("material")
 
         if spool_id_to_use is None and printer_id is not None:
             ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
@@ -538,19 +542,27 @@ async def _report_spool_usage_for_slots(
             )
             continue
 
-        # Record the spool's filament colour for the archive rewrite (#1494).
-        # The slot-assignment path resolved only an id, so fetch the spool.
-        # Strictly best-effort: a colour-fetch failure must never abort the
-        # weight reporting for the remaining slots, so the catch is broad.
-        if slot_colors_out is not None:
-            if spool_color_hex is None:
+        # Record the spool's filament colour + material for the archive
+        # rewrites (#1494, #2563). The slot-assignment path resolved only an
+        # id, so fetch the spool once for whichever value is still missing.
+        # Strictly best-effort: a fetch failure must never abort the weight
+        # reporting for the remaining slots, so the catch is broad.
+        if slot_colors_out is not None or slot_materials_out is not None:
+            need_color = slot_colors_out is not None and spool_color_hex is None
+            need_material = slot_materials_out is not None and spool_material is None
+            if need_color or need_material:
                 try:
-                    full_spool = await client.get_spool(spool_id_to_use)
-                    spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
-                except Exception as exc:  # noqa: BLE001 — colour is non-critical
-                    logger.debug("[SPOOLMAN] Slot %s: could not fetch spool colour: %s", slot_id, exc)
-            if spool_color_hex:
+                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    if need_color:
+                        spool_color_hex = _fil.get("color_hex")
+                    if need_material:
+                        spool_material = _fil.get("material")
+                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                    logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc)
+            if slot_colors_out is not None and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
+            if slot_materials_out is not None and spool_material:
+                slot_materials_out[slot_id] = spool_material
 
         try:
             await client.use_spool(spool_id_to_use, grams_used)
@@ -582,6 +594,7 @@ async def _report_spool_usage_split_by_tray_changes(
     printer_serial: str,
     printer_id: int,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> tuple[int, set[int]]:
     """Split each slot's grams across ``tray_changes`` and charge per-segment.
 
@@ -634,6 +647,7 @@ async def _report_spool_usage_split_by_tray_changes(
             spool_id_to_use: int | None = None
             resolution_path = ""
             spool_color_hex: str | None = None
+            spool_material: str | None = None
 
             spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
             if spool_tag:
@@ -642,6 +656,7 @@ async def _report_spool_usage_split_by_tray_changes(
                     spool_id_to_use = spool["id"]
                     resolution_path = "tag"
                     spool_color_hex = (spool.get("filament") or {}).get("color_hex")
+                    spool_material = (spool.get("filament") or {}).get("material")
 
             if spool_id_to_use is None:
                 seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
@@ -659,19 +674,27 @@ async def _report_spool_usage_split_by_tray_changes(
                 )
                 continue
 
-            # Colour rewrite (#1494) — first segment for a slot wins. The
-            # UI displays a single colour per slot, so later segments on the
-            # same slot don't overwrite (a backup swap can be a different
-            # colour but the archive card stays consistent with the origin).
-            if slot_colors_out is not None and slot_id not in slot_colors_out:
-                if spool_color_hex is None:
-                    try:
-                        full_spool = await client.get_spool(spool_id_to_use)
-                        spool_color_hex = (full_spool.get("filament") or {}).get("color_hex")
-                    except Exception as exc:  # noqa: BLE001 — colour is non-critical
-                        logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool colour: %s", slot_id, exc)
-                if spool_color_hex:
-                    slot_colors_out[slot_id] = spool_color_hex
+            # Colour (#1494) + material (#2563) rewrite — first segment for a
+            # slot wins. The UI displays a single colour/type per slot, so
+            # later segments on the same slot don't overwrite (a backup swap
+            # can differ but the archive card stays consistent with the origin).
+            need_color = slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex is None
+            need_material = (
+                slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None
+            )
+            if need_color or need_material:
+                try:
+                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    if need_color:
+                        spool_color_hex = _fil.get("color_hex")
+                    if need_material:
+                        spool_material = _fil.get("material")
+                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                    logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc)
+            if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex:
+                slot_colors_out[slot_id] = spool_color_hex
+            if slot_materials_out is not None and slot_id not in slot_materials_out and spool_material:
+                slot_materials_out[slot_id] = spool_material
 
             try:
                 await client.use_spool(spool_id_to_use, round(segment_grams, 2))
@@ -978,6 +1001,7 @@ async def report_usage(printer_id: int, archive_id: int):
         _layer_denom_hint = _total_layers or _current_layer
 
         slot_colors: dict[int, str] = {}
+        slot_materials: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()
         spools_updated = 0
 
@@ -1022,6 +1046,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_serial,
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
+                    slot_materials_out=slot_materials,
                 )
                 spools_updated += split_updated
                 handled_global_tray_ids |= split_handled
@@ -1037,6 +1062,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_serial,
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
+                    slot_materials_out=slot_materials,
                 )
                 # Track which physical slots the 3MF path already covered so
                 # Path 2 doesn't double-charge them.
@@ -1060,6 +1086,7 @@ async def report_usage(printer_id: int, archive_id: int):
                 handled_global_tray_ids=handled_global_tray_ids,
                 archive_id=archive_id,
                 slot_colors_out=slot_colors,
+                slot_materials_out=slot_materials,
             )
             spools_updated += fallback_updates
 
@@ -1073,6 +1100,10 @@ async def report_usage(printer_id: int, archive_id: int):
         # value (#1494) — mirrors the built-in inventory path in usage_tracker.
         await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
 
+        # Same for the material: a slot mapped to a differently-typed spool than
+        # it was sliced for otherwise records the sliced type (#2563).
+        await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
+
 
 async def _report_remain_delta_for_slots(
     client,
@@ -1083,6 +1114,7 @@ async def _report_remain_delta_for_slots(
     handled_global_tray_ids: set[int],
     archive_id: int,
     slot_colors_out: dict[int, str] | None = None,
+    slot_materials_out: dict[int, str] | None = None,
 ) -> int:
     """AMS remain%-delta path: write ``(start - current) * filament.weight``
     grams to Spoolman for slots the 3MF path didn't cover.
@@ -1167,15 +1199,19 @@ async def _report_remain_delta_for_slots(
             continue
 
         spools_updated += 1
+        # No 3MF slot_id for this path — use the AMS slot key so the maps can
+        # still be inspected by callers if needed. The archive rewrites
+        # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only
+        # prints intentionally don't participate (matches usage_tracker's
+        # slot_id=None).
         if slot_colors_out is not None:
             color = filament.get("color_hex")
             if color:
-                # No 3MF slot_id for this path — use the AMS slot key so the
-                # colour map can still be inspected by callers if needed.
-                # The archive-colour rewrite (#1494) keys on 3MF slot_ids so
-                # remain-delta-only prints intentionally don't participate
-                # in that rewrite (matches usage_tracker's slot_id=None).
                 slot_colors_out[-(global_tray_id + 1)] = color
+        if slot_materials_out is not None:
+            material = filament.get("material")
+            if material:
+                slot_materials_out[-(global_tray_id + 1)] = material
         logger.info(
             "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
             archive_id,
@@ -1230,3 +1266,44 @@ async def _apply_spool_colors_to_archive(
         )
         archive.filament_color = joined
         await db.commit()
+
+
+async def _apply_spool_types_to_archive(
+    db,
+    archive_id: int,
+    filament_usage: list[dict],
+    slot_materials: dict[int, str],
+) -> None:
+    """Overwrite an archive's ``filament_type`` with the materials of the
+    Spoolman spools that fed the print (#2563).
+
+    All-or-nothing, exactly like the colour path and the built-in inventory
+    path: the type is only rewritten when every used slot resolved to a spool
+    that carries a material, so a partial match never drops slots from the
+    archive or the material statistics.
+    """
+    if not slot_materials:
+        return
+
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.usage_tracker import _archive_types_from_spools
+
+    results = [{"slot_id": sid, "material": material} for sid, material in slot_materials.items()]
+    types = _archive_types_from_spools(filament_usage, results)
+    if not types:
+        return
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None:
+        return
+
+    joined = ",".join(types)
+    if joined != archive.filament_type:
+        logger.info(
+            "[SPOOLMAN] Archive %s filament_type %r -> %r (from Spoolman spools)",
+            archive_id,
+            archive.filament_type,
+            joined,
+        )
+        archive.filament_type = joined
+        await db.commit()

+ 56 - 0
backend/app/services/usage_tracker.py

@@ -117,6 +117,47 @@ def _archive_colors_from_spools(filament_usage: list[dict], results: list[dict])
     return ordered
 
 
+def _archive_types_from_spools(filament_usage: list[dict], results: list[dict]) -> list[str] | None:
+    """Slot-ordered, de-duplicated materials for an archive's ``filament_type``,
+    taken from the inventory spools that actually fed the print (#2563).
+
+    The slicer's 3MF records the filament type it was *sliced for*. When the
+    user manually maps a slot to a differently-typed loaded spool in the Print
+    dialog — a PLA slice routed to the only loaded PETG slot — that sliced type
+    misclassifies the run in the archive card, the Print Log and the material
+    statistics, even though the deduction correctly hit the PETG spool. Once
+    usage tracking has resolved every used slot to an inventory spool, the
+    spool's declared material is the authoritative record of what was consumed,
+    the same reasoning that already adopts the spool colour (#1494).
+
+    Returns ``None`` — leave the 3MF type untouched — unless *every* slot with
+    non-zero usage was matched to a spool that carries a material. All-or-
+    nothing, exactly like ``_archive_colors_from_spools``: a partial rewrite
+    would silently drop the unmatched slots' types from the archive (and the
+    material stats).
+    """
+    used_slots = {u["slot_id"] for u in filament_usage if u.get("used_g", 0) > 0 and u.get("slot_id") is not None}
+    if not used_slots:
+        return None
+
+    slot_material: dict[int, str] = {}
+    for r in results:
+        slot_id = r.get("slot_id")
+        material = (r.get("material") or "").strip()
+        if slot_id is not None and material:
+            slot_material.setdefault(slot_id, material)
+
+    if not used_slots.issubset(slot_material):
+        return None
+
+    ordered: list[str] = []
+    for slot_id in sorted(used_slots):
+        material = slot_material[slot_id]
+        if material not in ordered:
+            ordered.append(material)
+    return ordered
+
+
 def _match_slots_by_color(
     filament_usage: list[dict],
     ams_raw: dict | list | None,
@@ -1396,4 +1437,19 @@ async def _track_from_3mf(
                 )
                 archive.filament_color = joined
 
+        # Adopt the matched spools' materials too (#2563) — a slot mapped to a
+        # differently-typed spool than it was sliced for otherwise records the
+        # sliced type in the archive, Print Log and material stats.
+        spool_types = _archive_types_from_spools(filament_usage, results)
+        if spool_types:
+            joined_types = ",".join(spool_types)
+            if joined_types != archive.filament_type:
+                logger.info(
+                    "[UsageTracker] 3MF: archive %s filament_type %r -> %r (from inventory spools)",
+                    archive_id,
+                    archive.filament_type,
+                    joined_types,
+                )
+                archive.filament_type = joined_types
+
     return results

+ 46 - 0
backend/tests/unit/test_spoolman_tracking.py

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 
 from backend.app.services.spoolman_tracking import (
+    _apply_spool_types_to_archive,
     _get_fallback_spool_tag,
     _global_tray_id_to_ams_slot,
     _hash_serial_to_hex32,
@@ -272,3 +273,48 @@ class TestStorePrintData:
         # plate_id=2 must be passed as the second positional arg
         assert extract_mock.call_count == 1
         assert extract_mock.call_args.args[1] == 2
+
+
+class TestApplySpoolTypesToArchive:
+    """_apply_spool_types_to_archive() rewrites the archive's filament_type
+    from the resolved Spoolman spools' materials (#2563)."""
+
+    @staticmethod
+    async def _make_archive(db, filament_type):
+        from backend.app.models.archive import PrintArchive
+
+        archive = PrintArchive(
+            filename="job.gcode.3mf",
+            file_path="archive/job.3mf",
+            file_size=1,
+            filament_type=filament_type,
+        )
+        db.add(archive)
+        await db.commit()
+        await db.refresh(archive)
+        return archive
+
+    @pytest.mark.asyncio
+    async def test_overwrites_sliced_type_with_spool_material(self, db_session):
+        """PLA-sliced job mapped to a PETG Spoolman spool → recorded as PETG."""
+        archive = await self._make_archive(db_session, "PLA")
+        usage = [{"slot_id": 1, "used_g": 2.9}]
+        await _apply_spool_types_to_archive(db_session, archive.id, usage, {1: "PETG"})
+        await db_session.refresh(archive)
+        assert archive.filament_type == "PETG"
+
+    @pytest.mark.asyncio
+    async def test_partial_match_leaves_type_untouched(self, db_session):
+        """All-or-nothing: an unmatched used slot means no rewrite."""
+        archive = await self._make_archive(db_session, "PLA,PLA")
+        usage = [{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 3.0}]
+        await _apply_spool_types_to_archive(db_session, archive.id, usage, {1: "PETG"})
+        await db_session.refresh(archive)
+        assert archive.filament_type == "PLA,PLA"
+
+    @pytest.mark.asyncio
+    async def test_empty_materials_is_noop(self, db_session):
+        archive = await self._make_archive(db_session, "PLA")
+        await _apply_spool_types_to_archive(db_session, archive.id, [{"slot_id": 1, "used_g": 2.9}], {})
+        await db_session.refresh(archive)
+        assert archive.filament_type == "PLA"

+ 56 - 0
backend/tests/unit/test_usage_tracker.py

@@ -14,6 +14,7 @@ import pytest
 from backend.app.services.usage_tracker import (
     PrintSession,
     _active_sessions,
+    _archive_types_from_spools,
     _decode_mqtt_mapping,
     _find_3mf_by_filename,
     _match_slots_by_color,
@@ -2471,3 +2472,58 @@ class TestTrackFrom3mfPlateId:
             )
 
         assert extract_mock.call_args.args[1] is None
+
+
+class TestArchiveTypesFromSpools:
+    """Tests for _archive_types_from_spools() — adopting the mapped inventory
+    spool's material as the archive/print-log/stats filament type (#2563)."""
+
+    @staticmethod
+    def _usage(slots):
+        """slots: list of (slot_id, used_g)."""
+        return [{"slot_id": sid, "used_g": g} for sid, g in slots]
+
+    @staticmethod
+    def _results(entries):
+        """entries: list of (slot_id, material)."""
+        return [{"slot_id": sid, "material": m} for sid, m in entries]
+
+    def test_mapped_material_overrides_sliced_type(self):
+        """The reporter's case: PLA-sliced slot mapped to a PETG spool → PETG."""
+        usage = self._usage([(1, 2.9)])
+        results = self._results([(1, "PETG")])
+        assert _archive_types_from_spools(usage, results) == ["PETG"]
+
+    def test_multi_slot_slot_ordered_and_deduped(self):
+        """Distinct materials keep slot order; a repeated material collapses."""
+        usage = self._usage([(1, 5.0), (2, 3.0), (3, 4.0)])
+        results = self._results([(2, "PETG"), (1, "PLA"), (3, "PLA")])
+        assert _archive_types_from_spools(usage, results) == ["PLA", "PETG"]
+
+    def test_partial_match_returns_none(self):
+        """All-or-nothing: an unmatched used slot means no rewrite at all."""
+        usage = self._usage([(1, 5.0), (2, 3.0)])
+        results = self._results([(1, "PETG")])  # slot 2 never resolved
+        assert _archive_types_from_spools(usage, results) is None
+
+    def test_zero_usage_slot_ignored(self):
+        """A slot that consumed nothing doesn't need a spool material."""
+        usage = self._usage([(1, 5.0), (2, 0.0)])
+        results = self._results([(1, "PETG")])
+        assert _archive_types_from_spools(usage, results) == ["PETG"]
+
+    def test_no_used_slots_returns_none(self):
+        assert _archive_types_from_spools([], []) is None
+        assert _archive_types_from_spools(self._usage([(1, 0.0)]), []) is None
+
+    def test_fallback_results_without_slot_id_ignored(self):
+        """AMS remain%-delta results carry slot_id=None and must not count."""
+        usage = self._usage([(1, 5.0)])
+        results = [{"slot_id": None, "material": "PETG"}]
+        assert _archive_types_from_spools(usage, results) is None
+
+    def test_blank_material_is_not_a_match(self):
+        """An empty/whitespace material doesn't satisfy the all-or-nothing gate."""
+        usage = self._usage([(1, 5.0)])
+        assert _archive_types_from_spools(usage, self._results([(1, "  ")])) is None
+        assert _archive_types_from_spools(usage, self._results([(1, " PETG ")])) == ["PETG"]

Неке датотеке нису приказане због велике количине промена