1
0

9 Коммиты 461c3f045d ... 5afb485c12

Автор SHA1 Сообщение Дата
  maziggy 5afb485c12 fix(inventory): a finished print is not charged a second time by an AMS update while its usage is booked; a completion reported without any file name closes the print session instead of keeping the AMS weight sync off until the next print 3 дней назад
  maziggy 5b34997dfa fix(inventory): hand assign, unassign and add-from-slot wait for the printer's slot lock; the AMS remain sync and a print's usage no longer overwrite a weight set by hand or weighed meanwhile; a spool deleted while a print is charged is skipped 3 дней назад
  maziggy 5edbe7586d fix(inventory): deleting a spool frees and announces its slots; scale, AMS and manual weight syncs and a finished print's usage refresh other tabs and the printer cards; a deleted printer's spool assignments go with it on SQLite; a pre-assigned slot the printer could not be told about is retried; CSV last_used is stored in UTC 3 дней назад
  maziggy a6a4b7648c fix(queue): an override counts an equivalent filament type in its colour; preheat and keep-warm heat the bed for the plate the job prints; queue colour names and the colour name saved with an override follow the filament's product line 3 дней назад
  maziggy 2b5cd87c7f fix(queue): pipeline cancel stops the preheat and frees the budget; an offline stop frees the budget; the SD cleanup at a print's end is bounded to 45 s; a refused print start is logged and retried at once without a reconnect 3 дней назад
  maziggy 918aa26ffa fix(queue): models matched without regard to case when adding jobs and in pipelines, stored in the printers' spelling; webhook reprints reuse the slicer's AMS slots; a multi-plate file queued without a plate stores the plate that prints 3 дней назад
  maziggy abc971a718 fix(queue): a job edited during a scheduler round waits for the next one instead of being dispatched or mapped from the stale copy; the claim requires the selected printer; bulk edit rechecks the claim; no transaction held through a power-on wait 3 дней назад
  maziggy 3b7c287f68 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) 3 дней назад
  maziggy b342124f0e fix(timelapse): a video goes to the run it belongs to (scan stops on a newer run, claimed videos left out of matching, camera video replaces the printer's without orphaning it); every timelapse change refreshes open Archives pages 3 дней назад
66 измененных файлов с 3368 добавлено и 338 удалено
  1. 0 8
      .basedpyright/baseline.json
  2. 35 0
      CHANGELOG.md
  3. 16 7
      backend/app/api/routes/archives.py
  4. 67 53
      backend/app/api/routes/inventory.py
  5. 23 13
      backend/app/api/routes/library.py
  6. 21 3
      backend/app/api/routes/pipeline_runs.py
  7. 109 51
      backend/app/api/routes/print_queue.py
  8. 2 0
      backend/app/api/routes/printers.py
  9. 2 0
      backend/app/api/routes/spoolbuddy.py
  10. 3 1
      backend/app/api/routes/webhook.py
  11. 2 0
      backend/app/core/sqlite_fk.py
  12. 220 77
      backend/app/main.py
  13. 38 0
      backend/app/services/ams_assignment_lock.py
  14. 15 33
      backend/app/services/archive.py
  15. 27 4
      backend/app/services/bambu_mqtt.py
  16. 11 2
      backend/app/services/pipeline_eligibility.py
  17. 98 12
      backend/app/services/print_scheduler.py
  18. 9 2
      backend/app/services/spool_csv.py
  19. 63 7
      backend/app/services/usage_tracker.py
  20. 74 3
      backend/app/utils/threemf_tools.py
  21. 73 3
      backend/tests/integration/test_ams_remain_sync_while_printing.py
  22. 107 0
      backend/tests/integration/test_assignment_routes_take_the_slot_lock.py
  23. 58 0
      backend/tests/integration/test_bulk_edit_dispatch_claim.py
  24. 129 0
      backend/tests/integration/test_completion_keeps_printer_charging.py
  25. 61 0
      backend/tests/integration/test_inventory_ams_weight_sync_lock.py
  26. 17 0
      backend/tests/integration/test_inventory_csv.py
  27. 115 0
      backend/tests/integration/test_library_add_to_queue_plate_filament.py
  28. 64 0
      backend/tests/integration/test_pipeline_runs_api.py
  29. 78 0
      backend/tests/integration/test_plate_filament_preset.py
  30. 71 0
      backend/tests/integration/test_preconfig_replay_failure.py
  31. 93 2
      backend/tests/integration/test_print_complete_end_to_end.py
  32. 62 0
      backend/tests/integration/test_queue_target_model_case.py
  33. 28 0
      backend/tests/integration/test_queue_variants_api.py
  34. 23 0
      backend/tests/integration/test_spool_archive_unassigns.py
  35. 19 0
      backend/tests/integration/test_spoolbuddy.py
  36. 24 0
      backend/tests/integration/test_sqlite_dangling_references.py
  37. 43 0
      backend/tests/integration/test_timelapse_changes_announced.py
  38. 42 0
      backend/tests/integration/test_webhook_reprint_slicer_mapping.py
  39. 15 0
      backend/tests/unit/services/test_usage_tracker.py
  40. 14 14
      backend/tests/unit/test_ams_assignment_lock.py
  41. 19 0
      backend/tests/unit/test_archive_filtering.py
  42. 49 0
      backend/tests/unit/test_expected_print_rollback.py
  43. 82 0
      backend/tests/unit/test_layer_timelapse_attach.py
  44. 35 0
      backend/tests/unit/test_override_color_match_equivalent_types.py
  45. 171 0
      backend/tests/unit/test_queued_plate_bed_temperature.py
  46. 74 0
      backend/tests/unit/test_refused_project_file_ack.py
  47. 1 1
      backend/tests/unit/test_scheduler_keep_warm.py
  48. 3 3
      backend/tests/unit/test_scheduler_preheat.py
  49. 2 2
      backend/tests/unit/test_scheduler_preheat_ams_mapping_2886.py
  50. 4 4
      backend/tests/unit/test_scheduler_preheat_fast_path.py
  51. 1 1
      backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py
  52. 196 0
      backend/tests/unit/test_scheduler_snapshot_races.py
  53. 30 0
      backend/tests/unit/test_scheduler_watchdog.py
  54. 15 0
      backend/tests/unit/test_shared_tray_usage_3230.py
  55. 3 2
      backend/tests/unit/test_state_writers.py
  56. 35 2
      backend/tests/unit/test_timelapse_scan_2704.py
  57. 115 0
      backend/tests/unit/test_timelapse_scan_newer_run.py
  58. 231 0
      backend/tests/unit/test_usage_tracker_charge_race.py
  59. 131 0
      frontend/src/__tests__/components/PrintModal.test.tsx
  60. 30 1
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  61. 94 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  62. 11 2
      frontend/src/components/PrintModal/index.tsx
  63. 41 21
      frontend/src/components/PrintModal/useFilamentLabels.ts
  64. 5 1
      frontend/src/hooks/useWebSocket.ts
  65. 17 3
      frontend/src/pages/QueuePage.tsx
  66. 2 0
      frontend/src/types/plates.ts

+ 0 - 8
.basedpyright/baseline.json

@@ -2019,14 +2019,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 88,
-                    "endColumn": 97,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 35 - 0
CHANGELOG.md

@@ -164,6 +164,41 @@ 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
+- **A finished print's filament is charged once** — Two cases in the built-in inventory:
+  - **An AMS update right as a print finished** could raise the spool's used weight from the AMS remain% while the print's exact usage was still being worked out, and that usage was then added on top: the print was charged twice. The AMS sync now waits until the print is charged, as it does while the print runs.
+  - **A print the printer reported finished without any file name** (one that failed while preparing, right after Bambuddy started) kept the AMS weight sync off until the next print started. It now turns the sync back on.
+- **Spool weights and slot assignments written at the same moment no longer undo each other** — Three cases:
+  - **Assigning or unassigning a spool by hand, or adding a slot's spool to the inventory,** while the printer reported an AMS change for the same slot (or on a double-click) could fail with a server error on PostgreSQL. These now wait for the AMS update, as AMS updates already wait for each other.
+  - **A weight set by hand or weighed on the SpoolBuddy scale** just as the AMS remain% sync ran could be overwritten by the AMS figure, with the weight still marked as locked.
+  - **The same weight set at the end of a print** was replaced by the weight from before it plus the print's usage. The usage is now added to the stored weight.
+- **Inventory views stay current, and spools follow their printers and slots** — Five cases:
+  - **Deleting a spool** left it shown in its AMS slot in other open tabs, and **a weight from the SpoolBuddy scale, the automatic AMS sync or "Sync Weights from AMS"** reached other open tabs and the printer cards only when they next refreshed. These changes are now announced like any other inventory change, and the printer cards also show the weight a finished print leaves straight away.
+  - **Deleting a printer** on SQLite (the default database) left its spools assigned to it, shown as loaded on a printer that no longer exists. Their slots are now freed, and assignments left behind by earlier deletes are cleared at the next start.
+  - **A spool assigned to an empty slot from SpoolBuddy** that the printer could not be told about when filament went in (for example while it was reconnecting) was marked done anyway and never configured. It is now tried again on the next AMS change.
+  - **A spool CSV import** stored a `last_used` time with a timezone offset as if it were UTC on SQLite; it is now converted.
+- **Filament matching, preheat temperatures and colour names follow the plate's own filaments** — Three cases:
+  - **An "Any model" job with a filament override** waited forever when the printers held an equivalent filament under another name (PA-CF loaded for a PA12-CF override). The type check already counted it as the same filament, but the colour check that picks the printer did not.
+  - **Preheating a plate of a multi-filament project** heated the bed for the hottest filament of the whole project: a PLA plate in a project that also holds PETG was preheated, and kept warm between jobs, at the PETG temperature. Preheat and keep-warm now heat the bed for the filaments of the plate the job prints.
+  - **Colour names on the queue page** came from the plain filament type, so a colour named differently in a product line (the same black is "Charcoal" in PLA Matte) showed the generic name. The queue page, and the colour name the print dialog saves with a filament override, now name it within the filament's product line, as the print dialog already showed it. Jobs queued before keep the name they were saved with.
+- **Stopping and cancelling leave nothing running or reserved** — Four cases:
+  - **Cancelling a pipeline run** set its queued jobs to cancelled, but a job already preheating went on heating the printer (up to its soak time), and its share of the cost centre's budget stayed reserved. Both now happen as when the job is cancelled from the queue.
+  - **Stopping a job while its printer is offline** left its share of the cost centre's budget reserved until Bambuddy restarted; it is now released at once.
+  - **A printer slow to answer the end-of-print file cleanup** (up to about nine minutes) held up the end of the print so long that the stranded-job check closed the job first, and its auto power-off and its charge were skipped. The cleanup now gives up after 45 seconds and leaves the file on the card.
+  - **A print start the printer refused** is now logged with the printer's reason, and the job goes back to the queue for a retry at once. Bambuddy used to read the refusal as a print it accepted and waited three minutes for it to start.
+- **Queue jobs added through the API reach the printers they should** — Three cases in the API and webhooks:
+  - **A model in lower case** (`"target_model": "x1c"`) was refused with "No active printers for model", although the scheduler matches models without regard to case. Adding (one job, alternatives, or library files), editing and pipelines now match it the same way and store the printers' own spelling (`X1C`); two alternatives for one model in different case are refused as the same printers. A pipeline aimed at a model class in lower case found no printers.
+  - **A reprint through the webhook** (`/webhook/queue/add`) of a print sent through the virtual printer ignored the AMS slots the slicer had picked, so the scheduler matched slots by colour and type again. It now reuses them on the printer they were picked on, as a reprint from the UI does.
+  - **A multi-plate file queued without a plate** (`/library/files/add-to-queue`, `POST /queue/`) prints its default plate, but the scheduler required every plate's filament, so a printer with that plate's filament loaded was never chosen, or the job was held for another plate's filament. The plate that prints is now stored on the job.
+- **Queue edits made while the queue is being worked through are kept** — The scheduler works on the queue as it read it at the start of each round, and a round can take minutes while a smart plug powers a printer on. A job edited in the meantime lost the edit: an AMS mapping set by hand was overwritten, a job moved to another printer (or an "Any model" job pinned to one) could still be sent to the printer the round had picked, and a new plate could be mapped from the old plate's filaments. A job edited during a round now waits for the next one, which reads the edit. A bulk edit likewise leaves a job that started dispatching while the edit was checked, instead of changing its printer or cost centre under the upload. While waiting for a printer to power on, the scheduler no longer keeps a database transaction open.
+- **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:
+  - **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.
+  - **Both the external camera's and the printer's timelapse on:** the archive keeps the external camera's video, as before, but the printer's video it replaces is now deleted instead of staying in the archive folder unused. A camera video stitched for a print whose archive is already printing again (the next plate, a reprint) is no longer attached to the new print.
+- **Archives pages show a timelapse as soon as it changes** — Only the automatic scan told other open pages; a video picked or uploaded by hand, the external camera's, the conversion to MP4 and deleting one showed only after a reload.
 - **An archive cost set through the API, or by Recalculate costs, reaches the statistics** — Setting an archive's cost (`PATCH /api/v1/archives/{id}`) or **Recalculate costs** changed the archive only, while statistics, project totals and batch sums read the cost from the print log, so they kept the old figure. A completed print that had the archive's cost now gets the new one too; a print priced from its own spools, and a failed or cancelled one, keep theirs. **Recalculate costs** still recalculates a cost that was set by hand, as before. A negative cost, or one over 1,000,000, is refused.
 - **A request with `Infinity` or `NaN` in a field that fails validation answers 422** — Such a request ended in a server error, because the error response repeats the value sent and JSON can't hold it.
 - **Editing an archive while it is being reprinted leaves the previous print alone** — Its outcome, failure reason, verdict and filament weight went onto the previous print's log entry, rewriting that print's result in the print log and failure statistics. The edit now changes the archive only.

+ 16 - 7
backend/app/api/routes/archives.py

@@ -2958,6 +2958,9 @@ async def delete_timelapse(
     archive.timelapse_plate_id = None
     await db.commit()
 
+    from backend.app.services.archive import _announce_timelapse
+
+    await _announce_timelapse(archive_id, attached=False)
     return {"status": "deleted"}
 
 
@@ -3053,19 +3056,23 @@ async def scan_timelapse(
     # into a confident wrong answer. Those strategies stay for archives created
     # before the baseline was persisted.
     used_baseline = archive.timelapse_baseline is not None
+    # A video another archive of this printer holds is that print's, whichever
+    # strategy below would pick it (a short name like "1" is in every video's
+    # timestamp). Picking by hand still offers it: that is how a wrong match
+    # is corrected.
+    offered = video_files
+    async with async_session() as db:
+        claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
+    video_files = [f for f in video_files if Path(f.get("name", "")).stem not in claimed]
     if used_baseline:
         baseline = set(archive.timelapse_baseline)
-        async with async_session() as db:
-            claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
-        candidates = [
-            f for f in video_files if f.get("name", "") not in baseline and Path(f.get("name", "")).stem not in claimed
-        ]
+        candidates = [f for f in video_files if f.get("name", "") not in baseline]
         if len(candidates) == 1:
             matching_file = candidates[0]
             logger.info("Matched timelapse by print-start baseline: %s", matching_file.get("name"))
         elif candidates:
             # Ambiguous — offer only the plausible files instead of guessing.
-            video_files = candidates
+            offered = candidates
             logger.info("Baseline left %s unclaimed candidates for archive %s", len(candidates), archive_id)
         else:
             logger.info("Baseline shows no unclaimed new video on the printer for archive %s", archive_id)
@@ -3144,7 +3151,7 @@ async def scan_timelapse(
                 "size": f.get("size"),
                 "mtime": f.get("mtime").isoformat() if f.get("mtime") else None,
             }
-            for f in video_files
+            for f in offered
         ]
         # Sort by mtime descending (most recent first)
         available_files.sort(key=lambda x: x.get("mtime") or "", reverse=True)
@@ -4751,6 +4758,8 @@ async def get_archive_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 67 - 53
backend/app/api/routes/inventory.py

@@ -61,6 +61,7 @@ from backend.app.schemas.supplier import (
     SupplierUpdate,
 )
 from backend.app.services import slot_unlink_grace
+from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.location_service import (
     DUPLICATE_LOCATION_NAME,
@@ -1686,18 +1687,23 @@ async def delete_spool(
     if not spool:
         raise HTTPException(404, "Spool not found")
 
+    freed = await _release_slots(db, [spool.id])
     await db.delete(spool)
     await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"status": "deleted"}
 
 
 async def _release_slots(db: AsyncSession, spool_ids: list[int]) -> list[tuple[int, int, int]]:
-    """Unassign archived spools from their AMS slots; returns the freed slots.
+    """Unassign archived or deleted spools from their AMS slots; returns the
+    freed slots.
 
     Assigning refuses an archived spool and nothing downstream checks the flag,
     so a slot left pointing at one would keep showing it and charge it the next
-    print's usage. The caller commits, then announces the slots.
+    print's usage. A deleted spool's slots go with it, but other tabs refresh
+    their slot views only when the slots are announced. The caller commits,
+    then announces the slots.
     """
     if not spool_ids:
         return []
@@ -1881,11 +1887,13 @@ async def bulk_delete_spools(
     spools = list(result.scalars().all())
     found_ids = {s.id for s in spools}
     not_found = [sid for sid in payload.ids if sid not in found_ids]
+    freed = await _release_slots(db, list(found_ids))
     for spool in spools:
         await db.delete(spool)
     await db.commit()
     if spools:
         await ws_manager.broadcast({"type": "inventory_changed"})
+    await _announce_freed_slots(freed)
     return {"deleted": len(spools), "not_found": not_found}
 
 
@@ -2254,29 +2262,31 @@ async def assign_spool(
                     tray_state = raw_state
                 tray_has_spool = spool_present(tray)
 
-    # 3. Upsert assignment (replace if same printer+ams+tray)
-    existing = await db.execute(
-        select(SpoolAssignment).where(
-            SpoolAssignment.printer_id == data.printer_id,
-            SpoolAssignment.ams_id == data.ams_id,
-            SpoolAssignment.tray_id == data.tray_id,
+    # 3. Upsert assignment (replace if same printer+ams+tray), under the lock
+    # the AMS change handler writes the printer's slots with.
+    async with get_ams_assignment_lock(data.printer_id):
+        existing = await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == data.printer_id,
+                SpoolAssignment.ams_id == data.ams_id,
+                SpoolAssignment.tray_id == data.tray_id,
+            )
         )
-    )
-    old = existing.scalar_one_or_none()
-    if old:
-        await db.delete(old)
-        await db.flush()
-
-    assignment = SpoolAssignment(
-        spool_id=data.spool_id,
-        printer_id=data.printer_id,
-        ams_id=data.ams_id,
-        tray_id=data.tray_id,
-        fingerprint_color=fingerprint_color,
-        fingerprint_type=fingerprint_type,
-    )
-    db.add(assignment)
-    await db.commit()
+        old = existing.scalar_one_or_none()
+        if old:
+            await db.delete(old)
+            await db.flush()
+
+        assignment = SpoolAssignment(
+            spool_id=data.spool_id,
+            printer_id=data.printer_id,
+            ams_id=data.ams_id,
+            tray_id=data.tray_id,
+            fingerprint_color=fingerprint_color,
+            fingerprint_type=fingerprint_type,
+        )
+        db.add(assignment)
+        await db.commit()
     await db.refresh(assignment)
     slot_unlink_grace.forget_slot(data.printer_id, data.ams_id, data.tray_id)
 
@@ -2415,19 +2425,20 @@ async def unassign_spool(
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Unassign a spool from an AMS slot."""
-    result = await db.execute(
-        select(SpoolAssignment).where(
-            SpoolAssignment.printer_id == printer_id,
-            SpoolAssignment.ams_id == ams_id,
-            SpoolAssignment.tray_id == tray_id,
+    async with get_ams_assignment_lock(printer_id):
+        result = await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == printer_id,
+                SpoolAssignment.ams_id == ams_id,
+                SpoolAssignment.tray_id == tray_id,
+            )
         )
-    )
-    assignment = result.scalar_one_or_none()
-    if not assignment:
-        raise HTTPException(404, "Assignment not found")
+        assignment = result.scalar_one_or_none()
+        if not assignment:
+            raise HTTPException(404, "Assignment not found")
 
-    await db.delete(assignment)
-    await db.commit()
+        await db.delete(assignment)
+        await db.commit()
 
     await ws_manager.broadcast(
         {
@@ -2817,7 +2828,7 @@ async def sync_weights_from_ams(
     when it completes, on top of whatever weight the spool has by then.
     """
     from backend.app.services.printer_manager import PrinterManager, printer_manager
-    from backend.app.services.usage_tracker import _active_sessions
+    from backend.app.services.usage_tracker import printer_is_charging
 
     result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
     # Only slots on printers the caller may see (#1727)
@@ -2846,12 +2857,10 @@ async def sync_weights_from_ams(
             skipped += 1
             continue
 
-        # Until completion has charged the print its session stays open, also
-        # after the printer has reported the print finished.
-        if (
-            str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES
-            or assignment.printer_id in _active_sessions
-        ):
+        # Until completion has charged the print the printer counts as printing,
+        # also after it has reported the print finished.
+        printer_state = str(getattr(state, "state", "") or "").upper()
+        if printer_state in PrinterManager.ACTIVE_PRINT_STATES or printer_is_charging(assignment.printer_id):
             logger.info("AMS weight sync: printer %d is printing, skipping spool %d", assignment.printer_id, spool.id)
             skipped += 1
             printing += 1
@@ -2911,6 +2920,8 @@ async def sync_weights_from_ams(
             skipped += 1
 
     await db.commit()
+    if synced:
+        await ws_manager.broadcast({"type": "inventory_changed"})
     return {"synced": synced, "skipped": skipped, "printing": printing}
 
 
@@ -3251,17 +3262,20 @@ async def create_spool_from_slot(
     if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
         raise HTTPException(status_code=400, detail="Slot has no RFID tag")
 
-    spool = await create_spool_from_tray(db, tray)
-    await auto_assign_spool(
-        req.printer_id,
-        req.ams_id,
-        req.tray_id,
-        spool,
-        printer_manager,
-        db,
-        tray_info_idx=tray.get("tray_info_idx", ""),
-    )
-    await db.commit()
+    # Under the slot lock from the first write: on SQLite the spool insert takes
+    # the write lock, which an AMS update holding the slot lock would wait for.
+    async with get_ams_assignment_lock(req.printer_id):
+        spool = await create_spool_from_tray(db, tray)
+        await auto_assign_spool(
+            req.printer_id,
+            req.ams_id,
+            req.tray_id,
+            spool,
+            printer_manager,
+            db,
+            tray_info_idx=tray.get("tray_info_idx", ""),
+        )
+        await db.commit()
     await ws_manager.broadcast({"type": "inventory_changed"})
     await ws_manager.broadcast(
         {

+ 23 - 13
backend/app/api/routes/library.py

@@ -24,7 +24,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
-from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
+from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf, printed_plate_of_multi_plate_file
 from backend.app.core.auth import (
     ApiKeyActor,
     QueueReviewRequired,
@@ -3203,17 +3203,19 @@ async def add_files_to_queue(
     # silently declines when it finds nothing, because there, owning no such
     # printer is the user's situation rather than their mistake -- the file
     # still queues, as the unassigned row it has always been.
-    active_models: set[str] = set()
+    # Keyed without regard to case, as the scheduler matches a model, to the
+    # printers' own spelling, which the items store.
+    active_models: dict[str, str] = {}
     if request.printer_id is None:
-        active_models = {
-            model
-            for (model,) in (
-                await db.execute(select(Printer.model).where(Printer.is_active == True).distinct())  # noqa: E712
-            ).all()
-            if model
-        }
-        if target_model_norm and target_model_norm not in active_models:
-            raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        for (model,) in (
+            await db.execute(select(Printer.model).where(Printer.is_active == True).order_by(Printer.id))  # noqa: E712
+        ).all():
+            if model:
+                active_models.setdefault(model.lower(), model)
+        if target_model_norm:
+            if target_model_norm.lower() not in active_models:
+                raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+            target_model_norm = active_models[target_model_norm.lower()]
 
     # Get all requested files
     result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
@@ -3312,11 +3314,16 @@ async def add_files_to_queue(
                     item_target_model = target_model_norm
                 else:
                     inferred = resolve_variant_model(lib_file)
-                    item_target_model = inferred if inferred in active_models else None
+                    item_target_model = active_models.get(inferred.lower()) if inferred else None
 
             # Filament the scheduler must match before handing a model-based
             # item to hardware. Without it the item goes to whichever printer
             # of that model is idle, whatever is loaded in it.
+            # The plate that prints: added without one, a multi-plate file
+            # prints its default plate. Stored on the item, so the filament a
+            # printer must have, the AMS mapping and the unmatched-filament
+            # check all read that plate rather than every plate.
+            printed_plate = await asyncio.to_thread(printed_plate_of_multi_plate_file, file_path)
             required_filament_types = None
             if item_target_model:
                 # POST /queue/'s own extractor, borrowed rather than
@@ -3328,7 +3335,7 @@ async def add_files_to_queue(
                 # bulk add of a few hundred -- especially from an external
                 # folder on a NAS -- the zip reads add up to a stall the whole
                 # event loop takes, status ingest included.
-                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path)
+                filament_types = await asyncio.to_thread(_extract_filament_types_from_3mf, file_path, printed_plate)
                 if filament_types:
                     required_filament_types = json.dumps(filament_types)
 
@@ -3338,6 +3345,7 @@ async def add_files_to_queue(
                 printer_id=item_printer_id,
                 target_model=item_target_model,
                 required_filament_types=required_filament_types,
+                plate_id=printed_plate,
                 library_file_id=file_id,
                 project_id=lib_file.project_id
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
@@ -3585,6 +3593,8 @@ async def get_library_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 21 - 3
backend/app/api/routes/pipeline_runs.py

@@ -450,7 +450,7 @@ async def _pick_assignments(
         (
             await db.execute(
                 select(Printer)
-                .where(Printer.model == pipeline.target_model_class)
+                .where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
                 .where(Printer.is_active.is_(True))
                 .order_by(Printer.id)
             )
@@ -475,8 +475,9 @@ async def _pick_assignments(
         # ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
         return [(matching[i % len(matching)].id, None) for i in range(copies)]
     # max_parallel — leave printer_id=None, set target_model so the scheduler
-    # picks any free X1C / P1S / … for each item independently.
-    return [(None, pipeline.target_model_class)] * copies
+    # picks any free X1C / P1S / … for each item independently. In the
+    # printers' own spelling: the class is matched without regard to case.
+    return [(None, matching[0].model)] * copies
 
 
 def _make_orchestration_callable(
@@ -998,6 +999,9 @@ async def cancel_run(
     if not run.error_message:
         run.error_message = "Cancelled by user"
 
+    from backend.app.services.finance_budget import release_budget_reservation
+
+    cancelled_entries: list[int] = []
     job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
     for job in job_rows:
         queue_entry = None
@@ -1008,6 +1012,11 @@ async def cancel_run(
             if queue_entry is not None and queue_entry.status in ("pending", "queued"):
                 queue_entry.status = "cancelled"
                 queue_entry.completed_at = datetime.now(timezone.utc)
+                # As a cancel from the queue does: its budget hold is freed
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=queue_entry.id, status="released"
+                )
+                cancelled_entries.append(queue_entry.id)
         if job.status in ("queued", "printing") and queue_entry is None:
             # Dispatched, its entry gone: whether it printed is unknown, and
             # calling it cancelled would make retry print it again.
@@ -1017,6 +1026,15 @@ async def cancel_run(
             job.completed_at = datetime.now(timezone.utc)
 
     await db.commit()
+    if cancelled_entries:
+        from backend.app.services.print_batch import refresh_batches_after
+        from backend.app.services.print_scheduler import scheduler
+
+        await refresh_batches_after(cancelled_entries)
+        # A preheating entry's dispatch is parked in a sleep and cannot see the
+        # status written above (#2727): told, it stops heating the printer.
+        for entry_id in cancelled_entries:
+            scheduler.notify_dispatch_cancelled(entry_id)
     await db.refresh(run)
     await _publish_run_event(db, run)
     return await _materialise_run(db, run)

+ 109 - 51
backend/app/api/routes/print_queue.py

@@ -2,6 +2,7 @@
 
 import json
 import logging
+import re
 import zipfile
 from datetime import datetime, timezone
 from pathlib import Path
@@ -616,6 +617,65 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
     return response
 
 
+def saved_slicer_ams_mapping(archive: PrintArchive | None, printer_id: int | None) -> str | None:
+    """The AMS slots the slicer picked when *archive* printed, as JSON, for a
+    reprint on that same printer; None otherwise.
+
+    Slot numbers mean something only on the printer they were picked on, so a
+    model-based job (no printer) or one for another printer gets none.
+    """
+    if archive is None or not archive.extra_data or printer_id is None:
+        return None
+    saved = archive.extra_data.get("slicer_ams_mapping")
+    if (
+        isinstance(saved, dict)
+        and saved.get("printer_id") == printer_id
+        and isinstance(saved.get("mapping"), list)
+        and saved["mapping"]
+    ):
+        return json.dumps(saved["mapping"])
+    return None
+
+
+def printed_plate_of_multi_plate_file(file_path: Path | None) -> int | None:
+    """The plate a dispatch of *file_path* prints when no plate is named, for
+    a file with more than one sliced plate; None otherwise.
+
+    Stored on a queue job that names none, it makes every later step (the
+    filament a printer must have, the AMS mapping, the unmatched-filament
+    check, charging) read the plate that prints rather than all of them.
+    What prints does not change: the dispatch resolves the same plate.
+    """
+    if file_path is None or not file_path.name.lower().endswith(".3mf") or not file_path.exists():
+        return None
+    try:
+        with zipfile.ZipFile(file_path) as zf:
+            plates = [n for n in zf.namelist() if re.fullmatch(r"Metadata/plate_\d+\.gcode", n)]
+    except (OSError, zipfile.BadZipFile):
+        return None
+    if len(plates) < 2:
+        return None
+    from backend.app.services.print_scheduler import _effective_plate_id
+
+    return _effective_plate_id(None, file_path)
+
+
+async def _active_printer_model(db: AsyncSession, model: str) -> str | None:
+    """The model as an active printer spells it, matched without regard to case
+    as the scheduler matches it ("x1c" finds the X1Cs); None when no active
+    printer is of that model. Stored in that spelling, a job compares equal
+    wherever its model is compared exactly."""
+    return (
+        await db.execute(
+            select(Printer.model)
+            .where(func.lower(Printer.model) == model.lower())
+            .where(Printer.is_active == True)  # noqa: E712
+            .order_by(Printer.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+
+
 @router.get("/", response_model=list[PrintQueueItemResponse])
 async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
@@ -758,24 +818,17 @@ async def _resolve_queue_variants(
                 f"{library_file.filename} was sliced for {sliced_for} and cannot be dispatched to {model} printers",
             )
 
-        if model in seen_models:
+        # The printers' own spelling, matched without regard to case
+        has_printer = await _active_printer_model(db, model)
+        model = has_printer or model
+        if model.lower() in seen_models:
             raise HTTPException(
                 400,
-                f"{library_file.filename} and {seen_models[model]} are both for {model} — "
+                f"{library_file.filename} and {seen_models[model.lower()]} are both for {model} — "
                 "variants must target different printers",
             )
-        seen_models[model] = library_file.filename
-
-        has_printer = (
-            (
-                await db.execute(
-                    select(Printer).where(Printer.model == model).where(Printer.is_active == True)  # noqa: E712
-                )
-            )
-            .scalars()
-            .first()
-        )
-        any_active_printer = any_active_printer or bool(has_printer)
+        seen_models[model.lower()] = library_file.filename
+        any_active_printer = any_active_printer or has_printer is not None
 
         resolved.append((spec, library_file, model))
 
@@ -905,11 +958,10 @@ async def add_to_queue(
     # target_model there is just the first candidate, and _resolve_queue_variants
     # has already required that *some* candidate has a printer.
     if target_model_norm and not data.variants:
-        result = await db.execute(
-            select(Printer).where(Printer.model == target_model_norm).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, target_model_norm)
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {target_model_norm}")
+        target_model_norm = active_model
 
     # Validate archive exists (if provided) and get it for filament extraction
     archive = None
@@ -965,9 +1017,10 @@ async def add_to_queue(
     elif library_file:
         lib_path = Path(library_file.file_path)
         file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+    plate_id = data.plate_id or printed_plate_of_multi_plate_file(file_path)
     if target_model_norm:
         if file_path and file_path.exists():
-            filament_types = _extract_filament_types_from_3mf(file_path, data.plate_id)
+            filament_types = _extract_filament_types_from_3mf(file_path, plate_id)
             if filament_types:
                 required_filament_types = json.dumps(filament_types)
                 logger.info("Extracted filament types for model-based queue: %s", filament_types)
@@ -1126,7 +1179,7 @@ async def add_to_queue(
             db,
             archive=archive,
             library_file=library_file,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             ams_mapping=data.ams_mapping,
             printer_id=data.printer_id,
         )
@@ -1172,21 +1225,8 @@ async def add_to_queue(
     wants_live_color_match = any(
         isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
     )
-    if (
-        ams_mapping_json is None
-        and not wants_live_color_match
-        and archive
-        and archive.extra_data
-        and data.printer_id is not None
-    ):
-        saved = archive.extra_data.get("slicer_ams_mapping")
-        if (
-            isinstance(saved, dict)
-            and saved.get("printer_id") == data.printer_id
-            and isinstance(saved.get("mapping"), list)
-            and saved["mapping"]
-        ):
-            ams_mapping_json = json.dumps(saved["mapping"])
+    if ams_mapping_json is None and not wants_live_color_match:
+        ams_mapping_json = saved_slicer_ams_mapping(archive, data.printer_id)
     items = []
     for i in range(quantity):
         item = PrintQueueItem(
@@ -1208,7 +1248,7 @@ async def add_to_queue(
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
-            plate_id=data.plate_id,
+            plate_id=plate_id,
             bed_levelling=data.bed_levelling,
             flow_cali=data.flow_cali,
             vibration_cali=data.vibration_cali,
@@ -1336,17 +1376,12 @@ async def bulk_update_queue_items(
             continue
 
         item_update_data = update_data.copy()
-        if "printer_id" in item_update_data and item_update_data["printer_id"] != item.printer_id:
-            # A job with printer alternatives picks its own printer (#671),
-            # as the single edit refuses to override.
-            if item.variants:
-                skipped_count += 1
-                continue
-            # Now bound to this printer: not to a model, and nothing worked
-            # out for the old printer carries over.
-            item.target_model = None
-            item.target_location = None
-            drop_printer_resolved(item)
+        rebinds = "printer_id" in item_update_data and item_update_data["printer_id"] != item.printer_id
+        # A job with printer alternatives picks its own printer (#671), as the
+        # single edit refuses to override.
+        if rebinds and item.variants:
+            skipped_count += 1
+            continue
         if validates_billing_fields:
             trusted_estimated_cost = await _trusted_item_estimated_cost(
                 db,
@@ -1364,6 +1399,25 @@ async def bulk_update_queue_items(
                 exclude_queue_item_id=item.id,
             )
 
+        # Re-check right before changing it, as the single edit does (#2615):
+        # the validations above are awaited, and a scheduler worker may have
+        # claimed the row (or it stopped being pending) meanwhile. Nothing on
+        # this row has been changed yet, so skipping it leaves it as it is.
+        current = (
+            await db.execute(
+                select(PrintQueueItem.status, PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item.id)
+            )
+        ).one_or_none()
+        if current is None or current.status != "pending" or current.dispatching_at is not None:
+            skipped_count += 1
+            continue
+
+        if rebinds:
+            # Now bound to this printer: not to a model, and nothing worked
+            # out for the old printer carries over.
+            item.target_model = None
+            item.target_location = None
+            drop_printer_resolved(item)
         for field, value in item_update_data.items():
             setattr(item, field, value)
         updated_count += 1
@@ -2140,11 +2194,10 @@ async def update_queue_item(
 
     # Validate target_model has active printers
     if "target_model" in update_data and update_data["target_model"]:
-        result = await db.execute(
-            select(Printer).where(Printer.model == update_data["target_model"]).where(Printer.is_active == True)  # noqa: E712
-        )
-        if not result.scalars().first():
+        active_model = await _active_printer_model(db, update_data["target_model"])
+        if active_model is None:
             raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
+        update_data["target_model"] = active_model
 
         # Cross-model safety gate (#2578) — same check as the create route, so
         # a mismatched target can't be introduced by editing either.
@@ -2595,6 +2648,11 @@ async def stop_queue_item(
                 created_by_id=archive.created_by_id,
             )
             closed_archive_id = archive.id
+    if not stop_sent:
+        # No print end will come to charge the run and release what the job
+        # held of its cost centre's budget, so release it here, as a cancel
+        # does. A stop that reached the printer leaves it to the print's end.
+        await release_budget_reservation(db, source_type="print_queue", source_id=item.id, status="released")
 
     await db.commit()
     await refresh_batches_after([item.id])

+ 2 - 0
backend/app/api/routes/printers.py

@@ -1933,6 +1933,8 @@ async def get_printer_file_plates(
                                     "slot_id": int(filament_id),
                                     "type": filament_type,
                                     "color": filament_color,
+                                    # The preset the slicer assigned (GFA01, ...): names the product line
+                                    "tray_info_idx": filament_elem.get("tray_info_idx", "") or "",
                                     "used_grams": round(used_grams, 1),
                                     "used_meters": float(used_m) if used_m else 0,
                                 }

+ 2 - 0
backend/app/api/routes/spoolbuddy.py

@@ -1061,6 +1061,7 @@ async def update_spool_weight(
         # (as a hand-set one, see update_spool).
         spool.weight_locked = True
         await db.commit()
+        await ws_manager.broadcast({"type": "inventory_changed"})
         logger.info(
             "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
             spool.id,
@@ -1089,6 +1090,7 @@ async def update_spool_weight(
 
     async with _translate_spoolbuddy_errors():
         await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
+    await ws_manager.broadcast({"type": "inventory_changed"})
 
     weight_used = max(0.0, label_weight - remaining_weight)
     logger.info(

+ 3 - 1
backend/app/api/routes/webhook.py

@@ -5,7 +5,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.print_queue import _assert_can_queue_archive
+from backend.app.api.routes.print_queue import _assert_can_queue_archive, saved_slicer_ams_mapping
 from backend.app.core.auth import (
     ApiKeyActor,
     api_key_printer_scope,
@@ -168,6 +168,8 @@ async def webhook_add_to_queue(
         manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
         # No print options in the request: the install's defaults.
         **await default_print_options(db),
+        # The slicer's slots, as a reprint from the UI reuses them
+        ams_mapping=saved_slicer_ams_mapping(archive, data.printer_id),
     )
     db.add(queue_item)
     await db.commit()

+ 2 - 0
backend/app/core/sqlite_fk.py

@@ -90,6 +90,8 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("active_print_spoolman", "printer_id"),
         ("pipeline_jobs", "assigned_printer_id"),
         ("slicer_pipelines", "target_printer_id"),
+        # A deleted printer's spools would stay "assigned" to it (SA-1).
+        ("spool_assignment", "printer_id"),
         # smart_plugs: a printer delete now deletes its plugs; their energy
         # history would pass to the next plug given the id.
         ("smart_plug_energy_snapshots", "plug_id"),

+ 220 - 77
backend/app/main.py

@@ -11,6 +11,7 @@ from contextlib import asynccontextmanager
 from datetime import datetime, timedelta, timezone
 from logging.handlers import RotatingFileHandler
 from pathlib import Path, PurePosixPath
+from typing import cast
 from urllib.parse import urlparse
 
 from fastapi import FastAPI
@@ -18,7 +19,8 @@ from fastapi.encoders import jsonable_encoder
 from fastapi.exceptions import RequestValidationError
 from fastapi.responses import FileResponse, JSONResponse
 from fastapi.staticfiles import StaticFiles
-from sqlalchemy import delete, inspect as sa_inspect, or_, select, text, update
+from sqlalchemy import delete, func, inspect as sa_inspect, or_, select, text, update
+from sqlalchemy.engine import CursorResult
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm.exc import StaleDataError
 
@@ -101,6 +103,7 @@ from backend.app.core.static_assets import AssetStaticFiles
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.services import kprofile_drift, print_dispatch_context, slot_unlink_grace
+from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_cost_estimate import (
     schedule_archive_cost_estimate as _schedule_archive_cost_estimate,
@@ -706,35 +709,6 @@ def derive_failure_reason(status: str, hms_errors: list[dict] | None) -> str | N
 # {(printer_id, filename): created_by_id}
 _expected_print_creators: dict[tuple[int, str], int] = {}
 
-# Per-printer lock that serialises the spool-assignment side of on_ams_change
-# (auto-unlink stale + auto-assign new) when MQTT bursts deliver multiple AMS
-# updates for the same printer in quick succession (~30 ms apart, observed in
-# the wild on H2D + dual AMS).
-#
-# Without this serialisation, two concurrent on_ams_change callbacks each read
-# "no assignment for (printer, ams, tray)", each call auto_assign_spool, and
-# the second commit hits
-#   IntegrityError: duplicate key value violates unique constraint
-#                   "spool_assignment_printer_id_ams_id_tray_id_key"
-# SQLite's WAL serial-write semantics had been silently swallowing the race
-# until optional Postgres support landed (asyncpg allows true concurrent
-# transactions and surfaces the constraint violation).
-#
-# Scope is intentionally narrow: only the two DB-mutating blocks (unlink +
-# assign) are inside the lock. The Spoolman sync block further down stays
-# concurrent because it's network-bound and idempotent.
-_ams_assignment_locks: dict[int, asyncio.Lock] = {}
-
-
-def _get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
-    """Return the per-printer assignment lock, creating it on first use."""
-    lock = _ams_assignment_locks.get(printer_id)
-    if lock is None:
-        lock = asyncio.Lock()
-        _ams_assignment_locks[printer_id] = lock
-    return lock
-
-
 # Per-printer dedup for unknown_tag WS broadcasts. Keyed by
 # (ams_id, tray_id) -> (tag_uid, tray_uuid); we only re-broadcast when the
 # tag tuple changes for the slot. Cleared when the slot is reported empty
@@ -1257,16 +1231,25 @@ def register_expected_print(
         base = filename[:-4]
         _expected_prints[(printer_id, base)] = 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:
         _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:
         _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:
         _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
     # itself has no created_by_id (e.g. library-file-based queue prints)
     if created_by_id is not None:
@@ -1319,7 +1302,9 @@ def unregister_expected_print(printer_id: int, filename: str, archive_id: int) -
         _expected_print_creators.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_cost_center_ids.pop(archive_id, None)
         _print_plate_ids.pop(archive_id, None)
@@ -2442,7 +2427,7 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
     # per-printer assignment lock since #3186: the held-removal re-check runs
     # this outside any MQTT push, so it can now overlap one.
     try:
-        async with _get_ams_assignment_lock(printer_id), async_session() as db:
+        async with get_ams_assignment_lock(printer_id), async_session() as db:
             from sqlalchemy.orm import selectinload
 
             from backend.app.api.routes.inventory import _find_tray_in_ams_data
@@ -2594,12 +2579,18 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                         cur_state == 11 or (cur_state not in (9, 10) and cur_type.strip())
                     )
                     if not fp_type.strip() and loaded and assignment.spool:
+                        # The fingerprint ends this replay. Not recorded when nothing
+                        # could be sent (no MQTT client while the printer reconnects),
+                        # so the next AMS change tries again. An attempt that raised is
+                        # recorded as before: it may have sent part of the commands and
+                        # would raise again on every change, remain% steps included.
+                        configured: bool | None = False
                         try:
                             from backend.app.api.routes.inventory import (
                                 apply_spool_to_slot_via_mqtt,
                             )
 
-                            await apply_spool_to_slot_via_mqtt(
+                            configured = await apply_spool_to_slot_via_mqtt(
                                 db=db,
                                 current_user=None,
                                 spool=assignment.spool,
@@ -2610,23 +2601,35 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                                 current_tray_type=cur_type,
                                 current_tray_sub_brands=current_tray.get("tray_sub_brands", ""),
                             )
-                            logger.info(
-                                "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",
+                        except Exception:
+                            logger.exception(
+                                "Pre-config apply failed for spool %d on printer %d AMS%d-T%d",
                                 assignment.spool_id,
                                 printer_id,
                                 assignment.ams_id,
                                 assignment.tray_id,
                             )
-                        except Exception:
-                            logger.exception(
-                                "Pre-config apply failed for spool %d on printer %d AMS%d-T%d",
+                            configured = None
+                        if configured is False:
+                            logger.warning(
+                                "Pre-config not sent for spool %d on printer %d AMS%d-T%d (no MQTT client); "
+                                "retrying on the next AMS change",
                                 assignment.spool_id,
                                 printer_id,
                                 assignment.ams_id,
                                 assignment.tray_id,
                             )
-                        assignment.fingerprint_color = cur_color
-                        assignment.fingerprint_type = cur_type
+                        else:
+                            if configured:
+                                logger.info(
+                                    "SpoolBuddy pre-config applied on insert: spool %d → printer %d AMS%d-T%d",
+                                    assignment.spool_id,
+                                    printer_id,
+                                    assignment.ams_id,
+                                    assignment.tray_id,
+                                )
+                            assignment.fingerprint_color = cur_color
+                            assignment.fingerprint_type = cur_type
                         continue
 
                     if not _colors_similar(cur_color, fp_color) or cur_type.upper() != fp_type.upper():
@@ -2903,11 +2906,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
     """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
     logger = logging.getLogger(__name__)
 
-    # Snapshot BEFORE any await: if a print is active, skip weight sync later.
-    # on_print_complete may pop _active_sessions during our awaits (#880).
-    from backend.app.services.usage_tracker import _active_sessions
+    # Snapshot BEFORE any await: if a print is active, or a finished one is
+    # still being charged, skip weight sync later. on_print_complete may pop
+    # _active_sessions during our awaits (#880).
+    from backend.app.services.usage_tracker import printer_is_charging
 
-    _print_active = printer_id in _active_sessions
+    _print_active = printer_is_charging(printer_id)
 
     # A slot that reports empty while a print is running is a filament runout,
     # not a spool swap: the spool is still physically in the AMS, just
@@ -2950,14 +2954,14 @@ async def on_ams_change(printer_id: int, ams_data: list):
     await _unlink_stale_assignments(printer_id, ams_data, printing_now)
 
     # Auto-manage inventory spools from AMS tray data (skip if Spoolman manages AMS).
-    # Serialised per-printer via _ams_assignment_locks: MQTT bursts can deliver
+    # Serialised per-printer via services/ams_assignment_lock: MQTT bursts can deliver
     # two AMS pushes ~30 ms apart, and without the lock both callbacks read
     # "no existing assignment" for the same (printer, ams, tray) and race to
     # INSERT, hitting the spool_assignment_printer_id_ams_id_tray_id_key
     # unique constraint on Postgres. SQLite's WAL serialises writes so the
-    # bug stayed latent there. See _ams_assignment_locks comment for details.
+    # bug stayed latent there. See services/ams_assignment_lock for details.
     try:
-        async with _get_ams_assignment_lock(printer_id), async_session() as db:
+        async with get_ams_assignment_lock(printer_id), async_session() as db:
             from sqlalchemy.orm import selectinload
 
             from backend.app.api.routes.settings import get_setting
@@ -3026,15 +3030,31 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                     new_used = round(lw * (100 - remain_val) / 100.0, 1)
                                     current_used = existing_assignment.spool.weight_used or 0
                                     if new_used > current_used + 1:
-                                        logger.info(
-                                            "Weight sync: spool %d weight_used %s -> %s (remain=%d)",
-                                            existing_assignment.spool_id,
-                                            current_used,
-                                            new_used,
-                                            remain_val,
+                                        # Rechecked in the statement: the spool was loaded
+                                        # before this, and a weight set by hand (or weighed)
+                                        # and locked since then must stay (WL-6).
+                                        result = cast(
+                                            CursorResult,
+                                            await db.execute(
+                                                update(Spool)
+                                                .where(
+                                                    Spool.id == existing_assignment.spool_id,
+                                                    Spool.weight_locked.is_not(True),
+                                                    func.coalesce(Spool.weight_used, 0) < new_used - 1,
+                                                )
+                                                .values(weight_used=new_used)
+                                            ),
                                         )
-                                        existing_assignment.spool.weight_used = new_used
                                         await db.commit()
+                                        if result.rowcount:
+                                            logger.info(
+                                                "Weight sync: spool %d weight_used %s -> %s (remain=%d)",
+                                                existing_assignment.spool_id,
+                                                current_used,
+                                                new_used,
+                                                remain_val,
+                                            )
+                                            await ws_manager.broadcast({"type": "inventory_changed"})
 
                             # A K-profile the slot lost (the default K after a reset or
                             # power cycle) is put back by kprofile_drift on the next idle
@@ -6281,6 +6301,10 @@ async def _scan_for_timelapse_with_retries(
             if not archive.printer_id:
                 logger.warning("[TIMELAPSE] Archive %s has no printer, aborting", archive_id)
                 return
+            # The run this scan is for. The archive row passes to a newer run
+            # when it prints again (the next plate, a reprint), and that run's
+            # video is its own.
+            run_started_at = archive.started_at
 
             if baseline_names is not None:
                 logger.info(
@@ -6361,6 +6385,11 @@ async def _scan_for_timelapse_with_retries(
                 if archive.timelapse_path:
                     logger.info("[TIMELAPSE] Archive %s already has timelapse attached, stopping poll", archive_id)
                     return
+                if archive.started_at != run_started_at:
+                    logger.info(
+                        "[TIMELAPSE] Archive %s is printing again, stopping the previous run's poll", archive_id
+                    )
+                    return
 
                 result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
                 printer = result.scalar_one_or_none()
@@ -6398,6 +6427,7 @@ async def _scan_for_timelapse_with_retries(
                     quiet=not changed,
                     require_unambiguous=not baseline_trusted,
                     plate_id=plate_id,
+                    run_started_at=run_started_at,
                 )
                 if attached:
                     return
@@ -6421,6 +6451,55 @@ async def _scan_for_timelapse_with_retries(
     )
 
 
+async def _attach_layer_timelapse(
+    archive_id: int, timelapse_path: Path, plate_id: int | None, run_started_at: datetime | None
+) -> None:
+    """Attach the external camera's stitched timelapse to the archive.
+
+    The camera's video is the one the user set up in Bambuddy, so it wins
+    over the printer's own when both are on: it replaces the printer's video
+    (usually there first; stitching takes minutes) and that file is deleted,
+    leaving nothing in the archive folder unreferenced. ``run_started_at`` is
+    the run the frames were taken for: when the archive is printing again by
+    the time the stitch is done, the video is not the new run's and is
+    dropped. The stitched file is a temporary either way.
+    """
+    from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+
+    logger = logging.getLogger(__name__)
+    try:
+        async with async_session() as db:
+            service = ArchiveService(db)
+            archive = await service.get_archive(archive_id)
+            if archive is None:
+                return
+            if archive.started_at != run_started_at:
+                logger.info(
+                    "[LAYER-TL] Archive %s is printing again; dropping the previous run's layer timelapse", archive_id
+                )
+                return
+            replaced = archive.timelapse_path
+            logger.info("[LAYER-TL] Attaching timelapse %s to archive %s", timelapse_path, archive_id)
+            timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
+            if not await service.attach_timelapse(archive_id, timelapse_data, "layer_timelapse.mp4", plate_id=plate_id):
+                return
+            attached = archive.timelapse_path
+        logger.info("[LAYER-TL] Layer timelapse attached successfully")
+        if replaced and replaced != attached:
+            try:
+                old = safe_join_under(app_settings.base_dir, replaced, http=False)
+            except PathTraversalError:
+                return
+            await asyncio.to_thread(old.unlink, missing_ok=True)
+            logger.info("[LAYER-TL] Replaced the printer's timelapse %s on archive %s", replaced, archive_id)
+    finally:
+        await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
+
+
+# ``run_started_at`` not given: attach whichever run the archive is on.
+_ANY_RUN = object()
+
+
 async def _attach_first_unclaimed_timelapse(
     archive_id: int,
     printer,
@@ -6433,6 +6512,7 @@ async def _attach_first_unclaimed_timelapse(
     quiet: bool = False,
     require_unambiguous: bool = False,
     plate_id: int | None = None,
+    run_started_at: datetime | None | object = _ANY_RUN,
 ) -> bool:
     """Download and attach the one video that belongs to this print.
 
@@ -6495,7 +6575,7 @@ async def _attach_first_unclaimed_timelapse(
         )
 
     target = candidates[0]
-    file_name = target.get("name")
+    file_name = str(target.get("name") or "")  # every listed entry has one
     remote_path = target.get("path") or f"/timelapse/{file_name}"
     logger.info(
         "[TIMELAPSE] Attempt %s: New file detected: %s (downloading for archive %s)",
@@ -6540,15 +6620,28 @@ async def _attach_first_unclaimed_timelapse(
     ):
         return False
 
-    # Write phase: attach in a fresh short-lived session.
+    # Write phase: attach in a fresh short-lived session. The download took a
+    # while: by now the archive may have its video, or be printing again for a
+    # newer run, whose video this is not. Then nothing is attached or deleted,
+    # and the poll ends (True).
     async with async_session() as db:
-        success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, file_name, plate_id=plate_id)
+        service = ArchiveService(db)
+        if run_started_at is not _ANY_RUN:
+            current = await service.get_archive(archive_id)
+            if current is None or current.timelapse_path or current.started_at != run_started_at:
+                logger.info(
+                    "[TIMELAPSE] Archive %s moved on during the download (video attached or printing again); "
+                    "%s stays on the printer",
+                    archive_id,
+                    file_name,
+                )
+                return True
+        success = await service.attach_timelapse(archive_id, timelapse_data, file_name, plate_id=plate_id)
     if not success:
         logger.warning("[TIMELAPSE] Failed to attach timelapse to archive %s", archive_id)
         return False
 
     logger.info("[TIMELAPSE] Successfully attached timelapse to archive %s", archive_id)
-    await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": True})
 
     await delete_archived_timelapse(
         printer.ip_address,
@@ -7800,6 +7893,10 @@ async def _recover_fallback_from_cache_before_eviction(printer_id: int, data: di
             logger.debug("[RECOVER] Pre-eviction recovery for %s failed: %s", name, e)
 
 
+# Longest the print's end waits for its file to be deleted from the SD card
+_SD_CLEANUP_BUDGET_SECONDS = 45.0
+
+
 async def _cleanup_sd_card_after_print(
     printer_id: int, subtask_name: str | None, archive_id: int | None, logger
 ) -> None:
@@ -7916,7 +8013,22 @@ async def _cleanup_sd_card_after_print(
 
 
 async def on_print_complete(printer_id: int, data: dict):
-    """Handle print completion - update the archive status."""
+    """Handle print completion - update the archive status.
+
+    The printer counts as printing until the print's usage is booked (or this
+    ends), so the AMS remain% weight sync can't charge it a second time while
+    the usage tracker works (WU-3). Marked before the first await.
+    """
+    from backend.app.services.usage_tracker import done_completing, mark_completing
+
+    mark_completing(printer_id)
+    try:
+        await _close_out_print(printer_id, data)
+    finally:
+        done_completing(printer_id)
+
+
+async def _close_out_print(printer_id: int, data: dict):
     import time
 
     logger = logging.getLogger(__name__)
@@ -8025,7 +8137,19 @@ async def on_print_complete(printer_id: int, data: dict):
     subtask_name = data.get("subtask_name", "")
 
     if not filename and not subtask_name:
+        # Which print ended can't be told: the printer reported no name since
+        # Bambuddy started (a print that failed while preparing, a calibration).
+        # Charge nothing, but close the print session, which otherwise kept the
+        # AMS weight sync off until the next print (AS-5). A print archived as
+        # running is closed and charged by the reconnect reconcile.
         logger.warning("Print complete without filename or subtask_name")
+        try:
+            from backend.app.services.usage_tracker import discard_session
+
+            async with async_session() as db:
+                await discard_session(db, printer_id)
+        except Exception as e:
+            logger.warning("Failed to clear print session for printer %s: %s", printer_id, e)
         return
 
     logger.info("Print complete - filename: %s, subtask: %s, status: %s", filename, subtask_name, data.get("status"))
@@ -8104,7 +8228,25 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive:
                     archive_id = archive.id
 
-    await _cleanup_sd_card_after_print(printer_id, subtask_name, archive_id, logger)
+    # Bounded: a printer whose FTP hangs on DELE took up to 3 candidates x 3
+    # attempts x a 60 s timeout, about nine minutes, before the queue item was
+    # closed below. The stranded-job closer gives up after five and closed it
+    # first, so the job's auto-off and its billing never ran. Inline rather
+    # than in the background, so the next job of the same file isn't released
+    # (and uploaded under the name being deleted) while the cleanup still
+    # works on it. A delete already sent may still finish in its FTP thread
+    # after the cutoff, as one past delete_file_async's own timeout always did.
+    try:
+        await asyncio.wait_for(
+            _cleanup_sd_card_after_print(printer_id, subtask_name, archive_id, logger),
+            timeout=_SD_CLEANUP_BUDGET_SECONDS,
+        )
+    except TimeoutError:
+        logger.warning(
+            "SD card cleanup on printer %s took over %ss; leaving the file there and closing the print",
+            printer_id,
+            _SD_CLEANUP_BUDGET_SECONDS,
+        )
 
     log_timing("SD card cleanup")
 
@@ -8298,9 +8440,12 @@ async def on_print_complete(printer_id: int, data: dict):
     usage_results: list[dict] = []
     # Prefer ams_mapping captured from MQTT request topic (works for all print sources)
     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
     # consumed it at print-start injection; leaving it would leak into the next
@@ -9353,18 +9498,16 @@ async def on_print_complete(printer_id: int, data: dict):
         try:
             if print_status == "completed":
                 logger.info("[LAYER-TL] Stitching layer timelapse for printer %s", printer_id)
+                # The run the frames belong to, read before the stitch: it can
+                # take long enough for the archive to start printing again.
+                run_started_at = None
+                if archive_id:
+                    async with async_session() as db:
+                        run_archive = await ArchiveService(db).get_archive(archive_id)
+                        run_started_at = run_archive.started_at if run_archive else None
                 timelapse_path = await tl_complete(printer_id)
                 if timelapse_path and archive_id:
-                    logger.info("[LAYER-TL] Attaching timelapse %s to archive %s", timelapse_path, archive_id)
-                    async with async_session() as db:
-                        service = ArchiveService(db)
-                        timelapse_data = await asyncio.to_thread(timelapse_path.read_bytes)
-                        await service.attach_timelapse(
-                            archive_id, timelapse_data, "layer_timelapse.mp4", plate_id=notify_plate_id
-                        )
-                        # Clean up the temp file
-                        await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)
-                        logger.info("[LAYER-TL] Layer timelapse attached successfully")
+                    await _attach_layer_timelapse(archive_id, timelapse_path, notify_plate_id, run_started_at)
                 elif timelapse_path:
                     # Timelapse created but no archive - just clean up
                     await asyncio.to_thread(timelapse_path.unlink, missing_ok=True)

+ 38 - 0
backend/app/services/ams_assignment_lock.py

@@ -0,0 +1,38 @@
+"""Per-printer lock for writes to a printer's spool assignments (AMS slots).
+
+on_ams_change serialises its spool-assignment side (auto-unlink stale +
+auto-assign new) when MQTT bursts deliver multiple AMS updates for the same
+printer in quick succession (~30 ms apart, observed in the wild on H2D + dual
+AMS).
+
+Without this serialisation, two concurrent on_ams_change callbacks each read
+"no assignment for (printer, ams, tray)", each call auto_assign_spool, and
+the second commit hits
+  IntegrityError: duplicate key value violates unique constraint
+                  "spool_assignment_printer_id_ams_id_tray_id_key"
+SQLite's WAL serial-write semantics had been silently swallowing the race
+until optional Postgres support landed (asyncpg allows true concurrent
+transactions and surfaces the constraint violation).
+
+The assign, unassign and create-from-slot routes write the same rows and take
+the same lock (SA-5), before their first write: on SQLite a session that
+already holds the write lock would otherwise wait here on an AMS update that
+waits for that write lock.
+
+Scope is intentionally narrow: only the DB-mutating blocks are inside the lock.
+The Spoolman sync block in on_ams_change stays concurrent because it's
+network-bound and idempotent.
+"""
+
+import asyncio
+
+ams_assignment_locks: dict[int, asyncio.Lock] = {}
+
+
+def get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
+    """Return the per-printer assignment lock, creating it on first use."""
+    lock = ams_assignment_locks.get(printer_id)
+    if lock is None:
+        lock = asyncio.Lock()
+        ams_assignment_locks[printer_id] = lock
+    return lock

+ 15 - 33
backend/app/services/archive.py

@@ -26,7 +26,7 @@ from backend.app.utils.archive_paths import archive_dir as resolve_archive_dir
 from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 from backend.app.utils.filename import clean_display_name
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
-from backend.app.utils.threemf_tools import bed_temperature_from_config
+from backend.app.utils.threemf_tools import bed_temperature_from_config, used_filament_indexes
 
 logger = logging.getLogger(__name__)
 
@@ -277,36 +277,6 @@ class ThreeMFParser:
             )
         return self.metadata
 
-    def _first_used_filament_index(self, plates: list) -> int:
-        """Position, in the project's per-filament settings, of the first
-        filament the printed plate uses: 0 when that can't be told.
-
-        ``project_settings`` lists one value per filament of the whole
-        project, so the printed plate's own filament picks which applies
-        (a plate printing only filament 3 has filament 3's nozzle
-        temperature). Without a plate number, the only plate decides.
-        """
-        if self.plate_number:
-            printed = [
-                plate
-                for plate in plates
-                if any(
-                    meta.get("key") == "index" and meta.get("value") == str(self.plate_number)
-                    for meta in plate.findall("metadata")
-                )
-            ]
-        else:
-            printed = plates if len(plates) == 1 else []
-        used: list[int] = []
-        for plate in printed[:1]:
-            for filament in plate.findall("filament"):
-                try:
-                    if float(filament.get("used_g", "0")) > 0:
-                        used.append(int(filament.get("id", "")))
-                except (TypeError, ValueError):
-                    continue
-        return min(used) - 1 if used and min(used) > 0 else 0
-
     def _parse_slice_info(self, zf: zipfile.ZipFile):
         """Parse slice_info.config for print settings and printable objects."""
         try:
@@ -395,7 +365,7 @@ class ThreeMFParser:
                         if printable_objects:
                             self.metadata["printable_objects"] = printable_objects
 
-                self._plate_filament_index = self._first_used_filament_index(plates)
+                self._plate_filament_indexes = used_filament_indexes(plates, self.plate_number)
 
                 if any_time_seen:
                     self.metadata["print_time_seconds"] = summed_time
@@ -626,7 +596,7 @@ class ThreeMFParser:
                 if key in data:
                     val = data[key]
                     if isinstance(val, list) and val:
-                        index = getattr(self, "_plate_filament_index", 0)
+                        index = (getattr(self, "_plate_filament_indexes", None) or [0])[0]
                         self.metadata["nozzle_temperature"] = int(float(val[index if index < len(val) else 0]))
                     elif isinstance(val, (int, float, str)):
                         self.metadata["nozzle_temperature"] = int(float(val))
@@ -1981,6 +1951,7 @@ class ArchiveService:
         archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir))
         archive.timelapse_plate_id = plate_id
         await self.db.commit()
+        await _announce_timelapse(archive_id, attached=True)
 
         # For non-MP4 videos (e.g. AVI from P1S), kick off background conversion
         if not filename.lower().endswith(".mp4"):
@@ -2017,6 +1988,16 @@ def _free_timelapse_name(target: Path, current_relpath: str | None, base_dir: Pa
         n += 1
 
 
+async def _announce_timelapse(archive_id: int, *, attached: bool) -> None:
+    """Tell open Archives pages that this archive's timelapse changed."""
+    from backend.app.core.websocket import ws_manager
+
+    try:
+        await ws_manager.send_archive_updated({"id": archive_id, "timelapse_attached": attached})
+    except Exception:  # noqa: BLE001 -- a refresh hint must not fail the change it reports
+        logger.debug("Could not announce the timelapse of archive %s", archive_id, exc_info=True)
+
+
 async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
     """Background task: convert non-MP4 timelapse (e.g. AVI from P1S) to MP4.
 
@@ -2107,6 +2088,7 @@ async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
                 return
             archive.timelapse_path = str(mp4_path.relative_to(settings.base_dir))
             await db.commit()
+        await _announce_timelapse(archive_id, attached=True)
 
         # Remove original non-MP4 file
         if source_path.exists():

+ 27 - 4
backend/app/services/bambu_mqtt.py

@@ -1455,6 +1455,10 @@ class BambuMQTTClient:
         # printer hasn't reported it yet, letting queue/scheduled archives
         # persist a restart-stable id from the moment they dispatch (#1485).
         self.last_dispatch_subtask_id: str | None = None
+        # The printer's answer to the last print start it refused, as (result,
+        # reason); cleared when the next start is sent. The start watchdog
+        # reads it to stop waiting for a print that will not come.
+        self.last_start_refusal: tuple[str, str | None] | None = None
         self._is_dual_nozzle: bool = False  # Set when device.extruder.info has >= 2 entries
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
@@ -2210,8 +2214,18 @@ class BambuMQTTClient:
         if not isinstance(print_data, dict) or print_data.get("command") != "project_file":
             return
         # A refused dispatch names a file that was never written. Acting on it
-        # would pin an archive on a destination nothing ever went to.
-        if print_data.get("result") != "SUCCESS":
+        # would pin an archive on a destination nothing ever went to. Logged:
+        # nothing else says the printer refused a print it was sent.
+        result = str(print_data.get("result") or "")
+        if result.upper() != "SUCCESS":
+            logger.info(
+                "[%s] Printer refused a print start (project_file): result=%s reason=%s url=%s",
+                self.serial_number,
+                result,
+                print_data.get("reason"),
+                print_data.get("url"),
+            )
+            self.last_start_refusal = (result, print_data.get("reason"))
             return
         url = print_data.get("url")
         if not isinstance(url, str) or not url:
@@ -4378,12 +4392,20 @@ class BambuMQTTClient:
         if "gcode_file" in data:
             self.state.gcode_file = data["gcode_file"]
             self.state.current_print = data["gcode_file"]
-        if "subtask_name" in data:
+        # The answer to a print start the printer refused names the job it
+        # refused, not one it runs: read as status, its job id told the start
+        # watchdog the file had landed.
+        refused_start = (
+            data.get("command") == "project_file"
+            and data.get("result") is not None
+            and str(data["result"]).upper() != "SUCCESS"
+        )
+        if "subtask_name" in data and not refused_start:
             self.state.subtask_name = data["subtask_name"]
             # Prefer subtask_name as current_print if available
             if data["subtask_name"]:
                 self.state.current_print = data["subtask_name"]
-        if "subtask_id" in data:
+        if "subtask_id" in data and not refused_start:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
             # Billing: retain this frame's latest positive value immediately.
@@ -6353,6 +6375,7 @@ class BambuMQTTClient:
             # Remember it so on_print_start can persist a restart-stable id on
             # the archive even before the printer echoes subtask_id back (#1485).
             self.last_dispatch_subtask_id = submission_id
+            self.last_start_refusal = None
 
             # Tri-state calibration options → BambuStudio's getValueInt encoding:
             # off=0 (never), on=1 (force every print), auto=2 (printer runs it

+ 11 - 2
backend/app/services/pipeline_eligibility.py

@@ -30,7 +30,7 @@ import json
 from dataclasses import dataclass
 from typing import Literal
 
-from sqlalchemy import select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.local_preset import LocalPreset
@@ -282,7 +282,16 @@ async def check_pipeline_eligibility(
             issues=(EligibilityIssue(kind="class_not_set"),),
         )
 
-    candidates = (await db.execute(select(Printer).where(Printer.model == pipeline.target_model_class))).scalars().all()
+    # Without regard to case, as the queue and the scheduler match a model
+    candidates = (
+        (
+            await db.execute(
+                select(Printer).where(func.lower(Printer.model) == (pipeline.target_model_class or "").lower())
+            )
+        )
+        .scalars()
+        .all()
+    )
 
     if not candidates:
         return EligibilityReport(

+ 98 - 12
backend/app/services/print_scheduler.py

@@ -89,6 +89,7 @@ from backend.app.utils.threemf_tools import (
     default_plate_number,
     extract_rack_plan_from_3mf,
     extract_slot_extruders_from_3mf,
+    plate_bed_temperature,
     select_plate_gcode_name,
 )
 
@@ -733,6 +734,41 @@ def _int_or(value, default: int) -> int:
         return default
 
 
+# What a queue job's dispatch is worked out from, and an edit can change.
+_DISPATCH_INPUTS = (
+    "printer_id",
+    "status",
+    "ams_mapping",
+    "plate_id",
+    "filament_overrides",
+    "skip_filament_check",
+    "target_model",
+    "target_location",
+)
+
+
+async def _edited_since_loaded(db: AsyncSession, item: PrintQueueItem) -> bool:
+    """Whether ``item`` was changed or removed since ``check_queue`` loaded it.
+
+    The pass works on the jobs as loaded at its start, and it may have waited
+    since: a plug powering a printer on takes minutes. A job edited meanwhile
+    (moved to another printer, given a mapping or another plate, cancelled)
+    is left to the next pass, which reads it fresh: deciding from the stale
+    copy would overwrite the edit, or send the job where it no longer goes.
+    The query flushes the pass's own pending changes first, so those don't
+    count as edits.
+    """
+    row = (
+        await db.execute(
+            select(*(getattr(PrintQueueItem, name) for name in _DISPATCH_INPUTS)).where(PrintQueueItem.id == item.id)
+        )
+    ).one_or_none()
+    if row is not None and all(getattr(item, name) == value for name, value in zip(_DISPATCH_INPUTS, row, strict=True)):
+        return False
+    logger.info("Queue item %s changed during this pass; leaving it to the next", item.id)
+    return True
+
+
 def _global_tray_id(ams_id: int, tray_id: int) -> int:
     """Bambu's flat tray addressing: ``ams_id * 4 + tray_id`` for a four-slot
     unit, and the bare unit id for an AMS-HT (ids from 128, one tray each).
@@ -1018,6 +1054,22 @@ def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str
     )
 
 
+async def _queued_plate_bed_temperature(item: PrintQueueItem, archive: PrintArchive | None) -> int:
+    """Bed temperature for the plate a queued job prints, 0 when unknown.
+
+    The archive stores the highest bed temperature of any filament in the
+    project, while the job may print another plate than the one archived: a
+    PLA plate of a project that also holds PETG is heated for PLA. Read from
+    the archive's file for the job's own plate, falling back to the stored
+    value when the file can't be read.
+    """
+    stored = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+    if archive is None or not archive.file_path:
+        return stored
+    temperature = await asyncio.to_thread(plate_bed_temperature, settings.base_dir / archive.file_path, item.plate_id)
+    return temperature or stored
+
+
 def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int:
     """The plate to dispatch, resolved once in ``_start_print`` and reused at
     every call site below it: G-code injection, usage registration, rack-plan
@@ -2131,6 +2183,9 @@ class PrintScheduler:
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # frontend status-load race can persist [-1] (#2589) — and
                     # must be recomputed from live trays rather than trusted.
+                    if await _edited_since_loaded(db, item):
+                        continue
+
                     unmappable = await self._ensure_ams_mapping(db, item.printer_id, item)
                     if unmappable:
                         await self._fail_unmappable_item(db, item, item.printer_id, unmappable)
@@ -2396,6 +2451,9 @@ class PrintScheduler:
                             skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
                             continue
 
+                        if await _edited_since_loaded(db, item):
+                            continue
+
                         # Assign printer and start - clear waiting reason
                         item.printer_id = printer_id
                         item.waiting_reason = None
@@ -2655,7 +2713,7 @@ class PrintScheduler:
             # routes reject reassignment (409), so printer_id can't change out from
             # under the in-flight upload and split the queue row from the
             # archive/expected-print/physical command.
-            if not await self._claim_for_dispatch(item_db, item_id):
+            if not await self._claim_for_dispatch(item_db, item_id, selected_printer_id):
                 logger.info(
                     "Queue item %s not claimable for dispatch (cancelled, removed, or already claimed) — skipping",
                     item_id,
@@ -2959,19 +3017,24 @@ class PrintScheduler:
             sorted(pin),
         )
 
-    async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
+    async def _claim_for_dispatch(self, db: AsyncSession, item_id: int, printer_id: int | None = None) -> bool:
         """Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
 
         Returns True if this call won the claim, False if the row was already
         claimed, no longer pending (cancelled mid-tick), or removed. The CAS is
-        the load-bearing guard against reassign-during-dispatch (#2615)."""
-        res = await db.execute(
+        the load-bearing guard against reassign-during-dispatch (#2615).
+        ``printer_id`` is the printer the item was selected for: a row moved to
+        another printer since is not claimed, and the next pass selects it for
+        that one."""
+        query = (
             update(PrintQueueItem)
             .where(PrintQueueItem.id == item_id)
             .where(PrintQueueItem.status == "pending")
             .where(PrintQueueItem.dispatching_at.is_(None))
-            .values(dispatching_at=datetime.now(timezone.utc))
         )
+        if printer_id is not None:
+            query = query.where(PrintQueueItem.printer_id == printer_id)
+        res = await db.execute(query.values(dispatching_at=datetime.now(timezone.utc)))
         await db.commit()
         return res.rowcount > 0
 
@@ -3539,16 +3602,18 @@ class PrintScheduler:
                 tray_color = tray.get("tray_color") or ""
                 if tray_type:
                     color_norm = tray_color.replace("#", "").lower()[:6]
-                    loaded.add((tray_type.upper(), color_norm))
+                    loaded.add((canonical_filament_type(tray_type), color_norm))
         for vt in raw_data.get("vt_tray") or []:
             vt_type = vt.get("tray_type")
             if vt_type:
                 color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
-                loaded.add((vt_type.upper(), color_norm))
+                loaded.add((canonical_filament_type(vt_type), color_norm))
 
+        # Types compared as the type check compares them: a PA12-CF override
+        # is met by PA-CF in its colour.
         matches = 0
         for o in overrides:
-            o_type = (o.get("type") or "").upper()
+            o_type = canonical_filament_type(o.get("type") or "")
             o_color = (o.get("color") or "").replace("#", "").lower()[:6]
             if (o_type, o_color) in loaded:
                 matches += 1
@@ -6972,7 +7037,7 @@ class PrintScheduler:
             # missing bed_temperature (Orca gcode.3mf exports parse without
             # one) does NOT skip the hold — chamber need gates the feature.
             archive = next_item.archive
-            item_bed = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+            item_bed = await _queued_plate_bed_temperature(next_item, archive)
             bed_target = max(item_bed, hold_temp)
             if bed_target <= 0:
                 continue
@@ -7346,7 +7411,7 @@ class PrintScheduler:
             self._preheat_flap_to_cooling(item.id, printer)
             return True
 
-        bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
+        bed_target = await _queued_plate_bed_temperature(item, archive)
         if bed_target <= 0:
             # No bed temperature in the slicer metadata. When the print needs a
             # hot chamber the bed is simply how we heat it, so fall back to the
@@ -7630,6 +7695,11 @@ class PrintScheduler:
         if not printer:
             logger.error("Printer %s not found in database", printer_id)
             return False
+        # End the pass's transaction before waiting up to minutes for the boot:
+        # held open, it pins a pooled connection idle in transaction
+        # (PostgreSQL) and a stale read snapshot (SQLite). Nothing below reads
+        # through it.
+        await db.commit()
 
         # Wait for printer to boot (give it some time before trying to connect)
         logger.info("Waiting 30s for printer %s to boot...", printer_id)
@@ -9571,6 +9641,9 @@ class PrintScheduler:
         # once is what matters — it is the state the printer was in when it
         # declined to start (#2758).
         drying_ams_ids: list[int] = []
+        # The printer answered the start with a refusal: nothing will start,
+        # and the command was not lost, so no reconnect either.
+        start_refusal: tuple[str, str | None] | None = None
         deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
@@ -9610,6 +9683,10 @@ class PrintScheduler:
             if _mqtt_commands_rejected(status):
                 command_rejected = True
                 break
+            refusal = getattr(printer_manager.get_client(printer_id), "last_start_refusal", None)
+            if isinstance(refusal, tuple):
+                start_refusal = refusal
+                break
             if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
                 # Phase A exit — printer accepted the file (subtask_id flipped
                 # to our submission id). Don't return yet: the printer may
@@ -9814,7 +9891,16 @@ class PrintScheduler:
             )
             await scheduler._notify_dispatch_gave_up(queue_item_id, printer_id, created_by_id)
         elif revert_outcome == "reverted":
-            if landed_on_subtask:
+            if start_refusal is not None:
+                logger.warning(
+                    "Queue item %s: printer %d refused the print start (result=%s, reason=%s) — "
+                    "reverted to 'pending' for retry",
+                    queue_item_id,
+                    printer_id,
+                    start_refusal[0],
+                    start_refusal[1],
+                )
+            elif landed_on_subtask:
                 logger.warning(
                     "Queue item %s: printer %d accepted project_file (subtask_id "
                     "advanced) but never transitioned to an active state within "
@@ -9840,7 +9926,7 @@ class PrintScheduler:
         # project_file landed on the printer. A forced reconnect at this point
         # would interrupt the printer's parse and trigger 0500_4003 (#1150) —
         # skip the recovery entirely.
-        if landed_on_subtask:
+        if landed_on_subtask or start_refusal is not None:
             return
 
         # Phase A timeout path: if the printer's gcode_file changed since

+ 9 - 2
backend/app/services/spool_csv.py

@@ -14,7 +14,7 @@ only the rows that came back `valid`.
 
 import csv
 import io
-from datetime import datetime
+from datetime import datetime, timezone
 
 from pydantic import BaseModel, ValidationError
 from sqlalchemy import select
@@ -179,6 +179,10 @@ def _parse_datetime(value: str) -> datetime | None:
 
     Accepts what `datetime.isoformat()` produces (what export writes) plus a
     trailing 'Z' for UTC, which `fromisoformat` rejects before Python 3.11.
+
+    Returned as naive UTC, as the column holds it: SQLite would keep the wall
+    time of a value with an offset and drop the offset. A value without one is
+    taken as UTC, which is how export writes it.
     """
     raw = value.strip()
     if not raw:
@@ -186,9 +190,12 @@ def _parse_datetime(value: str) -> datetime | None:
     if raw.endswith("Z"):
         raw = raw[:-1] + "+00:00"
     try:
-        return datetime.fromisoformat(raw)
+        parsed = datetime.fromisoformat(raw)
     except ValueError:
         return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
 
 
 async def _load_color_catalog(db: AsyncSession) -> list[ColorCatalogEntry]:

+ 63 - 7
backend/app/services/usage_tracker.py

@@ -14,7 +14,7 @@ import re
 from dataclasses import dataclass, field
 from datetime import datetime, timezone
 
-from sqlalchemy import select
+from sqlalchemy import func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.spool import Spool
@@ -268,6 +268,30 @@ class PrintSession:
 # context — see ``persist_session`` / ``restore_session``.
 _active_sessions: dict[int, PrintSession] = {}
 
+# Printers whose finished print is being closed out. on_print_complete takes the
+# session first and charges the usage several awaits later; the printer already
+# reports FINISH, so without this mark the AMS remain% weight sync would run in
+# between and the print would be charged twice (WU-3).
+_completing: set[int] = set()
+
+
+def mark_completing(printer_id: int) -> None:
+    """The completion path's first step: hold the weight sync off until the
+    usage is booked (discard_session) or the completion ends."""
+    _completing.add(printer_id)
+
+
+def done_completing(printer_id: int) -> None:
+    _completing.discard(printer_id)
+
+
+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
+    printer's spools alone meanwhile (#880)."""
+    return printer_id in _active_sessions or printer_id in _completing
+
+
 # Serialises the read-modify-write on the persisted tray-change log, per printer.
 _tray_change_locks: dict[int, asyncio.Lock] = {}
 
@@ -297,6 +321,34 @@ def _tray_map_from_json(mapping: dict | None) -> dict[tuple[int, int], int]:
     return result
 
 
+async def _charge_spool(db: AsyncSession, spool: Spool, grams: float) -> bool:
+    """Add *grams* to the spool's usage in the database itself (WU-4).
+
+    The spool was loaded several awaits earlier; a weight set by hand or
+    weighed on the scale since then would be lost to a write of the value
+    computed from that copy. The loaded copy is set to the stored result: from
+    this statement until the commit, the transaction holds the row (the write
+    lock on SQLite), so writing it back on flush changes nothing.
+
+    False when the spool was deleted meanwhile: the caller skips it, since a
+    usage row for it would break its foreign key on PostgreSQL and roll back
+    the whole completion.
+    """
+    now = datetime.now(timezone.utc)
+    stored = await db.scalar(
+        update(Spool)
+        .where(Spool.id == spool.id)
+        .values(weight_used=func.coalesce(Spool.weight_used, 0) + grams, last_used=now)
+        .returning(Spool.weight_used)
+        .execution_options(synchronize_session=False)
+    )
+    if stored is None:
+        return False
+    spool.weight_used = stored
+    spool.last_used = now
+    return True
+
+
 async def persist_session(
     db: AsyncSession,
     session: PrintSession,
@@ -422,6 +474,7 @@ async def clear_persisted_session(db: AsyncSession, printer_id: int) -> None:
 def forget_printer(printer_id: int) -> None:
     """A deleted printer's print-start context in memory (the row goes with the printer)."""
     _active_sessions.pop(printer_id, None)
+    _completing.discard(printer_id)
     _tray_change_locks.pop(printer_id, None)
 
 
@@ -433,6 +486,7 @@ async def discard_session(db: AsyncSession, printer_id: int) -> None:
     the internal tracker's ``on_print_complete`` consumes (and pops) it.
     """
     _active_sessions.pop(printer_id, None)
+    _completing.discard(printer_id)
     _tray_change_locks.pop(printer_id, None)
     await clear_persisted_session(db, printer_id)
 
@@ -522,6 +576,8 @@ async def on_print_start(
     (#880); registering a session the internal tracker will never complete
     would suppress a sync those users still need.
     """
+    # A new print: whatever the last completion left marked is over.
+    _completing.discard(printer_id)
     state = printer_manager.get_status(printer_id)
     if not state or not state.raw_data:
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
@@ -926,8 +982,8 @@ async def on_print_complete(
                 weight_grams = (delta_pct / 100.0) * spool.label_weight
 
                 # Update spool
-                spool.weight_used = (spool.weight_used or 0) + weight_grams
-                spool.last_used = datetime.now(timezone.utc)
+                if not await _charge_spool(db, spool, weight_grams):
+                    continue
 
                 # Calculate cost for this usage
                 cost = None
@@ -1753,8 +1809,8 @@ async def _track_from_3mf(
                 if not spool:
                     continue
 
-                spool.weight_used = (spool.weight_used or 0) + segment_grams
-                spool.last_used = datetime.now(timezone.utc)
+                if not await _charge_spool(db, spool, segment_grams):
+                    continue
 
                 percent = round(segment_grams / (spool.label_weight or 1000) * 100)
 
@@ -1925,8 +1981,8 @@ async def _track_from_3mf(
             continue
 
         # Update spool
-        spool.weight_used = (spool.weight_used or 0) + weight_grams
-        spool.last_used = datetime.now(timezone.utc)
+        if not await _charge_spool(db, spool, weight_grams):
+            continue
 
         percent = round(weight_grams / (spool.label_weight or 1000) * 100)
 

+ 74 - 3
backend/app/utils/threemf_tools.py

@@ -1050,7 +1050,7 @@ _BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
 _GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
 
 
-def _plate_temperature(val) -> int | None:
+def _plate_temperature(val, filament_indexes: list[int] | None = None) -> int | None:
     """Bed temperature from one plate-temperature entry, or None.
 
     The plate arrays carry one entry per filament in the project, and a 0 means
@@ -1058,8 +1058,15 @@ def _plate_temperature(val) -> int | None:
     so the print runs at the highest its filaments ask for -- taking entry 0 the
     way the neighbouring scalar settings do would store a 0 for any project
     whose first filament is not one this plate is heated for.
+
+    ``filament_indexes`` (0-based) are the filaments the printed plate uses:
+    only theirs count, so a PLA plate of a project that also holds PETG is not
+    heated to the PETG temperature. Without them every filament counts.
     """
     values = val if isinstance(val, list) else [val]
+    if filament_indexes and isinstance(val, list):
+        picked = [values[i] for i in filament_indexes if 0 <= i < len(values)]
+        values = picked or values
     temps = []
     for entry in values:
         if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
@@ -1071,18 +1078,19 @@ def _plate_temperature(val) -> int | None:
     return max(temps) if temps else None
 
 
-def bed_temperature_from_config(data: dict) -> int | None:
+def bed_temperature_from_config(data: dict, filament_indexes: list[int] | None = None) -> int | None:
     """Bed temperature for the plate *data* is sliced for, or None (#2989).
 
     *data* is a parsed ``Metadata/project_settings.config``. Lives here rather
     than beside the archive parser so the ingest path and the one-shot backfill
     that repairs archives written before the fix read it exactly the same way.
+    ``filament_indexes``: the printed plate's filaments, see _plate_temperature.
     """
     bed_type = str(data.get("curr_bed_type") or "").strip()
     for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
         if key not in data:
             continue
-        temperature = _plate_temperature(data[key])
+        temperature = _plate_temperature(data[key], filament_indexes)
         # A plate array of all zeros means no filament in the project prints on
         # this plate, which is not a bed temperature -- keep looking rather than
         # recording a 0 that reads as "cold bed".
@@ -1175,6 +1183,69 @@ def _plate_number_of(name: str) -> int | None:
         return None
 
 
+def used_filament_indexes(plates: list, plate_number: int | None) -> list[int]:
+    """Positions, in the project's per-filament settings, of the filaments the
+    printed plate uses, sorted: empty when that can't be told.
+
+    *plates* are the ``<plate>`` elements of ``slice_info.config``, whose
+    filament ids are 1-based. ``project_settings`` lists one value per filament
+    of the whole project, so the printed plate's own filaments pick which apply.
+    Without a plate number, the only plate decides.
+    """
+    if plate_number:
+        printed = [
+            plate
+            for plate in plates
+            if any(
+                meta.get("key") == "index" and meta.get("value") == str(plate_number)
+                for meta in plate.findall("metadata")
+            )
+        ]
+    else:
+        printed = plates if len(plates) == 1 else []
+    used: list[int] = []
+    for plate in printed[:1]:
+        for filament in plate.findall("filament"):
+            try:
+                if float(filament.get("used_g", "0")) > 0:
+                    used.append(int(filament.get("id", "")))
+            except (TypeError, ValueError):
+                continue
+    return sorted(fid - 1 for fid in set(used) if fid > 0)
+
+
+def plate_bed_temperature(file_path: Path, plate_id: int | None) -> int | None:
+    """Bed temperature for the plate of *file_path* that prints, or None.
+
+    An archive stores one bed temperature, the highest any filament of the
+    project asks for, while a queued job may print any plate of it: a PLA plate
+    of a project that also holds PETG is heated for PLA. *plate_id* as a queue
+    job holds it; none (or 0) means the file's default plate, the one dispatched.
+    None when the file can't be read or names no bed temperature, so the caller
+    falls back to the stored value.
+
+    Every failure is None, as in extract_bed_temperature_from_3mf: preheat and
+    keep-warm run this for every job, and a damaged file raises well beyond the
+    expected few (zlib.error from corrupt deflate data, OverflowError from an
+    "inf" temperature). Escaping here would fail the dispatch every round, or
+    end keep-warm for every printer.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            names = zf.namelist()
+            if "Metadata/project_settings.config" not in names:
+                return None
+            data = json.loads(zf.read("Metadata/project_settings.config").decode())
+            slice_info = zf.read("Metadata/slice_info.config") if "Metadata/slice_info.config" in names else None
+        if not isinstance(data, dict):
+            return None
+        plates = ET.fromstring(slice_info).findall("plate") if slice_info else []
+        plate_number = plate_id if plate_id is not None and plate_id > 0 else default_plate_number(names)
+        return bed_temperature_from_config(data, used_filament_indexes(plates, plate_number))
+    except Exception:
+        return None
+
+
 def select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
     """The ``.gcode`` member for exactly ``plate_id``, or None if it isn't there.
 

+ 73 - 3
backend/tests/integration/test_ams_remain_sync_while_printing.py

@@ -9,13 +9,20 @@ stale persisted session never registers.
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
+from sqlalchemy import event
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.orm.attributes import set_committed_value
 
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.services.usage_tracker import _active_sessions
 
 
+def _loaded_before_the_hand_edit(spool, _context):
+    set_committed_value(spool, "weight_locked", False)
+    set_committed_value(spool, "weight_used", 100)
+
+
 @pytest.fixture(autouse=True)
 def _no_sessions():
     _active_sessions.clear()
@@ -23,11 +30,23 @@ def _no_sessions():
     _active_sessions.clear()
 
 
-async def _weight_after_ams_push(test_engine, db_session, printer_factory, state: str) -> float:
+async def _weight_after_ams_push(
+    test_engine,
+    db_session,
+    printer_factory,
+    state: str,
+    sent: list[str] | None = None,
+    *,
+    weight_used: float = 100,
+    weight_locked: bool = False,
+    loaded_as_unlocked: bool = False,
+) -> float:
+    """``loaded_as_unlocked``: the sync loads the spool unlocked at 100 g, as
+    it was before a hand edit that committed right after that load."""
     from backend.app.main import on_ams_change
 
     printer = await printer_factory(name="X1C")
-    spool = Spool(material="PLA", label_weight=1000, weight_used=100)
+    spool = Spool(material="PLA", label_weight=1000, weight_used=weight_used, weight_locked=weight_locked)
     db_session.add(spool)
     await db_session.flush()
     db_session.add(
@@ -65,7 +84,16 @@ async def _weight_after_ams_push(test_engine, db_session, printer_factory, state
         mock_ws.send_printer_status = AsyncMock()
         mock_ws.broadcast = AsyncMock()
 
-        await on_ams_change(printer.id, ams_data)
+        if loaded_as_unlocked:
+            event.listen(Spool, "load", _loaded_before_the_hand_edit)
+        try:
+            await on_ams_change(printer.id, ams_data)
+        finally:
+            if loaded_as_unlocked:
+                event.remove(Spool, "load", _loaded_before_the_hand_edit)
+
+    if sent is not None:
+        sent.extend(c.args[0]["type"] for c in mock_ws.broadcast.call_args_list)
 
     db_session.expire_all()
     return (await db_session.get(Spool, spool_id)).weight_used
@@ -82,3 +110,45 @@ async def test_no_sync_while_printing_without_a_tracker_session(test_engine, db_
 @pytest.mark.integration
 async def test_syncs_when_idle(test_engine, db_session, printer_factory):
     assert await _weight_after_ams_push(test_engine, db_session, printer_factory, "IDLE") == 500
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_weight_is_announced(test_engine, db_session, printer_factory):
+    """WU-9: other open tabs refresh weights only on inventory_changed."""
+    sent: list[str] = []
+
+    assert await _weight_after_ams_push(test_engine, db_session, printer_factory, "IDLE", sent) == 500
+    assert "inventory_changed" in sent
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_weight_set_by_hand_meanwhile_is_kept(test_engine, db_session, printer_factory):
+    """WL-6: the sync decided on the spool as it had loaded it. A weight set by
+    hand (and so locked) after that load was overwritten, with the lock left set."""
+    sent: list[str] = []
+    weight = await _weight_after_ams_push(
+        test_engine,
+        db_session,
+        printer_factory,
+        "IDLE",
+        sent,
+        weight_used=300,
+        weight_locked=True,
+        loaded_as_unlocked=True,
+    )
+
+    assert weight == 300
+    assert "inventory_changed" not in sent, "nothing changed, so nothing to announce"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_no_sync_while_a_finished_print_is_being_charged(test_engine, db_session, printer_factory):
+    """WU-3: the printer reports FINISH and the tracker has taken the session,
+    but the usage is not booked yet."""
+    with patch("backend.app.services.usage_tracker.printer_is_charging", lambda printer_id: True):
+        weight = await _weight_after_ams_push(test_engine, db_session, printer_factory, "FINISH")
+
+    assert weight == 100

+ 107 - 0
backend/tests/integration/test_assignment_routes_take_the_slot_lock.py

@@ -0,0 +1,107 @@
+"""Spool assignments made by hand wait for the printer's assignment lock (SA-5).
+
+The AMS change handler writes a printer's slot assignments under a per-printer
+lock, so two of its updates can't both find a slot free and both insert. The
+assign, unassign and create-from-slot routes wrote the same rows without it: a
+manual assign racing an AMS update (or a double-click) on PostgreSQL failed
+with a 500 on the unique (printer, AMS, tray) constraint, or on a row the other
+writer had just deleted.
+"""
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services.ams_assignment_lock import ams_assignment_locks, get_ams_assignment_lock
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _fresh_locks():
+    """A contended lock binds to its test's event loop; printer ids repeat."""
+    ams_assignment_locks.clear()
+    yield
+    ams_assignment_locks.clear()
+
+
+TRAY = {"id": 0, "tray_type": "PLA", "tray_color": "FF0000FF", "tag_uid": "A1B2C3D4E5F60708", "state": 11}
+
+
+async def _waits_for_the_lock(printer_id: int, request) -> object:
+    """Run *request* while the lock is held: it must not finish until released."""
+    lock = get_ams_assignment_lock(printer_id)
+    await lock.acquire()
+    try:
+        task = asyncio.ensure_future(request)
+        await asyncio.sleep(0.3)
+        assert not task.done(), "the route wrote the slot without waiting for the lock"
+    finally:
+        lock.release()
+    return await asyncio.wait_for(task, 10)
+
+
+@pytest.fixture
+def printer_status():
+    status = SimpleNamespace(raw_data={"ams": [{"id": 0, "tray": [TRAY]}]}, state="IDLE", connected=True)
+    with (
+        patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=status)),
+        patch("backend.app.services.printer_manager.printer_manager.get_client", MagicMock(return_value=None)),
+        patch("backend.app.api.routes.inventory.ws_manager") as ws,
+    ):
+        ws.broadcast = AsyncMock()
+        yield
+
+
+async def _spool(db_session) -> Spool:
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0)
+    db_session.add(spool)
+    await db_session.commit()
+    return spool
+
+
+async def test_assigning(async_client, db_session, printer_factory, printer_status):
+    printer = await printer_factory()
+    spool = await _spool(db_session)
+
+    response = await _waits_for_the_lock(
+        printer.id,
+        async_client.post(
+            "/api/v1/inventory/assignments",
+            json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
+        ),
+    )
+
+    assert response.status_code == 200, response.text
+
+
+async def test_unassigning(async_client, db_session, printer_factory, printer_status):
+    printer = await printer_factory()
+    spool = await _spool(db_session)
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
+    await db_session.commit()
+
+    response = await _waits_for_the_lock(
+        printer.id, async_client.delete(f"/api/v1/inventory/assignments/{printer.id}/0/0")
+    )
+
+    assert response.status_code == 200, response.text
+    assert (await db_session.execute(select(SpoolAssignment))).scalars().first() is None
+
+
+async def test_creating_a_spool_from_a_slot(async_client, db_session, printer_factory, printer_status):
+    printer = await printer_factory()
+
+    response = await _waits_for_the_lock(
+        printer.id,
+        async_client.post(
+            "/api/v1/inventory/spools/from-slot", json={"printer_id": printer.id, "ams_id": 0, "tray_id": 0}
+        ),
+    )
+
+    assert response.status_code == 200, response.text

+ 58 - 0
backend/tests/integration/test_bulk_edit_dispatch_claim.py

@@ -0,0 +1,58 @@
+"""Bulk edit leaves a row a dispatch claimed during the edit (DM/BULK).
+
+The bulk edit checked the claim on the rows it loaded, then awaited cost and
+budget checks per row before changing it. A row the scheduler claimed for
+dispatch in between was changed anyway, so its printer or cost centre no
+longer matched the upload already under way.
+"""
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.finance import CostCenter
+from backend.app.models.print_queue import PrintQueueItem
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_a_row_claimed_during_the_edit_is_skipped(
+    async_client, db_session, test_engine, printer_factory, archive_factory
+):
+    printer = await printer_factory()
+    other_printer = await printer_factory(name="Other")
+    archive = await archive_factory(printer.id)
+    center = CostCenter(name="Lab", is_active=True, is_private=False)
+    db_session.add(center)
+    claimed = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", target_model="X1C")
+    free = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending")
+    db_session.add_all([claimed, free])
+    await db_session.commit()
+    other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+
+    async def dispatch_claims(*_args, exclude_queue_item_id=None, **_kwargs):
+        if exclude_queue_item_id == claimed.id:
+            async with other() as db:
+                await db.execute(
+                    update(PrintQueueItem)
+                    .where(PrintQueueItem.id == claimed.id)
+                    .values(dispatching_at=datetime.now(timezone.utc))
+                )
+                await db.commit()
+
+    with patch("backend.app.api.routes.print_queue.validate_print_budget", AsyncMock(side_effect=dispatch_claims)):
+        response = await async_client.patch(
+            "/api/v1/queue/bulk",
+            json={"item_ids": [claimed.id, free.id], "cost_center_id": center.id, "printer_id": other_printer.id},
+        )
+
+    assert response.status_code == 200, response.text
+    assert (response.json()["updated_count"], response.json()["skipped_count"]) == (1, 1)
+    async with other() as db:
+        claimed_row = await db.get(PrintQueueItem, claimed.id)
+        free_row = await db.get(PrintQueueItem, free.id)
+    assert (claimed_row.printer_id, claimed_row.cost_center_id, claimed_row.target_model) == (printer.id, None, "X1C")
+    assert (free_row.printer_id, free_row.cost_center_id) == (other_printer.id, center.id)

+ 129 - 0
backend/tests/integration/test_completion_keeps_printer_charging.py

@@ -0,0 +1,129 @@
+"""A printer counts as printing until its finished print's usage is booked (WU-3, AS-5).
+
+The AMS remain% weight sync stays off while a print runs, because completion
+charges the print exactly from its 3MF (#880). The tracker takes the print's
+session first and only then works out and charges the usage, several awaits
+later. The printer already reports FINISH, so an AMS update in that window
+raised the spool's weight from remain% and the tracker then added the print
+on top: charged twice.
+
+A completion with neither file name nor subtask name returned before any of
+this, leaving the session open, which kept the weight sync off until the next
+print. It now closes the session; it charges nothing, having no print to
+charge (AS-5).
+"""
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services import usage_tracker
+from backend.app.services.usage_tracker import PrintSession, _active_sessions, printer_is_charging
+from backend.tests.integration.test_print_complete_end_to_end import (  # noqa: F401
+    _clean,
+    _complete,
+    _no_finish_photo,
+    queued_print,
+)
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+@pytest.fixture(autouse=True)
+def _no_sessions_or_marks():
+    _active_sessions.clear()
+    usage_tracker._completing.clear()
+    yield
+    _active_sessions.clear()
+    usage_tracker._completing.clear()
+
+
+def _open_session(printer_id: int) -> None:
+    _active_sessions[printer_id] = PrintSession(
+        printer_id=printer_id, print_name="part", started_at=datetime.now(timezone.utc)
+    )
+
+
+async def _complete_watching(test_engine, printer_id: int, **extra) -> list:
+    """Complete the print; the tracker, as the real one, takes the session first.
+
+    Returns the archive each charge was made for; asserts the printer still
+    counted as printing when it was made."""
+    seen: list = []
+
+    async def tracker(pid, data, printer_manager, db, **kwargs):
+        _active_sessions.pop(pid, None)
+        assert printer_is_charging(pid), "the AMS sync could charge the print as well"
+        seen.append(kwargs.get("archive_id"))
+        return []
+
+    with patch("backend.app.services.usage_tracker.on_print_complete", tracker):
+        await _complete(test_engine, printer_id, "completed", extra=extra or None)
+    return seen
+
+
+async def test_the_printer_counts_as_printing_until_the_usage_is_booked(test_engine, queued_print):
+    _open_session(queued_print["printer"])
+
+    assert await _complete_watching(test_engine, queued_print["printer"]) == [queued_print["archive"]]
+    assert not printer_is_charging(queued_print["printer"])
+
+
+async def test_a_completion_without_a_file_name_closes_the_session_and_charges_nothing(
+    test_engine, db_session, queued_print
+):
+    """Which print ended can't be told: a printing archive is almost always an
+    earlier print's, which the reconnect reconcile closes and charges. Closing
+    the session is what ends the stuck weight sync."""
+    from backend.app.models.archive import PrintArchive
+
+    _open_session(queued_print["printer"])
+
+    charges = await _complete_watching(test_engine, queued_print["printer"], filename="", subtask_name="")
+
+    assert charges == []
+    assert not printer_is_charging(queued_print["printer"]), "the weight sync stayed off"
+    db_session.expire_all()
+    assert (await db_session.get(PrintArchive, queued_print["archive"])).status == "printing"
+
+
+async def test_a_completion_that_fails_does_not_leave_the_printer_printing(test_engine, queued_print):
+    with (
+        patch("backend.app.main._recover_fallback_from_cache_before_eviction", AsyncMock(side_effect=RuntimeError)),
+        pytest.raises(RuntimeError),
+    ):
+        await _complete(test_engine, queued_print["printer"], "completed")
+
+    assert not printer_is_charging(queued_print["printer"])
+
+
+async def test_a_new_print_clears_a_stale_mark():
+    from types import SimpleNamespace
+    from unittest.mock import MagicMock
+
+    usage_tracker._completing.add(5)
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = SimpleNamespace(raw_data=None)
+
+    await usage_tracker.on_print_start(5, {}, printer_manager)
+
+    assert not printer_is_charging(5)
+
+
+async def test_booking_the_usage_ends_the_mark(db_session):
+    """The rest of the completion (SD cleanup, notifications) can take a while;
+    the sync need not wait for it once the print is charged."""
+    usage_tracker.mark_completing(5)
+
+    await usage_tracker.discard_session(db_session, 5)
+
+    assert not printer_is_charging(5)
+
+
+async def test_a_deleted_printer_is_forgotten():
+    usage_tracker.mark_completing(5)
+
+    usage_tracker.forget_printer(5)
+
+    assert not printer_is_charging(5)

+ 61 - 0
backend/tests/integration/test_inventory_ams_weight_sync_lock.py

@@ -133,3 +133,64 @@ async def test_a_disconnected_printers_last_report_is_not_used(async_client: Asy
     spool = await db_session.get(Spool, spool_id)
     await db_session.refresh(spool)
     assert spool.weight_used == 100
+
+
+def _broadcast_types(ws) -> list[str]:
+    return [c.args[0]["type"] for c in ws.broadcast.call_args_list]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_weight_is_announced(async_client: AsyncClient, db_session, printer_factory):
+    """WU-9: other open tabs refresh only on inventory_changed."""
+    from unittest.mock import AsyncMock
+
+    await _assigned_spool(db_session, printer_factory, locked=False)
+
+    with patch("backend.app.api.routes.inventory.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        await _sync(async_client, _printer("IDLE"))
+
+    assert "inventory_changed" in _broadcast_types(ws)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_weighed_spool_is_announced(async_client: AsyncClient, db_session):
+    from unittest.mock import AsyncMock
+
+    db_session.add(
+        SpoolBuddyDevice(
+            device_id="sb-1", hostname="spoolbuddy-1", ip_address="10.0.0.1", last_seen=datetime.now(timezone.utc)
+        )
+    )
+    spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=100)
+    db_session.add(spool)
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        await async_client.post(
+            "/api/v1/spoolbuddy/scale/update-spool-weight", json={"spool_id": spool.id, "weight_grams": 600}
+        )
+
+    assert "inventory_changed" in _broadcast_types(ws)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_print_whose_usage_is_being_booked_counts_as_printing(
+    async_client: AsyncClient, db_session, printer_factory
+):
+    """WU-3: the tracker has taken the session but not charged the print yet."""
+    from backend.app.services import usage_tracker
+
+    spool_id = await _assigned_spool(db_session, printer_factory, locked=False)
+    printer_id = await db_session.scalar(select(SpoolAssignment.printer_id).where(SpoolAssignment.spool_id == spool_id))
+    usage_tracker._completing.add(printer_id)
+    try:
+        response = await _sync(async_client, _printer("FINISH"))
+    finally:
+        usage_tracker._completing.discard(printer_id)
+
+    assert (response.json()["synced"], response.json()["printing"]) == (0, 1)

+ 17 - 0
backend/tests/integration/test_inventory_csv.py

@@ -433,6 +433,23 @@ class TestInventoryCsvUsageColumns:
         assert spool.last_used is not None
         assert spool.last_used.year == 2026 and spool.last_used.month == 6 and spool.last_used.day == 1
 
+    @pytest.mark.parametrize(
+        ("written", "stored"),
+        [("2026-10-01T10:00:00+02:00", "2026-10-01 08:00:00"), ("2026-10-01T10:00:00", "2026-10-01 10:00:00")],
+    )
+    async def test_import_stores_last_used_in_utc(
+        self, async_client: AsyncClient, db_session: AsyncSession, written, stored
+    ):
+        """LU-1: an offset was dropped on SQLite, keeping the wall time. A time
+        without one is UTC, as export writes it."""
+        csv_text = f"material,color_name,rgba,last_used\nPLA,White,ffffffff,{written}\n"
+
+        response = await async_client.post("/api/v1/inventory/spools/import", files=_csv_upload(csv_text))
+
+        assert response.status_code == 200, response.text
+        spool = (await db_session.execute(select(Spool))).scalars().one()
+        assert str(spool.last_used.replace(tzinfo=None)) == stored
+
     async def test_import_rejects_bad_last_used(self, async_client: AsyncClient):
         csv_text = "material,color_name,rgba,last_used\nPLA,White,ffffffff,not-a-date\n"
 

+ 115 - 0
backend/tests/integration/test_library_add_to_queue_plate_filament.py

@@ -0,0 +1,115 @@
+"""A multi-plate file queued without a plate is matched on the plate that prints (PL-C6).
+
+Queued without a plate, the file prints its default plate. The scheduler was
+nevertheless told every plate's filament, so an X1C with only plate 1's PLA
+loaded never qualified because plate 2 needs PETG-CF; and with the type list
+narrowed alone, the AMS mapping and the unmatched-filament check still read
+every plate. The plate that prints is now stored on the job.
+"""
+
+import json
+import zipfile
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _two_plate_3mf(path):
+    plate = (
+        "<plate><metadata key='index' value='{n}'/><filament id='{n}' type='{t}' color='#FFFFFF' used_g='5'/></plate>"
+    )
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config>" + plate.format(n=1, t="PLA") + plate.format(n=2, t="PETG-CF") + "</config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+        zf.writestr("Metadata/plate_2.gcode", "; plate 2")
+
+
+async def _library_file(db_session, tmp_path, monkeypatch) -> LibraryFile:
+    from backend.app.core.config import settings as app_settings
+
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    _two_plate_3mf(tmp_path / "two.gcode.3mf")
+    lib_file = LibraryFile(
+        filename="two.gcode.3mf",
+        file_path="two.gcode.3mf",
+        file_type="3mf",
+        file_size=1,
+        file_hash="two-plates",
+        source_type="uploaded",
+        file_metadata={"sliced_for_model": "X1C"},
+    )
+    db_session.add(lib_file)
+    await db_session.commit()
+    return lib_file
+
+
+async def _requirements(db_session, item) -> list[str]:
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    return [r["type"] for r in await PrintScheduler()._get_filament_requirements(db_session, item)]
+
+
+@pytest.mark.parametrize("model", ["X1C", "x1c"])
+async def test_a_bulk_add_from_the_library(async_client, db_session, printer_factory, tmp_path, monkeypatch, model):
+    """In lower case too, as the scheduler matches a model (Q-U8)."""
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post(
+        "/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id], "target_model": model}
+    )
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.library_file_id == lib_file.id))
+    assert (item.plate_id, item.target_model, json.loads(item.required_filament_types)) == (1, "X1C", ["PLA"])
+    assert await _requirements(db_session, item) == ["PLA"]
+
+
+async def test_post_queue(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post("/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C"})
+
+    assert response.status_code == 200, response.text
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert (item.plate_id, json.loads(item.required_filament_types)) == (1, ["PLA"])
+    assert await _requirements(db_session, item) == ["PLA"]
+
+
+async def test_a_plate_named_by_the_caller_stands(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+
+    response = await async_client.post(
+        "/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C", "plate_id": 2}
+    )
+
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert (item.plate_id, json.loads(item.required_filament_types)) == (2, ["PETG-CF"])
+
+
+async def test_a_single_plate_file_keeps_no_plate(async_client, db_session, printer_factory, tmp_path, monkeypatch):
+    """Nothing to choose between, so the job stays as it always was."""
+    await printer_factory(model="X1C")
+    lib_file = await _library_file(db_session, tmp_path, monkeypatch)
+    with zipfile.ZipFile(tmp_path / "two.gcode.3mf", "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config><plate><metadata key='index' value='1'/>"
+            "<filament id='1' type='PLA' color='#FFFFFF' used_g='5'/></plate></config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+
+    response = await async_client.post("/api/v1/queue/", json={"library_file_id": lib_file.id, "target_model": "X1C"})
+
+    item = await db_session.get(PrintQueueItem, response.json()["id"])
+    assert item.plate_id is None

+ 64 - 0
backend/tests/integration/test_pipeline_runs_api.py

@@ -472,6 +472,45 @@ class TestCancelRun:
         assert cancel_resp.status_code == 200
         assert cancel_resp.json()["status"] == "cancelled"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cancel_stops_the_queue_entry_like_a_queue_cancel(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+        db_session,
+    ):
+        """QS/I7: a preheating entry stops heating, its budget hold is freed."""
+        from backend.app.models.finance import BudgetReservation, CostCenter
+        from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        pipeline = await pipeline_factory(target_printer_id=printer.id)
+        src = await library_file_factory()
+        center = CostCenter(name="Lab", is_active=True, is_private=False)
+        entry = PrintQueueItem(printer_id=printer.id, library_file_id=src.id, status="pending")
+        db_session.add_all([center, entry])
+        await db_session.flush()
+        run = PipelineRun(pipeline_id=pipeline["id"], source_library_file_id=src.id, status="running", copies=1)
+        db_session.add(run)
+        await db_session.flush()
+        db_session.add(PipelineJob(pipeline_run_id=run.id, queue_entry_id=entry.id, status="queued"))
+        hold = BudgetReservation(cost_center_id=center.id, amount=5.0, source_type="print_queue", source_id=entry.id)
+        db_session.add(hold)
+        await db_session.commit()
+
+        with patch("backend.app.services.print_scheduler.scheduler.notify_dispatch_cancelled") as notify:
+            response = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
+
+        assert response.status_code == 200, response.text
+        notify.assert_called_once_with(entry.id)
+        await db_session.refresh(hold)
+        await db_session.refresh(entry)
+        assert (entry.status, hold.status) == ("cancelled", "released")
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_run_accepts_archive_source(
@@ -665,6 +704,31 @@ class TestPipelineC:
         # AMS empty + no live state → both are offline, so ok=False.
         assert body["ok"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_class_eligibility_matches_the_model_regardless_of_case(
+        self,
+        async_client: AsyncClient,
+        pipeline_factory,
+        printer_factory,
+        library_file_factory,
+    ):
+        """As the queue and the scheduler match a model (Q-U8)."""
+        await printer_factory(model="X1C")
+        pipeline = await pipeline_factory()
+        await async_client.put(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}",
+            json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "x1c"},
+        )
+        src = await library_file_factory()
+        resp = await async_client.post(
+            f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
+            json={"source_library_file_id": src.id},
+        )
+        body = resp.json()
+        assert len(body["printer_reports"]) == 1
+        assert not any(i["kind"] == "no_class_matches" for i in body["issues"])
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_class_eligibility_no_matching_printers(

+ 78 - 0
backend/tests/integration/test_plate_filament_preset.py

@@ -0,0 +1,78 @@
+"""Each plate filament carries the slicer preset it was sliced with (SN-C9).
+
+The plate's type is only "PLA"; the colour catalogue is keyed by product line
+("PLA Matte"), where the same hex has another name. The queue page names a
+colour from these plate listings, so each needs the preset id (tray_info_idx)
+that names the line. All three listings are copies of one parser.
+"""
+
+import io
+import zipfile
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+def _sliced_3mf() -> bytes:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr(
+            "Metadata/slice_info.config",
+            "<?xml version='1.0'?><config><plate><metadata key='index' value='1'/>"
+            "<filament id='1' tray_info_idx='GFA01' type='PLA' color='#000000' used_g='5' used_m='1.6'/>"
+            "<filament id='2' type='PLA' color='#FFFFFF' used_g='3' used_m='1'/>"
+            "</plate></config>",
+        )
+        zf.writestr("Metadata/plate_1.gcode", "; plate 1")
+    return buf.getvalue()
+
+
+def _presets(response) -> list[str]:
+    assert response.status_code == 200, response.text
+    return [f["tray_info_idx"] for f in response.json()["plates"][0]["filaments"]]
+
+
+@pytest.fixture
+def base_dir(monkeypatch, tmp_path) -> Path:
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+async def test_archive_plates(async_client, archive_factory, printer_factory, base_dir):
+    (base_dir / "matte.3mf").write_bytes(_sliced_3mf())
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, filename="matte.3mf", file_path="matte.3mf")
+
+    response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
+
+    assert _presets(response) == ["GFA01", ""]
+
+
+async def test_library_file_plates(async_client, db_session, base_dir):
+    from backend.app.models.library import LibraryFile
+
+    path = base_dir / "matte.3mf"
+    path.write_bytes(_sliced_3mf())
+    lib_file = LibraryFile(filename="matte.3mf", file_path=str(path), file_type="3mf", file_size=1)
+    db_session.add(lib_file)
+    await db_session.commit()
+
+    response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
+
+    assert _presets(response) == ["GFA01", ""]
+
+
+async def test_printer_file_plates(async_client, printer_factory):
+    printer = await printer_factory()
+
+    with patch("backend.app.api.routes.printers._download_printer_file_bytes", AsyncMock(return_value=_sliced_3mf())):
+        response = await async_client.get(
+            f"/api/v1/printers/{printer.id}/files/plates", params={"path": "/cache/matte.3mf"}
+        )
+
+    assert _presets(response) == ["GFA01", ""]

+ 71 - 0
backend/tests/integration/test_preconfig_replay_failure.py

@@ -0,0 +1,71 @@
+"""A pre-assigned slot the printer could not be told about is retried (SA-3).
+
+SpoolBuddy assigns a spool to an empty slot with an empty fingerprint; the
+slot is configured on the printer once filament goes in. The fingerprint was
+recorded even when nothing could be sent (no MQTT client while the printer
+reconnects), and a recorded fingerprint ends the replay, so the slot was never
+configured.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.tests.integration.test_inventory_assign import _make_mock_status, spool_factory  # noqa: F401
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply: AsyncMock):
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory(name="H2D")
+    spool = await spool_factory(slicer_filament="GFL05", material="PLA")
+    assignment = SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=2, tray_id=3)
+    db_session.add(assignment)
+    await db_session.commit()
+    ams_data = [{"id": 2, "tray": [{"id": 3, "tray_type": "PLA", "tray_color": "FF0000FF", "state": 11}]}]
+    status = _make_mock_status(ams_data=ams_data)
+
+    with (
+        patch("backend.app.api.routes.inventory.apply_spool_to_slot_via_mqtt", apply),
+        patch("backend.app.main.printer_manager") as pm,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+    ):
+        pm.get_printer.return_value = MagicMock(name="H2D", serial_number="0948BB540200427")
+        pm.get_status.return_value = status
+        pm.get_client.return_value = MagicMock()
+        pm.get_model.return_value = "H2D"
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        await on_ams_change(printer.id, ams_data)
+
+    await db_session.refresh(assignment)
+    apply.assert_awaited_once()
+    return assignment.fingerprint_type, assignment.fingerprint_color
+
+
+async def test_a_slot_the_printer_could_not_be_told_about_is_retried(
+    async_client, db_session, printer_factory, spool_factory
+):
+    """No MQTT client (reconnecting): nothing was sent."""
+    apply = AsyncMock(return_value=False)
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == (None, None)
+
+
+async def test_a_configuration_that_raised_is_not_retried(async_client, db_session, printer_factory, spool_factory):
+    """It may have sent part of the commands, and would raise again on every
+    AMS change, mid-print included (remain% steps count as changes)."""
+    apply = AsyncMock(side_effect=RuntimeError("preset lookup failed"))
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == ("PLA", "FF0000FF")
+
+
+async def test_a_configured_slot_records_its_fingerprint(async_client, db_session, printer_factory, spool_factory):
+    apply = AsyncMock(return_value=True)
+
+    assert await _fingerprint_after_insert(db_session, printer_factory, spool_factory, apply) == ("PLA", "FF0000FF")

+ 93 - 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}
 
 
-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)
     tasks_before = set(asyncio.all_tasks())
     with (
@@ -110,7 +110,13 @@ async def _complete(test_engine, printer_id: int, status: str, name: str = "part
         plug.on_print_complete = AsyncMock()
         await main_module.on_print_complete(
             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
         if spawned:
@@ -213,6 +219,43 @@ class TestAStopWhileThePrinterIsOffline:
         ]
         assert ws.send_archive_updated.await_args.args[0] == {"id": queued_print["archive"], "status": "cancelled"}
 
+    async def test_frees_the_budget_held_for_the_print(self, async_client, db_session, queued_print):
+        """QS/I5: no completion will come to release it, which left the cost
+        centre's budget reserved until Bambuddy restarted."""
+        from backend.app.models.finance import BudgetReservation
+
+        hold = BudgetReservation(
+            cost_center_id=queued_print["center"], amount=5.0, source_type="print_queue", source_id=queued_print["item"]
+        )
+        db_session.add(hold)
+        await db_session.commit()
+
+        await self._stop(async_client, queued_print)
+
+        await db_session.refresh(hold)
+        assert hold.status == "released"
+
+    async def test_a_stop_that_reached_the_printer_leaves_it_to_the_print_s_end(
+        self, async_client, db_session, queued_print
+    ):
+        """The print's end charges the run and releases the hold itself."""
+        from backend.app.models.finance import BudgetReservation
+
+        hold = BudgetReservation(
+            cost_center_id=queued_print["center"], amount=5.0, source_type="print_queue", source_id=queued_print["item"]
+        )
+        db_session.add(hold)
+        await db_session.commit()
+
+        with (
+            patch("backend.app.services.print_control.stop_print_by_user", return_value=True),
+            patch("backend.app.api.routes.print_queue.ws_manager"),
+        ):
+            await async_client.post(f"/api/v1/queue/{queued_print['item']}/stop")
+
+        await db_session.refresh(hold)
+        assert hold.status == "active"
+
     async def test_a_completion_that_arrives_after_all_replaces_it(
         self, async_client, test_engine, db_session, queued_print
     ):
@@ -307,3 +350,51 @@ class TestACompletionNoArchiveWasBoundTo:
 
         db_session.expire_all()
         assert (await db_session.get(PrintArchive, own_id)).status == "completed"
+
+
+async def test_the_layer_timelapse_is_attached_for_the_run_that_ended(test_engine, db_session, queued_print, tmp_path):
+    """The run is read before the stitch, which can take long enough for the
+    archive to start printing again (TL-3)."""
+    stitched = tmp_path / "stitched.mp4"
+    stitched.write_bytes(b"layer video")
+    attach = AsyncMock()
+
+    with (
+        patch("backend.app.services.layer_timelapse.on_print_complete", AsyncMock(return_value=stitched)),
+        patch("backend.app.main._attach_layer_timelapse", attach),
+    ):
+        await _complete(test_engine, queued_print["printer"], "completed")
+
+    attach.assert_awaited_once()
+    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))
+
+
+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)
+
+
+async def test_a_hung_sd_cleanup_does_not_hold_up_the_print_s_end(test_engine, db_session, queued_print, monkeypatch):
+    """QS/I9: the job is closed (and billed) by its end, not by the stranded-job
+    closer, however long the printer takes to answer a delete."""
+    import asyncio
+
+    async def hangs(*_args, **_kwargs):
+        await asyncio.sleep(30)
+
+    monkeypatch.setattr(main_module, "_SD_CLEANUP_BUDGET_SECONDS", 0.2)
+    monkeypatch.setattr(main_module, "_cleanup_sd_card_after_print", hangs)
+
+    await asyncio.wait_for(_complete(test_engine, queued_print["printer"], "completed"), timeout=15)
+
+    _archive, item, _logs, charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    assert item.status == "completed"
+    assert len(charges) == 1

+ 62 - 0
backend/tests/integration/test_queue_target_model_case.py

@@ -0,0 +1,62 @@
+"""A target model is matched without regard to case, as the scheduler does (Q-U8).
+
+The scheduler finds printers for "x1c" among the X1Cs, but adding or editing a
+queue job refused the same name ("No active printers for model: x1c"). The job
+stores the printer's own spelling, so everything comparing it exactly agrees.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_queue import PrintQueueItem
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_adding_a_job_for_a_model_in_lower_case(async_client, db_session, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+
+    response = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "target_model": "x1c"})
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
+    assert item.target_model == "X1C"
+
+
+async def test_editing_a_job_onto_a_model_in_lower_case(async_client, db_session, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+    created = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "printer_id": printer.id})
+
+    response = await async_client.patch(
+        f"/api/v1/queue/{created.json()['id']}", json={"printer_id": None, "target_model": "x1c"}
+    )
+
+    assert response.status_code == 200, response.text
+    assert response.json()["target_model"] == "X1C"
+
+
+async def test_a_model_no_active_printer_has_is_still_refused(async_client, printer_factory, archive_factory):
+    printer = await printer_factory(model="X1C")
+    archive = await archive_factory(printer.id)
+
+    response = await async_client.post("/api/v1/queue/", json={"archive_id": archive.id, "target_model": "p1s"})
+
+    assert response.status_code == 400
+
+
+@pytest.mark.parametrize(("strategy", "expected"), [("fill_one_first", "pinned"), ("max_parallel", "X1C")])
+async def test_a_pipeline_aimed_at_a_model_in_lower_case(db_session, printer_factory, strategy, expected):
+    from backend.app.api.routes.pipeline_runs import _pick_assignments
+    from backend.app.models.slicer_pipeline import SlicerPipeline
+
+    printer = await printer_factory(model="X1C")
+    pipeline = SlicerPipeline(target_kind="printer_class", target_model_class="x1c", fanout_strategy=strategy)
+
+    assignments = await _pick_assignments(db_session, pipeline, 2)
+
+    if expected == "pinned":
+        assert assignments == [(printer.id, None)] * 2
+    else:
+        assert assignments == [(None, "X1C")] * 2

+ 28 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -162,6 +162,34 @@ class TestQueueWithVariants:
         assert r.status_code == 400
         assert "different printers" in r.json()["detail"]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_same_model_in_another_case_is_the_same_printers(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Matched without regard to case, as the scheduler matches (Q-U8)."""
+        await printer_factory(model="H2S")
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("h2s")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_candidate_takes_the_printers_spelling(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        await printer_factory(model="X1C")
+        a = await sliced_file_factory("h2s")
+        b = await sliced_file_factory("X1C")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 200, r.text
+        assert [v.target_model for v in await _variants_of(db_session, r.json()["id"])] == ["H2S", "X1C"]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):

+ 23 - 0
backend/tests/integration/test_spool_archive_unassigns.py

@@ -69,6 +69,29 @@ async def test_bulk_archiving_unassigns_every_spool(async_client: AsyncClient, d
     assert await _built_in_slots(db_session) == []
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("bulk", [False, True])
+async def test_deleting_a_spool_announces_the_freed_slots(async_client: AsyncClient, db_session, printer_factory, bulk):
+    """SA-4: other tabs refresh slot views only on spool_assignment_changed;
+    inventory_changed alone left the deleted spool shown in its slot."""
+    ids = await _assigned_built_in_spools(db_session, printer_factory, count=2)
+
+    with patch("backend.app.api.routes.inventory.ws_manager") as ws:
+        ws.broadcast = AsyncMock()
+        if bulk:
+            response = await async_client.post("/api/v1/inventory/spools/bulk-delete", json={"ids": ids})
+        else:
+            response = await async_client.delete(f"/api/v1/inventory/spools/{ids[0]}")
+
+    assert response.status_code == 200
+    freed = sorted(
+        c.args[0]["tray_id"] for c in ws.broadcast.call_args_list if c.args[0]["type"] == "spool_assignment_changed"
+    )
+    assert freed == ([0, 1] if bulk else [0])
+    assert [a.tray_id for a in await _built_in_slots(db_session)] == ([] if bulk else [1])
+
+
 @pytest.fixture
 async def spoolman_slot(db_session, printer_factory):
     from backend.app.models.settings import Settings

+ 19 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -1759,6 +1759,25 @@ class TestUpdateSpoolWeightSpoolman:
         assert data["weight_used"] == pytest.approx(446.0)
         mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0))
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_spoolman_mode_announces_the_weight(self, async_client: AsyncClient, spoolman_settings):
+        """WU-9: other open Inventory tabs refresh only on inventory_changed."""
+        sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0)
+        mock_client = _mock_spoolman_client()
+        mock_client.get_spool = AsyncMock(return_value=sm_spool)
+        mock_client.update_spool = AsyncMock(return_value=sm_spool)
+
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=mock_client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=mock_client)),
+            patch("backend.app.api.routes.spoolbuddy.ws_manager") as ws,
+        ):
+            ws.broadcast = AsyncMock()
+            await async_client.post(f"{API}/scale/update-spool-weight", json={"spool_id": 42, "weight_grams": 750})
+
+        assert {"type": "inventory_changed"} in [c.args[0] for c in ws.broadcast.call_args_list]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings):

+ 24 - 0
backend/tests/integration/test_sqlite_dangling_references.py

@@ -310,3 +310,27 @@ async def test_the_archive_purge_skips_an_archive_that_is_being_printed(
     reloaded = await _reload(db_session, PrintArchive, archive.id)
     assert reloaded is not None and reloaded.deleted_at is None
     assert await _reload(db_session, PrintQueueItem, item_id) is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_deleting_a_printer_unassigns_its_spools(async_client: AsyncClient, db_session, printer_factory):
+    """SA-1: the spools of a deleted printer stayed "assigned" to it on SQLite."""
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    printer = await printer_factory()
+    kept_printer = await printer_factory(name="Kept")
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0)
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=kept_printer.id, ams_id=0, tray_id=1))
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/printers/{printer.id}")
+
+    assert response.status_code == 200, response.text
+    rows = (await db_session.execute(select(SpoolAssignment).execution_options(populate_existing=True))).scalars()
+    assert [row.printer_id for row in rows] == [kept_printer.id]
+    assert await _reload(db_session, Spool, spool.id) is not None

+ 43 - 0
backend/tests/integration/test_timelapse_changes_announced.py

@@ -0,0 +1,43 @@
+"""Every change to an archive's timelapse tells open Archives pages (TL-5).
+
+Only the automatic scan sent ``archive_updated``. A video attached any other
+way (picked or uploaded by hand, the external camera's, the MP4 conversion)
+or deleted showed on other open pages only once they refetched.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_an_attach_is_announced(db_session, archive_factory, printer_factory, tmp_path, monkeypatch):
+    from backend.app.core.config import settings as app_config
+    from backend.app.services.archive import ArchiveService
+
+    monkeypatch.setattr(app_config, "base_dir", tmp_path)
+    monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id)
+    announce = AsyncMock()
+
+    with patch("backend.app.services.archive._announce_timelapse", announce):
+        assert await ArchiveService(db_session).attach_timelapse(archive.id, b"video", "clip.mp4")
+
+    announce.assert_awaited_once_with(archive.id, attached=True)
+
+
+async def test_a_delete_is_announced(async_client, db_session, archive_factory, printer_factory, tmp_path, monkeypatch):
+    from backend.app.core.config import settings as app_config
+
+    monkeypatch.setattr(app_config, "base_dir", tmp_path)
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, timelapse_path="archive/x/clip.mp4")
+    announce = AsyncMock()
+
+    with patch("backend.app.services.archive._announce_timelapse", announce):
+        response = await async_client.delete(f"/api/v1/archives/{archive.id}/timelapse")
+
+    assert response.status_code == 200, response.text
+    announce.assert_awaited_once_with(archive.id, attached=False)

+ 42 - 0
backend/tests/integration/test_webhook_reprint_slicer_mapping.py

@@ -0,0 +1,42 @@
+"""A webhook reprint keeps the slicer's AMS slots, as a reprint from the UI does (F9).
+
+An archive printed through the virtual printer remembers which AMS slots the
+slicer picked on its printer. Queued again for that printer from the UI, the
+job reuses them; queued through the webhook it did not, and the scheduler
+matched slots by colour and type again, possibly other spools.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.tests.integration.test_queue_creation_paths_c4 import _webhook_key
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _archive(db_session, archive_factory, printer_id: int):
+    archive = await archive_factory(printer_id)
+    archive.extra_data = {"slicer_ams_mapping": {"printer_id": printer_id, "mapping": [2, 0]}}
+    await db_session.commit()
+    return archive
+
+
+@pytest.mark.parametrize("for_its_printer", [True, False])
+async def test_the_webhook_reuses_the_slots_for_the_printer_they_were_picked_on(
+    async_client, db_session, printer_factory, archive_factory, for_its_printer
+):
+    printer = await printer_factory()
+    other = await printer_factory(name="Other")
+    archive = await _archive(db_session, archive_factory, printer.id)
+
+    response = await async_client.post(
+        "/api/v1/webhook/queue/add",
+        json={"printer_id": printer.id if for_its_printer else other.id, "archive_id": archive.id},
+        headers=await _webhook_key(db_session),
+    )
+
+    assert response.status_code == 200, response.text
+    item = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == response.json()["id"]))
+    # Slot numbers only mean something on the printer they were picked on
+    assert item.ams_mapping == ("[2, 0]" if for_its_printer else None)

+ 15 - 0
backend/tests/unit/services/test_usage_tracker.py

@@ -20,6 +20,21 @@ from backend.app.services.usage_tracker import (
 )
 
 
+async def _charge_in_memory(_db, spool, grams):
+    """The fake session has no database for the tracker's UPDATE: add the
+    grams to the mock spool, which is what these tests check. The statement
+    itself is tested against a real database in test_usage_tracker_charge_race."""
+    spool.weight_used = (spool.weight_used or 0) + grams
+    spool.last_used = datetime.now(timezone.utc)
+    return True
+
+
+@pytest.fixture(autouse=True)
+def _charge_spools_in_memory():
+    with patch("backend.app.services.usage_tracker._charge_spool", _charge_in_memory):
+        yield
+
+
 def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None, rgba=None):
     """Create a mock Spool object."""
     spool = MagicMock()

+ 14 - 14
backend/tests/unit/test_ams_assignment_lock.py

@@ -1,4 +1,4 @@
-"""Regression tests for ``_ams_assignment_locks`` (per-printer serialisation
+"""Regression tests for ``ams_assignment_locks`` (per-printer serialisation
 of ``on_ams_change``'s spool-assignment block).
 
 Background
@@ -31,7 +31,7 @@ import asyncio
 
 import pytest
 
-from backend.app.main import _ams_assignment_locks, _get_ams_assignment_lock
+from backend.app.services.ams_assignment_lock import ams_assignment_locks, get_ams_assignment_lock
 
 
 @pytest.fixture(autouse=True)
@@ -40,28 +40,28 @@ def _isolate_locks_dict():
     tests' lazy-created locks leak across runs and a stale ``Lock`` object
     bound to an already-closed event loop trips uvloop's "Future attached to
     a different loop" assertion."""
-    saved = dict(_ams_assignment_locks)
-    _ams_assignment_locks.clear()
+    saved = dict(ams_assignment_locks)
+    ams_assignment_locks.clear()
     try:
         yield
     finally:
-        _ams_assignment_locks.clear()
-        _ams_assignment_locks.update(saved)
+        ams_assignment_locks.clear()
+        ams_assignment_locks.update(saved)
 
 
 class TestLockKeySeparation:
     def test_same_printer_returns_same_lock(self):
         """Two callbacks for the same printer must contend on the same lock —
         otherwise serialisation buys us nothing."""
-        a = _get_ams_assignment_lock(7)
-        b = _get_ams_assignment_lock(7)
+        a = get_ams_assignment_lock(7)
+        b = get_ams_assignment_lock(7)
         assert a is b
 
     def test_different_printers_get_different_locks(self):
         """Per-printer scope: one printer's slow assignment must not block
         unrelated printers from processing their own AMS pushes."""
-        a = _get_ams_assignment_lock(7)
-        b = _get_ams_assignment_lock(8)
+        a = get_ams_assignment_lock(7)
+        b = get_ams_assignment_lock(8)
         assert a is not b
 
 
@@ -77,7 +77,7 @@ class TestLockSerialisesConcurrentCallbacks:
         first_release = asyncio.Event()
 
         async def first():
-            async with _get_ams_assignment_lock(printer_id):
+            async with get_ams_assignment_lock(printer_id):
                 order.append("first-enter")
                 first_inside.set()
                 # Hold the lock until the test allows release; this is what
@@ -88,7 +88,7 @@ class TestLockSerialisesConcurrentCallbacks:
 
         async def second():
             await first_inside.wait()  # ensure first holds the lock
-            async with _get_ams_assignment_lock(printer_id):
+            async with get_ams_assignment_lock(printer_id):
                 order.append("second-enter")
 
         task_a = asyncio.create_task(first())
@@ -118,7 +118,7 @@ class TestLockSerialisesConcurrentCallbacks:
         printer_a_release = asyncio.Event()
 
         async def printer_a():
-            async with _get_ams_assignment_lock(1):
+            async with get_ams_assignment_lock(1):
                 order.append("a-enter")
                 printer_a_inside.set()
                 await printer_a_release.wait()
@@ -126,7 +126,7 @@ class TestLockSerialisesConcurrentCallbacks:
 
         async def printer_b():
             await printer_a_inside.wait()
-            async with _get_ams_assignment_lock(2):
+            async with get_ams_assignment_lock(2):
                 order.append("b-enter-and-exit")
 
         task_a = asyncio.create_task(printer_a())

+ 19 - 0
backend/tests/unit/test_archive_filtering.py

@@ -690,6 +690,7 @@ class TestConvertTimelapseToMp4:
             patch("backend.app.services.archive.settings") as mock_settings,
             patch("backend.app.utils.archive_paths.settings") as mock_paths_settings,
             patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as mock_exec,
+            patch("backend.app.services.archive._announce_timelapse", AsyncMock()) as announce,
         ):
             mock_settings.base_dir = tmp_path
             mock_paths_settings.base_dir = tmp_path
@@ -702,8 +703,22 @@ class TestConvertTimelapseToMp4:
 
             await _convert_timelapse_to_mp4(42, source)
 
+        mock_session.announce = announce
         return mock_exec, mock_session, mock_archive, mp4_path
 
+    @pytest.mark.asyncio
+    async def test_the_converted_video_is_announced(self, tmp_path):
+        """Open Archives pages refresh to the MP4 (TL-5)."""
+        _, session, _, _ = await self._convert(tmp_path, shown="video.avi")
+
+        session.announce.assert_awaited_once_with(42, attached=True)
+
+    @pytest.mark.asyncio
+    async def test_a_discarded_conversion_is_not_announced(self, tmp_path):
+        _, session, _, _ = await self._convert(tmp_path, shown="other.avi")
+
+        session.announce.assert_not_awaited()
+
     @pytest.mark.asyncio
     async def test_converts_avi_to_mp4(self, tmp_path):
         """Should call FFmpeg to convert and update the DB path."""
@@ -794,6 +809,8 @@ class TestAttachTimelapseBackgroundConversion:
             patch("backend.app.services.archive.settings") as mock_settings,
             patch("backend.app.utils.archive_paths.settings") as mock_paths_settings,
             patch("asyncio.create_task") as mock_create_task,
+            # Its broadcast opens a session task of its own
+            patch("backend.app.services.archive._announce_timelapse", AsyncMock()),
         ):
             mock_settings.base_dir = tmp_path
             mock_paths_settings.base_dir = tmp_path
