فهرست منبع

bugfix (aborted prints were not billed in specific cases)

behrinml 1 ماه پیش
والد
کامیت
449060ce0a

+ 45 - 2
backend/app/main.py

@@ -4970,6 +4970,28 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning("[BED-COOL] Failed to register waiter: %s", e)
 
+    # Capture the slicer estimate before usage tracking runs. The tracker may
+    # update archive.cost with this run's measured cost; billing partial runs
+    # against that already-partial value would discount the charge twice.
+    billing_planned_grams: float | None = None
+    billing_base_cost: float | None = None
+    if archive_id:
+        try:
+            async with async_session() as db:
+                from backend.app.models.archive import PrintArchive
+
+                billing_archive = await db.get(PrintArchive, archive_id)
+                if billing_archive:
+                    billing_path = (
+                        app_settings.base_dir / billing_archive.file_path if billing_archive.file_path else None
+                    )  # SEC-PATH-OK: archive.file_path is DB-stored, internally generated
+                    billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate(
+                        billing_archive,
+                        billing_path,
+                    )
+        except Exception as e:
+            logger.warning("[FINANCE] Failed to capture planned usage for archive %s: %s", archive_id, e)
+
     # --- Track filament consumption (must run before archive_id early-return so usage
     # is recorded even when auto-archive is disabled) ---
     usage_results: list[dict] = []
@@ -5192,7 +5214,11 @@ async def on_print_complete(printer_id: int, data: dict):
 
     log_timing("Archive status update")
 
-    # Apply finance wallet charge or release reservations once
+    # Apply finance wallet charge or release reservations once. For all partial
+    # terminal states (failed, aborted at the printer display, or cancelled via
+    # Bambuddy) use this run's measured spool delta, falling back to the last
+    # valid printer progress. PrintArchive.filament_used_grams is the slicer
+    # estimate and therefore cannot represent an interrupted run.
     try:
         if data.get("status") in ("completed", "failed", "aborted", "cancelled"):
             async with async_session() as db:
@@ -5200,12 +5226,29 @@ async def on_print_complete(printer_id: int, data: dict):
                 from backend.app.services.finance_billing import apply_print_charge_for_archive
 
                 archive = await db.get(PrintArchive, archive_id)
+                if archive and archive.created_by_id is None and _print_user_info:
+                    archive.created_by_id = _print_user_info.get("user_id")
+                    await db.flush()
+
+                run_status = data.get("status", "completed")
+                last_progress = data.get("last_progress")
+                if last_progress is None:
+                    last_progress = data.get("progress")
+                actual_run_grams = _compute_run_filament_grams(
+                    run_status,
+                    billing_planned_grams,
+                    last_progress,
+                    usage_results,
+                )
+                filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
                 cost_center_id = _print_cost_center_ids.pop(archive_id, None)
                 charged = await apply_print_charge_for_archive(
                     db,
                     archive_id,
                     cost_center_id=cost_center_id,
                     print_run_id=archive.subtask_id if archive else None,
+                    base_cost_override=billing_base_cost,
+                    filament_usage=filament_usage,
                 )
                 await db.commit()
                 if charged:
@@ -5254,7 +5297,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 _run_grams = _compute_run_filament_grams(
                     _run_status,
                     _est_grams,
-                    data.get("progress"),
+                    data.get("last_progress", data.get("progress")),
                     usage_results,
                 )
 

+ 4 - 3
backend/app/services/bambu_mqtt.py

@@ -2986,9 +2986,10 @@ class BambuMQTTClient:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
             # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
-            if self.state.progress > 0:
-                self._last_valid_progress = self.state.progress
-            self.state.progress = float(data["mc_percent"])
+            new_progress = float(data["mc_percent"])
+            if new_progress > 0:
+                self._last_valid_progress = new_progress
+            self.state.progress = new_progress
         if "mc_remaining_time" in data:
             self.state.remaining_time = int(data["mc_remaining_time"])
         if "mc_print_sub_stage" in data:

+ 27 - 9
backend/app/services/finance_billing.py

