bugfix (aborted prints were not billed in specific cases)

This commit is contained in:
behrinml
2026-07-30 21:51:13 +02:00
parent b74862d7d2
commit 449060ce0a
5 changed files with 162 additions and 17 deletions
+45 -2
View File
@@ -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
View File
@@ -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:
+26 -8
View File
@@ -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 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 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.")
@@ -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):
@@ -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