@@ -823,6 +840,8 @@ class TestAttachTimelapseBackgroundConversion:
             patch("backend.app.services.archive.settings") as mock_settings,
             patch("backend.app.utils.archive_paths.settings") as mock_paths_settings,
             patch("asyncio.create_task") as mock_create_task,
+            # Its broadcast opens a session task of its own
+            patch("backend.app.services.archive._announce_timelapse", AsyncMock()),
         ):
             mock_settings.base_dir = tmp_path
             mock_paths_settings.base_dir = tmp_path

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

@@ -30,6 +30,7 @@ def expected_print_tables():
         "_print_ams_mappings",
         "_print_plate_ids",
         "_print_cost_center_ids",
+        "_active_prints",
     )
     saved = {n: dict(getattr(main, n)) 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 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

+ 82 - 0
backend/tests/unit/test_layer_timelapse_attach.py

@@ -0,0 +1,82 @@
+"""The external camera's timelapse and the printer's own (TL-3).
+
+With both on, both videos arrive for one archive. The camera's, the one set
+up in Bambuddy, wins: it replaces the printer's (usually there first, since
+stitching takes minutes), and the replaced file is deleted instead of being
+left in the archive folder with nothing pointing at it. Frames taken for one
+run never go to a newer run of the same archive.
+"""
+
+from datetime import datetime
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.tests.unit.test_fallback_timelapse_baseline_2957 import _seed
+
+pytestmark = pytest.mark.asyncio
+
+RUN = datetime(2026, 10, 19, 8, 0)
+
+
+async def _attach(test_engine, tmp_path, monkeypatch, *, existing: str | None, run_started_at=RUN):
+    from backend.app import main as main_module
+    from backend.app.core.config import settings as app_config
+
+    monkeypatch.setattr(app_config, "base_dir", tmp_path)
+    monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
+    maker, _printer_id, archive_id = await _seed(test_engine)
+    async with maker() as db:
+        archive = await db.get(PrintArchive, archive_id)
+        archive.started_at = RUN
+        archive.timelapse_path = existing
+        await db.commit()
+    if existing:
+        (tmp_path / existing).parent.mkdir(parents=True, exist_ok=True)
+        (tmp_path / existing).write_bytes(b"printer video")
+    stitched = tmp_path / "stitched.mp4"
+    stitched.write_bytes(b"layer video")
+    announce = AsyncMock()
+
+    with (
+        patch.object(main_module, "async_session", maker),
+        patch("backend.app.services.archive._announce_timelapse", announce),
+    ):
+        await main_module._attach_layer_timelapse(archive_id, stitched, None, run_started_at)
+
+    async with maker() as db:
+        attached = (await db.get(PrintArchive, archive_id)).timelapse_path
+    return attached, stitched, announce
+
+
+async def test_it_replaces_the_printer_s_video_and_deletes_that_file(test_engine, tmp_path, monkeypatch):
+    attached, stitched, announce = await _attach(test_engine, tmp_path, monkeypatch, existing="archive/1/video_a.mp4")
+
+    assert attached and attached.endswith("layer_timelapse.mp4")
+    assert not (tmp_path / "archive/1/video_a.mp4").exists(), "the replaced video would be left orphaned"
+    assert not stitched.exists(), "the stitched temporary is removed either way"
+    assert announce.await_args.kwargs == {"attached": True}
+
+
+async def test_without_one_it_is_attached(test_engine, tmp_path, monkeypatch):
+    attached, stitched, _announce = await _attach(test_engine, tmp_path, monkeypatch, existing=None)
+
+    assert attached and attached.endswith("layer_timelapse.mp4")
+    assert not stitched.exists()
+
+
+async def test_a_newer_run_of_the_archive_does_not_get_it(test_engine, tmp_path, monkeypatch):
+    """The stitch took long enough for the archive to start printing again."""
+    attached, stitched, announce = await _attach(
+        test_engine,
+        tmp_path,
+        monkeypatch,
+        existing="archive/1/video_a.mp4",
+        run_started_at=datetime(2026, 10, 19, 7, 0),
+    )
+
+    assert attached == "archive/1/video_a.mp4"
+    assert (tmp_path / "archive/1/video_a.mp4").exists()
+    assert not stitched.exists()
+    announce.assert_not_awaited()

+ 35 - 0
backend/tests/unit/test_override_color_match_equivalent_types.py

@@ -0,0 +1,35 @@
+"""An override's colour matches a tray of an equivalent filament type (SN-C1).
+
+The type check already treats PA12-CF and PA-CF as the same filament, but the
+colour check that ranks printers for an "Any model" job compared the names as
+written. With an override for PA12-CF in a colour, a printer holding PA-CF in
+that colour counted no match and was skipped, so the job waited forever.
+"""
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+def _raw(tray_type: str, color: str = "FF0000FF") -> dict:
+    return {"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": tray_type, "tray_color": color}]}]}
+
+
+@pytest.mark.parametrize(("wanted", "loaded"), [("PA12-CF", "PA-CF"), ("PA-CF", "PA12-CF")])
+def test_an_equivalent_type_in_the_colour_counts(wanted, loaded):
+    overrides = [{"type": wanted, "color": "#FF0000"}]
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, _raw(loaded)) == 1
+
+
+def test_another_type_in_the_colour_does_not():
+    overrides = [{"type": "PETG", "color": "#FF0000"}]
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, _raw("PLA")) == 0
+
+
+def test_an_external_spool_of_an_equivalent_type_counts():
+    overrides = [{"type": "PA-CF", "color": "#FF0000"}]
+    raw = {"ams": [], "vt_tray": [{"id": 254, "tray_type": "PA12-CF", "tray_color": "FF0000FF"}]}
+
+    assert PrintScheduler()._count_override_color_matches(1, overrides, raw) == 1

