Explorar el Código

fix(vp): scope saved AMS mapping to the printer it was resolved against

    Round-2 review fixes for #2700.

    Blocking: the toggle didn't actually gate the archive write. archive.py's
    promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
    request-topic interception captures ams_mapping unconditionally for every
    print source (slicer-direct LAN prints included). Since main.py's
    real-printer auto-archive path forwards the full MQTT payload as
    print_data, every archive on any install — VP or not — grew
    extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
    with an explicit `slicer_ams_mapping` param on archive_print() that only
    the VP-queue path (already gated on save_ams_mapping) ever passes.

    Blocking: a saved mapping could get reused on a printer it was never
    resolved against — tray IDs only mean something relative to one printer's
    AMS layout. extra_data.slicer_ams_mapping is now stored as
    {mapping, printer_id} instead of a bare array:
    - add_to_queue's fallback only fires when the reprint's target printer_id
      matches the mapping's origin printer.
    - The frontend's archiveAmsMapping only surfaces (and the Mapping button
      only appears) when the print modal's selected printer matches too.
    - A model-based VP (target_printer_id=None, no MQTT bridge to any real
      printer) never stamps a mapping in the first place — there's no live AMS
      layout for the slicer to have resolved tray IDs against.

    Also from review:
    - Multi-plate archives now get the Mapping button too (the per-plate
      FilamentMapping loop was missing archiveAmsMapping entirely).
    - Added coverage for the previously-untested late-MQTT archive patch path
      (_restamp_recent_queue_item), including the model-based-VP skip case.
    - usingArchiveMapping now also resets on printer change, not just
      plate/archive (it already worked via the printer-scoping above, but is
      now an explicit dependency too).
    - The Mapping button's revert (OFF) now undoes only the slots it itself
      set, not every manual pick in scope — matches the comment above it.
    - Added a comment on why negative-value slots (external spool) are
      skipped rather than cleared when applying a saved mapping.
maziggy hace 1 mes
padre
commit
dc6217f4e6

+ 21 - 9
backend/app/api/routes/print_queue.py

