mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
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:
+1
-1
@@ -55,4 +55,4 @@ bambutrack.log.*
|
||||
firmware/
|
||||
|
||||
# Node modules
|
||||
node_modules/
|
||||
node_modules/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user