Bladeren bron

Pool AMS backup spools in the print dialog's filament check

The dialog weighed each plate against the spool in the slot it mapped to
and knew nothing about AMS Filament Backup, so a two-plate job needing
1441 g of ABS was refused against a 1000 g spool while the identical full
spool in the next slot went uncounted. The dispatcher has pooled matching
spools since #1762 and would have run the print -- "Print anyway" was
always the right answer to this warning.

The rule for which spools back each other up now lives in one place:
build_slot_materials() in filament_deficit, which the dispatcher's pool
and the new slot_materials half of GET /printers/{id}/inventory-remain
both draw on. The dialog groups on the keys it is handed rather than
resolving spools a second time, which is what let the two answers drift
apart, and which also gives the check to Spoolman users -- it read the
internal inventory only, so in Spoolman mode it approved everything.

Where a pool really is short the warning quotes the pooled totals, since
the per-slot figure reads as a contradiction next to a full peer spool.
maziggy 3 weken geleden
bovenliggende
commit
df5aa04df1

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 16 - 2
backend/app/api/routes/printers.py

@@ -2159,17 +2159,31 @@ async def get_inventory_remain(
     the dispatcher uses (#1766). Works for both internal inventory and
     the dispatcher uses (#1766). Works for both internal inventory and
     Spoolman; unbound slots are absent from the map (client falls back to the
     Spoolman; unbound slots are absent from the map (client falls back to the
     printer's MQTT `remain` for those).
     printer's MQTT `remain` for those).
+
+    `slot_materials` carries the same bindings with their material identity and
+    extruder side attached, which is what the modal's pre-flight filament check
+    needs to pool spools under AMS Filament Backup the way the dispatcher does.
+    It is deliberately server-computed: the identity rule lives in
+    `filament_deficit`, and a client-side reimplementation of it is exactly how
+    the modal came to block prints the dispatcher would have accepted. Unlike
+    `inventory_remain_g` it covers every binding, not just currently-loaded
+    slots — again matching what the dispatcher pools.
     """
     """
+    from backend.app.services.filament_deficit import build_slot_materials
     from backend.app.services.print_scheduler import PrintScheduler
     from backend.app.services.print_scheduler import PrintScheduler
 
 
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
     if not state:
     if not state:
-        return {"inventory_remain_g": {}}
+        return {"inventory_remain_g": {}, "slot_materials": []}
 
 
     scheduler = PrintScheduler()
     scheduler = PrintScheduler()
     loaded = scheduler._build_loaded_filaments(state)
     loaded = scheduler._build_loaded_filaments(state)
     overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
     overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
-    return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
+    slot_materials = await build_slot_materials(db, printer_id)
+    return {
+        "inventory_remain_g": {str(k): v for k, v in overrides.items()},
+        "slot_materials": [s.to_dict() for s in slot_materials],
+    }
 
 
 
 
 # ============================================
 # ============================================

+ 131 - 58
backend/app/services/filament_deficit.py

@@ -78,6 +78,20 @@ def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
     return (global_tray_id // 4, global_tray_id % 4)
     return (global_tray_id // 4, global_tray_id % 4)
 
 
 
 
+def _ams_key_to_global(ams_id: int, tray_id: int) -> int:
+    """Inverse of ``_global_to_ams_key``.
+
+    Mirrors the frontend ``getGlobalTrayId``: external / VT slots (``ams_id``
+    255) land at ``254 + tray_id``, AMS-HT units (``ams_id`` >= 128) use the
+    unit id directly, regular AMS slots use ``ams_id * 4 + tray_id``.
+    """
+    if ams_id >= 255:
+        return 254 + tray_id
+    if ams_id >= 128:
+        return ams_id
+    return ams_id * 4 + tray_id
+
+
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
     """Locate the 3MF file backing this queue item (archive or library).
     """Locate the 3MF file backing this queue item (archive or library).
 
 
@@ -287,6 +301,121 @@ async def _get_printer_backup_context(
     return backup_on, ams_extruder_map, is_dual
     return backup_on, ams_extruder_map, is_dual
 
 
 
 
+@dataclass(frozen=True)
+class SlotMaterial:
+    """One inventory-bound AMS slot: what's in it, how much is left, which side."""
+
+    ams_id: int
+    tray_id: int
+    global_tray_id: int
+    # Opaque grouping key. Two slots pool for AMS Filament Backup only when
+    # their keys AND extruder sides match. Never parse it — the format is an
+    # implementation detail of ``_material_identity_*``.
+    material_key: str
+    remaining_grams: float
+    extruder: int
+
+    def to_dict(self) -> dict:
+        return {
+            "ams_id": self.ams_id,
+            "tray_id": self.tray_id,
+            "global_tray_id": self.global_tray_id,
+            "material_key": self.material_key,
+            "remaining_g": self.remaining_grams,
+            "extruder": self.extruder,
+        }
+
+
+async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMaterial]:
+    """Every inventory-bound slot on ``printer_id``, with identity and remaining grams.
+
+    Mode-agnostic: internal inventory resolves via ``SpoolAssignment`` joined to
+    ``Spool`` (``label_weight`` minus ``weight_used``), Spoolman mode via
+    ``SpoolmanSlotAssignment`` plus a live ``get_spool`` fetch. Slots whose
+    remaining weight can't be determined — no spool row, zero label weight,
+    Spoolman unreachable — are omitted entirely rather than reported as zero, so
+    a missing binding never manufactures a shortfall.
+
+    This is the pool the AMS-Filament-Backup accounting draws on (#1762), and
+    the same data the PrintModal pre-flight check consumes through
+    ``GET /printers/{id}/inventory-remain``. Both sides share it so the modal's
+    warning and the dispatcher's 409 can't disagree about what backs what up.
+    """
+    _, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
+    materials: list[SlotMaterial] = []
+
+    def _append(ams_id: int, tray_id: int, material_key: str, remaining: float) -> None:
+        materials.append(
+            SlotMaterial(
+                ams_id=ams_id,
+                tray_id=tray_id,
+                global_tray_id=_ams_key_to_global(ams_id, tray_id),
+                material_key=material_key,
+                remaining_grams=remaining,
+                extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
+            )
+        )
+
+    if await _is_spoolman_mode(db):
+        sm_all = await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+
+        try:
+            client = await get_spoolman_client()
+        except Exception:
+            client = None
+        if client is None:
+            return []
+        for sa in sm_all.scalars().all():
+            try:
+                spool_dict = await client.get_spool(sa.spoolman_spool_id)
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                continue
+            except Exception as e:
+                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
+                continue
+            if not spool_dict:
+                continue
+            remaining: float | None = None
+            rw = spool_dict.get("remaining_weight")
+            if isinstance(rw, (int, float)) and rw >= 0:
+                remaining = float(rw)
+            else:
+                used = spool_dict.get("used_weight")
+                total = (spool_dict.get("filament") or {}).get("weight")
+                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                    remaining = max(0.0, float(total) - float(used))
+            if remaining is None:
+                continue
+            _append(sa.ams_id, sa.tray_id, _material_identity_spoolman(spool_dict), remaining)
+        return materials
+
+    internal_all = await db.execute(
+        select(SpoolAssignment)
+        .options(selectinload(SpoolAssignment.spool))
+        .where(SpoolAssignment.printer_id == printer_id)
+    )
+    for assignment in internal_all.scalars().all():
+        spool = assignment.spool
+        if spool is None:
+            continue
+        label_weight = float(spool.label_weight or 0)
+        weight_used = float(spool.weight_used or 0)
+        if label_weight <= 0:
+            continue
+        _append(
+            assignment.ams_id,
+            assignment.tray_id,
+            _material_identity_internal(spool),
+            max(0.0, label_weight - weight_used),
+        )
+    return materials
+
+
 async def compute_deficit_for_queue_item(
 async def compute_deficit_for_queue_item(
     db: AsyncSession,
     db: AsyncSession,
     item: PrintQueueItem,
     item: PrintQueueItem,
@@ -498,64 +627,8 @@ async def compute_deficit_for_queue_item(
     pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
     pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
     required_by_key: dict[tuple[str, int], float] = defaultdict(float)
     required_by_key: dict[tuple[str, int], float] = defaultdict(float)
 
 
-    if spoolman_mode:
-        sm_all = await db.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
-        )
-        from backend.app.services.spoolman import (
-            SpoolmanClientError,
-            SpoolmanNotFoundError,
-            get_spoolman_client,
-        )
-
-        try:
-            client = await get_spoolman_client()
-        except Exception:
-            client = None
-        for sa in sm_all.scalars().all():
-            if client is None:
-                break
-            try:
-                spool_dict = await client.get_spool(sa.spoolman_spool_id)
-            except (SpoolmanNotFoundError, SpoolmanClientError):
-                continue
-            except Exception as e:
-                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
-                continue
-            if not spool_dict:
-                continue
-            identity = _material_identity_spoolman(spool_dict)
-            rw = spool_dict.get("remaining_weight")
-            r: float | None = None
-            if isinstance(rw, (int, float)) and rw >= 0:
-                r = float(rw)
-            else:
-                used = spool_dict.get("used_weight")
-                total = (spool_dict.get("filament") or {}).get("weight")
-                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
-                    r = max(0.0, float(total) - float(used))
-            if r is None:
-                continue
-            extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
-            pool_by_key[(identity, extruder)] += r
-    else:
-        internal_all = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == item.printer_id)
-        )
-        for assignment in internal_all.scalars().all():
-            spool = assignment.spool
-            if spool is None:
-                continue
-            label_weight = float(spool.label_weight or 0)
-            weight_used = float(spool.weight_used or 0)
-            if label_weight <= 0:
-                continue
-            r = max(0.0, label_weight - weight_used)
-            identity = _material_identity_internal(spool)
-            extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
-            pool_by_key[(identity, extruder)] += r
+    for slot in await build_slot_materials(db, item.printer_id):
+        pool_by_key[(slot.material_key, slot.extruder)] += slot.remaining_grams
 
 
     for row in resolved:
     for row in resolved:
         required_by_key[(row.identity, row.extruder)] += row.required
         required_by_key[(row.identity, row.extruder)] += row.required

+ 205 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -730,3 +730,208 @@ class TestFilamentDeficitBackupAware:
 
 
         # Pool (10 + 500 = 510 g) covers 200 g → no deficit.
         # Pool (10 + 500 = 510 g) covers 200 g → no deficit.
         assert deficit == []
         assert deficit == []
+
+
+class TestBuildSlotMaterials:
+    """``build_slot_materials`` is the pool the backup accounting draws on, and
+    the payload ``GET /printers/{id}/inventory-remain`` hands the PrintModal.
+
+    The modal used to resolve spools itself and knew nothing about AMS Filament
+    Backup, so it blocked prints the dispatcher would have run — two full eSUN
+    spools in A3/A4, 1441 g needed, "A3: needs 1441g, remaining 1000g". Both
+    sides now group on the keys this builder emits, so the modal's warning and
+    the dispatcher's 409 cannot disagree about what backs what up.
+    """
+
+    @pytest.mark.asyncio
+    async def test_internal_mode_emits_shared_key_for_same_preset_and_colour(self, db_session, printer_factory):
+        """The reporter's slots: same preset, same colour, adjacent trays."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2D")
+        a3 = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#616777FF", slicer_filament="PFUS6488")
+        a4 = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#616777", slicer_filament="PFUS6488")
+        await _assign(db_session, printer_id=printer.id, spool_id=a3.id, ams_id=0, tray_id=2)
+        await _assign(db_session, printer_id=printer.id, spool_id=a4.id, ams_id=0, tray_id=3)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="H2D")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        by_tray = {s.global_tray_id: s for s in slots}
+        assert set(by_tray) == {2, 3}
+        assert by_tray[2].material_key == by_tray[3].material_key
+        assert by_tray[2].remaining_grams == 1000.0
+        # Pooled, the two cover the 1441 g the modal refused to start.
+        assert sum(s.remaining_grams for s in slots) == 2000.0
+
+    @pytest.mark.asyncio
+    async def test_internal_mode_separates_colours_and_presetless_spools(self, db_session, printer_factory):
+        """Different colour, and no preset at all, must never share a key."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        black = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#000000", slicer_filament="GFA00")
+        white = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#FFFFFF", slicer_filament="GFA00")
+        untagged_a = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#000000")
+        untagged_b = await _spool(db_session, label_weight=1000, weight_used=0.0, color="#000000")
+        for idx, spool in enumerate((black, white, untagged_a, untagged_b)):
+            await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=idx)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        keys = [s.material_key for s in sorted(slots, key=lambda s: s.global_tray_id)]
+        assert len(set(keys)) == 4, keys
+
+    @pytest.mark.asyncio
+    async def test_internal_mode_scopes_extruder_on_dual_nozzle(self, db_session, printer_factory):
+        """Same material on opposite sides of an H2D can't back each other up."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2D")
+        right = await _spool(db_session, label_weight=1000, weight_used=0.0, slicer_filament="GFA00")
+        left = await _spool(db_session, label_weight=1000, weight_used=0.0, slicer_filament="GFA00")
+        await _assign(db_session, printer_id=printer.id, spool_id=right.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=left.id, ams_id=1, tray_id=0)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(
+            printer_id=printer.id, backup_on=True, ams_extruder_map={"0": 0, "1": 1}, model="H2D"
+        )
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        by_tray = {s.global_tray_id: s for s in slots}
+        assert by_tray[0].material_key == by_tray[4].material_key  # same material...
+        assert {by_tray[0].extruder, by_tray[4].extruder} == {0, 1}  # ...different side
+
+    @pytest.mark.asyncio
+    async def test_internal_mode_omits_slots_with_no_usable_weight(self, db_session, printer_factory):
+        """A binding with no label weight is unknown, not empty — omit it so the
+        client can't read a missing slot as a zero-gram one."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        unweighed = await _spool(db_session, label_weight=0, weight_used=0.0, slicer_filament="GFA00")
+        ok = await _spool(db_session, label_weight=1000, weight_used=250.0, slicer_filament="GFA00")
+        await _assign(db_session, printer_id=printer.id, spool_id=unweighed.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=ok.id, ams_id=0, tray_id=1)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert [(s.global_tray_id, s.remaining_grams) for s in slots] == [(1, 750.0)]
+
+    @pytest.mark.asyncio
+    async def test_external_and_ht_slots_get_the_frontend_tray_numbering(self, db_session, printer_factory):
+        """``global_tray_id`` must match the client's ``getGlobalTrayId`` or the
+        modal looks up the wrong slot: 254+ for external, unit id for AMS-HT."""
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        ht = await _spool(db_session, label_weight=1000, weight_used=0.0, slicer_filament="GFA00")
+        ext = await _spool(db_session, label_weight=1000, weight_used=0.0, slicer_filament="GFA01")
+        await _assign(db_session, printer_id=printer.id, spool_id=ht.id, ams_id=128, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=ext.id, ams_id=255, tray_id=0)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert sorted(s.global_tray_id for s in slots) == [128, 254]
+
+    @pytest.mark.asyncio
+    async def test_spoolman_mode_pools_on_filament_id_and_colour(self, db_session, printer_factory):
+        """Spoolman parity: same catalog filament + colour → one pool key."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2D")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        for tray_id, spool_id in ((2, 68), (3, 69)):
+            db_session.add(
+                SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=tray_id, spoolman_spool_id=spool_id)
+            )
+        await db_session.commit()
+
+        spools = {
+            68: {"id": 68, "remaining_weight": 1000.0, "filament": {"id": 7, "color_hex": "616777"}},
+            # Same catalog entry, colour spelled with alpha, weight via used_weight.
+            69: {"id": 69, "used_weight": 0.0, "filament": {"id": 7, "weight": 1000.0, "color_hex": "616777FF"}},
+        }
+        client = AsyncMock()
+        client.get_spool = AsyncMock(side_effect=lambda sid: spools[sid])
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="H2D")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=client),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert len({s.material_key for s in slots}) == 1
+        assert sum(s.remaining_grams for s in slots) == 2000.0
+
+    @pytest.mark.asyncio
+    async def test_spoolman_unreachable_returns_no_slots(self, db_session, printer_factory):
+        """A Spoolman blip must read as "nothing to verify", never as an empty
+        AMS — the modal would otherwise warn on every slot while it's down."""
+        from unittest.mock import AsyncMock
+
+        from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="X1C")
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+        db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=0, spoolman_spool_id=1))
+        await db_session.commit()
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        for p in patches:
+            p.start()
+        try:
+            with patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                AsyncMock(return_value=None),
+            ):
+                slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        assert slots == []

+ 66 - 3
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -4,6 +4,11 @@ The endpoint exposes the same `_build_inventory_remain_overrides` map the
 dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
 dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
 sort agrees with what gets dispatched — closes the gap where Spoolman-mode
 sort agrees with what gets dispatched — closes the gap where Spoolman-mode
 users couldn't see inventory grams from the frontend.
 users couldn't see inventory grams from the frontend.
+
+It also carries `slot_materials`: every inventory binding with the backend's
+material identity and extruder side, which is what lets the modal's pre-flight
+filament check pool spools under AMS Filament Backup the way the dispatcher
+does instead of resolving spools itself.
 """
 """
 
 
 from types import SimpleNamespace
 from types import SimpleNamespace
@@ -12,6 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.api.routes.printers import get_inventory_remain
 from backend.app.api.routes.printers import get_inventory_remain
+from backend.app.services.filament_deficit import SlotMaterial
 
 
 
 
 @pytest.fixture
 @pytest.fixture
@@ -32,7 +38,7 @@ class TestGetInventoryRemain:
             return_value=None,
             return_value=None,
         ):
         ):
             result = await _call_endpoint(db)
             result = await _call_endpoint(db)