@@ -54,6 +54,8 @@ async def _get_balance_after_for_transaction(
 def _calculate_partial_charge(
     archive: PrintArchive,
     base_cost: float,
+    *,
+    filament_usage: tuple[float | None, float | None] | None = None,
 ) -> tuple[float, str]:
     """Calculate proportional charge for partial prints based on filament usage.
 
@@ -66,13 +68,23 @@ def _calculate_partial_charge(
         if archive.status == "completed":
             return round(float(base_cost), 2), ""
 
-        filament_used = float(archive.filament_used_grams or 0.0)
-        filament_planned = None
-
-        if archive.extra_data and isinstance(archive.extra_data, dict):
-            filament_planned = archive.extra_data.get("filament_grams_total")
-            if filament_planned is not None:
-                filament_planned = float(filament_planned)
+        if filament_usage is not None:
+            actual_grams, planned_grams = filament_usage
+            filament_used = float(actual_grams or 0.0)
+            filament_planned = float(planned_grams) if planned_grams is not None else None
+        else:
+            # Backwards-compatible fallback for recalculation and callers that
+            # do not have per-run telemetry. At print completion main.py passes
+            # the measured/progress-scaled run usage explicitly: the archive
+            # field is the slicer's planned amount and must not be mistaken for
+            # the amount consumed by an aborted run.
+            filament_used = float(archive.filament_used_grams or 0.0)
+            filament_planned = None
+
+            if archive.extra_data and isinstance(archive.extra_data, dict):
+                filament_planned = archive.extra_data.get("filament_grams_total")
+                if filament_planned is not None:
+                    filament_planned = float(filament_planned)
 
         # If we don't have reliable planned filament data, do not guess a partial charge.
         # Charging a failed/aborted print without an estimated baseline can overcharge users.
@@ -99,6 +111,8 @@ async def apply_print_charge_for_archive(
     *,
     cost_center_id: int | None = None,
     print_run_id: str | None = None,
+    base_cost_override: float | None = None,
+    filament_usage: tuple[float | None, float | None] | None = None,
 ) -> bool:
     """Apply an idempotent wallet charge for a print archive.
 
@@ -133,7 +147,7 @@ async def apply_print_charge_for_archive(
             logger.warning(f"Archive ID {archive_id} has no creator ID.")
             return False
 
-        base_cost = float(archive.cost or 0.0)
+        base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
         if base_cost <= 0:
             logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
             return False
@@ -150,7 +164,11 @@ async def apply_print_charge_for_archive(
             return False
 
         # Calculate charge (full for completed, partial for others)
-        charge, reason_suffix = _calculate_partial_charge(archive, base_cost)
+        charge, reason_suffix = _calculate_partial_charge(
+            archive,
+            base_cost,
+            filament_usage=filament_usage,
+        )
         if charge <= 0:
             await release_budget_reservation(db, print_archive_id=archive.id, status="released")
             logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")

+ 5 - 0
backend/tests/integration/test_print_lifecycle.py

@@ -396,6 +396,10 @@ class TestTimelapseTracking:
             }
         )
 
+        # A later status update records the last non-zero progress before
+        # firmware resets it during a display-side abort.
+        client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 25}})
+
         # User cancels (goes to IDLE)
         client._process_message(
             {
@@ -409,6 +413,7 @@ class TestTimelapseTracking:
 
         assert completion_data["status"] == "aborted"
         assert "hms_errors" in completion_data
+        assert completion_data["last_progress"] == 25
 
     @pytest.mark.asyncio
     async def test_timelapse_detected_from_ipcam_data(self):

+ 82 - 4
backend/tests/unit/services/test_finance_service_billing.py

@@ -218,6 +218,79 @@ class TestFinanceBilling:
 class TestPartialPrintCharges:
     """Tests for proportional charge calculation on aborted/failed/cancelled prints."""
 
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"])
+    async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation(
+        self,
+        db_session,
+        status,
+    ):
+        """Bambuddy stop, display abort, and printer failure share one billing path."""
+        await enable_billing(db_session)
+        user = User(username=f"partial_{status}", role="user", is_active=True)
+        cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False)
+        db_session.add_all([user, cost_center])
+        await db_session.commit()
+        await db_session.refresh(user)
+        await db_session.refresh(cost_center)
+
+        archive = PrintArchive(
+            printer_id=None,
+            filename=f"{status}.3mf",
+            file_path=f"archives/test/{status}.3mf",
+            file_size=100,
+            content_hash=f"partial-{status}-override",
+            status=status,
+            # The usage tracker may already have replaced archive.cost with the
+            # measured partial cost. Completion billing must use the estimate
+            # captured before tracking, not discount this value a second time.
+            cost=3.0,
+            filament_used_grams=100.0,
+            extra_data={"filament_grams_total": 100.0},
+            created_by_id=user.id,
+            cost_center_id=cost_center.id,
+        )
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+
+        reservation = BudgetReservation(
+            cost_center_id=cost_center.id,
+            amount=12.0,
+            status="active",
+            source_type="print_queue",
+            source_id=archive.id,
+            print_archive_id=archive.id,
+        )
+        db_session.add(reservation)
+        await db_session.commit()
+        await db_session.refresh(reservation)
+
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            base_cost_override=12.0,
+            filament_usage=(25.0, 100.0),
+        )
+        await db_session.commit()
+
+        assert changed is True
+        wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
+        assert wallet is not None
+        assert wallet.balance == -3.0
+
+        transaction = await db_session.scalar(
+            select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
+        )
+        assert transaction is not None
+        assert transaction.amount == -3.0
+        assert status in transaction.description.lower()
+        assert "25.0g/100.0g" in transaction.description
+
+        await db_session.refresh(reservation)
+        assert reservation.status == "consumed"
+        assert reservation.released_at is not None
+
     @pytest.mark.asyncio
     async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
         await enable_billing(db_session)
@@ -314,8 +387,8 @@ class TestPartialPrintCharges:
         assert "50.0" in tx.description  # filament used
 
     @pytest.mark.asyncio
-    async def test_cancelled_print_with_no_filament_data_is_not_charged(self, db_session):
-        """Verify cancelled print with no filament data is skipped."""
+    async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session):
+        """A slicer estimate alone is not mistaken for actual run consumption."""
         await enable_billing(db_session)
         user = User(username="cancel_no_data", role="user", is_active=True)
         cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
@@ -332,7 +405,8 @@ class TestPartialPrintCharges:
             content_hash="cancel-hash",
             status="cancelled",
             cost=5.0,
-            filament_used_grams=None,  # No data
+            filament_used_grams=100.0,
+            extra_data={"filament_grams_total": 100.0},
             created_by_id=user.id,
             cost_center_id=cost_center.id,
         )
@@ -351,7 +425,11 @@ class TestPartialPrintCharges:
         await db_session.commit()
         await db_session.refresh(reservation)
 
-        changed = await apply_print_charge_for_archive(db_session, archive.id)
+        changed = await apply_print_charge_for_archive(
+            db_session,
+            archive.id,
+            filament_usage=(None, 100.0),
+        )
         await db_session.commit()
 
         assert changed is False