Browse Source

Add a wear cost per printing hour to printers (#694)

Print costs covered filament and energy but not the printer. Each printer
can now carry an optional wear cost per printing hour. Every run's wear is
its logged duration at that rate, stored on the print log entry; the
archive keeps the first run's wear, like energy (#1378). Failed and
cancelled runs count, reconciled runs (unknown duration) don't, and a rate
applies to new prints only.

Wear is shown on the archive card, as a hidden sortable Print Log column,
as a Quick Stats tile, in the most expensive record, in project totals and
in batch order cost, and is kept by GitHub backup and restore. Billing
still charges filament only.

New nullable columns: printers.wear_cost_per_hour, print_log_entries.wear_cost
and print_archives.wear_cost.
maziggy 2 days ago
parent
commit
6c8a821bca
48 changed files with 633 additions and 16 deletions
  1. 5 0
      CHANGELOG.md
  2. 7 0
      backend/app/api/routes/archives.py
  3. 1 0
      backend/app/api/routes/print_log.py
  4. 11 1
      backend/app/api/routes/projects.py
  5. 8 0
      backend/app/core/database.py
  6. 7 2
      backend/app/main.py
  7. 2 0
      backend/app/models/archive.py
  8. 2 0
      backend/app/models/print_log.py
  9. 3 0
      backend/app/models/printer.py
  10. 4 0
      backend/app/schemas/archive.py
  11. 1 0
      backend/app/schemas/print_log.py
  12. 4 0
      backend/app/schemas/printer.py
  13. 1 0
      backend/app/schemas/project.py
  14. 2 0
      backend/app/services/export.py
  15. 1 0
      backend/app/services/github_backup.py
  16. 1 0
      backend/app/services/github_restore.py
  17. 5 1
      backend/app/services/print_batch.py
  18. 30 0
      backend/app/services/print_log.py
  19. 230 0
      backend/tests/integration/test_wear_cost_694.py
  20. 103 0
      frontend/src/__tests__/components/EditPrinterWearCost.test.tsx
  21. 15 0
      frontend/src/__tests__/pages/ArchivesPagePrintLogColumns.test.tsx
  22. 21 0
      frontend/src/__tests__/pages/StatsPage.test.tsx
  23. 7 0
      frontend/src/api/client.ts
  24. 6 0
      frontend/src/i18n/locales/de.ts
  25. 6 0
      frontend/src/i18n/locales/en.ts
  26. 6 0
      frontend/src/i18n/locales/es.ts
  27. 6 0
      frontend/src/i18n/locales/fr.ts
  28. 6 0
      frontend/src/i18n/locales/it.ts
  29. 6 0
      frontend/src/i18n/locales/ja.ts
  30. 6 0
      frontend/src/i18n/locales/ko.ts
  31. 6 0
      frontend/src/i18n/locales/nl.ts
  32. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  33. 6 0
      frontend/src/i18n/locales/ru.ts
  34. 6 0
      frontend/src/i18n/locales/sv.ts
  35. 6 0
      frontend/src/i18n/locales/tr.ts
  36. 6 0
      frontend/src/i18n/locales/uk.ts
  37. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  38. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  39. 18 3
      frontend/src/pages/ArchivesPage.tsx
  40. 23 0
      frontend/src/pages/PrintersPage.tsx
  41. 12 3
      frontend/src/pages/ProjectDetailPage.tsx
  42. 17 3
      frontend/src/pages/StatsPage.tsx
  43. 0 0
      static/assets/ImagePreviewModal-UBPjJ8pq.js
  44. 0 1
      static/assets/PdfPreviewModal-B_evlKk5.js
  45. 0 0
      static/assets/SpreadsheetPreviewModal-DNZrxR7Y.js
  46. 1 1
      static/assets/index-DntsHhWu.js
  47. 0 0
      static/assets/pdf-IiFYiX_u.js
  48. 1 1
      static/index.html

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Printers can have a wear cost per printing hour, added to what each print costs (#694, requested by @wreuel)** — Print costs covered filament and energy, but not the printer itself: nozzles, belts, the hotend and eventually the machine. Print farms and anyone selling prints usually add an hourly amount for that.
+  - **Setting it:** **Edit printer** has a new optional field, **Wear cost per printing hour**, for example 0.20 for a 600 printer you expect to last 3,000 hours. Empty or 0 leaves it off, so nothing changes until you set one.
+  - **How it's costed:** each print's duration as shown in the Print Log, times the printer's rate. Failed and cancelled prints count, because they wore the printer too. A print closed after Bambuddy was offline has no known duration and gets none. The rate applies from the next print on and never rewrites earlier ones.
+  - **Where it shows:** a third amount with a wrench icon on the archive card (the first print, like energy), a **Wear Cost** column in the Print Log (hidden by default, sortable), a **Wear Cost** tile in Quick Stats once there is any, the **Most Expensive** record, project totals and budgets, and the measured cost of batch orders.
+  - **Billing is unchanged:** charges stay filament only.
 - **The Slice dialog offers OrcaSlicer's built-in third-party filaments, and every other filament the slicer ships for Bambu printers (#3234, requested by @nvx)** — The **Standard** tier listed only part of the slicer's own presets. Two gaps, both in the slicer sidecar, which needs the updated image:
   - **OrcaSlicer's filament library was never read.** OrcaSlicer ships filaments that suit any printer (Elegoo, PolyLite, COEX and more, named `... @System`) in a folder next to the Bambu one. With the OrcaSlicer sidecar, the 264 of them that a Bambu printer can use are now listed. As in OrcaSlicer, each is offered on every Bambu printer except those with a filament made for that printer, so `Generic PLA @System` doesn't show on an X1 Carbon, which has `Generic PLA @BBL X1C`.
   - **Filaments in brand folders were missing.** Both slicers keep many Bambu-printer filaments in subfolders (`COEX PLA @BBL X1C`, `SUNLU PLA Matte @BBL X1C`, ...). The sidecar read only the top folder: OrcaSlicer 2.4.2 now lists 1997 Bambu filaments instead of 1156, Bambu Studio 02.08.02.61 1928 instead of 1792.

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

@@ -391,6 +391,7 @@ def archive_to_response(
         "quantity": archive.quantity,
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
+        "wear_cost": archive.wear_cost,
         "created_at": archive.created_at,
         # User tracking (Issue #206)
         "created_by_id": archive.created_by_id,
@@ -710,6 +711,7 @@ async def list_archives_slim(
             PrintLogEntry.cost,
             PrintLogEntry.energy_kwh,
             PrintLogEntry.energy_cost,
+            PrintLogEntry.wear_cost,
             PrintLogEntry.created_at,
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -754,6 +756,7 @@ async def list_archives_slim(
             "cost": r.cost,
             "energy_kwh": r.energy_kwh,
             "energy_cost": r.energy_cost,
+            "wear_cost": r.wear_cost,
             "quantity": 1,
             "created_at": r.created_at,
         }
@@ -1260,6 +1263,9 @@ async def get_archive_stats(
     cost_result = await db.execute(select(func.sum(PrintLogEntry.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
 
+    wear_result = await db.execute(select(func.sum(PrintLogEntry.wear_cost)).where(*base_conditions))
+    total_wear_cost = wear_result.scalar() or 0
+
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
         select(PrintLogEntry.filament_type).where(PrintLogEntry.filament_type.isnot(None), *base_conditions)
@@ -1414,6 +1420,7 @@ async def get_archive_stats(
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
         total_energy_kwh=round(total_energy_kwh, 3),
         total_energy_cost=round(total_energy_cost, 3),
+        total_wear_cost=round(total_wear_cost, 2),
         energy_data_warming_up=energy_data_warming_up,
     )
 

+ 1 - 0
backend/app/api/routes/print_log.py

@@ -47,6 +47,7 @@ _SORTABLE_COLUMNS = {
     "cost": PrintLogEntry.cost,
     "energy": PrintLogEntry.energy_kwh,
     "energy_cost": PrintLogEntry.energy_cost,
+    "wear_cost": PrintLogEntry.wear_cost,
 }
 
 

+ 11 - 1
backend/app/api/routes/projects.py

@@ -95,6 +95,7 @@ class _ProjectTotals:
     filament_cost: float = 0.0
     energy_kwh: float = 0.0
     energy_cost: float = 0.0
+    wear_cost: float = 0.0
     queued_prints: int = 0
     in_progress_prints: int = 0
     bom_total_items: int = 0
@@ -146,6 +147,7 @@ async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int
             func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
+            func.coalesce(func.sum(PrintLogEntry.wear_cost), 0).label("total_wear_cost"),
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
             # A completed run the user marked as reject (#1898) produced no
             # usable parts — keep it out of the good-parts count.
@@ -172,6 +174,7 @@ async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int
         entry.filament_cost = float(row.total_filament_cost or 0)
         entry.energy_kwh = float(row.total_energy or 0)
         entry.energy_cost = float(row.total_energy_cost or 0)
+        entry.wear_cost = float(row.total_wear_cost or 0)
         entry.total_items = int(row.total_items or 0)
         entry.completed_items = int(row.completed_items or 0)
         entry.failed_runs = int(row.failed_runs or 0)
@@ -243,6 +246,7 @@ def _stats_from_totals(
         estimated_cost=round(totals.filament_cost, 2),
         total_energy_kwh=round(totals.energy_kwh, 3),
         total_energy_cost=round(totals.energy_cost, 3),
+        total_wear_cost=round(totals.wear_cost, 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
         bom_total_items=totals.bom_total_items,
@@ -385,7 +389,13 @@ async def compute_subtree_stats(db: AsyncSession, root_id: int) -> _SubtreeRepor
                 completed_prints=child_stats.completed_prints,
                 total_print_time_hours=child_stats.total_print_time_hours,
                 total_filament_grams=child_stats.total_filament_grams,
-                total_cost=round(child_stats.estimated_cost + child_stats.total_energy_cost + child_stats.bom_cost, 2),
+                total_cost=round(
+                    child_stats.estimated_cost
+                    + child_stats.total_energy_cost
+                    + child_stats.total_wear_cost
+                    + child_stats.bom_cost,
+                    2,
+                ),
             )
         )
 

+ 8 - 0
backend/app/core/database.py

@@ -5302,6 +5302,14 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN shared BOOLEAN DEFAULT FALSE")
     await _backfill_library_folder_owners(conn)
 
+    # Migration: printer wear cost per printing hour, and the wear cost of each
+    # run and archive (#694). Nullable: no printer has a rate until one is set,
+    # and earlier prints keep no wear cost.
+    float_type = "REAL" if is_sqlite() else "DOUBLE PRECISION"
+    await _safe_execute(conn, f"ALTER TABLE printers ADD COLUMN wear_cost_per_hour {float_type}")
+    await _safe_execute(conn, f"ALTER TABLE print_log_entries ADD COLUMN wear_cost {float_type}")
+    await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN wear_cost {float_type}")
+
 
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.

+ 7 - 2
backend/app/main.py

@@ -7918,7 +7918,7 @@ async def on_print_complete(printer_id: int, data: dict):
     try:
         async with async_session() as db:
             from backend.app.models.archive import PrintArchive
-            from backend.app.services.print_log import write_log_entry
+            from backend.app.services.print_log import record_archive_wear, write_log_entry
 
             archive = await db.get(PrintArchive, archive_id)
             if archive:
@@ -7967,7 +7967,10 @@ async def on_print_complete(printer_id: int, data: dict):
                 if _run_cost is None and _run_status == "completed":
                     _run_cost = _est_cost
 
-                await write_log_entry(
+                from backend.app.models.printer import Printer as _Printer
+
+                _wear_rate = await db.scalar(select(_Printer.wear_cost_per_hour).where(_Printer.id == printer_id))
+                run_entry = await write_log_entry(
                     db,
                     archive_id=archive.id,
                     # Captured by _update_queue_status above; None for
@@ -7984,6 +7987,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     filament_color=archive.filament_color,
                     filament_used_grams=_run_grams,
                     cost=_run_cost,
+                    wear_cost_per_hour=_wear_rate,
                     failure_reason=archive.failure_reason,
                     thumbnail_path=archive.thumbnail_path,
                     created_by_id=archive.created_by_id,
@@ -7992,6 +7996,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     # log 0 duration instead of the whole disconnect gap (#2592).
                     reconciled=bool(data.get("_reconciled")),
                 )
+                await record_archive_wear(db, archive, run_entry)
                 await db.commit()
                 logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
     except Exception as e:

+ 2 - 0
backend/app/models/archive.py

@@ -144,6 +144,8 @@ class PrintArchive(Base):
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
+    # Printer wear for the first run, at the printer's hourly wear cost (#694).
+    wear_cost: Mapped[float | None] = mapped_column(Float)
     # Plug lifetime counter captured at print start; delta at print end becomes energy_kwh.
     # Persisted so per-print tracking survives backend restarts mid-print (#941).
     energy_start_kwh: Mapped[float | None] = mapped_column(Float)

+ 2 - 0
backend/app/models/print_log.py

@@ -44,6 +44,8 @@ class PrintLogEntry(Base):
     cost: Mapped[float | None] = mapped_column(Float)
     energy_kwh: Mapped[float | None] = mapped_column(Float)
     energy_cost: Mapped[float | None] = mapped_column(Float)
+    # Printer wear for this run: duration at the printer's hourly wear cost (#694).
+    wear_cost: Mapped[float | None] = mapped_column(Float)
     failure_reason: Mapped[str | None] = mapped_column(String(100))
     # User quality verdict ('good' / 'reject'), mirrored from the archive on
     # PATCH exactly like status/failure_reason (#1444 mirror) so verdict-aware

+ 3 - 0
backend/app/models/printer.py

@@ -20,6 +20,9 @@ class Printer(Base):
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
     print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0)  # Baseline hours to add
+    # What an hour of printing costs in wear on this machine (#694). Empty or 0
+    # leaves wear cost off for the printer.
+    wear_cost_per_hour: Mapped[float | None] = mapped_column(Float, nullable=True)
     runtime_seconds: Mapped[int] = mapped_column(
         default=0
     )  # Accumulated active runtime (RUNNING state only — see #1521)

+ 4 - 0
backend/app/schemas/archive.py

@@ -126,6 +126,7 @@ class ArchiveResponse(BaseModel):
     # Energy tracking
     energy_kwh: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # Printer wear, #694
 
     created_at: datetime | None
 
@@ -171,6 +172,7 @@ class ArchiveSlim(BaseModel):
     cost: float | None
     energy_kwh: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # Printer wear, #694
     quantity: int = 1
     created_at: datetime | None
 
@@ -202,6 +204,8 @@ class ArchiveStats(BaseModel):
     # Energy stats
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
+    # Printer wear across the runs counted above (#694)
+    total_wear_cost: float = 0.0
     # Set when the date-range query in "total consumption" mode is running on
     # incomplete snapshot history — e.g. right after a fresh upgrade before the
     # hourly snapshot loop has built up a baseline. Frontend shows a tooltip.

+ 1 - 0
backend/app/schemas/print_log.py

@@ -27,6 +27,7 @@ class PrintLogEntrySchema(BaseModel):
     cost: float | None = None
     energy_kwh: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # #694
     failure_reason: str | None = None
     user_verdict: str | None = None  # Post-print quality verdict (#1898)
     thumbnail_path: str | None = None

+ 4 - 0
backend/app/schemas/printer.py

@@ -77,6 +77,8 @@ class PrinterUpdate(BaseModel):
     is_active: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | None = None
+    # Wear cost per printing hour (#694); null or 0 turns it off.
+    wear_cost_per_hour: float | None = Field(default=None, ge=0, le=100000)
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
@@ -97,6 +99,7 @@ class PrinterResponse(PrinterBase):
     # printer_models.supports_nozzle_flow_type.
     supports_nozzle_flow_type: bool = True
     print_hours_offset: float = 0.0
+    wear_cost_per_hour: float | None = None  # #694
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
@@ -132,6 +135,7 @@ class PrinterResponse(PrinterBase):
             "nozzle_count": printer.nozzle_count,
             "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
+            "wear_cost_per_hour": printer.wear_cost_per_hour,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "created_at": printer.created_at,
             "updated_at": printer.updated_at,

+ 1 - 0
backend/app/schemas/project.py

@@ -82,6 +82,7 @@ class ProjectStats(BaseModel):
     estimated_cost: float = 0.0  # Based on filament cost
     total_energy_kwh: float = 0.0
     total_energy_cost: float = 0.0
+    total_wear_cost: float = 0.0  # Printer wear (#694)
     remaining_prints: int | None = None  # target_count - total_archives
     remaining_parts: int | None = None  # target_parts_count - completed_prints
     # BOM stats (Phase 7)

+ 2 - 0
backend/app/services/export.py

@@ -59,6 +59,8 @@ class ExportService:
         "nozzle_temperature": "Nozzle Temp (°C)",
         "total_layers": "Total Layers",
         "cost": "Cost",
+        "energy_cost": "Energy Cost",
+        "wear_cost": "Wear Cost",
         "designer": "Designer",
         "tags": "Tags",
         "notes": "Notes",

+ 1 - 0
backend/app/services/github_backup.py

@@ -880,6 +880,7 @@ class GitHubBackupService:
                 "quantity": a.quantity,
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
+                "wear_cost": a.wear_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
                 # Soft-deleted archives are collected too — their row is kept on
                 # purpose so the stats endpoint keeps counting their filament and

+ 1 - 0
backend/app/services/github_restore.py

@@ -1079,6 +1079,7 @@ class GitHubRestoreService:
                 "quantity": entry.get("quantity") or 1,
                 "energy_kwh": entry.get("energy_kwh"),
                 "energy_cost": entry.get("energy_cost"),
+                "wear_cost": entry.get("wear_cost"),
             }
 
             printer_id = entry.get("printer_id")

+ 5 - 1
backend/app/services/print_batch.py

@@ -268,7 +268,11 @@ async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
         await db.execute(
             select(
                 PrintQueueItem.plate_id,
-                func.sum(func.coalesce(PrintLogEntry.cost, 0.0) + func.coalesce(PrintLogEntry.energy_cost, 0.0)),
+                func.sum(
+                    func.coalesce(PrintLogEntry.cost, 0.0)
+                    + func.coalesce(PrintLogEntry.energy_cost, 0.0)
+                    + func.coalesce(PrintLogEntry.wear_cost, 0.0)
+                ),
                 func.sum(PrintLogEntry.filament_used_grams),
             )
             .select_from(PrintLogEntry)

+ 30 - 0
backend/app/services/print_log.py

@@ -6,6 +6,7 @@ Log entries are written to a separate table and never touch archives or queue it
 import logging
 from datetime import datetime
 
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.print_log import PrintLogEntry
@@ -13,6 +14,19 @@ from backend.app.models.print_log import PrintLogEntry
 logger = logging.getLogger(__name__)
 
 
+def wear_cost_for_run(duration_seconds: int | None, wear_cost_per_hour: float | None) -> float | None:
+    """Printer wear for one run: its duration at the printer's hourly rate (#694).
+
+    None when the printer has no rate or the run has no measured duration (a
+    reconciled run stores 0 because its real end time is unknown).
+    """
+    if not isinstance(wear_cost_per_hour, int | float) or not isinstance(duration_seconds, int):
+        return None
+    if wear_cost_per_hour <= 0 or duration_seconds <= 0:
+        return None
+    return round(duration_seconds / 3600 * wear_cost_per_hour, 3)
+
+
 async def write_log_entry(
     db: AsyncSession,
     *,
@@ -30,6 +44,7 @@ async def write_log_entry(
     cost: float | None = None,
     energy_kwh: float | None = None,
     energy_cost: float | None = None,
+    wear_cost_per_hour: float | None = None,
     failure_reason: str | None = None,
     thumbnail_path: str | None = None,
     created_by_id: int | None = None,
@@ -71,6 +86,7 @@ async def write_log_entry(
         cost=cost,
         energy_kwh=energy_kwh,
         energy_cost=energy_cost,
+        wear_cost=wear_cost_for_run(duration, wear_cost_per_hour),
         failure_reason=failure_reason,
         thumbnail_path=thumbnail_path,
         created_by_id=created_by_id,
@@ -79,3 +95,17 @@ async def write_log_entry(
     db.add(entry)
     await db.flush()
     return entry
+
+
+async def record_archive_wear(db: AsyncSession, archive, entry: PrintLogEntry) -> None:
+    """Copy a run's wear cost onto its archive when it is the archive's first run.
+
+    Same rule as energy (#1378): the archive shows its first print, and a
+    reprint's wear stays on its own log entry (#694). ``entry`` must already be
+    flushed, so it counts as one of the archive's runs.
+    """
+    if entry.wear_cost is None:
+        return
+    runs = await db.scalar(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
+    if (runs or 0) <= 1:
+        archive.wear_cost = entry.wear_cost

+ 230 - 0
backend/tests/integration/test_wear_cost_694.py

@@ -0,0 +1,230 @@
+"""Printer wear cost per printing hour (#694).
+
+Each printer can carry an optional wear cost per printing hour. Every run's
+wear is its logged duration at that rate, written to the print log entry; the
+archive keeps the first run's wear, like energy (#1378). Totals that add up
+filament and energy cost add wear too, and billing keeps charging filament only.
+"""
+
+from datetime import datetime, timedelta
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.print_log import record_archive_wear, wear_cost_for_run, write_log_entry
+
+START = datetime(2026, 10, 1, 8, 0, 0)
+
+
+class TestWearCostForRun:
+    def test_duration_at_the_hourly_rate(self):
+        assert wear_cost_for_run(5400, 0.2) == 0.3
+
+    @pytest.mark.parametrize("rate", [None, 0, 0.0, -1.0])
+    def test_no_rate_means_no_wear(self, rate):
+        assert wear_cost_for_run(3600, rate) is None
+
+    @pytest.mark.parametrize("duration", [None, 0, -60])
+    def test_no_measured_duration_means_no_wear(self, duration):
+        # A reconciled run logs 0 seconds: its real end time is unknown.
+        assert wear_cost_for_run(duration, 0.2) is None
+
+    def test_a_value_that_is_not_a_number_is_ignored(self):
+        assert wear_cost_for_run(3600, "0.2") is None
+
+
+async def _archive(db_session, **kwargs) -> PrintArchive:
+    archive = PrintArchive(
+        filename="cube.3mf", file_path="test/cube.3mf", file_size=1000, print_name="Cube", status="completed", **kwargs
+    )
+    db_session.add(archive)
+    await db_session.commit()
+    await db_session.refresh(archive)
+    return archive
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestRecordingWear:
+    async def test_log_entry_gets_the_runs_wear(self, db_session):
+        entry = await write_log_entry(
+            db_session,
+            status="completed",
+            started_at=START,
+            completed_at=START + timedelta(hours=2),
+            wear_cost_per_hour=0.25,
+        )
+        assert entry.wear_cost == 0.5
+
+    async def test_reconciled_run_has_no_wear(self, db_session):
+        entry = await write_log_entry(
+            db_session,
+            status="aborted",
+            started_at=START,
+            completed_at=START + timedelta(days=2),
+            wear_cost_per_hour=0.25,
+            reconciled=True,
+        )
+        assert entry.wear_cost is None
+
+    async def test_a_failed_run_still_wears_the_printer(self, db_session):
+        entry = await write_log_entry(
+            db_session,
+            status="failed",
+            started_at=START,
+            completed_at=START + timedelta(minutes=30),
+            wear_cost_per_hour=1.0,
+        )
+        assert entry.wear_cost == 0.5
+
+    async def test_first_run_sets_the_archives_wear(self, db_session):
+        archive = await _archive(db_session)
+        entry = await write_log_entry(
+            db_session,
+            archive_id=archive.id,
+            status="completed",
+            started_at=START,
+            completed_at=START + timedelta(hours=1),
+            wear_cost_per_hour=0.4,
+        )
+        await record_archive_wear(db_session, archive, entry)
+        assert archive.wear_cost == 0.4
+
+    async def test_a_reprint_leaves_the_archives_wear_alone(self, db_session):
+        archive = await _archive(db_session, wear_cost=0.4)
+        db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", wear_cost=0.4))
+        await db_session.commit()
+
+        entry = await write_log_entry(
+            db_session,
+            archive_id=archive.id,
+            status="completed",
+            started_at=START,
+            completed_at=START + timedelta(hours=3),
+            wear_cost_per_hour=0.4,
+        )
+        await record_archive_wear(db_session, archive, entry)
+        assert entry.wear_cost == 1.2
+        assert archive.wear_cost == 0.4
+
+    async def test_a_printer_without_a_rate_leaves_the_archive_empty(self, db_session):
+        archive = await _archive(db_session)
+        entry = await write_log_entry(
+            db_session,
+            archive_id=archive.id,
+            status="completed",
+            started_at=START,
+            completed_at=START + timedelta(hours=1),
+        )
+        await record_archive_wear(db_session, archive, entry)
+        assert archive.wear_cost is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestPrinterSetting:
+    async def test_set_read_and_clear(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+
+        response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": 0.35})
+        assert response.status_code == 200
+        assert response.json()["wear_cost_per_hour"] == 0.35
+
+        listed = (await async_client.get("/api/v1/printers/")).json()
+        assert next(p for p in listed if p["id"] == printer.id)["wear_cost_per_hour"] == 0.35
+
+        response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": None})
+        assert response.status_code == 200
+        assert response.json()["wear_cost_per_hour"] is None
+
+    async def test_negative_rate_is_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wear_cost_per_hour": -1})
+        assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestTotals:
+    async def test_stats_total(self, async_client: AsyncClient, db_session):
+        db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600, wear_cost=0.3))
+        db_session.add(PrintLogEntry(printer_id=1, status="failed", duration_seconds=1800, wear_cost=0.15))
+        db_session.add(PrintLogEntry(printer_id=1, status="completed", duration_seconds=3600))
+        await db_session.commit()
+
+        stats = (await async_client.get("/api/v1/archives/stats")).json()
+        assert stats["total_wear_cost"] == pytest.approx(0.45)
+        # Filament cost stays filament only.
+        assert stats["total_cost"] == 0
+
+    async def test_print_log_returns_and_sorts_by_wear(self, async_client: AsyncClient, db_session):
+        db_session.add(PrintLogEntry(print_name="cheap", status="completed", wear_cost=0.1))
+        db_session.add(PrintLogEntry(print_name="dear", status="completed", wear_cost=2.0))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/print-log/", params={"sort_by": "wear_cost", "sort_dir": "desc"})
+        assert response.status_code == 200
+        items = response.json()["items"]
+        assert [i["print_name"] for i in items[:2]] == ["dear", "cheap"]
+        assert items[0]["wear_cost"] == 2.0
+
+    async def test_archive_response_carries_wear(self, async_client: AsyncClient, db_session):
+        archive = await _archive(db_session, wear_cost=0.75)
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+        assert response.status_code == 200
+        assert response.json()["wear_cost"] == 0.75
+
+    async def test_project_totals_include_wear(self, async_client: AsyncClient, db_session):
+        from backend.app.models.project import Project
+
+        project = Project(name="Wear project")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        archive = await _archive(db_session, project_id=project.id)
+        db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.5))
+        db_session.add(PrintLogEntry(archive_id=archive.id, status="completed", cost=1.0, wear_cost=0.25))
+        await db_session.commit()
+
+        stats = (await async_client.get(f"/api/v1/projects/{project.id}")).json()["stats"]
+        assert stats["total_wear_cost"] == pytest.approx(0.75)
+        assert stats["estimated_cost"] == pytest.approx(2.0)
+
+    async def test_batch_cost_includes_wear(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        order = (
+            await async_client.post(
+                "/api/v1/queue/batches",
+                json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 2}]},
+            )
+        ).json()
+        item = (
+            await async_client.post(
+                "/api/v1/queue/",
+                json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": order["id"], "plate_id": 1},
+            )
+        ).json()
+        queued = await db_session.get(PrintQueueItem, item["id"])
+        queued.status = "completed"
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                queue_item_id=item["id"],
+                status="completed",
+                cost=2.0,
+                energy_cost=0.5,
+                wear_cost=0.25,
+            )
+        )
+        await db_session.commit()
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["actual_cost"] == pytest.approx(2.75)

+ 103 - 0
frontend/src/__tests__/components/EditPrinterWearCost.test.tsx

@@ -0,0 +1,103 @@
+/**
+ * Wear cost per printing hour in the Edit Printer dialog (#694).
+ *
+ * The rate is optional: a number turns it on for the printer, and an empty
+ * field sends null so the printer stops adding wear to its prints.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const basePrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: null,
+  auto_archive: true,
+  is_active: true,
+  wear_cost_per_hour: null as number | null,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const mockStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  vt_tray: [],
+};
+
+let saved: Record<string, unknown> | null = null;
+
+function mockPrinter(printer: typeof basePrinter) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([printer])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatus)),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.post('/api/v1/printers/diagnostic', () =>
+      HttpResponse.json({ printer_id: null, ip_address: printer.ip_address, overall: 'ok', checks: [] }),
+    ),
+    http.patch('/api/v1/printers/:id', async ({ request }) => {
+      saved = (await request.json()) as Record<string, unknown>;
+      return HttpResponse.json({ ...printer, ...saved });
+    }),
+  );
+}
+
+async function openEditModal() {
+  render(<PrintersPage />);
+  await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
+  const menuBtn = [...document.querySelectorAll('button')].find((b) =>
+    b.querySelector('.lucide-ellipsis-vertical'),
+  )!;
+  await userEvent.click(menuBtn);
+  await userEvent.click(await screen.findByRole('button', { name: /^edit$/i }));
+  await screen.findByText('Edit Printer');
+}
+
+describe('EditPrinterModal wear cost', () => {
+  beforeEach(() => {
+    saved = null;
+  });
+
+  it('saves a rate the user enters', async () => {
+    mockPrinter(basePrinter);
+    await openEditModal();
+
+    await userEvent.type(screen.getByLabelText(/Wear cost per printing hour/i), '0.35');
+    await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
+
+    await waitFor(() => expect(saved).not.toBeNull());
+    expect(saved!.wear_cost_per_hour).toBe(0.35);
+  });
+
+  it('turns wear cost off when the field is cleared', async () => {
+    mockPrinter({ ...basePrinter, wear_cost_per_hour: 0.5 });
+    await openEditModal();
+
+    const field = screen.getByLabelText(/Wear cost per printing hour/i);
+    expect(field).toHaveValue(0.5);
+    await userEvent.clear(field);
+    await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
+
+    await waitFor(() => expect(saved).not.toBeNull());
+    expect(saved!.wear_cost_per_hour).toBeNull();
+  });
+});

+ 15 - 0
frontend/src/__tests__/pages/ArchivesPagePrintLogColumns.test.tsx

@@ -38,6 +38,7 @@ const LOG_ENTRIES = [
     cost: 0.42,
     energy_kwh: 0.31,
     energy_cost: 0.09,
+    wear_cost: 0.25,
     failure_reason: null,
     thumbnail_path: null,
     created_by_id: null,
@@ -202,6 +203,20 @@ describe('Print Log columns', () => {
     expect(within(table).getByText('Filament Used')).toBeInTheDocument();
   });
 
+  it('shows the printer wear cost when that column is switched on (#694)', async () => {
+    stubStoredColumns(
+      JSON.stringify([
+        { id: 'date', visible: true },
+        { id: 'wear_cost', visible: true },
+      ]),
+    );
+    await openLogView();
+
+    const table = screen.getByRole('table');
+    expect(within(table).getByText('Wear Cost')).toBeInTheDocument();
+    expect(within(table).getByText('$0.25')).toBeInTheDocument();
+  });
+
   it('shows a dash for energy that the background task has not written yet', async () => {
     // energy_kwh / energy_cost land via a background task after the row is
     // created, so a just-finished print genuinely has none.

+ 21 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -266,6 +266,27 @@ describe('StatsPage', () => {
         expect(screen.getByText('Energy Cost')).toBeInTheDocument();
       });
     });
+
+    it('shows printer wear cost once a printer has a rate (#694)', async () => {
+      server.use(
+        http.get('/api/v1/archives/stats', () => HttpResponse.json({ ...mockStats, total_wear_cost: 7.25 })),
+      );
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Wear Cost')).toBeInTheDocument();
+      });
+      expect(screen.getByText(/7\.25/)).toBeInTheDocument();
+    });
+
+    it('leaves the wear tile out when no wear was recorded (#694)', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Energy Cost')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Wear Cost')).not.toBeInTheDocument();
+    });
   });
 
   describe('widgets', () => {

+ 7 - 0
frontend/src/api/client.ts

@@ -456,6 +456,7 @@ export interface Printer {
   camera_light_auto: boolean;  // picked for the chamber light when camera_light_mode is 'selected' (#1655)
   plate_detection_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
+  wear_cost_per_hour: number | null;  // Wear cost per printing hour (#694)
   created_at: string;
   updated_at: string;
 }
@@ -763,6 +764,7 @@ export interface PrinterCreate {
   camera_light_auto?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
+  wear_cost_per_hour?: number | null;
 }
 
 // Plate Detection
@@ -891,6 +893,7 @@ export interface Archive {
   quantity: number;
   energy_kwh: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   created_at: string;
   // User tracking (Issue #206)
   created_by_id: number | null;
@@ -917,6 +920,7 @@ export interface ArchiveSlim {
   cost: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   quantity: number;
   created_at: string;
 }
@@ -937,6 +941,7 @@ export interface PrintLogEntry {
   cost: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   failure_reason: string | null;
   thumbnail_path: string | null;
   created_by_id: number | null;
@@ -966,6 +971,7 @@ export interface ArchiveStats {
   time_accuracy_by_printer: Record<string, number> | null;
   total_energy_kwh: number;
   total_energy_cost: number;
+  total_wear_cost: number;  // Printer wear (#694)
   // True when a date-filtered total-consumption query is running on incomplete
   // snapshot history (e.g. right after upgrade, before hourly snapshots have
   // a baseline). UI should explain why the number may undercount.
@@ -1084,6 +1090,7 @@ export interface ProjectStats {
   estimated_cost: number;
   total_energy_kwh: number;
   total_energy_cost: number;
+  total_wear_cost: number;  // Printer wear (#694)
   remaining_prints: number | null;  // Remaining plates
   remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Standort / Gruppe (optional)',
       locationPlaceholder: 'z.B. Werkstatt, Büro, Keller',
       autoArchiveLabel: 'Abgeschlossene Drucke automatisch archivieren',
+      wearCostLabel: 'Verschleißkosten pro Druckstunde ({{currency}})',
+      wearCostHelp: 'Was eine Druckstunde diesen Drucker an Verschleiß und Ersatzteilen kostet. Wird ab jetzt zu den Kosten jedes Drucks addiert; frühere Drucke bleiben unverändert. Leer lassen zum Abschalten.',
       fromPrinterSettings: 'Aus Druckereinstellungen',
       modelOptional: 'Modell (optional)',
       saveChanges: 'Änderungen speichern',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} Schicht',
       layers: '{{count}} Schichten',
+      wearCost: 'Druckerverschleiß',
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Kosten',
       energy: 'Energie',
       energyCost: 'Energiekosten',
+      wearCost: 'Verschleißkosten',
       completedAt: 'Beendet',
       columns: 'Spalten',
       sortBy: 'Nach {{column}} sortieren',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Gesamtkosten',
     energyUsed: 'Energieverbrauch',
     energyCost: 'Energiekosten',
+    wearCost: 'Verschleißkosten',
     energyWarmingUpTooltip: 'Die Energieerfassung sammelt noch stündliche Snapshots. Zeitraumwerte werden genau, sobald vor dem gewählten Bereich mindestens ein Snapshot vorliegt. Frühe Werte können zu niedrig sein.',
     averagePrintTime: 'Durchschnittliche Druckzeit',
     printsPerDay: 'Drucke pro Tag',
@@ -4528,6 +4533,7 @@ export default {
       totalCost: 'Erwartete Gesamtkosten',
       total: 'Gesamt',
       includesBom: 'inkl. Stückliste',
+      wear: 'Druckerverschleiß',
       budget: 'Budget',
       remaining: 'Verbleibend',
     },

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

@@ -630,6 +630,8 @@ export default {
       locationGroup: 'Location / Group (optional)',
       locationPlaceholder: 'e.g., Workshop, Office, Basement',
       autoArchiveLabel: 'Auto-archive completed prints',
+      wearCostLabel: 'Wear cost per printing hour ({{currency}})',
+      wearCostHelp: 'What an hour of printing costs this printer in wear and parts. It is added to the cost of every print from now on; earlier prints are not changed. Leave empty to turn it off.',
       fromPrinterSettings: 'From printer settings',
       modelOptional: 'Model (optional)',
       saveChanges: 'Save Changes',
@@ -1167,6 +1169,7 @@ export default {
       filament: '{{weight}}g',
       layer: '{{count}} layer',
       layers: '{{count}} layers',
+      wearCost: 'Printer wear',
       object: '{{count}} object',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
@@ -1271,6 +1274,7 @@ export default {
       cost: 'Cost',
       energy: 'Energy',
       energyCost: 'Energy Cost',
+      wearCost: 'Wear Cost',
       completedAt: 'Finished',
       columns: 'Columns',
       sortBy: 'Sort by {{column}}',
@@ -1741,6 +1745,7 @@ export default {
     totalCost: 'Total Cost',
     energyUsed: 'Energy Used',
     energyCost: 'Energy Cost',
+    wearCost: 'Wear Cost',
     energyWarmingUpTooltip: 'Energy tracking is still collecting hourly snapshots. Date-range totals will become accurate once at least one snapshot exists before the selected range. Early values may undercount.',
     averagePrintTime: 'Average Print Time',
     printsPerDay: 'Prints per Day',
@@ -4560,6 +4565,7 @@ export default {
       totalCost: 'Estimated Cost',
       total: 'Total',
       includesBom: 'incl. BOM',
+      wear: 'Printer Wear',
       budget: 'Budget',
       remaining: 'Remaining',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Ubicación / Grupo (opcional)',
       locationPlaceholder: 'p. ej., Taller, Oficina, Sótano',
       autoArchiveLabel: 'Archivar automáticamente las impresiones completadas',
+      wearCostLabel: 'Coste de desgaste por hora de impresión ({{currency}})',
+      wearCostHelp: 'Lo que una hora de impresión le cuesta a esta impresora en desgaste y piezas. Se suma al coste de cada impresión a partir de ahora; las impresiones anteriores no cambian. Déjalo vacío para desactivarlo.',
       fromPrinterSettings: 'Desde los ajustes de la impresora',
       modelOptional: 'Modelo (opcional)',
       saveChanges: 'Guardar cambios',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} capa',
       layers: '{{count}} capas',
+      wearCost: 'Desgaste de la impresora',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       slicedFor: 'Laminado para {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Coste',
       energy: 'Energía',
       energyCost: 'Coste de energía',
+      wearCost: 'Coste de desgaste',
       completedAt: 'Finalizado',
       columns: 'Columnas',
       sortBy: 'Ordenar por {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Coste total',
     energyUsed: 'Energía usada',
     energyCost: 'Coste de la energía',
+    wearCost: 'Coste de desgaste',
     energyWarmingUpTooltip: 'El seguimiento de energía aún está recopilando instantáneas horarias. Los totales por rango de fechas serán precisos una vez que exista al menos una instantánea antes del rango seleccionado. Los valores iniciales pueden quedarse cortos.',
     averagePrintTime: 'Tiempo medio de impresión',
     printsPerDay: 'Impresiones por día',
@@ -4530,6 +4535,7 @@ export default {
       totalCost: 'Coste total',
       total: 'Total',
       includesBom: 'incl. lista de materiales',
+      wear: 'Desgaste de la impresora',
       budget: 'Presupuesto',
       remaining: 'Restante',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Emplacement / Groupe (optionnel)',
       locationPlaceholder: 'ex: Atelier, Bureau',
       autoArchiveLabel: 'Auto-archiver les impressions terminées',
+      wearCostLabel: 'Coût d\'usure par heure d\'impression ({{currency}})',
+      wearCostHelp: 'Ce qu\'une heure d\'impression coûte à cette imprimante en usure et en pièces. Il s\'ajoute au coût de chaque impression à partir de maintenant ; les impressions précédentes ne changent pas. Laissez vide pour le désactiver.',
       fromPrinterSettings: 'Depuis les paramètres imprimante',
       modelOptional: 'Modèle (optionnel)',
       saveChanges: 'Enregistrer les modifications',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} couche',
       layers: '{{count}} couches',
+      wearCost: 'Usure de l\'imprimante',
       object: '{{count}} objet',
       objects: '{{count}} objets',
       slicedFor: 'Découpé pour {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Coût',
       energy: 'Énergie',
       energyCost: 'Coût énergétique',
+      wearCost: 'Coût d\'usure',
       completedAt: 'Terminé',
       columns: 'Colonnes',
       sortBy: 'Trier par {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Coût total',
     energyUsed: 'Énergie consommée',
     energyCost: 'Coût énergie',
+    wearCost: 'Coût d\'usure',
     energyWarmingUpTooltip: 'Le suivi énergétique collecte encore des instantanés horaires. Les totaux par période deviendront précis dès qu’un instantané existera avant la plage sélectionnée. Les premières valeurs peuvent être sous-estimées.',
     averagePrintTime: 'Temps moyen par impression',
     printsPerDay: 'Impressions par jour',
@@ -4516,6 +4521,7 @@ export default {
       totalCost: 'Coût Total',
       total: 'Total',
       includesBom: 'BOM incluse',
+      wear: 'Usure de l\'imprimante',
       budget: 'Budget',
       remaining: 'Restant',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Posizione / Gruppo (opzionale)',
       locationPlaceholder: 'es. Officina, Ufficio, Cantina',
       autoArchiveLabel: 'Archivia automaticamente stampe completate',
+      wearCostLabel: 'Costo di usura per ora di stampa ({{currency}})',
+      wearCostHelp: 'Quanto costa un\'ora di stampa a questa stampante in usura e ricambi. Viene aggiunto al costo di ogni stampa da ora in poi; le stampe precedenti non cambiano. Lascia vuoto per disattivarlo.',
       fromPrinterSettings: 'Dalle impostazioni della stampante',
       modelOptional: 'Modello (opzionale)',
       saveChanges: 'Salva modifiche',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} strato',
       layers: '{{count}} strati',
+      wearCost: 'Usura della stampante',
       object: '{{count}} oggetto',
       objects: '{{count}} oggetti',
       slicedFor: 'Sliced per {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Costo',
       energy: 'Energia',
       energyCost: 'Costo energia',
+      wearCost: 'Costo di usura',
       completedAt: 'Completato',
       columns: 'Colonne',
       sortBy: 'Ordina per {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Costo totale',
     energyUsed: 'Energia usata',
     energyCost: 'Costo energia',
+    wearCost: 'Costo di usura',
     energyWarmingUpTooltip: 'Il tracciamento energia sta ancora raccogliendo snapshot orari. I totali per intervallo diventeranno accurati quando esisterà almeno uno snapshot prima dell’intervallo selezionato. I primi valori potrebbero essere sottostimati.',
     averagePrintTime: 'Tempo medio di stampa',
     printsPerDay: 'Stampe al giorno',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: 'Costo totale',
       total: 'Totale',
       includesBom: 'incl. distinta materiali',
+      wear: 'Usura della stampante',
       budget: 'Budget',
       remaining: 'Rimanente',
     },

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

@@ -624,6 +624,8 @@ export default {
       locationGroup: 'ロケーション / グループ',
       locationPlaceholder: '例: 工房、オフィス、地下室',
       autoArchiveLabel: '完了した印刷を自動アーカイブ',
+      wearCostLabel: '印刷1時間あたりの摩耗コスト({{currency}})',
+      wearCostHelp: 'このプリンターで1時間印刷したときの摩耗と部品のコストです。今後のすべての印刷のコストに加算されます。過去の印刷は変更されません。空欄にすると無効になります。',
       fromPrinterSettings: 'プリンターの設定から取得',
       modelOptional: 'モデル(任意)',
       saveChanges: '変更を保存',
@@ -1159,6 +1161,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} レイヤー',
       layers: '{{count}} レイヤー',
+      wearCost: 'プリンターの摩耗',
       object: '{{count}}オブジェクト',
       objects: '{{count}}オブジェクト',
       slicedFor: '{{model}}用にスライス',
@@ -1263,6 +1266,7 @@ export default {
       cost: 'コスト',
       energy: '電力量',
       energyCost: '電力コスト',
+      wearCost: '摩耗コスト',
       completedAt: '終了',
       columns: '列',
       sortBy: '{{column}} で並べ替え',
@@ -1721,6 +1725,7 @@ export default {
     totalCost: '総コスト',
     energyUsed: 'エネルギー使用量',
     energyCost: 'エネルギーコスト',
+    wearCost: '摩耗コスト',
     energyWarmingUpTooltip: 'エネルギー追跡は毎時スナップショットを収集中です。選択範囲の前に少なくとも1つのスナップショットが存在すると、期間合計が正確になります。初期値は過小になる場合があります。',
     averagePrintTime: '平均印刷時間',
     printsPerDay: '1日あたりの印刷数',
@@ -4528,6 +4533,7 @@ export default {
       totalCost: '合計コスト',
       total: '合計',
       includesBom: 'BOM含む',
+      wear: 'プリンターの摩耗',
       budget: '予算',
       remaining: '残り',
     },

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

@@ -592,6 +592,8 @@ export default {
       locationGroup: '위치 / 그룹 (선택사항)',
       locationPlaceholder: '예: 작업실, 사무실, 지하실',
       autoArchiveLabel: '완료된 인쇄 자동 아카이브',
+      wearCostLabel: '출력 시간당 마모 비용 ({{currency}})',
+      wearCostHelp: '이 프린터로 한 시간 출력할 때 드는 마모 및 부품 비용입니다. 지금부터 모든 출력의 비용에 더해지며, 이전 출력은 바뀌지 않습니다. 비워 두면 꺼집니다.',
       fromPrinterSettings: '프린터 설정에서',
       modelOptional: '모델 (선택사항)',
       saveChanges: '변경사항 저장'
@@ -1114,6 +1116,7 @@ export default {
       filament: '{{weight}}g',
       layer: '{{count}}층',
       layers: '{{count}}층',
+      wearCost: '프린터 마모',
       object: '{{count}}개 객체',
       objects: '{{count}}개 객체',
       slicedFor: '{{model}}용으로 슬라이싱됨',
@@ -1199,6 +1202,7 @@ export default {
       cost: '비용',
       energy: '전력량',
       energyCost: '전력 비용',
+      wearCost: '마모 비용',
       completedAt: '완료',
       columns: '열',
       sortBy: '{{column}} 기준 정렬',
@@ -1656,6 +1660,7 @@ export default {
     totalCost: '총 비용',
     energyUsed: '사용된 에너지',
     energyCost: '에너지 비용',
+    wearCost: '마모 비용',
     energyWarmingUpTooltip: '에너지 추적이 시간별 스냅샷을 수집 중입니다. 날짜 범위 합계는 선택한 범위 이전에 하나 이상의 스냅샷이 존재하면 정확해집니다. 초기 값이 적게 집계될 수 있습니다.',
     averagePrintTime: '평균 인쇄 시간',
     printsPerDay: '일일 인쇄 수',
@@ -4327,6 +4332,7 @@ export default {
       totalCost: '총 비용',
       total: '합계',
       includesBom: 'BOM 포함',
+      wear: '프린터 마모',
       budget: '예산',
       remaining: '남은 예산'
     },

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

@@ -628,6 +628,8 @@ export default {
       locationGroup: 'Locatie / groep (optioneel)',
       locationPlaceholder: 'bijv. Werkplaats, kantoor, kelder',
       autoArchiveLabel: 'Voltooide afdrukken automatisch archiveren',
+      wearCostLabel: 'Slijtagekosten per printuur ({{currency}})',
+      wearCostHelp: 'Wat een uur printen deze printer kost aan slijtage en onderdelen. Wordt vanaf nu bij de kosten van elke print opgeteld; eerdere prints veranderen niet. Laat leeg om het uit te zetten.',
       fromPrinterSettings: 'Uit printerinstellingen',
       modelOptional: 'Model (optioneel)',
       saveChanges: 'Wijzigingen opslaan',
@@ -1165,6 +1167,7 @@ export default {
       filament: '{{weight}}g',
       layer: '{{count}} laag',
       layers: '{{count}} lagen',
+      wearCost: 'Printerslijtage',
       object: '{{count}} object',
       objects: '{{count}} objecten',
       slicedFor: 'Gesliced voor {{model}}',
@@ -1269,6 +1272,7 @@ export default {
       cost: 'Kosten',
       energy: 'Energie',
       energyCost: 'Energiekosten',
+      wearCost: 'Slijtagekosten',
       completedAt: 'Voltooid',
       columns: 'Kolommen',
       sortBy: 'Sorteren op {{column}}',
@@ -1739,6 +1743,7 @@ export default {
     totalCost: 'Totale kosten',
     energyUsed: 'Energieverbruik',
     energyCost: 'Energiekosten',
+    wearCost: 'Slijtagekosten',
     energyWarmingUpTooltip: 'Energietracking verzamelt nog steeds uurlijkse momentopnamen. Totalen over een datumbereik worden nauwkeurig zodra er minstens één momentopname van vóór het geselecteerde bereik bestaat. Vroege waarden kunnen te laag uitvallen.',
     averagePrintTime: 'Gemiddelde afdruktijd',
     printsPerDay: 'Afdrukken per dag',
@@ -4558,6 +4563,7 @@ export default {
       totalCost: 'Geschatte kosten',
       total: 'Totaal',
       includesBom: 'incl. materiaallijst',
+      wear: 'Printerslijtage',
       budget: 'Budget',
       remaining: 'Resterend',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Localização / Grupo (opcional)',
       locationPlaceholder: 'ex.: Oficina, Escritório, Porão',
       autoArchiveLabel: 'Arquivar automaticamente impressões concluídas',
+      wearCostLabel: 'Custo de desgaste por hora de impressão ({{currency}})',
+      wearCostHelp: 'Quanto uma hora de impressão custa a esta impressora em desgaste e peças. É somado ao custo de cada impressão a partir de agora; impressões anteriores não mudam. Deixe vazio para desativar.',
       fromPrinterSettings: 'A partir das configurações da impressora',
       modelOptional: 'Modelo (opcional)',
       saveChanges: 'Salvar alterações',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       layer: '{{count}} camada',
       layers: '{{count}} camadas',
+      wearCost: 'Desgaste da impressora',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       slicedFor: 'Fatiado para {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Custo',
       energy: 'Energia',
       energyCost: 'Custo de energia',
+      wearCost: 'Custo de desgaste',
       completedAt: 'Concluído',
       columns: 'Colunas',
       sortBy: 'Ordenar por {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Custo Total',
     energyUsed: 'Energia Utilizada',
     energyCost: 'Custo da Energia',
+    wearCost: 'Custo de desgaste',
     energyWarmingUpTooltip: 'O monitoramento de energia ainda está coletando snapshots por hora. Os totais por período ficarão precisos quando houver pelo menos um snapshot antes do intervalo selecionado. Valores iniciais podem ser subestimados.',
     averagePrintTime: 'Tempo Médio de Impressão',
     printsPerDay: 'Impressões por Dia',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: 'Custo Total',
       total: 'Total',
       includesBom: 'incl. lista de materiais',
+      wear: 'Desgaste da impressora',
       budget: 'Orçamento',
       remaining: 'Restante',
     },

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

@@ -597,6 +597,8 @@ export default {
       locationGroup: "Расположение / группа (необязательно)",
       locationPlaceholder: "например: Мастерская, Офис, Подвал",
       autoArchiveLabel: "Автоматически архивировать завершённые печати",
+      wearCostLabel: 'Стоимость износа за час печати ({{currency}})',
+      wearCostHelp: 'Во что обходится этому принтеру час печати с учётом износа и запчастей. С этого момента добавляется к стоимости каждой печати; прежние печати не меняются. Оставьте пустым, чтобы отключить.',
       fromPrinterSettings: "Из настроек принтера",
       modelOptional: "Модель (необязательно)",
       saveChanges: "Сохранить изменения",
@@ -1115,6 +1117,7 @@ export default {
       filament: "{{weight}} г",
       layer: "{{count}} слой",
       layers: "{{count}} слоёв",
+      wearCost: 'Износ принтера',
       object: "{{count}} объект",
       objects: "{{count}} объектов",
       slicedFor: "Нарезано для {{model}}",
@@ -1219,6 +1222,7 @@ export default {
       cost: 'Стоимость',
       energy: 'Энергия',
       energyCost: 'Стоимость энергии',
+      wearCost: 'Стоимость износа',
       completedAt: 'Завершено',
       columns: 'Столбцы',
       sortBy: 'Сортировать по: {{column}}',
@@ -1653,6 +1657,7 @@ export default {
     totalCost: "Общая стоимость",
     energyUsed: "Потреблено энергии",
     energyCost: "Стоимость энергии",
+    wearCost: 'Стоимость износа',
     energyWarmingUpTooltip: "Учёт энергии ещё накапливает почасовые снимки. Итоги за выбранный период станут точными, когда появится хотя бы один снимок до начала этого периода. Первые значения могут быть занижены.",
     averagePrintTime: "Среднее время печати",
     printsPerDay: "Печатей в день",
@@ -4319,6 +4324,7 @@ export default {
       totalCost: "Общая стоимость",
       total: "Итого",
       includesBom: "включая спецификацию",
+      wear: 'Износ принтера',
       budget: "Бюджет",
       remaining: "Остаток",
     },

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

@@ -628,6 +628,8 @@ export default {
       locationGroup: 'Plats / grupp (valfritt)',
       locationPlaceholder: 't.ex. Verkstad, Kontor, Källare',
       autoArchiveLabel: 'Auto-arkivera färdiga utskrifter',
+      wearCostLabel: 'Slitagekostnad per utskriftstimme ({{currency}})',
+      wearCostHelp: 'Vad en timmes utskrift kostar den här skrivaren i slitage och delar. Läggs från och med nu till kostnaden för varje utskrift; tidigare utskrifter ändras inte. Lämna tomt för att stänga av.',
       fromPrinterSettings: 'Från skrivarens inställningar',
       modelOptional: 'Modell (valfritt)',
       saveChanges: 'Spara ändringar',
@@ -1165,6 +1167,7 @@ export default {
       filament: '{{weight}}g',
       layer: '{{count}} skikt',
       layers: '{{count}} skikt',
+      wearCost: 'Skrivarslitage',
       object: '{{count}} objekt',
       objects: '{{count}} objekt',
       slicedFor: 'Slicad för {{model}}',
@@ -1269,6 +1272,7 @@ export default {
       cost: 'Kostnad',
       energy: 'Energi',
       energyCost: 'Energikostnad',
+      wearCost: 'Slitagekostnad',
       completedAt: 'Slutförd',
       columns: 'Kolumner',
       sortBy: 'Sortera efter {{column}}',
@@ -1739,6 +1743,7 @@ export default {
     totalCost: 'Total kostnad',
     energyUsed: 'Energi använt',
     energyCost: 'Energikostnad',
+    wearCost: 'Slitagekostnad',
     energyWarmingUpTooltip: 'Energispårning samlar fortfarande in timvisa ögonblicksbilder. Datumintervallstotaler kommer att bli korrekta när åtminstone en ögonblicksbild existerar före det valda intervallet. Tidiga värden kan underskattas.',
     averagePrintTime: 'Genomsnittlig utskriftstid',
     printsPerDay: 'Utskrifter per dag',
@@ -4557,6 +4562,7 @@ errors: {
       totalCost: 'Uppskattad kostnad',
       total: 'Totalt',
       includesBom: 'inkl. BOM',
+      wear: 'Skrivarslitage',
       budget: 'Budget',
       remaining: 'Återstående',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Konum / Grup (isteğe bağlı)',
       locationPlaceholder: 'örn., Atölye, Ofis, Bodrum',
       autoArchiveLabel: 'Tamamlanan baskıları otomatik arşivle',
+      wearCostLabel: 'Baskı saati başına aşınma maliyeti ({{currency}})',
+      wearCostHelp: 'Bir saatlik baskının bu yazıcıya aşınma ve parça olarak maliyeti. Bundan sonra her baskının maliyetine eklenir; önceki baskılar değişmez. Kapatmak için boş bırakın.',
       fromPrinterSettings: 'Yazıcı ayarlarından',
       modelOptional: 'Model (isteğe bağlı)',
       saveChanges: 'Değişiklikleri Kaydet',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}}g',
       layer: '{{count}} katman',
       layers: '{{count}} katman',
+      wearCost: 'Yazıcı aşınması',
       object: '{{count}} nesne',
       objects: '{{count}} nesne',
       slicedFor: '{{model}} için dilimlendi',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Maliyet',
       energy: 'Enerji',
       energyCost: 'Enerji maliyeti',
+      wearCost: 'Aşınma maliyeti',
       completedAt: 'Bitti',
       columns: 'Sütunlar',
       sortBy: '{{column}} ölçütüne göre sırala',
@@ -1723,6 +1727,7 @@ export default {
     totalCost: 'Toplam Maliyet',
     energyUsed: 'Kullanılan Enerji',
     energyCost: 'Enerji Maliyeti',
+    wearCost: 'Aşınma maliyeti',
     energyWarmingUpTooltip: 'Enerji takibi hâlâ saatlik anlık görüntüler topluyor. Tarih aralığı toplamları, seçilen aralıktan önce en az bir anlık görüntü olduğunda doğru hâle gelir. Erken değerler eksik sayabilir.',
     averagePrintTime: 'Ortalama Baskı Süresi',
     printsPerDay: 'Günlük Baskı',
@@ -4516,6 +4521,7 @@ export default {
       totalCost: 'Toplam Maliyet',
       total: 'Toplam',
       includesBom: 'BOM dahil',
+      wear: 'Yazıcı aşınması',
       budget: 'Bütçe',
       remaining: 'Kalan',
     },

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

@@ -628,6 +628,8 @@ export default {
       locationGroup: "Розташування/група (необов’язково)",
       locationPlaceholder: "наприклад, майстерня, офіс, підвал",
       autoArchiveLabel: "Автоматичне архівування завершених роздруківок",
+      wearCostLabel: 'Вартість зносу за годину друку ({{currency}})',
+      wearCostHelp: 'Скільки цьому принтеру коштує година друку в зносі та запчастинах. Відтепер додається до вартості кожного друку; попередні друки не змінюються. Залиште порожнім, щоб вимкнути.',
       fromPrinterSettings: "З налаштувань принтера",
       modelOptional: "Модель (необов’язково)",
       saveChanges: "Зберегти зміни",
@@ -1164,6 +1166,7 @@ export default {
       filament: "{{weight}} г",
       layer: "{{count}} шар",
       layers: "Шарів: {{count}}",
+      wearCost: 'Знос принтера',
       object: "{{count}} об’єкт",
       objects: "Об’єктів: {{count}}",
       slicedFor: "Нарізаний для {{model}}",
@@ -1268,6 +1271,7 @@ export default {
       cost: 'Вартість',
       energy: 'Енергія',
       energyCost: 'Вартість енергії',
+      wearCost: 'Вартість зносу',
       completedAt: 'Завершено',
       columns: 'Стовпці',
       sortBy: 'Сортувати за: {{column}}',
@@ -1738,6 +1742,7 @@ export default {
     totalCost: "Загальна вартість",
     energyUsed: "Використана енергія",
     energyCost: "Вартість енергії",
+    wearCost: 'Вартість зносу',
     energyWarmingUpTooltip: "Відстеження енергії все ще збирає щогодинні знімки. Підсумкові показники за діапазон дат стануть точними, коли існує принаймні один знімок до вибраного діапазону. Ранні значення можуть бути заниженими.",
     averagePrintTime: "Середній час друку",
     printsPerDay: "Друків на день",
@@ -4555,6 +4560,7 @@ export default {
       totalCost: "Загальна вартість",
       total: "Всього",
       includesBom: "в т.ч. BOM",
+      wear: 'Знос принтера',
       budget: "Бюджет",
       remaining: "Залишилося",
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: '位置 / 分组(可选)',
       locationPlaceholder: '例如:工作室、办公室、地下室',
       autoArchiveLabel: '自动归档已完成的打印',
+      wearCostLabel: '每打印小时磨损成本({{currency}})',
+      wearCostHelp: '这台打印机每打印一小时的磨损和零件成本。从现在起计入每次打印的成本,之前的打印不受影响。留空即关闭。',
       fromPrinterSettings: '来自打印机设置',
       modelOptional: '型号(可选)',
       saveChanges: '保存更改',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} 克',
       layer: '{{count}} 层',
       layers: '{{count}} 层',
+      wearCost: '打印机磨损',
       object: '{{count}} 个对象',
       objects: '{{count}} 个对象',
       slicedFor: '为 {{model}} 切片',
@@ -1264,6 +1267,7 @@ export default {
       cost: '成本',
       energy: '电量',
       energyCost: '电费',
+      wearCost: '磨损成本',
       completedAt: '完成时间',
       columns: '列',
       sortBy: '按{{column}}排序',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: '总成本',
     energyUsed: '能耗',
     energyCost: '能源成本',
+    wearCost: '磨损成本',
     energyWarmingUpTooltip: '能耗追踪正在收集每小时快照。当所选范围之前至少存在一个快照时,时间段合计将变得准确。早期数值可能偏低。',
     averagePrintTime: '平均打印时间',
     printsPerDay: '每日打印次数',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: '总成本',
       total: '总计',
       includesBom: '含物料清单',
+      wear: '打印机磨损',
       budget: '预算',
       remaining: '剩余',
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: '位置 / 分組(可選)',
       locationPlaceholder: '例如:工作室、辦公室、地下室',
       autoArchiveLabel: '自動歸檔已完成的列印',
+      wearCostLabel: '每列印小時磨損成本({{currency}})',
+      wearCostHelp: '這台印表機每列印一小時的磨損與零件成本。從現在起計入每次列印的成本,先前的列印不受影響。留空即關閉。',
       fromPrinterSettings: '來自印表機設定',
       modelOptional: '型號(可選)',
       saveChanges: '儲存更改',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} 克',
       layer: '{{count}} 層',
       layers: '{{count}} 層',
+      wearCost: '印表機磨損',
       object: '{{count}} 個物件',
       objects: '{{count}} 個物件',
       slicedFor: '為 {{model}} 切片',
@@ -1264,6 +1267,7 @@ export default {
       cost: '成本',
       energy: '電量',
       energyCost: '電費',
+      wearCost: '磨損成本',
       completedAt: '完成時間',
       columns: '欄位',
       sortBy: '依{{column}}排序',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: '總成本',
     energyUsed: '能耗',
     energyCost: '能源成本',
+    wearCost: '磨損成本',
     energyWarmingUpTooltip: '能耗追蹤正在收集每小時快照。當所選範圍之前至少存在一個快照時,時間段合計將變得準確。早期數值可能偏低。',
     averagePrintTime: '平均列印時間',
     printsPerDay: '每日列印次數',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: '總成本',
       total: '總計',
       includesBom: '含物料清單',
+      wear: '印表機磨損',
       budget: '預算',
       remaining: '剩餘',
     },

+ 18 - 3
frontend/src/pages/ArchivesPage.tsx

@@ -54,6 +54,7 @@ import {
   Play,
   ClipboardList,
   Zap,
+  Wrench,
   Cog,
   Archive as ArchiveIcon,
   History,
@@ -136,6 +137,7 @@ const LOG_COLUMN_LABEL_KEYS: Record<string, string> = {
   cost: 'archives.log.cost',
   energy: 'archives.log.energy',
   energy_cost: 'archives.log.energyCost',
+  wear_cost: 'archives.log.wearCost',
 };
 
 // Defaults reproduce the previous seven columns in the same order, plus the
@@ -155,6 +157,7 @@ const DEFAULT_LOG_COLUMNS: Array<{ id: string; visible: boolean }> = [
   { id: 'cost', visible: false },
   { id: 'energy', visible: false },
   { id: 'energy_cost', visible: false },
+  { id: 'wear_cost', visible: false },
 ];
 
 /** Stored config merged with the defaults: unknown ids (removed columns) are
@@ -1275,7 +1278,7 @@ function ArchiveCard({
               {archive.filament_used_grams.toFixed(1)}g
             </div>
           )}
-          {(archive.cost != null || archive.energy_cost != null) && (
+          {(archive.cost != null || archive.energy_cost != null || archive.wear_cost != null) && (
             <div className="flex items-center gap-3 text-bambu-gray">
               {archive.cost != null && (
                 <div className="flex items-center gap-1.5">
@@ -1289,6 +1292,12 @@ function ArchiveCard({
                     {currency}{archive.energy_cost.toFixed(2)}
                   </div>
                 )}
+                {archive.wear_cost != null && (
+                  <div className="flex items-center gap-1.5" title={t('archives.card.wearCost')}>
+                    <Wrench className="w-3 h-3" />
+                    {currency}{archive.wear_cost.toFixed(2)}
+                  </div>
+                )}
             </div>
           )}
           {(archive.layer_height || archive.total_layers) && (
@@ -3207,7 +3216,7 @@ export function ArchivesPage() {
   const handleLogSort = useCallback((colId: string) => {
     if (!SORTABLE_LOG_COLUMNS.has(colId)) return;
     setLogSort((prev) => {
-      const numericFirstDesc = ['date', 'completed_at', 'duration', 'filament_used', 'cost', 'energy', 'energy_cost'];
+      const numericFirstDesc = ['date', 'completed_at', 'duration', 'filament_used', 'cost', 'energy', 'energy_cost', 'wear_cost'];
       const next: LogSortState =
         prev.column === colId
           ? { column: colId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
@@ -3230,7 +3239,7 @@ export function ArchivesPage() {
   // Columns that hold a number and read better right-aligned. Kept as data so
   // the header and the body can't drift apart.
   const LOG_NUMERIC_COLUMNS = useMemo(
-    () => new Set(['duration', 'filament_used', 'cost', 'energy', 'energy_cost']),
+    () => new Set(['duration', 'filament_used', 'cost', 'energy', 'energy_cost', 'wear_cost']),
     [],
   );
 
@@ -3348,6 +3357,12 @@ export function ArchivesPage() {
               {entry.energy_cost != null ? `${currency}${entry.energy_cost.toFixed(2)}` : '—'}
             </span>
           );
+        case 'wear_cost':
+          return (
+            <span className="text-bambu-gray-light whitespace-nowrap tabular-nums">
+              {entry.wear_cost != null ? `${currency}${entry.wear_cost.toFixed(2)}` : '—'}
+            </span>
+          );
         default:
           return null;
       }

+ 23 - 0
frontend/src/pages/PrintersPage.tsx

@@ -203,6 +203,7 @@ import { Collapsible } from '../components/Collapsible';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { NumberInput } from '../components/NumberInput';
+import { getCurrencySymbol } from '../utils/currency';
 
 // The status filter's options, and the only values it may hold. One list so a
 // saved filter cannot be validated against a set the dropdown has since moved
@@ -8467,7 +8468,10 @@ function EditPrinterModal({
     location: printer.location || '',
     auto_archive: printer.auto_archive,
     is_active: printer.is_active,
+    wear_cost_per_hour: printer.wear_cost_per_hour ? String(printer.wear_cost_per_hour) : '',
   });
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const currency = getCurrencySymbol(settings?.currency || 'USD');
 
   // Groups can be given a location (#1727), so a move changes who can use the
   // printer. Non-admins can't read groups; the server refuses their move instead.
@@ -8521,6 +8525,8 @@ function EditPrinterModal({
       location: form.location.trim() || null,
       auto_archive: form.auto_archive,
       is_active: form.is_active,
+      // Empty or 0 turns wear cost off for this printer (#694)
+      wear_cost_per_hour: Number(form.wear_cost_per_hour) > 0 ? Number(form.wear_cost_per_hour) : null,
     };
     // Only include access_code if it was changed
     if (form.access_code) {
@@ -8668,6 +8674,23 @@ function EditPrinterModal({
                 {t('printers.modal.autoArchiveLabel')}
               </label>
             </div>
+            <div>
+              <label htmlFor="edit_wear_cost" className="block text-sm text-bambu-gray mb-1">
+                {t('printers.modal.wearCostLabel', { currency })}
+              </label>
+              <input
+                id="edit_wear_cost"
+                type="number"
+                min={0}
+                step="0.01"
+                inputMode="decimal"
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                value={form.wear_cost_per_hour}
+                onChange={(e) => setForm({ ...form, wear_cost_per_hour: e.target.value })}
+                placeholder="0.00"
+              />
+              <p className="text-xs text-bambu-gray mt-1">{t('printers.modal.wearCostHelp')}</p>
+            </div>
             {/* Maintenance Mode toggle (#1476) — checkbox is the inverse of
                 is_active because the user-facing concept is "is this printer
                 in maintenance" not "is it active". */}

+ 12 - 3
frontend/src/pages/ProjectDetailPage.tsx

@@ -714,6 +714,7 @@ export function ProjectDetailPage() {
                 const rollupCost =
                   project.rollup_stats.estimated_cost +
                   project.rollup_stats.total_energy_cost +
+                  (project.rollup_stats.total_wear_cost ?? 0) +
                   project.rollup_stats.bom_cost;
                 if (rollupCost <= 0) return null;
                 return (
@@ -754,7 +755,7 @@ export function ProjectDetailPage() {
 
       {/* Cost tracking */}
       {stats && (() => {
-        const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+        const totalCost = stats.estimated_cost + stats.total_energy_cost + (stats.total_wear_cost ?? 0) + stats.bom_cost;
         return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
       })() && (
         <Card>
@@ -782,8 +783,16 @@ export function ProjectDetailPage() {
                   </p>
                 </div>
               )}
+              {(stats.total_wear_cost ?? 0) > 0 && (
+                <div>
+                  <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.wear')}</p>
+                  <p className="text-lg font-semibold text-white">
+                    {currency}{stats.total_wear_cost.toFixed(2)}
+                  </p>
+                </div>
+              )}
               {(() => {
-                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+                const totalCost = stats.estimated_cost + stats.total_energy_cost + (stats.total_wear_cost ?? 0) + stats.bom_cost;
                 if (totalCost <= 0) return null;
                 return (
                   <div>
@@ -798,7 +807,7 @@ export function ProjectDetailPage() {
                 );
               })()}
               {project.budget !== null && (() => {
-                const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
+                const totalCost = stats.estimated_cost + stats.total_energy_cost + (stats.total_wear_cost ?? 0) + stats.bom_cost;
                 const remaining = project.budget - totalCost;
                 return (
                   <div>

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

@@ -10,6 +10,7 @@ import {
   DollarSign,
   Target,
   Zap,
+  Wrench,
   AlertTriangle,
   TrendingDown,
   FileSpreadsheet,
@@ -133,6 +134,7 @@ function QuickStatsWidget({
     total_cost: number;
     total_energy_kwh: number;
     total_energy_cost: number;
+    total_wear_cost?: number;
     energy_data_warming_up?: boolean;
   } | undefined;
   currency: string;
@@ -163,6 +165,18 @@ function QuickStatsWidget({
       warning: warmingUp,
       tooltip: warmingUpTooltip,
     },
+    // Only once a printer has a wear cost set (#694), so installs without one
+    // don't get a tile that always reads zero.
+    ...((stats?.total_wear_cost ?? 0) > 0
+      ? [{
+          icon: Wrench,
+          color: 'text-slate-500 dark:text-slate-300',
+          label: t('stats.wearCost'),
+          value: `${currency} ${stats?.total_wear_cost?.toFixed(2) ?? '0.00'}`,
+          warning: false,
+          tooltip: undefined,
+        }]
+      : []),
   ];
 
   return (
@@ -897,9 +911,9 @@ function RecordsWidget({ archives, currency }: { archives: ArchiveSlim[]; curren
       });
     }
 
-    // Filament + measured energy (#1432); prints without a smart plug have
-    // energy_cost null and compete on filament cost alone.
-    const costliest = findMax(a => (a.cost ?? 0) + (a.energy_cost ?? 0));
+    // Filament + measured energy (#1432) + printer wear (#694); a cost that
+    // wasn't recorded is null and simply doesn't add.
+    const costliest = findMax(a => (a.cost ?? 0) + (a.energy_cost ?? 0) + (a.wear_cost ?? 0));
     if (costliest.archive) {
       result.push({
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-UBPjJ8pq.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-B_evlKk5.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DNZrxR7Y.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-DntsHhWu.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-IiFYiX_u.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-yYp_wKBl.js"></script>
+    <script type="module" crossorigin src="/assets/index-DntsHhWu.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>

Some files were not shown because too many files changed in this diff