-        assert result == {"inventory_remain_g": {}}
+        assert result == {"inventory_remain_g": {}, "slot_materials": []}
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_serialises_globaltrayid_keys_as_strings(self, db):
     async def test_serialises_globaltrayid_keys_as_strings(self, db):
@@ -55,10 +61,14 @@ class TestGetInventoryRemain:
                 "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
                 "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
                 new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
                 new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
             ),
             ),
+            patch(
+                "backend.app.services.filament_deficit.build_slot_materials",
+                new=AsyncMock(return_value=[]),
+            ),
         ):
         ):
             result = await _call_endpoint(db)
             result = await _call_endpoint(db)
 
 
-        assert result == {"inventory_remain_g": {"0": 950.0, "3": 50.0}}
+        assert result["inventory_remain_g"] == {"0": 950.0, "3": 50.0}
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_returns_empty_dict_when_no_bound_slots(self, db):
     async def test_returns_empty_dict_when_no_bound_slots(self, db):
@@ -80,7 +90,60 @@ class TestGetInventoryRemain:
                 "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
                 "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
                 new=AsyncMock(return_value={}),
                 new=AsyncMock(return_value={}),
             ),
             ),
+            patch(
+                "backend.app.services.filament_deficit.build_slot_materials",
+                new=AsyncMock(return_value=[]),
+            ),
+        ):
+            result = await _call_endpoint(db)
+
+        assert result["inventory_remain_g"] == {}
+
+    @pytest.mark.asyncio
+    async def test_slot_materials_carry_identity_and_extruder(self, db):
+        # The modal groups on (material_key, extruder) to decide what AMS
+        # Filament Backup can pool, so both fields have to survive the wire.
+        # Unlike inventory_remain_g this covers every binding, not just the
+        # slots currently loaded — the dispatcher pools all of them.
+        state = SimpleNamespace(raw_data={})
+        with (
+            patch(
+                "backend.app.services.printer_manager.printer_manager.get_status",
+                return_value=state,
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
+                return_value=[],
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
+                new=AsyncMock(return_value={}),
+            ),
+            patch(
+                "backend.app.services.filament_deficit.build_slot_materials",
+                new=AsyncMock(
+                    return_value=[
+                        SlotMaterial(
+                            ams_id=0,
+                            tray_id=2,
+                            global_tray_id=2,
+                            material_key="preset:PFUS6488|color:616777",
+                            remaining_grams=1000.0,
+                            extruder=0,
+                        ),
+                    ]
+                ),
+            ),
         ):
         ):
             result = await _call_endpoint(db)
             result = await _call_endpoint(db)
 
 
