فهرست منبع

Refactor cost calculation logic to prioritize spool usage history and update schema validation for cost_per_kg

Matteo Parenti 3 ماه پیش
والد
کامیت
8043bb18ba

+ 35 - 14
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}

+ 2 - 2
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):

+ 3 - 31
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

+ 1 - 1
frontend/src/pages/InventoryPage.tsx

@@ -140,7 +140,7 @@ const columnHeaders: Record<string, (t: TFn) => 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)