+ 171 - 0
backend/tests/unit/test_queued_plate_bed_temperature.py

@@ -0,0 +1,171 @@
+"""Preheat heats the bed for the plate the queued job prints (PL-C7).
+
+The archive stores one bed temperature, the highest any filament of the
+project asks for, and preheat and keep-warm used it for every job: a PLA plate
+of a project that also holds PETG was preheated to the PETG temperature. They
+now read the job's own plate from the archive's file.
+"""
+
+import json
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from backend.app.services.archive import ThreeMFParser
+from backend.app.services.print_scheduler import _queued_plate_bed_temperature
+from backend.app.utils.threemf_tools import plate_bed_temperature
+
+PLA, PETG_PLATE_FILAMENTS = [1], [2, 3]
+
+
+def _3mf(path: Path, plates: dict[int, list[int]], gcode: tuple[int, ...] = ()) -> Path:
+    """Plate N prints the filaments listed for it (1-based ids), PLA at 55,
+    PLA at 60 and PETG at 70; ``gcode`` are the plates sliced."""
+    plate_xml = "".join(
+        f"<plate><metadata key='index' value='{index}'/>"
+        + "".join(f"<filament id='{fid}' type='PLA' color='#FFFFFF' used_g='5'/>" for fid in filaments)
+        + "</plate>"
+        for index, filaments in plates.items()
+    )
+    settings = {"curr_bed_type": "Textured PEI Plate", "textured_plate_temp": ["55", "60", "70"]}
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/slice_info.config", f"<?xml version='1.0'?><config>{plate_xml}</config>")
+        zf.writestr("Metadata/project_settings.config", json.dumps(settings))
+        zf.writestr("3D/3dmodel.model", "<model/>")
+        for index in gcode:
+            zf.writestr(f"Metadata/plate_{index}.gcode", "; plate")
+    return path
+
+
+def test_each_plate_has_its_own_filaments_temperature(tmp_path):
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+
+    assert plate_bed_temperature(path, 1) == 55
+    assert plate_bed_temperature(path, 2) == 70
+
+
+def test_without_a_plate_the_one_dispatched_decides(tmp_path):
+    """A job without a plate prints the file's default (lowest sliced) plate."""
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS}, gcode=(1, 2))
+
+    assert plate_bed_temperature(path, None) == 55
+    assert plate_bed_temperature(path, 0) == 55
+
+
+def test_a_plate_whose_filaments_are_unknown_takes_the_highest(tmp_path):
+    assert plate_bed_temperature(_3mf(tmp_path / "a.3mf", {1: [], 2: []}), None) == 70
+
+
+def test_an_unreadable_file_is_none(tmp_path):
+    (tmp_path / "broken.3mf").write_bytes(b"not a zip")
+
+    assert plate_bed_temperature(tmp_path / "broken.3mf", 1) is None
+    assert plate_bed_temperature(tmp_path / "missing.3mf", 1) is None
+
+
+def test_a_damaged_member_is_none(tmp_path):
+    """Corrupt deflate data raises zlib.error, which is no OSError: preheat
+    runs this on every dispatch, so nothing may escape it."""
+    path = tmp_path / "a.3mf"
+    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["60"] * 200}))
+    raw = bytearray(path.read_bytes())
+    start = raw.index(b"PK\x03\x04") + 30 + len("Metadata/project_settings.config")
+    raw[start : start + 8] = b"\xff" * 8
+    path.write_bytes(bytes(raw))
+
+    assert plate_bed_temperature(path, 1) is None
+
+
+def test_an_infinite_temperature_is_none(tmp_path):
+    path = tmp_path / "a.3mf"
+    with zipfile.ZipFile(path, "w") as zf:
+        zf.writestr("Metadata/project_settings.config", json.dumps({"bed_temperature": ["inf"]}))
+
+    assert plate_bed_temperature(path, 1) is None
+
+
+def test_the_archive_keeps_the_projects_highest(tmp_path):
+    """The stored value stays the safe upper bound for any plate of it."""
+    path = _3mf(tmp_path / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+
+    assert ThreeMFParser(path, plate_number=1).parse().get("bed_temperature") == 70
+
+
+@pytest.fixture
+def base_dir(monkeypatch, tmp_path) -> Path:
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    return tmp_path
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("plate_id", "expected"), [(1, 55), (2, 70)])
+async def test_a_job_is_heated_for_its_plate(base_dir, plate_id, expected):
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    archive = SimpleNamespace(file_path="a.3mf", bed_temperature=70)
+
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=plate_id), archive) == expected
+
+
+@pytest.mark.asyncio
+async def test_an_unreadable_file_falls_back_to_the_stored_value(base_dir):
+    archive = SimpleNamespace(file_path="gone.3mf", bed_temperature=65)
+
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=2), archive) == 65
+
+
+@pytest.mark.asyncio
+async def test_no_archive_is_zero():
+    assert await _queued_plate_bed_temperature(SimpleNamespace(plate_id=None), None) == 0
+
+
+@pytest.mark.asyncio
+async def test_preheat_heats_for_the_job_s_plate(base_dir):
+    from unittest.mock import AsyncMock, patch
+
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.tests.unit.test_scheduler_preheat import _ints, _make_client, _make_printer, _make_state
+
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    scheduler = PrintScheduler()
+    item = SimpleNamespace(id=42, plate_id=1, preheat_override="on", preheat_chamber_target_override=0)
+    client = _make_client()
+
+    with (
+        patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
+        patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        pm.get_status.return_value = _make_state(55.0, 0.0)
+        await scheduler._preheat_and_soak(
+            AsyncMock(), item, _make_printer("H2D"), SimpleNamespace(file_path="a.3mf", bed_temperature=70)
+        )
+
+    client.set_bed_temperature.assert_called_once_with(55)
+
+
+@pytest.mark.asyncio
+async def test_keep_warm_holds_for_the_next_job_s_plate(base_dir):
+    from unittest.mock import patch
+
+    from backend.app.services.print_scheduler import PrintScheduler
+    from backend.tests.unit.test_scheduler_keep_warm import _make_client, _make_item, _make_state, _run
+
+    _3mf(base_dir / "a.3mf", {1: PLA, 2: PETG_PLATE_FILAMENTS})
+    item = _make_item()
+    item.plate_id = 2
+    item.archive = SimpleNamespace(file_path="a.3mf", bed_temperature=110)
+    client = _make_client()
+
+    with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+        pm.get_status.return_value = _make_state()
+        pm.get_client.return_value = client
+        await _run(PrintScheduler(), items=[item], hold_temp=40)
+
+    client.set_bed_temperature.assert_called_once_with(70)

+ 74 - 0
backend/tests/unit/test_refused_project_file_ack.py

@@ -0,0 +1,74 @@
+"""A print start the printer refused is logged, and its answer is not read as
+an accepted print (FW-C1).
+
+The printer answers a ``project_file`` command on the report topic. A refusal
+went unlogged, so a support bundle showed nothing of it; and its job id was
+copied into the state like any status field, which the start watchdog reads
+as "the printer took the file" and so waited out its full 180 s before giving
+up.
+"""
+
+import logging
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client() -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.9", serial_number="SN-FW", access_code="x", model="X1C")
+    client.state.subtask_id = "100"
+    client.state.subtask_name = "previous"
+    return client
+
+
+def _ack(result: str) -> dict:
+    return {
+        "print": {
+            "command": "project_file",
+            "result": result,
+            "reason": "file not found",
+            "subtask_id": "200",
+            "subtask_name": "refused",
+            "url": "ftp://refused.3mf",
+        }
+    }
+
+
+def test_a_refusal_is_logged_and_changes_no_job(caplog):
+    client = _client()
+
+    with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+        client._process_message(_ack("FAIL"))
+
+    assert (client.state.subtask_id, client.state.subtask_name) == ("100", "previous")
+    assert any("refused" in r.getMessage() and "FAIL" in r.getMessage() for r in caplog.records)
+
+
+def test_an_accepted_start_still_names_its_job():
+    client = _client()
+
+    client._process_message(_ack("SUCCESS"))
+
+    assert (client.state.subtask_id, client.state.subtask_name) == ("200", "refused")
+
+
+def test_a_refusal_is_remembered_until_the_next_start_is_sent():
+    from unittest.mock import MagicMock
+
+    client = _client()
+    client._process_message(_ack("FAIL"))
+    assert client.last_start_refusal == ("FAIL", "file not found")
+
+    client._client = MagicMock()
+    client.state.connected = True
+    client.start_print("next.3mf")
+
+    assert client.last_start_refusal is None
+
+
+def test_success_in_lower_case_is_not_a_refusal():
+    client = _client()
+
+    client._process_message(_ack("success"))
+
+    assert client.last_start_refusal is None
+    assert client.state.subtask_id == "200"

+ 1 - 1
backend/tests/unit/test_scheduler_keep_warm.py

@@ -57,7 +57,7 @@ def _make_item(
     needed check pass without any AMS-derivation mocking. Set to ``None`` in
     tests that specifically want to exercise the derivation branch.
     """
-    archive = SimpleNamespace(bed_temperature=bed_temperature)
+    archive = SimpleNamespace(file_path=None, bed_temperature=bed_temperature)
     return SimpleNamespace(
         id=item_id,
         printer_id=printer_id,

+ 3 - 3
backend/tests/unit/test_scheduler_preheat.py

@@ -36,7 +36,7 @@ def item():
 
 @pytest.fixture
 def archive():
-    return SimpleNamespace(bed_temperature=60)
+    return SimpleNamespace(file_path=None, bed_temperature=60)
 
 
 def _make_printer(model: str, printer_id: int = 7):
@@ -417,7 +417,7 @@ async def test_no_bed_temperature_but_chamber_needed_heats_bed_to_configured_tem
     """
     db = AsyncMock()
     client = _make_client()
-    bare_archive = SimpleNamespace(bed_temperature=None)
+    bare_archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -449,7 +449,7 @@ async def test_no_bed_temperature_and_no_chamber_target_still_skips(scheduler, i
     """
     db = AsyncMock()
     client = _make_client()
-    bare_archive = SimpleNamespace(bed_temperature=None)
+    bare_archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),

+ 2 - 2
backend/tests/unit/test_scheduler_preheat_ams_mapping_2886.py

@@ -153,7 +153,7 @@ async def test_end_to_end_the_pla_job_skips_preheat_entirely(scheduler):
     """
     db = AsyncMock()
     client = _make_client()
-    archive = SimpleNamespace(bed_temperature=None)
+    archive = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -184,7 +184,7 @@ async def test_end_to_end_a_mapped_asa_job_still_heats_the_bed_to_drive_the_cham
     chamber-heating bed temperature."""
     db = AsyncMock()
     client = _make_client()
-    archive = SimpleNamespace(bed_temperature=None)
+    archive = SimpleNamespace(file_path=None, bed_temperature=None)
     mapping = json.dumps([-1, -1, -1, ASA_TRAY])
 
     with (

+ 4 - 4
backend/tests/unit/test_scheduler_preheat_fast_path.py

@@ -45,7 +45,7 @@ def item():
 
 @pytest.fixture
 def archive():
-    return SimpleNamespace(bed_temperature=60)
+    return SimpleNamespace(file_path=None, bed_temperature=60)
 
 
 def _make_printer(model: str, printer_id: int = PRINTER_ID):
@@ -441,7 +441,7 @@ async def test_preheat_falls_back_to_configured_bed_temp_when_metadata_missing(s
     """
     db = AsyncMock()
     client = _make_client()
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -469,7 +469,7 @@ async def test_preheat_fallback_honours_configured_temp(scheduler, item):
     """The fallback reads `queue_keep_warm_bed_temp`; it is not hard-coded."""
     db = AsyncMock()
     client = _make_client()
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
@@ -500,7 +500,7 @@ async def test_preheat_still_skips_when_no_bed_temp_and_no_chamber_target(schedu
     db = AsyncMock()
     client = _make_client()
     pla_item = SimpleNamespace(id=43, preheat_override="inherit", preheat_chamber_target_override=0)
-    archive_no_bed = SimpleNamespace(bed_temperature=None)
+    archive_no_bed = SimpleNamespace(file_path=None, bed_temperature=None)
 
     with (
         patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),

+ 1 - 1
backend/tests/unit/test_scheduler_release_conn_before_ftp_2572.py

@@ -131,7 +131,7 @@ async def test_connection_released_before_preheat_soak(dispatch_case):
 
     item = SimpleNamespace(id=1, preheat_override="on", preheat_chamber_target_override=None)
     printer = SimpleNamespace(id=7, model="P1S")  # no chamber sensor → straight to soak
-    archive = SimpleNamespace(bed_temperature=60)
+    archive = SimpleNamespace(file_path=None, bed_temperature=60)
 
     async with dispatch_case.session_maker() as db:
         # Simulate the caller's earlier reads holding an open transaction.

+ 196 - 0
backend/tests/unit/test_scheduler_snapshot_races.py

@@ -0,0 +1,196 @@
+"""An edit made while the scheduler's pass runs is not overwritten (Q2).
+
+``check_queue`` loads the pending items once and works on that copy. A queue
+item edited in between (a mapping set by hand, a move to another printer) was
+overwritten when the pass computed and saved its AMS mapping, or dispatched
+to the printer it was on before. The edit lands where the pass waits on
+something; the previous-print check is such a place.
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import update
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.tests.unit.test_scheduler_busy_reasons_3018 import _scheduler, one_printer  # noqa: F401
+
+
+async def _pinned_item(ctx, ams_mapping: str | None = None) -> int:
+    item_id = await ctx.add_item()
+    async with ctx.session_maker() as db:
+        await db.execute(
+            update(PrintQueueItem)
+            .where(PrintQueueItem.id == item_id)
+            .values(require_previous_success=True, ams_mapping=ams_mapping)
+        )
+        await db.commit()
+    return item_id
+
+
+def _edit_during_the_pass(ctx, scheduler, **values):
+    async def edit(_db, item, *_args, **_kwargs):
+        async with ctx.session_maker() as other:
+            await other.execute(update(PrintQueueItem).where(PrintQueueItem.id == item.id).values(**values))
+            await other.commit()
+        return True
+
+    return patch.object(scheduler, "_check_previous_success", AsyncMock(side_effect=edit))
+
+
+@pytest.mark.asyncio
+async def test_a_mapping_set_meanwhile_is_kept(one_printer):  # noqa: F811
+    """The pass would clear the bogus all-unresolved mapping it loaded."""
+    item_id = await _pinned_item(one_printer, ams_mapping="[-1]")
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, ams_mapping="[2]"):
+            await scheduler.check_queue()
+
+    async with one_printer.session_maker() as db:
+        assert (await db.get(PrintQueueItem, item_id)).ams_mapping == "[2]"
+
+
+@pytest.mark.asyncio
+async def test_an_item_moved_meanwhile_is_not_sent_to_its_old_printer(one_printer):  # noqa: F811
+    """Nor given a mapping worked out for its old printer: the pass would
+    clear the bogus all-unresolved one it loaded."""
+    item_id = await _pinned_item(one_printer, ams_mapping="[-1]")
+    async with one_printer.session_maker() as db:
+        other = Printer(name="Other", serial_number="OTHER", ip_address="10.0.0.2", access_code="code", model="X1C")
+        db.add(other)
+        await db.commit()
+        other_id = other.id
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, printer_id=other_id):
+            await scheduler.check_queue()
+            assert not scheduler._inflight
+
+    async with one_printer.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+    assert (item.status, item.printer_id, item.ams_mapping) == ("pending", other_id, "[-1]")
+
+
+@pytest.mark.asyncio
+async def test_the_claim_requires_the_printer_the_item_was_selected_for(one_printer):  # noqa: F811
+    """A move landing after the re-read: the dispatch's claim still refuses it."""
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    item_id = await one_printer.add_item()
+    scheduler = PrintScheduler()
+
+    async with one_printer.session_maker() as db:
+        assert not await scheduler._claim_for_dispatch(db, item_id, one_printer.printer_id + 1)
+        assert await scheduler._claim_for_dispatch(db, item_id, one_printer.printer_id)
+
+
+@pytest.mark.asyncio
+async def test_the_power_on_wait_holds_no_transaction(one_printer):  # noqa: F811
+    """RC-C8: 30-180 s of waiting for a printer to boot with the pass's
+    transaction open pins a connection (PostgreSQL) or a stale snapshot."""
+    from unittest.mock import MagicMock
+
+    from backend.app.services import print_scheduler as scheduler_module
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    scheduler = PrintScheduler()
+    order: list[str] = []
+    plug = MagicMock(name="plug")
+    plug.name = "Plug"
+    service = MagicMock(
+        get_status=AsyncMock(return_value={"reachable": True, "state": "ON"}), turn_on=AsyncMock(return_value=True)
+    )
+
+    async def sleep(_seconds):
+        order.append("sleep")
+
+    async with one_printer.session_maker() as db:
+        real_commit = db.commit
+
+        async def commit():
+            order.append("commit")
+            await real_commit()
+
+        with (
+            patch.object(scheduler_module.smart_plug_manager, "get_service_for_plug", AsyncMock(return_value=service)),
+            patch.object(scheduler_module.asyncio, "sleep", sleep),
+            patch.object(scheduler_module.printer_manager, "connect_printer", AsyncMock(return_value=True)),
+            patch.object(db, "commit", commit),
+        ):
+            assert await scheduler._power_on_and_wait(plug, one_printer.printer_id, db)
+
+    assert order[:2] == ["commit", "sleep"]
+
+
+@pytest.mark.asyncio
+async def test_an_item_cancelled_meanwhile_is_left_as_cancelled(one_printer):  # noqa: F811
+    item_id = await _pinned_item(one_printer, ams_mapping="[-1]")
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, status="cancelled"):
+            await scheduler.check_queue()
+
+    async with one_printer.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+    assert (item.status, item.ams_mapping) == ("cancelled", "[-1]")
+
+
+@pytest.mark.asyncio
+async def test_another_plate_chosen_meanwhile_is_not_mapped_from_the_old_one(one_printer):  # noqa: F811
+    """The new mapping belongs to the new plate; checked against the old
+    plate's filaments it would be thrown away."""
+    item_id = await _pinned_item(one_printer, ams_mapping="[-1]")
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, plate_id=2, ams_mapping="[3]"):
+            await scheduler.check_queue()
+            assert not scheduler._inflight
+
+    async with one_printer.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+    assert (item.plate_id, item.ams_mapping) == (2, "[3]")
+
+
+@pytest.mark.asyncio
+async def test_a_model_job_pinned_meanwhile_is_not_sent_to_the_printer_it_was_offered(one_printer):  # noqa: F811
+    item_id = await _pinned_item(one_printer)
+    async with one_printer.session_maker() as db:
+        other = Printer(name="Other", serial_number="OTHER", ip_address="10.0.0.2", access_code="code", model="X1C")
+        db.add(other)
+        await db.commit()
+        other_id = other.id
+        await db.execute(
+            update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(printer_id=None, target_model="X1C")
+        )
+        await db.commit()
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, printer_id=other_id, target_model=None):
+            await scheduler.check_queue()
+            assert not scheduler._inflight
+
+    async with one_printer.session_maker() as db:
+        item = await db.get(PrintQueueItem, item_id)
+    assert (item.status, item.printer_id, item.target_model) == ("pending", other_id, None)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "edit",
+    [
+        {"plate_id": 2},
+        {"filament_overrides": '[{"slot_id": 1, "type": "PETG"}]'},
+        {"skip_filament_check": True},
+        {"target_model": "P1S"},
+        {"target_location": "Lab"},
+    ],
+)
+async def test_any_edit_to_what_the_dispatch_is_worked_out_from_waits_a_round(one_printer, edit):  # noqa: F811
+    await _pinned_item(one_printer)
+
+    async with _scheduler(one_printer, idle=True) as scheduler:
+        with _edit_during_the_pass(one_printer, scheduler, **edit):
+            await scheduler.check_queue()
+            assert not scheduler._inflight

