|
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.archive import PrintArchive
|
|
|
from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
|
|
from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
|
|
|
|
|
+from backend.app.models.print_log import INCLUDE_OPEN_RUNS, PrintLogEntry
|
|
|
from backend.app.services.finance_balance import sync_personal_wallet_balance
|
|
from backend.app.services.finance_balance import sync_personal_wallet_balance
|
|
|
from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
|
|
from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
|
|
|
|
|
|
|
@@ -63,16 +64,20 @@ def _calculate_partial_charge(
|
|
|
base_cost: float,
|
|
base_cost: float,
|
|
|
*,
|
|
*,
|
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
|
|
|
+ status: str | None = None,
|
|
|
) -> tuple[float, str]:
|
|
) -> tuple[float, str]:
|
|
|
"""Calculate proportional charge for partial prints based on filament usage.
|
|
"""Calculate proportional charge for partial prints based on filament usage.
|
|
|
|
|
|
|
|
|
|
+ ``status`` is how the charged run ended; without it, the archive's.
|
|
|
|
|
+
|
|
|
Returns (charge_amount, description_suffix) where:
|
|
Returns (charge_amount, description_suffix) where:
|
|
|
- charge_amount: absolute cost to charge (0 if insufficient data)
|
|
- charge_amount: absolute cost to charge (0 if insufficient data)
|
|
|
- description_suffix: reason/details for transaction description
|
|
- description_suffix: reason/details for transaction description
|
|
|
"""
|
|
"""
|
|
|
try:
|
|
try:
|
|
|
|
|
+ status = status or archive.status
|
|
|
# Only apply proportional calculation for non-completed prints
|
|
# Only apply proportional calculation for non-completed prints
|
|
|
- if archive.status == "completed":
|
|
|
|
|
|
|
+ if status == "completed":
|
|
|
return round(float(base_cost), 2), ""
|
|
return round(float(base_cost), 2), ""
|
|
|
|
|
|
|
|
if filament_usage is not None:
|
|
if filament_usage is not None:
|
|
@@ -96,7 +101,7 @@ def _calculate_partial_charge(
|
|
|
# If we don't have reliable planned filament data, do not guess a partial charge.
|
|
# 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.
|
|
# Charging a failed/aborted print without an estimated baseline can overcharge users.
|
|
|
if filament_planned is None or filament_planned <= 0:
|
|
if filament_planned is None or filament_planned <= 0:
|
|
|
- return 0.0, f"[{archive.status}: insufficient filament data]"
|
|
|
|
|
|
|
+ return 0.0, f"[{status}: insufficient filament data]"
|
|
|
|
|
|
|
|
# Calculate proportional cost
|
|
# Calculate proportional cost
|
|
|
filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
|
|
filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
|
|
@@ -105,13 +110,25 @@ def _calculate_partial_charge(
|
|
|
# Round charges to 2 decimals for consistent persistence
|
|
# Round charges to 2 decimals for consistent persistence
|
|
|
charge = round(charge, 2)
|
|
charge = round(charge, 2)
|
|
|
|
|
|
|
|
- suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
|
|
|
|
|
|
|
+ suffix = f"[{status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
|
|
|
return charge, suffix
|
|
return charge, suffix
|
|
|
except ValueError as e:
|
|
except ValueError as e:
|
|
|
logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
|
|
logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
|
|
|
raise
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+async def _settle_reservation(db: AsyncSession, status: str, *, archive_id: int, print_queue_id: int | None) -> None:
|
|
|
|
|
+ """Release or consume what the charged print reserved. Reservations are
|
|
|
|
|
+ made per queue item. Without the print's own (no queue item, or its item
|
|
|
|
|
+ closed already, e.g. cancelled by a stop before the printer reported the
|
|
|
|
|
+ start) the archive's whose items are over, never those of another plate
|
|
|
|
|
+ of it still pending or printing."""
|
|
|
|
|
+ if print_queue_id is not None:
|
|
|
|
|
+ await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status=status)
|
|
|
|
|
+ else:
|
|
|
|
|
+ await release_budget_reservation(db, print_archive_id=archive_id, status=status)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
async def apply_print_charge_for_archive(
|
|
async def apply_print_charge_for_archive(
|
|
|
db: AsyncSession,
|
|
db: AsyncSession,
|
|
|
archive_id: int,
|
|
archive_id: int,
|
|
@@ -122,43 +139,72 @@ async def apply_print_charge_for_archive(
|
|
|
print_run_id: str | None = None,
|
|
print_run_id: str | None = None,
|
|
|
base_cost_override: float | None = None,
|
|
base_cost_override: float | None = None,
|
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
|
|
|
+ run_entry_id: int | None = None,
|
|
|
|
|
+ run_status: str | None = None,
|
|
|
) -> bool:
|
|
) -> bool:
|
|
|
"""Apply an idempotent wallet charge for a print archive.
|
|
"""Apply an idempotent wallet charge for a print archive.
|
|
|
|
|
|
|
|
Charges completed prints at full cost, and partial/failed prints proportionally
|
|
Charges completed prints at full cost, and partial/failed prints proportionally
|
|
|
based on actual filament used vs. planned filament.
|
|
based on actual filament used vs. planned filament.
|
|
|
|
|
|
|
|
|
|
+ ``run_entry_id`` is the run being charged (its print log entry) and
|
|
|
|
|
+ ``run_status`` how it ended. The charge is then the run's: its billing run
|
|
|
|
|
+ id and cost centre, under a lock on the run row, so plates of
|
|
|
|
|
+ one archive printing on two printers are each charged once. Without a run
|
|
|
|
|
+ (a print started before runs were written at the start) the archive's.
|
|
|
|
|
+
|
|
|
Returns True when a new wallet transaction was created.
|
|
Returns True when a new wallet transaction was created.
|
|
|
"""
|
|
"""
|
|
|
try:
|
|
try:
|
|
|
if not await is_billing_enabled(db):
|
|
if not await is_billing_enabled(db):
|
|
|
- if print_queue_id is not None:
|
|
|
|
|
- await release_budget_reservation(
|
|
|
|
|
- db, source_type="print_queue", source_id=print_queue_id, status="released"
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- await release_budget_reservation(db, print_archive_id=archive_id, status="released")
|
|
|
|
|
|
|
+ await _settle_reservation(db, "released", archive_id=archive_id, print_queue_id=print_queue_id)
|
|
|
logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
|
|
logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
- archive = (
|
|
|
|
|
- await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
|
|
|
|
|
- ).scalar_one_or_none()
|
|
|
|
|
|
|
+ run = None
|
|
|
|
|
+ if run_entry_id is not None:
|
|
|
|
|
+ # Locked, and read again under the lock (the caller loaded it):
|
|
|
|
|
+ # two ends of the same run (a repeated completion) mint and charge
|
|
|
|
|
+ # its billing run id once.
|
|
|
|
|
+ run = (
|
|
|
|
|
+ await db.execute(
|
|
|
|
|
+ select(PrintLogEntry)
|
|
|
|
|
+ .where(PrintLogEntry.id == run_entry_id)
|
|
|
|
|
+ .with_for_update()
|
|
|
|
|
+ .execution_options(populate_existing=True, **{INCLUDE_OPEN_RUNS: True})
|
|
|
|
|
+ )
|
|
|
|
|
+ ).scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ archive_query = select(PrintArchive).where(PrintArchive.id == archive_id)
|
|
|
|
|
+ if run is None:
|
|
|
|
|
+ archive_query = archive_query.with_for_update()
|
|
|
|
|
+ archive = (await db.execute(archive_query)).scalar_one_or_none()
|
|
|
if archive is None:
|
|
if archive is None:
|
|
|
logger.warning(f"Archive with ID {archive_id} not found.")
|
|
logger.warning(f"Archive with ID {archive_id} not found.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
- effective_run_id = print_run_id or archive.billing_run_id
|
|
|
|
|
- # The archive-level flag is retained only for legacy deleted charges.
|
|
|
|
|
- # A new scheduler dispatch clears it while persisting its new run UUID;
|
|
|
|
|
- # current deletions are represented by a voided transaction instead.
|
|
|
|
|
- if archive.wallet_charge_skipped:
|
|
|
|
|
|
|
+ if run is not None:
|
|
|
|
|
+ # A run opened at its start has its own id. One written only at
|
|
|
|
|
+ # its end (no start seen) has none, and is charged as before by the
|
|
|
|
|
+ # archive's, which a repeated end then finds charged.
|
|
|
|
|
+ effective_run_id = print_run_id or run.billing_run_id or archive.billing_run_id
|
|
|
|
|
+ skipped = bool(run.wallet_charge_skipped)
|
|
|
|
|
+ else:
|
|
|
|
|
+ effective_run_id = print_run_id or archive.billing_run_id
|
|
|
|
|
+ # The archive-level flag is retained only for legacy deleted charges.
|
|
|
|
|
+ # A new scheduler dispatch clears it while persisting its new run UUID;
|
|
|
|
|
+ # current deletions are represented by a voided transaction instead.
|
|
|
|
|
+ skipped = archive.wallet_charge_skipped
|
|
|
|
|
+ if skipped:
|
|
|
logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
|
|
logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
- # Accept completed, aborted, cancelled, and failed prints
|
|
|
|
|
- if archive.status not in ("completed", "aborted", "cancelled", "failed"):
|
|
|
|
|
- logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
|
|
|
|
|
|
|
+ # Accept completed, aborted, cancelled, and failed prints. A run's own
|
|
|
|
|
+ # end: with another plate of the archive ending at the same time, the
|
|
|
|
|
+ # archive's status may be that one's.
|
|
|
|
|
+ charged_status = run_status or archive.status
|
|
|
|
|
+ if charged_status not in ("completed", "aborted", "cancelled", "failed"):
|
|
|
|
|
+ logger.info(f"Archive ID {archive_id} has status {charged_status}, which is not chargeable.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
# A queue print's user is the one who queued it, also when deleted
|
|
# A queue print's user is the one who queued it, also when deleted
|
|
@@ -176,7 +222,12 @@ async def apply_print_charge_for_archive(
|
|
|
actual_user_id = None
|
|
actual_user_id = None
|
|
|
else:
|
|
else:
|
|
|
actual_user_id = archive.created_by_id
|
|
actual_user_id = archive.created_by_id
|
|
|
- actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
|
|
|
|
|
|
|
+ if cost_center_id is not None:
|
|
|
|
|
+ actual_cost_center_id = cost_center_id
|
|
|
|
|
+ elif run is not None and run.cost_center_id is not None:
|
|
|
|
|
+ actual_cost_center_id = run.cost_center_id
|
|
|
|
|
+ else:
|
|
|
|
|
+ actual_cost_center_id = archive.cost_center_id
|
|
|
# No user: deleted while the print ran, or never had one (login turned
|
|
# No user: deleted while the print ran, or never had one (login turned
|
|
|
# off). A cost centre's print is still its cost: charged to the cost
|
|
# off). A cost centre's print is still its cost: charged to the cost
|
|
|
# centre without a user (shown as "No user"), which also consumes its
|
|
# centre without a user (shown as "No user"), which also consumes its
|
|
@@ -194,21 +245,20 @@ async def apply_print_charge_for_archive(
|
|
|
# Nothing to charge, but the print is over: settle what it held
|
|
# Nothing to charge, but the print is over: settle what it held
|
|
|
# (reserved from an estimate, e.g. an older archive with grams but
|
|
# (reserved from an estimate, e.g. an older archive with grams but
|
|
|
# no stored cost), as the zero-charge case below does.
|
|
# no stored cost), as the zero-charge case below does.
|
|
|
- if print_queue_id is not None:
|
|
|
|
|
- await release_budget_reservation(
|
|
|
|
|
- db, source_type="print_queue", source_id=print_queue_id, status="released"
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- await release_budget_reservation(db, print_archive_id=archive.id, status="released")
|
|
|
|
|
|
|
+ await _settle_reservation(db, "released", archive_id=archive.id, print_queue_id=print_queue_id)
|
|
|
logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
|
|
logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
|
- # New dispatches persist a UUID before sending the printer command.
|
|
|
|
|
- # Generate one here only for legacy/in-flight rows created before that
|
|
|
|
|
- # migration; the locked archive row makes this fallback durable.
|
|
|
|
|
|
|
+ # New dispatches persist a UUID before sending the printer command,
|
|
|
|
|
+ # and a run gets its own when it opens. Generate one here only for
|
|
|
|
|
+ # legacy/in-flight rows created before that; the locked row makes this
|
|
|
|
|
+ # fallback durable.
|
|
|
if not effective_run_id:
|
|
if not effective_run_id:
|
|
|
effective_run_id = str(uuid.uuid4())
|
|
effective_run_id = str(uuid.uuid4())
|
|
|
- archive.billing_run_id = effective_run_id
|
|
|
|
|
|
|
+ if run is None:
|
|
|
|
|
+ archive.billing_run_id = effective_run_id
|
|
|
|
|
+ if run is not None and run.billing_run_id != effective_run_id:
|
|
|
|
|
+ run.billing_run_id = effective_run_id
|
|
|
|
|
|
|
|
tx_conditions = [
|
|
tx_conditions = [
|
|
|
WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
|
|
WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
|
|
@@ -237,14 +287,10 @@ async def apply_print_charge_for_archive(
|
|
|
archive,
|
|
archive,
|
|
|
base_cost,
|
|
base_cost,
|
|
|
filament_usage=filament_usage,
|
|
filament_usage=filament_usage,
|
|
|
|
|
+ status=charged_status,
|
|
|
)
|
|
)
|
|
|
if charge <= 0:
|
|
if charge <= 0:
|
|
|
- if print_queue_id is not None:
|
|
|
|
|
- await release_budget_reservation(
|
|
|
|
|
- db, source_type="print_queue", source_id=print_queue_id, status="released"
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- await release_budget_reservation(db, print_archive_id=archive.id, status="released")
|
|
|
|
|
|
|
+ await _settle_reservation(db, "released", archive_id=archive.id, print_queue_id=print_queue_id)
|
|
|
logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
|
|
logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
|
|
|
return False
|
|
return False
|
|
|
|
|
|
|
@@ -313,10 +359,7 @@ async def apply_print_charge_for_archive(
|
|
|
new_wallet_balance = await sync_personal_wallet_balance(db, wallet) if wallet is not None else None
|
|
new_wallet_balance = await sync_personal_wallet_balance(db, wallet) if wallet is not None else None
|
|
|
|
|
|
|
|
# Consume matching budget reservations after the transaction is persisted
|
|
# Consume matching budget reservations after the transaction is persisted
|
|
|
- if print_queue_id is not None:
|
|
|
|
|
- await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status="consumed")
|
|
|
|
|
- else:
|
|
|
|
|
- await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
|
|
|
|
|
|
|
+ await _settle_reservation(db, "consumed", archive_id=archive.id, print_queue_id=print_queue_id)
|
|
|
logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
|
|
logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
|
|
|
return True
|
|
return True
|
|
|
except SQLAlchemyError as e:
|
|
except SQLAlchemyError as e:
|