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

fix(queue): a print is charged with its own plate and AMS mapping (a fresh dispatch replaces a never-started one's, a failed send leaves a running plate's, completion drains the mapping)

maziggy 3 дней назад
Родитель
Сommit
3b7c287f68

+ 4 - 0
CHANGELOG.md

@@ -164,6 +164,10 @@ 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.
 - **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
 ### Fixed
+- **A print's filament and cost are charged with its own plate and AMS mapping** — Bambuddy remembers per archive which plate and AMS slots a dispatch uses, for the usage and cost charged when the print ends. Three cases used the wrong ones:
+  - **A whole file queued after one plate of it** was sent but never started (refused, or the printer stayed idle) was charged as that plate, with its slots.
+  - **A plate that failed to send** wiped the plate number and slots of another plate of the same archive still printing, whose end then charged the whole file.
+  - **A reprint dispatched without a slot mapping** could be charged through the previous run's slots, when that run's printer had reported its own mapping.
 - **A timelapse goes to the print it belongs to** — Several cases gave an archive the wrong video, or none:
 - **A timelapse goes to the print it belongs to** — Several cases gave an archive the wrong video, or none:
   - **The next plate or a reprint started meanwhile:** the printer's timelapse is looked for for some minutes after a print ends. When the same archive started printing again in that time, the previous print's video went to the new print, and the new print's own video was then never imported. The search now stops once the archive is printing again, and nothing is attached or deleted from the printer.
   - **The next plate or a reprint started meanwhile:** the printer's timelapse is looked for for some minutes after a print ends. When the same archive started printing again in that time, the previous print's video went to the new print, and the new print's own video was then never imported. The search now stops once the archive is printing again, and nothing is attached or deleted from the printer.
   - **Scan for Timelapse on an older archive** (from before the video list was recorded at print start) could pick a video another archive already has, for instance by a short name like "1" that every video's timestamp contains. Those are now left out of the automatic match, and are still offered for picking by hand.
   - **Scan for Timelapse on an older archive** (from before the video list was recorded at print start) could pick a video another archive already has, for instance by a short name like "1" that every video's timestamp contains. Those are now left out of the automatic match, and are still offered for picking by hand.

+ 22 - 8
backend/app/main.py

