Przeglądaj źródła

Price a running print from its spools at start (#3261)

maziggy 4 dni temu
rodzic
commit
879e403315
100 zmienionych plików z 863 dodań i 5 usunięć
  1. 1 0
      CHANGELOG.md
  2. 23 0
      backend/app/main.py
  3. 346 0
      backend/app/services/archive_cost_estimate.py
  4. 379 0
      backend/tests/unit/services/test_archive_cost_estimate_3261.py
  5. 90 0
      frontend/src/__tests__/pages/ArchivesCostEstimate.test.tsx
  6. 1 0
      frontend/src/i18n/locales/de.ts
  7. 1 0
      frontend/src/i18n/locales/en.ts
  8. 1 0
      frontend/src/i18n/locales/es.ts
  9. 1 0
      frontend/src/i18n/locales/fr.ts
  10. 1 0
      frontend/src/i18n/locales/it.ts
  11. 1 0
      frontend/src/i18n/locales/ja.ts
  12. 1 0
      frontend/src/i18n/locales/ko.ts
  13. 1 0
      frontend/src/i18n/locales/nl.ts
  14. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  15. 1 0
      frontend/src/i18n/locales/ru.ts
  16. 1 0
      frontend/src/i18n/locales/sv.ts
  17. 1 0
      frontend/src/i18n/locales/tr.ts
  18. 1 0
      frontend/src/i18n/locales/uk.ts
  19. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  20. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  21. 7 1
      frontend/src/pages/ArchivesPage.tsx
  22. 0 0
      static/assets/AdditionalSection-B9iB6xW2.js
  23. 0 0
      static/assets/AmsUnitCard-6tucYF7U.js
  24. 0 0
      static/assets/ArchivesPage-BSb_MB9S.js
  25. 0 0
      static/assets/AssignToAmsModal-DKCru_ir.js
  26. 0 0
      static/assets/CamWallPage-B4heNGsV.js
  27. 0 0
      static/assets/CameraDiagnoseModal-DaFY1v8I.js
  28. 0 0
      static/assets/CameraPage--Jmbrerh.js
  29. 0 0
      static/assets/CameraWall-yInrBATM.js
  30. 0 0
      static/assets/ColumnConfigModal-BPctn5Lk.js
  31. 0 0
      static/assets/ConfigureAmsSlotModal-uPwVXZ4s.js
  32. 0 0
      static/assets/ConnectAuthorizePage-Chyu6mpt.js
  33. 0 0
      static/assets/ContextMenu-B4uGJkKW.js
  34. 0 0
      static/assets/ExternalLinkPage-BhKNB8Xn.js
  35. 0 1
      static/assets/FileManagerPage-4cVsnIkI.js
  36. 0 0
      static/assets/FileUploadModal-GYUY0fS6.js
  37. 0 0
      static/assets/FinancePage-COWXFGjj.js
  38. 0 0
      static/assets/GCodeViewerPage-BhjPYegv.js
  39. 0 0
      static/assets/GroupEditPage-D6XMJTTK.js
  40. 0 0
      static/assets/ImagePreviewModal-CTLU6Jnd.js
  41. 0 0
      static/assets/InventoryPage-CFjZN794.js
  42. 0 0
      static/assets/LibraryTrashPage-C41Ue4qA.js
  43. 0 0
      static/assets/LoginPage-YsL4u_Va.js
  44. 0 0
      static/assets/MaintenancePage-BewPZHCP.js
  45. 0 0
      static/assets/ModelSourcesPage-8I2_SqrV.js
  46. 0 0
      static/assets/NotificationsPage-Wf6csR7A.js
  47. 0 1
      static/assets/PdfPreviewModal-HVSkaROI.js
  48. 0 0
      static/assets/PhotoGalleryModal-N1J3Wu90.js
  49. 0 0
      static/assets/PrinterLocationsPage-BIW5YJnm.js
  50. 0 0
      static/assets/PrintersPage-CLoeMCjh.js
  51. 0 0
      static/assets/ProfilesPage-CN3SHdd_.js
  52. 0 0
      static/assets/ProjectDetailPage-BpwdspD8.js
  53. 0 0
      static/assets/ProjectsPage-BvSVSfNa.js
  54. 0 0
      static/assets/QueuePage-DVAUmwSb.js
  55. 0 0
      static/assets/RichTextEditor-DoPGmmPm.js
  56. 0 0
      static/assets/SettingsPage-CEuITKXc.js
  57. 0 0
      static/assets/SetupPage-MuOSaMDM.js
  58. 0 0
      static/assets/SliceModal-DYhyNfSM.js
  59. 0 0
      static/assets/SpoolBuddyAmsPage-DjBGn9co.js
  60. 0 0
      static/assets/SpoolBuddyCalibrationPage-1VQoTPzX.js
  61. 0 0
      static/assets/SpoolBuddyDashboard-CpDVcJoI.js
  62. 0 0
      static/assets/SpoolBuddyInventoryPage-B8Mx48p2.js
  63. 1 1
      static/assets/SpoolBuddySettingsPage-DJ5YGyN7.js
  64. 0 0
      static/assets/SpoolBuddyWriteTagPage-D3rzn4tk.js
  65. 0 0
      static/assets/SpreadsheetPreviewModal-sKEz1Isz.js
  66. 0 0
      static/assets/StatsPage-BdTapXHV.js
  67. 0 0
      static/assets/StreamOverlayPage-Cj5x5-Pf.js
  68. 0 0
      static/assets/SystemInfoPage-DWpWi9jq.js
  69. 0 0
      static/assets/de-RHfkhf78.js
  70. 0 0
      static/assets/de-SDAs-nZl.js
  71. 0 0
      static/assets/es-2wkz10gb.js
  72. 0 0
      static/assets/es-5tXCFuNX.js
  73. 0 0
      static/assets/fr-DF4R3Wbb.js
  74. 0 0
      static/assets/fr-DvO8gKKR.js
  75. 0 0
      static/assets/index-Bw-_P2Fg.js
  76. 1 1
      static/assets/inventorySearch-C82dQp2B.js
  77. 0 0
      static/assets/it-5JPxr7nq.js
  78. 0 0
      static/assets/it-Dd9dKQH1.js
  79. 0 0
      static/assets/ja-DQTngE8Y.js
  80. 0 0
      static/assets/ja-DkzMOqxg.js
  81. 0 0
      static/assets/ko-C2MmhZAd.js
  82. 0 0
      static/assets/ko-CtMumSND.js
  83. 0 0
      static/assets/locationSensorDefaults-BtezRgcN.js
  84. 0 0
      static/assets/nl-Btq4jfMj.js
  85. 0 0
      static/assets/nl-DJUe00ao.js
  86. 0 0
      static/assets/pdf-h6Jgng8Y.js
  87. 0 0
      static/assets/pt-BR-DOdeZdt6.js
  88. 0 0
      static/assets/pt-BR-u7vOhYKQ.js
  89. 0 0
      static/assets/ru-BSXECPwi.js
  90. 0 0
      static/assets/ru-DBcLI5Nh.js
  91. 0 0
      static/assets/sv-B3RkVm_Y.js
  92. 0 0
      static/assets/sv-Ba7un84l.js
  93. 0 0
      static/assets/tr-CNC804MB.js
  94. 0 0
      static/assets/tr-DyGF5y9d.js
  95. 0 0
      static/assets/uk-DOw6djx7.js
  96. 0 0
      static/assets/uk-w6_n9GAL.js
  97. 0 0
      static/assets/utils-D29gNIio.js
  98. 0 0
      static/assets/zh-CN-2TvSngBr.js
  99. 0 0
      static/assets/zh-CN-BCrLNhW2.js
  100. 0 0
      static/assets/zh-TW-CXHBn8MJ.js

+ 1 - 0
CHANGELOG.md

@@ -149,6 +149,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **A running print's cost used the default rate instead of its spools, then changed without notice at completion (#3261, reported by @ojimpo)** — The archive was priced at creation from the Filament catalogue or **Default Filament Cost**, and the spools that fed the print only priced it once it finished, in both Spoolman mode and the built-in inventory. Anyone noting the cost while a long print ran recorded a placeholder. A print is now priced from its spools when it starts, from the sliced file's per-slot estimate and the spool in each mapped tray, with the same default-rate cover for grams no spool prices; a reprint still keeps its first run's cost. The archive card marks a running print's cost with `~` and a tooltip, since the final figure comes from what was actually used. A print whose slots can't be matched to trays at the start keeps the placeholder until it finishes.
 - **AMS Filament Backup showed "?" on the P1S, P1P, A1 and A1 Mini (#3259, reported by @HopNotch)** — These printers never send the `cfg` field Bambuddy read the setting from; they carry it in `home_flag` bit 10, where Bambu Studio reads it for every printer. Bambuddy now falls back to that bit, so the badge shows the printer's real setting and the prefer-lowest-remaining gate follows it. Before 1.2.5.6 the badge seemed to work on these printers only because it read Bambuddy's own print command echoed back, which always said off (#3040).
 - **AMS Filament Backup showed "?" on the P1S, P1P, A1 and A1 Mini (#3259, reported by @HopNotch)** — These printers never send the `cfg` field Bambuddy read the setting from; they carry it in `home_flag` bit 10, where Bambu Studio reads it for every printer. Bambuddy now falls back to that bit, so the badge shows the printer's real setting and the prefer-lowest-remaining gate follows it. Before 1.2.5.6 the badge seemed to work on these printers only because it read Bambuddy's own print command echoed back, which always said off (#3040).
 - **Slicing failed with a printer preset saved under a name of its own (#3250, reported by @bobobo1618)** — A printer preset copied from a stock one and renamed, such as "Bambu Lab H2D 0.4 nozzle - Apply Skew Calibration", failed every slice with "The selected printer is not compatible with the process preset in the 3mf", even with a stock process the stock printer slices fine.
 - **Slicing failed with a printer preset saved under a name of its own (#3250, reported by @bobobo1618)** — A printer preset copied from a stock one and renamed, such as "Bambu Lab H2D 0.4 nozzle - Apply Skew Calibration", failed every slice with "The selected printer is not compatible with the process preset in the 3mf", even with a stock process the stock printer slices fine.
   - **Why:** the slicer checks a process against the stock preset a printer preset was saved from. The slicer sidecar handed it the renamed preset as a stock preset in its own right, and no process lists that name. It now names the stock preset the printer was saved from, while still slicing with the renamed preset's own settings, start G-code included. A process or filament preset that lists the renamed printer itself keeps fitting it, as it does in the slicer. Needs the updated slicer sidecar image (OrcaSlicer and Bambu Studio).
   - **Why:** the slicer checks a process against the stock preset a printer preset was saved from. The slicer sidecar handed it the renamed preset as a stock preset in its own right, and no process lists that name. It now names the stock preset the printer was saved from, while still slicing with the renamed preset's own settings, start G-code included. A process or filament preset that lists the renamed printer itself keeps fitting it, as it does in the slicer. Needs the updated slicer sidecar image (OrcaSlicer and Bambu Studio).

+ 23 - 0
backend/app/main.py

@@ -94,6 +94,9 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 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 import kprofile_drift, print_dispatch_context, slot_unlink_grace
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 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,
+)
 from backend.app.services.archive_purge import archive_purge_service
 from backend.app.services.archive_purge import archive_purge_service
 from backend.app.services.bambu_ftp import (
 from backend.app.services.bambu_ftp import (
     FileNotOnPrinterError,
     FileNotOnPrinterError,
@@ -4556,6 +4559,16 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
 
+                # Price the print from its spools now rather than only at completion (#3261)
+                _schedule_archive_cost_estimate(
+                    printer_id,
+                    archive.id,
+                    printer_manager,
+                    ams_mapping=_get_start_ams_mapping(data, archive.id),
+                    plate_id=_get_start_plate_id(archive.id),
+                    session_factory=async_session,
+                )
+
                 # Capture timelapse file baseline for snapshot-diff on completion
                 # Capture timelapse file baseline for snapshot-diff on completion
                 # (mirrors the new-archive branch). Queue / VP-dispatched prints
                 # (mirrors the new-archive branch). Queue / VP-dispatched prints
                 # hit this branch — without the baseline the completion-time scan
                 # hit this branch — without the baseline the completion-time scan
@@ -5473,6 +5486,16 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                 except Exception as e:
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
                     logger.warning("[SPOOLMAN] Failed to store tracking data: %s", e)
 
 
+                # Price the print from its spools now rather than only at completion (#3261)
+                _schedule_archive_cost_estimate(
+                    printer_id,
+                    archive.id,
+                    printer_manager,
+                    ams_mapping=_get_start_ams_mapping(data, archive.id),
+                    plate_id=_get_start_plate_id(archive.id),
+                    session_factory=async_session,
+                )
+
                 # Capture timelapse file baseline for snapshot-diff on completion
                 # Capture timelapse file baseline for snapshot-diff on completion
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
                 await _capture_timelapse_baseline_at_start(printer, printer_id, logger, archive_id=archive.id)
         finally:
         finally:

+ 346 - 0
backend/app/services/archive_cost_estimate.py

@@ -0,0 +1,346 @@
+"""Price a print from its spools when it starts, not only when it ends (#3261).
+
+``archive.py`` prices an archive at creation from the built-in Filament catalogue
+matched on the primary type, falling back to the global default rate. The
+spools that actually feed the print only price it at completion -- the
+built-in inventory in ``usage_tracker.on_print_complete``, Spoolman in
+``spoolman_tracking._apply_spool_cost_to_archive`` (#2591). In between, the
+archive card showed the placeholder, and anyone writing the cost down while a
+long print ran recorded a number that later changed without notice.
+
+This runs the completion-time pricing once at print start, on the 3MF's
+per-slot estimates instead of charged grams: each slot is priced at the spool
+in the tray it is mapped to, grams no spool could price are covered at the
+default rate, and the result replaces the placeholder. The figure is still an
+estimate -- a failed or stopped print ends up cheaper -- and the archive card
+says so while the print runs.
+
+Only mappings that are trustworthy at print start are used: the print
+command's own ``ams_mapping``, the queue item's stored mapping, and a colour
+match against the trays loaded right now. The printer's ``mapping`` field is
+deliberately not read here: it still describes the previous job until the
+printer pushes an update (see ``spoolman_tracking._resolve_slot_to_tray_fallback``).
+With no trustworthy mapping, nothing is priced and the placeholder stays.
+
+Strictly best-effort. Nothing here may fail a print start.
+"""
+
+import asyncio
+import json
+import logging
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings as app_settings
+
+logger = logging.getLogger(__name__)
+
+_EXTERNAL_TRAY_IDS = (254, 255)
+
+
+def _mapped_tray(slot_id: int, slot_to_tray: list, ams_trays: dict[int, dict]) -> int | None:
+    """The global tray a 1-based slot is mapped to, or None if the mapping doesn't say.
+
+    Unlike ``spoolman_tracking._resolve_global_tray_id`` there is no positional
+    default: a guess would price the slot at an unrelated spool, and the
+    default rate is the honest answer for a slot nobody mapped.
+    """
+    if slot_id <= 0 or slot_id > len(slot_to_tray):
+        return None
+    tray = slot_to_tray[slot_id - 1]
+    if not isinstance(tray, int) or isinstance(tray, bool):
+        return None
+    if tray >= 0:
+        return tray
+    if tray == -1:
+        # The flat ams_mapping writes the external spool as -1 (see
+        # _resolve_global_tray_id).
+        for ext_id in _EXTERNAL_TRAY_IDS:
+            if ext_id in ams_trays:
+                return ext_id
+    return None
+
+
+async def _default_cost_per_kg(db: AsyncSession) -> float:
+    from backend.app.api.routes.settings import get_setting
+
+    try:
+        value = await get_setting(db, "default_filament_cost")
+        return float(value) if value else 25.0
+    except (TypeError, ValueError):
+        return 25.0
+
+
+async def _internal_rates(
+    db: AsyncSession, printer_id: int, trays: set[int], default_cost_per_kg: float
+) -> dict[int, float]:
+    """Price per gram of the built-in inventory spool in each tray.
+
+    Same rule as ``usage_tracker``'s completion writer: the spool's own
+    ``cost_per_kg``, or the default rate when the spool has none.
+    """
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot
+
+    rates: dict[int, float] = {}
+    for tray in trays:
+        ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
+        spool = (
+            await db.execute(
+                select(Spool)
+                .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
+                .where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()  # one assignment per tray (UniqueConstraint)
+        if spool is None:
+            continue
+        cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_cost_per_kg
+        rates[tray] = cost_per_kg / 1000.0
+    return rates
+
+
+async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int, dict]) -> dict[int, float]:
+    """Price per gram of the Spoolman spool in each tray.
+
+    Resolves the spool the way usage reporting does (tag first, then the local
+    slot assignment, #1459) and prices it with ``_spool_cost_per_gram``, so the
+    estimate and the completion figure read the same price fields.
+    """
+    from backend.app.services.spoolman import get_spoolman_client
+    from backend.app.services.spoolman_tracking import (
+        _get_printer_serial,
+        _global_tray_id_to_ams_slot,
+        _resolve_spool_id_via_slot_assignment,
+        _resolve_spool_tag,
+        _spool_cost_per_gram,
+    )
+
+    # The client the integration already set up. No health check: that's an
+    # extra request per print, and its failure log says usage reporting is
+    # being skipped. An unreachable Spoolman fails per tray below instead.
+    client = await get_spoolman_client()
+    if client is None:
+        return {}
+    serial = await _get_printer_serial(printer_id)
+
+    rates: dict[int, float] = {}
+    for tray in trays:
+        spool = None
+        try:
+            tray_info = ams_trays.get(tray)
+            if tray_info:
+                tag = _resolve_spool_tag(tray_info, serial, tray)
+                if tag:
+                    spool = await client.find_spool_by_tag(tag)
+            if spool is None:
+                ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
+                spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
+                if spool_id is not None:
+                    spool = await client.get_spool(spool_id)
+        except Exception as exc:  # noqa: BLE001 -- one unreachable spool leaves its slot at the default rate
+            logger.debug("[COST] Tray %s: could not fetch the Spoolman spool: %s", tray, exc)
+            continue
+        rate = _spool_cost_per_gram(spool)
+        if rate is not None:
+            rates[tray] = rate
+    return rates
+
+
+def schedule_archive_cost_estimate(
+    printer_id: int,
+    archive_id: int,
+    printer_manager,
+    ams_mapping: list | None = None,
+    plate_id: int | None = None,
+    session_factory=None,
+) -> asyncio.Task | None:
+    """Run ``estimate_archive_cost_at_start`` in the background, on its own session.
+
+    Print start must not wait for it: in Spoolman mode it makes a request per
+    tray, and a slow Spoolman would hold up everything after it.
+
+    ``session_factory`` is the caller's ``async_session``, so the estimate
+    opens its session the same way the rest of the caller does.
+    """
+    from backend.app.core.tasks import spawn_background_task
+
+    if session_factory is None:
+        from backend.app.core.database import async_session as session_factory
+
+    try:
+        mapping = list(ams_mapping) if ams_mapping else None
+    except TypeError:
+        mapping = None
+
+    async def _run() -> None:
+        async with session_factory() as db:
+            await estimate_archive_cost_at_start(db, printer_id, archive_id, printer_manager, mapping, plate_id)
+
+    try:
+        return spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
+    except Exception:  # noqa: BLE001 -- an estimate must never fail a print start
+        logger.warning("[COST] Archive %s: could not schedule the cost estimate", archive_id, exc_info=True)
+        return None
+
+
+async def estimate_archive_cost_at_start(
+    db: AsyncSession,
+    printer_id: int,
+    archive_id: int,
+    printer_manager,
+    ams_mapping: list | None = None,
+    plate_id: int | None = None,
+) -> None:
+    """Replace a just-started archive's placeholder cost with a spool-based estimate."""
+    try:
+        await _estimate(db, printer_id, archive_id, printer_manager, ams_mapping, plate_id)
+    except Exception:  # noqa: BLE001 -- an estimate must never fail a print start
+        logger.warning("[COST] Archive %s: could not estimate the cost at print start", archive_id, exc_info=True)
+
+
+async def _estimate(
+    db: AsyncSession,
+    printer_id: int,
+    archive_id: int,
+    printer_manager,
+    ams_mapping: list | None,
+    plate_id: int | None,
+) -> None:
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_log import PrintLogEntry
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.services.spoolman_tracking import build_ams_tray_lookup
+    from backend.app.services.usage_tracker import _match_slots_by_color
+    from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None or not archive.file_path or not archive.filament_used_grams:
+        return
+
+    # A reprint keeps its first run's cost on the card (#1378); completion
+    # doesn't overwrite it either.
+    existing_runs = (
+        await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
+    ).scalar()
+    if existing_runs:
+        return
+
+    full_path = (
+        app_settings.base_dir / archive.file_path
+    )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+    if not full_path.exists():
+        return
+
+    queue_item = (
+        (
+            await db.execute(
+                select(PrintQueueItem)
+                .where(PrintQueueItem.archive_id == archive_id)
+                .where(PrintQueueItem.printer_id == printer_id)
+                .where(PrintQueueItem.status == "printing")
+            )
+        )
+        .scalars()
+        .first()
+    )
+    if plate_id is None and queue_item is not None:
+        plate_id = queue_item.plate_id
+
+    filament_usage = extract_filament_usage_from_3mf(full_path, plate_id) or []
+    used = [(u.get("slot_id", 0), u.get("used_g", 0) or 0) for u in filament_usage]
+    used = [(slot, grams) for slot, grams in used if grams > 0]
+    if not used:
+        return
+
+    state = printer_manager.get_status(printer_id)
+    raw_data = getattr(state, "raw_data", None) or {}
+    ams_trays = build_ams_tray_lookup(raw_data)
+
+    slot_to_tray = ams_mapping or None
+    source = "print_cmd" if slot_to_tray else None
+    if not slot_to_tray and queue_item is not None and queue_item.ams_mapping:
+        try:
+            slot_to_tray = json.loads(queue_item.ams_mapping)
+            source = "queue"
+        except json.JSONDecodeError:
+            slot_to_tray = None
+    if not slot_to_tray:
+        slot_to_tray = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+        source = "color_match" if slot_to_tray else None
+    if not slot_to_tray:
+        logger.info("[COST] Archive %s: no slot-to-tray mapping at print start, keeping the placeholder", archive_id)
+        return
+
+    slot_trays = {slot: _mapped_tray(slot, slot_to_tray, ams_trays) for slot, _ in used}
+    trays = {tray for tray in slot_trays.values() if tray is not None}
+    if not trays:
+        return
+
+    default_cost_per_kg = await _default_cost_per_kg(db)
+    spoolman_enabled = (await get_setting(db, "spoolman_enabled") or "").lower() == "true"
+    if spoolman_enabled:
+        rates = await _spoolman_rates(printer_id, trays, ams_trays)
+    else:
+        rates = await _internal_rates(db, printer_id, trays, default_cost_per_kg)
+
+    cost = 0.0
+    priced_grams = 0.0
+    for slot, grams in used:
+        rate = rates.get(slot_trays[slot])
+        if rate is None:
+            continue
+        cost += grams * rate
+        priced_grams += grams
+    if priced_grams <= 0:
+        return
+
+    # Grams no spool priced -- unmapped slots, empty trays, unpriced spools, and
+    # anything the 3MF didn't attribute to a slot -- at the default rate, in
+    # one subtraction against the archive's own total, as both completion
+    # writers do.
+    unpriced_grams = max(0.0, archive.filament_used_grams - priced_grams)
+    if unpriced_grams > 0 and default_cost_per_kg > 0:
+        cost += (unpriced_grams / 1000.0) * default_cost_per_kg
+    if cost <= 0:
+        return
+
+    # The Spoolman lookups above can take a while. If the print already ended
+    # -- a failure seconds in -- completion has written the real figure, and
+    # an estimate must not replace it.
+    await db.refresh(archive)
+    still_running_first_run = (
+        archive.status == "printing"
+        and not (
+            await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
+        ).scalar()
+    )
+    if not still_running_first_run:
+        return
+
+    new_cost = round(cost, 2)
+    if new_cost == archive.cost:
+        return
+    logger.info(
+        "[COST] Archive %s: estimated cost %s -> %s at print start (%.2fg priced from %s spools via %s mapping, "
+        "%.2fg at the default rate)",
+        archive_id,
+        archive.cost,
+        new_cost,
+        priced_grams,
+        "Spoolman" if spoolman_enabled else "inventory",
+        source,
+        unpriced_grams,
+    )
+    archive.cost = new_cost
+    await db.commit()
+
+    from backend.app.core.websocket import ws_manager
+
+    await ws_manager.send_archive_updated({"id": archive_id, "cost": new_cost})

+ 379 - 0
backend/tests/unit/services/test_archive_cost_estimate_3261.py

@@ -0,0 +1,379 @@
+"""A running print is priced from its spools at start, not only at completion (#3261).
+
+Before this, the archive card showed ``grams x default_filament_cost`` (or the
+catalogue rate) for the whole print and swapped in the spool-based figure only
+when the print finished. The estimate uses the same rules as the completion
+writers: each slot at the spool in its mapped tray, everything else at the
+default rate, and a reprint keeps its first run's cost.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.services import archive_cost_estimate
+from backend.app.services.archive_cost_estimate import estimate_archive_cost_at_start
+
+# Reporter's archive 295: 4.17 g of PLA; their default rate is 3,400/kg and the
+# linked spool cost 1,870 for 1,000 g.
+DEFAULT_PER_KG = "3400"
+
+
+def _printer_manager(trays: list[dict] | None = None, vt_tray: list[dict] | None = None):
+    raw = {
+        "ams": [
+            {"id": 0, "tray": trays if trays is not None else [{"id": 0, "tray_type": "PLA", "tray_color": "FFFFFFFF"}]}
+        ],
+        "vt_tray": vt_tray or [],
+    }
+    state = SimpleNamespace(raw_data=raw)
+    return SimpleNamespace(get_status=lambda _pid: state)
+
+
+@pytest.fixture
+def three_mf(tmp_path, monkeypatch):
+    """Point base_dir at tmp_path, drop a 3MF there, and stub its per-slot usage."""
+    monkeypatch.setattr(archive_cost_estimate.app_settings, "base_dir", tmp_path)
+    (tmp_path / "archive").mkdir()
+    (tmp_path / "archive" / "print.3mf").write_bytes(b"3mf")
+    usage: list[dict] = []
+    monkeypatch.setattr(
+        "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+        lambda _path, _plate: usage,
+    )
+    return usage
+
+
+@pytest.fixture
+def ws():
+    with patch("backend.app.core.websocket.ws_manager.send_archive_updated", new=AsyncMock()) as sent:
+        yield sent
+
+
+async def _setup(db_session, printer_factory, archive_factory, *, spoolman=False, with_run=False, grams=4.17):
+    db_session.add(Settings(key="default_filament_cost", value=DEFAULT_PER_KG))
+    if spoolman:
+        db_session.add(Settings(key="spoolman_enabled", value="true"))
+    printer = await printer_factory()
+    archive = await archive_factory(
+        printer.id,
+        print_name="Archive295",
+        status="printing",
+        cost=14.18,
+        with_run=with_run,
+        file_path="archive/print.3mf",
+        filament_used_grams=grams,
+        filament_type="PLA",
+    )
+    await db_session.commit()
+    return printer, archive
+
+
+async def _assign_spool(db_session, printer_id: int, ams_id: int, tray_id: int, cost_per_kg: float | None):
+    spool = Spool(material="PLA", cost_per_kg=cost_per_kg)
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
+    await db_session.commit()
+
+
+class TestInternalInventory:
+    @pytest.mark.asyncio
+    async def test_priced_from_the_assigned_spool(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
+        three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF"})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 7.80
+        ws.assert_awaited_once_with({"id": archive.id, "cost": 7.80})
+
+    @pytest.mark.asyncio
+    async def test_unpriced_grams_at_the_default_rate(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        """Slot 2 maps to a tray with no spool; its grams, and grams the 3MF
+        didn't attribute, are covered at the default rate like completion does."""
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=12.0)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1000)
+        three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0, 1])
+
+        await db_session.refresh(archive)
+        # 5 g at 1.0/g + 7 g at 3.4/g
+        assert archive.cost == 28.80
+
+    @pytest.mark.asyncio
+    async def test_spool_without_price_uses_the_default_rate(
+        self, db_session, printer_factory, archive_factory, three_mf, ws
+    ):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, grams=10.0)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=None)
+        three_mf.append({"slot_id": 1, "used_g": 10.0})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 34.00
+
+    @pytest.mark.asyncio
+    async def test_queue_item_mapping_is_used(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 0, 2, cost_per_kg=1870)
+        db_session.add(
+            PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", ams_mapping="[2]")
+        )
+        await db_session.commit()
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+        trays = [{"id": i, "tray_type": "PLA", "tray_color": "FFFFFFFF"} for i in range(4)]
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(trays))
+
+        await db_session.refresh(archive)
+        assert archive.cost == 7.80
+
+    @pytest.mark.asyncio
+    async def test_external_spool_mapped_as_minus_one(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 255, 0, cost_per_kg=1870)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+        pm = _printer_manager(vt_tray=[{"id": 254, "tray_type": "PLA"}])
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[-1])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 7.80
+
+
+class TestLeavesThePlaceholder:
+    @pytest.mark.asyncio
+    async def test_reprint_keeps_the_first_runs_cost(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, with_run=True)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+        ws.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_no_trustworthy_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        """No print-command or queue mapping, and the colours don't match a tray:
+        no positional guess, the placeholder stays."""
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
+        three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#123456"})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+
+    @pytest.mark.asyncio
+    async def test_colour_match_when_no_mapping(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
+        three_mf.append({"slot_id": 1, "used_g": 4.17, "color": "#FFFFFF", "type": "PLA"})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager())
+
+        await db_session.refresh(archive)
+        assert archive.cost == 7.80
+
+    @pytest.mark.asyncio
+    async def test_no_spool_in_any_mapped_tray(self, db_session, printer_factory, archive_factory, three_mf, ws):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+
+    @pytest.mark.asyncio
+    async def test_missing_3mf(self, db_session, printer_factory, archive_factory, three_mf, ws, tmp_path):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        await _assign_spool(db_session, printer.id, 0, 0, cost_per_kg=1870)
+        (tmp_path / "archive" / "print.3mf").unlink()
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+
+    @pytest.mark.asyncio
+    async def test_an_error_never_escapes(
+        self, db_session, printer_factory, archive_factory, three_mf, ws, monkeypatch
+    ):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+
+        def boom(_pid):
+            raise RuntimeError("printer state unavailable")
+
+        pm = SimpleNamespace(get_status=boom)
+
+        await estimate_archive_cost_at_start(db_session, printer.id, archive.id, pm, ams_mapping=[0])
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+
+
+class TestSpoolman:
+    @staticmethod
+    def _client(spools_by_tag: dict | None = None, spools_by_id: dict | None = None):
+        client = SimpleNamespace()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: (spools_by_tag or {}).get(tag))
+        client.get_spool = AsyncMock(side_effect=lambda sid: (spools_by_id or {})[sid])
+        return client
+
+    @pytest.mark.asyncio
+    async def test_priced_from_the_slot_assigned_spool(
+        self, db_session, printer_factory, archive_factory, three_mf, ws
+    ):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+        spool = {"id": 7, "price": 1870, "initial_weight": 1000, "filament": {"weight": 1000}}
+        client = self._client(spools_by_id={7: spool})
+
+        with (
+            patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                new=AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                new=AsyncMock(return_value=7),
+            ),
+        ):
+            await estimate_archive_cost_at_start(
+                db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
+            )
+
+        await db_session.refresh(archive)
+        assert archive.cost == 7.80
+
+    @pytest.mark.asyncio
+    async def test_spoolman_unreachable_keeps_the_placeholder(
+        self, db_session, printer_factory, archive_factory, three_mf, ws
+    ):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+
+        with patch(
+            "backend.app.services.spoolman.get_spoolman_client",
+            new=AsyncMock(return_value=None),
+        ):
+            await estimate_archive_cost_at_start(
+                db_session, printer.id, archive.id, _printer_manager(), ams_mapping=[0]
+            )
+
+        await db_session.refresh(archive)
+        assert archive.cost == 14.18
+
+    @pytest.mark.asyncio
+    async def test_a_failing_spool_fetch_leaves_only_that_slot_unpriced(
+        self, db_session, printer_factory, archive_factory, three_mf, ws
+    ):
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True, grams=10.0)
+        three_mf.extend([{"slot_id": 1, "used_g": 5.0}, {"slot_id": 2, "used_g": 5.0}])
+        client = self._client(spools_by_id={7: {"id": 7, "price": 1000, "initial_weight": 1000}})
+        trays = [{"id": i, "tray_type": "PLA"} for i in range(2)]
+
+        async def slot_assignment(_pid, _ams, tray_id):
+            return 7 if tray_id == 0 else 8  # spool 8 isn't in Spoolman: get_spool raises
+
+        with (
+            patch(
+                "backend.app.services.spoolman.get_spoolman_client",
+                new=AsyncMock(return_value=client),
+            ),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                new=slot_assignment,
+            ),
+        ):
+            await estimate_archive_cost_at_start(
+                db_session, printer.id, archive.id, _printer_manager(trays), ams_mapping=[0, 1]
+            )
+
+        await db_session.refresh(archive)
+        # 5 g at 1.0/g + 5 g at the 3.4/g default
+        assert archive.cost == 22.00
+
+
+class TestScheduling:
+    @pytest.mark.asyncio
+    async def test_runs_in_the_background_on_its_own_session(self):
+        """Print start doesn't wait for the estimate; it gets its own session and
+        a copy of the mapping, so later changes to the caller's list don't leak in."""
+        from contextlib import asynccontextmanager
+
+        sentinel_db = object()
+
+        @asynccontextmanager
+        async def fake_session():
+            yield sentinel_db
+
+        mapping = [0, 1]
+        with (
+            patch("backend.app.core.database.async_session", new=fake_session),
+            patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=AsyncMock()) as estimate,
+        ):
+            task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm", ams_mapping=mapping, plate_id=2)
+            mapping.append(9)
+            await task
+
+        estimate.assert_awaited_once_with(sentinel_db, 3, 42, "pm", [0, 1], 2)
+
+
+class TestRaceWithCompletion:
+    @pytest.mark.asyncio
+    async def test_print_that_ended_during_the_lookup_keeps_the_final_cost(
+        self, db_session, printer_factory, archive_factory, three_mf, ws
+    ):
+        """A print that fails seconds in can complete while Spoolman is still
+        answering; the figure completion wrote must survive."""
+        printer, archive = await _setup(db_session, printer_factory, archive_factory, spoolman=True)
+        three_mf.append({"slot_id": 1, "used_g": 4.17})
+        archive_id = archive.id
+
+        async def slow_spool(_sid):
+            # Completion lands while the estimate waits on Spoolman.
+            from sqlalchemy import update
+
+            from backend.app.models.archive import PrintArchive
+
+            await db_session.execute(
+                update(PrintArchive).where(PrintArchive.id == archive_id).values(status="failed", cost=1.23)
+            )
+            await db_session.commit()
+            return {"id": 7, "price": 1870, "initial_weight": 1000}
+
+        client = SimpleNamespace(find_spool_by_tag=AsyncMock(return_value=None), get_spool=slow_spool)
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", new=AsyncMock(return_value=client)),
+            patch("backend.app.services.spoolman_tracking._get_printer_serial", new=AsyncMock(return_value="")),
+            patch(
+                "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                new=AsyncMock(return_value=7),
+            ),
+        ):
+            await estimate_archive_cost_at_start(
+                db_session, printer.id, archive_id, _printer_manager(), ams_mapping=[0]
+            )
+
+        await db_session.refresh(archive)
+        assert archive.cost == 1.23
+        ws.assert_not_awaited()

