Просмотр исходного кода

fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)

Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.

The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.

Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.

Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button

maziggy 1 неделя назад
Родитель
Сommit
b5a83924eb

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 40 - 13
backend/app/api/routes/archives.py

@@ -1316,15 +1316,29 @@ async def rescan_archive(
     if metadata.get("designer"):
     if metadata.get("designer"):
         archive.designer = metadata["designer"]
         archive.designer = metadata["designer"]
 
 
-    # Calculate cost: prefer spool-based cost if available, else catalog-based
+    # Calculate cost: prefer spool-based cost if available, else catalog-based.
+    # When spool-based costs exist but don't cover every filament gram used
+    # (#1344), fall back to the global default rate for the untracked weight
+    # so the displayed cost still reflects the whole print.
 
 
     if archive.filament_used_grams and archive.filament_type:
     if archive.filament_used_grams and archive.filament_type:
+        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
         usage_result = await db.execute(
         usage_result = await db.execute(
-            select(func.sum(SpoolUsageHistory.cost)).where(SpoolUsageHistory.archive_id == archive.id)
+            select(
+                func.sum(SpoolUsageHistory.cost),
+                func.sum(SpoolUsageHistory.weight_used),
+            ).where(SpoolUsageHistory.archive_id == archive.id)
         )
         )
-        usage_cost = usage_result.scalar()
+        usage_cost_row = usage_result.one()
+        usage_cost = usage_cost_row[0]
+        tracked_grams = float(usage_cost_row[1] or 0)
         if usage_cost is not None and usage_cost > 0:
         if usage_cost is not None and usage_cost > 0:
-            archive.cost = float(Decimal(str(usage_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
+            total_cost = float(usage_cost)
+            untracked_grams = max(0.0, archive.filament_used_grams - tracked_grams)
+            if untracked_grams > 0 and default_cost_per_kg > 0:
+                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
+            archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
         else:
         else:
             primary_type = archive.filament_type.split(",")[0].strip()
             primary_type = archive.filament_type.split(",")[0].strip()
             filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
             filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
@@ -1336,9 +1350,6 @@ async def rescan_archive(
                     )
                     )
                 )
                 )
             else:
             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 = float(
                 archive.cost = float(
                     Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
                     Decimal(str((archive.filament_used_grams / 1000) * default_cost_per_kg)).quantize(
                         Decimal("0.01"), rounding=ROUND_HALF_UP
                         Decimal("0.01"), rounding=ROUND_HALF_UP
@@ -1370,18 +1381,34 @@ async def recalculate_all_costs(
     default_cost_setting = await get_setting(db, "default_filament_cost")
     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
     default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
 
 
-    # Pre-fetch all usage costs by archive_id
+    # Pre-fetch all usage costs and tracked weight by archive_id.
+    # Tracked weight is used to top-up the cost at the default rate for any
+    # filament grams not covered by an inventory spool (#1344).
     usage_costs_result = await db.execute(
     usage_costs_result = await db.execute(
-        select(SpoolUsageHistory.archive_id, func.sum(SpoolUsageHistory.cost)).group_by(SpoolUsageHistory.archive_id)
+        select(
+            SpoolUsageHistory.archive_id,
+            func.sum(SpoolUsageHistory.cost),
+            func.sum(SpoolUsageHistory.weight_used),
+        ).group_by(SpoolUsageHistory.archive_id)
     )
     )
     usage_costs = usage_costs_result.fetchall()
     usage_costs = usage_costs_result.fetchall()
-    cost_map = {row[0]: row[1] for row in usage_costs if row[0] is not None and row[1] is not None and row[1] > 0}
+    cost_map = {
+        row[0]: (row[1], float(row[2] or 0))
+        for row in usage_costs
+        if row[0] is not None and row[1] is not None and row[1] > 0
+    }
 
 
     updated = 0
     updated = 0
     for archive in archives:
     for archive in archives:
-        usage_cost = cost_map.get(archive.id)
-        if usage_cost is not None:
-            new_cost = round(usage_cost, 2)
+        usage = cost_map.get(archive.id)
+        if usage is not None:
+            usage_cost, tracked_grams = usage
+            total_cost = float(usage_cost)
+            archive_grams = float(archive.filament_used_grams or 0)
+            untracked_grams = max(0.0, archive_grams - tracked_grams)
+            if untracked_grams > 0 and default_cost_per_kg > 0:
+                total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
+            new_cost = round(total_cost, 2)
         else:
         else:
             # Fallback: sum costs for old records by print_name
             # Fallback: sum costs for old records by print_name
             usage_result = await db.execute(
             usage_result = await db.execute(

+ 13 - 0
backend/app/services/usage_tracker.py

@@ -606,6 +606,14 @@ async def on_print_complete(
         await db.commit()
         await db.commit()
 
 
     # --- Update PrintArchive.cost from THIS print session only ---
     # --- Update PrintArchive.cost from THIS print session only ---
+    #
+    # Cover any filament weight that wasn't tracked by an inventory spool with
+    # the global default rate (#1344). Without this, a multi-color print where
+    # only some AMS trays are mapped to inventory spools would record only the
+    # mapped slots' share — e.g. $0.01 for a 110g print when 3 of 4 trays had
+    # no spool record. The initial cost set by archive.py (total grams *
+    # primary cost_per_kg) is fine on its own, but this block overwrites it,
+    # so the overwrite must reconstruct the whole-print cost.
 
 
     if archive_id and results:
     if archive_id and results:
         from sqlalchemy import select
         from sqlalchemy import select
@@ -616,6 +624,11 @@ async def on_print_complete(
         archive = archive_result.scalar_one_or_none()
         archive = archive_result.scalar_one_or_none()
         if archive:
         if archive:
             total_cost = sum(r.get("cost", 0) or 0 for r in results)
             total_cost = sum(r.get("cost", 0) or 0 for r in results)
+            tracked_grams = sum(r.get("weight_used", 0) or 0 for r in results)
+            archive_grams = archive.filament_used_grams or 0
+            untracked_grams = max(0.0, archive_grams - tracked_grams)
+            if untracked_grams > 0 and default_filament_cost > 0:
+                total_cost += (untracked_grams / 1000.0) * default_filament_cost
             if total_cost > 0:
             if total_cost > 0:
                 archive.cost = round(total_cost, 2)
                 archive.cost = round(total_cost, 2)
                 await db.commit()
                 await db.commit()

+ 5 - 1
backend/tests/integration/test_cost_statistics.py

@@ -350,12 +350,16 @@ class TestCostCalculationScenarios:
         await db_session.refresh(spool_new)
         await db_session.refresh(spool_new)
         await db_session.refresh(spool_old)
         await db_session.refresh(spool_old)
 
 
-        # Create archive with new SpoolUsageHistory (archive_id set)
+        # Create archive with new SpoolUsageHistory (archive_id set).
+        # filament_used_grams matches the tracked weight so the #1344 default-
+        # rate top-up for untracked filament doesn't apply -- this test pins
+        # the query routing, not the top-up branch.
         archive_new = await archive_factory(
         archive_new = await archive_factory(
             printer.id,
             printer.id,
             print_name="UniquePrint",
             print_name="UniquePrint",
             status="completed",
             status="completed",
             cost=None,
             cost=None,
+            filament_used_grams=20.0,
         )
         )
 
 
         history_new = SpoolUsageHistory(
         history_new = SpoolUsageHistory(

+ 5 - 3
backend/tests/unit/services/test_usage_tracker.py

@@ -707,6 +707,9 @@ class TestSpoolAssignmentSnapshot:
         spool = _make_spool(id=8, label_weight=1000, weight_used=50)
         spool = _make_spool(id=8, label_weight=1000, weight_used=50)
         archive = MagicMock()
         archive = MagicMock()
         archive.file_path = "archives/big_print.3mf"
         archive.file_path = "archives/big_print.3mf"
+        # Explicit numeric so the #1344 top-up branch doesn't trip a
+        # MagicMock-vs-float comparison.
+        archive.filament_used_grams = 14.2
 
 
         # Session was created at print start WITH snapshot
         # Session was created at print start WITH snapshot
         _active_sessions[1] = PrintSession(
         _active_sessions[1] = PrintSession(
@@ -736,9 +739,8 @@ class TestSpoolAssignmentSnapshot:
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
-                # Cost aggregation: sum query (uses .scalar()), archive lookup
-                MagicMock(scalar=MagicMock(return_value=0)),
-                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
+                # Cost-update block re-selects the archive to mutate cost.
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
             ]
             ]
         )
         )
 
 

+ 170 - 0
backend/tests/unit/test_cost_tracking.py

@@ -75,6 +75,10 @@ def _make_archive(archive_id=1, file_path=None):
     archive = MagicMock()
     archive = MagicMock()
     archive.id = archive_id
     archive.id = archive_id
     archive.file_path = file_path
     archive.file_path = file_path
+    # Explicit numeric default so the #1344 top-up logic (archive_grams -
+    # tracked_grams) doesn't compare a MagicMock to a float. Tests that
+    # exercise the top-up path overwrite this with a real number.
+    archive.filament_used_grams = 0
     return archive
     return archive
 
 
 
 
@@ -689,6 +693,172 @@ class TestCostAggregation:
         # Archive cost should have been updated
         # Archive cost should have been updated
         assert archive.cost == expected_cost
         assert archive.cost == expected_cost
 
 
+    @pytest.mark.asyncio
+    async def test_archive_cost_includes_untracked_filament_at_default_rate(self):
+        """#1344: when only some AMS trays have inventory spools, the untracked
+        filament weight is charged at the global default rate so the total
+        archive cost still reflects the whole print."""
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=10.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = None
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+        archive.filament_used_grams = 110.0  # whole-print weight from slicer
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        responses = [
+            ("scalar_one_or_none", archive),
+            ("scalar_one_or_none", None),  # queue item
+            ("scalar_one_or_none", assignment),
+            ("scalar_one_or_none", spool),
+            ("scalar_one_or_none", archive),  # cost-update select
+        ]
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                _, value = responses[idx]
+                result.scalar.return_value = value
+                result.scalar_one_or_none.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        # 3MF reports a single slot using 10g, but archive.filament_used_grams
+        # says the whole print was 110g -- the other 100g came from spools that
+        # aren't in inventory.
+        filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="10.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # Tracked slot: 10g * $10/kg = $0.10
+        assert len(results) == 1
+        assert results[0]["cost"] == 0.10
+        # Untracked: 110g - 10g = 100g at $10/kg default = $1.00
+        # Archive total: $0.10 + $1.00 = $1.10 (was $0.01 pre-fix because only
+        # the tracked slot's tiny share was kept)
+        assert archive.cost == 1.10
+
+    @pytest.mark.asyncio
+    async def test_archive_cost_fully_tracked_unchanged_by_topup(self):
+        """When every gram is covered by inventory spools, the default-rate
+        top-up adds nothing -- the archive cost is just the sum of tracked
+        costs, same as before #1344."""
+        spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=25.0)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+        archive.cost = None
+        archive.print_name = "TestPrint"
+        archive.printer_id = 1
+        archive.filament_used_grams = 20.0  # exactly what the slot reports
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="TestPrint",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+            tray_now_at_start=0,
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        responses = [
+            ("scalar_one_or_none", archive),
+            ("scalar_one_or_none", None),
+            ("scalar_one_or_none", assignment),
+            ("scalar_one_or_none", spool),
+            ("scalar_one_or_none", archive),
+        ]
+
+        db = AsyncMock()
+        call_count = [0]
+
+        async def mock_execute(*args, **kwargs):
+            idx = call_count[0]
+            call_count[0] += 1
+            result = MagicMock()
+            if idx < len(responses):
+                _, value = responses[idx]
+                result.scalar.return_value = value
+                result.scalar_one_or_none.return_value = value
+            else:
+                result.scalar_one_or_none.return_value = None
+                result.scalar.return_value = None
+            return result
+
+        db.execute = mock_execute
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # 20g at $25/kg = $0.50 -- no top-up because tracked >= archive grams
+        assert len(results) == 1
+        assert results[0]["cost"] == 0.50
+        assert archive.cost == 0.50
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_cost_with_archive_id(self):
     async def test_cost_with_archive_id(self):
         """Test cost aggregation using archive_id (3MF path)."""
         """Test cost aggregation using archive_id (3MF path)."""

Некоторые файлы не были показаны из-за большого количества измененных файлов