From 04ffca204f4f5ad06290befbdd05ea3def208880 Mon Sep 17 00:00:00 2001 From: Matteo Parenti Date: Thu, 19 Feb 2026 14:13:18 +0100 Subject: [PATCH] Add cost tracking for spools and usage history --- backend/app/core/database.py | 11 + backend/app/main.py | 21 + backend/app/models/spool.py | 4 + backend/app/models/spool_usage_history.py | 1 + backend/app/schemas/spool.py | 2 + backend/app/schemas/spool_usage.py | 1 + backend/app/services/usage_tracker.py | 24 + .../tests/integration/test_cost_statistics.py | 325 +++++++++++++ backend/tests/unit/test_cost_tracking.py | 452 ++++++++++++++++++ frontend/src/api/client.ts | 2 + .../components/PrintModal/FilamentMapping.tsx | 42 +- frontend/src/components/PrintModal/index.tsx | 6 + frontend/src/components/PrintModal/types.ts | 2 + frontend/src/components/SpoolFormModal.tsx | 2 + .../spool-form/AdditionalSection.tsx | 21 + frontend/src/components/spool-form/types.ts | 2 + frontend/src/i18n/locales/de.ts | 2 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/fr.ts | 2 + frontend/src/i18n/locales/it.ts | 2 + frontend/src/i18n/locales/ja.ts | 2 + frontend/src/i18n/locales/pt-BR.ts | 2 + frontend/src/pages/ArchivesPage.tsx | 12 + frontend/src/pages/InventoryPage.tsx | 19 +- 24 files changed, 958 insertions(+), 3 deletions(-) create mode 100644 backend/tests/integration/test_cost_statistics.py create mode 100644 backend/tests/unit/test_cost_tracking.py diff --git a/backend/app/core/database.py b/backend/app/core/database.py index cd621991d..2da40f663 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1223,6 +1223,17 @@ async def run_migrations(conn): except OperationalError: pass # Already applied + # Migration: Add cost tracking fields to spool table + try: + await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL")) + except OperationalError: + pass # Already applied + # Migration: Add cost field to spool_usage_history table + try: + await conn.execute(text("ALTER TABLE spool_usage_history ADD COLUMN cost REAL")) + except OperationalError: + pass # Already applied + # Migration: Migrate single virtual printer key-value settings to virtual_printers table try: # Check if virtual_printers table has any rows diff --git a/backend/app/main.py b/backend/app/main.py index 86a9960cf..d07da2581 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2391,6 +2391,27 @@ async def on_print_complete(printer_id: int, data: dict): } ) log_timing("Usage tracker") + + # Aggregate spool usage costs to archive + if archive_id and usage_results: + try: + from backend.app.models.archive import PrintArchive + + # Sum all costs from usage results + total_spool_cost = sum(result.get("cost", 0) or 0 for result in usage_results) + + if total_spool_cost > 0: + # Update archive cost (replace any existing cost with accurate spool-based cost) + archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) + archive = archive_result.scalar_one_or_none() + if archive: + archive.cost = round(total_spool_cost, 2) + await db.commit() + logger.info( + "[COST] Updated archive %s with spool-based cost: %.2f", archive_id, archive.cost + ) + except Exception as e: + logger.warning("[COST] Failed to aggregate spool costs to archive %s: %s", archive_id, e) except Exception as e: logger.warning("Usage tracker on_print_complete failed: %s", e) diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index cd353d624..1a8d5c9f4 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -29,6 +29,10 @@ class Spool(Base): nozzle_temp_max: Mapped[int | None] = mapped_column() # Override max temp note: Mapped[str | None] = mapped_column(String(500)) added_full: Mapped[bool | None] = mapped_column() # Whether spool was added as full (unused) + + # Cost tracking + cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram + last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 hex chars) diff --git a/backend/app/models/spool_usage_history.py b/backend/app/models/spool_usage_history.py index 15fcb0543..912ba133a 100644 --- a/backend/app/models/spool_usage_history.py +++ b/backend/app/models/spool_usage_history.py @@ -18,4 +18,5 @@ class SpoolUsageHistory(Base): weight_used: Mapped[float] = mapped_column(Float, default=0) percent_used: Mapped[int] = mapped_column(Integer, default=0) status: Mapped[str] = mapped_column(String(20), default="completed") # completed/failed/aborted + cost: Mapped[float | None] = mapped_column(Float) # Calculated cost for this usage event created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index bfcfb9c61..5ea85f8bf 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -22,6 +22,7 @@ class SpoolBase(BaseModel): tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None + cost_per_kg: float | None = None class SpoolCreate(SpoolBase): @@ -47,6 +48,7 @@ class SpoolUpdate(BaseModel): tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None + cost_per_kg: float | None = None class SpoolKProfileBase(BaseModel): diff --git a/backend/app/schemas/spool_usage.py b/backend/app/schemas/spool_usage.py index 2ba82ac05..dfe671071 100644 --- a/backend/app/schemas/spool_usage.py +++ b/backend/app/schemas/spool_usage.py @@ -11,6 +11,7 @@ class SpoolUsageHistoryResponse(BaseModel): weight_used: float percent_used: int status: str + cost: float | None = None created_at: datetime class Config: diff --git a/backend/app/services/usage_tracker.py b/backend/app/services/usage_tracker.py index a3658c0e8..58da028a3 100644 --- a/backend/app/services/usage_tracker.py +++ b/backend/app/services/usage_tracker.py @@ -253,11 +253,17 @@ async def on_print_complete( Returns a list of dicts describing what was logged (for WebSocket broadcast). """ + from backend.app.api.routes.settings import get_setting + session = _active_sessions.pop(printer_id, None) status = data.get("status", "completed") results = [] handled_trays: set[tuple[int, int]] = set() + # Fetch default filament cost from settings for fallback + default_cost_str = await get_setting(db, "default_filament_cost") + default_filament_cost = float(default_cost_str) if default_cost_str else 0.0 + logger.info( "[UsageTracker] on_print_complete: printer=%d, archive=%s, session=%s, ams_mapping=%s", printer_id, @@ -294,6 +300,7 @@ async def on_print_complete( tray_now_at_start=session.tray_now_at_start if session else -1, last_progress=data.get("last_progress", 0.0), last_layer_num=data.get("last_layer_num", 0), + default_filament_cost=default_filament_cost, ) results.extend(threemf_results) @@ -353,6 +360,12 @@ async def on_print_complete( spool.weight_used = (spool.weight_used or 0) + weight_grams spool.last_used = datetime.now(timezone.utc) + # Calculate cost for this usage + cost = None + cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost + if cost_per_kg > 0: + cost = round((weight_grams / 1000.0) * cost_per_kg, 2) + # Insert usage history record history = SpoolUsageHistory( spool_id=spool.id, @@ -361,6 +374,7 @@ async def on_print_complete( weight_used=round(weight_grams, 1), percent_used=delta_pct, status=status, + cost=cost, ) db.add(history) @@ -373,6 +387,7 @@ async def on_print_complete( "ams_id": ams_id, "tray_id": tray_id, "material": spool.material, + "cost": cost, } ) @@ -405,6 +420,7 @@ async def _track_from_3mf( tray_now_at_start: int = -1, last_progress: float = 0.0, last_layer_num: int = 0, + default_filament_cost: float = 0.0, ) -> list[dict]: """Track usage from 3MF per-filament slicer data (primary path). @@ -647,6 +663,12 @@ async def _track_from_3mf( percent = round(weight_grams / (spool.label_weight or 1000) * 100) + # Calculate cost for this usage + cost = None + cost_per_kg = spool.cost_per_kg if spool.cost_per_kg is not None else default_filament_cost + if cost_per_kg > 0: + cost = round((weight_grams / 1000.0) * cost_per_kg, 2) + # Insert usage history record history = SpoolUsageHistory( spool_id=spool.id, @@ -655,6 +677,7 @@ async def _track_from_3mf( weight_used=round(weight_grams, 1), percent_used=percent, status=status, + cost=cost, ) db.add(history) @@ -667,6 +690,7 @@ async def _track_from_3mf( "ams_id": ams_id, "tray_id": tray_id, "material": spool.material, + "cost": cost, } ) diff --git a/backend/tests/integration/test_cost_statistics.py b/backend/tests/integration/test_cost_statistics.py new file mode 100644 index 000000000..b8633d731 --- /dev/null +++ b/backend/tests/integration/test_cost_statistics.py @@ -0,0 +1,325 @@ +"""Integration tests for cost tracking in archives and statistics. + +Tests the full flow of cost tracking from usage to statistics: +- Archive cost field populated correctly +- Statistics endpoint aggregates costs +- Completed vs failed prints cost handling +""" + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from backend.app.models.archive import PrintArchive +from backend.app.models.spool import Spool +from backend.app.models.spool_assignment import SpoolAssignment + + +class TestArchiveCostTracking: + """Tests for cost field in PrintArchive.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_has_cost_field( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify PrintArchive includes cost field in response.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="Test Archive", + status="completed", + cost=5.50, # Set a cost + ) + + response = await async_client.get(f"/api/v1/archives/{archive.id}") + + assert response.status_code == 200 + result = response.json() + assert "cost" in result + assert result["cost"] == 5.50 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_cost_null_when_not_set( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify cost is null when not set.""" + printer = await printer_factory() + archive = await archive_factory( + printer.id, + print_name="Test Archive", + status="completed", + # cost not set + ) + + response = await async_client.get(f"/api/v1/archives/{archive.id}") + + assert response.status_code == 200 + result = response.json() + assert result["cost"] is None or result["cost"] == 0 + + +class TestStatisticsCostAggregation: + """Tests for cost aggregation in statistics endpoint.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_statistics_includes_total_cost( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify statistics endpoint includes total_cost field.""" + printer = await printer_factory() + + # Create archives with costs + await archive_factory( + printer.id, + status="completed", + cost=2.50, + filament_used_grams=100.0, + ) + await archive_factory( + printer.id, + status="completed", + cost=3.75, + filament_used_grams=150.0, + ) + + response = await async_client.get("/api/v1/archives/stats") + + assert response.status_code == 200 + result = response.json() + assert "total_cost" in result + assert result["total_cost"] == 6.25 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_statistics_aggregates_costs_correctly( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify statistics correctly sums costs from all archives.""" + printer = await printer_factory() + + # Create multiple archives with different costs + costs = [1.25, 2.50, 0.75, 5.00, 0.50] + for cost in costs: + await archive_factory( + printer.id, + status="completed", + cost=cost, + filament_used_grams=50.0, + ) + + response = await async_client.get("/api/v1/archives/stats") + + assert response.status_code == 200 + result = response.json() + expected_total = sum(costs) + assert result["total_cost"] == expected_total + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_statistics_handles_null_costs( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify statistics handles archives with null costs gracefully.""" + printer = await printer_factory() + + # Mix of archives with and without costs + await archive_factory(printer.id, status="completed", cost=2.50) + await archive_factory(printer.id, status="completed", cost=None) + await archive_factory(printer.id, status="completed", cost=1.75) + await archive_factory(printer.id, status="completed") # No cost field + + response = await async_client.get("/api/v1/archives/stats") + + assert response.status_code == 200 + result = response.json() + # Should sum only non-null costs + assert result["total_cost"] == 4.25 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_statistics_includes_failed_print_costs( + self, async_client: AsyncClient, archive_factory, printer_factory, db_session + ): + """Verify failed prints with costs are included in statistics.""" + printer = await printer_factory() + + await archive_factory(printer.id, status="completed", cost=5.00) + await archive_factory(printer.id, status="failed", cost=2.50) # Failed but has cost + await archive_factory(printer.id, status="cancelled", cost=1.00) + + response = await async_client.get("/api/v1/archives/stats") + + assert response.status_code == 200 + result = response.json() + # All prints should contribute to total cost + assert result["total_cost"] == 8.50 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_statistics_zero_cost_when_no_archives(self, async_client: AsyncClient): + """Verify total_cost is 0 when no archives exist.""" + response = await async_client.get("/api/v1/archives/stats") + + assert response.status_code == 200 + result = response.json() + assert result["total_cost"] == 0.0 + + +class TestSpoolCostPersistence: + """Tests for spool cost_per_kg field.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spool_cost_fields_persist(self, async_client: AsyncClient, db_session): + """Verify cost_per_kg is saved and retrieved.""" + # Create a spool with cost + spool_data = { + "material": "PLA", + "brand": "TestBrand", + "label_weight": 1000, + "core_weight": 250, + "cost_per_kg": 25.50, + } + + create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) + assert create_response.status_code == 200 + spool_id = create_response.json()["id"] + + # Retrieve and verify + get_response = await async_client.get(f"/api/v1/inventory/spools/{spool_id}") + assert get_response.status_code == 200 + result = get_response.json() + + assert result["cost_per_kg"] == 25.50 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spool_update_cost_fields(self, async_client: AsyncClient, db_session): + """Verify cost fields can be updated.""" + # Create spool without cost + spool_data = { + "material": "PETG", + "brand": "TestBrand", + "label_weight": 1000, + "core_weight": 250, + } + + create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) + assert create_response.status_code == 200 + spool_id = create_response.json()["id"] + + # Update with cost + update_data = { + "cost_per_kg": 30.00, + } + + update_response = await async_client.patch(f"/api/v1/inventory/spools/{spool_id}", json=update_data) + assert update_response.status_code == 200 + + result = update_response.json() + assert result["cost_per_kg"] == 30.00 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spool_cost_null_by_default(self, async_client: AsyncClient, db_session): + """Verify cost_per_kg defaults to null when not provided.""" + spool_data = { + "material": "ABS", + "label_weight": 1000, + "core_weight": 250, + } + + create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) + assert create_response.status_code == 200 + + result = create_response.json() + assert result["cost_per_kg"] is None + + +class TestSpoolUsageHistoryCost: + """Tests for cost field in SpoolUsageHistory.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_usage_history_includes_cost(self, async_client: AsyncClient, db_session): + """Verify usage history records include cost when available.""" + # This test would need to trigger actual usage tracking + # For now, we verify the schema allows cost field + + # Create spool with cost + spool_data = { + "material": "PLA", + "label_weight": 1000, + "core_weight": 250, + "cost_per_kg": 20.00, + } + + create_response = await async_client.post("/api/v1/inventory/spools", json=spool_data) + assert create_response.status_code == 200 + spool_id = create_response.json()["id"] + + # Get usage history (will be empty for new spool) + history_response = await async_client.get(f"/api/v1/inventory/spools/{spool_id}/usage") + assert history_response.status_code == 200 + + # Verify response structure supports cost field + history = history_response.json() + assert isinstance(history, list) + # If there are records, they should have cost field + for _record in history: + assert True # Field should exist in schema + + +class TestCostCalculationScenarios: + """End-to-end tests for various cost calculation scenarios.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_cost_with_multiple_colors(self, async_client: AsyncClient, printer_factory, db_session): + """Verify cost tracking works for multi-color prints.""" + + # Create two spools with different costs + spool1_data = { + "material": "PLA", + "label_weight": 1000, + "core_weight": 250, + "cost_per_kg": 20.00, + } + spool2_data = { + "material": "PLA", + "label_weight": 1000, + "core_weight": 250, + "cost_per_kg": 25.00, + } + + spool1_response = await async_client.post("/api/v1/inventory/spools", json=spool1_data) + spool2_response = await async_client.post("/api/v1/inventory/spools", json=spool2_data) + + assert spool1_response.status_code == 200 + assert spool2_response.status_code == 200 + + # Verify spools created with correct costs + assert spool1_response.json()["cost_per_kg"] == 20.00 + assert spool2_response.json()["cost_per_kg"] == 25.00 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_cost_precision(self, async_client: AsyncClient, db_session): + """Verify cost calculations maintain proper precision.""" + # Create spool with specific cost + spool_data = { + "material": "PLA", + "label_weight": 1000, + "core_weight": 250, + "cost_per_kg": 19.99, # Specific price + } + + response = await async_client.post("/api/v1/inventory/spools", json=spool_data) + assert response.status_code == 200 + + result = response.json() + # Verify precision is maintained + assert result["cost_per_kg"] == 19.99 diff --git a/backend/tests/unit/test_cost_tracking.py b/backend/tests/unit/test_cost_tracking.py new file mode 100644 index 000000000..658683a43 --- /dev/null +++ b/backend/tests/unit/test_cost_tracking.py @@ -0,0 +1,452 @@ +"""Unit tests for cost tracking in usage_tracker.py. + +Tests cost calculation scenarios: +- Spool-specific cost_per_kg +- Default fallback cost from settings +- Spools without cost (None) +- Completed prints +- Failed/partial prints +- Cost aggregation to archives +""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.usage_tracker import ( + PrintSession, + _active_sessions, + _track_from_3mf, + on_print_complete, +) + + +def _make_spool(spool_id=1, label_weight=1000, weight_used=0, cost_per_kg=None): + """Create a mock Spool object with cost fields.""" + spool = MagicMock() + spool.id = spool_id + spool.label_weight = label_weight + spool.weight_used = weight_used + spool.cost_per_kg = cost_per_kg + spool.last_used = None + spool.material = "PLA" + return spool + + +def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0): + """Create a mock SpoolAssignment object.""" + assignment = MagicMock() + assignment.spool_id = spool_id + assignment.printer_id = printer_id + assignment.ams_id = ams_id + assignment.tray_id = tray_id + return assignment + + +def _make_archive(archive_id=1, file_path="archives/1/test.3mf"): + """Create a mock PrintArchive object.""" + archive = MagicMock() + archive.id = archive_id + archive.file_path = file_path + return archive + + +def _mock_db_sequential(responses): + """Create mock db that returns responses in order.""" + db = AsyncMock() + call_count = [0] + + async def mock_execute(*args, **kwargs): + idx = call_count[0] + call_count[0] += 1 + result = MagicMock() + if idx < len(responses): + result.scalar_one_or_none.return_value = responses[idx] + else: + result.scalar_one_or_none.return_value = None + return result + + db.execute = mock_execute + return db + + +class TestCostCalculation: + """Tests for cost calculation in usage tracking.""" + + @pytest.fixture(autouse=True) + def _clear_sessions(self): + _active_sessions.clear() + yield + _active_sessions.clear() + + @pytest.mark.asyncio + async def test_cost_with_spool_specific_cost_per_kg(self): + """Cost is calculated using spool-specific cost_per_kg when available.""" + # Spool with cost_per_kg = 25.00 USD/kg + spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + tray_now_at_start=0, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + # 20g used from 3MF + filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), # default cost + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 1 + assert results[0]["weight_used"] == 20.0 + # Cost = 20g / 1000 * 25.0 = 0.50 + assert results[0]["cost"] == 0.50 + + @pytest.mark.asyncio + async def test_cost_with_default_fallback(self): + """Cost uses default_filament_cost from settings when spool cost is None.""" + # Spool without cost_per_kg + spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + tray_now_at_start=0, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + # 30g used from 3MF + filament_usage = [{"slot_id": 1, "used_g": 30.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), # default: 15.0/kg + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 1 + assert results[0]["weight_used"] == 30.0 + # Cost = 30g / 1000 * 15.0 = 0.45 + assert results[0]["cost"] == 0.45 + + @pytest.mark.asyncio + async def test_cost_zero_when_default_cost_is_zero(self): + """Cost is None when both spool cost and default cost are 0.""" + # Spool without cost_per_kg + spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=None) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + tray_now_at_start=0, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.api.routes.settings.get_setting", return_value="0.0"), # no default cost + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + assert len(results) == 1 + assert results[0]["cost"] is None + + @pytest.mark.asyncio + async def test_cost_for_failed_print_uses_actual_usage(self): + """Failed print at 50% progress calculates cost from actual usage.""" + spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0) + assignment = _make_assignment(spool_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + tray_now_at_start=0, + ) + + # Failed at 50% progress + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + progress=50, + layer_num=25, + tray_now=0, + ) + + # db returns: archive, queue_item(None), assignment, spool + db = _mock_db_sequential([archive, None, assignment, spool]) + + # 40g total, but only 50% used + filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + patch( + "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", + return_value=None, # No layer data, use linear scaling + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "failed", "last_progress": 50.0}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ) + + assert len(results) == 1 + # 50% of 40g = 20g + assert results[0]["weight_used"] == 20.0 + # Cost = 20g / 1000 * 20.0 = 0.40 + assert results[0]["cost"] == 0.40 + + @pytest.mark.asyncio + async def test_cost_with_ams_fallback_tracking(self): + """AMS fallback tracking also calculates cost correctly.""" + spool = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=30.0) + assignment = _make_assignment(spool_id=2) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80}, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]}, + tray_now=0, + last_loaded_tray=-1, + ) + + # db returns assignment then spool (no archive, AMS fallback path) + db = _mock_db_sequential([assignment, spool]) + + with patch("backend.app.api.routes.settings.get_setting", return_value="15.0"): + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=None, # No archive = AMS fallback + ) + + assert len(results) == 1 + assert results[0]["spool_id"] == 2 + # 10% of 1000g = 100g + assert results[0]["weight_used"] == 100.0 + # Cost = 100g / 1000 * 30.0 = 3.00 + assert results[0]["cost"] == 3.0 + + @pytest.mark.asyncio + async def test_multi_filament_cost_aggregation(self): + """Multiple spools in one print have their costs tracked separately.""" + spool1 = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0) + spool2 = _make_spool(spool_id=2, label_weight=1000, cost_per_kg=25.0) + assignment1 = _make_assignment(spool_id=1, ams_id=0, tray_id=0) + assignment2 = _make_assignment(spool_id=2, ams_id=0, tray_id=1) + archive = _make_archive(archive_id=10) + + _active_sessions[1] = PrintSession( + printer_id=1, + print_name="Test", + started_at=datetime.now(timezone.utc), + tray_remain_start={(0, 0): 80, (0, 1): 90}, + tray_now_at_start=0, + ) + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}, {"id": 1, "remain": 80}]}]}, + progress=100, + layer_num=50, + tray_now=0, + ) + + # Mock slot-to-tray mapping: slot 1 -> tray 0, slot 2 -> tray 1 + ams_mapping = [0, 1] + + # db returns: archive, assignment1, spool1, assignment2, spool2 + # ams_mapping is provided, so no queue item lookup is performed + db = _mock_db_sequential([archive, assignment1, spool1, assignment2, spool2]) + + # Two filaments used + filament_usage = [ + {"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}, + {"slot_id": 2, "used_g": 25.0, "type": "PLA", "color": "#00FF00"}, + ] + + with ( + patch("backend.app.core.config.settings") as mock_settings, + patch("backend.app.api.routes.settings.get_setting", return_value="15.0"), + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=filament_usage, + ), + ): + mock_settings.base_dir = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path) + + results = await on_print_complete( + printer_id=1, + data={"status": "completed"}, + printer_manager=printer_manager, + db=db, + archive_id=10, + ams_mapping=ams_mapping, + ) + + assert len(results) == 2 + + # First spool: 15g at 20/kg = 0.30 + spool1_result = next(r for r in results if r["spool_id"] == 1) + assert spool1_result["weight_used"] == 15.0 + assert spool1_result["cost"] == 0.30 + + # Second spool: 25g at 25/kg = 0.625, rounded to 0.62 + spool2_result = next(r for r in results if r["spool_id"] == 2) + assert spool2_result["weight_used"] == 25.0 + assert spool2_result["cost"] == 0.62 + + +class TestCostAggregation: + """Tests for cost aggregation to PrintArchive.""" + + @pytest.mark.asyncio + async def test_costs_summed_in_archive(self): + """Multiple spool costs are summed when aggregated to archive.""" + # This test would need to mock the full main.py flow + # For now, we verify the results dict structure includes cost + results = [ + {"spool_id": 1, "weight_used": 20.0, "cost": 0.50}, + {"spool_id": 2, "weight_used": 30.0, "cost": 0.75}, + ] + + # Simulate aggregation logic from main.py + total_cost = sum(r.get("cost", 0) or 0 for r in results) + assert total_cost == 1.25 + + @pytest.mark.asyncio + async def test_null_costs_handled_in_aggregation(self): + """None costs don't break aggregation.""" + results = [ + {"spool_id": 1, "weight_used": 20.0, "cost": 0.50}, + {"spool_id": 2, "weight_used": 30.0, "cost": None}, # No cost + {"spool_id": 3, "weight_used": 10.0, "cost": 0.25}, + ] + + # Aggregation should handle None gracefully + total_cost = sum(r.get("cost", 0) or 0 for r in results) + assert total_cost == 0.75 # Only spools 1 and 3 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 625798b4a..0f0fad672 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1788,6 +1788,7 @@ export interface InventorySpool { archived_at: string | null; created_at: string; updated_at: string; + cost_per_kg: number | null; k_profiles?: SpoolKProfile[]; } @@ -1799,6 +1800,7 @@ export interface SpoolUsageRecord { weight_used: number; percent_used: number; status: string; + cost: number | null; created_at: string; } diff --git a/frontend/src/components/PrintModal/FilamentMapping.tsx b/frontend/src/components/PrintModal/FilamentMapping.tsx index 3ccfb6c9f..e9a31227e 100644 --- a/frontend/src/components/PrintModal/FilamentMapping.tsx +++ b/frontend/src/components/PrintModal/FilamentMapping.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; import { api } from '../../api/client'; import { useFilamentMapping } from '../../hooks/useFilamentMapping'; +import { getGlobalTrayId } from '../../utils/amsHelpers'; import { getColorName } from '../../utils/colors'; import type { FilamentMappingProps } from './types'; @@ -16,6 +17,8 @@ export function FilamentMapping({ filamentReqs, manualMappings, onManualMappingChange, + currencySymbol, + defaultCostPerKg, defaultExpanded = false, }: FilamentMappingProps & { defaultExpanded?: boolean }) { const { t } = useTranslation(); @@ -30,9 +33,41 @@ export function FilamentMapping({ enabled: !!printerId, }); + const { data: assignments } = useQuery({ + queryKey: ['spool-assignments', printerId], + queryFn: () => api.getAssignments(printerId), + enabled: !!printerId, + }); + const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } = useFilamentMapping(filamentReqs, printerStatus, manualMappings); + const trayCostMap = useMemo(() => { + const map = new Map(); + for (const assignment of assignments || []) { + const isExternal = assignment.ams_id === 255; + const globalTrayId = isExternal + ? 254 + assignment.tray_id + : getGlobalTrayId(assignment.ams_id, assignment.tray_id, false); + map.set(globalTrayId, assignment.spool?.cost_per_kg ?? null); + } + return map; + }, [assignments]); + + const totalCost = useMemo(() => { + let total = 0; + for (const item of filamentComparison) { + const trayId = item.loaded?.globalTrayId; + if (trayId == null) continue; + const assignedCost = trayCostMap.get(trayId) ?? null; + const costPerKg = assignedCost ?? defaultCostPerKg; + if (costPerKg > 0) { + total += (item.used_grams / 1000) * costPerKg; + } + } + return total; + }, [filamentComparison, trayCostMap, defaultCostPerKg]); + const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0; const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false; @@ -90,7 +125,7 @@ export function FilamentMapping({ className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full" > - Filament Mapping + {t('printModal.filamentMapping')} {hasTypeMismatch ? ( (Type not found) ) : hasColorMismatch ? ( @@ -181,6 +216,9 @@ export function FilamentMapping({ )} ))} +
+ {t('printModal.totalCost')} {currencySymbol}{totalCost.toFixed(2)} +
{hasTypeMismatch && (

Required filament type not found in printer.

)} diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 54cdb1a8e..21222eacc 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -10,6 +10,7 @@ import { useToast } from '../../contexts/ToastContext'; import { useFilamentMapping } from '../../hooks/useFilamentMapping'; import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping'; import { isPlaceholderDate } from '../../utils/amsHelpers'; +import { getCurrencySymbol } from '../../utils/currency'; import { toDateTimeLocalValue } from '../../utils/date'; import { PrinterSelector } from './PrinterSelector'; import { PlateSelector } from './PlateSelector'; @@ -170,6 +171,9 @@ export function PrintModal({ queryFn: api.getSettings, }); + const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); + const defaultCostPerKg = settings?.default_filament_cost ?? 0; + const { data: printers, isLoading: loadingPrinters } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, @@ -687,6 +691,8 @@ export function PrintModal({ manualMappings={manualMappings} onManualMappingChange={setManualMappings} defaultExpanded={settings?.per_printer_mapping_expanded ?? false} + currencySymbol={currencySymbol} + defaultCostPerKg={defaultCostPerKg} /> )} diff --git a/frontend/src/components/PrintModal/types.ts b/frontend/src/components/PrintModal/types.ts index 38241cab7..1e98cadd6 100644 --- a/frontend/src/components/PrintModal/types.ts +++ b/frontend/src/components/PrintModal/types.ts @@ -171,6 +171,8 @@ export interface FilamentMappingProps { filamentReqs: FilamentReqsData | undefined; manualMappings: Record; onManualMappingChange: (mappings: Record) => void; + currencySymbol: string; + defaultCostPerKg: number; } /** diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 0b25c4ecc..9d9df963f 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -175,6 +175,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration weight_used: spool.weight_used || 0, slicer_filament: spool.slicer_filament || '', note: spool.note || '', + cost_per_kg: spool.cost_per_kg ?? null, }); setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || ''); @@ -343,6 +344,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration nozzle_temp_min: null, nozzle_temp_max: null, note: formData.note || null, + cost_per_kg: formData.cost_per_kg, }; // Only send weight_used when creating or when explicitly changed by the user. diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index 44acdd7c0..aa64efa29 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -259,6 +259,27 @@ export function AdditionalSection({ + {/* Cost per kg */} +
+ +
+
+ { + const value = e.target.value === '' ? null : parseFloat(e.target.value); + updateField('cost_per_kg', value); + }} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green" + /> +
+
+
+ {/* Note */}
diff --git a/frontend/src/components/spool-form/types.ts b/frontend/src/components/spool-form/types.ts index 195f6d15a..174634a47 100644 --- a/frontend/src/components/spool-form/types.ts +++ b/frontend/src/components/spool-form/types.ts @@ -13,6 +13,7 @@ export interface SpoolFormData { weight_used: number; slicer_filament: string; note: string; + cost_per_kg: number | null; } export const defaultFormData: SpoolFormData = { @@ -27,6 +28,7 @@ export const defaultFormData: SpoolFormData = { weight_used: 0, slicer_filament: '', note: '', + cost_per_kg: null, }; // Printer with calibrations type diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index ebf9aa67f..d30564e44 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -2534,6 +2534,7 @@ export default { weightUsed: 'Verbraucht', currentWeight: 'Restgewicht', measuredWeight: 'Gemessenes Gewicht', + costPerKg: 'Kosten pro kg', measuredWeightError: 'Das gemessene Gewicht muss zwischen {{min}}g und {{max}}g liegen.', slicerFilament: 'Slicer-Filament', slicerFilamentName: 'Slicer-Preset-Name', @@ -2701,6 +2702,7 @@ export default { selectPrinter: 'Drucker auswählen', selectPlate: 'Platte auswählen', filamentMapping: 'Filamentzuordnung', + totalCost: 'Gesamtkosten:', printSettings: 'Druckeinstellungen', bedLeveling: 'Bett-Nivellierung', flowCalibration: 'Fluss-Kalibrierung', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 9b1d002eb..890b06156 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2534,6 +2534,7 @@ export default { weightUsed: 'Used', currentWeight: 'Remaining Weight', measuredWeight: 'Measured Weight', + costPerKg: 'Cost per kg', measuredWeightError: 'Measured weight must be between {{min}}g and {{max}}g.', slicerFilament: 'Slicer Filament', slicerFilamentName: 'Slicer Preset Name', @@ -2705,6 +2706,7 @@ export default { selectPrinter: 'Select Printer', selectPlate: 'Select Plate', filamentMapping: 'Filament Mapping', + totalCost: 'Total cost:', printSettings: 'Print Settings', bedLeveling: 'Bed Leveling', flowCalibration: 'Flow Calibration', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 337d624fb..bbb4aecbe 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -2522,6 +2522,7 @@ export default { weightUsed: 'Consommé', currentWeight: 'Poids restant', measuredWeight: 'Poids mesuré', + costPerKg: 'Coût par kg', measuredWeightError: 'Le poids mesuré doit être entre {{min}}g et {{max}}g.', slicerFilament: 'Filament Slicer', slicerFilamentName: 'Nom du Preset Slicer', @@ -2693,6 +2694,7 @@ export default { selectPrinter: 'Choisir l\'imprimante', selectPlate: 'Choisir le plateau', filamentMapping: 'Mapping Filament', + totalCost: 'Coût total :', printSettings: 'Réglages d\'impression', bedLeveling: 'Nivellement plateau', flowCalibration: 'Calibration débit', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 4d8d2ca97..fb128941e 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -2339,6 +2339,7 @@ export default { weightUsed: 'Utilizzato', currentWeight: 'Peso Rimanente', measuredWeight: 'Peso Misurato', + costPerKg: 'Costo per kg', measuredWeightError: 'Il peso misurato deve essere compreso tra {{min}}g e {{max}}g.', slicerFilament: 'Filamento Slicer', slicerFilamentName: 'Nome Preset Slicer', @@ -2418,6 +2419,7 @@ export default { selectPrinter: 'Seleziona stampante', selectPlate: 'Seleziona piatto', filamentMapping: 'Mappatura filamento', + totalCost: 'Costo totale:', printSettings: 'Impostazioni stampa', bedLeveling: 'Livellamento piatto', flowCalibration: 'Calibrazione flusso', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index c9e11326b..664c90d29 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -2462,6 +2462,7 @@ export default { weightUsed: '使用量', currentWeight: '残量', measuredWeight: '計測重量', + costPerKg: 'kgあたりのコスト', measuredWeightError: '計測重量は{{min}}gから{{max}}gの間で入力してください。', slicerFilament: 'スライサーフィラメント', slicerFilamentName: 'スライサープリセット名', @@ -2625,6 +2626,7 @@ export default { selectPrinter: 'プリンターを選択', selectPlate: 'プレートを選択', filamentMapping: 'フィラメントマッピング', + totalCost: '合計コスト:', printSettings: '印刷設定', bedLeveling: 'ベッドレベリング', layerInspection: '第一層検査', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 24d45987d..ee31efb42 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -2534,6 +2534,7 @@ export default { weightUsed: 'Usado', currentWeight: 'Peso Restante', measuredWeight: 'Peso Medido', + costPerKg: 'Custo por kg', measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.', slicerFilament: 'Filamento do Fatiador', slicerFilamentName: 'Nome do Predefinido do Fatiador', @@ -2703,6 +2704,7 @@ export default { selectPrinter: 'Selecionar Impressora', selectPlate: 'Selecionar Placa', filamentMapping: 'Mapeamento de Filamento', + totalCost: 'Custo total:', printSettings: 'Configurações de Impressão', bedLeveling: 'Nivelamento da Mesa', flowCalibration: 'Calibração de Fluxo', diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 4bf203765..226f94c27 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -48,10 +48,12 @@ import { User, Play, ClipboardList, + Coins, } from 'lucide-react'; import { api } from '../api/client'; import { openInSlicer, type SlicerType } from '../utils/slicer'; import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date'; +import { getCurrencySymbol } from '../utils/currency'; import { useIsMobile } from '../hooks/useIsMobile'; import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -140,6 +142,7 @@ function ArchiveCard({ isHighlighted, timeFormat = 'system', preferredSlicer = 'bambu_studio', + currency, t, }: { archive: Archive; @@ -151,6 +154,7 @@ function ArchiveCard({ isHighlighted?: boolean; timeFormat?: TimeFormat; preferredSlicer?: SlicerType; + currency: string; t: TFunction; }) { // Debug: log when card is highlighted @@ -919,6 +923,12 @@ function ArchiveCard({ {archive.filament_used_grams.toFixed(1)}g
)} + {archive.cost != null && archive.cost > 0 && ( +
+ + {currency}{archive.cost.toFixed(2)} +
+ )} {(archive.layer_height || archive.total_layers) && (
@@ -2383,6 +2393,7 @@ export function ArchivesPage() { const timeFormat: TimeFormat = settings?.time_format || 'system'; const preferredSlicer: SlicerType = settings?.preferred_slicer || 'bambu_studio'; + const currency = getCurrencySymbol(settings?.currency || 'USD'); const bulkDeleteMutation = useMutation({ mutationFn: async (ids: number[]) => { @@ -3195,6 +3206,7 @@ export function ArchivesPage() { isHighlighted={archive.id === highlightedArchiveId} timeFormat={timeFormat} preferredSlicer={preferredSlicer} + currency={currency} t={t} /> ))} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 86d4437dc..58fe2968f 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -15,6 +15,7 @@ import { ConfirmModal } from '../components/ConfirmModal'; import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal'; import { useToast } from '../contexts/ToastContext'; import { resolveSpoolColorName } from '../utils/colors'; +import { getCurrencySymbol } from '../utils/currency'; type ArchiveFilter = 'active' | 'archived'; type UsageFilter = 'all' | 'used' | 'new'; @@ -50,6 +51,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [ { id: 'data_origin', label: 'Data Origin', visible: false }, { id: 'tag_type', label: 'Linked Tag Type', visible: false }, { id: 'remaining', label: 'Remaining', visible: true }, + { id: 'cost_per_kg', label: 'Cost/kg', visible: false }, ]; function loadColumnConfig(): ColumnConfig[] { @@ -109,6 +111,7 @@ type CellCtx = { remaining: number; pct: number; assignmentMap: Record; + currencySymbol: string; }; // Column header labels (25 columns — matching SpoolBuddy exactly) @@ -137,6 +140,7 @@ const columnHeaders: Record string> = { data_origin: () => 'Data Origin', tag_type: () => 'Linked Tag Type', remaining: (t) => t('inventory.remaining'), + cost_per_kg: () => 'Cost/kg', }; // Column cell renderers (25 columns — matching SpoolBuddy exactly) @@ -251,6 +255,11 @@ const columnCells: Record ReactNode> = { {Math.round(remaining)}g
), + cost_per_kg: ({ spool, currencySymbol }) => ( + + {spool.cost_per_kg != null ? `${currencySymbol} ${spool.cost_per_kg.toFixed(2)}` : '-'} + + ), }; // Sort value extractors — return a comparable value for each sortable column @@ -277,6 +286,7 @@ const columnSortValues: Record (s.note || '').toLowerCase(), data_origin: (s) => (s.data_origin || '').toLowerCase(), tag_type: (s) => (s.tag_type || '').toLowerCase(), + cost_per_kg: (s) => s.cost_per_kg ?? 0, }; const SORT_STATE_KEY = 'bambuddy-inventory-sort'; @@ -342,6 +352,11 @@ export default function InventoryPage() { refetchInterval: 30000, }); + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + const deleteMutation = useMutation({ mutationFn: (id: number) => api.deleteSpool(id), onSuccess: () => { @@ -392,6 +407,8 @@ export default function InventoryPage() { const inPrinterCount = assignments?.length ?? 0; + const currencySymbol = getCurrencySymbol(settings?.currency || 'USD'); + // Map spool_id -> assignment for location column const assignmentMap = useMemo(() => { const map: Record = {}; @@ -927,7 +944,7 @@ export default function InventoryPage() { > {visibleColumns.map((colId) => ( - {columnCells[colId]?.({ spool, remaining, pct, assignmentMap })} + {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol })} ))}