@@ -1257,16 +1257,25 @@ def register_expected_print(
         base = filename[:-4]
         base = filename[:-4]
         _expected_prints[(printer_id, base)] = archive_id
         _expected_prints[(printer_id, base)] = archive_id
         _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
         _expected_prints[(printer_id, f"{base}.gcode")] = archive_id
-    # Store AMS mapping for usage tracking at print completion
+    # Store AMS mapping for usage tracking at print completion, and plate_id for
+    # a single-plate dispatch from a multi-plate 3MF — without it, the direct-Print
+    # path attributes the whole file's filament total to the spool instead of just
+    # the printed plate (#1697). They describe this dispatch: one that has none
+    # drops what an earlier dispatch of the archive left (sent, never started),
+    # unless a plate of the archive is printing now and still needs them.
+    archive_printing = archive_id in set(_active_prints.values())
     if ams_mapping is not None:
     if ams_mapping is not None:
         _print_ams_mappings[archive_id] = ams_mapping
         _print_ams_mappings[archive_id] = ams_mapping
+    elif not archive_printing:
+        _print_ams_mappings.pop(archive_id, None)
     if cost_center_id is not None:
     if cost_center_id is not None:
         _print_cost_center_ids[archive_id] = cost_center_id
         _print_cost_center_ids[archive_id] = cost_center_id
-    # Store plate_id for usage tracking when this is a single-plate dispatch from
-    # a multi-plate 3MF — without this, the direct-Print path attributes the whole
-    # file's filament total to the spool instead of just the printed plate (#1697).
+    elif not archive_printing:
+        _print_cost_center_ids.pop(archive_id, None)
     if plate_id is not None:
     if plate_id is not None:
         _print_plate_ids[archive_id] = plate_id
         _print_plate_ids[archive_id] = plate_id
+    elif not archive_printing:
+        _print_plate_ids.pop(archive_id, None)
     # Store created_by_id so the user start email can be sent even when the archive
     # Store created_by_id so the user start email can be sent even when the archive
     # itself has no created_by_id (e.g. library-file-based queue prints)
     # itself has no created_by_id (e.g. library-file-based queue prints)
     if created_by_id is not None:
     if created_by_id is not None:
@@ -1319,7 +1328,9 @@ def unregister_expected_print(printer_id: int, filename: str, archive_id: int) -
         _expected_print_creators.pop(key, None)
         _expected_print_creators.pop(key, None)
         _expected_print_registered_at.pop(key, None)
         _expected_print_registered_at.pop(key, None)
 
 
-    if archive_id not in set(_expected_prints.values()):
+    # Still needed by another expected print of the archive, or a plate of it
+    # that is printing, as in the sweep.
+    if archive_id not in set(_expected_prints.values()) | set(_active_prints.values()):
         _print_ams_mappings.pop(archive_id, None)
         _print_ams_mappings.pop(archive_id, None)
         _print_cost_center_ids.pop(archive_id, None)
         _print_cost_center_ids.pop(archive_id, None)
         _print_plate_ids.pop(archive_id, None)
         _print_plate_ids.pop(archive_id, None)
@@ -8371,9 +8382,12 @@ async def on_print_complete(printer_id: int, data: dict):
     usage_results: list[dict] = []
     usage_results: list[dict] = []
     # Prefer ams_mapping captured from MQTT request topic (works for all print sources)
     # Prefer ams_mapping captured from MQTT request topic (works for all print sources)
     stored_ams_mapping = data.get("ams_mapping")
     stored_ams_mapping = data.get("ams_mapping")
-    # Fallback to _print_ams_mappings for queue/reprint (set before print starts)
-    if not stored_ams_mapping and archive_id:
-        stored_ams_mapping = _print_ams_mappings.pop(archive_id, None)
+    # Fallback to _print_ams_mappings for queue/reprint (set before print starts).
+    # Drained either way, like the plate below: left over, it would be read by
+    # the archive's next run, dispatched without a mapping.
+    registered_ams_mapping = _print_ams_mappings.pop(archive_id, None) if archive_id else None
+    if not stored_ams_mapping:
+        stored_ams_mapping = registered_ams_mapping
 
 
     # Always drain the plate_id register on completion — the session already
     # Always drain the plate_id register on completion — the session already
     # consumed it at print-start injection; leaving it would leak into the next
     # consumed it at print-start injection; leaving it would leak into the next

+ 20 - 2
backend/tests/integration/test_print_complete_end_to_end.py

@@ -87,7 +87,7 @@ async def queued_print(db_session, printer_factory):
     return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
     return {"printer": printer.id, "user": user.id, "center": center.id, "archive": archive.id, "item": item.id}
 
 
 
 
-async def _complete(test_engine, printer_id: int, status: str, name: str = "part") -> None:
+async def _complete(test_engine, printer_id: int, status: str, name: str = "part", extra: dict | None = None) -> None:
     maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
     tasks_before = set(asyncio.all_tasks())
     tasks_before = set(asyncio.all_tasks())
     with (
     with (
@@ -110,7 +110,13 @@ async def _complete(test_engine, printer_id: int, status: str, name: str = "part
         plug.on_print_complete = AsyncMock()
         plug.on_print_complete = AsyncMock()
         await main_module.on_print_complete(
         await main_module.on_print_complete(
             printer_id,
             printer_id,
-            {"status": status, "filename": f"{name}.gcode", "subtask_name": name, "timelapse_was_active": False},
+            {
+                "status": status,
+                "filename": f"{name}.gcode",
+                "subtask_name": name,
+                "timelapse_was_active": False,
+                **(extra or {}),
+            },
         )
         )
         spawned = asyncio.all_tasks() - tasks_before
         spawned = asyncio.all_tasks() - tasks_before
         if spawned:
         if spawned:
@@ -325,3 +331,15 @@ async def test_the_layer_timelapse_is_attached_for_the_run_that_ended(test_engin
     attach.assert_awaited_once()
     attach.assert_awaited_once()
     archive_id, path, _plate, run_started_at = attach.await_args.args
     archive_id, path, _plate, run_started_at = attach.await_args.args
     assert (archive_id, path, run_started_at) == (queued_print["archive"], stitched, datetime(2026, 10, 19, 8, 0))
     assert (archive_id, path, run_started_at) == (queued_print["archive"], stitched, datetime(2026, 10, 19, 8, 0))
+
+
+async def test_the_registered_mapping_is_drained_when_the_printer_reported_one(test_engine, db_session, queued_print):
+    """PAM/I3: left behind, the archive's next run (sent without a mapping)
+    would charge its usage through this run's trays."""
+    main_module._print_ams_mappings[queued_print["archive"]] = [4]
+    try:
+        await _complete(test_engine, queued_print["printer"], "completed", extra={"ams_mapping": [0]})
+
+        assert queued_print["archive"] not in main_module._print_ams_mappings
+    finally:
+        main_module._print_ams_mappings.pop(queued_print["archive"], None)

+ 49 - 0
backend/tests/unit/test_expected_print_rollback.py

@@ -30,6 +30,7 @@ def expected_print_tables():
         "_print_ams_mappings",
         "_print_ams_mappings",
         "_print_plate_ids",
         "_print_plate_ids",
         "_print_cost_center_ids",
         "_print_cost_center_ids",
+        "_active_prints",
     )
     )
     saved = {n: dict(getattr(main, n)) for n in names}
     saved = {n: dict(getattr(main, n)) for n in names}
     for n in names:
     for n in names:
@@ -265,3 +266,51 @@ def test_rollback_entries_are_per_item(expected_print_tables):
 
 
     assert (1, "a.3mf") not in main._expected_prints
     assert (1, "a.3mf") not in main._expected_prints
     assert main._expected_prints[(2, "b.3mf")] == 2
     assert main._expected_prints[(2, "b.3mf")] == 2
+
+
+# ---------------------------------------------------------------------------
+# The archive-keyed tables follow the newest dispatch of the archive
+# ---------------------------------------------------------------------------
+
+
+def test_a_fresh_dispatch_without_a_plate_drops_the_previous_plate(expected_print_tables):
+    """EP-6: plate 2 was sent but never started; the whole file is queued next.
+    Its start and completion must not read plate 2 for usage and cost."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3], plate_id=2, cost_center_id=5)
+    main.register_expected_print(1, "widget.3mf", 298)
+
+    assert 298 not in main._print_plate_ids
+    assert 298 not in main._print_ams_mappings
+    assert 298 not in main._print_cost_center_ids
+
+
+def test_but_not_while_a_plate_of_the_archive_is_printing(expected_print_tables):
+    """Plate 1 runs; dropping its entries would leave its completion without them."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3], plate_id=1)
+    main._active_prints[(1, "widget.3mf")] = 298
+    main.register_expected_print(2, "widget.3mf", 298)
+
+    assert main._print_plate_ids.get(298) == 1
+    assert main._print_ams_mappings.get(298) == [3]
+
+
+def test_a_failed_send_leaves_a_running_plate_s_entries(expected_print_tables):
+    """EP-4: plate 1 runs on printer 1, plate 2's send to printer 2 fails."""
+    main = expected_print_tables
+
+    main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1, cost_center_id=5)
+    # Plate 1 started: its expectation became an active print
+    for key in [k for k in main._expected_prints if k[0] == 1]:
+        del main._expected_prints[key]
+    main._active_prints[(1, "plate1.3mf")] = 298
+    main.register_expected_print(2, "plate2.3mf", 298, ams_mapping=[3], plate_id=1, cost_center_id=5)
+
+    main.unregister_expected_print(2, "plate2.3mf", 298)
+
+    assert main._print_ams_mappings.get(298) == [3]
+    assert main._print_plate_ids.get(298) == 1
+    assert main._print_cost_center_ids.get(298) == 5