Просмотр исходного кода

feat(spoolman): remain%-delta fallback for no-3MF "Untitled" prints (#1820)

  Brings the Spoolman writer up to parity with the internal-inventory
  side, which has had this fallback since #1119. When a Bambu print
  starts without a retrievable .gcode.3mf on the printer (typically an
  unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
  longer silently skips the print's filament consumption.

  - ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
    column captures per-slot {remain, tray_uuid} at print start.
  - store_print_data: always snapshots remain, even when 3MF is present
    (mirrors usage_tracker.on_print_start), so partial-3MF prints can also
    fall back per-slot.
  - report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
    handles slots the 3MF didn't cover via delta * Filament.weight / 100,
    resolving the spool via the existing slot-assignment table.
  - _report_partial_usage: same fallback for aborted no-3MF prints.

  #1119 invariant preserved: per-slot, per-print, gated on a valid
  start/current remain AND a resolvable Spoolman spool. Uses curated
  Filament.weight (not MQTT's unreliable tray_weight) — same trick the
  internal-inventory side uses.

  Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
  Double-charge prevented via handled_global_tray_ids dedup.
maziggy 2 месяцев назад
Родитель
Сommit
e09a33be16

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 42 - 3
backend/app/core/database.py

@@ -1528,7 +1528,10 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass  # Already applied
 
-    # Create active_print_spoolman table for Spoolman per-filament tracking
+    # Create active_print_spoolman table for Spoolman per-filament tracking.
+    # filament_usage is nullable so the no-3MF branch can still create a row
+    # that carries only tray_remain_start for the remain%-delta fallback
+    # (#1820 — matches internal-inventory Path 2 in usage_tracker).
     await _safe_execute(
         conn,
         """
@@ -1536,11 +1539,12 @@ async def run_migrations(conn):
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
+            filament_usage TEXT,
             ams_trays TEXT NOT NULL,
             slot_to_tray TEXT,
             layer_usage TEXT,
             filament_properties TEXT,
+            tray_remain_start TEXT,
             UNIQUE(printer_id, archive_id)
         )
         """
@@ -1550,15 +1554,50 @@ async def run_migrations(conn):
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
+            filament_usage TEXT,
             ams_trays TEXT NOT NULL,
             slot_to_tray TEXT,
             layer_usage TEXT,
             filament_properties TEXT,
+            tray_remain_start TEXT,
             UNIQUE(printer_id, archive_id)
         )
         """,
     )
+    # Migration for installs that already created active_print_spoolman with
+    # the original schema: add tray_remain_start, and relax filament_usage's
+    # NOT NULL so the no-3MF branch can persist a remain-only tracking row.
+    await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT")
+    if is_sqlite():
+        # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the
+        # users.password_hash NULL-relaxation a few hundred lines below — see
+        # the comment there for the schema_version bump rationale.
+        try:
+            result = await conn.execute(
+                text("SELECT sql FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'")
+            )
+            tbl_sql = result.scalar()
+            if tbl_sql and "filament_usage TEXT NOT NULL" in tbl_sql:
+                version_result = await conn.execute(text("PRAGMA schema_version"))
+                schema_version = version_result.scalar() or 0
+                await conn.execute(text("PRAGMA writable_schema = ON"))
+                await conn.execute(
+                    text(
+                        "UPDATE sqlite_master "
+                        "SET sql = replace(sql, 'filament_usage TEXT NOT NULL', 'filament_usage TEXT') "
+                        "WHERE type='table' AND name='active_print_spoolman'"
+                    )
+                )
+                await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
+                await conn.execute(text("PRAGMA writable_schema = OFF"))
+        except (OperationalError, ProgrammingError) as exc:
+            logger.warning(
+                "Could not relax active_print_spoolman.filament_usage NOT NULL via writable_schema: %s — "
+                "no-3MF Spoolman fallback will be a no-op on this install",
+                exc,
+            )
+    else:
+        await _safe_execute(conn, "ALTER TABLE active_print_spoolman ALTER COLUMN filament_usage DROP NOT NULL")
 
     # Migration: Add preset_source column to slot_preset_mappings for local preset support
     try:

+ 12 - 1
backend/app/models/active_print_spoolman.py

@@ -24,7 +24,11 @@ class ActivePrintSpoolman(Base):
     archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
 
     # Per-filament usage from 3MF: [{"slot_id": 1, "used_g": 50.5, "type": "PLA"}, ...]
-    filament_usage: Mapped[list] = mapped_column(JSON)
+    # Nullable for the no-3MF case ("Untitled" prints where Bambu didn't keep a
+    # .gcode.3mf on the printer): the row still gets created so the completion
+    # path can use ``tray_remain_start`` for an AMS remain%-delta write,
+    # mirroring the internal-inventory Path 2 fallback in usage_tracker (#1820).
+    filament_usage: Mapped[list | None] = mapped_column(JSON, nullable=True)
 
     # AMS tray state at print start: {0: {"tray_uuid": "...", "tag_uid": "..."}, ...}
     ams_trays: Mapped[dict] = mapped_column(JSON)
@@ -40,3 +44,10 @@ class ActivePrintSpoolman(Base):
     # Filament properties (density, diameter per filament slot)
     # Format: {1: {"density": 1.24, "diameter": 1.75, "type": "PLA"}, ...}
     filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+    # AMS tray remain% per slot at print start, captured so the completion
+    # path can compute a remain-delta when the 3MF didn't cover a slot (or
+    # there was no 3MF at all — #1820). Matches the internal-inventory
+    # ``tray_remain_start`` snapshot at usage_tracker.py:301.
+    # Format: {"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}, ...}
+    tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True)

+ 297 - 57
backend/app/services/spoolman_tracking.py

@@ -182,6 +182,52 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     return lookup
 
 
+def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
+    """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the
+    completion path can compute a remain-delta when 3MF data doesn't cover
+    the slot (or there's no 3MF at all — #1820).
+
+    Returns ``{"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}}``.
+    Only slots whose ``remain`` is a valid 0..100 int are included; invalid
+    values mean the AMS hasn't read the spool yet and a delta would be
+    meaningless. Mirrors the gate in
+    ``usage_tracker.on_print_start:309``.
+    """
+    snapshot: dict[str, dict] = {}
+    ams_raw = raw_data.get("ams", [])
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    for ams_unit in ams_data:
+        if not isinstance(ams_unit, dict):
+            continue
+        ams_id = int(ams_unit.get("id", 0))
+        for tray in ams_unit.get("tray", []):
+            if not isinstance(tray, dict):
+                continue
+            tray_id = int(tray.get("id", 0))
+            remain = tray.get("remain", -1)
+            if isinstance(remain, int) and 0 <= remain <= 100:
+                snapshot[f"{ams_id}-{tray_id}"] = {
+                    "remain": remain,
+                    "tray_uuid": tray.get("tray_uuid", "") or "",
+                }
+    vt_tray_raw = raw_data.get("vt_tray") or []
+    if isinstance(vt_tray_raw, dict):
+        vt_tray_raw = [vt_tray_raw]
+    for vt in vt_tray_raw:
+        if not isinstance(vt, dict):
+            continue
+        vt_id = int(vt.get("id", 254))
+        # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding.
+        vt_tray_id = vt_id - 254
+        remain = vt.get("remain", -1)
+        if isinstance(remain, int) and 0 <= remain <= 100:
+            snapshot[f"255-{vt_tray_id}"] = {
+                "remain": remain,
+                "tray_uuid": vt.get("tray_uuid", "") or "",
+            }
+    return snapshot
+
+
 async def store_print_data(
     printer_id: int,
     archive_id: int,
@@ -219,38 +265,65 @@ async def store_print_data(
     if not spoolman_enabled or spoolman_enabled.lower() != "true":
         return
 
-    # Get 3MF file path
+    # Get current AMS tray state up front — needed both for the 3MF path's
+    # ams_trays field and for the remain%-delta snapshot (#1820 fallback for
+    # no-3MF "Untitled" prints, mirroring usage_tracker.on_print_start).
+    state = printer_manager.get_status(printer_id)
+    ams_trays: dict[int, dict] = {}
+    tray_remain_start: dict[str, dict] = {}
+    if state and state.raw_data:
+        ams_trays = build_ams_tray_lookup(state.raw_data)
+        tray_remain_start = _snapshot_tray_remain(state.raw_data)
+
+    # Try to read per-slot filament estimates from the 3MF. Two paths can
+    # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf
+    # was downloadable from the printer — "Untitled" prints, see #1820),
+    # (2) 3MF present but slice_info missing per-filament estimates.
+    # Both fall through to the remain%-delta path at completion.
+    filament_usage: list | None = None
+    layer_usage_json: dict | None = None
+    filament_properties: dict | None = None
     full_path = (
         app_settings.base_dir / file_path
     )  # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
-    if not full_path.exists():
-        logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
-        return
-
-    # Resolve the queue item once — used both for the plate-scoped 3MF parsing
-    # fallback (#1697: multi-plate file dispatched for one plate must only count
-    # that plate's filament) and for the ams_mapping fallback below.
-    queue_result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
-    )
-    queue_item = queue_result.scalar_one_or_none()
-    # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
-    # item's plate_id (queue dispatch path).
-    effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
-
-    # Extract per-filament usage from 3MF (total usage for the dispatched plate,
-    # or the whole file for direct/library prints with no plate_id).
-    filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
-    if not filament_usage:
-        logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
+    threemf_available = bool(file_path) and full_path.exists()
+    queue_item = None
+    if threemf_available:
+        # Resolve the queue item once — used both for the plate-scoped 3MF parsing
+        # fallback (#1697: multi-plate file dispatched for one plate must only count
+        # that plate's filament) and for the ams_mapping fallback below.
+        queue_result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.archive_id == archive_id)
+            .where(PrintQueueItem.status == "printing")
+        )
+        queue_item = queue_result.scalar_one_or_none()
+        # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
+        # item's plate_id (queue dispatch path).
+        effective_plate_id = (
+            plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
+        )
+        filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
+
+        layer_usage = extract_layer_filament_usage_from_3mf(full_path)
+        if layer_usage:
+            # Convert int keys to string for JSON serialization
+            layer_usage_json = {str(k): v for k, v in layer_usage.items()}
+            logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
+
+        filament_properties = extract_filament_properties_from_3mf(full_path)
+    else:
+        # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
+        # was never on the printer's FTP. Logged at debug since the
+        # fallback path below picks up the slack when remain% is available.
+        logger.debug("[SPOOLMAN] 3MF file not available: %s", full_path)
+
+    # If neither path has anything useful, there's nothing to track.
+    if not filament_usage and not tray_remain_start:
+        if threemf_available:
+            logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
         return
 
-    # Get current AMS tray state
-    state = printer_manager.get_status(printer_id)
-    ams_trays = {}
-    if state and state.raw_data:
-        ams_trays = build_ams_tray_lookup(state.raw_data)
-
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
@@ -260,17 +333,6 @@ async def store_print_data(
         except json.JSONDecodeError:
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
-    # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
-    layer_usage = extract_layer_filament_usage_from_3mf(full_path)
-    layer_usage_json = None
-    if layer_usage:
-        # Convert int keys to string for JSON serialization
-        layer_usage_json = {str(k): v for k, v in layer_usage.items()}
-        logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
-
-    # Extract filament properties (density, diameter) for mm -> grams conversion
-    filament_properties = extract_filament_properties_from_3mf(full_path)
-
     # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
     await db.execute(
         delete(ActivePrintSpoolman)
@@ -278,7 +340,8 @@ async def store_print_data(
         .where(ActivePrintSpoolman.archive_id == archive_id)
     )
 
-    # Insert new tracking data
+    # Insert new tracking data. ``filament_usage`` may be None for the
+    # no-3MF case; report_usage falls back to ``tray_remain_start``.
     tracking = ActivePrintSpoolman(
         printer_id=printer_id,
         archive_id=archive_id,
@@ -287,11 +350,18 @@ async def store_print_data(
         slot_to_tray=slot_to_tray,
         layer_usage=layer_usage_json,
         filament_properties=filament_properties,
+        tray_remain_start=tray_remain_start or None,
     )
     db.add(tracking)
     await db.commit()
 
-    logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
+    logger.info(
+        "[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s (3mf=%s, remain_snapshot=%d slot(s))",
+        printer_id,
+        archive_id,
+        "yes" if filament_usage else "no",
+        len(tray_remain_start),
+    )
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
     if slot_to_tray:
@@ -571,6 +641,7 @@ async def _report_partial_usage(
     filament_usage = tracking.filament_usage or []
     ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
     slot_to_tray = tracking.slot_to_tray
+    tray_remain_start = tracking.tray_remain_start or {}
     printer_serial = await _get_printer_serial(printer_id)
 
     client = await _get_spoolman_client_with_fallback()
@@ -578,6 +649,24 @@ async def _report_partial_usage(
         logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
         return
 
+    # No-3MF aborted print (#1820 mirror of the completion path): nothing in
+    # filament_usage or layer_usage to base partial estimates on, but the
+    # remain%-delta snapshot we captured at start still describes consumption
+    # up to the abort moment. Write it the same way report_usage's fallback
+    # does, then return — there's no 3MF-derived partial to layer on top.
+    # ``state`` was already fetched at the top of the function for current_layer.
+    if not filament_usage and not layer_usage and tray_remain_start:
+        current_lookup = _snapshot_tray_remain(state.raw_data) if state and state.raw_data else {}
+        await _report_remain_delta_for_slots(
+            client,
+            printer_id=printer_id,
+            tray_remain_start=tray_remain_start,
+            current_lookup=current_lookup,
+            handled_global_tray_ids=set(),
+            archive_id=getattr(tracking, "archive_id", -1),
+        )
+        return
+
     # Try to use accurate G-code parsed data
     if layer_usage:
         layer_usage_int = {
@@ -670,8 +759,15 @@ async def _report_partial_usage(
 async def report_usage(printer_id: int, archive_id: int):
     """Report filament usage to Spoolman after print completion.
 
-    Uses per-filament usage data captured at print start to report
-    usage to the correct spools.
+    Two writers, mirroring the internal-inventory split in usage_tracker:
+
+    1. **3MF path (primary)** — per-filament slice estimates captured at
+       print start drive a precise per-slot ``use_spool`` call.
+    2. **AMS remain%-delta (fallback)** — for slots the 3MF path didn't
+       handle (including the no-3MF "Untitled" case from #1820): compute
+       ``start_remain - current_remain``, multiply by the resolved
+       Spoolman filament's reference weight, and write the delta. Mirrors
+       ``usage_tracker.on_print_complete`` Path 2 (line 517).
     """
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
@@ -692,14 +788,15 @@ async def report_usage(printer_id: int, archive_id: int):
         filament_usage = tracking.filament_usage or []
         ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
         slot_to_tray = tracking.slot_to_tray
+        tray_remain_start = tracking.tray_remain_start or {}
         printer_serial = await _get_printer_serial(printer_id)
 
         # Delete tracking row (we're done with it)
         await db.delete(tracking)
         await db.commit()
 
-        if not filament_usage:
-            logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
+        if not filament_usage and not tray_remain_start:
+            logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", archive_id)
             return
 
         # Check if Spoolman is enabled
@@ -712,20 +809,48 @@ async def report_usage(printer_id: int, archive_id: int):
             logger.warning("[SPOOLMAN] Not reachable for usage reporting")
             return
 
-        logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
-
-        usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
         slot_colors: dict[int, str] = {}
-        spools_updated = await _report_spool_usage_for_slots(
-            client,
-            usage_items,
-            ams_trays,
-            slot_to_tray,
-            f"Archive {archive_id}",
-            printer_serial,
-            printer_id=printer_id,
-            slot_colors_out=slot_colors,
-        )
+        handled_global_tray_ids: set[int] = set()
+        spools_updated = 0
+
+        # --- Path 1: 3MF per-slot estimates -----------------------------
+        if filament_usage:
+            logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
+            usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
+            spools_updated = await _report_spool_usage_for_slots(
+                client,
+                usage_items,
+                ams_trays,
+                slot_to_tray,
+                f"Archive {archive_id}",
+                printer_serial,
+                printer_id=printer_id,
+                slot_colors_out=slot_colors,
+            )
+            # Track which physical slots the 3MF path already covered so
+            # Path 2 doesn't double-charge them.
+            for u in filament_usage:
+                slot_id = u.get("slot_id", 0)
+                handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
+
+        # --- Path 2: AMS remain%-delta for slots 3MF didn't cover -------
+        # Triggered for no-3MF "Untitled" prints (#1820) AND for partial
+        # 3MF coverage (slots whose filament_id wasn't in slice_info).
+        if tray_remain_start:
+            from backend.app.services.printer_manager import printer_manager
+
+            current = printer_manager.get_status(printer_id)
+            current_lookup = _snapshot_tray_remain(current.raw_data) if current and current.raw_data else {}
+            fallback_updates = await _report_remain_delta_for_slots(
+                client,
+                printer_id=printer_id,
+                tray_remain_start=tray_remain_start,
+                current_lookup=current_lookup,
+                handled_global_tray_ids=handled_global_tray_ids,
+                archive_id=archive_id,
+                slot_colors_out=slot_colors,
+            )
+            spools_updated += fallback_updates
 
         if spools_updated == 0:
             logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
@@ -738,6 +863,121 @@ async def report_usage(printer_id: int, archive_id: int):
         await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
 
 
+async def _report_remain_delta_for_slots(
+    client,
+    *,
+    printer_id: int,
+    tray_remain_start: dict[str, dict],
+    current_lookup: dict[str, dict],
+    handled_global_tray_ids: set[int],
+    archive_id: int,
+    slot_colors_out: dict[int, str] | None = None,
+) -> int:
+    """AMS remain%-delta path: write ``(start - current) * filament.weight``
+    grams to Spoolman for slots the 3MF path didn't cover.
+
+    Mirrors ``usage_tracker.on_print_complete`` Path 2: per-slot, gated on a
+    valid current ``remain%``, skipped on spool swap (``tray_uuid`` changed),
+    using the resolved spool's filament reference weight rather than MQTT's
+    unreliable ``tray_weight`` (which is the failure mode #1119 documented).
+    """
+    spools_updated = 0
+    for slot_key, start in tray_remain_start.items():
+        try:
+            ams_id_str, tray_id_str = slot_key.split("-", 1)
+            ams_id, tray_id = int(ams_id_str), int(tray_id_str)
+        except (ValueError, AttributeError):
+            continue
+
+        # Skip slots already handled by the 3MF path. Encoding mirrors
+        # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
+        # its native id (>=128), regular AMS slots are ams_id*4+tray_id.
+        if ams_id == 255:
+            global_tray_id = 254 + tray_id
+        elif ams_id >= 128:
+            global_tray_id = ams_id
+        else:
+            global_tray_id = ams_id * 4 + tray_id
+        if global_tray_id in handled_global_tray_ids:
+            continue
+
+        current = current_lookup.get(slot_key)
+        if not current:
+            logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id)
+            continue
+
+        # Spool swap mid-print — tray_uuid changed. We don't know how much
+        # of the print went to which spool; skip rather than mis-attribute.
+        start_uuid = (start.get("tray_uuid") or "").lower()
+        cur_uuid = (current.get("tray_uuid") or "").lower()
+        if start_uuid and cur_uuid and start_uuid != cur_uuid:
+            logger.info(
+                "[SPOOLMAN] AMS%d-T%d: spool swapped mid-print (uuid changed), skipping remain-delta", ams_id, tray_id
+            )
+            continue
+
+        delta_pct = start["remain"] - current["remain"]
+        if delta_pct <= 0:
+            continue  # No consumption captured at AMS granularity, or refilled
+
+        spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
+        if spool_id is None:
+            logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id)
+            continue
+
+        # Look up the spool's filament reference weight. Use a fresh GET so
+        # we don't depend on a stale cached_spools list. Failure here is
+        # silent-skip rather than fatal — other slots can still be written.
+        try:
+            spool = await client.get_spool(spool_id)
+        except Exception as exc:  # noqa: BLE001
+            logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc)
+            continue
+        filament = spool.get("filament") or {}
+        ref_weight = filament.get("weight")
+        if not ref_weight or ref_weight <= 0:
+            logger.debug(
+                "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta",
+                ams_id,
+                tray_id,
+                spool_id,
+            )
+            continue
+
+        grams_used = round((delta_pct / 100.0) * ref_weight, 2)
+        if grams_used <= 0:
+            continue
+        try:
+            await client.use_spool(spool_id, grams_used)
+        except Exception as exc:  # noqa: BLE001
+            logger.warning(
+                "[SPOOLMAN] AMS%d-T%d: use_spool(%s, %.2fg) failed: %s", ams_id, tray_id, spool_id, grams_used, exc
+            )
+            continue
+
+        spools_updated += 1
+        if slot_colors_out is not None:
+            color = filament.get("color_hex")
+            if color:
+                # No 3MF slot_id for this path — use the AMS slot key so the
+                # colour map can still be inspected by callers if needed.
+                # The archive-colour rewrite (#1494) keys on 3MF slot_ids so
+                # remain-delta-only prints intentionally don't participate
+                # in that rewrite (matches usage_tracker's slot_id=None).
+                slot_colors_out[-(global_tray_id + 1)] = color
+        logger.info(
+            "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
+            archive_id,
+            ams_id,
+            tray_id,
+            grams_used,
+            delta_pct,
+            ref_weight,
+            spool_id,
+        )
+    return spools_updated
+
+
 async def _apply_spool_colors_to_archive(
     db,
     archive_id: int,

+ 436 - 0
backend/tests/unit/test_spoolman_no3mf_remain_fallback.py

@@ -0,0 +1,436 @@
+"""AMS remain%-delta fallback for the no-3MF Spoolman path (#1820).
+
+When a Bambu print starts without leaving a retrievable .gcode.3mf on the
+printer (subtask_name='名称未設定'/'Untitled'), Bambuddy creates a
+fallback archive with no 3MF on disk. Before this fix the Spoolman
+tracking row was never created, so the print silently didn't decrement
+the spool weight. This is the Spoolman mirror of usage_tracker's Path 2
+fallback (already in place for the internal-inventory side).
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.spoolman_tracking import (
+    _snapshot_tray_remain,
+    store_print_data,
+)
+
+
+class TestSnapshotTrayRemain:
+    def test_captures_valid_remain(self):
+        raw = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_uuid": "AAAA", "remain": 75},
+                        {"id": 1, "tray_uuid": "BBBB", "remain": 30},
+                    ],
+                }
+            ]
+        }
+        snap = _snapshot_tray_remain(raw)
+        assert snap == {
+            "0-0": {"remain": 75, "tray_uuid": "AAAA"},
+            "0-1": {"remain": 30, "tray_uuid": "BBBB"},
+        }
+
+    def test_skips_invalid_remain(self):
+        """remain=-1 means the AMS hasn't read the spool; a delta would be
+        meaningless. Skip those slots — usage_tracker does the same
+        (line 309)."""
+        raw = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_uuid": "AAAA", "remain": 75},
+                        {"id": 1, "tray_uuid": "BBBB", "remain": -1},
+                        {"id": 2, "tray_uuid": "CCCC", "remain": 150},
+                    ],
+                }
+            ]
+        }
+        snap = _snapshot_tray_remain(raw)
+        assert set(snap.keys()) == {"0-0"}
+
+    def test_captures_vt_tray(self):
+        """External (VT) spool gets ams_id=255, tray_id=vt_id-254."""
+        raw = {"ams": [], "vt_tray": [{"id": 254, "tray_uuid": "EEEE", "remain": 50}]}
+        snap = _snapshot_tray_remain(raw)
+        assert snap == {"255-0": {"remain": 50, "tray_uuid": "EEEE"}}
+
+    def test_empty_when_no_ams_data(self):
+        assert _snapshot_tray_remain({}) == {}
+
+    def test_handles_missing_uuid(self):
+        raw = {"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]}
+        snap = _snapshot_tray_remain(raw)
+        assert snap == {"0-0": {"remain": 80, "tray_uuid": ""}}
+
+
+class TestStorePrintDataNo3mf:
+    """store_print_data must create an ActivePrintSpoolman row even when
+    no 3MF is available, populating tray_remain_start so report_usage can
+    write a remain-delta at completion (#1820)."""
+
+    @pytest.mark.asyncio
+    async def test_creates_row_with_snapshot_when_no_3mf(self):
+        db = AsyncMock()
+        # No queue lookup for the no-3MF branch — only the DELETE.
+        delete_result = MagicMock()
+        db.execute = AsyncMock(side_effect=[delete_result])
+        db.add = MagicMock()
+        db.commit = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={
+                "ams": [
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA", "remain": 80},
+                            {"id": 1, "tray_uuid": "BBBB", "tag_uid": "22", "tray_type": "PLA", "remain": 20},
+                        ],
+                    }
+                ]
+            }
+        )
+
+        mock_settings = MagicMock()
+        mock_path = MagicMock()
+        mock_path.exists.return_value = False  # no 3MF — the #1820 case
+        mock_settings.base_dir.__truediv__.return_value = mock_path
+
+        with (
+            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+        ):
+            await store_print_data(
+                printer_id=1,
+                archive_id=42,
+                file_path="",  # fallback-archive file_path
+                db=db,
+                printer_manager=printer_manager,
+            )
+
+        db.add.assert_called_once()
+        tracking = db.add.call_args.args[0]
+        assert tracking.filament_usage is None
+        assert tracking.tray_remain_start == {
+            "0-0": {"remain": 80, "tray_uuid": "AAAA"},
+            "0-1": {"remain": 20, "tray_uuid": "BBBB"},
+        }
+
+    @pytest.mark.asyncio
+    async def test_no_row_when_no_3mf_and_no_remain_data(self):
+        """If the AMS has no slot with valid remain either (e.g. printer
+        offline at print start), there's nothing to track. Don't create
+        a row that contributes no value."""
+        db = AsyncMock()
+        db.execute = AsyncMock()
+        db.add = MagicMock()
+        db.commit = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
+        )
+
+        mock_settings = MagicMock()
+        mock_path = MagicMock()
+        mock_path.exists.return_value = False
+        mock_settings.base_dir.__truediv__.return_value = mock_path
+
+        with (
+            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+        ):
+            await store_print_data(
+                printer_id=1,
+                archive_id=42,
+                file_path="",
+                db=db,
+                printer_manager=printer_manager,
+            )
+
+        db.add.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_3mf_path_also_captures_snapshot(self):
+        """The remain snapshot is captured ALWAYS, not just for no-3MF.
+        That lets report_usage fall back per-slot when 3MF coverage is
+        partial — same shape as usage_tracker.on_print_complete which
+        runs Path 1 (3MF) and Path 2 (remain delta) for unhandled slots."""
+        db = AsyncMock()
+        queue_item = SimpleNamespace(ams_mapping=None, plate_id=None)
+        queue_result = MagicMock()
+        queue_result.scalar_one_or_none.return_value = queue_item
+        delete_result = MagicMock()
+        db.execute = AsyncMock(side_effect=[queue_result, delete_result])
+        db.add = MagicMock()
+        db.commit = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "tray_type": "PLA", "remain": 90}]}]}
+        )
+
+        mock_settings = MagicMock()
+        mock_path = MagicMock()
+        mock_path.exists.return_value = True
+        mock_settings.base_dir.__truediv__.return_value = mock_path
+
+        with (
+            patch("backend.app.services.spoolman_tracking.app_settings", mock_settings),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=[{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}],
+            ),
+            patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+            patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}),
+        ):
+            await store_print_data(
+                printer_id=1,
+                archive_id=42,
+                file_path="archives/test.3mf",
+                db=db,
+                printer_manager=printer_manager,
+            )
+
+        db.add.assert_called_once()
+        tracking = db.add.call_args.args[0]
+        assert tracking.filament_usage == [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#000000"}]
+        assert tracking.tray_remain_start == {"0-0": {"remain": 90, "tray_uuid": "AAAA"}}
+
+
+class TestReportUsageRemainDelta:
+    """report_usage must write a per-slot remain-delta when filament_usage
+    is missing (no-3MF print), gated on a resolvable Spoolman spool and a
+    sane current remain%."""
+
+    @pytest.mark.asyncio
+    async def test_remain_delta_writes_to_resolved_spool(self):
+        """Print started at remain=80% on a 1000g filament, finished at 60%.
+        Delta = 20% × 1000g = 200g."""
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=None,
+            ams_trays={"0": {"tray_uuid": "AAAA", "tag_uid": "11", "tray_type": "PLA"}},
+            slot_to_tray=None,
+            tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
+        )
+
+        # Mock db.execute().scalar_one_or_none() -> tracking
+        db = AsyncMock()
+        select_result = MagicMock()
+        select_result.scalar_one_or_none.return_value = tracking
+        db.execute = AsyncMock(return_value=select_result)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0, "color_hex": "00FF00"}})
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                AsyncMock(return_value=7),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=42)
+
+        client.use_spool.assert_awaited_once_with(7, 200.0)
+
+    @pytest.mark.asyncio
+    async def test_remain_delta_skips_swapped_spool(self):
+        """tray_uuid changed between start and completion → user replaced
+        the spool mid-print. We don't know how much went to each side; skip
+        rather than mis-charge."""
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=None,
+            ams_trays={"0": {"tray_uuid": "AAAA"}},
+            slot_to_tray=None,
+            tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
+        )
+
+        db = AsyncMock()
+        select_result = MagicMock()
+        select_result.scalar_one_or_none.return_value = tracking
+        db.execute = AsyncMock(return_value=select_result)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            # tray_uuid changed -> swap detected
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "CCCC", "remain": 50}]}]}
+        )
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                AsyncMock(return_value=7),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=42)
+
+        client.use_spool.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_remain_delta_skips_slot_handled_by_3mf(self):
+        """Mixed coverage: 3MF carried slot 1 (=global tray 0). Remain
+        delta on the same physical slot must not double-charge it."""
+        from backend.app.services.spoolman_tracking import report_usage
+
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 50.0}],
+            ams_trays={"0": {"tray_uuid": "AAAA", "tray_type": "PLA"}},
+            slot_to_tray=None,
+            tray_remain_start={"0-0": {"remain": 80, "tray_uuid": "AAAA"}},
+        )
+
+        db = AsyncMock()
+        select_result = MagicMock()
+        select_result.scalar_one_or_none.return_value = tracking
+        db.execute = AsyncMock(return_value=select_result)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.use_spool = AsyncMock()
+        client.get_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 60}]}]}
+        )
+
+        # Make the 3MF path resolve to a spool too, so it actually writes.
+        async def fake_report_slots(_client, items, *args, **kwargs):
+            for _slot_id, grams in items:
+                if grams > 0:
+                    await _client.use_spool(99, grams)
+            return 1
+
+        with (
+            patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="serial")),
+            patch(
+                "backend.app.services.spoolman_tracking._report_spool_usage_for_slots",
+                AsyncMock(side_effect=fake_report_slots),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await report_usage(printer_id=1, archive_id=42)
+
+        # Only the 3MF path called use_spool. get_spool (remain-delta path)
+        # was never reached because slot 0 was already in the handled set.
+        client.use_spool.assert_awaited_once_with(99, 50.0)
+        client.get_spool.assert_not_called()
+
+
+class TestPartialUsageRemainDelta:
+    """cleanup_tracking → _report_partial_usage must also write the
+    remain-delta for ABORTED no-3MF prints — same shape as the completion
+    path, otherwise aborts of "Untitled" prints stay silent."""
+
+    @pytest.mark.asyncio
+    async def test_aborted_no_3mf_writes_remain_delta(self):
+        """Aborted at remain=70% from start of 90% on a 1000g spool.
+        Delta = 20% × 1000g = 200g — must be written even though no
+        3MF / layer data is available."""
+        from backend.app.services.spoolman_tracking import _report_partial_usage
+
+        tracking = SimpleNamespace(
+            archive_id=99,
+            filament_usage=None,
+            layer_usage=None,
+            filament_properties=None,
+            ams_trays={"0": {"tray_uuid": "AAAA"}},
+            slot_to_tray=None,
+            tray_remain_start={"0-0": {"remain": 90, "tray_uuid": "AAAA"}},
+        )
+
+        client = AsyncMock()
+        client.get_spool = AsyncMock(return_value={"id": 7, "filament": {"weight": 1000.0}})
+        client.use_spool = AsyncMock()
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_uuid": "AAAA", "remain": 70}]}]},
+            layer_num=42,
+            total_layers=100,
+        )
+
+        with (
+            patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+            patch(
+                "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                AsyncMock(return_value=client),
+            ),
+            patch(
+                "backend.app.services.spoolman_tracking._get_printer_serial",
+                AsyncMock(return_value="serial"),
+            ),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                AsyncMock(return_value=7),
+            ),
+            patch("backend.app.services.printer_manager.printer_manager", printer_manager),
+        ):
+            await _report_partial_usage(printer_id=1, tracking=tracking)
+
+        client.use_spool.assert_awaited_once_with(7, 200.0)
+
+
+class _AsyncCtx:
+    """Tiny async-context shim returning a pre-built db mock; mirrors
+    async_session()'s ``async with`` interface."""
+
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *_):
+        return False

Некоторые файлы не были показаны из-за большого количества измененных файлов