+ 30 - 0
backend/tests/unit/test_scheduler_watchdog.py

@@ -907,3 +907,33 @@ class TestWatchdogNamesDryingAsTheObstacle:
             item = await db.get(PrintQueueItem, 1)
         assert item.status == "printing"
         assert (item.dispatch_attempts or 0) == 0
+
+
+class TestARefusedStart:
+    """FW-C1: the printer answered the start with a refusal. Nothing will
+    start, and the command was not lost: no full wait, no reconnect."""
+
+    @pytest.mark.asyncio
+    async def test_reverts_at_once_without_a_reconnect(self, db_session):
+        get_status = MagicMock(return_value=_status("FINISH", "OLD_SUBTASK", gcode_file="/old.3mf"))
+        client = MagicMock(last_start_refusal=("FAIL", "file not found"))
+
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock(return_value=client)),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="FINISH",
+                pre_subtask_id="OLD_SUBTASK",
+                pre_gcode_file="/old.3mf",
+                timeout=30,
+                poll_interval=0.05,
+            )
+
+        client.force_reconnect_stale_session.assert_not_called()
+        async with db_session() as db:
+            assert (await db.get(PrintQueueItem, 1)).status == "pending"

+ 15 - 0
backend/tests/unit/test_shared_tray_usage_3230.py

