فهرست منبع

fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)

  The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
maziggy 5 روز پیش
والد
کامیت
7ea4410b21

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
CHANGELOG.md


+ 31 - 4
backend/app/api/routes/print_queue.py

@@ -32,6 +32,7 @@ from backend.app.schemas.print_queue import (
     PrintQueueItemUpdate,
     PrintQueueReorder,
 )
+from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
 from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
@@ -196,6 +197,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "require_previous_success": item.require_previous_success,
         "auto_off_after": item.auto_off_after,
         "manual_start": item.manual_start,
+        "filament_short": bool(item.filament_short),
         "ams_mapping": ams_mapping_parsed,
         "plate_id": item.plate_id,
         "bed_levelling": item.bed_levelling,
@@ -1028,19 +1030,25 @@ async def stop_queue_item(
 @router.post("/{item_id}/start")
 async def start_queue_item(
     item_id: int,
+    skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
     """Manually start a staged (manual_start) queue item.
 
-    This clears the manual_start flag so the scheduler will pick it up,
-    or starts immediately if the printer is ready.
+    Clears the manual_start flag so the scheduler picks it up. When
+    ``skip_filament_check`` is false (the default) the live filament
+    deficit (#1496) is checked first — if the assigned spool can't satisfy
+    a slot's required grams, the route returns ``409`` with the deficit
+    payload so the caller can show a confirm dialog and retry with
+    ``skip_filament_check=true``.
     """
     result = await db.execute(
         select(PrintQueueItem)
         .options(
             selectinload(PrintQueueItem.archive),
             selectinload(PrintQueueItem.printer),
+            selectinload(PrintQueueItem.library_file),
             selectinload(PrintQueueItem.batch),
         )
         .where(PrintQueueItem.id == item_id)
@@ -1052,10 +1060,29 @@ async def start_queue_item(
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
 
-    # Clear manual_start flag so scheduler picks it up
+    # Live deficit check — re-evaluated against current spool state, so a
+    # spool swap between scheduler flagging and the user clicking ▶ clears
+    # the block automatically.
+    if not skip_filament_check:
+        deficit = await compute_deficit_for_queue_item(db, item)
+        if deficit:
+            raise HTTPException(
+                status_code=409,
+                detail={
+                    "code": "insufficient_filament",
+                    "deficit": [d.to_dict() for d in deficit],
+                },
+            )
+
+    # Print Anyway / no deficit: clear the flags and let the scheduler dispatch.
     item.manual_start = False
+    item.filament_short = False
     await db.commit()
     await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
 
-    logger.info("Manually started queue item %s (cleared manual_start flag)", item_id)
+    logger.info(
+        "Manually started queue item %s (cleared manual_start; skip_filament_check=%s)",
+        item_id,
+        skip_filament_check,
+    )
     return _enrich_response(item)

+ 9 - 0
backend/app/core/database.py

@@ -906,6 +906,15 @@ async def run_migrations(conn):
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
 
+    # Migration: filament_short flag on print_queue (#1496). Set by the
+    # dispatch scheduler when the assigned spool can't satisfy the print's
+    # per-slot weight; surfaced as a "filament short" badge on the queue row.
+    # Postgres rejects `DEFAULT 0` for BOOLEAN — branch on dialect.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler

+ 7 - 0
backend/app/models/print_queue.py

@@ -76,6 +76,13 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
+    # Set by the dispatch scheduler when the assigned spool can't satisfy
+    # this print's per-slot filament weight (#1496). Display-only flag — the
+    # actual deficit is recomputed live every time the user clicks ▶, so
+    # swapping a spool to a fuller one between flag and dispatch clears the
+    # block automatically.
+    filament_short: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Tracking
     started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 5 - 0
backend/app/schemas/print_queue.py

@@ -86,6 +86,11 @@ class PrintQueueItemResponse(BaseModel):
     require_previous_success: bool
     auto_off_after: bool
     manual_start: bool
+    # True when the dispatch scheduler last evaluated this item and the
+    # assigned spool could not satisfy at least one slot's required grams
+    # (#1496). Display-only — the ▶ click recomputes deficit against live
+    # spool state.
+    filament_short: bool = False
     ams_mapping: list[int] | None = None
     plate_id: int | None = None  # Plate ID for multi-plate 3MF files
     # Print options

+ 287 - 0
backend/app/services/filament_deficit.py

@@ -0,0 +1,287 @@
+"""Filament-deficit check used by every queue dispatch path.
+
+The PrintModal warns when an assigned spool can't satisfy a print's per-slot
+filament weight (``Pre-print checks now also warn when the spool has
+insufficient material`` — #720). That check only runs when the user clicks
+"Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
+route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
+
+This module is the single source of truth for the check. Both the route
+handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
+``compute_deficit_for_queue_item`` against live spool state.
+
+Design notes:
+* The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
+  comes from ``extract_filament_requirements`` (#1188's filament-overrides
+  pipeline) or — when the item points at an unsliced library file — falls
+  through to the file's archive copy. Anything that yields no requirements
+  is treated as "no deficit" so a malformed or stripped 3MF never blocks.
+* Both internal-inventory and Spoolman modes are covered. Internal mode
+  resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
+  minus ``weight_used``). Spoolman mode resolves via
+  ``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
+  remaining weight; if Spoolman is unreachable we return no deficit rather
+  than wedge the queue on a flaky network call.
+* The ``disable_filament_warnings`` user setting is respected at the
+  service boundary — callers do not have to know about it.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.filament_requirements import extract_filament_requirements
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class FilamentDeficit:
+    """One slot's filament shortfall."""
+
+    slot_id: int
+    ams_id: int | None
+    tray_id: int | None
+    filament_type: str
+    required_grams: float
+    remaining_grams: float | None  # None = could not determine
+
+    def to_dict(self) -> dict:
+        return {
+            "slot_id": self.slot_id,
+            "ams_id": self.ams_id,
+            "tray_id": self.tray_id,
+            "filament_type": self.filament_type,
+            "required_grams": self.required_grams,
+            "remaining_grams": self.remaining_grams,
+        }
+
+
+def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
+    """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
+    if global_tray_id >= 254:
+        return (255, global_tray_id - 254)
+    if global_tray_id >= 128:
+        return (global_tray_id, 0)
+    return (global_tray_id // 4, global_tray_id % 4)
+
+
+def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
+    """Locate the 3MF file backing this queue item (archive or library)."""
+    if item.archive is not None and item.archive.file_path:
+        return app_settings.base_dir / item.archive.file_path
+    if item.library_file is not None and item.library_file.file_path:
+        return Path(item.library_file.file_path)
+    return None
+
+
+async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
+    """Live remaining grams for a Spoolman spool, or None if unavailable."""
+    try:
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+    except ImportError:
+        return None
+    try:
+        client = await get_spoolman_client()
+        if client is None:
+            return None
+        spool = await client.get_spool(spoolman_spool_id)
+    except (SpoolmanNotFoundError, SpoolmanClientError):
+        return None
+    except Exception as e:
+        logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
+        return None
+
+    if not spool:
+        return None
+
+    # Spoolman exposes either an absolute remaining_weight, or used_weight +
+    # filament.weight. Either is sufficient — prefer remaining_weight when
+    # present (the user may have overridden it).
+    remaining = spool.get("remaining_weight")
+    if isinstance(remaining, (int, float)) and remaining >= 0:
+        return float(remaining)
+
+    used = spool.get("used_weight")
+    filament = spool.get("filament") or {}
+    total = filament.get("weight")
+    if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+        return max(0.0, float(total) - float(used))
+
+    return None
+
+
+async def _is_spoolman_mode(db: AsyncSession) -> bool:
+    """Check whether the user has opted in to Spoolman inventory mode."""
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
+    except Exception:
+        return False
+
+
+async def _warnings_disabled(db: AsyncSession) -> bool:
+    """Honour the ``disable_filament_warnings`` setting (#720)."""
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        disabled = await get_setting(db, "disable_filament_warnings")
+        return bool(disabled) and disabled.lower() == "true"
+    except Exception:
+        return False
+
+
+def _parse_ams_mapping(raw: str | None) -> list[int] | None:
+    if not raw:
+        return None
+    try:
+        parsed = json.loads(raw)
+    except (json.JSONDecodeError, TypeError):
+        return None
+    if not isinstance(parsed, list):
+        return None
+    return [v for v in parsed if isinstance(v, int)]
+
+
+async def compute_deficit_for_queue_item(
+    db: AsyncSession,
+    item: PrintQueueItem,
+) -> list[FilamentDeficit]:
+    """Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
+
+    Returns an empty list whenever any of the following hold:
+
+    * The ``disable_filament_warnings`` setting is on.
+    * The item has no resolved ``printer_id`` (model-based assignment not
+      yet picked a printer — the scheduler re-runs the check after it does).
+    * No source 3MF is available, or the 3MF carries no per-slot
+      requirements (treated as "nothing to verify" rather than an error,
+      matching the PrintModal behaviour).
+    * No AMS mapping is set yet — the scheduler computes the mapping just
+      before dispatch; until it does we cannot map slot → tray.
+    * Spoolman mode is on but the Spoolman server is unreachable. We do not
+      wedge the queue on a network blip.
+    """
+    if await _warnings_disabled(db):
+        return []
+    if item.printer_id is None:
+        return []
+
+    # Refresh the relationships we need without assuming the caller eagerly
+    # loaded them — both the route and the scheduler call this from contexts
+    # with different loading strategies.
+    refreshed = await db.execute(
+        select(PrintQueueItem)
+        .options(
+            selectinload(PrintQueueItem.archive),
+            selectinload(PrintQueueItem.library_file),
+        )
+        .where(PrintQueueItem.id == item.id)
+    )
+    item = refreshed.scalar_one_or_none() or item
+
+    source_path = _resolve_source_3mf(item)
+    if source_path is None or not source_path.exists():
+        return []
+
+    requirements = extract_filament_requirements(source_path, item.plate_id)
+    if not requirements:
+        return []
+
+    mapping = _parse_ams_mapping(item.ams_mapping)
+    if not mapping:
+        return []
+
+    spoolman_mode = await _is_spoolman_mode(db)
+
+    deficits: list[FilamentDeficit] = []
+    for req in requirements:
+        slot_id = req.get("slot_id")
+        used_grams = req.get("used_grams")
+        if not isinstance(slot_id, int) or slot_id <= 0:
+            continue
+        if not isinstance(used_grams, (int, float)) or used_grams <= 0:
+            continue
+        idx = slot_id - 1
+        if idx >= len(mapping):
+            continue
+        global_tray_id = mapping[idx]
+        if global_tray_id is None or global_tray_id < 0:
+            continue
+        ams_id, tray_id = _global_to_ams_key(global_tray_id)
+
+        remaining: float | None = None
+        if spoolman_mode:
+            sm_result = await db.execute(
+                select(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.printer_id == item.printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                )
+            )
+            sm_assignment = sm_result.scalar_one_or_none()
+            if sm_assignment is None:
+                continue
+            remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
+        else:
+            internal_result = await db.execute(
+                select(SpoolAssignment)
+                .options(selectinload(SpoolAssignment.spool))
+                .where(
+                    SpoolAssignment.printer_id == item.printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+            assignment = internal_result.scalar_one_or_none()
+            if assignment is None or assignment.spool is None:
+                continue
+            spool = assignment.spool
+            label_weight = float(spool.label_weight or 0)
+            weight_used = float(spool.weight_used or 0)
+            if label_weight <= 0:
+                continue
+            remaining = max(0.0, label_weight - weight_used)
+
+        if remaining is None:
+            # Spoolman unreachable for this spool — skip rather than block.
+            continue
+        if remaining >= float(used_grams):
+            continue
+
+        deficits.append(
+            FilamentDeficit(
+                slot_id=slot_id,
+                ams_id=ams_id,
+                tray_id=tray_id,
+                filament_type=str(req.get("type", "")),
+                required_grams=float(used_grams),
+                remaining_grams=remaining,
+            )
+        )
+
+    return deficits
+
+
+# Re-export the most useful pieces for callers that just want the data.
+__all__ = [
+    "FilamentDeficit",
+    "compute_deficit_for_queue_item",
+]

+ 61 - 0
backend/app/services/print_scheduler.py

@@ -25,6 +25,7 @@ from backend.app.services.bambu_ftp import (
     upload_file_async,
     with_ftp_retry,
 )
+from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager, supports_drying
 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -300,6 +301,13 @@ class PrintScheduler:
                             )
                             await db.commit()
 
+                    # Filament-deficit pre-dispatch check (#1496). If the
+                    # assigned spool can't satisfy any required slot grams,
+                    # promote the item to manual_start so the user must
+                    # acknowledge via the ▶ button (which re-checks live).
+                    if await self._block_on_filament_deficit(db, item):
+                        continue
+
                     # Start the print
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
@@ -423,6 +431,10 @@ class PrintScheduler:
                                 )
                                 await db.commit()
 
+                        # Filament-deficit pre-dispatch check (#1496).
+                        if await self._block_on_filament_deficit(db, item):
+                            continue
+
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
 
@@ -1648,6 +1660,55 @@ class PrintScheduler:
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         return result.scalar_one_or_none()
 
+    async def _block_on_filament_deficit(
+        self,
+        db: AsyncSession,
+        item: PrintQueueItem,
+    ) -> bool:
+        """Promote the item to manual_start when the assigned spool is short (#1496).
+
+        Returns True when this dispatch attempt was blocked, False when the
+        item is clear to start. A previously-flagged item whose spool has
+        since been swapped to one with enough material clears the flag here
+        so the next scheduler tick dispatches it.
+        """
+        try:
+            deficit = await compute_deficit_for_queue_item(db, item)
+        except Exception as e:
+            # Never let a flaky deficit check wedge the queue — log and let
+            # dispatch proceed. The PrintModal-side check still runs on the
+            # manual paths.
+            logger.warning("Filament deficit check failed for item %s: %s", item.id, e)
+            return False
+
+        if deficit:
+            item.filament_short = True
+            item.manual_start = True
+            await db.commit()
+            job_name = await self._get_job_name(db, item)
+            printer = await self._get_printer(db, item.printer_id) if item.printer_id else None
+            logger.info(
+                "Queue item %s blocked on filament deficit (%d slot(s)) — promoted to manual_start",
+                item.id,
+                len(deficit),
+            )
+            try:
+                await notification_service.on_queue_job_waiting(
+                    job_name=job_name,
+                    target_model=(printer.model if printer else "") or "",
+                    waiting_reason="filament_short",
+                    db=db,
+                )
+            except Exception as e:
+                logger.debug("filament_short notification failed for item %s: %s", item.id, e)
+            return True
+
+        # No deficit — clear any stale flag from a previous tick.
+        if item.filament_short:
+            item.filament_short = False
+            await db.commit()
+        return False
+
     async def _start_print(self, db: AsyncSession, item: PrintQueueItem):
         """Upload file and start print for a queue item.
 

+ 87 - 0
backend/tests/integration/test_print_queue_api.py

@@ -496,6 +496,93 @@ class TestQueueStartEndpoint:
         response = await async_client.post(f"/api/v1/queue/{item.id}/start")
         assert response.status_code == 400
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_returns_409_on_filament_deficit(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+        monkeypatch,
+    ):
+        """Filament deficit must surface as 409 + structured payload (#1496)."""
+        from backend.app.services import filament_deficit as fd_module
+
+        item = await queue_item_factory(manual_start=True)
+
+        async def _fake_deficit(_db, _item):
+            return [
+                fd_module.FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+
+        monkeypatch.setattr(
+            "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
+            _fake_deficit,
+        )
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start")
+        assert response.status_code == 409
+        body = response.json()
+        assert body["detail"]["code"] == "insufficient_filament"
+        assert len(body["detail"]["deficit"]) == 1
+        assert body["detail"]["deficit"][0]["slot_id"] == 1
+        assert body["detail"]["deficit"][0]["required_grams"] == 270.0
+        assert body["detail"]["deficit"][0]["remaining_grams"] == 200.0
+
+        # Item still pending, manual_start unchanged.
+        await db_session.refresh(item)
+        assert item.status == "pending"
+        assert item.manual_start is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_start_with_skip_flag_bypasses_deficit_check(
+        self,
+        async_client: AsyncClient,
+        queue_item_factory,
+        db_session,
+        monkeypatch,
+    ):
+        """With skip_filament_check=true the route dispatches even when short (#1496)."""
+        from backend.app.services import filament_deficit as fd_module
+
+        item = await queue_item_factory(manual_start=True, filament_short=True)
+        called_with = {}
+
+        async def _fake_deficit(_db, _item):
+            called_with["called"] = True
+            return [
+                fd_module.FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+
+        monkeypatch.setattr(
+            "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
+            _fake_deficit,
+        )
+
+        response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["manual_start"] is False
+        assert body["filament_short"] is False
+        # Helper not called on the bypass path — we trust the operator's
+        # decision to print anyway.
+        assert called_with == {}
+
 
 class TestQueueCancelEndpoint:
     """Tests for the /queue/{item_id}/cancel endpoint."""

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

@@ -0,0 +1,267 @@
+"""Unit tests for the filament-deficit pre-dispatch check (#1496).
+
+The check is the single source of truth that both ``POST /queue/{id}/start``
+and the dispatch scheduler call before sending a print to the printer. Pin
+the contract for the cases that matter:
+
+* Internal-inventory mode: shortfall + sufficient + no assignment.
+* AMS-mapping gating: a missing mapping means "not yet decided, skip".
+* Disabled-warnings setting + missing printer (model-based item) + no
+  source 3MF all short-circuit to "no deficit".
+"""
+
+from __future__ import annotations
+
+import json
+import zipfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.filament_deficit import (
+    FilamentDeficit,
+    compute_deficit_for_queue_item,
+)
+
+
+def _write_3mf(file_path: Path, filaments: list[dict]) -> None:
+    """Minimal 3MF that ``extract_filament_requirements`` can parse (flat shape)."""
+    body = "".join(
+        f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
+        f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
+        for f in filaments
+    )
+    config = f'<?xml version="1.0" encoding="utf-8"?><config>{body}</config>'
+    with zipfile.ZipFile(file_path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", config)
+
+
+async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict]) -> PrintArchive:
+    """Create a 3MF on disk and a PrintArchive row pointing at it."""
+    file_name = "model.3mf"
+    file_path = tmp_path / file_name
+    _write_3mf(file_path, filaments)
+    archive = PrintArchive(
+        filename=file_name,
+        print_name="Test",
+        # The helper resolves via app_settings.base_dir / file_path, but
+        # storing the absolute path on the model also works because
+        # ``Path / abs`` collapses to the absolute side.
+        file_path=str(file_path),
+        file_size=file_path.stat().st_size,
+        status="completed",
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    await db_session.refresh(archive)
+    return archive
+
+
+async def _spool(db_session, *, label_weight: int, weight_used: float, color: str = "#000000") -> Spool:
+    spool = Spool(
+        material="PLA",
+        label_weight=label_weight,
+        weight_used=weight_used,
+        rgba=color,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    await db_session.refresh(spool)
+    return spool
+
+
+async def _assign(db_session, *, printer_id: int, spool_id: int, ams_id: int = 0, tray_id: int = 0) -> None:
+    db_session.add(
+        SpoolAssignment(
+            spool_id=spool_id,
+            printer_id=printer_id,
+            ams_id=ams_id,
+            tray_id=tray_id,
+        )
+    )
+    await db_session.commit()
+
+
+async def _queue_item(
+    db_session,
+    *,
+    printer_id: int | None,
+    archive: PrintArchive | None,
+    ams_mapping: list[int] | None,
+    plate_id: int | None = None,
+) -> PrintQueueItem:
+    item = PrintQueueItem(
+        printer_id=printer_id,
+        archive_id=archive.id if archive else None,
+        ams_mapping=json.dumps(ams_mapping) if ams_mapping is not None else None,
+        plate_id=plate_id,
+        status="pending",
+        manual_start=True,
+    )
+    db_session.add(item)
+    await db_session.commit()
+    await db_session.refresh(item, ["archive", "library_file"])
+    return item
+
+
+class TestFilamentDeficit:
+    @pytest.mark.asyncio
+    async def test_returns_deficit_when_spool_too_light(self, db_session, printer_factory, tmp_path):
+        """Spool with 30g remaining for a 100g print → one deficit row."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=970.0)  # 30g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert len(deficit) == 1
+        assert isinstance(deficit[0], FilamentDeficit)
+        assert deficit[0].slot_id == 1
+        assert deficit[0].required_grams == 100.0
+        assert deficit[0].remaining_grams == 30.0
+        assert deficit[0].filament_type == "PLA"
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_spool_has_enough(self, db_session, printer_factory, tmp_path):
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=200.0)  # 800g left
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=0, tray_id=0)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_ams_mapping_missing(self, db_session, printer_factory, tmp_path):
+        """No mapping yet = scheduler hasn't decided which slot maps where."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=None)
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_printer_assigned(self, db_session, tmp_path):
+        """Model-based queue items with no resolved printer_id can't be checked."""
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=None, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_warnings_disabled(self, db_session, printer_factory, tmp_path):
+        """Honour the disable_filament_warnings setting (#720 toggle)."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        spool = await _spool(db_session, label_weight=1000, weight_used=970.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+        db_session.add(Settings(key="disable_filament_warnings", value="true"))
+        await db_session.commit()
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_no_assignment(self, db_session, printer_factory, tmp_path):
+        """Mapping points at a slot with no spool assigned → silent, not blocked."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"}],
+        )
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_3mf_missing(self, db_session, printer_factory):
+        printer = await printer_factory()
+        archive = PrintArchive(
+            filename="ghost.3mf",
+            file_path="/tmp/nope-does-not-exist.3mf",
+            file_size=0,
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        item = await _queue_item(db_session, printer_id=printer.id, archive=archive, ams_mapping=[0])
+
+        deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_multi_slot_only_shorted_slot_returned(self, db_session, printer_factory, tmp_path):
+        """One slot fine, one short — only the short slot is in the result."""
+        printer = await printer_factory()
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [
+                {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "100.0"},
+                {"id": "2", "type": "PETG", "color": "#000000", "used_g": "80.0"},
+            ],
+        )
+        plenty = await _spool(db_session, label_weight=1000, weight_used=100.0)  # 900g
+        shorted = await _spool(db_session, label_weight=1000, weight_used=950.0)  # 50g
+        await _assign(db_session, printer_id=printer.id, spool_id=plenty.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=shorted.id, ams_id=0, tray_id=1)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0, 1],  # slot 1 -> tray 0, slot 2 -> tray 1
+        )
+
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            deficit = await compute_deficit_for_queue_item(db_session, item)
+
+        assert [d.slot_id for d in deficit] == [2]
+        assert deficit[0].remaining_grams == 50.0
+        assert deficit[0].required_grams == 80.0

+ 119 - 0
backend/tests/unit/test_scheduler_filament_deficit.py

@@ -0,0 +1,119 @@
+"""Scheduler pre-dispatch filament-deficit guard tests (#1496).
+
+``PrintScheduler._block_on_filament_deficit`` is the gate that keeps an
+auto_dispatch=True VP intake (or any other scheduler-driven dispatch) from
+sending a print onto a spool that can't satisfy it. On a deficit it
+promotes the item to manual_start; when a previously-flagged item's spool
+is now adequate it clears the flag so the next tick dispatches.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.filament_deficit import FilamentDeficit
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    """A fresh scheduler instance — internal state is not exercised."""
+    return PrintScheduler()
+
+
+@pytest.fixture
+def queue_item(db_session, printer_factory):
+    """Helper to drop a queue item the helper can mutate."""
+
+    async def _make(**overrides):
+        printer = await printer_factory()
+        defaults = {
+            "printer_id": printer.id,
+            "status": "pending",
+            "manual_start": False,
+            "filament_short": False,
+        }
+        defaults.update(overrides)
+        item = PrintQueueItem(**defaults)
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    return _make
+
+
+@pytest.mark.asyncio
+async def test_blocks_on_deficit_promotes_to_manual_start(scheduler, db_session, queue_item):
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(
+            return_value=[
+                FilamentDeficit(
+                    slot_id=1,
+                    ams_id=0,
+                    tray_id=0,
+                    filament_type="PLA",
+                    required_grams=270.0,
+                    remaining_grams=200.0,
+                ),
+            ]
+        ),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is True
+    await db_session.refresh(item)
+    assert item.manual_start is True
+    assert item.filament_short is True
+
+
+@pytest.mark.asyncio
+async def test_clears_stale_flag_when_deficit_resolves(scheduler, db_session, queue_item):
+    """Previously-flagged item whose spool was swapped is unblocked."""
+    item = await queue_item(filament_short=True, manual_start=False)
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(return_value=[]),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False
+    assert item.manual_start is False
+
+
+@pytest.mark.asyncio
+async def test_no_deficit_no_op(scheduler, db_session, queue_item):
+    """Happy path — no deficit, no flag changes, dispatch proceeds."""
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(return_value=[]),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False
+    assert item.manual_start is False
+
+
+@pytest.mark.asyncio
+async def test_helper_exception_does_not_wedge_dispatch(scheduler, db_session, queue_item):
+    """A flaky deficit check (e.g. Spoolman timeout) must not block dispatch."""
+    item = await queue_item()
+    with patch(
+        "backend.app.services.print_scheduler.compute_deficit_for_queue_item",
+        AsyncMock(side_effect=RuntimeError("network down")),
+    ):
+        blocked = await scheduler._block_on_filament_deficit(db_session, item)
+
+    assert blocked is False
+    await db_session.refresh(item)
+    assert item.filament_short is False

+ 79 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -442,4 +442,83 @@ describe('QueuePage', () => {
       expect(screen.queryByText('G-code')).not.toBeInTheDocument();
     });
   });
+
+  describe('filament-short ▶ flow (#1496)', () => {
+    /**
+     * The dispatch pre-flight flags a queue item as filament_short. The user
+     * clicks ▶, the backend re-checks live and either dispatches (no deficit
+     * anymore — clear flag) or returns 409 with the per-slot deficit so the
+     * frontend can render the "Print Anyway" confirm modal.
+     */
+    const shortItem = {
+      ...mockQueueItems[0],
+      manual_start: true,
+      filament_short: true,
+    };
+
+    it('renders the filament-short badge on a flagged pending row', async () => {
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
+      );
+
+      render(<QueuePage />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Insufficient filament for the assigned spool/i)).toBeInTheDocument();
+      });
+    });
+
+    it('opens the Print Anyway modal when ▶ returns 409 and retries with skip_filament_check', async () => {
+      let secondCallSkippedCheck: boolean | null = null;
+      let attempts = 0;
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json([shortItem])),
+        http.post('/api/v1/queue/:id/start', ({ request }) => {
+          attempts += 1;
+          const url = new URL(request.url);
+          const skip = url.searchParams.get('skip_filament_check') === 'true';
+          if (attempts === 1) {
+            return HttpResponse.json(
+              {
+                detail: {
+                  code: 'insufficient_filament',
+                  deficit: [
+                    {
+                      slot_id: 1,
+                      ams_id: 0,
+                      tray_id: 0,
+                      filament_type: 'PLA',
+                      required_grams: 270,
+                      remaining_grams: 200,
+                    },
+                  ],
+                },
+              },
+              { status: 409 },
+            );
+          }
+          secondCallSkippedCheck = skip;
+          return HttpResponse.json({ ...shortItem, manual_start: false, filament_short: false });
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const playButton = await screen.findByTitle(/Start Print|do not have permission to start prints/i);
+      await userEvent.click(playButton);
+
+      // Wait for the start endpoint to be hit (the 409 path returns to onError).
+      await waitFor(() => expect(attempts).toBe(1));
+      // Modal shows the deficit detail
+      await screen.findByRole('button', { name: /Print Anyway/i });
+      expect(
+        screen.getByText(/Slot 1: needs 270 g, 200 g remaining/i),
+      ).toBeInTheDocument();
+
+      await userEvent.click(screen.getByRole('button', { name: /Print Anyway/i }));
+
+      await waitFor(() => expect(secondCallSkippedCheck).toBe(true));
+      expect(attempts).toBe(2);
+    });
+  });
 });

+ 32 - 6
frontend/src/api/client.ts

@@ -8,11 +8,21 @@ export class ApiError extends Error {
    *  Frontend uses this to look up an i18n key instead of showing the raw
    *  English fallback. Null when the backend returned a plain-string detail. */
   code: string | null;
-  constructor(message: string, status: number, code: string | null = null) {
+  /** Full structured detail object when the backend returned `{code, ...}`
+   *  with additional fields (e.g. the deficit list for 409s on queue
+   *  start, #1496). Null for plain-string or array-shaped details. */
+  detail: Record<string, unknown> | null;
+  constructor(
+    message: string,
+    status: number,
+    code: string | null = null,
+    detail: Record<string, unknown> | null = null,
+  ) {
     super(message);
     this.name = 'ApiError';
     this.status = status;
     this.code = code;
+    this.detail = detail;
   }
 }
 
@@ -129,13 +139,17 @@ async function request<T>(
         .join('; ');
       message = joined || JSON.stringify(detail) || `HTTP ${response.status}`;
     } else if (detail && typeof detail === 'object') {
-      // Structured detail `{code, message}` — frontend uses the code to
-      // pick an i18n key, message is the English fallback.
+      // Structured detail `{code, message, ...}` — frontend uses the code
+      // to pick an i18n key, message is the English fallback, any extra
+      // fields land on ApiError.detail (e.g. `deficit` for #1496).
       code = typeof detail.code === 'string' ? detail.code : null;
       message = typeof detail.message === 'string' ? detail.message : `HTTP ${response.status}`;
     } else {
       message = `HTTP ${response.status}`;
     }
+    const structuredDetail = detail && typeof detail === 'object' && !Array.isArray(detail)
+      ? (detail as Record<string, unknown>)
+      : null;
 
     // Handle 401 Unauthorized - only clear token if it's actually invalid
     // Don't clear on "Authentication required" which might be a timing issue
@@ -152,7 +166,7 @@ async function request<T>(
       }
     }
 
-    throw new ApiError(message, response.status, code);
+    throw new ApiError(message, response.status, code, structuredDetail);
   }
 
   // Handle empty responses (204 No Content, etc.)
@@ -1783,6 +1797,10 @@ export interface PrintQueueItem {
   require_previous_success: boolean;
   auto_off_after: boolean;
   manual_start: boolean;  // Requires manual trigger to start (staged)
+  // Set by the dispatch scheduler when the assigned spool can't satisfy
+  // any required slot's grams (#1496). Surfaced on the queue row as a
+  // "filament short" badge; cleared on a successful ▶ click (live recheck).
+  filament_short: boolean;
   ams_mapping: number[] | null;  // AMS slot mapping for multi-color prints
   filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;  // Filament overrides for model-based assignment
   plate_id: number | null;  // Plate ID for multi-plate 3MF files
@@ -4458,8 +4476,16 @@ export const api = {
     request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
   stopQueueItem: (id: number) =>
     request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
-  startQueueItem: (id: number) =>
-    request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
+  /**
+   * Start a staged queue item. The backend re-checks live filament deficit
+   * for the assigned spool and, when short, returns 409 with a structured
+   * payload so the caller can confirm and retry. Pass `skipFilamentCheck`
+   * after the user confirms "Print Anyway" (#1496).
+   */
+  startQueueItem: (id: number, opts?: { skipFilamentCheck?: boolean }) => {
+    const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : '';
+    return request<PrintQueueItem>(`/queue/${id}/start${qs}`, { method: 'POST' });
+  },
   bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
     request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
       method: 'PATCH',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: 'Filament fuer die zugewiesene Spule reicht nicht',
+      rowTooltip: 'Der Dispatcher hat diese Position markiert. Klicke auf Play, um den Pro-Slot-Fehlbestand zu sehen und zu entscheiden, ob trotzdem gedruckt werden soll.',
+      confirmTitle: 'Filament reicht nicht',
+      confirmIntro: 'Die zugewiesene Spule kann mindestens einen Slot nicht versorgen. Trotzdem drucken?',
+      lineItem: 'Slot {{slot}}: benoetigt {{required}} g, {{remaining}} g verbleibend',
+      unknown: 'unbekannt',
+      printAnyway: 'Trotzdem drucken',
+    },
     title: 'Druckwarteschlange',
     subtitle: 'Planen und verwalten Sie Ihre Druckaufträge',
     addToQueue: 'Zur Warteschlange hinzufügen',

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

@@ -930,6 +930,15 @@ export default {
     title: 'Print Queue',
     subtitle: 'Schedule and manage your print jobs',
     addToQueue: 'Add to Queue',
+    filamentShort: {
+      rowBadge: 'Insufficient filament for the assigned spool',
+      rowTooltip: 'The dispatch scheduler flagged this item. Click Play to see the per-slot deficit and decide whether to print anyway.',
+      confirmTitle: 'Insufficient filament',
+      confirmIntro: 'The assigned spool cannot satisfy at least one slot. Print anyway?',
+      lineItem: 'Slot {{slot}}: needs {{required}} g, {{remaining}} g remaining',
+      unknown: 'unknown',
+      printAnyway: 'Print Anyway',
+    },
     // Print modal
     print: 'Print',
     reprint: 'Re-print',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: 'Filamento insuficiente para la bobina asignada',
+      rowTooltip: 'El planificador marco este elemento. Pulsa Reproducir para ver el deficit por slot y decidir si imprimir de todos modos.',
+      confirmTitle: 'Filamento insuficiente',
+      confirmIntro: 'La bobina asignada no puede cubrir al menos un slot. Imprimir de todos modos?',
+      lineItem: 'Slot {{slot}}: necesita {{required}} g, quedan {{remaining}} g',
+      unknown: 'desconocido',
+      printAnyway: 'Imprimir de todos modos',
+    },
     title: 'Cola de impresión',
     subtitle: 'Programe y gestione sus trabajos de impresión',
     addToQueue: 'Añadir a la cola',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: 'Filament insuffisant pour la bobine assignee',
+      rowTooltip: "Le planificateur a signale cet element. Cliquez sur Lecture pour voir le deficit par emplacement et decider de lancer quand meme l'impression.",
+      confirmTitle: 'Filament insuffisant',
+      confirmIntro: 'La bobine assignee ne peut pas couvrir au moins un emplacement. Imprimer quand meme ?',
+      lineItem: 'Emplacement {{slot}} : besoin de {{required}} g, {{remaining}} g restants',
+      unknown: 'inconnu',
+      printAnyway: 'Imprimer quand meme',
+    },
     title: 'File d\'attente',
     subtitle: 'Gérez vos travaux d\'impression',
     addToQueue: 'Ajouter à la file',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: 'Filamento insufficiente per la bobina assegnata',
+      rowTooltip: 'Lo scheduler ha segnalato questo elemento. Premi Play per vedere il deficit per slot e decidere se stampare comunque.',
+      confirmTitle: 'Filamento insufficiente',
+      confirmIntro: 'La bobina assegnata non puo coprire almeno uno slot. Stampare comunque?',
+      lineItem: 'Slot {{slot}}: servono {{required}} g, rimangono {{remaining}} g',
+      unknown: 'sconosciuto',
+      printAnyway: 'Stampa comunque',
+    },
     title: 'Coda di stampa',
     subtitle: 'Programma e gestisci i tuoi lavori di stampa',
     addToQueue: 'Aggiungi alla coda',

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

@@ -926,6 +926,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: '割り当てられたスプールのフィラメントが不足しています',
+      rowTooltip: 'スケジューラーがこのアイテムをフラグしました。再生ボタンをクリックしてスロットごとの不足量を確認し、それでも印刷するか判断してください。',
+      confirmTitle: 'フィラメント不足',
+      confirmIntro: '割り当てられたスプールでは少なくとも 1 つのスロットを賄えません。それでも印刷しますか?',
+      lineItem: 'スロット {{slot}}:必要 {{required}} g、残り {{remaining}} g',
+      unknown: '不明',
+      printAnyway: 'それでも印刷',
+    },
     title: '印刷キュー',
     subtitle: '印刷ジョブのスケジュールと管理',
     addToQueue: 'キューに追加',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: 'Filamento insuficiente para a bobina atribuida',
+      rowTooltip: 'O escalonador sinalizou este item. Clique em Play para ver o deficit por slot e decidir se quer imprimir mesmo assim.',
+      confirmTitle: 'Filamento insuficiente',
+      confirmIntro: 'A bobina atribuida nao pode cobrir pelo menos um slot. Imprimir mesmo assim?',
+      lineItem: 'Slot {{slot}}: precisa de {{required}} g, restam {{remaining}} g',
+      unknown: 'desconhecido',
+      printAnyway: 'Imprimir mesmo assim',
+    },
     title: 'Fila de Impressão',
     subtitle: 'Agende e gerencie seus trabalhos de impressão',
     addToQueue: 'Adicionar à Fila',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: '所分配料盘的耀丝不足',
+      rowTooltip: '调度程序已标记该项。点击播放查看各插槽的赤字并决定是否仍要打印。',
+      confirmTitle: '耀丝不足',
+      confirmIntro: '所分配的料盘至少有一个插槽无法满足。仍要打印吗?',
+      lineItem: '插槽 {{slot}}:需要 {{required}} g,剩余 {{remaining}} g',
+      unknown: '未知',
+      printAnyway: '仍要打印',
+    },
     title: '打印队列',
     subtitle: '排程和管理您的打印任务',
     addToQueue: '添加到队列',

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

@@ -927,6 +927,15 @@ export default {
 
   // Queue page
   queue: {
+    filamentShort: {
+      rowBadge: '所分配料盤的線材不足',
+      rowTooltip: '調度程式已標記此項目。點擊播放查看各槽位的不足量,並決定是否仍要列印。',
+      confirmTitle: '線材不足',
+      confirmIntro: '所分配的料盤至少有一個槽位無法滿足。仍要列印嗎?',
+      lineItem: '槽位 {{slot}}:需要 {{required}} g,剩餘 {{remaining}} g',
+      unknown: '不明',
+      printAnyway: '仍要列印',
+    },
     title: '列印佇列',
     subtitle: '排程和管理您的列印任務',
     addToQueue: '新增到佇列',

+ 70 - 4
frontend/src/pages/QueuePage.tsx

@@ -55,7 +55,7 @@ import {
   Code,
   Snail,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, ApiError } from '../api/client';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card } from '../components/Card';
@@ -612,6 +612,17 @@ function SortableQueueItem({
             </p>
           )}
 
+          {/* Filament-short flag from the dispatch pre-flight (#1496). */}
+          {item.filament_short && item.status === 'pending' && (
+            <p
+              className="text-[10px] sm:text-xs text-yellow-400 mt-1.5 sm:mt-2 flex items-start gap-1"
+              title={t('queue.filamentShort.rowTooltip')}
+            >
+              <AlertCircle className="w-3 h-3 mt-0.5 flex-shrink-0" />
+              <span>{t('queue.filamentShort.rowBadge')}</span>
+            </p>
+          )}
+
           {/* Error message */}
           {item.error_message && (
             <p className="text-[10px] sm:text-xs text-red-400 mt-1.5 sm:mt-2 flex items-center gap-1">
@@ -827,13 +838,41 @@ export function QueuePage() {
     onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
   });
 
+  // Filament-deficit confirmation state (#1496). When the backend returns
+  // 409 with `code=insufficient_filament` we stash the deficit + item id
+  // here; the modal at the bottom of the page reads it and the "Print
+  // Anyway" path re-issues the start with `skipFilamentCheck=true`.
+  const [filamentShortConfirm, setFilamentShortConfirm] = useState<{
+    itemId: number;
+    deficit: Array<{
+      slot_id: number;
+      required_grams: number;
+      remaining_grams: number | null;
+      filament_type?: string | null;
+    }>;
+  } | null>(null);
+
   const startMutation = useMutation({
-    mutationFn: (id: number) => api.startQueueItem(id),
+    mutationFn: ({ id, skipFilamentCheck }: { id: number; skipFilamentCheck?: boolean }) =>
+      api.startQueueItem(id, { skipFilamentCheck }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       showToast(t('queue.toast.released'));
+      setFilamentShortConfirm(null);
+    },
+    onError: (error: unknown, variables) => {
+      if (error instanceof ApiError && error.status === 409 && error.code === 'insufficient_filament') {
+        const deficitRaw = (error.detail?.deficit ?? []) as Array<{
+          slot_id: number;
+          required_grams: number;
+          remaining_grams: number | null;
+          filament_type?: string | null;
+        }>;
+        setFilamentShortConfirm({ itemId: variables.id, deficit: deficitRaw });
+        return;
+      }
+      showToast(t('queue.toast.startFailed'), 'error');
     },
-    onError: () => showToast(t('queue.toast.startFailed'), 'error'),
   });
 
   const reorderMutation = useMutation({
@@ -1367,7 +1406,7 @@ export function QueuePage() {
                         onRemove={() => {}}
                         onStop={() => {}}
                         onRequeue={() => {}}
-                        onStart={() => startMutation.mutate(item.id)}
+                        onStart={() => startMutation.mutate({ id: item.id })}
                         timeFormat={timeFormat}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
@@ -1464,6 +1503,33 @@ export function QueuePage() {
       )}
 
       {/* Confirm Action Modal */}
+      {filamentShortConfirm && (
+        <ConfirmModal
+          title={t('queue.filamentShort.confirmTitle')}
+          message={
+            t('queue.filamentShort.confirmIntro') + '\n\n' +
+            filamentShortConfirm.deficit
+              .map((d) =>
+                t('queue.filamentShort.lineItem', {
+                  slot: d.slot_id,
+                  required: Math.round(d.required_grams),
+                  remaining:
+                    d.remaining_grams == null
+                      ? t('queue.filamentShort.unknown')
+                      : Math.round(d.remaining_grams),
+                }),
+              )
+              .join('\n')
+          }
+          confirmText={t('queue.filamentShort.printAnyway')}
+          variant="warning"
+          onConfirm={() => {
+            startMutation.mutate({ id: filamentShortConfirm.itemId, skipFilamentCheck: true });
+          }}
+          onCancel={() => setFilamentShortConfirm(null)}
+        />
+      )}
+
       {confirmAction && (
         <ConfirmModal
           title={

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-vzamAN0i.js


+ 1 - 1
static/index.html

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است