Procházet zdrojové kódy

fix(archive): runs of another plate are logged and charged for that plate

maziggy před 6 dny
rodič
revize
12422a7c7e

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ 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
+- **Which plate of a multi-plate file printed** —
+  - Print History logs the filament and cost of the plate that printed. A reprint of another plate of the same file used to log the first plate's estimate.
+  - Filament usage after a Bambuddy restart mid-print is charged for the plate the queue job printed, not for the plate of the archive's first print.
+  - A print started outside Bambuddy records its plate on the archive when the printer reports it, so reprints, usage and Print History use that plate instead of the first one or the whole file.
+  - The archive's thumbnail and details describe the plate Bambuddy sent, also when the printer reports only the file name or names another plate.
 - **Reprints and their archives** —
   - A reprint that completes no longer keeps showing why the previous run of the same archive failed (for example a clogged nozzle).
   - A queued job whose archive was deleted before it started is now archived anew when it prints, on a printer that archives its prints. Before, the print ran with no archive, no start notification and no usage tracking.

+ 7 - 1
backend/app/main.py

@@ -8449,7 +8449,13 @@ async def on_print_complete(printer_id: int, data: dict):
                 _est_full_path = (
                     app_settings.base_dir / archive.file_path if archive.file_path else None
                 )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
-                _est_grams, _est_cost = _plate_scoped_run_estimate(archive, _est_full_path)
+                # The plate that printed, as billing reads it: a reprint of
+                # another plate must not log the archive's first plate.
+                _est_grams, _est_cost = _plate_scoped_run_estimate(
+                    archive,
+                    _est_full_path,
+                    billing_plate_id if billing_plate_id is not None else notify_plate_id,
+                )
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _est_grams,

+ 19 - 0
backend/app/services/archive.py

@@ -1250,6 +1250,14 @@ class ArchiveService:
 
         return duplicates
 
