Procházet zdrojové kódy

deleted. Nothing would have been charged twice, but that printer would have looked mid-print to later spool archiving. It's closed using the "completion in progress"

maziggy před 2 dny
rodič
revize
005b34af6d

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Spoolman: a print whose 3MF arrived late is charged from the 3MF** — A print that started before Bambuddy could fetch its 3MF (an "Untitled" print, a printer briefly unreachable) gets an empty archive that is filled in once the 3MF turns up. In Spoolman mode its filament was still charged from the AMS remaining-percentage change — in steps of 10 g on a 1 kg spool, nothing for a spool without a percentage, and nothing at all when no slot reported one at the start. The recovered 3MF's usage is now used, as the built-in inventory already did.
 - **Spoolman inventory: Cost/kg shows the price prints are charged** — A Spoolman spool priced only through its filament (the spool itself has no price, or a price of 0) showed "-" or "0.00" in the Inventory's Cost/kg column and no price on the SpoolBuddy spool card, although every print off it was charged that filament price. Both now show it, the column marked "(filament price)"; the spool's own price still wins, and editing the spool does not copy the filament's price onto it.
 - **Spoolman AMS syncs keep a spool assigned by hand, and say what they changed** — Three cases:
   - **A spool assigned to an AMS slot by hand** while the automatic Spoolman sync was waiting for Spoolman could be replaced by the spool the sync had read before. The syncs now change only the slots whose spool they found different, and only while the slot still holds what they read.

+ 21 - 0
backend/app/main.py

@@ -3877,6 +3877,27 @@ async def _recover_fallback_archive_locked(
             source_3mf,
             recovered.file_size,
         )
+        # Spoolman's tracking stored no 3MF usage at print start and would
+        # charge the AMS remain% change; the built-in tracker reads the archive
+        # at completion and needs nothing. Also at the print's end (``price``
+        # False): that recovery runs inside the close-out, before the charge.
+        try:
+            from backend.app.services.spoolman_tracking import attach_recovered_3mf
+            from backend.app.services.usage_tracker import is_completing
+
+            await attach_recovered_3mf(
+                printer_id,
+                recovered.id,
+                recovered.file_path,
+                db,
+                printer_manager,
+                plate_id=_get_start_plate_id(recovered.id) or recovered.plate_id,
+                may_start=not price or (recovered.status == "printing" and not is_completing(printer_id)),
+            )
+        except Exception:
+            logger.warning(
+                "[RECOVER] Archive %s: could not add the 3MF to Spoolman's usage tracking", archive_id, exc_info=True
+            )
         if price and recovered.status == "printing":
             # Print start priced it from the default rate, having no 3MF to
             # estimate from; now it has one. An ended print is left alone: its

+ 91 - 14
backend/app/services/spoolman_tracking.py

@@ -416,6 +416,96 @@ def _snapshot_tray_remain(raw_data: dict, skipped_out: list[str] | None = None)
     return snapshot
 
 