@@ -21,6 +21,21 @@ from backend.app.services.usage_tracker import _track_from_3mf
 pytestmark = pytest.mark.unit
 
 
+async def _charge_in_memory(_db, spool, grams):
+    """The fake session has no database for the tracker's UPDATE: add the
+    grams to the mock spool, which is what these tests check. The statement
+    itself is tested against a real database in test_usage_tracker_charge_race."""
+    spool.weight_used = (spool.weight_used or 0) + grams
+    spool.last_used = datetime.now(timezone.utc)
+    return True
+
+
+@pytest.fixture(autouse=True)
+def _charge_spools_in_memory():
+    with patch("backend.app.services.usage_tracker._charge_spool", _charge_in_memory):
+        yield
+
+
 def _spool(spool_id: int):
     spool = MagicMock()
     spool.id = spool_id

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

@@ -34,8 +34,9 @@ GUARDED_FIELDS = [
             ("backend/app/main.py", "on_ams_change"): "AMS remain% sync while idle",
             ("backend/app/api/routes/inventory.py", "sync_weights_from_ams"): "manual Sync AMS weights",
             ("backend/app/api/routes/spoolbuddy.py", "update_spool_weight"): "SpoolBuddy scale reading",
-            ("backend/app/services/usage_tracker.py", "on_print_complete"): "AMS remain% delta fallback",
-            ("backend/app/services/usage_tracker.py", "_track_from_3mf"): "3MF per-filament usage",
+            ("backend/app/services/usage_tracker.py", "_charge_spool"): (
+                "a print's usage (3MF, 3MF split, AMS remain% delta), added in SQL"
+            ),
             ("backend/app/services/spool_tag_matcher.py", "create_spool_from_tray"): "new spool from an RFID tray",
             ("backend/app/core/database.py", "_migrate_repair_rfid_core_weight"): "one-shot #2909 backfill",
             ("backend/app/api/routes/inventory.py", "create_spool"): "Spool(**payload) from the add form",

+ 35 - 2
backend/tests/unit/test_timelapse_scan_2704.py

@@ -382,7 +382,7 @@ class TestManualScanUsesTheBaseline:
         a.created_at = a.completed_at
         return a
 
-    async def _scan(self, archive, listing, download=None, delete=None):
+    async def _scan(self, archive, listing, download=None, delete=None, claimed=()):
         from backend.app.api.routes import archives as archives_mod
 
         service = MagicMock()
@@ -395,7 +395,8 @@ class TestManualScanUsesTheBaseline:
         session.execute = AsyncMock(
             return_value=MagicMock(
                 scalar_one_or_none=MagicMock(return_value=_printer()),
-                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+                # Videos other archives of this printer hold
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=list(claimed)))),
             )
         )
 
