diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index 4cf82d67d..26701c125 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -856,18 +856,27 @@ async def rescan_archive( if metadata.get("designer"): archive.designer = metadata["designer"] - # Calculate cost based on filament usage and type + # Calculate cost: prefer spool-based cost if available, else catalog-based if archive.filament_used_grams and archive.filament_type: - primary_type = archive.filament_type.split(",")[0].strip() - filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1)) - filament = filament_result.scalar_one_or_none() - if filament: - archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2) + from backend.app.models.spool_usage_history import SpoolUsageHistory + + usage_result = await db.execute( + select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.print_name == archive.print_name) + ) + usage_cost = usage_result.scalar() + if usage_cost is not None and usage_cost > 0: + archive.cost = round(usage_cost, 2) else: - # Use default filament cost from settings - default_cost_setting = await get_setting(db, "default_filament_cost") - default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0 - archive.cost = round((archive.filament_used_grams / 1000) * default_cost_per_kg, 2) + primary_type = archive.filament_type.split(",")[0].strip() + filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1)) + filament = filament_result.scalar_one_or_none() + if filament: + archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2) + else: + # Use default filament cost from settings + default_cost_setting = await get_setting(db, "default_filament_cost") + default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0 + archive.cost = round((archive.filament_used_grams / 1000) * default_cost_per_kg, 2) await db.commit() await db.refresh(archive) @@ -893,15 +902,27 @@ async def recalculate_all_costs( default_cost_setting = await get_setting(db, "default_filament_cost") default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0 + # Import SpoolUsageHistory for cost lookup + from backend.app.models.spool_usage_history import SpoolUsageHistory + updated = 0 for archive in archives: - if archive.filament_used_grams and archive.filament_type: + # Prefer sum of spool_usage_history.cost for this archive's print_name + usage_result = await db.execute( + select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.print_name == archive.print_name) + ) + usage_cost = usage_result.scalar() + if usage_cost is not None and usage_cost > 0: + new_cost = round(usage_cost, 2) + elif archive.filament_used_grams and archive.filament_type: primary_type = archive.filament_type.split(",")[0].strip() cost_per_kg = filaments.get(primary_type, default_cost_per_kg) new_cost = round((archive.filament_used_grams / 1000) * cost_per_kg, 2) - if archive.cost != new_cost: - archive.cost = new_cost - updated += 1 + else: + new_cost = None + if new_cost is not None and archive.cost != new_cost: + archive.cost = new_cost + updated += 1 await db.commit() return {"message": f"Recalculated costs for {updated} archives", "updated": updated} diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 5ea85f8bf..0692749e8 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -22,7 +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 + cost_per_kg: float | None = Field(default=None, ge=0) class SpoolCreate(SpoolBase): @@ -48,7 +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 + cost_per_kg: float | None = Field(default=None, ge=0) class SpoolKProfileBase(BaseModel): diff --git a/backend/tests/integration/test_cost_statistics.py b/backend/tests/integration/test_cost_statistics.py index b8633d731..f80a176d7 100644 --- a/backend/tests/integration/test_cost_statistics.py +++ b/backend/tests/integration/test_cost_statistics.py @@ -242,36 +242,6 @@ class TestSpoolCostPersistence: 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.""" @@ -283,7 +253,8 @@ class TestCostCalculationScenarios: # Create two spools with different costs spool1_data = { - "material": "PLA", + "material": "ABS", + "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 20.00, @@ -312,6 +283,7 @@ class TestCostCalculationScenarios: # Create spool with specific cost spool_data = { "material": "PLA", + "brand": "TestBrand", "label_weight": 1000, "core_weight": 250, "cost_per_kg": 19.99, # Specific price diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx index 58fe2968f..9928dcff3 100644 --- a/frontend/src/pages/InventoryPage.tsx +++ b/frontend/src/pages/InventoryPage.tsx @@ -140,7 +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', + cost_per_kg: (t) => t('inventory.costPerKg'), }; // Column cell renderers (25 columns — matching SpoolBuddy exactly)