-        assert result == {"inventory_remain_g": {}}
+        assert result["slot_materials"] == [
+            {
+                "ams_id": 0,
+                "tray_id": 2,
+                "global_tray_id": 2,
+                "material_key": "preset:PFUS6488|color:616777",
+                "remaining_g": 1000.0,
+                "extruder": 0,
+            }
+        ]

+ 104 - 8
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1811,14 +1811,13 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
         if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
         if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
         return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
         return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
       }),
       }),
-      http.get('/api/v1/inventory/assignments', () =>
-        HttpResponse.json([
-          {
-            id: 1, spool_id: 1, printer_id: 1, printer_name: 'X1 Carbon', ams_id: 0, tray_id: 0,
-            fingerprint_color: null, fingerprint_type: null, configured: true,
-            spool: { id: 1, label_weight: 1000, weight_used: 940 },
-          },
-        ]),
+      http.get('/api/v1/printers/:id/inventory-remain', () =>
+        HttpResponse.json({
+          inventory_remain_g: { '0': 60 },
+          slot_materials: [
+            { ams_id: 0, tray_id: 0, global_tray_id: 0, material_key: 'preset:GFA00|color:FF0000', remaining_g: 60, extruder: 0 },
+          ],
+        }),
       ),
       ),
     );
     );
 
 
