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
This commit is contained in:
maziggy
2026-01-24 07:05:15 +01:00
parent 40c672b1b9
commit 5672d7965a
11 changed files with 539 additions and 387 deletions
+1 -1
View File
@@ -55,4 +55,4 @@ bambutrack.log.*
firmware/
# Node modules
node_modules/
node_modules/
+1 -1
View File
@@ -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/
+7
View File
@@ -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
+16
View File
@@ -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,
+37
View File
@@ -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,
@@ -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
@@ -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
View File
@@ -1680,6 +1680,8 @@ export const api = {
deleteArchive: (id: number) =>
request<void>(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request<ArchiveStats>('/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));
+30 -1
View File
@@ -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() {
<RotateCcw className="w-4 h-4" />
Reset Layout
</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 */}
<div className="relative">
<Button
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<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">
</head>
<body>