@@ -657,15 +657,27 @@ async def add_to_queue(
     # to the exact same physical spool instead of the scheduler re-deriving a
     # to the exact same physical spool instead of the scheduler re-deriving a
     # (possibly ambiguous) mapping from just the file's static type/color.
     # (possibly ambiguous) mapping from just the file's static type/color.
     #
     #
-    # Note this is unconditional — it applies regardless of whether the
-    # physical spool in that slot has changed since the original print. #1308
-    # covers re-verifying a stored mapping against live AMS state at dispatch
-    # time; that check is a separate PR and, once merged, will also catch a
-    # stale slot inherited through this fallback.
-    if ams_mapping_json is None and archive and archive.extra_data:
-        saved_mapping = archive.extra_data.get("slicer_ams_mapping")
-        if isinstance(saved_mapping, list) and saved_mapping:
-            ams_mapping_json = json.dumps(saved_mapping)
+    # Global tray IDs only mean something relative to the specific printer
+    # they were resolved against, so this only fires when the reprint targets
+    # that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
+    # for a model-based dispatch (data.printer_id is None) or a reprint aimed
+    # at a different printer, where the same tray number can hold a
+    # completely different spool (#2700 review).
+    #
+    # Note this is otherwise unconditional — it applies regardless of whether
+    # the physical spool in that slot has changed since the original print.
+    # #1308 covers re-verifying a stored mapping against live AMS state at
+    # dispatch time; that check is a separate PR and, once merged, will also
+    # catch a stale slot inherited through this fallback.
+    if ams_mapping_json is None and archive and archive.extra_data and data.printer_id is not None:
+        saved = archive.extra_data.get("slicer_ams_mapping")
+        if (
+            isinstance(saved, dict)
+            and saved.get("printer_id") == data.printer_id
+            and isinstance(saved.get("mapping"), list)
+            and saved["mapping"]
+        ):
+            ams_mapping_json = json.dumps(saved["mapping"])
     items = []
     items = []
     for i in range(quantity):
     for i in range(quantity):
         item = PrintQueueItem(
         item = PrintQueueItem(

+ 36 - 12
backend/app/services/archive.py

@@ -1144,6 +1144,8 @@ class ArchiveService:
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         plate_id: int | None = None,
         library_file_id: int | None = None,
         library_file_id: int | None = None,
+        slicer_ams_mapping: list[int] | None = None,
+        slicer_ams_mapping_printer_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
         """Archive a 3MF file with metadata.
 
 
@@ -1166,6 +1168,21 @@ class ArchiveService:
                 metadata. Used by virtual-printer flows so users who rename a job in
                 metadata. Used by virtual-printer flows so users who rename a job in
                 BambuStudio's "send to printer" dialog see that name instead of the
                 BambuStudio's "send to printer" dialog see that name instead of the
                 creator-baked title (#1152).
                 creator-baked title (#1152).
+            slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
+                onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
+                a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
+                populated on every MQTT print-start callback regardless of source (bambu_mqtt's
+                request-topic interception captures it for slicer-direct LAN prints too), so
+                promoting it unconditionally would stamp every archive on installs with no
+                virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
+                "Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
+            slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
+                resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
+                only means something relative to one printer's specific AMS layout, so a
+                mapping saved without knowing which printer it came from can't be safely
+                reused later on any printer, including the same one (there'd be no way to
+                tell). A model-based VP with no fixed target printer has no valid value to
+                pass here and must leave both params unset.
         """
         """
         # Verify printer exists if specified
         # Verify printer exists if specified
         if printer_id is not None:
         if printer_id is not None:
@@ -1253,18 +1270,25 @@ class ArchiveService:
         # Merge with print data from MQTT
         # Merge with print data from MQTT
         if print_data:
         if print_data:
             metadata["_print_data"] = print_data
             metadata["_print_data"] = print_data
-            # Promote the slicer's own live-resolved AMS-slot pick (captured
-            # from the project_file MQTT command by the VP-queue path — see
-            # `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py)
-            # to a stable top-level extra_data key. Lets a later reprint reuse
-            # the exact tray the user picked/BambuStudio auto-matched at slice
-            # time instead of the scheduler re-deriving one from just the
-            # file's static type/color, which can land on the wrong physical
-            # spool when that match isn't unique. Top-level (not nested under
-            # the `_print_data` diagnostic bag) so API consumers have a single
-            # stable path: `archive.extra_data.slicer_ams_mapping`.
-            if print_data.get("ams_mapping"):
-                metadata["slicer_ams_mapping"] = print_data["ams_mapping"]
+
+        # Promote the slicer's own live-resolved AMS-slot pick, when the caller
+        # explicitly opted in (see the `slicer_ams_mapping` param docstring for
+        # why this is NOT read off `print_data["ams_mapping"]`), to a stable
+        # top-level extra_data key. Lets a later reprint reuse the exact tray
+        # the user picked/BambuStudio auto-matched at slice time instead of the
+        # scheduler re-deriving one from just the file's static type/color,
+        # which can land on the wrong physical spool when that match isn't
+        # unique. Top-level (not nested under the `_print_data` diagnostic bag)
+        # so API consumers have a single stable path:
+        # `archive.extra_data.slicer_ams_mapping`. Stored together with the
+        # printer it was resolved against — see `slicer_ams_mapping_printer_id`
+        # param docstring — so a later reprint can tell whether it's even
+        # applicable before trying to reuse it.
+        if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
+            metadata["slicer_ams_mapping"] = {
+                "mapping": slicer_ams_mapping,
+                "printer_id": slicer_ams_mapping_printer_id,
+            }
 
 
         # Determine status and timestamps
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
         status = print_data.get("status", "completed") if print_data else "archived"

+ 37 - 14
backend/app/services/virtual_printer/manager.py

@@ -521,7 +521,15 @@ class VirtualPrinterInstance:
             if raw is not None:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
                 patch["nozzle_mapping"] = json.dumps(raw)
 
 
-        ams_mapping_json = _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+        # Same target_printer_id gate as the immediate path in
+        # _add_to_print_queue — a model-based VP has no MQTT bridge to a real
+        # printer, so there's no live AMS layout for the slicer to have
+        # resolved tray IDs against.
+        ams_mapping_json = (
+            _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+            if self.target_printer_id is not None
+            else None
+        )
         if ams_mapping_json is not None:
         if ams_mapping_json is not None:
             patch["ams_mapping"] = ams_mapping_json
             patch["ams_mapping"] = ams_mapping_json
 
 
@@ -566,7 +574,10 @@ class VirtualPrinterInstance:
                         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
                         archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
                         for archive in archive_result.scalars().all():
                         for archive in archive_result.scalars().all():
                             extra = dict(archive.extra_data or {})
                             extra = dict(archive.extra_data or {})
-                            extra["slicer_ams_mapping"] = json.loads(ams_mapping_json)
+                            extra["slicer_ams_mapping"] = {
+                                "mapping": json.loads(ams_mapping_json),
+                                "printer_id": self.target_printer_id,
+                            }
                             archive.extra_data = extra
                             archive.extra_data = extra
 
 
                 await db.commit()
                 await db.commit()
@@ -917,8 +928,17 @@ class VirtualPrinterInstance:
                 # present it makes `_ensure_ams_mapping` skip its own
                 # present it makes `_ensure_ams_mapping` skip its own
                 # type/color re-derivation entirely and dispatch use exactly
                 # type/color re-derivation entirely and dispatch use exactly
                 # the tray the slicer/user picked.
                 # the tray the slicer/user picked.
+                #
+                # Only trust it when this VP targets one fixed printer. A
+                # model-based ("Any <model>") VP has no MQTT bridge to a real
+                # printer, so the slicer has no live AMS layout to resolve
+                # tray IDs against — whatever it sends here is meaningless
+                # (or, worse, coincidentally valid for the wrong printer once
+                # the scheduler later picks one). Leaving it unset lets the
+                # scheduler's normal type/color re-derivation run against
+                # whichever printer actually gets the job.
                 ams_mapping_json: str | None = None
                 ams_mapping_json: str | None = None
-                if slicer_opts is not None:
+                if slicer_opts is not None and self.target_printer_id is not None:
                     ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
                     ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
 
 
                 service = ArchiveService(db)
                 service = ArchiveService(db)
@@ -929,19 +949,22 @@ class VirtualPrinterInstance:
                         "status": "archived",
                         "status": "archived",
                         "source": "virtual_printer",
                         "source": "virtual_printer",
                         "source_ip": source_ip,
                         "source_ip": source_ip,
-                        # Slicer's own live AMS-slot pick -- promoted to
-                        # `extra_data.slicer_ams_mapping` by archive_print() so
-                        # a later reprint can reuse it. Opt-in per VP
-                        # (`save_ams_mapping`) — only the archive persistence is
-                        # gated; the queue item's own `ams_mapping` (used for
-                        # *this* dispatch, below) is set unconditionally
-                        # whenever the slicer provides it, since that's a
-                        # correctness fix, not a feature toggle.
-                        "ams_mapping": (
-                            json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
-                        ),
                     },
                     },
                     prefer_filename_for_name=prefer_filename,
                     prefer_filename_for_name=prefer_filename,
+                    # Slicer's own live AMS-slot pick -- promoted to
+                    # `extra_data.slicer_ams_mapping` by archive_print() so a
+                    # later reprint can reuse it. Opt-in per VP
+                    # (`save_ams_mapping`) — only the archive persistence is
+                    # gated; the queue item's own `ams_mapping` (used for
+                    # *this* dispatch, below) is set unconditionally whenever
+                    # the slicer provides it, since that's a correctness fix,
+                    # not a feature toggle. Tagged with the printer it was
+                    # resolved against so a later reprint on a *different*
+                    # printer knows not to reuse it (#2700 review).
+                    slicer_ams_mapping=(
+                        json.loads(ams_mapping_json) if ams_mapping_json and self.save_ams_mapping else None
+                    ),
+                    slicer_ams_mapping_printer_id=self.target_printer_id,
                 )
                 )
                 if archive:
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)

+ 59 - 6
backend/tests/integration/test_print_queue_api.py

@@ -264,13 +264,16 @@ class TestPrintQueueAPI:
         self, async_client: AsyncClient, printer_factory, archive_factory, db_session
         self, async_client: AsyncClient, printer_factory, archive_factory, db_session
     ):
     ):
         """When the caller sends no explicit ams_mapping, but the archive
         """When the caller sends no explicit ams_mapping, but the archive
-        carries the slicer's own saved pick (extra_data.slicer_ams_mapping,
-        written by a VP with "Save AMS mapping" on), the queue item should
-        inherit it — the same exact-physical-spool reuse the "Mapping"
-        button gives you, but automatic when nothing was hand-edited.
+        carries the slicer's own saved pick for this exact printer
+        (extra_data.slicer_ams_mapping, written by a VP with "Save AMS
+        mapping" on), the queue item should inherit it — the same
+        exact-physical-spool reuse the "Mapping" button gives you, but
+        automatic when nothing was hand-edited.
         """
         """
         printer = await printer_factory()
         printer = await printer_factory()
-        archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
 
 
         data = {
         data = {
             "printer_id": printer.id,
             "printer_id": printer.id,
@@ -281,6 +284,54 @@ class TestPrintQueueAPI:
         result = response.json()
         result = response.json()
         assert result["ams_mapping"] == [5, -1, 2, -1]
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A saved mapping's tray IDs only mean something relative to the
+        printer they were resolved against. Reprinting the same archive on a
+        *different* printer must not inherit it — tray 5 on printer A can
+        hold a completely different spool than tray 5 on printer B.
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "printer_id": other_printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A model-based item (no fixed printer_id) can't know in advance
+        which printer the scheduler will pick, so a saved mapping resolved
+        against one specific printer must never be inherited here either.
+        """
+        origin_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "target_model": "X1C",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
     async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
@@ -291,7 +342,9 @@ class TestPrintQueueAPI:
         pick — the fallback only fires when the caller sent nothing at all.
         pick — the fallback only fires when the caller sent nothing at all.
         """
         """
         printer = await printer_factory()
         printer = await printer_factory()
-        archive = await archive_factory(extra_data={"slicer_ams_mapping": [5, -1, 2, -1]})
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
 
 
         data = {
         data = {
             "printer_id": printer.id,
             "printer_id": printer.id,

+ 274 - 7
backend/tests/unit/services/test_virtual_printer.py

@@ -1880,6 +1880,7 @@ class TestVirtualPrinterInstance:
             base_dir=tmp_path,
             base_dir=tmp_path,
             session_factory=mock_session_factory,
             session_factory=mock_session_factory,
             save_ams_mapping=False,
             save_ams_mapping=False,
+            target_printer_id=7,
         )
         )
 
 
         file_path = tmp_path / "test.3mf"
         file_path = tmp_path / "test.3mf"
@@ -1913,9 +1914,8 @@ class TestVirtualPrinterInstance:
         assert item.ams_mapping is not None
         assert item.ams_mapping is not None
         assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
         assert _json.loads(item.ams_mapping) == [4, -1, 12, -1]
 
 
-        # Toggle is off: the archive's print_data must NOT carry the mapping.
-        print_data = mock_archive_print.await_args.kwargs["print_data"]
-        assert print_data["ams_mapping"] is None
+        # Toggle is off: archive_print must not be told to persist a mapping.
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
     async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
@@ -1948,6 +1948,7 @@ class TestVirtualPrinterInstance:
             base_dir=tmp_path,
             base_dir=tmp_path,
             session_factory=mock_session_factory,
             session_factory=mock_session_factory,
             save_ams_mapping=True,
             save_ams_mapping=True,
+            target_printer_id=7,
         )
         )
 
 
         file_path = tmp_path / "test.3mf"
         file_path = tmp_path / "test.3mf"
@@ -1979,8 +1980,75 @@ class TestVirtualPrinterInstance:
         assert len(added_items) == 1
         assert len(added_items) == 1
         assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
         assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
 
 
-        print_data = mock_archive_print.await_args.kwargs["print_data"]
-        assert print_data["ams_mapping"] == [4, -1, 12, -1]
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] == [4, -1, 12, -1]
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] == 7
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_ignores_ams_mapping_for_model_based_vp(self, tmp_path):
+        """A model-based VP (`target_printer_id=None`, dispatched later by the
+        scheduler to whichever printer matches) has no MQTT bridge to a real
+        printer, so the slicer has no live AMS layout to resolve tray IDs
+        against. Whatever it sends must be ignored on both the queue item and
+        the archive — trusting it would dispatch the eventually-chosen
+        printer onto a tray resolved against nothing (#2700 review).
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=451,
+            name="AMSMappingModelBased",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800451",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            target_printer_id=None,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as mock_archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].ams_mapping is None
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping_printer_id"] is None
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
     async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
@@ -2010,6 +2078,7 @@ class TestVirtualPrinterInstance:
             base_dir=tmp_path,
             base_dir=tmp_path,
             session_factory=mock_session_factory,
             session_factory=mock_session_factory,
             save_ams_mapping=True,
             save_ams_mapping=True,
+            target_printer_id=7,
         )
         )
 
 
         file_path = tmp_path / "test.3mf"
         file_path = tmp_path / "test.3mf"
@@ -2040,8 +2109,7 @@ class TestVirtualPrinterInstance:
 
 
         assert len(added_items) == 1
         assert len(added_items) == 1
         assert added_items[0].ams_mapping is None
         assert added_items[0].ams_mapping is None
-        print_data = mock_archive_print.await_args.kwargs["print_data"]
-        assert print_data["ams_mapping"] is None
+        assert mock_archive_print.await_args.kwargs["slicer_ams_mapping"] is None
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
     async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
@@ -2228,6 +2296,205 @@ class TestVirtualPrinterInstance:
         # Recent-queue tracking dict is cleared after the patch.
         # Recent-queue tracking dict is cleared after the patch.
         assert file_path.name not in inst._recent_queue_items
         assert file_path.name not in inst._recent_queue_items
 
 
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_retroactively_stamps_archive_ams_mapping(self, tmp_path):
+        """Same #1780-round-3 race as the test above, but for `ams_mapping`
+        specifically: the archive was already created (with no
+        `slicer_ams_mapping`, since the slicer's pick hadn't arrived yet)
+        before this late MQTT lands. With the toggle on and a fixed target
+        printer, the archive must be retroactively patched too — otherwise a
+        reprint from this archive would never see the badge or the Mapping
+        button, even though the queue item itself dispatches correctly.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
+        )
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        mock_archive_row = MagicMock()
+        mock_archive_row.id = 55
+        mock_archive_row.extra_data = None
+
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        # (queue_item_id, archive_id) — matches the id set by db.add above.
+        select_pending_result.all = MagicMock(return_value=[(101, 55)])
+        update_result = MagicMock()
+        select_archives_result = MagicMock()
+        select_archives_result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row])))
+        mock_db.execute = AsyncMock(
+            side_effect=[position_max_result, select_pending_result, update_result, select_archives_result]
+        )
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=98,
+            name="LateMQTTArchivePatch",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800098",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            target_printer_id=7,
+        )
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 55
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].ams_mapping is None  # MQTT was never received in time
+
+        # MQTT project_file arrives late, carrying the slicer's AMS pick.
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "file": file_path.name,
+                "ams_mapping": [4, -1, 12, -1],
+            },
+        )
+
+        # 3rd execute() is the queue-item UPDATE; 4th is the archive SELECT.
+        update_call = mock_db.execute.await_args_list[2]
+        compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
+        assert _json.loads(dict(compiled.params)["ams_mapping"]) == [4, -1, 12, -1]
+
+        assert mock_archive_row.extra_data == {
+            "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7},
+        }
+
+    @pytest.mark.asyncio
+    async def test_on_print_command_late_mqtt_skips_archive_patch_for_model_based_vp(self, tmp_path):
+        """The archive patch above must not fire for a model-based VP
+        (`target_printer_id=None`) either — same rationale as the immediate
+        path: no fixed printer means no live AMS layout to have resolved the
+        late mapping against.
+        """
+        import json as _json
+
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items: list = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(
+            side_effect=lambda item: (added_items.append(item), setattr(item, "id", 200 + len(added_items)))[0]
+        )
+        mock_db.flush = AsyncMock()
+        mock_db.commit = AsyncMock()
+
+        position_max_result = MagicMock()
+        position_max_result.scalar = MagicMock(return_value=None)
+        select_pending_result = MagicMock()
+        select_pending_result.all = MagicMock(return_value=[(201, 65)])
+        update_result = MagicMock()
+        # No 4th execute() expected — the archive patch must be skipped
+        # entirely, so only 3 calls should ever happen.
+        mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=97,
+            name="LateMQTTModelBased",
+            mode="queue",
+            model="X1C",
+            access_code="12345678",
+            serial_suffix="391800097",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+            save_ams_mapping=True,
+            target_printer_id=None,
+        )
+        inst._mqtt = MagicMock()
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 65
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+            patch(
+                "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
+                0.05,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "file": file_path.name,
+                "ams_mapping": [4, -1, 12, -1],
+                # An unrelated field so `patch` isn't empty and the UPDATE
+                # actually runs — isolates the assertion to "ams_mapping was
+                # excluded" rather than "nothing happened at all".
+                "timelapse": True,
+            },
+        )
+
+        # 3rd execute() is the queue-item UPDATE — ams_mapping must be absent
+        # from it. No 4th execute() (the archive SELECT) should follow.
+        assert mock_db.execute.await_count == 3
+        update_call = mock_db.execute.await_args_list[2]
+        compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
+        assert "ams_mapping" not in dict(compiled.params)
+        assert dict(compiled.params)["timelapse"] is True
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
     async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
         """The actual race-window scenario: wait_for times out, then MQTT
         """The actual race-window scenario: wait_for times out, then MQTT