+ 90 - 0
frontend/src/__tests__/pages/ArchivesCostEstimate.test.tsx

@@ -0,0 +1,90 @@
+/**
+ * A running print's cost is an estimate until completion re-prices it from
+ * the spools that actually fed it (#3261). The card says so; a finished
+ * print's cost is shown as final.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ArchivesPage } from '../../pages/ArchivesPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
+
+const baseArchive = {
+  printer_id: 1,
+  printer_name: 'H2S',
+  print_time_seconds: 3600,
+  filament_used_grams: 4.17,
+  thumbnail_path: null,
+  notes: null,
+  rating: null,
+  project_id: null,
+  project_name: null,
+  project_color: null,
+  print_count: 1,
+  tags: '',
+  has_f3d: false,
+  started_at: '2026-10-07T10:00:00Z',
+  created_at: '2026-10-07T10:00:00Z',
+  updated_at: '2026-10-07T10:00:00Z',
+};
+
+const archives = [
+  {
+    ...baseArchive,
+    id: 1,
+    filename: 'running.gcode.3mf',
+    print_name: 'Running',
+    status: 'printing',
+    completed_at: null,
+    cost: 7.8,
+  },
+  {
+    ...baseArchive,
+    id: 2,
+    filename: 'finished.gcode.3mf',
+    print_name: 'Finished',
+    status: 'completed',
+    completed_at: '2026-10-07T11:00:00Z',
+    cost: 10.94,
+  },
+];
+
+describe('ArchivesPage cost estimate (#3261)', () => {
+  beforeEach(() => {
+    setAuthToken(null);
+    server.use(
+      http.get('/api/v1/archives/', () => HttpResponse.json(archives)),
+      http.get('/api/v1/archives/stats', () =>
+        HttpResponse.json({
+          total_archives: 2,
+          total_print_time_seconds: 7200,
+          total_filament_grams: 8.34,
+          prints_this_week: 2,
+          prints_this_month: 2,
+        })
+      ),
+      http.get('/api/v1/printers/', () => HttpResponse.json([{ id: 1, name: 'H2S' }])),
+      http.get('/api/v1/projects/', () => HttpResponse.json([])),
+      http.get('/api/v1/archives/tags', () => HttpResponse.json([]))
+    );
+  });
+
+  it('marks a running print cost as an estimate and a finished one as final', async () => {
+    render(<ArchivesPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('archive-cost')).toHaveLength(2);
+    });
+    const [running, finished] = screen.getAllByTestId('archive-cost');
+
+    expect(running.textContent).toMatch(/^~.*7\.80$/);
+    expect(running).toHaveAttribute('title', 'Estimate — the final cost is set when the print finishes');
+
+    expect(finished.textContent).not.toContain('~');
+    expect(finished.textContent).toMatch(/10\.94$/);
+    expect(finished).not.toHaveAttribute('title');
+  });
+});

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} Schicht',
       layer: '{{count}} Schicht',
       layers: '{{count}} Schichten',
       layers: '{{count}} Schichten',
       wearCost: 'Druckerverschleiß',
       wearCost: 'Druckerverschleiß',
+      costEstimate: 'Schätzung – die endgültigen Kosten stehen fest, wenn der Druck abgeschlossen ist',
       object: '{{count}} Objekt',
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
       slicedFor: 'Geslict für {{model}}',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -1176,6 +1176,7 @@ export default {
       layer: '{{count}} layer',
       layer: '{{count}} layer',
       layers: '{{count}} layers',
       layers: '{{count}} layers',
       wearCost: 'Printer wear',
       wearCost: 'Printer wear',
+      costEstimate: 'Estimate — the final cost is set when the print finishes',
       object: '{{count}} object',
       object: '{{count}} object',
       objects: '{{count}} objects',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
       slicedFor: 'Sliced for {{model}}',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} capa',
       layer: '{{count}} capa',
       layers: '{{count}} capas',
       layers: '{{count}} capas',
       wearCost: 'Desgaste de la impresora',
       wearCost: 'Desgaste de la impresora',
+      costEstimate: 'Estimación: el coste final se fija cuando termina la impresión',
       object: '{{count}} objeto',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       objects: '{{count}} objetos',
       slicedFor: 'Laminado para {{model}}',
       slicedFor: 'Laminado para {{model}}',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} couche',
       layer: '{{count}} couche',
       layers: '{{count}} couches',
       layers: '{{count}} couches',
       wearCost: 'Usure de l\'imprimante',
       wearCost: 'Usure de l\'imprimante',
+      costEstimate: 'Estimation : le coût final est fixé à la fin de l\'impression',
       object: '{{count}} objet',
       object: '{{count}} objet',
       objects: '{{count}} objets',
       objects: '{{count}} objets',
       slicedFor: 'Découpé pour {{model}}',
       slicedFor: 'Découpé pour {{model}}',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} strato',
       layer: '{{count}} strato',
       layers: '{{count}} strati',
       layers: '{{count}} strati',
       wearCost: 'Usura della stampante',
       wearCost: 'Usura della stampante',
+      costEstimate: 'Stima: il costo finale viene fissato al termine della stampa',
       object: '{{count}} oggetto',
       object: '{{count}} oggetto',
       objects: '{{count}} oggetti',
       objects: '{{count}} oggetti',
       slicedFor: 'Sliced per {{model}}',
       slicedFor: 'Sliced per {{model}}',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -1168,6 +1168,7 @@ export default {
       layer: '{{count}} レイヤー',
       layer: '{{count}} レイヤー',
       layers: '{{count}} レイヤー',
       layers: '{{count}} レイヤー',
       wearCost: 'プリンターの摩耗',
       wearCost: 'プリンターの摩耗',
+      costEstimate: '見積もり — 最終コストは印刷完了時に確定します',
       object: '{{count}}オブジェクト',
       object: '{{count}}オブジェクト',
       objects: '{{count}}オブジェクト',
       objects: '{{count}}オブジェクト',
       slicedFor: '{{model}}用にスライス',
       slicedFor: '{{model}}用にスライス',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -1123,6 +1123,7 @@ export default {
       layer: '{{count}}층',
       layer: '{{count}}층',
       layers: '{{count}}층',
       layers: '{{count}}층',
       wearCost: '프린터 마모',
       wearCost: '프린터 마모',
+      costEstimate: '예상치 — 최종 비용은 출력이 끝나면 확정됩니다',
       object: '{{count}}개 객체',
       object: '{{count}}개 객체',
       objects: '{{count}}개 객체',
       objects: '{{count}}개 객체',
       slicedFor: '{{model}}용으로 슬라이싱됨',
       slicedFor: '{{model}}용으로 슬라이싱됨',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -1174,6 +1174,7 @@ export default {
       layer: '{{count}} laag',
       layer: '{{count}} laag',
       layers: '{{count}} lagen',
       layers: '{{count}} lagen',
       wearCost: 'Printerslijtage',
       wearCost: 'Printerslijtage',
+      costEstimate: 'Schatting — de definitieve kosten worden vastgesteld als de print klaar is',
       object: '{{count}} object',
       object: '{{count}} object',
       objects: '{{count}} objecten',
       objects: '{{count}} objecten',
       slicedFor: 'Gesliced voor {{model}}',
       slicedFor: 'Gesliced voor {{model}}',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} camada',
       layer: '{{count}} camada',
       layers: '{{count}} camadas',
       layers: '{{count}} camadas',
       wearCost: 'Desgaste da impressora',
       wearCost: 'Desgaste da impressora',
+      costEstimate: 'Estimativa — o custo final é definido quando a impressão termina',
       object: '{{count}} objeto',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       objects: '{{count}} objetos',
       slicedFor: 'Fatiado para {{model}}',
       slicedFor: 'Fatiado para {{model}}',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -1124,6 +1124,7 @@ export default {
       layer: "{{count}} слой",
       layer: "{{count}} слой",
       layers: "{{count}} слоёв",
       layers: "{{count}} слоёв",
       wearCost: 'Износ принтера',
       wearCost: 'Износ принтера',
+      costEstimate: 'Оценка — итоговая стоимость определяется по завершении печати',
       object: "{{count}} объект",
       object: "{{count}} объект",
       objects: "{{count}} объектов",
       objects: "{{count}} объектов",
       slicedFor: "Нарезано для {{model}}",
       slicedFor: "Нарезано для {{model}}",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -1174,6 +1174,7 @@ export default {
       layer: '{{count}} skikt',
       layer: '{{count}} skikt',
       layers: '{{count}} skikt',
       layers: '{{count}} skikt',
       wearCost: 'Skrivarslitage',
       wearCost: 'Skrivarslitage',
+      costEstimate: 'Uppskattning – den slutliga kostnaden fastställs när utskriften är klar',
       object: '{{count}} objekt',
       object: '{{count}} objekt',
       objects: '{{count}} objekt',
       objects: '{{count}} objekt',
       slicedFor: 'Slicad för {{model}}',
       slicedFor: 'Slicad för {{model}}',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} katman',
       layer: '{{count}} katman',
       layers: '{{count}} katman',
       layers: '{{count}} katman',
       wearCost: 'Yazıcı aşınması',
       wearCost: 'Yazıcı aşınması',
+      costEstimate: 'Tahmin — nihai maliyet baskı bittiğinde belirlenir',
       object: '{{count}} nesne',
       object: '{{count}} nesne',
       objects: '{{count}} nesne',
       objects: '{{count}} nesne',
       slicedFor: '{{model}} için dilimlendi',
       slicedFor: '{{model}} için dilimlendi',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -1173,6 +1173,7 @@ export default {
       layer: "{{count}} шар",
       layer: "{{count}} шар",
       layers: "Шарів: {{count}}",
       layers: "Шарів: {{count}}",
       wearCost: 'Знос принтера',
       wearCost: 'Знос принтера',
+      costEstimate: 'Оцінка — остаточна вартість визначається після завершення друку',
       object: "{{count}} об’єкт",
       object: "{{count}} об’єкт",
       objects: "Об’єктів: {{count}}",
       objects: "Об’єктів: {{count}}",
       slicedFor: "Нарізаний для {{model}}",
       slicedFor: "Нарізаний для {{model}}",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} 层',
       layer: '{{count}} 层',
       layers: '{{count}} 层',
       layers: '{{count}} 层',
       wearCost: '打印机磨损',
       wearCost: '打印机磨损',
+      costEstimate: '估算值——最终成本在打印完成时确定',
       object: '{{count}} 个对象',
       object: '{{count}} 个对象',
       objects: '{{count}} 个对象',
       objects: '{{count}} 个对象',
       slicedFor: '为 {{model}} 切片',
       slicedFor: '为 {{model}} 切片',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1169,6 +1169,7 @@ export default {
       layer: '{{count}} 層',
       layer: '{{count}} 層',
       layers: '{{count}} 層',
       layers: '{{count}} 層',
       wearCost: '印表機磨損',
       wearCost: '印表機磨損',
+      costEstimate: '估算值——最終成本在列印完成時確定',
       object: '{{count}} 個物件',
       object: '{{count}} 個物件',
       objects: '{{count}} 個物件',
       objects: '{{count}} 個物件',
       slicedFor: '為 {{model}} 切片',
       slicedFor: '為 {{model}} 切片',

+ 7 - 1
frontend/src/pages/ArchivesPage.tsx

@@ -1281,8 +1281,14 @@ function ArchiveCard({
           {(archive.cost != null || archive.energy_cost != null || archive.wear_cost != null) && (
           {(archive.cost != null || archive.energy_cost != null || archive.wear_cost != null) && (
             <div className="flex items-center gap-3 text-bambu-gray">
             <div className="flex items-center gap-3 text-bambu-gray">
               {archive.cost != null && (
               {archive.cost != null && (
-                <div className="flex items-center gap-1.5">
+                // A running print's cost is an estimate until completion re-prices it (#3261)
+                <div
+                  className="flex items-center gap-1.5"
+                  title={archive.status === 'printing' ? t('archives.card.costEstimate') : undefined}
+                  data-testid="archive-cost"
+                >
                   <Coins className="w-3 h-3" />
                   <Coins className="w-3 h-3" />
+                  {archive.status === 'printing' && '~'}
                   {currency}{archive.cost.toFixed(2)}
                   {currency}{archive.cost.toFixed(2)}
                 </div>
                 </div>
               )}
               )}

Plik diff jest za duży
+ 0 - 0
static/assets/AdditionalSection-B9iB6xW2.js


Plik diff jest za duży
+ 0 - 0
static/assets/AmsUnitCard-6tucYF7U.js


Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-BSb_MB9S.js


Plik diff jest za duży
+ 0 - 0
static/assets/AssignToAmsModal-DKCru_ir.js


Plik diff jest za duży
+ 0 - 0
static/assets/CamWallPage-B4heNGsV.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraDiagnoseModal-DaFY1v8I.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage--Jmbrerh.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraWall-yInrBATM.js


Plik diff jest za duży
+ 0 - 0
static/assets/ColumnConfigModal-BPctn5Lk.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConfigureAmsSlotModal-uPwVXZ4s.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConnectAuthorizePage-Chyu6mpt.js


Plik diff jest za duży
+ 0 - 0
static/assets/ContextMenu-B4uGJkKW.js


Plik diff jest za duży
+ 0 - 0
static/assets/ExternalLinkPage-BhKNB8Xn.js


Plik diff jest za duży
+ 0 - 1
static/assets/FileManagerPage-4cVsnIkI.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileUploadModal-GYUY0fS6.js


Plik diff jest za duży
+ 0 - 0
static/assets/FinancePage-COWXFGjj.js


Plik diff jest za duży
+ 0 - 0
static/assets/GCodeViewerPage-BhjPYegv.js


Plik diff jest za duży
+ 0 - 0
static/assets/GroupEditPage-D6XMJTTK.js


Plik diff jest za duży
+ 0 - 0
static/assets/ImagePreviewModal-CTLU6Jnd.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-CFjZN794.js


Plik diff jest za duży
+ 0 - 0
static/assets/LibraryTrashPage-C41Ue4qA.js


Plik diff jest za duży
+ 0 - 0
static/assets/LoginPage-YsL4u_Va.js


Plik diff jest za duży
+ 0 - 0
static/assets/MaintenancePage-BewPZHCP.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-8I2_SqrV.js


Plik diff jest za duży
+ 0 - 0
static/assets/NotificationsPage-Wf6csR7A.js


Plik diff jest za duży
+ 0 - 1
static/assets/PdfPreviewModal-HVSkaROI.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-N1J3Wu90.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrinterLocationsPage-BIW5YJnm.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-CLoeMCjh.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProfilesPage-CN3SHdd_.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectDetailPage-BpwdspD8.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectsPage-BvSVSfNa.js


Plik diff jest za duży
+ 0 - 0
static/assets/QueuePage-DVAUmwSb.js


Plik diff jest za duży
+ 0 - 0
static/assets/RichTextEditor-DoPGmmPm.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-CEuITKXc.js


Plik diff jest za duży
+ 0 - 0
static/assets/SetupPage-MuOSaMDM.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-DYhyNfSM.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyAmsPage-DjBGn9co.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-1VQoTPzX.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyDashboard-CpDVcJoI.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-B8Mx48p2.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DJ5YGyN7.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-D3rzn4tk.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpreadsheetPreviewModal-sKEz1Isz.js


Plik diff jest za duży
+ 0 - 0
static/assets/StatsPage-BdTapXHV.js


Plik diff jest za duży
+ 0 - 0
static/assets/StreamOverlayPage-Cj5x5-Pf.js


Plik diff jest za duży
+ 0 - 0
static/assets/SystemInfoPage-DWpWi9jq.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-RHfkhf78.js


Plik diff jest za duży
+ 0 - 0
static/assets/de-SDAs-nZl.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-2wkz10gb.js


Plik diff jest za duży
+ 0 - 0
static/assets/es-5tXCFuNX.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-DF4R3Wbb.js


Plik diff jest za duży
+ 0 - 0
static/assets/fr-DvO8gKKR.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-Bw-_P2Fg.js


+ 1 - 1
static/assets/inventorySearch-X-uWpJqR.js → static/assets/inventorySearch-C82dQp2B.js

@@ -1 +1 @@
-import{ft as e}from"./index-CxFwVtB_.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-Bw-_P2Fg.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Plik diff jest za duży
+ 0 - 0
static/assets/it-5JPxr7nq.js


Plik diff jest za duży
+ 0 - 0
static/assets/it-Dd9dKQH1.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-DQTngE8Y.js


Plik diff jest za duży
+ 0 - 0
static/assets/ja-DkzMOqxg.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-C2MmhZAd.js


Plik diff jest za duży
+ 0 - 0
static/assets/ko-CtMumSND.js


Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-BtezRgcN.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-Btq4jfMj.js


Plik diff jest za duży
+ 0 - 0
static/assets/nl-DJUe00ao.js


Plik diff jest za duży
+ 0 - 0
static/assets/pdf-h6Jgng8Y.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-DOdeZdt6.js


Plik diff jest za duży
+ 0 - 0
static/assets/pt-BR-u7vOhYKQ.js


Plik diff jest za duży
+ 0 - 0
static/assets/ru-BSXECPwi.js


Plik diff jest za duży
+ 0 - 0
static/assets/ru-DBcLI5Nh.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-B3RkVm_Y.js


Plik diff jest za duży
+ 0 - 0
static/assets/sv-Ba7un84l.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-CNC804MB.js


Plik diff jest za duży
+ 0 - 0
static/assets/tr-DyGF5y9d.js


Plik diff jest za duży
+ 0 - 0
static/assets/uk-DOw6djx7.js


Plik diff jest za duży
+ 0 - 0
static/assets/uk-w6_n9GAL.js


Plik diff jest za duży
+ 0 - 0
static/assets/utils-D29gNIio.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-2TvSngBr.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-CN-BCrLNhW2.js


Plik diff jest za duży
+ 0 - 0
static/assets/zh-TW-CXHBn8MJ.js


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików