Browse Source

Add recalculate costs button and reprint cost tracking
- Add "Recalculate Costs" button to Dashboard that updates all archive
costs using current filament prices (Issue #120)
- Track reprints and add cost to existing archive total on completion,
so statistics accurately reflect total filament expenditure

Closes #120

maziggy 4 months ago
parent
commit
5672d7965a

+ 1 - 1
.gitignore

@@ -55,4 +55,4 @@ bambutrack.log.*
 firmware/
 firmware/
 
 
 # Node modules
 # Node modules
-node_modules/
+node_modules/

+ 1 - 1
.pre-commit-config.yaml

@@ -24,7 +24,7 @@ repos:
         exclude: ^static/
         exclude: ^static/
       - id: check-yaml
       - id: check-yaml
       - id: check-json
       - id: check-json
-        exclude: ^static/
+        exclude: ^(static/|frontend/tsconfig\.)
       - id: check-added-large-files
       - id: check-added-large-files
         args: ['--maxkb=1000']
         args: ['--maxkb=1000']
         exclude: ^static/assets/
         exclude: ^static/assets/

+ 7 - 0
CHANGELOG.md

@@ -2,6 +2,13 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 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
 ## [0.1.6b11] - 2026-01-22
 
 
 ### New Features
 ### New Features

+ 16 - 0
backend/app/main.py

@@ -112,6 +112,9 @@ _expected_prints: dict[tuple[int, str], int] = {}
 # Track starting energy for prints: {archive_id: starting_kwh}
 # Track starting energy for prints: {archive_id: starting_kwh}
 _print_energy_start: dict[int, float] = {}
 _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:
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota or Home Assistant).
     """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:
                 if subtask_name:
                     _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id
                     _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
                 # Set up energy tracking
                 try:
                 try:
                     plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                     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}")
             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(
             await ws_manager.send_archive_updated(
                 {
                 {
                     "id": archive_id,
                     "id": archive_id,

+ 37 - 0
backend/app/services/archive.py

@@ -919,6 +919,43 @@ class ArchiveService:
         await self.db.commit()
         await self.db.commit()
         return True
         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(
     async def list_archives(
         self,
         self,
         printer_id: int | None = None,
         printer_id: int | None = None,

+ 51 - 0
backend/tests/unit/services/test_archive_service.py

@@ -611,3 +611,54 @@ class TestMultiPlate3MFParsing:
 
 
         is_multi_plate = len(plate_indices) > 1
         is_multi_plate = len(plate_indices) > 1
         assert is_multi_plate is False
         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

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

@@ -196,4 +196,14 @@ describe('StatsPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('recalculate costs', () => {
+    it('has recalculate costs button', async () => {
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Recalculate Costs')).toBeInTheDocument();
+      });
+    });
+  });
 });
 });

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

@@ -1680,6 +1680,8 @@ export const api = {
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  recalculateCosts: () =>
+    request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
     if (options?.days) params.set('days', String(options.days));
     if (options?.days) params.set('days', String(options.days));

+ 30 - 1
frontend/src/pages/StatsPage.tsx

@@ -16,6 +16,7 @@ import {
   Loader2,
   Loader2,
   Eye,
   Eye,
   RotateCcw,
   RotateCcw,
+  Calculator,
 } from 'lucide-react';
 } from 'lucide-react';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -508,6 +509,7 @@ export function StatsPage() {
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [hiddenCount, setHiddenCount] = useState(0);
   const [hiddenCount, setHiddenCount] = useState(0);
+  const [isRecalculating, setIsRecalculating] = useState(false);
 
 
   // Read hidden count from localStorage
   // Read hidden count from localStorage
   useEffect(() => {
   useEffect(() => {
@@ -533,7 +535,7 @@ export function StatsPage() {
     };
     };
   }, [dashboardKey]);
   }, [dashboardKey]);
 
 
-  const { data: stats, isLoading } = useQuery({
+  const { data: stats, isLoading, refetch: refetchStats } = useQuery({
     queryKey: ['archiveStats'],
     queryKey: ['archiveStats'],
     queryFn: api.getArchiveStats,
     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 currency = settings?.currency || '$';
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
   const printDates = archives?.map((a) => a.created_at) || [];
   const printDates = archives?.map((a) => a.created_at) || [];
@@ -672,6 +687,20 @@ export function StatsPage() {
             <RotateCcw className="w-4 h-4" />
             <RotateCcw className="w-4 h-4" />
             Reset Layout
             Reset Layout
           </Button>
           </Button>
+          {/* Recalculate Costs */}
+          <Button
+            variant="secondary"
+            onClick={handleRecalculateCosts}
+            disabled={isRecalculating}
+            title="Recalculate all archive costs using current filament prices"
+          >
+            {isRecalculating ? (
+              <Loader2 className="w-4 h-4 animate-spin" />
+            ) : (
+              <Calculator className="w-4 h-4" />
+            )}
+            Recalculate Costs
+          </Button>
           {/* Export dropdown */}
           {/* Export dropdown */}
           <div className="relative">
           <div className="relative">
             <Button
             <Button

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


+ 1 - 1
static/index.html

@@ -23,7 +23,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-CPB3KMs0.js"></script>
+    <script type="module" crossorigin src="/assets/index-SsFUChPG.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CVDQtTMh.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CVDQtTMh.css">
   </head>
   </head>
   <body>
   <body>

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