@@ -1836,6 +1835,103 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
     expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
     expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
   });
   });
 
 
+  // Two red trays holding the same material, so AMS Filament Backup can switch
+  // between them mid-print. Both tests below map both plates onto tray 0.
+  const twoRedTrays = (backupOn: boolean) =>
+    http.get('/api/v1/printers/:id/status', () =>
+      HttpResponse.json({
+        connected: true,
+        state: 'IDLE',
+        ams_filament_backup: backupOn,
+        ams: [{ id: 0, tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000' },
+        ] }],
+        vt_tray: [],
+      }),
+    );
+
+  const redPool = (trayZeroGrams: number, trayOneGrams: number) =>
+    http.get('/api/v1/printers/:id/inventory-remain', () =>
+      HttpResponse.json({
+        inventory_remain_g: { '0': trayZeroGrams, '1': trayOneGrams },
+        slot_materials: [
+          { ams_id: 0, tray_id: 0, global_tray_id: 0, material_key: 'preset:GFA00|color:FF0000', remaining_g: trayZeroGrams, extruder: 0 },
+          { ams_id: 0, tray_id: 1, global_tray_id: 1, material_key: 'preset:GFA00|color:FF0000', remaining_g: trayOneGrams, extruder: 0 },
+        ],
+      }),
+    );
+
+  const bothPlatesNeedForty = http.get('/api/v1/archives/:id/filament-requirements', ({ request }) => {
+    const plateId = new URL(request.url).searchParams.get('plate_id');
+    if (plateId === '1') return HttpResponse.json({ filaments: [{ ...SLOT_1_RED, used_grams: 40 }] });
+    if (plateId === '2') return HttpResponse.json({ filaments: [{ ...SLOT_2_RED, used_grams: 40 }] });
+    return HttpResponse.json({ filaments: [SLOT_1_RED, SLOT_2_RED] });
+  });
+
+  it('lets the print through when AMS Filament Backup covers it from a peer spool', async () => {
+    // The reported block: two plates draw 80 g from a tray holding 60 g, but the
+    // slot next to it holds the same material. The firmware switches between
+    // them, and the dispatcher has pooled them since #1762 — the modal used to
+    // weigh the mapped slot alone and refused a print the dispatcher would run.
+    const queued: unknown[] = [];
+    server.use(
+      bothPlatesNeedForty,
+      twoRedTrays(true),
+      redPool(60, 1000),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push(await request.json());
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+
+    await selectBothPlates(user);
+    await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
+
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(queued.length).toBe(2));
+    expect(screen.queryByText(/Not enough filament/i)).not.toBeInTheDocument();
+  });
+
+  it('warns against the pooled total when AMS Filament Backup still cannot cover it', async () => {
+    // Backup is on but both spools together are short. The shortfall is real, so
+    // it still has to be raised — against the pool, not the one slot, or the
+    // numbers contradict the AMS the user is looking at.
+    server.use(bothPlatesNeedForty, twoRedTrays(true), redPool(60, 10));
+
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+
+    await selectBothPlates(user);
+    await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
+
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
+    expect(screen.getByText(/needs 80g, 70g available across matching spools/i)).toBeInTheDocument();
+  });
+
+  it('keeps weighing the mapped slot alone when AMS Filament Backup is off', async () => {
+    // Same two spools, backup off: the firmware will not switch, so the peer's
+    // grams are not available to this print and the warning must still fire.
+    server.use(bothPlatesNeedForty, twoRedTrays(false), redPool(60, 1000));
+
+    const user = userEvent.setup();
+    render(<PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />);
+
+    await selectBothPlates(user);
+    await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 2/)).toBeInTheDocument());
+
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(screen.getByText(/Not enough filament/i)).toBeInTheDocument());
+    expect(screen.getByText(/needs 80g, remaining 60g/i)).toBeInTheDocument();
+  });
+
   it('holds the Print button until every selected plate has answered', async () => {
   it('holds the Print button until every selected plate has answered', async () => {
     // A plate whose filaments cannot be read has no mapping and no forced colours;
     // A plate whose filaments cannot be read has no mapping and no forced colours;
     // queueing it anyway prints it in whatever happens to be loaded.
     // queueing it anyway prints it in whatever happens to be loaded.

+ 3 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -476,6 +476,9 @@ export const handlers = [
   http.get('/api/v1/notification-templates', () => HttpResponse.json([])),
   http.get('/api/v1/notification-templates', () => HttpResponse.json([])),
   http.get('/api/v1/pending-uploads/', () => HttpResponse.json([])),
   http.get('/api/v1/pending-uploads/', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/ams-labels', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/ams-labels', () => HttpResponse.json([])),
+  http.get('/api/v1/printers/:id/inventory-remain', () =>
+    HttpResponse.json({ inventory_remain_g: {}, slot_materials: [] }),
+  ),
   http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id/scripts', () => HttpResponse.json([])),
   http.get('/api/v1/smart-plugs/by-printer/:id/scripts', () => HttpResponse.json([])),

+ 22 - 1
frontend/src/api/client.ts

@@ -3465,6 +3465,27 @@ export interface SpoolKProfileInput {
   setting_id?: string | null;
   setting_id?: string | null;
 }
 }
 
 
+/** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
+export interface SlotMaterial {
+  ams_id: number;
+  tray_id: number;
+  global_tray_id: number;
+  /** Opaque grouping key from the backend. Two slots back each other up under
+   *  AMS Filament Backup only when both `material_key` and `extruder` match.
+   *  Never parse it — the format belongs to the backend's identity rule. */
+  material_key: string;
+  remaining_g: number;
+  /** 0 = right / single nozzle, 1 = left. */
+  extruder: number;
+}
+
+export interface InventoryRemainResponse {
+  /** Currently-loaded, inventory-bound slots only — drives the prefer-lowest sort. */
+  inventory_remain_g: Record<string, number>;
+  /** Every inventory binding on the printer, with identity + extruder side. */
+  slot_materials: SlotMaterial[];
+}
+
 export interface SpoolAssignment {
 export interface SpoolAssignment {
   id: number;
   id: number;
   spool_id: number;
   spool_id: number;
@@ -4503,7 +4524,7 @@ export const api = {
   // when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides`
   // when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides`
   // so internal and Spoolman modes both work uniformly.
   // so internal and Spoolman modes both work uniformly.
   getInventoryRemain: (printerId: number) =>
   getInventoryRemain: (printerId: number) =>
-    request<{ inventory_remain_g: Record<string, number> }>(
+    request<InventoryRemainResponse>(
       `/printers/${printerId}/inventory-remain`,
       `/printers/${printerId}/inventory-remain`,
     ),
     ),
 
 

+ 96 - 49
frontend/src/components/PrintModal/index.tsx

@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
+import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
@@ -21,7 +21,7 @@ import { isGcodeCompatible } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
-import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
+import { isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
 import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { resolveArchiveSlicerAmsMapping } from './archiveAmsMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { FilamentOverride } from './FilamentOverride';
@@ -97,6 +97,9 @@ export function PrintModal({
     slotLabel: string;
     slotLabel: string;
     requiredGrams: number;
     requiredGrams: number;
     remainingGrams: number;
     remainingGrams: number;
+    /** True when AMS Filament Backup pooled more than one spool for this slot;
+     *  `requiredGrams` / `remainingGrams` are then the pooled totals. */
+    pooled?: boolean;
   };
   };
 
 
   // Multiple printer selection (used for all modes now)
   // Multiple printer selection (used for all modes now)
@@ -339,13 +342,6 @@ export function PrintModal({
     setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
     setSelectedCostCenterId(preferredPrivate?.id ?? printableCostCenters[0].id);
   }, [printableCostCenters, selectedCostCenterId]);
   }, [printableCostCenters, selectedCostCenterId]);
 
 
-  const { data: spoolAssignments } = useQuery({
-    queryKey: ['spool-assignments'],
-    queryFn: () => api.getAssignments(),
-    staleTime: 30 * 1000,
-    enabled: !isEditing && assignmentMode === 'printer',
-  });
-
   // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
   // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
   // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
   // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
   // so internal and Spoolman modes both work uniformly, VT/external slots are
   // so internal and Spoolman modes both work uniformly, VT/external slots are
@@ -374,6 +370,23 @@ export function PrintModal({
     return result;
     return result;
   }, [selectedPrinters, inventoryRemainQueries]);
   }, [selectedPrinters, inventoryRemainQueries]);
 
 
+  // Same endpoint, the other half of its payload: every inventory-bound slot on
+  // the printer with the backend's material identity and extruder side. The
+  // pre-flight filament check groups on these instead of resolving spools
+  // itself, which is what makes it agree with the dispatcher and work in
+  // Spoolman mode (where the modal has no assignment rows of its own).
+  const slotMaterialsPerPrinter = useMemo(() => {
+    const result = new Map<number, Map<number, SlotMaterial>>();
+    selectedPrinters.forEach((printerId, idx) => {
+      const slots = inventoryRemainQueries[idx]?.data?.slot_materials;
+      if (!slots) return;
+      const printerMap = new Map<number, SlotMaterial>();
+      slots.forEach((slot) => printerMap.set(slot.global_tray_id, slot));
+      result.set(printerId, printerMap);
+    });
+    return result;
+  }, [selectedPrinters, inventoryRemainQueries]);
+
   // Fetch archive details to get sliced_for_model
   // Fetch archive details to get sliced_for_model
   const { data: archiveDetails } = useQuery({
   const { data: archiveDetails } = useQuery({
     queryKey: ['archive', archiveId],
     queryKey: ['archive', archiveId],
@@ -747,32 +760,26 @@ export function PrintModal({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const plates = platesData?.plates ?? [];
   const plates = platesData?.plates ?? [];
 
 
-  const spoolAssignmentsByPrinter = useMemo(() => {
-    const map = new Map<number, Map<number, SpoolAssignment>>();
-    if (!spoolAssignments) return map;
-    spoolAssignments.forEach((assignment) => {
-      const isExternal = assignment.ams_id === 255;
-      const globalTrayId = getGlobalTrayId(
-        assignment.ams_id,
-        assignment.tray_id,
-        isExternal
-      );
-      const printerMap = map.get(assignment.printer_id) ?? new Map();
-      printerMap.set(globalTrayId, assignment);
-      map.set(assignment.printer_id, printerMap);
-    });
-    return map;
-  }, [spoolAssignments]);
-
   const filamentWarningMessage = useMemo(() => {
   const filamentWarningMessage = useMemo(() => {
     if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
     if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
     const lines = filamentWarningItems.map((item) =>
     const lines = filamentWarningItems.map((item) =>
-      t('printModal.insufficientFilamentLine', {
-        printer: item.printerName,
-        slot: item.slotLabel,
-        required: Math.round(item.requiredGrams),
-        remaining: Math.round(item.remainingGrams),
-      })
+      // Under AMS Filament Backup the shortfall is against the pooled spools,
+      // not the one slot — quoting that slot's remaining next to a pooled
+      // requirement reads as a contradiction ("needs 1441g, remaining 1000g"
+      // while a second full spool sits next to it).
+      item.pooled
+        ? t('printModal.insufficientFilamentLinePooled', {
+            printer: item.printerName,
+            slot: item.slotLabel,
+            required: Math.round(item.requiredGrams),
+            remaining: Math.round(item.remainingGrams),
+          })
+        : t('printModal.insufficientFilamentLine', {
+            printer: item.printerName,
+            slot: item.slotLabel,
+            required: Math.round(item.requiredGrams),
+            remaining: Math.round(item.remainingGrams),
+          })
     );
     );
     return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
     return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
   }, [filamentWarningItems, t]);
   }, [filamentWarningItems, t]);
@@ -846,13 +853,7 @@ export function PrintModal({
         ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
         ? selectedPlateIds.map((plateId) => ({ plateId, reqs: perPlateReqs.get(plateId)?.filaments ?? [] }))
         : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
         : [{ plateId: selectedPlate, reqs: effectiveFilamentReqs?.filaments ?? [] }];
 
 
-      if (plateJobs.some((job) => job.reqs.length > 0) && spoolAssignmentsByPrinter.size > 0) {
-        const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
-          if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
-          if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
-          return Math.max(0, labelWeight - weightUsed);
-        };
-
+      if (plateJobs.some((job) => job.reqs.length > 0) && slotMaterialsPerPrinter.size > 0) {
         for (const printerId of selectedPrinters) {
         for (const printerId of selectedPrinters) {
           const printerStatusForWarning = selectedPrinters.length > 1
           const printerStatusForWarning = selectedPrinters.length > 1
             ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
             ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
@@ -860,10 +861,13 @@ export function PrintModal({
 
 
           const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
           const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
           const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
           const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
-          const assignments = spoolAssignmentsByPrinter.get(printerId);
+          // Slots the backend could price. A slot missing here is one with no
+          // inventory binding, or one whose Spoolman spool it could not read —
+          // both mean "nothing to weigh", never "empty".
+          const slotMaterials = slotMaterialsPerPrinter.get(printerId);
           const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
           const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
 
 
-          if (!assignments) continue;
+          if (!slotMaterials || slotMaterials.size === 0) continue;
 
 
           const gramsByTray = new Map<number, number>();
           const gramsByTray = new Map<number, number>();
           for (const job of plateJobs) {
           for (const job of plateJobs) {
@@ -880,19 +884,62 @@ export function PrintModal({
             });
             });
           }
           }
 
 
-          for (const [globalTrayId, requiredGrams] of gramsByTray) {
-            const spool = assignments.get(globalTrayId)?.spool;
-            if (!spool) continue;
+          // With AMS Filament Backup ON the firmware switches to any other slot
+          // holding the same material, so the print is only short when the whole
+          // pool is (#1762). The dispatcher has accounted for this since #1762 —
+          // this check did not, and blocked prints the dispatcher would have run.
+          // Dual-extruder printers pool per side: the firmware cannot cross
+          // nozzles even with the backup bit set, which is why `extruder` is part
+          // of the key the backend hands us.
+          const backupOn = printerStatusForWarning?.ams_filament_backup === true;
+          const poolKey = (slot: SlotMaterial) => `${slot.material_key}#${slot.extruder}`;
+
+          const pooledGrams = new Map<string, number>();
+          const pooledSlotCount = new Map<string, number>();
+          const pooledRequired = new Map<string, number>();
+          if (backupOn) {
+            slotMaterials.forEach((slot) => {
+              const key = poolKey(slot);
+              pooledGrams.set(key, (pooledGrams.get(key) ?? 0) + slot.remaining_g);
+              pooledSlotCount.set(key, (pooledSlotCount.get(key) ?? 0) + 1);
+            });
+            for (const [globalTrayId, requiredGrams] of gramsByTray) {
+              const slot = slotMaterials.get(globalTrayId);
+              if (!slot) continue;
+              const key = poolKey(slot);
+              pooledRequired.set(key, (pooledRequired.get(key) ?? 0) + requiredGrams);
+            }
+          }
 
 
-            const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
-            if (remainingGrams === null) continue;
-            if (remainingGrams >= requiredGrams) continue;
+          for (const [globalTrayId, requiredGrams] of gramsByTray) {
+            const slot = slotMaterials.get(globalTrayId);
+            if (!slot) continue;
+
+            const slotLabel = slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`;
+
+            if (backupOn) {
+              const key = poolKey(slot);
+              const available = pooledGrams.get(key) ?? 0;
+              const needed = pooledRequired.get(key) ?? 0;
+              if (available >= needed) continue;
+              warningItems.push({
+                printerName,
+                slotLabel,
+                requiredGrams: needed,
+                remainingGrams: available,
+                // A pool of one is just the slot itself — same numbers, so use
+                // the plain wording rather than talk about spools that aren't there.
+                pooled: (pooledSlotCount.get(key) ?? 1) > 1,
+              });
+              continue;
+            }
 
 
+            if (slot.remaining_g >= requiredGrams) continue;
             warningItems.push({
             warningItems.push({
               printerName,
               printerName,
-              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Tray ${globalTrayId}`,
+              slotLabel,
               requiredGrams,
               requiredGrams,
-              remainingGrams,
+              remainingGrams: slot.remaining_g,
             });
             });
           }
           }
         }
         }

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4934,6 +4934,7 @@ export default {
     waitingForAmsStatus: 'Warte auf AMS-Status von {{printer}}…',
     waitingForAmsStatus: 'Warte auf AMS-Status von {{printer}}…',
     insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:',
     insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benötigt:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: benötigt {{required}}g, {{remaining}}g über passende Spulen verfügbar (AMS Filament Backup)',
     printAnyway: 'Trotzdem drucken',
     printAnyway: 'Trotzdem drucken',
     forceColorMatch: 'Farbe erzwingen',
     forceColorMatch: 'Farbe erzwingen',
     staggerPrinterStarts: 'Druckerstarts staffeln',
     staggerPrinterStarts: 'Druckerstarts staffeln',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4977,6 +4977,7 @@ export default {
     waitingForAmsStatus: 'Waiting for AMS status from {{printer}}…',
     waitingForAmsStatus: 'Waiting for AMS status from {{printer}}…',
     insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',
     insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: needs {{required}}g, {{remaining}}g available across matching spools (AMS Filament Backup)',
     printAnyway: 'Print anyway',
     printAnyway: 'Print anyway',
     forceColorMatch: 'Force color match',
     forceColorMatch: 'Force color match',
     staggerPrinterStarts: 'Stagger printer starts',
     staggerPrinterStarts: 'Stagger printer starts',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4941,6 +4941,7 @@ export default {
     waitingForAmsStatus: 'Esperando el estado del AMS de {{printer}}…',
     waitingForAmsStatus: 'Esperando el estado del AMS de {{printer}}…',
     insufficientFilamentMessage: 'Algunas bobinas asignadas tienen menos filamento restante del que necesita esta impresión:',
     insufficientFilamentMessage: 'Algunas bobinas asignadas tienen menos filamento restante del que necesita esta impresión:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necesita {{required}} g, restante {{remaining}} g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necesita {{required}} g, restante {{remaining}} g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: necesita {{required}} g, {{remaining}} g disponibles entre bobinas equivalentes (AMS Filament Backup)',
     printAnyway: 'Imprimir de todos modos',
     printAnyway: 'Imprimir de todos modos',
     forceColorMatch: 'Forzar la coincidencia de color',
     forceColorMatch: 'Forzar la coincidencia de color',
     staggerPrinterStarts: 'Escalonar los inicios de las impresoras',
     staggerPrinterStarts: 'Escalonar los inicios de las impresoras',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4923,6 +4923,7 @@ export default {
     waitingForAmsStatus: 'En attente de l\'état de l\'AMS de {{printer}}…',
     waitingForAmsStatus: 'En attente de l\'état de l\'AMS de {{printer}}…',
     insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',
     insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',
     insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}} : nécessite {{required}}g, {{remaining}}g disponibles sur les bobines correspondantes (AMS Filament Backup)',
     printAnyway: 'Imprimer quand même',
     printAnyway: 'Imprimer quand même',
     forceColorMatch: 'Forcer correspondance des couleurs',
     forceColorMatch: 'Forcer correspondance des couleurs',
     staggerPrinterStarts: 'Échelonner démarrages',
     staggerPrinterStarts: 'Échelonner démarrages',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4922,6 +4922,7 @@ export default {
     waitingForAmsStatus: 'In attesa dello stato AMS di {{printer}}…',
     waitingForAmsStatus: 'In attesa dello stato AMS di {{printer}}…',
     insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',
     insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: necessita di {{required}}g, {{remaining}}g disponibili tra le bobine corrispondenti (AMS Filament Backup)',
     printAnyway: 'Stampa comunque',
     printAnyway: 'Stampa comunque',
     forceColorMatch: 'Forza corrispondenza colore',
     forceColorMatch: 'Forza corrispondenza colore',
     staggerPrinterStarts: 'Scaglionare avvii',
     staggerPrinterStarts: 'Scaglionare avvii',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4934,6 +4934,7 @@ export default {
     waitingForAmsStatus: '{{printer}} のAMSステータスを待機しています…',
     waitingForAmsStatus: '{{printer}} のAMSステータスを待機しています…',
     insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',
     insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: 必要 {{required}}g、一致するスプール合計で {{remaining}}g(AMSフィラメントバックアップ)',
     printAnyway: 'それでも印刷',
     printAnyway: 'それでも印刷',
     forceColorMatch: 'カラーマッチを強制',
     forceColorMatch: 'カラーマッチを強制',
     staggerPrinterStarts: 'プリンター開始を段階的に',
     staggerPrinterStarts: 'プリンター開始を段階的に',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4704,6 +4704,7 @@ export default {
     waitingForAmsStatus: '{{printer}}의 AMS 상태를 기다리는 중…',
     waitingForAmsStatus: '{{printer}}의 AMS 상태를 기다리는 중…',
     insufficientFilamentMessage: '일부 할당된 스풀에 이 인쇄에 필요한 것보다 적은 필라멘트가 남아 있습니다:',
     insufficientFilamentMessage: '일부 할당된 스풀에 이 인쇄에 필요한 것보다 적은 필라멘트가 남아 있습니다:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
     insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g 필요, {{remaining}}g 남음',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: {{required}}g 필요, 일치하는 스풀 합계 {{remaining}}g (AMS 필라멘트 백업)',
     printAnyway: '그래도 인쇄',
     printAnyway: '그래도 인쇄',
     forceColorMatch: '색상 일치 강제',
     forceColorMatch: '색상 일치 강제',
     staggerPrinterStarts: '프린터 시작 분산',
     staggerPrinterStarts: '프린터 시작 분산',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4922,6 +4922,7 @@ export default {
     waitingForAmsStatus: 'Aguardando o status do AMS de {{printer}}…',
     waitingForAmsStatus: 'Aguardando o status do AMS de {{printer}}…',
     insufficientFilamentMessage: 'Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:',
     insufficientFilamentMessage: 'Alguns dos carretéis atribuídos têm menos filamento restante do que o necessário para esta impressão:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: necessário {{required}}g, {{remaining}}g disponíveis entre bobinas correspondentes (AMS Filament Backup)',
     printAnyway: 'Imprimir mesmo assim',
     printAnyway: 'Imprimir mesmo assim',
     forceColorMatch: 'Forçar correspondência de cor',
     forceColorMatch: 'Forçar correspondência de cor',
     staggerPrinterStarts: 'Escalonar inícios',
     staggerPrinterStarts: 'Escalonar inícios',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4693,6 +4693,7 @@ export default {
     waitingForAmsStatus: "Ожидание состояния AMS от принтера {{printer}}…",
     waitingForAmsStatus: "Ожидание состояния AMS от принтера {{printer}}…",
     insufficientFilamentMessage: "На некоторых назначенных катушках осталось меньше филамента, чем требуется для этой печати:",
     insufficientFilamentMessage: "На некоторых назначенных катушках осталось меньше филамента, чем требуется для этой печати:",
     insufficientFilamentLine: "{{printer}} — {{slot}}: требуется {{required}} г, осталось {{remaining}} г",
     insufficientFilamentLine: "{{printer}} — {{slot}}: требуется {{required}} г, осталось {{remaining}} г",
+    insufficientFilamentLinePooled: "{{printer}} — {{slot}}: требуется {{required}} г, доступно {{remaining}} г на подходящих катушках (резервный филамент AMS)",
     printAnyway: "Всё равно печатать",
     printAnyway: "Всё равно печатать",
     forceColorMatch: "Обязательно учитывать цвет",
     forceColorMatch: "Обязательно учитывать цвет",
     staggerPrinterStarts: "Запускать принтеры с задержкой",
     staggerPrinterStarts: "Запускать принтеры с задержкой",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4911,6 +4911,7 @@ export default {
     waitingForAmsStatus: '{{printer}} için AMS durumu bekleniyor…',
     waitingForAmsStatus: '{{printer}} için AMS durumu bekleniyor…',
     insufficientFilamentMessage: 'Bazı atanmış makaralarda bu baskının ihtiyaç duyduğundan daha az filament kaldı:',
     insufficientFilamentMessage: 'Bazı atanmış makaralarda bu baskının ihtiyaç duyduğundan daha az filament kaldı:',
     insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g gerekli, {{remaining}}g kaldı',
     insufficientFilamentLine: '{{printer}} - {{slot}}: {{required}}g gerekli, {{remaining}}g kaldı',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}: {{required}}g gerekli, eşleşen makaralarda toplam {{remaining}}g (AMS Filament Backup)',
     printAnyway: 'Yine de yazdır',
     printAnyway: 'Yine de yazdır',
     forceColorMatch: 'Renk eşleşmesini zorla',
     forceColorMatch: 'Renk eşleşmesini zorla',
     staggerPrinterStarts: 'Yazıcı başlangıçlarını kademelendir',
     staggerPrinterStarts: 'Yazıcı başlangıçlarını kademelendir',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4976,6 +4976,7 @@ export default {
     waitingForAmsStatus: "Очікування статусу AMS від {{printer}}…",
     waitingForAmsStatus: "Очікування статусу AMS від {{printer}}…",
     insufficientFilamentMessage: "У деяких призначених котушках залишилося менше філаменту, ніж потрібно для друку:",
     insufficientFilamentMessage: "У деяких призначених котушках залишилося менше філаменту, ніж потрібно для друку:",
     insufficientFilamentLine: "{{printer}} - {{slot}}: потрібно {{required}}g, залишилося {{remaining}}g",
     insufficientFilamentLine: "{{printer}} - {{slot}}: потрібно {{required}}g, залишилося {{remaining}}g",
+    insufficientFilamentLinePooled: "{{printer}} - {{slot}}: потрібно {{required}}g, доступно {{remaining}}g на відповідних котушках (резервне перемикання філаменту AMS)",
     printAnyway: "Усе одно друкувати",
     printAnyway: "Усе одно друкувати",
     forceColorMatch: "Примусовий збіг кольорів",
     forceColorMatch: "Примусовий збіг кольорів",
     staggerPrinterStarts: "Запускати принтери почергово",
     staggerPrinterStarts: "Запускати принтери почергово",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4922,6 +4922,7 @@ export default {
     waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 状态…',
     waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 状态…',
     insufficientFilamentMessage: '部分已分配线轴的剩余耗材少于本次打印所需:',
     insufficientFilamentMessage: '部分已分配线轴的剩余耗材少于本次打印所需:',
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}:需要 {{required}}g,匹配料卷共剩余 {{remaining}}g(AMS 备用料盘)',
     printAnyway: '仍然打印',
     printAnyway: '仍然打印',
     forceColorMatch: '强制颜色匹配',
     forceColorMatch: '强制颜色匹配',
     staggerPrinterStarts: '错峰启动打印机',
     staggerPrinterStarts: '错峰启动打印机',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4922,6 +4922,7 @@ export default {
     waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 狀態…',
     waitingForAmsStatus: '正在等待 {{printer}} 的 AMS 狀態…',
     insufficientFilamentMessage: '部分已分配料盤的剩餘耗材少於本次列印所需:',
     insufficientFilamentMessage: '部分已分配料盤的剩餘耗材少於本次列印所需:',
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩餘 {{remaining}}g',
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩餘 {{remaining}}g',
+    insufficientFilamentLinePooled: '{{printer}} - {{slot}}:需要 {{required}}g,相符料卷共剩餘 {{remaining}}g(AMS 備用料盤)',
     printAnyway: '仍然列印',
     printAnyway: '仍然列印',
     forceColorMatch: '強制顏色匹配',
     forceColorMatch: '強制顏色匹配',
     staggerPrinterStarts: '錯開印表機啟動',
     staggerPrinterStarts: '錯開印表機啟動',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CLoFXTKS.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-moQVuKgX.js"></script>
+    <script type="module" crossorigin src="/assets/index-CLoFXTKS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BkuH4t27.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BkuH4t27.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff