diff --git a/.gitignore b/.gitignore index f5eb2dfd3..470b31f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,4 @@ bambutrack.log.* firmware/ # Node modules -node_modules/ \ No newline at end of file +node_modules/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1da6db071..468e50c6d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,7 +24,7 @@ repos: exclude: ^static/ - id: check-yaml - id: check-json - exclude: ^static/ + exclude: ^(static/|frontend/tsconfig\.) - id: check-added-large-files args: ['--maxkb=1000'] exclude: ^static/assets/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ab1293f..0e77a1446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6] - 2026-01-24 + +### New Features +- **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120) +### Fixes +- **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints + ## [0.1.6b11] - 2026-01-22 ### New Features diff --git a/backend/app/main.py b/backend/app/main.py index a2cf84c55..849bee5f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -112,6 +112,9 @@ _expected_prints: dict[tuple[int, str], int] = {} # Track starting energy for prints: {archive_id: starting_kwh} _print_energy_start: dict[int, float] = {} +# Track reprints to add costs on completion: {archive_id} +_reprint_archives: set[int] = set() + async def _get_plug_energy(plug, db) -> dict | None: """Get energy from plug regardless of type (Tasmota or Home Assistant). @@ -526,6 +529,10 @@ async def on_print_start(printer_id: int, data: dict): if subtask_name: _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id + # Mark as reprint so we add cost on completion + _reprint_archives.add(archive.id) + logger.info(f"Marked archive {archive.id} as reprint for cost addition on completion") + # Set up energy tracking try: plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) @@ -1235,6 +1242,15 @@ async def on_print_complete(printer_id: int, data: dict): ) logger.info(f"[ARCHIVE] Archive {archive_id} status updated to {status}, failure_reason={failure_reason}") + # Add cost for reprints (first prints have cost set in archive_print()) + if status == "completed" and archive_id in _reprint_archives: + _reprint_archives.discard(archive_id) + try: + await service.add_reprint_cost(archive_id) + logger.info(f"[ARCHIVE] Added reprint cost for archive {archive_id}") + except Exception as e: + logger.warning(f"[ARCHIVE] Failed to add reprint cost for archive {archive_id}: {e}") + await ws_manager.send_archive_updated( { "id": archive_id, diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index c0a3a27dc..ba28004ec 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -919,6 +919,43 @@ class ArchiveService: await self.db.commit() return True + async def add_reprint_cost(self, archive_id: int) -> bool: + """Add cost for a reprint to the existing archive cost.""" + archive = await self.get_archive(archive_id) + if not archive: + return False + + if not archive.filament_used_grams or not archive.filament_type: + return False + + # Calculate cost based on filament type or default + from backend.app.api.routes.settings import get_setting + + primary_type = archive.filament_type.split(",")[0].strip() + + # Look up filament cost_per_kg from database + filament_result = await self.db.execute(select(Filament).where(Filament.type == primary_type).limit(1)) + filament = filament_result.scalar_one_or_none() + + if filament: + cost_per_kg = filament.cost_per_kg + else: + # Use default filament cost from settings + default_cost_setting = await get_setting(self.db, "default_filament_cost") + cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0 + + additional_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2) + + # Add to existing cost (or set if None) + if archive.cost is None: + archive.cost = additional_cost + else: + archive.cost = round(archive.cost + additional_cost, 2) + + await self.db.commit() + logger.info(f"Added reprint cost {additional_cost} to archive {archive_id}, new total: {archive.cost}") + return True + async def list_archives( self, printer_id: int | None = None, diff --git a/backend/tests/unit/services/test_archive_service.py b/backend/tests/unit/services/test_archive_service.py index 1402ac03e..962559252 100644 --- a/backend/tests/unit/services/test_archive_service.py +++ b/backend/tests/unit/services/test_archive_service.py @@ -611,3 +611,54 @@ class TestMultiPlate3MFParsing: is_multi_plate = len(plate_indices) > 1 assert is_multi_plate is False + + +class TestReprintCostCalculation: + """Tests for reprint cost calculation.""" + + def test_cost_addition_logic(self): + """Test that reprint costs are added correctly.""" + # Simulate the cost addition logic + existing_cost = 5.25 # Original print cost + filament_grams = 100.0 + cost_per_kg = 25.0 # Default cost + + # Calculate additional cost for reprint + additional_cost = round((filament_grams / 1000) * cost_per_kg, 2) + assert additional_cost == 2.50 + + # Add to existing cost + new_total = round(existing_cost + additional_cost, 2) + assert new_total == 7.75 + + def test_cost_addition_with_none_existing(self): + """Test cost addition when existing cost is None.""" + existing_cost = None + filament_grams = 200.0 + cost_per_kg = 15.0 + + additional_cost = round((filament_grams / 1000) * cost_per_kg, 2) + assert additional_cost == 3.0 + + # When existing is None, just use additional + new_total = additional_cost if existing_cost is None else round(existing_cost + additional_cost, 2) + assert new_total == 3.0 + + def test_cost_with_custom_filament_price(self): + """Test cost calculation with custom filament price.""" + filament_grams = 150.0 + custom_cost_per_kg = 35.0 # More expensive filament + + cost = round((filament_grams / 1000) * custom_cost_per_kg, 2) + assert cost == 5.25 + + def test_multiple_reprints_accumulate(self): + """Test that multiple reprints accumulate costs correctly.""" + filament_grams = 100.0 + cost_per_kg = 20.0 + single_print_cost = round((filament_grams / 1000) * cost_per_kg, 2) + assert single_print_cost == 2.0 + + # After 3 prints (1 original + 2 reprints) + total_after_3_prints = round(single_print_cost * 3, 2) + assert total_after_3_prints == 6.0 diff --git a/frontend/src/__tests__/pages/StatsPage.test.tsx b/frontend/src/__tests__/pages/StatsPage.test.tsx index 26d0d7ba7..e86c2e2f7 100644 --- a/frontend/src/__tests__/pages/StatsPage.test.tsx +++ b/frontend/src/__tests__/pages/StatsPage.test.tsx @@ -196,4 +196,14 @@ describe('StatsPage', () => { }); }); }); + + describe('recalculate costs', () => { + it('has recalculate costs button', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Recalculate Costs')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9502de323..60f7598a2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1680,6 +1680,8 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + recalculateCosts: () => + request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }), getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => { const params = new URLSearchParams(); if (options?.days) params.set('days', String(options.days)); diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index f85dec4a6..d3e09ed1e 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -16,6 +16,7 @@ import { Loader2, Eye, RotateCcw, + Calculator, } from 'lucide-react'; import { Button } from '../components/Button'; import { useToast } from '../contexts/ToastContext'; @@ -508,6 +509,7 @@ export function StatsPage() { const [showExportMenu, setShowExportMenu] = useState(false); const [dashboardKey, setDashboardKey] = useState(0); const [hiddenCount, setHiddenCount] = useState(0); + const [isRecalculating, setIsRecalculating] = useState(false); // Read hidden count from localStorage useEffect(() => { @@ -533,7 +535,7 @@ export function StatsPage() { }; }, [dashboardKey]); - const { data: stats, isLoading } = useQuery({ + const { data: stats, isLoading, refetch: refetchStats } = useQuery({ queryKey: ['archiveStats'], queryFn: api.getArchiveStats, }); @@ -572,6 +574,19 @@ export function StatsPage() { } }; + const handleRecalculateCosts = async () => { + setIsRecalculating(true); + try { + const result = await api.recalculateCosts(); + await refetchStats(); + showToast(`Recalculated costs for ${result.updated} archives`); + } catch { + showToast('Failed to recalculate costs', 'error'); + } finally { + setIsRecalculating(false); + } + }; + const currency = settings?.currency || '$'; const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []); const printDates = archives?.map((a) => a.created_at) || []; @@ -672,6 +687,20 @@ export function StatsPage() { Reset Layout + {/* Recalculate Costs */} + {/* Export dropdown */}