+    @staticmethod
+    def _has_sliced_plate(file_path: Path, plate_number: int) -> bool:
+        try:
+            with zipfile.ZipFile(file_path) as zf:
+                return f"Metadata/plate_{plate_number}.gcode" in zf.namelist()
+        except (OSError, zipfile.BadZipFile):
+            return False
+
     async def archive_print(
         self,
         printer_id: int | None,
@@ -1387,6 +1395,17 @@ class ArchiveService:
             match = re.search(r"plate_(\d+)", filename)
             if match:
                 plate_number = int(match.group(1))
+        if plate_id is None:
+            # A print Bambuddy did not dispatch only names its plate in the
+            # gcode path. Kept as the archive's plate when the file really has
+            # it: some firmware reports the .3mf name there instead, and a user
+            # name like "x_plate_2" must not label a single-plate file.
+            if plate_number is not None and self._has_sliced_plate(dest_file, plate_number):
+                plate_id = plate_number
+        else:
+            # The dispatcher knows the plate; the path may name none, or not
+            # the plate sent (#1166). Without it the parser describes another.
+            plate_number = plate_id
 
         # Parse 3MF metadata
         parser = ThreeMFParser(dest_file, plate_number=plate_number)

+ 18 - 15
backend/app/services/usage_tracker.py

@@ -1404,9 +1404,9 @@ async def _track_from_3mf(
         return []
 
     # The queue item carries both the plate and the dispatched mapping; look it
-    # up at most once. ``.first()`` rather than ``.scalar_one_or_none()``
-    # because a batch dispatches one archive as several queue items, and
-    # raising there would cost the print all of its usage tracking.
+    # up at most once. A batch dispatches one archive as several queue items,
+    # on several printers at once: only this printer's newest is this run.
+    # Completion has already closed it, as cancelled for an aborted print.
     _queue_item_lookup: list = []
 
     async def _dispatch_queue_item():
@@ -1417,7 +1417,9 @@ async def _track_from_3mf(
                 queue_result = await db.execute(
                     select(PrintQueueItem)
                     .where(PrintQueueItem.archive_id == archive_id)
-                    .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
+                    .where(PrintQueueItem.printer_id == printer_id)
+                    .where(PrintQueueItem.status.in_(["printing", "completed", "failed", "cancelled"]))
+                    .order_by(PrintQueueItem.id.desc())
                 )
                 _queue_item_lookup.append(queue_result.scalars().first())
         return _queue_item_lookup[0]
@@ -1425,20 +1427,21 @@ async def _track_from_3mf(
     # The caller's plate_id comes from the in-memory session, which a restart
     # mid-print destroys. Both the archive and the queue item recorded the
     # plate at dispatch — without falling back to them the parser sums every
-    # plate of a multi-plate file and charges the lot to one spool.
+    # plate of a multi-plate file and charges the lot to one spool. The
+    # newest queue item goes first: the archive keeps the plate of its first
+    # print, so a reprint of another plate is only on the queue item.
     if plate_id is None:
-        if archive is not None and archive.plate_id is not None:
+        plate_queue_item = await _dispatch_queue_item()
+        if plate_queue_item is not None and plate_queue_item.plate_id is not None:
+            plate_id = plate_queue_item.plate_id
+            logger.info(
+                "[UsageTracker] 3MF: plate_id=%s recovered from queue item %s",
+                plate_id,
+                plate_queue_item.id,
+            )
+        elif archive is not None and archive.plate_id is not None:
             plate_id = archive.plate_id
             logger.info("[UsageTracker] 3MF: plate_id=%s recovered from archive %s", plate_id, archive_id)
-        else:
-            plate_queue_item = await _dispatch_queue_item()
-            if plate_queue_item is not None and plate_queue_item.plate_id is not None:
-                plate_id = plate_queue_item.plate_id
-                logger.info(
-                    "[UsageTracker] 3MF: plate_id=%s recovered from queue item %s",
-                    plate_id,
-                    plate_queue_item.id,
-                )
 
     filament_usage = extract_filament_usage_from_3mf(file_path, plate_id)
     if not filament_usage and plate_id is not None:

+ 96 - 0
backend/tests/unit/test_archive_print_plate.py

@@ -0,0 +1,96 @@
+"""The archive records which plate of a multi-plate file was printed.
+
+A print Bambuddy did not dispatch names its plate only in the gcode path the
+printer reports; a print Bambuddy did dispatch passes the plate explicitly,
+while the path may name none (some firmware reports the .3mf name there).
+"""
+
+import zipfile
+
+import pytest
+
+from backend.app.core.config import settings
+from backend.app.models.printer import Printer
+from backend.app.services import archive as archive_module
+from backend.app.services.archive import ArchiveService
+
+
+def _three_mf(path, plates):
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        for n in plates:
+            zf.writestr(f"Metadata/plate_{n}.gcode", "; gcode\n")
+    return path
+
+
+@pytest.fixture
+def dirs(tmp_path, monkeypatch):
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    return tmp_path
+
+
+@pytest.fixture
+async def printer_id(db_session):
+    row = Printer(name="X1C-1", ip_address="192.168.0.11", access_code="1234", serial_number="PLATETEST")
+    db_session.add(row)
+    await db_session.commit()
+    return row.id
+
+
+@pytest.fixture
+def parsed_plates(monkeypatch):
+    seen: list = []
+    original = archive_module.ThreeMFParser
+
+    class _Recording(original):
+        def __init__(self, file_path, plate_number=None):
+            seen.append(plate_number)
+            super().__init__(file_path, plate_number=plate_number)
+
+    monkeypatch.setattr(archive_module, "ThreeMFParser", _Recording)
+    return seen
+
+
+@pytest.mark.asyncio
+async def test_plate_from_the_gcode_path_is_stored(db_session, dirs, printer_id):
+    source = _three_mf(dirs / "multi.3mf", [1, 2, 3])
+
+    archive = await ArchiveService(db_session).archive_print(
+        printer_id, source, print_data={"filename": "/data/Metadata/plate_3.gcode"}
+    )
+
+    assert archive.plate_id == 3
+
+
+@pytest.mark.asyncio
+async def test_a_plate_the_file_does_not_have_is_not_stored(db_session, dirs, printer_id):
+    source = _three_mf(dirs / "x_plate_2.gcode.3mf", [1])
+
+    archive = await ArchiveService(db_session).archive_print(
+        printer_id, source, print_data={"filename": "x_plate_2.gcode.3mf"}
+    )
+
+    assert archive.plate_id is None
+
+
+@pytest.mark.asyncio
+async def test_the_given_plate_wins_over_the_path(db_session, dirs, printer_id, parsed_plates):
+    """The dispatcher knows the plate; the path can be stale (#1166)."""
+    source = _three_mf(dirs / "multi.3mf", [1, 2, 3])
+
+    archive = await ArchiveService(db_session).archive_print(
+        printer_id, source, print_data={"filename": "/data/Metadata/plate_3.gcode"}, plate_id=2
+    )
+
+    assert archive.plate_id == 2
+    assert parsed_plates == [2]
+
+
+@pytest.mark.asyncio
+async def test_the_given_plate_is_parsed_when_the_path_names_none(db_session, dirs, printer_id, parsed_plates):
+    source = _three_mf(dirs / "multi.3mf", [1, 2, 3])
+
+    await ArchiveService(db_session).archive_print(printer_id, source, print_data={"filename": "multi.3mf"}, plate_id=3)
+
+    assert parsed_plates == [3]

+ 3 - 2
backend/tests/unit/test_cost_tracking.py

@@ -454,8 +454,9 @@ class TestCostCalculation:
         ams_mapping = [0, 1]
 
         # db returns: archive, assignment1, spool1, assignment2, spool2
-        # ams_mapping is provided, so no queue item lookup is performed
-        db = _mock_db_sequential([archive, assignment1, spool1, assignment2, spool2])
+        # db: archive, queue item for the plate (None), then assignments and
+        # spools; the given ams_mapping skips only the mapping fallback
+        db = _mock_db_sequential([archive, None, assignment1, spool1, assignment2, spool2])
 
         # Two filaments used
         filament_usage = [

+ 101 - 0
backend/tests/unit/test_run_estimate_plate.py

@@ -0,0 +1,101 @@
+"""The print-log estimate covers the plate that printed.
+
+The archive keeps the plate of its first print. A later run of another plate
+of the same multi-plate file, dispatched directly or from the queue, has to be
+estimated for that plate, not for the archive's.
+"""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app import main as main_module
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.services.bambu_ftp import DeleteResult
+
+
+@pytest.fixture(autouse=True)
+def _clean_state():
+    main_module._active_prints.clear()
+    main_module._print_plate_ids.clear()
+    yield
+    main_module._active_prints.clear()
+    main_module._print_plate_ids.clear()
+
+
+async def _complete(test_engine, printer_id: int) -> list:
+    """Run on_print_complete against the test database; return the plates the
+    estimates were asked for (billing first, then the print log)."""
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    asked: list = []
+
+    def _estimate(archive, full_path, plate_id=None):
+        asked.append(plate_id)
+        return None, None
+
+    tasks_before = set(asyncio.all_tasks())
+    with (
+        patch("backend.app.main.async_session", real_db),
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.main._plate_scoped_run_estimate", side_effect=_estimate),
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.clear_3mf_cache"),
+        patch("backend.app.services.bambu_ftp.delete_file_async", new=AsyncMock(return_value=DeleteResult.DELETED)),
+    ):
+        mock_ws.send_print_complete = AsyncMock()
+        mock_ws.broadcast = AsyncMock()
+        mock_relay.on_print_complete = AsyncMock()
+        mock_relay.on_queue_job_completed = AsyncMock()
+        mock_notif.on_print_complete = AsyncMock()
+        mock_plug.on_print_complete = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+        mock_pm.get_current_print_user = MagicMock(return_value=None)
+        mock_pm.clear_current_print_user = MagicMock()
+        mock_pm.set_awaiting_plate_clear = MagicMock()
+
+        await main_module.on_print_complete(
+            printer_id,
+            {"status": "completed", "filename": "multi.gcode.3mf", "subtask_name": "multi", "progress": 100},
+        )
+
+        for task in asyncio.all_tasks() - tasks_before:
+            task.cancel()
+            try:
+                await task
+            except (asyncio.CancelledError, Exception):
+                pass
+    return asked
+
+
+@pytest.mark.asyncio
+async def test_a_direct_print_is_estimated_for_its_plate(test_engine, db_session, printer_factory, archive_factory):
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id, filename="multi.gcode.3mf", plate_id=1, status="printing", with_run=False
+    )
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+    main_module._print_plate_ids[archive.id] = 3
+
+    assert await _complete(test_engine, printer.id) == [3, 3]
+
+
+@pytest.mark.asyncio
+async def test_a_queue_run_is_estimated_for_the_queue_items_plate(
+    test_engine, db_session, printer_factory, archive_factory
+):
+    """After a restart the in-memory plate is gone; the queue item still has it."""
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id, filename="multi.gcode.3mf", plate_id=1, status="printing", with_run=False
+    )
+    db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, plate_id=2, status="printing"))
+    await db_session.commit()
+    main_module._active_prints[(printer.id, "multi.gcode.3mf")] = archive.id
+
+    assert await _complete(test_engine, printer.id) == [2, 2]

+ 9 - 6
backend/tests/unit/test_usage_tracker.py

@@ -817,8 +817,9 @@ class TestTrackFrom3mf:
         assignment = _make_assignment(spool_id=10, ams_id=2, tray_id=1)
         archive = _make_archive(archive_id=50)
 
-        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
-        db = _mock_db_sequential([archive, assignment, spool])
+        # db: archive, queue item for the plate (None), assignment, spool;
+        # the given ams_mapping skips only the mapping fallback
+        db = _mock_db_sequential([archive, None, assignment, spool])
 
         printer_manager = MagicMock()
         printer_manager.get_status.return_value = SimpleNamespace(
@@ -1127,9 +1128,10 @@ class TestTrayChangeSplit:
         assign_b = _make_assignment(spool_id=20, ams_id=0, tray_id=1)
         archive = _make_archive(archive_id=200)
 
-        # No queue_item placeholder: passing ams_mapping bypasses the queue lookup
+        # The queue lookup runs once, for the plate; the given ams_mapping
+        # skips only the mapping fallback
         # at usage_tracker.py:816 (`if not slot_to_tray and archive_id`).
-        db = _mock_db_sequential([archive, assign_a, spool_a, assign_b, spool_b])
+        db = _mock_db_sequential([archive, None, assign_a, spool_a, assign_b, spool_b])
 
         # Slicer mapping says slot 1 -> tray 0; printer actually swapped to tray 1 at layer 30
         printer_manager = MagicMock()
@@ -1852,8 +1854,9 @@ class TestMqttMappingIntegration:
         assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=2)
         archive = _make_archive(archive_id=10)
 
-        # db: archive, assignment, spool (no queue lookup when ams_mapping provided)
-        db = _mock_db_sequential([archive, assignment, spool])
+        # db: archive, queue item for the plate (None), assignment, spool;
+        # the given ams_mapping skips only the mapping fallback
+        db = _mock_db_sequential([archive, None, assignment, spool])
 
         printer_manager = MagicMock()
         printer_manager.get_status.return_value = SimpleNamespace(

+ 137 - 0
backend/tests/unit/test_usage_tracker_restart_recovery.py

@@ -371,6 +371,143 @@ class TestPlateIdRecovery:
 
         assert seen_plate_ids == [2]
 
+    @pytest.mark.asyncio
+    async def test_queue_item_plate_wins_over_the_archive_plate(self):
+        """The archive keeps the plate of its first print; a reprint of plate 3
+        is recorded only on its queue item."""
+        archive = _make_archive(archive_id=312, plate_id=1)
+        queue_item = _make_queue_item(plate_id=3)
+        spool = _make_spool(spool_id=68)
+        assignment = _make_assignment(spool_id=68, ams_id=0, tray_id=3)
+        db = _mock_db_sequential([archive, queue_item, assignment, spool])
+        seen_plate_ids: list = []
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"mapping": [3]},
+            progress=100,
+            layer_num=361,
+            tray_now=255,
+            tray_change_log=[],
+            total_layers=361,
+        )
+
+        with (
+            _settings_patch() as mock_settings,
+            _patched_3mf([{"slot_id": 1, "used_g": 318.82, "type": "ABS", "color": "#808080"}], seen_plate_ids),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            await _track_from_3mf(
+                printer_id=1,
+                archive_id=312,
+                status="completed",
+                print_name="AMS_Rack",
+                handled_trays=set(),
+                printer_manager=printer_manager,
+                db=db,
+                plate_id=None,
+            )
+
+        assert seen_plate_ids == [3]
+
+    @staticmethod
+    async def _archive_on_new_printer(db_session, serial="TESTSERIAL"):
+        from backend.app.models.archive import PrintArchive
+
+        printer = Printer(name=serial, ip_address="192.168.0.10", access_code="1234", serial_number=serial)
+        db_session.add(printer)
+        await db_session.flush()
+        archive = PrintArchive(
+            printer_id=printer.id,
+            filename="multi_plate.3mf",
+            file_path="archives/1/multi_plate.3mf",
+            file_size=1,
+            content_hash="x",
+            plate_id=1,
+            status="completed",
+        )
+        db_session.add(archive)
+        await db_session.flush()
+        return printer.id, archive.id
+
+    @staticmethod
+    async def _plates_parsed(db_session, printer_id, archive_id) -> list:
+        seen_plate_ids: list = []
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={},
+            progress=100,
+            layer_num=10,
+            tray_now=255,
+            last_loaded_tray=255,
+            tray_change_log=[],
+            total_layers=10,
+        )
+        with (
+            _settings_patch() as mock_settings,
+            _patched_3mf([{"slot_id": 1, "used_g": 10.0, "type": "ABS", "color": "#808080"}], seen_plate_ids),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            await _track_from_3mf(
+                printer_id=printer_id,
+                archive_id=archive_id,
+                status="completed",
+                print_name="multi_plate",
+                handled_trays=set(),
+                printer_manager=printer_manager,
+                db=db_session,
+                plate_id=None,
+            )
+        return seen_plate_ids
+
+    @pytest.mark.asyncio
+    async def test_the_newest_queue_item_of_the_archive_is_read(self, db_session):
+        """An archive printed twice from the queue has two finished items; the
+        current run is the newer one."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer_id, archive_id = await self._archive_on_new_printer(db_session)
+        db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
+        db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=3, status="printing"))
+        await db_session.commit()
+
+        assert await self._plates_parsed(db_session, printer_id, archive_id) == [3]
+
+    @pytest.mark.asyncio
+    async def test_another_printers_queue_item_is_not_read(self, db_session):
+        """Plates of one archive can run on several printers at once."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer_id, archive_id = await self._archive_on_new_printer(db_session)
+        other = Printer(name="other", ip_address="192.168.0.12", access_code="1234", serial_number="OTHER")
+        db_session.add(other)
+        await db_session.flush()
+        db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
+        db_session.add(PrintQueueItem(printer_id=other.id, archive_id=archive_id, plate_id=3, status="printing"))
+        await db_session.commit()
+
+        assert await self._plates_parsed(db_session, printer_id, archive_id) == [2]
+
+    @pytest.mark.asyncio
+    async def test_a_cancelled_run_reads_its_own_queue_item(self, db_session):
+        """Completion marks the item cancelled before usage is tracked."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer_id, archive_id = await self._archive_on_new_printer(db_session)
+        db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=2, status="completed"))
+        db_session.add(PrintQueueItem(printer_id=printer_id, archive_id=archive_id, plate_id=3, status="cancelled"))
+        await db_session.commit()
+
+        assert await self._plates_parsed(db_session, printer_id, archive_id) == [3]
+
 
 class TestMappingPriority:
     """AMS filament backup rewrites the printer's live ``mapping`` field to the