+ 19 - 7
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
@@ -38,34 +38,46 @@ export function FilamentMapping({
   // normal auto-match, without touching any *other* manual picks the user
   // normal auto-match, without touching any *other* manual picks the user
   // made by hand.
   // made by hand.
   const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
   const [usingArchiveMapping, setUsingArchiveMapping] = useState(false);
+  // Which slot IDs the ON branch below actually wrote into manualMappings —
+  // so OFF can undo exactly those and leave any *other* manual pick the user
+  // made by hand (before or after pressing the button) untouched.
+  const appliedSlotIdsRef = useRef<number[]>([]);
 
 
   // Reset the toggle whenever the saved mapping it would apply changes — a
   // Reset the toggle whenever the saved mapping it would apply changes — a
-  // different plate selection or a different archive entirely. Without this
-  // the button can read ON (green) from a previous archive/plate even though
-  // it was never pressed against the mapping currently in scope.
+  // different printer, plate selection, or archive entirely. Without this
+  // the button can read ON (green) from a previous printer/archive/plate
+  // even though it was never pressed against the mapping currently in scope.
   useEffect(() => {
   useEffect(() => {
     setUsingArchiveMapping(false);
     setUsingArchiveMapping(false);
-  }, [archiveAmsMapping, plateLabel]);
+    appliedSlotIdsRef.current = [];
+  }, [archiveAmsMapping, plateLabel, printerId]);
 
 
   const toggleArchiveMapping = () => {
   const toggleArchiveMapping = () => {
     if (!archiveAmsMapping || !filamentReqs?.filaments) return;
     if (!archiveAmsMapping || !filamentReqs?.filaments) return;
     if (usingArchiveMapping) {
     if (usingArchiveMapping) {
       const next = { ...manualMappings };
       const next = { ...manualMappings };
-      for (const req of filamentReqs.filaments) {
-        if (req.slot_id > 0) delete next[req.slot_id];
+      for (const slotId of appliedSlotIdsRef.current) {
+        delete next[slotId];
       }
       }
       onManualMappingChange(next);
       onManualMappingChange(next);
+      appliedSlotIdsRef.current = [];
       setUsingArchiveMapping(false);
       setUsingArchiveMapping(false);
       return;
       return;
     }
     }
     const next = { ...manualMappings };
     const next = { ...manualMappings };
+    const appliedSlotIds: number[] = [];
     for (const req of filamentReqs.filaments) {
     for (const req of filamentReqs.filaments) {
       const idx = req.slot_id - 1;
       const idx = req.slot_id - 1;
+      // A negative value (e.g. the external spool sentinel) means the
+      // slicer didn't resolve this filament to an AMS tray — leave that
+      // slot's existing auto-match/manual pick alone rather than clearing it.
       if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
       if (req.slot_id > 0 && idx >= 0 && idx < archiveAmsMapping.length && archiveAmsMapping[idx] >= 0) {
         next[req.slot_id] = archiveAmsMapping[idx];
         next[req.slot_id] = archiveAmsMapping[idx];
+        appliedSlotIds.push(req.slot_id);
       }
       }
     }
     }
     onManualMappingChange(next);
     onManualMappingChange(next);
+    appliedSlotIdsRef.current = appliedSlotIds;
     setUsingArchiveMapping(true);
     setUsingArchiveMapping(true);
   };
   };
 
 

+ 15 - 3
frontend/src/components/PrintModal/index.tsx

@@ -327,10 +327,21 @@ export function PrintModal({
 
 
   // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
   // The archive's own saved AMS-slot pick from the slicer (see the "Save AMS
   // mapping" virtual-printer setting) — undefined for library files or
   // mapping" virtual-printer setting) — undefined for library files or
-  // archives that predate the feature / had it off at print time.
-  const archiveSlicerAmsMapping = !isLibraryFile
-    ? (archiveDetails?.extra_data?.slicer_ams_mapping as number[] | undefined)
+  // archives that predate the feature / had it off at print time. Stored as
+  // `{mapping, printer_id}`: the tray IDs are only meaningful against the
+  // specific printer's AMS layout they were resolved from, so only surface
+  // them when reprinting on that same printer — a different printer's slot 3
+  // can hold a completely different spool.
+  const archiveSlicerAmsMappingRaw = !isLibraryFile
+    ? (archiveDetails?.extra_data?.slicer_ams_mapping as
+        | { mapping?: number[]; printer_id?: number }
+        | undefined)
     : undefined;
     : undefined;
+  const archiveSlicerAmsMapping =
+    archiveSlicerAmsMappingRaw?.printer_id === effectivePrinterId &&
+    Array.isArray(archiveSlicerAmsMappingRaw.mapping)
+      ? archiveSlicerAmsMappingRaw.mapping
+      : undefined;
 
 
   // Fetch plates for archives
   // Fetch plates for archives
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
   const { data: archivePlatesData, isError: archivePlatesError } = useQuery({
@@ -1441,6 +1452,7 @@ export function PrintModal({
                   onForceColorMatchChange={(slotId, value) =>
                   onForceColorMatchChange={(slotId, value) =>
                     setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                     setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
                   }
                   }
+                  archiveAmsMapping={archiveSlicerAmsMapping}
                 />
                 />
               );
               );
             })}
             })}

+ 7 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -1113,7 +1113,13 @@ function ArchiveCard({
         {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
         {/* Slicer's own saved AMS-slot pick (see "Save AMS mapping" VP setting) —
             a reprint reuses this exact physical spool instead of re-deriving
             a reprint reuses this exact physical spool instead of re-deriving
             one from the file's type/color. */}
             one from the file's type/color. */}
-        {Array.isArray((archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping) && (
+        {Array.isArray(
+          (
+            (archive.extra_data as Record<string, unknown> | null)?.slicer_ams_mapping as
+              | { mapping?: unknown }
+              | undefined
+          )?.mapping,
+        ) && (
           <div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
           <div className="flex items-center gap-1.5 text-bambu-green text-xs mb-3" title={t('archives.card.slicerAmsMappingTooltip')}>
             <CheckCircle2 className="w-3.5 h-3.5" />
             <CheckCircle2 className="w-3.5 h-3.5" />
             {t('archives.card.slicerAmsMapping')}
             {t('archives.card.slicerAmsMapping')}