+def _extract_3mf_tracking(full_path, plate_id: int | None) -> tuple[list | None, dict | None, dict | None]:
+    """What Spoolman's tracking reads from a print's 3MF: the per-filament
+    grams, the per-layer usage (JSON keys) and the filament properties."""
+    from backend.app.utils.threemf_tools import (
+        extract_filament_properties_from_3mf,
+        extract_filament_usage_from_3mf,
+        extract_layer_filament_usage_from_3mf,
+    )
+
+    filament_usage = extract_filament_usage_from_3mf(full_path, plate_id) or None
+    layer_usage_json: dict | None = None
+    layer_usage = extract_layer_filament_usage_from_3mf(full_path, plate_id)
+    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))
+    return filament_usage, layer_usage_json, extract_filament_properties_from_3mf(full_path)
+
+
+async def attach_recovered_3mf(
+    printer_id: int,
+    archive_id: int,
+    file_path: str,
+    db,
+    printer_manager,
+    *,
+    plate_id: int | None = None,
+    may_start: bool = True,
+) -> None:
+    """Add a fallback archive's recovered 3MF to Spoolman's tracking of its print.
+
+    Print start found no 3MF, so it stored only the AMS remain% snapshot and
+    completion would charge the remain% change. Only the 3MF's own data is
+    added: the snapshot was taken at print start, and replacing it from the
+    mid-print state now would charge the wrong change for a slot the 3MF does
+    not cover. Usage already tracked is left alone.
+
+    When print start stored nothing (no slot had a remain%), the 3MF is
+    tracked on its own if ``may_start``: from the recovery inside the print's
+    close-out, which runs before the charge, or from any other while the print
+    runs and is not being closed. During a close-out another recovery's row
+    could follow the charge that already deleted the print's tracking, and be
+    left behind.
+    """
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+
+    spoolman_enabled = await get_setting(db, "spoolman_enabled")
+    if not spoolman_enabled or spoolman_enabled.lower() != "true":
+        return
+    full_path = (
+        app_settings.base_dir / file_path
+    )  # SEC-PATH-OK: file_path is archive.file_path — DB-stored, internally generated
+    if not file_path or not full_path.exists():
+        return
+
+    row = (
+        await db.execute(
+            select(ActivePrintSpoolman)
+            .where(ActivePrintSpoolman.printer_id == printer_id)
+            .where(ActivePrintSpoolman.archive_id == archive_id)
+        )
+    ).scalar_one_or_none()
+    if row is not None and row.filament_usage:
+        return
+    if row is None and not may_start:
+        return
+
+    filament_usage, layer_usage_json, filament_properties = _extract_3mf_tracking(full_path, plate_id)
+    if not filament_usage:
+        return
+    if row is None:
+        state = printer_manager.get_status(printer_id)
+        row = ActivePrintSpoolman(
+            printer_id=printer_id,
+            archive_id=archive_id,
+            ams_trays=build_ams_tray_lookup(state.raw_data) if state and state.raw_data else {},
+        )
+        db.add(row)
+    row.filament_usage = filament_usage
+    row.layer_usage = layer_usage_json
+    row.filament_properties = filament_properties
+    await db.commit()
+    logger.info(
+        "[SPOOLMAN] Archive %s: tracking now has the recovered 3MF's usage (%d filament(s))",
+        archive_id,
+        len(filament_usage),
+    )
+
+
 async def store_print_data(
     printer_id: int,
     archive_id: int,
@@ -442,11 +532,6 @@ async def store_print_data(
     from backend.app.api.routes.settings import get_setting
     from backend.app.models.active_print_spoolman import ActivePrintSpoolman
     from backend.app.models.print_queue import PrintQueueItem
-    from backend.app.utils.threemf_tools import (
-        extract_filament_properties_from_3mf,
-        extract_filament_usage_from_3mf,
-        extract_layer_filament_usage_from_3mf,
-    )
 
     # Check if Spoolman is enabled
     spoolman_enabled = await get_setting(db, "spoolman_enabled")
@@ -505,15 +590,7 @@ async def store_print_data(
         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, effective_plate_id)
-        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)
+        filament_usage, layer_usage_json, filament_properties = _extract_3mf_tracking(full_path, effective_plate_id)
     else:
         # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
         # was never on the printer's FTP. Logged at debug since the

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

@@ -285,6 +285,11 @@ def done_completing(printer_id: int) -> None:
     _completing.discard(printer_id)
 
 
+def is_completing(printer_id: int) -> bool:
+    """The printer's finished print is being closed out (on_print_complete runs)."""
+    return printer_id in _completing
+
+
 def printer_is_charging(printer_id: int) -> bool:
     """A print's usage is still to be charged: it runs, or it ended and its
     completion has not booked it yet. The AMS remain% sync leaves the

+ 165 - 0
backend/tests/unit/test_spoolman_recovered_3mf_tracking.py

@@ -0,0 +1,165 @@
+"""A recovered 3MF reaches Spoolman's usage tracking (late-3MF item).
+
+A print that started without its 3MF gets a fallback archive, and Spoolman's
+print-start tracking stores no per-filament usage for it: completion charges
+the AMS remain% change instead, in 10 g steps on a 1 kg spool, nothing for a
+slot without a remain%, and nothing at all when no slot had one. When the 3MF
+turned up later, the recovery filled the archive in but left Spoolman's
+tracking as it was; the built-in inventory reads the archive at completion and
+was not affected. The recovered 3MF's usage is now added to it.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.active_print_spoolman import ActivePrintSpoolman
+from backend.app.models.settings import Settings
+from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
+
+pytestmark = pytest.mark.asyncio
+
+USED = [{"slot_id": 1, "used_g": 42.5, "type": "PLA", "color": "#00AE42"}]
+REMAIN_AT_START = {"0:0": {"remain": 80, "tray_uuid": "A" * 32}}
+
+
+async def _with(maker, *, spoolman: bool = True, row: dict | None = None, printer_id: int, archive_id: int) -> None:
+    async with maker() as db:
+        if spoolman:
+            db.add(Settings(key="spoolman_enabled", value="true"))
+        if row is not None:
+            db.add(ActivePrintSpoolman(printer_id=printer_id, archive_id=archive_id, ams_trays={}, **row))
+        await db.commit()
+
+
+async def _recover(maker, tmp_path, printer_id: int, archive_id: int, *, price: bool = True) -> None:
+    from backend.app import main as main_module
+
+    source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = SimpleNamespace(
+        raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 71, "tray_type": "PLA"}]}]}, tray_now=0
+    )
+    with (
+        patch.object(main_module, "async_session", maker),
+        patch.object(main_module, "printer_manager", printer_manager),
+    ):
+        await main_module._recover_fallback_archive_locked(archive_id, source, printer_id, price=price)
+
+
+async def _row(maker, archive_id: int) -> ActivePrintSpoolman | None:
+    async with maker() as db:
+        return (
+            await db.execute(select(ActivePrintSpoolman).where(ActivePrintSpoolman.archive_id == archive_id))
+        ).scalar_one_or_none()
+
+
+@pytest.fixture
+async def seeded(test_engine, tmp_path):
+    from backend.app import main as main_module
+
+    maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
+    with patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True):
+        yield maker, printer_id, archive_id
+
+
+async def test_the_recovered_usage_is_added_and_the_start_snapshot_kept(seeded, tmp_path):
+    maker, printer_id, archive_id = seeded
+    await _with(
+        maker,
+        printer_id=printer_id,
+        archive_id=archive_id,
+        row={"tray_remain_start": REMAIN_AT_START, "tray_now_at_start": 0, "slot_to_tray": [0]},
+    )
+
+    await _recover(maker, tmp_path, printer_id, archive_id)
+
+    row = await _row(maker, archive_id)
+    assert row.filament_usage == USED
+    # Taken at print start; the recovery is mid-print, so it must not replace them.
+    assert (row.tray_remain_start, row.tray_now_at_start, row.slot_to_tray, row.ams_trays) == (
+        REMAIN_AT_START,
+        0,
+        [0],
+        {},
+    )
+
+
+async def test_so_does_the_recovery_at_the_print_s_end(seeded, tmp_path):
+    """It runs just before Spoolman is charged, so the charge reads it."""
+    maker, printer_id, archive_id = seeded
+    await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"tray_remain_start": REMAIN_AT_START})
+
+    await _recover(maker, tmp_path, printer_id, archive_id, price=False)
+
+    assert (await _row(maker, archive_id)).filament_usage == USED
+
+
+async def test_usage_already_tracked_is_left_alone(seeded, tmp_path):
+    maker, printer_id, archive_id = seeded
+    own = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFFFFF"}]
+    await _with(maker, printer_id=printer_id, archive_id=archive_id, row={"filament_usage": own})
+
+    await _recover(maker, tmp_path, printer_id, archive_id)
+
+    assert (await _row(maker, archive_id)).filament_usage == own
+
+
+async def test_a_print_tracked_with_nothing_at_its_start_is_tracked_now(seeded, tmp_path):
+    """No slot had a remain% at start, so nothing was stored and the print would
+    be charged nothing. The 3MF is all there is; no start snapshot is invented."""
+    maker, printer_id, archive_id = seeded
+    await _with(maker, printer_id=printer_id, archive_id=archive_id)
+
+    await _recover(maker, tmp_path, printer_id, archive_id)
+
+    row = await _row(maker, archive_id)
+    assert row.filament_usage == USED
+    assert row.tray_remain_start is None
+    assert row.tray_now_at_start is None
+
+
+async def test_the_recovery_at_the_print_s_end_tracks_it_too(seeded, tmp_path):
+    """It runs inside the close-out (marked as such), before the charge, so the
+    charge reads it."""
+    from backend.app.services import usage_tracker
+
+    maker, printer_id, archive_id = seeded
+    await _with(maker, printer_id=printer_id, archive_id=archive_id)
+    usage_tracker.mark_completing(printer_id)
+    try:
+        await _recover(maker, tmp_path, printer_id, archive_id, price=False)
+    finally:
+        usage_tracker.done_completing(printer_id)
+
+    assert (await _row(maker, archive_id)).filament_usage == USED
+
+
+async def test_no_other_recovery_starts_tracking_while_the_print_is_closed(seeded, tmp_path):
+    """The cover endpoint's download can land while the print is being closed:
+    tracking made then could follow the charge that already deleted it, and be
+    left behind, making the printer look mid-print to later spool archiving."""
+    from backend.app.services import usage_tracker
+
+    maker, printer_id, archive_id = seeded
+    await _with(maker, printer_id=printer_id, archive_id=archive_id)
+    usage_tracker.mark_completing(printer_id)
+    try:
+        await _recover(maker, tmp_path, printer_id, archive_id)
+    finally:
+        usage_tracker.done_completing(printer_id)
+
+    assert await _row(maker, archive_id) is None
+
+
+async def test_nothing_without_spoolman(seeded, tmp_path):
+    maker, printer_id, archive_id = seeded
+    await _with(maker, spoolman=False, printer_id=printer_id, archive_id=archive_id)
+
+    await _recover(maker, tmp_path, printer_id, archive_id)
+
+    assert await _row(maker, archive_id) is None