@@ -767,3 +768,35 @@ class TestFileMustHaveStoppedGrowing:
         assert result is True
         # Checked against what we actually received, not against the listing.
         assert settled.await_args.args[3] == 1000
+
+
+class TestAScanWithoutABaselineSkipsClaimedVideos:
+    """An archive from before the baseline was stored is matched on name and
+    clock, which must not hand it a video another archive already holds
+    (SN-C7)."""
+
+    _archive = TestManualScanUsesTheBaseline._archive
+    _scan = TestManualScanUsesTheBaseline._scan
+
+    @pytest.mark.asyncio
+    async def test_a_short_name_does_not_take_another_archive_s_video(self):
+        archive = self._archive(None)
+        archive.filename = "1.3mf"  # "1" is in every video's timestamp
+        listing = [_video("video_2026-07-28_21-19-10.avi"), _video("video_2026-07-28_21-19-40.avi")]
+
+        result = await self._scan(archive, listing, claimed=["archive/1/other/video_2026-07-28_21-19-10.mp4"])
+
+        assert result.get("filename") != "video_2026-07-28_21-19-10.avi"
+
+    @pytest.mark.asyncio
+    async def test_a_claimed_video_is_still_offered_for_picking_by_hand(self):
+        """Picking by hand is how a wrong match is put right."""
+        archive = self._archive(None)
+        archive.filename = "mops.3mf"
+        archive.started_at = archive.completed_at = archive.created_at = None
+        listing = [_video("video_a.avi"), _video("video_b.avi")]
+
+        result = await self._scan(archive, listing, claimed=["archive/1/other/video_a.mp4"])
+
+        assert result["status"] == "not_found"
+        assert {f["name"] for f in result["available_files"]} == {"video_a.avi", "video_b.avi"}

