mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
bugfix (aborted prints were not billed in specific cases)
This commit is contained in:
+45
-2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user