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
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### 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:
 - **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`.
   - **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.
   - **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,
         "quantity": archive.quantity,
         "energy_kwh": archive.energy_kwh,
         "energy_kwh": archive.energy_kwh,
         "energy_cost": archive.energy_cost,
         "energy_cost": archive.energy_cost,
+        "wear_cost": archive.wear_cost,
         "created_at": archive.created_at,
         "created_at": archive.created_at,
         # User tracking (Issue #206)
         # User tracking (Issue #206)
         "created_by_id": archive.created_by_id,
         "created_by_id": archive.created_by_id,
@@ -710,6 +711,7 @@ async def list_archives_slim(
             PrintLogEntry.cost,
             PrintLogEntry.cost,
             PrintLogEntry.energy_kwh,
             PrintLogEntry.energy_kwh,
             PrintLogEntry.energy_cost,
             PrintLogEntry.energy_cost,
+            PrintLogEntry.wear_cost,
             PrintLogEntry.created_at,
             PrintLogEntry.created_at,
         )
         )
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
@@ -754,6 +756,7 @@ async def list_archives_slim(
             "cost": r.cost,
             "cost": r.cost,
             "energy_kwh": r.energy_kwh,
             "energy_kwh": r.energy_kwh,
             "energy_cost": r.energy_cost,
             "energy_cost": r.energy_cost,
+            "wear_cost": r.wear_cost,
             "quantity": 1,
             "quantity": 1,
             "created_at": r.created_at,
             "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))
     cost_result = await db.execute(select(func.sum(PrintLogEntry.cost)).where(*base_conditions))
     total_cost = cost_result.scalar() or 0
     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)
     # By filament type (split comma-separated values for multi-material prints)
     filament_type_result = await db.execute(
     filament_type_result = await db.execute(
         select(PrintLogEntry.filament_type).where(PrintLogEntry.filament_type.isnot(None), *base_conditions)
         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,
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
         total_energy_kwh=round(total_energy_kwh, 3),
         total_energy_kwh=round(total_energy_kwh, 3),
         total_energy_cost=round(total_energy_cost, 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,
         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,
     "cost": PrintLogEntry.cost,
     "energy": PrintLogEntry.energy_kwh,
     "energy": PrintLogEntry.energy_kwh,
     "energy_cost": PrintLogEntry.energy_cost,
     "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
     filament_cost: float = 0.0
     energy_kwh: float = 0.0
     energy_kwh: float = 0.0
     energy_cost: float = 0.0
     energy_cost: float = 0.0
+    wear_cost: float = 0.0
     queued_prints: int = 0
     queued_prints: int = 0
     in_progress_prints: int = 0
     in_progress_prints: int = 0
     bom_total_items: 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.cost), 0).label("total_filament_cost"),
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             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.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"),
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
             # A completed run the user marked as reject (#1898) produced no
             # A completed run the user marked as reject (#1898) produced no
             # usable parts — keep it out of the good-parts count.
             # 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.filament_cost = float(row.total_filament_cost or 0)
         entry.energy_kwh = float(row.total_energy or 0)
         entry.energy_kwh = float(row.total_energy or 0)
         entry.energy_cost = float(row.total_energy_cost 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.total_items = int(row.total_items or 0)
         entry.completed_items = int(row.completed_items or 0)
         entry.completed_items = int(row.completed_items or 0)
         entry.failed_runs = int(row.failed_runs 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),
         estimated_cost=round(totals.filament_cost, 2),
         total_energy_kwh=round(totals.energy_kwh, 3),
         total_energy_kwh=round(totals.energy_kwh, 3),
         total_energy_cost=round(totals.energy_cost, 3),
         total_energy_cost=round(totals.energy_cost, 3),
+        total_wear_cost=round(totals.wear_cost, 3),
         remaining_prints=remaining_prints,
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
         remaining_parts=remaining_parts,
         bom_total_items=totals.bom_total_items,
         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,
                 completed_prints=child_stats.completed_prints,
                 total_print_time_hours=child_stats.total_print_time_hours,
                 total_print_time_hours=child_stats.total_print_time_hours,
                 total_filament_grams=child_stats.total_filament_grams,
                 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 _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN shared BOOLEAN DEFAULT FALSE")
     await _backfill_library_folder_owners(conn)
     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:
 async def _backfill_snapshot_prices(conn) -> None:
     """Give the energy snapshots taken before #1251 the price set at upgrade.
     """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:
     try:
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.models.archive import PrintArchive
             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)
             archive = await db.get(PrintArchive, archive_id)
             if archive:
             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":
                 if _run_cost is None and _run_status == "completed":
                     _run_cost = _est_cost
                     _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,
                     db,
                     archive_id=archive.id,
                     archive_id=archive.id,
                     # Captured by _update_queue_status above; None for
                     # 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_color=archive.filament_color,
                     filament_used_grams=_run_grams,
                     filament_used_grams=_run_grams,
                     cost=_run_cost,
                     cost=_run_cost,
+                    wear_cost_per_hour=_wear_rate,
                     failure_reason=archive.failure_reason,
                     failure_reason=archive.failure_reason,
                     thumbnail_path=archive.thumbnail_path,
                     thumbnail_path=archive.thumbnail_path,
                     created_by_id=archive.created_by_id,
                     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).
                     # log 0 duration instead of the whole disconnect gap (#2592).
                     reconciled=bool(data.get("_reconciled")),
                     reconciled=bool(data.get("_reconciled")),
                 )
                 )
+                await record_archive_wear(db, archive, run_entry)
                 await db.commit()
                 await db.commit()
                 logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
                 logger.info("[PRINT_LOG] Log entry written for archive %s", archive_id)
     except Exception as e:
     except Exception as e:

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

@@ -144,6 +144,8 @@ class PrintArchive(Base):
     # Energy tracking
     # Energy tracking
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_kwh: Mapped[float | None] = mapped_column(Float)  # Energy consumed in kWh
     energy_cost: Mapped[float | None] = mapped_column(Float)  # Cost of energy consumed
     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.
     # 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).
     # Persisted so per-print tracking survives backend restarts mid-print (#941).
     energy_start_kwh: Mapped[float | None] = mapped_column(Float)
     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)
     cost: Mapped[float | None] = mapped_column(Float)
     energy_kwh: Mapped[float | None] = mapped_column(Float)
     energy_kwh: Mapped[float | None] = mapped_column(Float)
     energy_cost: 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))
     failure_reason: Mapped[str | None] = mapped_column(String(100))
     # User quality verdict ('good' / 'reject'), mirrored from the archive on
     # User quality verdict ('good' / 'reject'), mirrored from the archive on
     # PATCH exactly like status/failure_reason (#1444 mirror) so verdict-aware
     # 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)
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: 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
     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(
     runtime_seconds: Mapped[int] = mapped_column(
         default=0
         default=0
     )  # Accumulated active runtime (RUNNING state only — see #1521)
     )  # 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 tracking
     energy_kwh: float | None = None
     energy_kwh: float | None = None
     energy_cost: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # Printer wear, #694
 
 
     created_at: datetime | None
     created_at: datetime | None
 
 
@@ -171,6 +172,7 @@ class ArchiveSlim(BaseModel):
     cost: float | None
     cost: float | None
     energy_kwh: float | None = None
     energy_kwh: float | None = None
     energy_cost: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # Printer wear, #694
     quantity: int = 1
     quantity: int = 1
     created_at: datetime | None
     created_at: datetime | None
 
 
@@ -202,6 +204,8 @@ class ArchiveStats(BaseModel):
     # Energy stats
     # Energy stats
     total_energy_kwh: float = 0.0
     total_energy_kwh: float = 0.0
     total_energy_cost: 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
     # 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
     # incomplete snapshot history — e.g. right after a fresh upgrade before the
     # hourly snapshot loop has built up a baseline. Frontend shows a tooltip.
     # 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
     cost: float | None = None
     energy_kwh: float | None = None
     energy_kwh: float | None = None
     energy_cost: float | None = None
     energy_cost: float | None = None
+    wear_cost: float | None = None  # #694
     failure_reason: str | None = None
     failure_reason: str | None = None
     user_verdict: str | None = None  # Post-print quality verdict (#1898)
     user_verdict: str | None = None  # Post-print quality verdict (#1898)
     thumbnail_path: str | None = None
     thumbnail_path: str | None = None

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

@@ -77,6 +77,8 @@ class PrinterUpdate(BaseModel):
     is_active: bool | None = None
     is_active: bool | None = None
     auto_archive: bool | None = None
     auto_archive: bool | None = None
     print_hours_offset: float | 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_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool | None = None
     external_camera_enabled: bool | None = None
@@ -97,6 +99,7 @@ class PrinterResponse(PrinterBase):
     # printer_models.supports_nozzle_flow_type.
     # printer_models.supports_nozzle_flow_type.
     supports_nozzle_flow_type: bool = True
     supports_nozzle_flow_type: bool = True
     print_hours_offset: float = 0.0
     print_hours_offset: float = 0.0
+    wear_cost_per_hour: float | None = None  # #694
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
     external_camera_enabled: bool = False
     external_camera_enabled: bool = False
@@ -132,6 +135,7 @@ class PrinterResponse(PrinterBase):
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,
             "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
             "print_hours_offset": printer.print_hours_offset,
+            "wear_cost_per_hour": printer.wear_cost_per_hour,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "created_at": printer.created_at,
             "created_at": printer.created_at,
             "updated_at": printer.updated_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
     estimated_cost: float = 0.0  # Based on filament cost
     total_energy_kwh: float = 0.0
     total_energy_kwh: float = 0.0
     total_energy_cost: 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_prints: int | None = None  # target_count - total_archives
     remaining_parts: int | None = None  # target_parts_count - completed_prints
     remaining_parts: int | None = None  # target_parts_count - completed_prints
     # BOM stats (Phase 7)
     # BOM stats (Phase 7)

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

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

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

@@ -880,6 +880,7 @@ class GitHubBackupService:
                 "quantity": a.quantity,
                 "quantity": a.quantity,
                 "energy_kwh": a.energy_kwh,
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "energy_cost": a.energy_cost,
+                "wear_cost": a.wear_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
                 "created_at": str(a.created_at) if a.created_at else None,
                 # Soft-deleted archives are collected too — their row is kept on
                 # Soft-deleted archives are collected too — their row is kept on
                 # purpose so the stats endpoint keeps counting their filament and
                 # 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,
                 "quantity": entry.get("quantity") or 1,
                 "energy_kwh": entry.get("energy_kwh"),
                 "energy_kwh": entry.get("energy_kwh"),
                 "energy_cost": entry.get("energy_cost"),
                 "energy_cost": entry.get("energy_cost"),
+                "wear_cost": entry.get("wear_cost"),
             }
             }
 
 
             printer_id = entry.get("printer_id")
             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(
         await db.execute(
             select(
             select(
                 PrintQueueItem.plate_id,
                 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),
                 func.sum(PrintLogEntry.filament_used_grams),
             )
             )
             .select_from(PrintLogEntry)
             .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
 import logging
 from datetime import datetime
 from datetime import datetime
 
 
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
@@ -13,6 +14,19 @@ from backend.app.models.print_log import PrintLogEntry
 logger = logging.getLogger(__name__)
 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(
 async def write_log_entry(
     db: AsyncSession,
     db: AsyncSession,
     *,
     *,
@@ -30,6 +44,7 @@ async def write_log_entry(
     cost: float | None = None,
     cost: float | None = None,
     energy_kwh: float | None = None,
     energy_kwh: float | None = None,
     energy_cost: float | None = None,
     energy_cost: float | None = None,
+    wear_cost_per_hour: float | None = None,
     failure_reason: str | None = None,
     failure_reason: str | None = None,
     thumbnail_path: str | None = None,
     thumbnail_path: str | None = None,
     created_by_id: int | None = None,
     created_by_id: int | None = None,
@@ -71,6 +86,7 @@ async def write_log_entry(
         cost=cost,
         cost=cost,
         energy_kwh=energy_kwh,
         energy_kwh=energy_kwh,
         energy_cost=energy_cost,
         energy_cost=energy_cost,
+        wear_cost=wear_cost_for_run(duration, wear_cost_per_hour),
         failure_reason=failure_reason,
         failure_reason=failure_reason,
         thumbnail_path=thumbnail_path,
         thumbnail_path=thumbnail_path,
         created_by_id=created_by_id,
         created_by_id=created_by_id,
@@ -79,3 +95,17 @@ async def write_log_entry(
     db.add(entry)
     db.add(entry)
     await db.flush()
     await db.flush()
     return entry
     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,
     cost: 0.42,
     energy_kwh: 0.31,
     energy_kwh: 0.31,
     energy_cost: 0.09,
     energy_cost: 0.09,
+    wear_cost: 0.25,
     failure_reason: null,
     failure_reason: null,
     thumbnail_path: null,
     thumbnail_path: null,
     created_by_id: null,
     created_by_id: null,
@@ -202,6 +203,20 @@ describe('Print Log columns', () => {
     expect(within(table).getByText('Filament Used')).toBeInTheDocument();
     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 () => {
   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
     // energy_kwh / energy_cost land via a background task after the row is
     // created, so a just-finished print genuinely has none.
     // 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();
         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', () => {
   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)
   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_enabled: boolean;  // Check plate before print
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
   plate_detection_roi?: PlateDetectionROI;  // ROI for plate detection
+  wear_cost_per_hour: number | null;  // Wear cost per printing hour (#694)
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -763,6 +764,7 @@ export interface PrinterCreate {
   camera_light_auto?: boolean;
   camera_light_auto?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_enabled?: boolean;
   plate_detection_roi?: PlateDetectionROI;
   plate_detection_roi?: PlateDetectionROI;
+  wear_cost_per_hour?: number | null;
 }
 }
 
 
 // Plate Detection
 // Plate Detection
@@ -891,6 +893,7 @@ export interface Archive {
   quantity: number;
   quantity: number;
   energy_kwh: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   created_at: string;
   created_at: string;
   // User tracking (Issue #206)
   // User tracking (Issue #206)
   created_by_id: number | null;
   created_by_id: number | null;
@@ -917,6 +920,7 @@ export interface ArchiveSlim {
   cost: number | null;
   cost: number | null;
   energy_kwh: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   quantity: number;
   quantity: number;
   created_at: string;
   created_at: string;
 }
 }
@@ -937,6 +941,7 @@ export interface PrintLogEntry {
   cost: number | null;
   cost: number | null;
   energy_kwh: number | null;
   energy_kwh: number | null;
   energy_cost: number | null;
   energy_cost: number | null;
+  wear_cost: number | null;  // Printer wear (#694)
   failure_reason: string | null;
   failure_reason: string | null;
   thumbnail_path: string | null;
   thumbnail_path: string | null;
   created_by_id: number | null;
   created_by_id: number | null;
@@ -966,6 +971,7 @@ export interface ArchiveStats {
   time_accuracy_by_printer: Record<string, number> | null;
   time_accuracy_by_printer: Record<string, number> | null;
   total_energy_kwh: number;
   total_energy_kwh: number;
   total_energy_cost: number;
   total_energy_cost: number;
+  total_wear_cost: number;  // Printer wear (#694)
   // True when a date-filtered total-consumption query is running on incomplete
   // True when a date-filtered total-consumption query is running on incomplete
   // snapshot history (e.g. right after upgrade, before hourly snapshots have
   // snapshot history (e.g. right after upgrade, before hourly snapshots have
   // a baseline). UI should explain why the number may undercount.
   // a baseline). UI should explain why the number may undercount.
@@ -1084,6 +1090,7 @@ export interface ProjectStats {
   estimated_cost: number;
   estimated_cost: number;
   total_energy_kwh: number;
   total_energy_kwh: number;
   total_energy_cost: number;
   total_energy_cost: number;
+  total_wear_cost: number;  // Printer wear (#694)
   remaining_prints: number | null;  // Remaining plates
   remaining_prints: number | null;  // Remaining plates
   remaining_parts: number | null;  // Remaining parts
   remaining_parts: number | null;  // Remaining parts
   bom_total_items: number;
   bom_total_items: number;

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Standort / Gruppe (optional)',
       locationGroup: 'Standort / Gruppe (optional)',
       locationPlaceholder: 'z.B. Werkstatt, Büro, Keller',
       locationPlaceholder: 'z.B. Werkstatt, Büro, Keller',
       autoArchiveLabel: 'Abgeschlossene Drucke automatisch archivieren',
       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',
       fromPrinterSettings: 'Aus Druckereinstellungen',
       modelOptional: 'Modell (optional)',
       modelOptional: 'Modell (optional)',
       saveChanges: 'Änderungen speichern',
       saveChanges: 'Änderungen speichern',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       filament: '{{weight}} g',
       layer: '{{count}} Schicht',
       layer: '{{count}} Schicht',
       layers: '{{count}} Schichten',
       layers: '{{count}} Schichten',
+      wearCost: 'Druckerverschleiß',
       object: '{{count}} Objekt',
       object: '{{count}} Objekt',
       objects: '{{count}} Objekte',
       objects: '{{count}} Objekte',
       slicedFor: 'Geslict für {{model}}',
       slicedFor: 'Geslict für {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Kosten',
       cost: 'Kosten',
       energy: 'Energie',
       energy: 'Energie',
       energyCost: 'Energiekosten',
       energyCost: 'Energiekosten',
+      wearCost: 'Verschleißkosten',
       completedAt: 'Beendet',
       completedAt: 'Beendet',
       columns: 'Spalten',
       columns: 'Spalten',
       sortBy: 'Nach {{column}} sortieren',
       sortBy: 'Nach {{column}} sortieren',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Gesamtkosten',
     totalCost: 'Gesamtkosten',
     energyUsed: 'Energieverbrauch',
     energyUsed: 'Energieverbrauch',
     energyCost: 'Energiekosten',
     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.',
     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',
     averagePrintTime: 'Durchschnittliche Druckzeit',
     printsPerDay: 'Drucke pro Tag',
     printsPerDay: 'Drucke pro Tag',
@@ -4528,6 +4533,7 @@ export default {
       totalCost: 'Erwartete Gesamtkosten',
       totalCost: 'Erwartete Gesamtkosten',
       total: 'Gesamt',
       total: 'Gesamt',
       includesBom: 'inkl. Stückliste',
       includesBom: 'inkl. Stückliste',
+      wear: 'Druckerverschleiß',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Verbleibend',
       remaining: 'Verbleibend',
     },
     },

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

@@ -630,6 +630,8 @@ export default {
       locationGroup: 'Location / Group (optional)',
       locationGroup: 'Location / Group (optional)',
       locationPlaceholder: 'e.g., Workshop, Office, Basement',
       locationPlaceholder: 'e.g., Workshop, Office, Basement',
       autoArchiveLabel: 'Auto-archive completed prints',
       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',
       fromPrinterSettings: 'From printer settings',
       modelOptional: 'Model (optional)',
       modelOptional: 'Model (optional)',
       saveChanges: 'Save Changes',
       saveChanges: 'Save Changes',
@@ -1167,6 +1169,7 @@ export default {
       filament: '{{weight}}g',
       filament: '{{weight}}g',
       layer: '{{count}} layer',
       layer: '{{count}} layer',
       layers: '{{count}} layers',
       layers: '{{count}} layers',
+      wearCost: 'Printer wear',
       object: '{{count}} object',
       object: '{{count}} object',
       objects: '{{count}} objects',
       objects: '{{count}} objects',
       slicedFor: 'Sliced for {{model}}',
       slicedFor: 'Sliced for {{model}}',
@@ -1271,6 +1274,7 @@ export default {
       cost: 'Cost',
       cost: 'Cost',
       energy: 'Energy',
       energy: 'Energy',
       energyCost: 'Energy Cost',
       energyCost: 'Energy Cost',
+      wearCost: 'Wear Cost',
       completedAt: 'Finished',
       completedAt: 'Finished',
       columns: 'Columns',
       columns: 'Columns',
       sortBy: 'Sort by {{column}}',
       sortBy: 'Sort by {{column}}',
@@ -1741,6 +1745,7 @@ export default {
     totalCost: 'Total Cost',
     totalCost: 'Total Cost',
     energyUsed: 'Energy Used',
     energyUsed: 'Energy Used',
     energyCost: 'Energy Cost',
     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.',
     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',
     averagePrintTime: 'Average Print Time',
     printsPerDay: 'Prints per Day',
     printsPerDay: 'Prints per Day',
@@ -4560,6 +4565,7 @@ export default {
       totalCost: 'Estimated Cost',
       totalCost: 'Estimated Cost',
       total: 'Total',
       total: 'Total',
       includesBom: 'incl. BOM',
       includesBom: 'incl. BOM',
+      wear: 'Printer Wear',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Remaining',
       remaining: 'Remaining',
     },
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Ubicación / Grupo (opcional)',
       locationGroup: 'Ubicación / Grupo (opcional)',
       locationPlaceholder: 'p. ej., Taller, Oficina, Sótano',
       locationPlaceholder: 'p. ej., Taller, Oficina, Sótano',
       autoArchiveLabel: 'Archivar automáticamente las impresiones completadas',
       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',
       fromPrinterSettings: 'Desde los ajustes de la impresora',
       modelOptional: 'Modelo (opcional)',
       modelOptional: 'Modelo (opcional)',
       saveChanges: 'Guardar cambios',
       saveChanges: 'Guardar cambios',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       filament: '{{weight}} g',
       layer: '{{count}} capa',
       layer: '{{count}} capa',
       layers: '{{count}} capas',
       layers: '{{count}} capas',
+      wearCost: 'Desgaste de la impresora',
       object: '{{count}} objeto',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       objects: '{{count}} objetos',
       slicedFor: 'Laminado para {{model}}',
       slicedFor: 'Laminado para {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Coste',
       cost: 'Coste',
       energy: 'Energía',
       energy: 'Energía',
       energyCost: 'Coste de energía',
       energyCost: 'Coste de energía',
+      wearCost: 'Coste de desgaste',
       completedAt: 'Finalizado',
       completedAt: 'Finalizado',
       columns: 'Columnas',
       columns: 'Columnas',
       sortBy: 'Ordenar por {{column}}',
       sortBy: 'Ordenar por {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Coste total',
     totalCost: 'Coste total',
     energyUsed: 'Energía usada',
     energyUsed: 'Energía usada',
     energyCost: 'Coste de la energía',
     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.',
     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',
     averagePrintTime: 'Tiempo medio de impresión',
     printsPerDay: 'Impresiones por día',
     printsPerDay: 'Impresiones por día',
@@ -4530,6 +4535,7 @@ export default {
       totalCost: 'Coste total',
       totalCost: 'Coste total',
       total: 'Total',
       total: 'Total',
       includesBom: 'incl. lista de materiales',
       includesBom: 'incl. lista de materiales',
+      wear: 'Desgaste de la impresora',
       budget: 'Presupuesto',
       budget: 'Presupuesto',
       remaining: 'Restante',
       remaining: 'Restante',
     },
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Emplacement / Groupe (optionnel)',
       locationGroup: 'Emplacement / Groupe (optionnel)',
       locationPlaceholder: 'ex: Atelier, Bureau',
       locationPlaceholder: 'ex: Atelier, Bureau',
       autoArchiveLabel: 'Auto-archiver les impressions terminées',
       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',
       fromPrinterSettings: 'Depuis les paramètres imprimante',
       modelOptional: 'Modèle (optionnel)',
       modelOptional: 'Modèle (optionnel)',
       saveChanges: 'Enregistrer les modifications',
       saveChanges: 'Enregistrer les modifications',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       filament: '{{weight}} g',
       layer: '{{count}} couche',
       layer: '{{count}} couche',
       layers: '{{count}} couches',
       layers: '{{count}} couches',
+      wearCost: 'Usure de l\'imprimante',
       object: '{{count}} objet',
       object: '{{count}} objet',
       objects: '{{count}} objets',
       objects: '{{count}} objets',
       slicedFor: 'Découpé pour {{model}}',
       slicedFor: 'Découpé pour {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Coût',
       cost: 'Coût',
       energy: 'Énergie',
       energy: 'Énergie',
       energyCost: 'Coût énergétique',
       energyCost: 'Coût énergétique',
+      wearCost: 'Coût d\'usure',
       completedAt: 'Terminé',
       completedAt: 'Terminé',
       columns: 'Colonnes',
       columns: 'Colonnes',
       sortBy: 'Trier par {{column}}',
       sortBy: 'Trier par {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Coût total',
     totalCost: 'Coût total',
     energyUsed: 'Énergie consommée',
     energyUsed: 'Énergie consommée',
     energyCost: 'Coût énergie',
     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.',
     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',
     averagePrintTime: 'Temps moyen par impression',
     printsPerDay: 'Impressions par jour',
     printsPerDay: 'Impressions par jour',
@@ -4516,6 +4521,7 @@ export default {
       totalCost: 'Coût Total',
       totalCost: 'Coût Total',
       total: 'Total',
       total: 'Total',
       includesBom: 'BOM incluse',
       includesBom: 'BOM incluse',
+      wear: 'Usure de l\'imprimante',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Restant',
       remaining: 'Restant',
     },
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Posizione / Gruppo (opzionale)',
       locationGroup: 'Posizione / Gruppo (opzionale)',
       locationPlaceholder: 'es. Officina, Ufficio, Cantina',
       locationPlaceholder: 'es. Officina, Ufficio, Cantina',
       autoArchiveLabel: 'Archivia automaticamente stampe completate',
       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',
       fromPrinterSettings: 'Dalle impostazioni della stampante',
       modelOptional: 'Modello (opzionale)',
       modelOptional: 'Modello (opzionale)',
       saveChanges: 'Salva modifiche',
       saveChanges: 'Salva modifiche',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       filament: '{{weight}} g',
       layer: '{{count}} strato',
       layer: '{{count}} strato',
       layers: '{{count}} strati',
       layers: '{{count}} strati',
+      wearCost: 'Usura della stampante',
       object: '{{count}} oggetto',
       object: '{{count}} oggetto',
       objects: '{{count}} oggetti',
       objects: '{{count}} oggetti',
       slicedFor: 'Sliced per {{model}}',
       slicedFor: 'Sliced per {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Costo',
       cost: 'Costo',
       energy: 'Energia',
       energy: 'Energia',
       energyCost: 'Costo energia',
       energyCost: 'Costo energia',
+      wearCost: 'Costo di usura',
       completedAt: 'Completato',
       completedAt: 'Completato',
       columns: 'Colonne',
       columns: 'Colonne',
       sortBy: 'Ordina per {{column}}',
       sortBy: 'Ordina per {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Costo totale',
     totalCost: 'Costo totale',
     energyUsed: 'Energia usata',
     energyUsed: 'Energia usata',
     energyCost: 'Costo energia',
     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.',
     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',
     averagePrintTime: 'Tempo medio di stampa',
     printsPerDay: 'Stampe al giorno',
     printsPerDay: 'Stampe al giorno',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: 'Costo totale',
       totalCost: 'Costo totale',
       total: 'Totale',
       total: 'Totale',
       includesBom: 'incl. distinta materiali',
       includesBom: 'incl. distinta materiali',
+      wear: 'Usura della stampante',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Rimanente',
       remaining: 'Rimanente',
     },
     },

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

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

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

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

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

@@ -628,6 +628,8 @@ export default {
       locationGroup: 'Locatie / groep (optioneel)',
       locationGroup: 'Locatie / groep (optioneel)',
       locationPlaceholder: 'bijv. Werkplaats, kantoor, kelder',
       locationPlaceholder: 'bijv. Werkplaats, kantoor, kelder',
       autoArchiveLabel: 'Voltooide afdrukken automatisch archiveren',
       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',
       fromPrinterSettings: 'Uit printerinstellingen',
       modelOptional: 'Model (optioneel)',
       modelOptional: 'Model (optioneel)',
       saveChanges: 'Wijzigingen opslaan',
       saveChanges: 'Wijzigingen opslaan',
@@ -1165,6 +1167,7 @@ export default {
       filament: '{{weight}}g',
       filament: '{{weight}}g',
       layer: '{{count}} laag',
       layer: '{{count}} laag',
       layers: '{{count}} lagen',
       layers: '{{count}} lagen',
+      wearCost: 'Printerslijtage',
       object: '{{count}} object',
       object: '{{count}} object',
       objects: '{{count}} objecten',
       objects: '{{count}} objecten',
       slicedFor: 'Gesliced voor {{model}}',
       slicedFor: 'Gesliced voor {{model}}',
@@ -1269,6 +1272,7 @@ export default {
       cost: 'Kosten',
       cost: 'Kosten',
       energy: 'Energie',
       energy: 'Energie',
       energyCost: 'Energiekosten',
       energyCost: 'Energiekosten',
+      wearCost: 'Slijtagekosten',
       completedAt: 'Voltooid',
       completedAt: 'Voltooid',
       columns: 'Kolommen',
       columns: 'Kolommen',
       sortBy: 'Sorteren op {{column}}',
       sortBy: 'Sorteren op {{column}}',
@@ -1739,6 +1743,7 @@ export default {
     totalCost: 'Totale kosten',
     totalCost: 'Totale kosten',
     energyUsed: 'Energieverbruik',
     energyUsed: 'Energieverbruik',
     energyCost: 'Energiekosten',
     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.',
     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',
     averagePrintTime: 'Gemiddelde afdruktijd',
     printsPerDay: 'Afdrukken per dag',
     printsPerDay: 'Afdrukken per dag',
@@ -4558,6 +4563,7 @@ export default {
       totalCost: 'Geschatte kosten',
       totalCost: 'Geschatte kosten',
       total: 'Totaal',
       total: 'Totaal',
       includesBom: 'incl. materiaallijst',
       includesBom: 'incl. materiaallijst',
+      wear: 'Printerslijtage',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Resterend',
       remaining: 'Resterend',
     },
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Localização / Grupo (opcional)',
       locationGroup: 'Localização / Grupo (opcional)',
       locationPlaceholder: 'ex.: Oficina, Escritório, Porão',
       locationPlaceholder: 'ex.: Oficina, Escritório, Porão',
       autoArchiveLabel: 'Arquivar automaticamente impressões concluídas',
       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',
       fromPrinterSettings: 'A partir das configurações da impressora',
       modelOptional: 'Modelo (opcional)',
       modelOptional: 'Modelo (opcional)',
       saveChanges: 'Salvar alterações',
       saveChanges: 'Salvar alterações',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}} g',
       filament: '{{weight}} g',
       layer: '{{count}} camada',
       layer: '{{count}} camada',
       layers: '{{count}} camadas',
       layers: '{{count}} camadas',
+      wearCost: 'Desgaste da impressora',
       object: '{{count}} objeto',
       object: '{{count}} objeto',
       objects: '{{count}} objetos',
       objects: '{{count}} objetos',
       slicedFor: 'Fatiado para {{model}}',
       slicedFor: 'Fatiado para {{model}}',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Custo',
       cost: 'Custo',
       energy: 'Energia',
       energy: 'Energia',
       energyCost: 'Custo de energia',
       energyCost: 'Custo de energia',
+      wearCost: 'Custo de desgaste',
       completedAt: 'Concluído',
       completedAt: 'Concluído',
       columns: 'Colunas',
       columns: 'Colunas',
       sortBy: 'Ordenar por {{column}}',
       sortBy: 'Ordenar por {{column}}',
@@ -1722,6 +1726,7 @@ export default {
     totalCost: 'Custo Total',
     totalCost: 'Custo Total',
     energyUsed: 'Energia Utilizada',
     energyUsed: 'Energia Utilizada',
     energyCost: 'Custo da Energia',
     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.',
     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',
     averagePrintTime: 'Tempo Médio de Impressão',
     printsPerDay: 'Impressões por Dia',
     printsPerDay: 'Impressões por Dia',
@@ -4515,6 +4520,7 @@ export default {
       totalCost: 'Custo Total',
       totalCost: 'Custo Total',
       total: 'Total',
       total: 'Total',
       includesBom: 'incl. lista de materiais',
       includesBom: 'incl. lista de materiais',
+      wear: 'Desgaste da impressora',
       budget: 'Orçamento',
       budget: 'Orçamento',
       remaining: 'Restante',
       remaining: 'Restante',
     },
     },

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

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

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

@@ -628,6 +628,8 @@ export default {
       locationGroup: 'Plats / grupp (valfritt)',
       locationGroup: 'Plats / grupp (valfritt)',
       locationPlaceholder: 't.ex. Verkstad, Kontor, Källare',
       locationPlaceholder: 't.ex. Verkstad, Kontor, Källare',
       autoArchiveLabel: 'Auto-arkivera färdiga utskrifter',
       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',
       fromPrinterSettings: 'Från skrivarens inställningar',
       modelOptional: 'Modell (valfritt)',
       modelOptional: 'Modell (valfritt)',
       saveChanges: 'Spara ändringar',
       saveChanges: 'Spara ändringar',
@@ -1165,6 +1167,7 @@ export default {
       filament: '{{weight}}g',
       filament: '{{weight}}g',
       layer: '{{count}} skikt',
       layer: '{{count}} skikt',
       layers: '{{count}} skikt',
       layers: '{{count}} skikt',
+      wearCost: 'Skrivarslitage',
       object: '{{count}} objekt',
       object: '{{count}} objekt',
       objects: '{{count}} objekt',
       objects: '{{count}} objekt',
       slicedFor: 'Slicad för {{model}}',
       slicedFor: 'Slicad för {{model}}',
@@ -1269,6 +1272,7 @@ export default {
       cost: 'Kostnad',
       cost: 'Kostnad',
       energy: 'Energi',
       energy: 'Energi',
       energyCost: 'Energikostnad',
       energyCost: 'Energikostnad',
+      wearCost: 'Slitagekostnad',
       completedAt: 'Slutförd',
       completedAt: 'Slutförd',
       columns: 'Kolumner',
       columns: 'Kolumner',
       sortBy: 'Sortera efter {{column}}',
       sortBy: 'Sortera efter {{column}}',
@@ -1739,6 +1743,7 @@ export default {
     totalCost: 'Total kostnad',
     totalCost: 'Total kostnad',
     energyUsed: 'Energi använt',
     energyUsed: 'Energi använt',
     energyCost: 'Energikostnad',
     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.',
     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',
     averagePrintTime: 'Genomsnittlig utskriftstid',
     printsPerDay: 'Utskrifter per dag',
     printsPerDay: 'Utskrifter per dag',
@@ -4557,6 +4562,7 @@ errors: {
       totalCost: 'Uppskattad kostnad',
       totalCost: 'Uppskattad kostnad',
       total: 'Totalt',
       total: 'Totalt',
       includesBom: 'inkl. BOM',
       includesBom: 'inkl. BOM',
+      wear: 'Skrivarslitage',
       budget: 'Budget',
       budget: 'Budget',
       remaining: 'Återstående',
       remaining: 'Återstående',
     },
     },

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

@@ -625,6 +625,8 @@ export default {
       locationGroup: 'Konum / Grup (isteğe bağlı)',
       locationGroup: 'Konum / Grup (isteğe bağlı)',
       locationPlaceholder: 'örn., Atölye, Ofis, Bodrum',
       locationPlaceholder: 'örn., Atölye, Ofis, Bodrum',
       autoArchiveLabel: 'Tamamlanan baskıları otomatik arşivle',
       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',
       fromPrinterSettings: 'Yazıcı ayarlarından',
       modelOptional: 'Model (isteğe bağlı)',
       modelOptional: 'Model (isteğe bağlı)',
       saveChanges: 'Değişiklikleri Kaydet',
       saveChanges: 'Değişiklikleri Kaydet',
@@ -1160,6 +1162,7 @@ export default {
       filament: '{{weight}}g',
       filament: '{{weight}}g',
       layer: '{{count}} katman',
       layer: '{{count}} katman',
       layers: '{{count}} katman',
       layers: '{{count}} katman',
+      wearCost: 'Yazıcı aşınması',
       object: '{{count}} nesne',
       object: '{{count}} nesne',
       objects: '{{count}} nesne',
       objects: '{{count}} nesne',
       slicedFor: '{{model}} için dilimlendi',
       slicedFor: '{{model}} için dilimlendi',
@@ -1264,6 +1267,7 @@ export default {
       cost: 'Maliyet',
       cost: 'Maliyet',
       energy: 'Enerji',
       energy: 'Enerji',
       energyCost: 'Enerji maliyeti',
       energyCost: 'Enerji maliyeti',
+      wearCost: 'Aşınma maliyeti',
       completedAt: 'Bitti',
       completedAt: 'Bitti',
       columns: 'Sütunlar',
       columns: 'Sütunlar',
       sortBy: '{{column}} ölçütüne göre sırala',
       sortBy: '{{column}} ölçütüne göre sırala',
@@ -1723,6 +1727,7 @@ export default {
     totalCost: 'Toplam Maliyet',
     totalCost: 'Toplam Maliyet',
     energyUsed: 'Kullanılan Enerji',
     energyUsed: 'Kullanılan Enerji',
     energyCost: 'Enerji Maliyeti',
     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.',
     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',
     averagePrintTime: 'Ortalama Baskı Süresi',
     printsPerDay: 'Günlük Baskı',
     printsPerDay: 'Günlük Baskı',
@@ -4516,6 +4521,7 @@ export default {
       totalCost: 'Toplam Maliyet',
       totalCost: 'Toplam Maliyet',
       total: 'Toplam',
       total: 'Toplam',
       includesBom: 'BOM dahil',
       includesBom: 'BOM dahil',
+      wear: 'Yazıcı aşınması',
       budget: 'Bütçe',
       budget: 'Bütçe',
       remaining: 'Kalan',
       remaining: 'Kalan',
     },
     },

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

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

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

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

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

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

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

@@ -54,6 +54,7 @@ import {
   Play,
   Play,
   ClipboardList,
   ClipboardList,
   Zap,
   Zap,
+  Wrench,
   Cog,
   Cog,
   Archive as ArchiveIcon,
   Archive as ArchiveIcon,
   History,
   History,
@@ -136,6 +137,7 @@ const LOG_COLUMN_LABEL_KEYS: Record<string, string> = {
   cost: 'archives.log.cost',
   cost: 'archives.log.cost',
   energy: 'archives.log.energy',
   energy: 'archives.log.energy',
   energy_cost: 'archives.log.energyCost',
   energy_cost: 'archives.log.energyCost',
+  wear_cost: 'archives.log.wearCost',
 };
 };
 
 
 // Defaults reproduce the previous seven columns in the same order, plus the
 // 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: 'cost', visible: false },
   { id: 'energy', visible: false },
   { id: 'energy', visible: false },
   { id: 'energy_cost', visible: false },
   { id: 'energy_cost', visible: false },
+  { id: 'wear_cost', visible: false },
 ];
 ];
 
 
 /** Stored config merged with the defaults: unknown ids (removed columns) are
 /** Stored config merged with the defaults: unknown ids (removed columns) are
@@ -1275,7 +1278,7 @@ function ArchiveCard({
               {archive.filament_used_grams.toFixed(1)}g
               {archive.filament_used_grams.toFixed(1)}g
             </div>
             </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">
             <div className="flex items-center gap-3 text-bambu-gray">
               {archive.cost != null && (
               {archive.cost != null && (
                 <div className="flex items-center gap-1.5">
                 <div className="flex items-center gap-1.5">
@@ -1289,6 +1292,12 @@ function ArchiveCard({
                     {currency}{archive.energy_cost.toFixed(2)}
                     {currency}{archive.energy_cost.toFixed(2)}
                   </div>
                   </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>
             </div>
           )}
           )}
           {(archive.layer_height || archive.total_layers) && (
           {(archive.layer_height || archive.total_layers) && (
@@ -3207,7 +3216,7 @@ export function ArchivesPage() {
   const handleLogSort = useCallback((colId: string) => {
   const handleLogSort = useCallback((colId: string) => {
     if (!SORTABLE_LOG_COLUMNS.has(colId)) return;
     if (!SORTABLE_LOG_COLUMNS.has(colId)) return;
     setLogSort((prev) => {
     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 =
       const next: LogSortState =
         prev.column === colId
         prev.column === colId
           ? { column: colId, direction: prev.direction === 'asc' ? 'desc' : 'asc' }
           ? { 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
   // Columns that hold a number and read better right-aligned. Kept as data so
   // the header and the body can't drift apart.
   // the header and the body can't drift apart.
   const LOG_NUMERIC_COLUMNS = useMemo(
   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)}` : '—'}
               {entry.energy_cost != null ? `${currency}${entry.energy_cost.toFixed(2)}` : '—'}
             </span>
             </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:
         default:
           return null;
           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 { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { NumberInput } from '../components/NumberInput';
 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
 // 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
 // saved filter cannot be validated against a set the dropdown has since moved
@@ -8467,7 +8468,10 @@ function EditPrinterModal({
     location: printer.location || '',
     location: printer.location || '',
     auto_archive: printer.auto_archive,
     auto_archive: printer.auto_archive,
     is_active: printer.is_active,
     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
   // 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.
   // printer. Non-admins can't read groups; the server refuses their move instead.
@@ -8521,6 +8525,8 @@ function EditPrinterModal({
       location: form.location.trim() || null,
       location: form.location.trim() || null,
       auto_archive: form.auto_archive,
       auto_archive: form.auto_archive,
       is_active: form.is_active,
       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
     // Only include access_code if it was changed
     if (form.access_code) {
     if (form.access_code) {
@@ -8668,6 +8674,23 @@ function EditPrinterModal({
                 {t('printers.modal.autoArchiveLabel')}
                 {t('printers.modal.autoArchiveLabel')}
               </label>
               </label>
             </div>
             </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
             {/* Maintenance Mode toggle (#1476) — checkbox is the inverse of
                 is_active because the user-facing concept is "is this printer
                 is_active because the user-facing concept is "is this printer
                 in maintenance" not "is it active". */}
                 in maintenance" not "is it active". */}

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

@@ -714,6 +714,7 @@ export function ProjectDetailPage() {
                 const rollupCost =
                 const rollupCost =
                   project.rollup_stats.estimated_cost +
                   project.rollup_stats.estimated_cost +
                   project.rollup_stats.total_energy_cost +
                   project.rollup_stats.total_energy_cost +
+                  (project.rollup_stats.total_wear_cost ?? 0) +
                   project.rollup_stats.bom_cost;
                   project.rollup_stats.bom_cost;
                 if (rollupCost <= 0) return null;
                 if (rollupCost <= 0) return null;
                 return (
                 return (
@@ -754,7 +755,7 @@ export function ProjectDetailPage() {
 
 
       {/* Cost tracking */}
       {/* Cost tracking */}
       {stats && (() => {
       {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);
         return (stats.estimated_cost > 0 || totalCost > 0 || project.budget !== null);
       })() && (
       })() && (
         <Card>
         <Card>
@@ -782,8 +783,16 @@ export function ProjectDetailPage() {
                   </p>
                   </p>
                 </div>
                 </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;
                 if (totalCost <= 0) return null;
                 return (
                 return (
                   <div>
                   <div>
@@ -798,7 +807,7 @@ export function ProjectDetailPage() {
                 );
                 );
               })()}
               })()}
               {project.budget !== null && (() => {
               {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;
                 const remaining = project.budget - totalCost;
                 return (
                 return (
                   <div>
                   <div>

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

@@ -10,6 +10,7 @@ import {
   DollarSign,
   DollarSign,
   Target,
   Target,
   Zap,
   Zap,
+  Wrench,
   AlertTriangle,
   AlertTriangle,
   TrendingDown,
   TrendingDown,
   FileSpreadsheet,
   FileSpreadsheet,
@@ -133,6 +134,7 @@ function QuickStatsWidget({
     total_cost: number;
     total_cost: number;
     total_energy_kwh: number;
     total_energy_kwh: number;
     total_energy_cost: number;
     total_energy_cost: number;
+    total_wear_cost?: number;
     energy_data_warming_up?: boolean;
     energy_data_warming_up?: boolean;
   } | undefined;
   } | undefined;
   currency: string;
   currency: string;
@@ -163,6 +165,18 @@ function QuickStatsWidget({
       warning: warmingUp,
       warning: warmingUp,
       tooltip: warmingUpTooltip,
       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 (
   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) {
     if (costliest.archive) {
       result.push({
       result.push({
         icon: DollarSign, iconColor: 'text-green-600 dark:text-green-400', label: t('stats.mostExpensivePrint'),
         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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Bb4TsUSQ.css">
   </head>
   </head>

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