+ 115 - 0
backend/tests/unit/test_timelapse_scan_newer_run.py

@@ -0,0 +1,115 @@
+"""A timelapse scan attaches only to the run it was started for (TL-2).
+
+The scan polls the printer for minutes after a print ends. When the same
+archive starts printing again meanwhile (the next plate of a shared archive, a
+reprint), the archive row belongs to the new run: attaching then gave the new
+run the old run's video, and the new run's own video was never imported
+because the archive already had one.
+"""
+
+from datetime import datetime
+from unittest.mock import AsyncMock
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.tests.unit.test_fallback_timelapse_baseline_2957 import (
+    NEW_VIDEO,
+    OLD_VIDEO,
+    _entry,
+    _patches,
+    _seed,
+)
+
+pytestmark = pytest.mark.asyncio
+
+FIRST_RUN = datetime(2026, 10, 19, 8, 0)
+NEXT_RUN = datetime(2026, 10, 19, 9, 30)
+
+
+async def _first_run_ended(maker, archive_id):
+    async with maker() as db:
+        archive = await db.get(PrintArchive, archive_id)
+        archive.started_at = FIRST_RUN
+        archive.timelapse_baseline = [OLD_VIDEO]
+        await db.commit()
+
+
+async def _next_run_starts(maker, archive_id):
+    async with maker() as db:
+        archive = await db.get(PrintArchive, archive_id)
+        archive.started_at = NEXT_RUN
+        archive.status = "printing"
+        await db.commit()
+
+
+async def test_a_run_that_started_during_the_download_does_not_get_the_video(test_engine, tmp_path, monkeypatch):
+    from backend.app import main as main_module
+    from backend.app.services import bambu_ftp
+
+    maker, _printer_id, archive_id = await _seed(test_engine)
+    await _first_run_ended(maker, archive_id)
+
+    with _patches(main_module, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_VIDEO), _entry(NEW_VIDEO)]):
+
+        async def download(*_args, **_kwargs):
+            await _next_run_starts(maker, archive_id)
+            return b"x" * 1024
+
+        monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(side_effect=download))
+        await main_module._scan_for_timelapse_with_retries(archive_id, None)
+
+    async with maker() as db:
+        assert (await db.get(PrintArchive, archive_id)).timelapse_path is None
+    bambu_ftp.delete_archived_timelapse.assert_not_awaited()
+
+
+async def test_the_poll_stops_once_a_newer_run_has_started(test_engine, tmp_path, monkeypatch):
+    from backend.app import main as main_module
+    from backend.app.services import bambu_ftp
+
+    maker, _printer_id, archive_id = await _seed(test_engine)
+    await _first_run_ended(maker, archive_id)
+    rounds = []
+
+    with _patches(main_module, maker, monkeypatch, tmp_path, lambda: []):
+
+        async def listing(_ip, _code, path, printer_model=None):
+            if path != "/timelapse":
+                return []
+            rounds.append(path)
+            if len(rounds) == 1:
+                await _next_run_starts(maker, archive_id)
+                return [_entry(OLD_VIDEO)]
+            return [_entry(OLD_VIDEO), _entry(NEW_VIDEO)]
+
+        monkeypatch.setattr(bambu_ftp, "list_files_async", listing)
+        await main_module._scan_for_timelapse_with_retries(archive_id, None)
+
+    assert len(rounds) == 1, "the poll went on for a run that is not its own"
+    bambu_ftp.download_file_bytes_async.assert_not_awaited()
+
+
+async def test_a_video_attached_during_the_download_is_kept(test_engine, tmp_path, monkeypatch):
+    """Picked by hand (Scan for Timelapse) while the automatic scan downloaded."""
+    from backend.app import main as main_module
+    from backend.app.services import bambu_ftp
+
+    maker, _printer_id, archive_id = await _seed(test_engine)
+    await _first_run_ended(maker, archive_id)
+
+    with _patches(main_module, maker, monkeypatch, tmp_path, lambda: [_entry(OLD_VIDEO), _entry(NEW_VIDEO)]):
+
+        async def download(*_args, **_kwargs):
+            async with maker() as db:
+                archive = await db.get(PrintArchive, archive_id)
+                archive.timelapse_path = "archive/1/picked_by_hand.mp4"
+                await db.commit()
+            return b"x" * 1024
+
+        monkeypatch.setattr(bambu_ftp, "download_file_bytes_async", AsyncMock(side_effect=download))
+        await main_module._scan_for_timelapse_with_retries(archive_id, None)
+
+    async with maker() as db:
+        assert (await db.get(PrintArchive, archive_id)).timelapse_path == "archive/1/picked_by_hand.mp4"
+    bambu_ftp.delete_archived_timelapse.assert_not_awaited()

+ 231 - 0
backend/tests/unit/test_usage_tracker_charge_race.py

