feat(finance): add cost center management and wallet transactions in the backend

- Introduced CostCenter and related models for managing print costs and budgets.
- Updated PrintArchive and PrintQueueItem models to include cost_center_id and estimated_cost.
- Implemented budget reservation logic in finance services to validate and manage print costs.
- Enhanced ArchiveService and BackgroundDispatchService to handle cost center information during print jobs.
- Added wallet transaction handling for print charges, including partial charges based on filament usage.
- Created finance billing and budget services to manage user wallets and budget reservations.
- Ensured user finance defaults are created upon user registration, including wallets and private cost centers.
- Updated print scheduler to validate budget before processing print jobs.
This commit is contained in:
behrinml
2026-06-24 10:52:26 +02:00
parent 733de133ab
commit 010687eafd
16 changed files with 2067 additions and 13 deletions
+8
View File
@@ -29,6 +29,7 @@ from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveSta
from backend.app.schemas.print_log import PrintLogResponse
from backend.app.schemas.slicer import SliceRequest
from backend.app.services.archive import ArchiveService
from backend.app.services.finance_budget import validate_print_budget
from backend.app.utils.http import build_content_disposition
from backend.app.utils.safe_path import safe_join_under
from backend.app.utils.threemf_tools import (
@@ -4078,6 +4079,13 @@ async def reprint_archive(
if not file_path.is_file():
raise HTTPException(404, "Archive file not found")
await validate_print_budget(
db,
cost_center_id=body.cost_center_id,
estimated_cost=body.estimated_cost,
current_user=user,
)
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
File diff suppressed because it is too large Load Diff
+8
View File
@@ -65,6 +65,7 @@ from backend.app.schemas.library import (
)
from backend.app.schemas.slicer import SliceRequest, SliceResponse
from backend.app.services.archive import ThreeMFParser
from backend.app.services.finance_budget import validate_print_budget
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
@@ -4181,6 +4182,13 @@ async def print_library_file(
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
await validate_print_budget(
db,
cost_center_id=body.cost_center_id,
estimated_cost=body.estimated_cost,
current_user=current_user,
)
plate_name = body.plate_name
if not plate_name and body.plate_id is not None:
plate_name = f"Plate {body.plate_id}"
+41 -9
View File
@@ -36,6 +36,7 @@ from backend.app.schemas.print_queue import (
PrintQueueReorder,
)
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.finance_budget import validate_print_budget
from backend.app.services.notification_service import notification_service
from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
from backend.app.utils.threemf_tools import (
@@ -164,6 +165,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"waiting_reason": item.waiting_reason,
"archive_id": item.archive_id,
"library_file_id": item.library_file_id,
"cost_center_id": item.cost_center_id,
"estimated_cost": item.estimated_cost,
"position": item.position,
"scheduled_time": item.scheduled_time,
"require_previous_success": item.require_previous_success,
@@ -545,6 +548,14 @@ async def add_to_queue(
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
await validate_print_budget(
db,
cost_center_id=data.cost_center_id,
estimated_cost=data.estimated_cost,
current_user=current_user,
quantity=quantity,
)
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
items = []
for i in range(quantity):
@@ -556,6 +567,8 @@ async def add_to_queue(
filament_overrides=filament_overrides_json,
archive_id=data.archive_id,
library_file_id=data.library_file_id,
cost_center_id=data.cost_center_id,
estimated_cost=data.estimated_cost,
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
@@ -672,6 +685,7 @@ async def bulk_update_queue_items(
updated_count = 0
skipped_count = 0
validates_billing_fields = "cost_center_id" in update_data or "estimated_cost" in update_data
for item in items:
if item.status != "pending":
@@ -683,6 +697,15 @@ async def bulk_update_queue_items(
skipped_count += 1
continue
if validates_billing_fields:
await validate_print_budget(
db,
cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=update_data.get("estimated_cost", item.estimated_cost),
current_user=user,
exclude_queue_item_id=item.id,
)
for field, value in update_data.items():
setattr(item, field, value)
updated_count += 1
@@ -1022,12 +1045,13 @@ async def update_queue_item(
json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
)
# Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
# storage; same Text-as-opaque-blob convention as ams_mapping above.
if "nozzle_mapping" in update_data:
update_data["nozzle_mapping"] = (
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
)
await validate_print_budget(
db,
cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=update_data.get("estimated_cost", item.estimated_cost),
current_user=user,
exclude_queue_item_id=item.id,
)
for field, value in update_data.items():
setattr(item, field, value)
@@ -1212,7 +1236,7 @@ async def start_queue_item(
item_id: int,
skip_filament_check: bool = Query(default=False),
db: AsyncSession = Depends(get_db),
user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
):
"""Manually start a staged (manual_start) queue item.
@@ -1240,6 +1264,14 @@ async def start_queue_item(
if item.status != "pending":
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
await validate_print_budget(
db,
cost_center_id=item.cost_center_id,
estimated_cost=item.estimated_cost,
current_user=current_user,
exclude_queue_item_id=item.id,
)
# Live deficit check — re-evaluated against current spool state, so a
# spool swap between scheduler flagging and the user clicking ▶ clears
# the block automatically.
@@ -1269,8 +1301,8 @@ async def start_queue_item(
# (#1670). An item that already has a creator (UI-added queue items)
# keeps that attribution; the dispatcher is not promoted over the
# original uploader.
if user is not None and item.created_by_id is None:
item.created_by_id = user.id
if current_user is not None and item.created_by_id is None:
item.created_by_id = current_user.id
await db.commit()
await db.refresh(item, ["archive", "printer", "library_file", "created_by", "batch"])
+3
View File
@@ -135,6 +135,8 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"default_vibration_cali",
"default_layer_inspect",
"default_timelapse",
"billing_enabled",
"printer_kill_switch_enabled",
"default_nozzle_offset_cali",
"ldap_enabled",
"ldap_auto_provision",
@@ -161,6 +163,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"stagger_group_size",
"stagger_interval_minutes",
"forecast_global_lead_time_days",
"finance_budget_reset_day",
"session_max_hours",
]:
settings_dict[setting.key] = int(setting.value)
+5
View File
@@ -41,6 +41,7 @@ from backend.app.services.email_service import (
get_smtp_settings,
send_email,
)
from backend.app.services.finance_defaults import ensure_user_finance_defaults
router = APIRouter(prefix="/users", tags=["users"])
@@ -164,6 +165,8 @@ async def create_user(
new_user.groups = list(groups)
db.add(new_user)
await db.flush()
await ensure_user_finance_defaults(db, new_user)
await db.commit()
await db.refresh(new_user)
@@ -307,6 +310,8 @@ async def update_user(
)
user.groups = list(groups)
await ensure_user_finance_defaults(db, user)
await db.commit()
result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
user = result.scalar_one()
+122
View File
@@ -30,6 +30,7 @@ from backend.app.api.routes import (
discovery,
external_links,
filaments,
finance,
firmware,
github_backup,
groups,
@@ -372,6 +373,9 @@ _expected_prints: dict[tuple[int, str], int] = {}
# Used by usage tracker to map 3MF slots to physical AMS trays
_print_ams_mappings: dict[int, list[int]] = {}
# Track cost center selection for the current print run: {archive_id: cost_center_id}
_print_cost_center_ids: dict[int, int] = {}
# Track plate_id for prints from multi-plate 3MFs: {archive_id: plate_id}
# Used by usage tracker to scope 3MF parsing to the dispatched plate (#1697).
# Populated by direct-Print and queue dispatch paths; queue prints also have a
@@ -386,6 +390,9 @@ _last_progress_milestone: dict[int, int] = {}
# Track whether first layer complete notification has been sent for current print
_first_layer_notified: dict[int, bool] = {}
# Track whether we already sent a kill-switch stop for the current unauthorized print
_unauthorized_print_kill_sent: set[int] = set()
# Track HMS errors that have been notified: {printer_id: set of error codes}
# This prevents sending duplicate notifications for the same error
_notified_hms_errors: dict[int, set[str]] = {}
@@ -611,6 +618,54 @@ _expected_print_registered_at: dict[tuple[int, str], float] = {}
_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60 # 15 minutes
_expected_prints_cleanup_task: asyncio.Task | None = None
_ACTIVE_PRINT_STATES: set[str] = {"RUNNING", "PRINTING", "PAUSE"}
def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple[int, str]]:
"""Build filename keys for matching a printer status update to Bambuddy-owned jobs."""
possible_keys: list[tuple[int, str]] = []
filename = (state.gcode_file or state.current_print or "").strip()
subtask_name = (state.subtask_name or "").strip()
if subtask_name:
possible_keys.append((printer_id, subtask_name))
possible_keys.append((printer_id, f"{subtask_name}.3mf"))
possible_keys.append((printer_id, f"{subtask_name}.gcode.3mf"))
if filename:
base_name = filename.rsplit("/", 1)[-1]
if base_name.endswith(".gcode.3mf"):
root_name = base_name[: -len(".gcode.3mf")]
possible_keys.append((printer_id, root_name))
possible_keys.append((printer_id, base_name))
possible_keys.append((printer_id, f"{root_name}.gcode"))
possible_keys.append((printer_id, f"{root_name}.3mf"))
elif base_name.endswith(".3mf"):
root_name = base_name[: -len(".3mf")]
possible_keys.append((printer_id, root_name))
possible_keys.append((printer_id, base_name))
elif base_name.endswith(".gcode"):
root_name = base_name[: -len(".gcode")]
possible_keys.append((printer_id, root_name))
possible_keys.append((printer_id, f"{root_name}.3mf"))
possible_keys.append((printer_id, base_name))
else:
possible_keys.append((printer_id, base_name))
possible_keys.append((printer_id, f"{base_name}.3mf"))
return possible_keys
def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState) -> bool:
"""Return True when the current status belongs to a print started by Bambuddy."""
if printer_manager.get_current_print_user(printer_id):
return True
possible_keys = _build_status_print_keys(printer_id, state)
return any(key in _expected_prints or key in _active_prints for key in possible_keys)
async def _get_plug_energy(plug, db) -> dict | None:
"""Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
@@ -683,6 +738,7 @@ def register_expected_print(
archive_id: int,
ams_mapping: list[int] | None = None,
created_by_id: int | None = None,
cost_center_id: int | None = None,
plate_id: int | None = None,
):
"""Register an expected print from reprint/scheduled so we don't create duplicate archives."""
@@ -696,6 +752,8 @@ def register_expected_print(
# Store AMS mapping for usage tracking at print completion
if ams_mapping is not None:
_print_ams_mappings[archive_id] = ams_mapping
if cost_center_id is not None:
_print_cost_center_ids[archive_id] = cost_center_id
# Store plate_id for usage tracking when this is a single-plate dispatch from
# a multi-plate 3MF — without this, the direct-Print path attributes the whole
# file's filament total to the spool instead of just the printed plate (#1697).
@@ -1184,6 +1242,45 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
)
is_active_print = state.state in _ACTIVE_PRINT_STATES
if not is_active_print:
_unauthorized_print_kill_sent.discard(printer_id)
else:
kill_switch_enabled = False
status_logger = logging.getLogger(__name__)
try:
async with async_session() as db:
from backend.app.services.finance_budget import is_printer_kill_switch_enabled
kill_switch_enabled = await is_printer_kill_switch_enabled(db)
except Exception as e:
status_logger.warning("[KILL SWITCH] Failed to read kill-switch setting for printer %s: %s", printer_id, e)
if not kill_switch_enabled or _is_bambuddy_authorized_print(printer_id, state):
_unauthorized_print_kill_sent.discard(printer_id)
elif printer_id in _unauthorized_print_kill_sent:
pass
else:
try:
stopped = printer_manager.stop_print(printer_id)
if stopped:
_unauthorized_print_kill_sent.add(printer_id)
status_logger.warning(
"[KILL SWITCH] Stopped unauthorized print on printer %s (state=%s)",
printer_id,
state.state,
)
else:
status_logger.warning(
"[KILL SWITCH] Could not stop unauthorized print on printer %s (state=%s)",
printer_id,
state.state,
)
except Exception as e:
status_logger.warning(
"[KILL SWITCH] Failed to stop unauthorized print on printer %s: %s", printer_id, e
)
# MQTT relay - publish status (before dedup check - always publish to MQTT)
try:
printer_info = printer_manager.get_printer(printer_id)
@@ -4603,6 +4700,29 @@ async def on_print_complete(printer_id: int, data: dict):
log_timing("Archive status update")
# Apply finance wallet charge or release reservations once
try:
if data.get("status") in ("completed", "failed", "aborted", "cancelled"):
async with async_session() as db:
from backend.app.models.archive import PrintArchive
from backend.app.services.finance_billing import apply_print_charge_for_archive
archive = await db.get(PrintArchive, archive_id)
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,
)
await db.commit()
if charged:
logger.info("[FINANCE] Applied print charge for archive %s", archive_id)
except Exception as e:
logger.warning("[FINANCE] Failed to apply print charge for archive %s: %s", archive_id, e)
log_timing("Finance charge update")
# Write independent print log entry (separate table, never touches archives)
try:
async with async_session() as db:
@@ -5830,6 +5950,7 @@ def _evict_stale_expected_prints() -> None:
for archive_id in evicted_archive_ids:
if archive_id not in live_archive_ids:
_print_ams_mappings.pop(archive_id, None)
_print_cost_center_ids.pop(archive_id, None)
_print_plate_ids.pop(archive_id, None)
logging.getLogger(__name__).info(
@@ -6711,6 +6832,7 @@ app.include_router(groups.router, prefix=app_settings.api_prefix)
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
app.include_router(finance.router, prefix=app_settings.api_prefix)
app.include_router(inventory.router, prefix=app_settings.api_prefix)
app.include_router(labels.router, prefix=app_settings.api_prefix)
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
+6
View File
@@ -12,6 +12,9 @@ class PrintArchive(Base):
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
cost_center_id: Mapped[int | None] = mapped_column(
ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
)
# File info
filename: Mapped[str] = mapped_column(String(255))
@@ -68,6 +71,7 @@ class PrintArchive(Base):
# User additions
is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False)
tags: Mapped[str | None] = mapped_column(Text)
notes: Mapped[str | None] = mapped_column(Text)
cost: Mapped[float | None] = mapped_column(Float)
@@ -98,9 +102,11 @@ class PrintArchive(Base):
# Relationships
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
project: Mapped["Project | None"] = relationship(back_populates="archives")
cost_center: Mapped["CostCenter | None"] = relationship()
created_by: Mapped["User | None"] = relationship()
from backend.app.models.finance import CostCenter # noqa: E402, F811
from backend.app.models.printer import Printer # noqa: E402, F811
from backend.app.models.project import Project # noqa: E402, F811
from backend.app.models.user import User # noqa: E402, F811
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum as PyEnum
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.archive import PrintArchive
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.user import User
class TransactionType(str, PyEnum):
PRINT_CHARGE = "print_charge"
DEPOSIT = "deposit"
WITHDRAW = "withdraw"
MANUAL_ADJUSTMENT = "manual_adjustment"
VALID_TRANSACTION_TYPES = {item.value for item in TransactionType}
def normalize_transaction_type(value: str | TransactionType) -> str:
if isinstance(value, TransactionType):
return value.value
if value not in VALID_TRANSACTION_TYPES:
raise ValueError(f"Invalid transaction type: {value}")
return value
class UserWallet(Base):
"""Per-user wallet balance.
Balance updates are driven by wallet transactions.
"""
__tablename__ = "user_wallets"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
balance: Mapped[float] = mapped_column(Float, default=0.0)
currency: Mapped[str] = mapped_column(String(3), default="EUR")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
user: Mapped[User] = relationship()
class CostCenter(Base):
"""Cost center for assigning print costs and budgets."""
__tablename__ = "cost_centers"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
name: Mapped[str] = mapped_column(String(150), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_private: Mapped[bool] = mapped_column(Boolean, default=False)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
total_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
monthly_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
owner: Mapped[User | None] = relationship()
members: Mapped[list[CostCenterMember]] = relationship(
"CostCenterMember",
back_populates="cost_center",
cascade="all, delete-orphan",
lazy="selectin",
)
class CostCenterMember(Base):
"""User-to-cost-center assignment with print permission."""
__tablename__ = "cost_center_members"
__table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
id: Mapped[int] = mapped_column(primary_key=True)
cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
can_print: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
user: Mapped[User] = relationship()
class BudgetReservation(Base):
"""Persisted budget hold for accepted print work that has not been charged yet."""
__tablename__ = "budget_reservations"
id: Mapped[int] = mapped_column(primary_key=True)
cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
amount: Mapped[float] = mapped_column(Float)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
source_type: Mapped[str] = mapped_column(String(50), index=True)
source_id: Mapped[int | None] = mapped_column(index=True)
print_archive_id: Mapped[int | None] = mapped_column(
ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
cost_center: Mapped[CostCenter] = relationship()
print_archive: Mapped[PrintArchive | None] = relationship()
class WalletTransaction(Base):
"""Immutable wallet ledger entry."""
__tablename__ = "wallet_transactions"
__table_args__ = (
CheckConstraint(
"transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
name="ck_wallet_transactions_transaction_type",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
cost_center_id: Mapped[int | None] = mapped_column(
ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
)
transaction_type: Mapped[str] = mapped_column(String(40), index=True)
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float | None] = mapped_column(Float, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
print_archive_id: Mapped[int | None] = mapped_column(
ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
)
print_queue_id: Mapped[int | None] = mapped_column(
ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
user: Mapped[User] = relationship(foreign_keys=[user_id])
cost_center: Mapped[CostCenter | None] = relationship()
created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
print_archive: Mapped[PrintArchive | None] = relationship()
print_queue: Mapped[PrintQueueItem | None] = relationship()
@validates("transaction_type")
def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
return normalize_transaction_type(value)
+7 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -32,6 +32,10 @@ class PrintQueueItem(Base):
library_file_id: Mapped[int | None] = mapped_column(
ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
)
cost_center_id: Mapped[int | None] = mapped_column(
ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
)
estimated_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
@@ -120,12 +124,14 @@ class PrintQueueItem(Base):
printer: Mapped["Printer"] = relationship()
archive: Mapped["PrintArchive | None"] = relationship()
library_file: Mapped["LibraryFile | None"] = relationship()
cost_center: Mapped["CostCenter | None"] = relationship()
project: Mapped["Project | None"] = relationship(back_populates="queue_items")
batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
created_by: Mapped["User | None"] = relationship()
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.finance import CostCenter # noqa: E402
from backend.app.models.library import LibraryFile # noqa: E402
from backend.app.models.print_batch import PrintBatch # noqa: E402
from backend.app.models.printer import Printer # noqa: E402
+2
View File
@@ -1127,6 +1127,7 @@ class ArchiveService:
created_by_id: int | None = None,
original_filename: str | None = None,
project_id: int | None = None,
cost_center_id: int | None = None,
subtask_id: str | None = None,
prefer_filename_for_name: bool = False,
) -> PrintArchive | None:
@@ -1300,6 +1301,7 @@ class ArchiveService:
extra_data=metadata,
created_by_id=created_by_id,
project_id=project_id,
cost_center_id=cost_center_id,
subtask_id=subtask_id,
)
+60 -3
View File
@@ -22,8 +22,10 @@ from backend.app.core.config import settings
from backend.app.core.database import async_session
from backend.app.core.tasks import spawn_background_task
from backend.app.core.websocket import ws_manager
from backend.app.models.finance import BudgetReservation, CostCenter
from backend.app.models.library import LibraryFile
from backend.app.models.printer import Printer
from backend.app.models.user import User
from backend.app.services.archive import ArchiveService
from backend.app.services.bambu_ftp import (
cache_3mf_download,
@@ -32,6 +34,7 @@ from backend.app.services.bambu_ftp import (
upload_file_async,
with_ftp_retry,
)
from backend.app.services.finance_budget import create_budget_reservation, release_budget_reservation
from backend.app.services.printer_manager import printer_manager
from backend.app.utils.filename import derive_remote_filename
@@ -116,12 +119,16 @@ class BackgroundDispatchService:
dispatcher = self._dispatcher_task
self._dispatcher_task = None
running_tasks = list(self._running_tasks.values())
jobs_to_release = [*self._queued_jobs, *(state.job for state in self._active_jobs.values())]
self._running_tasks.clear()
self._active_jobs.clear()
self._queued_jobs.clear()
self._cancel_requested_job_ids.clear()
self._job_event.set()
for job in jobs_to_release:
await self._release_budget_reservation(job, status="released")
if dispatcher:
dispatcher.cancel()
for task in running_tasks:
@@ -290,8 +297,22 @@ class BackgroundDispatchService:
raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
job_id = self._next_job_id
async with async_session() as db:
requested_by = await db.get(User, requested_by_user_id) if requested_by_user_id is not None else None
await create_budget_reservation(
db,
cost_center_id=options.get("cost_center_id"),
estimated_cost=options.get("estimated_cost"),
current_user=requested_by,
source_type="background_dispatch",
source_id=job_id,
print_archive_id=source_id if kind == "reprint_archive" else None,
)
await db.commit()
job = PrintDispatchJob(
id=self._next_job_id,
id=job_id,
kind=kind,
source_id=source_id,
source_name=source_name,
@@ -431,6 +452,9 @@ class BackgroundDispatchService:
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
if failed:
await self._release_budget_reservation(job, status="released")
async with self._lock:
if failed:
self._batch_failed += 1
@@ -463,6 +487,8 @@ class BackgroundDispatchService:
self._batch_failed = 0
async def _mark_job_cancelled(self, job: PrintDispatchJob):
await self._release_budget_reservation(job, status="released")
async with self._lock:
self._active_jobs.pop(job.id, None)
self._running_tasks.pop(job.id, None)
@@ -493,6 +519,19 @@ class BackgroundDispatchService:
if self._is_cancel_requested(job.id):
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
@staticmethod
async def _release_budget_reservation(job: PrintDispatchJob, *, status: str):
if job.options.get("cost_center_id") is None:
return
async with async_session() as db:
await release_budget_reservation(
db,
source_type="background_dispatch",
source_id=job.id,
status=status,
)
await db.commit()
def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
processing = len(self._active_jobs)
dispatched = len(self._queued_jobs)
@@ -565,6 +604,12 @@ class BackgroundDispatchService:
if not archive:
raise RuntimeError("Archive not found")
cost_center_id = job.options.get("cost_center_id")
if cost_center_id is not None:
cost_center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id))
if not cost_center:
raise RuntimeError("Cost center not found")
printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
if not printer:
raise RuntimeError("Printer not found")
@@ -669,6 +714,7 @@ class BackgroundDispatchService:
remote_filename,
job.source_id,
ams_mapping=job.options.get("ams_mapping"),
cost_center_id=job.options.get("cost_center_id"),
plate_id=plate_id,
)
@@ -783,11 +829,21 @@ class BackgroundDispatchService:
original_filename=lib_file.filename,
project_id=job.project_id,
created_by_id=job.requested_by_user_id,
cost_center_id=job.options.get("cost_center_id"),
)
if not archive:
raise RuntimeError("Failed to create archive")
await db.flush()
if job.options.get("cost_center_id") is not None:
reservation = await db.scalar(
select(BudgetReservation).where(
BudgetReservation.source_type == "background_dispatch",
BudgetReservation.source_id == job.id,
BudgetReservation.status == "active",
)
)
if reservation:
reservation.print_archive_id = archive.id
await db.flush()
remote_filename = derive_remote_filename(lib_file.filename)
remote_path = f"/{remote_filename}"
@@ -876,6 +932,7 @@ class BackgroundDispatchService:
remote_filename,
archive.id,
ams_mapping=job.options.get("ams_mapping"),
cost_center_id=job.options.get("cost_center_id"),
plate_id=plate_id,
)
+213
View File
@@ -0,0 +1,213 @@
import logging
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
logger = logging.getLogger(__name__)
async def _get_balance_after_for_transaction(
db: AsyncSession,
user_id: int,
cost_center_id: int | None,
amount: float,
) -> float:
"""Calculate balance_after for a transaction.
For cost-center transactions: sum of ALL transactions for that cost center (global).
For personal transactions (cost_center_id=None): user's wallet balance (personal).
Args:
user_id: The user making the transaction
cost_center_id: The cost center (None for personal)
amount: The transaction amount (positive/negative)
Returns:
The balance after this transaction would be applied
"""
try:
if cost_center_id is None:
# Personal transaction: use user wallet balance
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
if wallet is None:
return float(amount)
return float(wallet.balance) + amount
else:
# Cost-center transaction: sum of ALL transactions for this cost center (global, not per-user)
result = await db.execute(
select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
WalletTransaction.cost_center_id == cost_center_id,
)
)
current_balance = float(result.scalar() or 0.0)
return current_balance + amount
except SQLAlchemyError as e:
logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
raise
def _calculate_partial_charge(
archive: PrintArchive,
base_cost: float,
) -> tuple[float, str]:
"""Calculate proportional charge for partial prints based on filament usage.
Returns (charge_amount, description_suffix) where:
- charge_amount: absolute cost to charge (0 if insufficient data)
- description_suffix: reason/details for transaction description
"""
try:
# Only apply proportional calculation for non-completed prints
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 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.
if filament_planned is None or filament_planned <= 0:
return 0.0, f"[{archive.status}: insufficient filament data]"
# Calculate proportional cost
filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
charge = float(base_cost) * filament_ratio
# Round charges to 2 decimals for consistent persistence
charge = round(charge, 2)
suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
return charge, suffix
except ValueError as e:
logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
raise
async def apply_print_charge_for_archive(
db: AsyncSession,
archive_id: int,
*,
cost_center_id: int | None = None,
print_run_id: str | None = None,
) -> bool:
"""Apply an idempotent wallet charge for a print archive.
Charges completed prints at full cost, and partial/failed prints proportionally
based on actual filament used vs. planned filament.
Returns True when a new wallet transaction was created.
"""
try:
if not await is_billing_enabled(db):
await release_budget_reservation(db, print_archive_id=archive_id, status="released")
logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
return False
archive = (
await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
).scalar_one_or_none()
if archive is None:
logger.warning(f"Archive with ID {archive_id} not found.")
return False
if archive.wallet_charge_skipped:
logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
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.")
return False
if archive.created_by_id is None:
logger.warning(f"Archive ID {archive_id} has no creator ID.")
return False
base_cost = float(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
tx_conditions = [WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value]
if print_run_id:
tx_conditions.append(WalletTransaction.print_run_id == print_run_id)
else:
tx_conditions.append(WalletTransaction.print_archive_id == archive.id)
existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
if existing_tx is not None:
logger.info(f"Transaction already exists for archive ID {archive_id}.")
return False
# Calculate charge (full for completed, partial for others)
charge, reason_suffix = _calculate_partial_charge(archive, base_cost)
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.")
return False
actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
wallet = (
await db.execute(select(UserWallet).where(UserWallet.user_id == archive.created_by_id))
).scalar_one_or_none()
if wallet is None:
wallet = UserWallet(user_id=archive.created_by_id, balance=0.0, currency="EUR")
db.add(wallet)
await db.flush()
logger.info(f"Created new wallet for user ID {archive.created_by_id}.")
# Persist wallet balances rounded to cents
new_wallet_balance = round(float(wallet.balance) - charge, 2)
wallet.balance = new_wallet_balance
label = archive.print_name or archive.filename or f"Archive {archive.id}"
description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
balance_after = await _get_balance_after_for_transaction(
db, archive.created_by_id, actual_cost_center_id, -charge
)
if balance_after is not None:
balance_after = round(float(balance_after), 2)
tx = WalletTransaction(
user_id=archive.created_by_id,
cost_center_id=actual_cost_center_id,
transaction_type=TransactionType.PRINT_CHARGE.value,
amount=-charge,
balance_after=balance_after,
description=description,
created_by_user_id=None,
print_run_id=print_run_id or archive.subtask_id,
print_archive_id=archive.id,
)
db.add(tx)
# Ensure the transaction is flushed to detect unique/index constraint violations
try:
await db.flush()
except IntegrityError as e:
# Another concurrent worker likely created the same transaction
logger.info("Transaction already exists for archive ID %s (concurrent), skipping: %s", archive_id, e)
await db.rollback()
return False
# Consume matching budget reservations after the transaction is persisted
await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
return True
except SQLAlchemyError as e:
logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
return False
except ValueError as e:
logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
return False
+234
View File
@@ -0,0 +1,234 @@
"""Budget validation helpers for finance-aware print dispatch."""
import calendar
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.finance import BudgetReservation, CostCenter, CostCenterMember, WalletTransaction
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
async def is_billing_enabled(db: AsyncSession) -> bool:
# Consider any 'billing_enabled' setting with a true-ish value as enabling billing.
result = await db.execute(
select(func.count())
.select_from(Settings)
.where(Settings.key == "billing_enabled", func.lower(func.coalesce(Settings.value, "")) == "true")
)
count = int(result.scalar_one() or 0)
return count > 0
async def is_printer_kill_switch_enabled(db: AsyncSession) -> bool:
"""Return True when billing and the printer kill-switch are both enabled."""
result = await db.execute(
select(Settings.key, Settings.value).where(Settings.key.in_(("billing_enabled", "printer_kill_switch_enabled")))
)
values = {key: (value or "").strip().lower() for key, value in result.all()}
return values.get("billing_enabled") == "true" and values.get("printer_kill_switch_enabled") == "true"
async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
result = await db.execute(
select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
)
values = {setting.key: setting.value for setting in result.scalars().all()}
desired_day = 1
try:
parsed = int(values.get("finance_budget_reset_day") or 1)
if 1 <= parsed <= 31:
desired_day = parsed
except (TypeError, ValueError):
pass
timezone_name = values.get("finance_budget_reset_timezone") or "UTC"
try:
tz = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
tz = ZoneInfo("UTC")
now = datetime.now(tz)
current_month_reset_day = min(desired_day, calendar.monthrange(now.year, now.month)[1])
if now.day < current_month_reset_day:
month = now.month - 1
year = now.year
if month == 0:
month = 12
year -= 1
else:
month = now.month
year = now.year
reset_day = min(desired_day, calendar.monthrange(year, month)[1])
return datetime(year, month, reset_day, tzinfo=tz).astimezone(timezone.utc)
async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly: bool) -> float:
spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
conditions = [
WalletTransaction.cost_center_id == cost_center_id,
WalletTransaction.cost_center_id.is_not(None),
]
if monthly:
conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
result = await db.execute(select(func.coalesce(func.sum(spend_expr), 0.0)).where(*conditions))
return float(result.scalar() or 0.0)
async def _cost_center_open_queue_reservations(
db: AsyncSession,
cost_center_id: int,
*,
exclude_queue_item_id: int | None = None,
) -> float:
conditions = [
PrintQueueItem.cost_center_id == cost_center_id,
PrintQueueItem.status.in_(("pending", "printing")),
]
if exclude_queue_item_id is not None:
conditions.append(PrintQueueItem.id != exclude_queue_item_id)
result = await db.execute(select(func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0)).where(*conditions))
return float(result.scalar() or 0.0)
async def _cost_center_active_budget_reservations(db: AsyncSession, cost_center_id: int) -> float:
result = await db.execute(
select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(
BudgetReservation.cost_center_id == cost_center_id,
BudgetReservation.status == "active",
)
)
return float(result.scalar() or 0.0)
async def validate_print_budget(
db: AsyncSession,
*,
cost_center_id: int | None,
estimated_cost: float | None,
current_user: User | None,
quantity: int = 1,
exclude_queue_item_id: int | None = None,
) -> None:
"""Validate that a print can be assigned to a cost center budget."""
if not await is_billing_enabled(db):
return
if cost_center_id is None:
raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
if estimated_cost is None or estimated_cost <= 0:
raise HTTPException(status_code=400, detail="Estimated cost is required for cost center prints")
center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id).with_for_update())
if not center:
raise HTTPException(status_code=404, detail="Cost center not found")
if not center.is_active:
raise HTTPException(status_code=400, detail="Cost center is inactive")
if current_user is not None and not current_user.is_admin:
if center.is_private:
if center.owner_user_id != current_user.id:
raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
else:
member = await db.scalar(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == cost_center_id,
CostCenterMember.user_id == current_user.id,
)
)
if not member or not member.can_print:
raise HTTPException(status_code=403, detail="You cannot print with this cost center")
budget_limit = center.monthly_budget if center.monthly_budget is not None else center.total_budget
if budget_limit is None:
return
used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
reserved = await _cost_center_open_queue_reservations(
db,
cost_center_id,
exclude_queue_item_id=exclude_queue_item_id,
)
reserved += await _cost_center_active_budget_reservations(db, cost_center_id)
requested = estimated_cost * max(1, quantity)
available = float(budget_limit) - used - reserved
if requested > available:
raise HTTPException(
status_code=400,
detail=f"Estimated print cost exceeds available cost center budget ({requested:.2f} > {available:.2f})",
)
async def create_budget_reservation(
db: AsyncSession,
*,
cost_center_id: int | None,
estimated_cost: float | None,
current_user: User | None,
source_type: str,
source_id: int | None,
print_archive_id: int | None = None,
) -> BudgetReservation | None:
if not await is_billing_enabled(db):
return None
if cost_center_id is None:
raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
await validate_print_budget(
db,
cost_center_id=cost_center_id,
estimated_cost=estimated_cost,
current_user=current_user,
)
reservation = BudgetReservation(
cost_center_id=cost_center_id,
amount=float(estimated_cost or 0.0),
status="active",
source_type=source_type,
source_id=source_id,
print_archive_id=print_archive_id,
)
db.add(reservation)
await db.flush()
return reservation
async def release_budget_reservation(
db: AsyncSession,
*,
source_type: str | None = None,
source_id: int | None = None,
print_archive_id: int | None = None,
status: str = "released",
) -> int:
conditions = [BudgetReservation.status == "active"]
if print_archive_id is not None:
conditions.append(BudgetReservation.print_archive_id == print_archive_id)
else:
conditions.extend(
[
BudgetReservation.source_type == source_type,
BudgetReservation.source_id == source_id,
]
)
result = await db.execute(select(BudgetReservation).where(*conditions))
reservations = result.scalars().all()
for reservation in reservations:
reservation.status = status
reservation.released_at = datetime.now(timezone.utc)
if reservations:
await db.flush()
return len(reservations)
+68
View File
@@ -0,0 +1,68 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
from backend.app.models.settings import Settings as AppSettingModel
from backend.app.models.user import User
from backend.app.schemas.settings import AppSettings as AppSettingsSchema
async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
"""Ensure wallet and private cost center defaults exist for a user.
Returns True when database objects were created or changed.
"""
changed = False
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
if wallet is None:
# Respect admin-configured currency if present, otherwise fall back to app default
default_currency = AppSettingsSchema().currency
result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
setting = result.scalar_one_or_none()
currency = setting.value if setting and setting.value else default_currency
db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
changed = True
private_center = (
(
await db.execute(
select(CostCenter)
.where(
CostCenter.is_private.is_(True),
CostCenter.owner_user_id == user.id,
)
.order_by(CostCenter.id.asc())
)
)
.scalars()
.first()
)
if private_center is None:
private_center = CostCenter(
name=user.username,
is_active=True,
is_private=True,
owner_user_id=user.id,
)
db.add(private_center)
await db.flush()
changed = True
elif private_center.name != user.username:
private_center.name = user.username
changed = True
membership = (
await db.execute(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == private_center.id,
CostCenterMember.user_id == user.id,
)
)
).scalar_one_or_none()
if membership is None:
db.add(CostCenterMember(cost_center_id=private_center.id, user_id=user.id, can_print=True))
changed = True
return changed
+44
View File
@@ -7,6 +7,7 @@ import time
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -15,6 +16,7 @@ from backend.app.core.config import settings
from backend.app.core.database import async_session, run_with_retry
from backend.app.core.tasks import spawn_background_task
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import CostCenter, CostCenterMember
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
@@ -22,6 +24,7 @@ from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
from backend.app.models.user import User
from backend.app.services.bambu_ftp import (
cache_3mf_download,
delete_file_async,
@@ -30,6 +33,7 @@ from backend.app.services.bambu_ftp import (
with_ftp_retry,
)
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.finance_budget import validate_print_budget
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import printer_manager, supports_drying
from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -2054,6 +2058,44 @@ class PrintScheduler:
"""
logger.info("Starting queue item %s", item.id)
try:
queue_user = await db.get(User, item.created_by_id) if item.created_by_id is not None else None
if queue_user is not None and item.cost_center_id is not None and not queue_user.is_admin:
center = await db.scalar(
select(CostCenter).where(CostCenter.id == item.cost_center_id).with_for_update()
)
if not center:
raise HTTPException(status_code=404, detail="Cost center not found")
if not center.is_active:
raise HTTPException(status_code=400, detail="Cost center is inactive")
if center.is_private:
if center.owner_user_id != queue_user.id:
raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
else:
member = await db.scalar(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == item.cost_center_id,
CostCenterMember.user_id == queue_user.id,
)
)
if not member or not member.can_print:
raise HTTPException(status_code=403, detail="You cannot print with this cost center")
await validate_print_budget(
db,
cost_center_id=item.cost_center_id,
estimated_cost=item.estimated_cost,
current_user=queue_user,
exclude_queue_item_id=item.id,
)
except HTTPException as exc:
item.status = "failed"
item.error_message = getattr(exc, "detail", str(exc))
item.completed_at = datetime.now(timezone.utc)
await db.commit()
logger.error("Queue item %s: Budget check failed: %s", item.id, item.error_message)
await self._power_off_if_needed(db, item)
return
# Get printer first (needed for both paths)
result = await db.execute(select(Printer).where(Printer.id == item.printer_id))
printer = result.scalar_one_or_none()
@@ -2126,6 +2168,7 @@ class PrintScheduler:
original_filename=filename,
created_by_id=item.created_by_id,
project_id=item.project_id,
cost_center_id=item.cost_center_id,
)
if archive:
item.archive_id = archive.id
@@ -2288,6 +2331,7 @@ class PrintScheduler:
archive.id,
ams_mapping=ams_mapping,
created_by_id=item.created_by_id,
cost_center_id=item.cost_center_id,
plate_id=item.plate_id,
)