@@ -0,0 +1,231 @@
+"""The tracker adds a print's usage to what is stored, not to its own copy (WU-4).
+
+Completion loads the spool, awaits more lookups, and only then charges it. A
+weight set by hand or weighed on the SpoolBuddy scale in between was lost:
+the tracker wrote back its earlier copy plus the print's grams. Each test
+stores 300 g used (the hand edit) while the tracker's copy still reads the
+100 g it loaded before that edit.
+"""
+
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import event, select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.orm.attributes import set_committed_value
+
+from backend.app.models.spool import Spool
+from backend.app.services.usage_tracker import (
+    PrintSession,
+    _active_sessions,
+    _charge_spool,
+    _track_from_3mf,
+    on_print_complete,
+)
+
+pytestmark = pytest.mark.asyncio
+
+HAND_SET = 300.0
+
+
+def _loaded_before_the_hand_edit(spool, _context):
+    set_committed_value(spool, "weight_used", 100.0)
+
+
+@pytest.fixture
+def stale_loads():
+    event.listen(Spool, "load", _loaded_before_the_hand_edit)
+    yield
+    event.remove(Spool, "load", _loaded_before_the_hand_edit)
+
+
+@pytest.fixture
+async def maker(test_engine):
+    return async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def _spools(maker, count: int = 1) -> list[int]:
+    async with maker() as db:
+        rows = [Spool(material="PLA", label_weight=1000, weight_used=HAND_SET) for _ in range(count)]
+        db.add_all(rows)
+        await db.commit()
+        return [row.id for row in rows]
+
+
+async def _printer(maker) -> int:
+    from backend.app.models.printer import Printer
+
+    async with maker() as db:
+        printer = Printer(name="P", serial_number="RACE01", ip_address="10.0.0.9", access_code="12345678")
+        db.add(printer)
+        await db.commit()
+        return printer.id
+
+
+async def _stored(maker, spool_id: int) -> float:
+    """Read as a column, so the stale-load hook does not touch it."""
+    async with maker() as db:
+        return await db.scalar(select(Spool.weight_used).where(Spool.id == spool_id))
+
+
+async def test_the_charge_adds_to_the_stored_weight(maker, stale_loads):
+    (spool_id,) = await _spools(maker)
+
+    async with maker() as db:
+        spool = await db.get(Spool, spool_id)
+        assert spool.weight_used == 100.0, "the copy is the stale one"
+        await _charge_spool(db, spool, 25.0)
+        assert spool.weight_used == HAND_SET + 25.0, "the copy follows what is stored"
+        await db.commit()
+
+    assert await _stored(maker, spool_id) == HAND_SET + 25.0
+
+
+async def test_a_spool_deleted_meanwhile_is_left_alone(maker):
+    (spool_id,) = await _spools(maker)
+
+    async with maker() as db:
+        spool = await db.get(Spool, spool_id)
+        async with maker() as other:
+            await other.delete(await other.get(Spool, spool_id))
+            await other.commit()
+        assert await _charge_spool(db, spool, 25.0) is False
+        await db.commit()
+
+
+async def _track_3mf(
+    maker, tmp_path, spool_ids: dict[tuple[int, int], int], slots: int = 1, mapping=(0,), **state
+) -> None:
+    printer_id = await _printer(maker)
+    threemf = tmp_path / "part.3mf"
+    threemf.write_bytes(b"3mf")
+    status = SimpleNamespace(
+        progress=100, layer_num=90, tray_now=0, last_loaded_tray=-1, raw_data={}, **{"total_layers": 0, **state}
+    )
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = status
+
+    async def resolve(printer_id, ams_id, tray_id, **kwargs):
+        return spool_ids.get((ams_id, tray_id))
+
+    usage = [{"slot_id": n, "used_g": 9.0, "type": "PLA", "color": "#FFFFFF"} for n in range(1, slots + 1)]
+    with (
+        patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
+        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={}),
+        patch("backend.app.services.usage_tracker._resolve_spool_id_for_tray", side_effect=resolve),
+    ):
+        async with maker() as db:
+            await _track_from_3mf(
+                printer_id=printer_id,
+                archive_id=None,
+                status="completed",
+                print_name="part",
+                handled_trays=set(),
+                printer_manager=printer_manager,
+                db=db,
+                ams_mapping=list(mapping),
+                print_started_at=datetime.now(timezone.utc),
+                threemf_path=threemf,
+            )
+            await db.commit()
+
+
+async def test_the_3mf_charge(maker, tmp_path, stale_loads):
+    (spool_id,) = await _spools(maker)
+
+    await _track_3mf(maker, tmp_path, {(0, 0): spool_id}, tray_change_log=[])
+
+    assert await _stored(maker, spool_id) == HAND_SET + 9.0
+
+
+async def test_the_3mf_charge_split_across_trays(maker, tmp_path, stale_loads):
+    first, second = await _spools(maker, 2)
+
+    await _track_3mf(
+        maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
+    )
+
+    assert (await _stored(maker, first), await _stored(maker, second)) == (HAND_SET + 4.5, HAND_SET + 4.5)
+
+
+@pytest.mark.parametrize("deleted", [False, True], ids=["charged", "deleted-meanwhile"])
+async def test_the_remain_percentage_charge(maker, stale_loads, deleted):
+    from backend.app.models.spool_assignment import SpoolAssignment
+
+    (spool_id,) = await _spools(maker)
+    printer_id = await _printer(maker)
+    async with maker() as db:
+        db.add(SpoolAssignment(spool_id=spool_id, printer_id=printer_id, ams_id=0, tray_id=0))
+        await db.commit()
+
+    # 80% -> 70% of a 1000 g spool: 100 g.
+    _active_sessions[printer_id] = PrintSession(
+        printer_id=printer_id, print_name="part", started_at=datetime.now(timezone.utc), tray_remain_start={(0, 0): 80}
+    )
+    status = SimpleNamespace(
+        raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, progress=100, layer_num=0, tray_now=255
+    )
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = status
+    try:
+        with (
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+            patch("backend.app.services.usage_tracker._track_from_3mf", AsyncMock(return_value=[])),
+            patch(
+                "backend.app.services.usage_tracker._charge_spool",
+                _deleting(maker, spool_id if deleted else -1),
+            ),
+        ):
+            async with maker() as db:
+                await on_print_complete(printer_id, {"status": "completed"}, printer_manager, db)
+    finally:
+        _active_sessions.pop(printer_id, None)
+
+    if deleted:
+        assert await _charged(maker) == []
+    else:
+        assert await _stored(maker, spool_id) == HAND_SET + 100.0
+
+
+@pytest.mark.parametrize("split", [True, False], ids=["split-across-trays", "one-tray-per-slot"])
+async def test_a_spool_deleted_while_the_print_is_charged_is_skipped(maker, tmp_path, split):
+    """The rest of the print is still charged: a usage row for the deleted spool
+    broke the foreign key on PostgreSQL and rolled the whole completion back."""
+    first, second = await _spools(maker, 2)
+
+    with patch("backend.app.services.usage_tracker._charge_spool", _deleting(maker, first)):
+        if split:
+            await _track_3mf(
+                maker, tmp_path, {(0, 0): first, (0, 1): second}, tray_change_log=[(0, 0), (1, 45)], total_layers=90
+            )
+        else:
+            await _track_3mf(maker, tmp_path, {(0, 0): first, (0, 1): second}, slots=2, mapping=(0, 1))
+
+    assert await _stored(maker, second) == HAND_SET + (4.5 if split else 9.0)
+    assert await _charged(maker) == [second]
+
+
+def _deleting(maker, spool_id: int):
+    """_charge_spool, with *spool_id* deleted by someone else just before."""
+    from backend.app.services import usage_tracker
+
+    real_charge = usage_tracker._charge_spool
+
+    async def charge(db, spool, grams):
+        if spool.id == spool_id:
+            async with maker() as other:
+                await other.delete(await other.get(Spool, spool_id))
+                await other.commit()
+        return await real_charge(db, spool, grams)
+
+    return charge
+
+
+async def _charged(maker) -> list[int]:
+    from backend.app.models.spool_usage_history import SpoolUsageHistory
+
+    async with maker() as db:
+        return list((await db.execute(select(SpoolUsageHistory.spool_id))).scalars().all())

+ 131 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -20,6 +20,7 @@ import { ToastProvider } from '../../contexts/ToastContext';
 import { http, HttpResponse } from 'msw';
 import { setAuthToken } from '../../api/client';
 import { server } from '../mocks/server';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 import type { PrintQueueItem } from '../../api/client';
 
 const mockPrinters = [
@@ -2381,6 +2382,92 @@ describe('PrintModal — per-plate filament override in model mode (#2552)', ()
       expect.objectContaining({ slot_id: 2, force_color_match: true }),
     ]);
   });
+
+  // The queue page shows the stored colour name. The same blue is "Marine
+  // Blue" in PLA Matte; the plain type "PLA" only reaches the generic name (SN-C9).
+  it("stores a forced slot's colour name within its sliced product line", async () => {
+    setColorCatalog({ '0000ff': 'Blue' }, { 'PLA Matte|0000ff': 'Marine Blue' });
+    type Queued = { plate_id: number; filament_overrides?: Array<{ slot_id: number; color_name?: string }> | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ ...SLOT_2_BLUE, tray_info_idx: 'GFA01' }] }),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    try {
+      const user = userEvent.setup();
+      renderWithCache(
+        <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+        (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+      );
+      await selectAnyX1CAndBothPlates(user);
+      await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
+      // Wait for the preset names, which the stored colour name is derived from.
+      await waitFor(() => expect(screen.getAllByText(/PLA Matte/).length).toBeGreaterThan(0));
+
+      await user.click(screen.getAllByRole('checkbox', { name: /force color match/i })[1]);
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queued.length).toBe(2));
+      expect(queued.find((q) => q.plate_id === 2)?.filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 2, color_name: 'Marine Blue' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
+
+  it("does not name a swapped slot's colour by the preset it was sliced with", async () => {
+    setColorCatalog({ '00ff00': 'Green' }, { 'PLA Matte|00ff00': 'Matte Green' });
+    type Queued = { plate_id: number; filament_overrides?: Array<{ slot_id: number; color_name?: string }> | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ ...SLOT_2_BLUE, tray_info_idx: 'GFA01' }] }),
+      ),
+      http.get('/api/v1/cloud/builtin-filaments', () =>
+        HttpResponse.json([{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]),
+      ),
+      http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    try {
+      const user = userEvent.setup();
+      renderWithCache(
+        <PrintModal mode="create" archiveId={1} archiveName="Two.gcode.3mf" onClose={mockOnClose} />,
+        (qc) => qc.setQueryData(['archive-plates', 1], PLATES),
+      );
+      await selectAnyX1CAndBothPlates(user);
+      await waitFor(() => expect(screen.getByText(/Filament Override — Plate 2/)).toBeInTheDocument());
+      await waitFor(() => expect(screen.getAllByText(/PLA Matte/).length).toBeGreaterThan(0));
+
+      // Swap the PLA Matte blue for the loaded PLA Basic green.
+      const pickers = screen.getAllByRole('combobox', { name: /filament override for/i });
+      await user.click(pickers[pickers.length - 1]);
+      await user.click(within(await screen.findByRole('listbox')).getByText(/Green/));
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queued.length).toBe(2));
+      expect(queued.find((q) => q.plate_id === 2)?.filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 2, color: '#00FF00', color_name: 'Green' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
 });
 
 describe('PrintModal — per-plate quantity (#342)', () => {
@@ -2785,6 +2872,50 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
     ]);
   });
 
+  it("names a kept pin's colour within its product line when the file's filaments can't be read", async () => {
+    const hex = BROWN.replace('#', '').toLowerCase();
+    setColorCatalog({ [hex]: 'Brown' }, { [`PLA Matte|${hex}`]: 'Matte Brown' });
+    server.use(
+      http.get('/api/v1/archives/:id/filament-requirements', () => HttpResponse.json({ detail: 'gone' }, { status: 500 })),
+    );
+    const vpItem = createMockQueueItem({
+      printer_id: null,
+      target_model: 'P2S',
+      filament_overrides: [
+        { slot_id: 1, type: 'PLA', color: BROWN, tray_info_idx: 'GFA01', force_color_match: true },
+      ],
+    } as Partial<PrintQueueItem>);
+    // The preset names are known from the first render, so the save can't race them.
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
+    queryClient.setQueryData(['builtin-filaments'], [{ filament_id: 'GFA01', name: 'Bambu PLA Matte' }]);
+    queryClient.setQueryData(['filament-id-map'], {});
+
+    try {
+      const user = userEvent.setup();
+      rtlRender(
+        <QueryClientProvider client={queryClient}>
+          <BrowserRouter>
+            <AuthProvider>
+              <ThemeProvider>
+                <ToastProvider>
+                  <PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={vpItem} onClose={mockOnClose} />
+                </ToastProvider>
+              </ThemeProvider>
+            </AuthProvider>
+          </BrowserRouter>
+        </QueryClientProvider>,
+      );
+      await submit(user);
+
+      await waitFor(() => expect(patched).toHaveLength(1));
+      expect(patched[0].filament_overrides).toEqual([
+        expect.objectContaining({ slot_id: 1, tray_info_idx: 'GFA01', color_name: 'Matte Brown' }),
+      ]);
+    } finally {
+      __resetColorCatalogForTests();
+    }
+  });
+
   it('carries an override picked in model mode into a specific-printer job when creating', async () => {
     const user = userEvent.setup();
     render(<PrintModal mode="create" archiveId={1} archiveName="Job" onClose={mockOnClose} />);

+ 30 - 1
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -512,8 +512,9 @@ describe('useWebSocket hook', () => {
         ws.simulateMessage({ type: 'inventory_changed' });
       });
 
+      // 3 s debounce, then one key every 500 ms.
       await act(async () => {
-        vi.advanceTimersByTime(5000);
+        vi.advanceTimersByTime(6000);
       });
 
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
@@ -524,6 +525,34 @@ describe('useWebSocket hook', () => {
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-suppliers'] });
       // The per-material-number aggregate is derived from the same rows (#2870).
       expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['material-number-stats'] });
+      // A weight synced or weighed elsewhere reaches printer cards through the slot rows (WU-9).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
+
+    it("refreshes the slot rows when a print's usage is logged", async () => {
+      // The printer card reads a spool's remaining weight from the slot rows;
+      // a finished print is the most common weight change.
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({ type: 'spool_usage_logged', data: {} });
+      });
+      await act(async () => {
+        vi.advanceTimersByTime(6000);
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
 
       vi.useRealTimers();
       vi.unstubAllGlobals();

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

@@ -10,6 +10,7 @@ import { QueuePage } from '../../pages/QueuePage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { setAuthToken } from '../../api/client';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 
 // Mock queue data
 const mockQueueItems = [
@@ -634,6 +635,99 @@ describe('QueuePage', () => {
       });
     });
 
+    // The colour catalogue is keyed by product line: the same black is
+    // "Charcoal" in PLA Matte and "Black" in PLA Basic. The plate's type is
+    // only "PLA"; its slicer preset (tray_info_idx) names the line (SN-C9).
+    describe('colour names by product line', () => {
+      const plateWith = (filament: Record<string, unknown>) =>
+        HttpResponse.json({
+          file_id: 16,
+          filename: 'matte.3mf',
+          plates: [
+            {
+              index: 1,
+              name: 'Plate 1',
+              objects: ['Part'],
+              has_thumbnail: false,
+              thumbnail_url: null,
+              print_time_seconds: 3600,
+              filament_used_grams: 20,
+              filaments: [{ slot_id: 1, type: 'PLA', color: '#000000', used_grams: 20, used_meters: 6.7, ...filament }],
+            },
+          ],
+          is_multi_plate: false,
+        });
+
+      const show = async (plateFilament: Record<string, unknown> | null, overrides: unknown = null) => {
+        const item = {
+          ...mockQueueItems[0],
+          id: 85,
+          printer_id: null,
+          target_model: 'P2S',
+          archive_id: null,
+          library_file_id: 16,
+          archive_name: null,
+          library_file_name: 'Product line test',
+          plate_id: 1,
+          filament_overrides: overrides,
+        };
+        server.use(
+          http.get('/api/v1/queue/', () => HttpResponse.json([item])),
+          http.get('/api/v1/library/files/16/plates', () =>
+            plateFilament ? plateWith(plateFilament) : HttpResponse.json({ detail: 'Not found' }, { status: 404 }),
+          ),
+          http.get('/api/v1/cloud/builtin-filaments', () =>
+            HttpResponse.json([
+              { filament_id: 'GFA01', name: 'Bambu PLA Matte' },
+              { filament_id: 'GFA00', name: 'Bambu PLA Basic' },
+            ]),
+          ),
+          http.get('/api/v1/cloud/filament-id-map', () => HttpResponse.json({})),
+        );
+        render(<QueuePage />);
+        await screen.findByText('Product line test');
+        return screen.getByText('Product line test').closest('.group') as HTMLElement;
+      };
+
+      beforeEach(() => {
+        setColorCatalog({ '000000': 'Black' }, { 'PLA Matte|000000': 'Charcoal' });
+      });
+      afterEach(() => {
+        __resetColorCatalogForTests();
+      });
+
+      it("names the plate colour within the preset's product line", async () => {
+        const row = await show({ tray_info_idx: 'GFA01' });
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it("names an override within the override's own preset", async () => {
+        const row = await show({ tray_info_idx: 'GFA00' }, [
+          { slot_id: 1, type: 'PLA', color: '#000000', tray_info_idx: 'GFA01', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it('names an override within its preset when the file has no plate listing', async () => {
+        const row = await show(null, [
+          { slot_id: 1, type: 'PLA', color: '#000000', tray_info_idx: 'GFA01', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Charcoal')).toBeInTheDocument();
+      });
+
+      it("does not lend the plate's preset to an override of another filament", async () => {
+        const row = await show({ tray_info_idx: 'GFA01' }, [
+          { slot_id: 1, type: 'PETG', color: '#000000', force_color_match: false },
+        ]);
+
+        expect(await within(row).findByText('Black')).toBeInTheDocument();
+        expect(within(row).queryByText('Charcoal')).toBeNull();
+      });
+    });
+
     it('keeps an eight-colour job compact and exposes details in the tooltip (#3132)', async () => {
       const colours = [
         '#FF0000',

+ 11 - 2
frontend/src/components/PrintModal/index.tsx

@@ -17,6 +17,7 @@ import {
 } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
+import { productLineOf, useFilamentNameByIdx } from './useFilamentLabels';
 import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
@@ -521,6 +522,8 @@ export function PrintModal({
   );
 
   // Fetch available filaments for model-based assignment (for filament override UI)
+  // Preset id -> name, to store a colour name within its product line (SN-C9)
+  const filamentNameByIdx = useFilamentNameByIdx();
   const { data: availableFilaments } = useQuery({
     queryKey: ['available-filaments', targetModel, targetLocation],
     queryFn: () => api.getAvailableFilaments(targetModel!, targetLocation ?? undefined),
@@ -1224,7 +1227,11 @@ export function PrintModal({
 
           // Include slot if user changed the filament OR force_color_match is enabled
           if (userOverride || isForceColor) {
-            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: getColorName(effectiveColor), ...storedVariantFor(req.slot_id, effectiveType, effectiveColor), force_color_match: isForceColor });
+            const variant = storedVariantFor(req.slot_id, effectiveType, effectiveColor);
+            // A slot left as sliced keeps its own preset; a swapped one only a stored matching one.
+            const preset = variant.tray_info_idx ?? (userOverride ? undefined : req.tray_info_idx);
+            const colorName = getColorName(effectiveColor, productLineOf(preset, effectiveType, filamentNameByIdx));
+            entries.push({ slot_id: req.slot_id, type: effectiveType, color: effectiveColor, color_name: colorName, ...variant, force_color_match: isForceColor });
           }
         }
       } else {
@@ -1232,7 +1239,9 @@ export function PrintModal({
         for (const [slotId, { type, color }] of Object.entries(filamentOverrides)) {
           const id = parseInt(slotId, 10);
           const isForceColor = forceColorMatch[id] ?? false;
-          entries.push({ slot_id: id, type, color, color_name: getColorName(color), ...storedVariantFor(id, type, color), force_color_match: isForceColor });
+          const variant = storedVariantFor(id, type, color);
+          const colorName = getColorName(color, productLineOf(variant.tray_info_idx, type, filamentNameByIdx));
+          entries.push({ slot_id: id, type, color, color_name: colorName, ...variant, force_color_match: isForceColor });
         }
       }
 

+ 41 - 21
frontend/src/components/PrintModal/useFilamentLabels.ts

@@ -38,27 +38,12 @@ interface FilamentReqLike {
 }
 
 /**
- * Resolve per-slot human-readable labels for the schedule modal's filament
- * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
- * mode ``FilamentMapping`` consume this so the two panels render the same
- * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
- * the inline implementation in ``FilamentOverride`` so the two callers can't
- * drift.
- *
- * Three queries back the resolution:
- *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
- *     "Bambu PLA Matte" etc.).
- *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
- *     map (P-prefix). Wins over the builtin entry for the same id.
- *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
- *     ``useQueries``, keyed on hex + material hint) → catalog color name
- *     disambiguated by material context.
- *
- * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
- * an empty array when ``reqs`` is undefined / empty so callers can safely
- * index without a length check.
+ * Slicer preset id (``tray_info_idx``: GFA01, P-prefixed user presets) to its
+ * name ("Bambu PLA Matte"). The cloud user-preset map wins over the builtin
+ * entry for the same id. Shared by the print dialog and the queue page so both
+ * name a colour within the same product line.
  */
-export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+export function useFilamentNameByIdx(): Record<string, string> {
   const { data: builtinFilaments } = useQuery({
     queryKey: ['builtin-filaments'],
     queryFn: () => api.getBuiltinFilaments(),
@@ -70,7 +55,7 @@ export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined):
     staleTime: 5 * 60 * 1000,
   });
 
-  const filamentNameByIdx = useMemo(() => {
+  return useMemo(() => {
     const map: Record<string, string> = {};
     for (const f of builtinFilaments || []) {
       if (f.filament_id) map[f.filament_id] = f.name;
@@ -82,6 +67,41 @@ export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined):
     }
     return map;
   }, [builtinFilaments, cloudFilamentIdMap]);
+}
+
+/** The colour catalogue's material for a filament: the product line its
+ *  preset names ("PLA Matte"), else its bare type ("PLA"). */
+export function productLineOf(
+  trayInfoIdx: string | null | undefined,
+  type: string,
+  filamentNameByIdx: Record<string, string>,
+): string {
+  return extractMaterialHint((trayInfoIdx && filamentNameByIdx[trayInfoIdx]) || type);
+}
+
+/**
+ * Resolve per-slot human-readable labels for the schedule modal's filament
+ * panels (#1718). Both the model-mode ``FilamentOverride`` and the printer-
+ * mode ``FilamentMapping`` consume this so the two panels render the same
+ * sub-brand + disambiguated color for the same sliced 3MF. Extracted from
+ * the inline implementation in ``FilamentOverride`` so the two callers can't
+ * drift.
+ *
+ * Three queries back the resolution:
+ *   - ``/cloud/builtin-filaments`` → Bambu factory SKU → name map (GFA01 →
+ *     "Bambu PLA Matte" etc.).
+ *   - ``/cloud/filament-id-map``   → user custom cloud preset SKU → name
+ *     map (P-prefix). Wins over the builtin entry for the same id.
+ *   - ``/inventory/colors/by-material`` (one ``useQuery`` per slot via
+ *     ``useQueries``, keyed on hex + material hint) → catalog color name
+ *     disambiguated by material context.
+ *
+ * Output is positional — ``labels[i]`` corresponds to ``reqs[i]``. Returns
+ * an empty array when ``reqs`` is undefined / empty so callers can safely
+ * index without a length check.
+ */
+export function useFilamentLabels(reqs: readonly FilamentReqLike[] | undefined): FilamentLabel[] {
+  const filamentNameByIdx = useFilamentNameByIdx();
 
   // Compute the per-slot (resolvedName, materialHint) pairs up-front so the
   // ``useQueries`` call below has a stable shape and the render path below

+ 5 - 1
frontend/src/hooks/useWebSocket.ts

@@ -488,6 +488,8 @@ export function useWebSocket() {
         debouncedInvalidate(inventorySuppliersQueryKey[0]);
         // The per-material-number aggregate is derived from the same rows (#2870).
         debouncedInvalidate('material-number-stats');
+        // Printer cards and slot views read a spool's weight from the slot rows.
+        debouncedInvalidate('spool-assignments');
         break;
 
       case 'spool_assignment_changed':
@@ -535,8 +537,10 @@ export function useWebSocket() {
         break;
 
       case 'spool_usage_logged':
-        // Filament consumption recorded - refresh spool data
+        // Filament consumption recorded - refresh spool data, and the slot rows
+        // printer cards read a spool's remaining weight from.
         debouncedInvalidate('inventory-spools');
+        debouncedInvalidate('spool-assignments');
         break;
 
       case 'unknown_tag': {

+ 17 - 3
frontend/src/pages/QueuePage.tsx

@@ -70,6 +70,7 @@ import { PipelineRunsView } from './PipelineRunsPage';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import { getBedTypeInfo } from '../utils/bedType';
 import { getColorName } from '../utils/colors';
+import { productLineOf, useFilamentNameByIdx } from '../components/PrintModal/useFilamentLabels';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
 import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
 import type { PlateMetadata } from '../types/plates';
@@ -180,6 +181,7 @@ function resolveQueueFilaments(
   loadedFilaments: LoadedFilament[] = [],
   status?: PrinterStatus,
   emptyLabel = 'Empty',
+  filamentNameByIdx: Record<string, string> = {},
 ): QueueFilamentDisplay[] {
   // A stored AMS mapping names the physical tray that will actually feed a 3MF
   // slot. Once its live tray data is available, that is more specific than
@@ -231,12 +233,16 @@ function resolveQueueFilaments(
         const override = overrides.get(filament.slot_id);
         const color = override?.color ?? filament.color;
         const type = override?.type ?? filament.type;
+        // An override is another filament: the plate's preset is not its own.
+        const productLine = override
+          ? productLineOf(override.tray_info_idx, override.type, filamentNameByIdx)
+          : productLineOf(filament.tray_info_idx, filament.type, filamentNameByIdx);
 
         return resolveSlot({
           slotId: filament.slot_id,
           type,
           color,
-          colorName: override?.color_name?.trim() || getColorName(color, type),
+          colorName: override?.color_name?.trim() || getColorName(color, productLine),
         });
       });
   }
@@ -252,7 +258,7 @@ function resolveQueueFilaments(
         color: override.color,
         colorName:
           override.color_name?.trim() ||
-          getColorName(override.color, override.type),
+          getColorName(override.color, productLineOf(override.tray_info_idx, override.type, filamentNameByIdx)),
       }),
     );
   }
@@ -658,7 +664,15 @@ function SortableQueueItem({
   // Combine plates data from either source
   const platesData = isLibraryFile ? libraryPlatesData : archivePlatesData;
   const plates = platesData?.plates ?? [];
-  const queueFilaments = resolveQueueFilaments(item, plates, loadedFilaments, status, t('ams.empty'));
+  const filamentNameByIdx = useFilamentNameByIdx();
+  const queueFilaments = resolveQueueFilaments(
+    item,
+    plates,
+    loadedFilaments,
+    status,
+    t('ams.empty'),
+    filamentNameByIdx,
+  );
 
   const canReorder = hasPermission('queue:reorder');
   const {

+ 2 - 0
frontend/src/types/plates.ts

@@ -2,6 +2,8 @@ export interface PlateFilament {
   slot_id: number;
   type: string;
   color: string;
+  // The slicer preset (GFA01, ...); names the product line for colour names
+  tray_info_idx?: string;
   used_grams: number;
   used_meters: number;
   // True when this AMS slot is consumed by the picked plate. False