mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
implemented pr feedback #2
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, case, func, or_, select
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -39,6 +39,11 @@ from backend.app.schemas.finance import (
|
||||
WalletTransactionListResponse,
|
||||
WalletTransactionResponse,
|
||||
)
|
||||
from backend.app.services.finance_balance import (
|
||||
is_personal_transaction,
|
||||
personal_balance_condition,
|
||||
sync_personal_wallet_balance,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/finance", tags=["finance"])
|
||||
|
||||
@@ -306,35 +311,21 @@ async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[in
|
||||
select(
|
||||
WalletTransaction.id,
|
||||
WalletTransaction.amount,
|
||||
WalletTransaction.cost_center_id,
|
||||
CostCenter.is_private,
|
||||
CostCenter.owner_user_id,
|
||||
)
|
||||
.where(
|
||||
WalletTransaction.user_id == user_id,
|
||||
)
|
||||
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
||||
.where(WalletTransaction.user_id == user_id, personal_balance_condition(user_id))
|
||||
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
||||
)
|
||||
|
||||
running_balance = 0.0
|
||||
balance_map: dict[int, float] = {}
|
||||
for transaction_id, amount, cost_center_id, is_private, owner_user_id in result.all():
|
||||
is_personal_cost_center = bool(cost_center_id is not None and is_private and owner_user_id == user_id)
|
||||
if cost_center_id is None or is_personal_cost_center:
|
||||
running_balance += float(amount)
|
||||
balance_map[int(transaction_id)] = running_balance
|
||||
for transaction_id, amount in result.all():
|
||||
running_balance += float(amount)
|
||||
balance_map[int(transaction_id)] = running_balance
|
||||
|
||||
return balance_map
|
||||
|
||||
|
||||
def _personal_balance_condition(user_id: int):
|
||||
return or_(
|
||||
WalletTransaction.cost_center_id.is_(None),
|
||||
and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
|
||||
)
|
||||
|
||||
|
||||
async def _create_wallet_adjustment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -351,6 +342,7 @@ async def _create_wallet_adjustment(
|
||||
await _get_cost_center_or_404(db, cost_center_id)
|
||||
|
||||
wallet = await _get_or_create_wallet(db, target_user_id)
|
||||
affects_personal_wallet = await is_personal_transaction(db, target_user_id, cost_center_id)
|
||||
|
||||
# Calculate balance_after for this specific transaction context
|
||||
if cost_center_id is None:
|
||||
@@ -358,7 +350,6 @@ async def _create_wallet_adjustment(
|
||||
new_balance = wallet.balance + amount
|
||||
if new_balance < 0:
|
||||
raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
|
||||
wallet.balance = new_balance
|
||||
balance_after = new_balance
|
||||
else:
|
||||
# Cost-center transaction: validate against cost center balance only (global, not per-user)
|
||||
@@ -372,7 +363,8 @@ async def _create_wallet_adjustment(
|
||||
if new_cc_balance < 0:
|
||||
raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
|
||||
balance_after = new_cc_balance
|
||||
# Do NOT update wallet.balance for cost-center transactions
|
||||
if affects_personal_wallet and wallet.balance + amount < 0:
|
||||
raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
|
||||
|
||||
tx = WalletTransaction(
|
||||
user_id=target_user_id,
|
||||
@@ -385,12 +377,13 @@ async def _create_wallet_adjustment(
|
||||
)
|
||||
db.add(tx)
|
||||
await db.flush()
|
||||
await sync_personal_wallet_balance(db, wallet)
|
||||
await db.commit()
|
||||
await db.refresh(wallet)
|
||||
await db.refresh(tx)
|
||||
|
||||
# Return appropriate balance based on transaction type
|
||||
if cost_center_id is None:
|
||||
if affects_personal_wallet:
|
||||
# Personal transaction: return user wallet balance
|
||||
response_balance = _to_balance_response(wallet)
|
||||
else:
|
||||
@@ -416,19 +409,7 @@ async def get_my_balance(
|
||||
"""Return the current user's wallet balance."""
|
||||
user = await _require_authenticated_user(current_user)
|
||||
wallet = await _get_or_create_wallet(db, user.id)
|
||||
personal_balance_result = await db.execute(
|
||||
select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
|
||||
.select_from(WalletTransaction)
|
||||
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
||||
.where(WalletTransaction.user_id == user.id, _personal_balance_condition(user.id))
|
||||
)
|
||||
personal_balance = float(personal_balance_result.scalar_one() or 0.0)
|
||||
return WalletBalanceResponse(
|
||||
user_id=user.id,
|
||||
balance=personal_balance,
|
||||
currency=wallet.currency,
|
||||
updated_at=wallet.updated_at,
|
||||
)
|
||||
return _to_balance_response(wallet)
|
||||
|
||||
|
||||
@router.get("/me/transactions", response_model=WalletTransactionListResponse)
|
||||
@@ -504,47 +485,10 @@ async def get_all_transactions(
|
||||
|
||||
|
||||
async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
|
||||
"""Recompute `balance_after` for all wallet transactions of a user.
|
||||
"""Rebuild through the same canonical ledger repair used at startup."""
|
||||
from backend.app.core.database import repair_wallet_ledger_internal
|
||||
|
||||
- Personal transactions (cost_center_id=None): running balance per user
|
||||
- Cost-center transactions: running balance GLOBAL for entire cost center (not per-user)
|
||||
- Also updates the user's wallet balance (sum of personal transactions only)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(WalletTransaction)
|
||||
.where(WalletTransaction.user_id == user_id)
|
||||
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
||||
)
|
||||
user_transactions = result.scalars().all()
|
||||
|
||||
# Handle personal transactions (cost_center_id=None)
|
||||
personal_balance = 0.0
|
||||
for tx in user_transactions:
|
||||
if tx.cost_center_id is None:
|
||||
personal_balance += float(tx.amount)
|
||||
tx.balance_after = personal_balance
|
||||
db.add(tx)
|
||||
|
||||
for cc_id in {tx.cost_center_id for tx in user_transactions if tx.cost_center_id is not None}:
|
||||
# Get all transactions for this cost center (all users, all time)
|
||||
result_all_cc = await db.execute(
|
||||
select(WalletTransaction)
|
||||
.where(WalletTransaction.cost_center_id == cc_id)
|
||||
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
||||
)
|
||||
all_cc_transactions = result_all_cc.scalars().all()
|
||||
|
||||
running = 0.0
|
||||
for tx in all_cc_transactions:
|
||||
running += float(tx.amount)
|
||||
tx.balance_after = running
|
||||
db.add(tx)
|
||||
|
||||
# Update user wallet balance (sum of all personal transactions only)
|
||||
wallet = await _get_or_create_wallet(db, user_id)
|
||||
wallet.balance = personal_balance
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await repair_wallet_ledger_internal(db)
|
||||
|
||||
|
||||
@router.delete("/transactions/{transaction_id}")
|
||||
@@ -794,51 +738,19 @@ async def rebuild_balance_ledger(
|
||||
"""Recompute balance_after for all wallet transactions.
|
||||
|
||||
This rebuilds the running balance for all users and cost centers.
|
||||
- Personal transactions: per-user running balance
|
||||
- Personal transactions: unassigned plus the user's own private cost center
|
||||
- Cost-center transactions: global running balance for the entire cost center
|
||||
"""
|
||||
await _require_authenticated_user(current_user)
|
||||
|
||||
# Get ALL transactions sorted by timestamp
|
||||
result = await db.execute(
|
||||
select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
|
||||
)
|
||||
all_transactions = result.scalars().all()
|
||||
from backend.app.core.database import repair_wallet_ledger_internal
|
||||
|
||||
# Build running balances per (user, cost_center_id) pair
|
||||
# For each cost center, track its global running balance
|
||||
# For each user's personal balance, track that separately
|
||||
cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
|
||||
user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
|
||||
|
||||
tx_updates: list[tuple[WalletTransaction, float]] = []
|
||||
|
||||
for tx in all_transactions:
|
||||
if tx.cost_center_id is None:
|
||||
# Personal transaction: per-user running balance
|
||||
current = user_personal_balances.get(tx.user_id, 0.0)
|
||||
new_balance = current + float(tx.amount)
|
||||
user_personal_balances[tx.user_id] = new_balance
|
||||
tx_updates.append((tx, new_balance))
|
||||
else:
|
||||
# Cost-center transaction: global running balance for this cost center
|
||||
current = cc_running_balances.get(tx.cost_center_id, 0.0)
|
||||
new_balance = current + float(tx.amount)
|
||||
cc_running_balances[tx.cost_center_id] = new_balance
|
||||
tx_updates.append((tx, new_balance))
|
||||
|
||||
# Update all transactions with the new balance_after values
|
||||
for tx, new_balance in tx_updates:
|
||||
tx.balance_after = new_balance
|
||||
db.add(tx)
|
||||
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
rebuilt = await repair_wallet_ledger_internal(db)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"transactions_rebuilt": len(all_transactions),
|
||||
"message": f"Rebuilt balance_after for {len(all_transactions)} transactions",
|
||||
"transactions_rebuilt": rebuilt,
|
||||
"message": f"Rebuilt {rebuilt} wallet ledger values",
|
||||
}
|
||||
|
||||
|
||||
@@ -1053,6 +965,12 @@ async def delete_cost_center(
|
||||
raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
|
||||
|
||||
await db.delete(center)
|
||||
await db.flush()
|
||||
# ON DELETE SET NULL reclassifies the former shared-center entries as
|
||||
# personal transactions, so synchronize affected wallets immediately.
|
||||
from backend.app.core.database import repair_wallet_ledger_internal
|
||||
|
||||
await repair_wallet_ledger_internal(db)
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ from backend.app.services.print_batch import (
|
||||
load_progress,
|
||||
refresh_batch_status,
|
||||
)
|
||||
from backend.app.services.print_cost_estimate import estimate_queue_source_cost
|
||||
from backend.app.utils.printer_models import (
|
||||
is_gcode_compatible,
|
||||
)
|
||||
@@ -279,6 +280,55 @@ async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path |
|
||||
return None
|
||||
|
||||
|
||||
async def _trusted_item_estimated_cost(
|
||||
db: AsyncSession,
|
||||
item: PrintQueueItem,
|
||||
*,
|
||||
printer_id: int | None,
|
||||
plate_id: int | None,
|
||||
ams_mapping: list[int] | str | None,
|
||||
) -> float | None:
|
||||
"""Recompute an existing queue item's cost from its persisted source."""
|
||||
|
||||
if item.archive_id:
|
||||
archive = await db.scalar(select(PrintArchive).where(PrintArchive.id == item.archive_id))
|
||||
return await estimate_queue_source_cost(
|
||||
db,
|
||||
archive=archive,
|
||||
plate_id=plate_id,
|
||||
ams_mapping=ams_mapping,
|
||||
printer_id=printer_id,
|
||||
)
|
||||
if item.library_file_id:
|
||||
library_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
|
||||
return await estimate_queue_source_cost(
|
||||
db,
|
||||
library_file=library_file,
|
||||
plate_id=plate_id,
|
||||
ams_mapping=ams_mapping,
|
||||
printer_id=printer_id,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintQueueVariant, LibraryFile)
|
||||
.join(LibraryFile, LibraryFile.id == PrintQueueVariant.library_file_id)
|
||||
.where(PrintQueueVariant.queue_item_id == item.id)
|
||||
)
|
||||
estimates = [
|
||||
await estimate_queue_source_cost(
|
||||
db,
|
||||
library_file=library_file,
|
||||
plate_id=variant.plate_id,
|
||||
ams_mapping=variant.ams_mapping,
|
||||
printer_id=printer_id,
|
||||
)
|
||||
for variant, library_file in result.all()
|
||||
]
|
||||
if not estimates or any(cost is None for cost in estimates):
|
||||
return None
|
||||
return max(cost for cost in estimates if cost is not None)
|
||||
|
||||
|
||||
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
||||
"""Add nested archive/printer/library_file info to response."""
|
||||
# Parse ams_mapping from JSON string BEFORE model_validate
|
||||
@@ -945,10 +995,39 @@ async def add_to_queue(
|
||||
if not project_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Security boundary: the browser's estimated_cost is only a display hint.
|
||||
# Budget enforcement and the persisted reservation value must be derived
|
||||
# from the server-owned archive/library metadata and spool assignments.
|
||||
if variant_specs:
|
||||
variant_costs = [
|
||||
await estimate_queue_source_cost(
|
||||
db,
|
||||
library_file=variant_file,
|
||||
plate_id=spec.plate_id,
|
||||
ams_mapping=spec.ams_mapping,
|
||||
printer_id=data.printer_id,
|
||||
)
|
||||
for spec, variant_file, _model in variant_specs
|
||||
]
|
||||
trusted_estimated_cost = (
|
||||
max(cost for cost in variant_costs if cost is not None)
|
||||
if variant_costs and all(cost is not None for cost in variant_costs)
|
||||
else None
|
||||
)
|
||||
else:
|
||||
trusted_estimated_cost = await estimate_queue_source_cost(
|
||||
db,
|
||||
archive=archive,
|
||||
library_file=library_file,
|
||||
plate_id=data.plate_id,
|
||||
ams_mapping=data.ams_mapping,
|
||||
printer_id=data.printer_id,
|
||||
)
|
||||
|
||||
await validate_print_budget(
|
||||
db,
|
||||
cost_center_id=data.cost_center_id,
|
||||
estimated_cost=data.estimated_cost,
|
||||
estimated_cost=trusted_estimated_cost,
|
||||
current_user=current_user,
|
||||
quantity=quantity,
|
||||
)
|
||||
@@ -1010,7 +1089,7 @@ async def add_to_queue(
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
cost_center_id=data.cost_center_id,
|
||||
estimated_cost=data.estimated_cost,
|
||||
estimated_cost=trusted_estimated_cost,
|
||||
scheduled_time=data.scheduled_time,
|
||||
require_previous_success=data.require_previous_success,
|
||||
auto_off_after=data.auto_off_after,
|
||||
@@ -1161,16 +1240,25 @@ async def bulk_update_queue_items(
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
item_update_data = update_data.copy()
|
||||
if validates_billing_fields:
|
||||
trusted_estimated_cost = await _trusted_item_estimated_cost(
|
||||
db,
|
||||
item,
|
||||
printer_id=item_update_data.get("printer_id", item.printer_id),
|
||||
plate_id=item.plate_id,
|
||||
ams_mapping=item.ams_mapping,
|
||||
)
|
||||
item_update_data["estimated_cost"] = trusted_estimated_cost
|
||||
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),
|
||||
cost_center_id=item_update_data.get("cost_center_id", item.cost_center_id),
|
||||
estimated_cost=trusted_estimated_cost,
|
||||
current_user=user,
|
||||
exclude_queue_item_id=item.id,
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
for field, value in item_update_data.items():
|
||||
setattr(item, field, value)
|
||||
updated_count += 1
|
||||
|
||||
@@ -1826,10 +1914,19 @@ async def update_queue_item(
|
||||
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
|
||||
)
|
||||
|
||||
trusted_estimated_cost = await _trusted_item_estimated_cost(
|
||||
db,
|
||||
item,
|
||||
printer_id=update_data.get("printer_id", item.printer_id),
|
||||
plate_id=update_data.get("plate_id", item.plate_id),
|
||||
ams_mapping=update_data.get("ams_mapping", item.ams_mapping),
|
||||
)
|
||||
update_data["estimated_cost"] = trusted_estimated_cost
|
||||
|
||||
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),
|
||||
estimated_cost=trusted_estimated_cost,
|
||||
current_user=user,
|
||||
exclude_queue_item_id=item.id,
|
||||
)
|
||||
@@ -2169,6 +2266,14 @@ async def start_queue_item(
|
||||
if item.status != "pending":
|
||||
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
|
||||
|
||||
item.estimated_cost = await _trusted_item_estimated_cost(
|
||||
db,
|
||||
item,
|
||||
printer_id=item.printer_id,
|
||||
plate_id=item.plate_id,
|
||||
ams_mapping=item.ams_mapping,
|
||||
)
|
||||
|
||||
await validate_print_budget(
|
||||
db,
|
||||
cost_center_id=item.cost_center_id,
|
||||
|
||||
@@ -1232,6 +1232,14 @@ async def _migrate_create_finance_indexes(conn) -> None:
|
||||
await _safe_execute(conn, statement)
|
||||
|
||||
|
||||
async def _migrate_add_print_archive_cost_center(conn) -> None:
|
||||
"""Add the nullable cost-center link missing from pre-billing archives."""
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"ALTER TABLE print_archives ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL",
|
||||
)
|
||||
|
||||
|
||||
async def run_migrations(conn):
|
||||
"""Run all schema migrations and data backfills on startup.
|
||||
|
||||
@@ -1585,6 +1593,9 @@ async def run_migrations(conn):
|
||||
except (OperationalError, ProgrammingError):
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add cost_center_id column to print_archives for billing metadata
|
||||
await _migrate_add_print_archive_cost_center(conn)
|
||||
|
||||
# Migration: Add wiki_url column to maintenance_types for documentation links
|
||||
await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
|
||||
|
||||
@@ -4913,21 +4924,11 @@ async def repair_wallet_ledger():
|
||||
|
||||
This is called during database initialization to ensure all balance_after
|
||||
values are correct after code changes. Fixes old balance_after values to:
|
||||
- Personal transactions: per-user running balance
|
||||
- Personal transactions: unassigned plus the user's own private cost center
|
||||
- Cost-center transactions: global running balance for the entire cost center
|
||||
Also updates UserWallet.balance to match final personal transaction balance.
|
||||
Also updates UserWallet.balance to the canonical personal ledger sum.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.finance import WalletTransaction
|
||||
|
||||
async with async_session() as session:
|
||||
# Check if there are any transactions first
|
||||
result = await session.execute(select(WalletTransaction).limit(1))
|
||||
if not result.scalar_one_or_none():
|
||||
logger.info("No wallet transactions found, skipping ledger rebuild")
|
||||
return
|
||||
|
||||
updated_count = await repair_wallet_ledger_internal(session)
|
||||
await session.commit()
|
||||
|
||||
@@ -4942,7 +4943,8 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.finance import UserWallet, WalletTransaction
|
||||
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
|
||||
from backend.app.services.finance_balance import transaction_affects_personal_balance
|
||||
|
||||
# Get ALL transactions sorted by timestamp
|
||||
result = await session.execute(
|
||||
@@ -4950,8 +4952,10 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
|
||||
)
|
||||
all_transactions = result.scalars().all()
|
||||
|
||||
if not all_transactions:
|
||||
return 0
|
||||
center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
|
||||
centers = {
|
||||
int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
|
||||
}
|
||||
|
||||
# Build running balances per (user, cost_center_id) pair
|
||||
cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
|
||||
@@ -4960,6 +4964,13 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
|
||||
tx_updates: list[tuple[WalletTransaction, float]] = []
|
||||
|
||||
for tx in all_transactions:
|
||||
center_is_private, center_owner_user_id = centers.get(tx.cost_center_id, (False, None))
|
||||
affects_personal = transaction_affects_personal_balance(
|
||||
tx.user_id,
|
||||
tx.cost_center_id,
|
||||
is_private=center_is_private,
|
||||
owner_user_id=center_owner_user_id,
|
||||
)
|
||||
if tx.cost_center_id is None:
|
||||
# Personal transaction: per-user running balance
|
||||
current = user_personal_balances.get(tx.user_id, 0.0)
|
||||
@@ -4973,6 +4984,11 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
|
||||
cc_running_balances[tx.cost_center_id] = new_balance
|
||||
tx_updates.append((tx, new_balance))
|
||||
|
||||
# The owner's private cost center is also part of that user's
|
||||
# personal wallet. Shared centers never enter this sum.
|
||||
if affects_personal:
|
||||
user_personal_balances[tx.user_id] = user_personal_balances.get(tx.user_id, 0.0) + float(tx.amount)
|
||||
|
||||
# Update all transactions with the new balance_after values
|
||||
updated_count = 0
|
||||
for tx, new_balance in tx_updates:
|
||||
@@ -4981,11 +4997,12 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
|
||||
session.add(tx)
|
||||
updated_count += 1
|
||||
|
||||
# Update UserWallet balances to match final personal balances
|
||||
for user_id, balance in user_personal_balances.items():
|
||||
wallet_result = await session.execute(select(UserWallet).where(UserWallet.user_id == user_id))
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet and wallet.balance != balance:
|
||||
# Update every wallet, including stale wallets whose canonical balance is
|
||||
# now zero because their last personal transaction was deleted.
|
||||
wallet_result = await session.execute(select(UserWallet))
|
||||
for wallet in wallet_result.scalars().all():
|
||||
balance = round(user_personal_balances.get(wallet.user_id, 0.0), 2)
|
||||
if wallet.balance != balance:
|
||||
wallet.balance = balance
|
||||
session.add(wallet)
|
||||
updated_count += 1
|
||||
|
||||
+173
-65
@@ -106,6 +106,7 @@ from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.obico_detection import obico_detection_service
|
||||
from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
|
||||
from backend.app.services.print_scheduler import scheduler as print_scheduler
|
||||
from backend.app.services.printer_manager import (
|
||||
init_printer_connections,
|
||||
@@ -425,6 +426,11 @@ _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()
|
||||
|
||||
# Provider notification started when the kill switch stops a print. The later
|
||||
# MQTT print-complete callback awaits this task and only sends its regular
|
||||
# provider notification when the immediate attempt failed.
|
||||
_kill_switch_notification_tasks: dict[int, asyncio.Task[bool]] = {}
|
||||
|
||||
# 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]] = {}
|
||||
@@ -689,14 +695,94 @@ def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple
|
||||
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."""
|
||||
async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db) -> bool | None:
|
||||
"""Resolve whether the current print was started by Bambuddy.
|
||||
|
||||
``None`` means identity is not yet safe to decide. The kill switch must
|
||||
defer in that case: stopping a print is irreversible, and the first status
|
||||
frames after a restart may arrive before all subtask fields are populated.
|
||||
"""
|
||||
|
||||
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)
|
||||
if any(key in _expected_prints or key in _active_prints for key in possible_keys):
|
||||
return True
|
||||
|
||||
# In-memory ownership is lost on every Bambuddy restart. The archive row is
|
||||
# the durable source of truth; subtask_id is minted per print and avoids
|
||||
# authorizing an unrelated job that happens to reuse the same filename.
|
||||
raw_subtask_id = getattr(state, "subtask_id", None)
|
||||
subtask_id = str(raw_subtask_id).strip() if raw_subtask_id is not None else ""
|
||||
if subtask_id in ("", "0"):
|
||||
return None
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(
|
||||
PrintArchive.printer_id == printer_id,
|
||||
PrintArchive.status == "printing",
|
||||
PrintArchive.subtask_id == subtask_id,
|
||||
)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive is None:
|
||||
return False
|
||||
|
||||
# Rehydrate the fast in-memory path for subsequent status frames. Include
|
||||
# both the archive filename and every normalized key reported by MQTT.
|
||||
_active_prints[(printer_id, archive.filename)] = archive.id
|
||||
for key in possible_keys:
|
||||
_active_prints[key] = archive.id
|
||||
return True
|
||||
|
||||
|
||||
async def _send_kill_switch_provider_notification(
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""Send the immediate print-stopped provider notification.
|
||||
|
||||
Returning a success flag lets the normal MQTT completion path retry when
|
||||
this early notification could not be delivered.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
await notification_service.on_print_complete(
|
||||
printer_id,
|
||||
printer_name,
|
||||
"stopped",
|
||||
data,
|
||||
db,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[KILL SWITCH] Immediate provider notification failed for printer %s: %s",
|
||||
printer_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _kill_switch_notification_already_sent(task: asyncio.Task[bool] | None) -> bool:
|
||||
"""Wait for an immediate kill-switch notification, if one was scheduled."""
|
||||
|
||||
if task is None:
|
||||
return False
|
||||
try:
|
||||
return await task
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning("[KILL SWITCH] Notification task failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _get_plug_energy(plug, db) -> dict | None:
|
||||
@@ -888,41 +974,6 @@ def _compute_run_filament_grams(
|
||||
return None
|
||||
|
||||
|
||||
def _plate_scoped_run_estimate(archive, full_path) -> tuple[float | None, float | None]:
|
||||
"""Per-run (grams, cost) scoped to the plate this run actually printed (#2614).
|
||||
|
||||
``PrintArchive.filament_used_grams`` / ``.cost`` are the sum over EVERY plate of
|
||||
the source 3MF — correct for the archive card and project rollup, but wrong for a
|
||||
single plate dispatched from a multi-plate file: without scoping, each printed
|
||||
plate of a 22-plate file logs the whole ~12 kg and inflates every statistic. When
|
||||
the archive carries a ``plate_id`` and its 3MF is on disk, return that plate's
|
||||
slicer estimate instead; cost is scaled by the plate's share of the whole so it
|
||||
stays consistent with the scoped grams without re-doing the filament price lookup.
|
||||
Falls back to the archive's whole-file values when there's no plate to scope to.
|
||||
"""
|
||||
whole_grams = archive.filament_used_grams
|
||||
if archive.plate_id is None or full_path is None or not full_path.exists():
|
||||
return whole_grams, archive.cost
|
||||
try:
|
||||
from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
|
||||
|
||||
plate_grams = extract_plate_metadata_from_3mf(full_path, archive.plate_id).filament_used_grams
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).debug(
|
||||
"[#2614] plate-scoped estimate failed for archive %s (plate %s): %s",
|
||||
archive.id,
|
||||
archive.plate_id,
|
||||
exc,
|
||||
)
|
||||
return whole_grams, archive.cost
|
||||
if not plate_grams or plate_grams <= 0:
|
||||
return whole_grams, archive.cost
|
||||
plate_cost = archive.cost
|
||||
if archive.cost and whole_grams and whole_grams > 0:
|
||||
plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
|
||||
return round(plate_grams, 2), plate_cost
|
||||
|
||||
|
||||
def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | None:
|
||||
"""Resolve AMS mapping for print start without consuming stored queue/reprint state."""
|
||||
stored_ams_mapping = data.get("ams_mapping")
|
||||
@@ -1380,17 +1431,31 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
_unauthorized_print_kill_sent.discard(printer_id)
|
||||
else:
|
||||
kill_switch_enabled = False
|
||||
authorization: bool | None = None
|
||||
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)
|
||||
if kill_switch_enabled:
|
||||
authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
|
||||
except Exception as e:
|
||||
status_logger.warning("[KILL SWITCH] Failed to read kill-switch setting for printer %s: %s", printer_id, e)
|
||||
# Fail safe: a database/reconciliation error must never turn into an
|
||||
# irreversible stop of a print whose ownership is still unknown.
|
||||
authorization = None
|
||||
status_logger.warning(
|
||||
"[KILL SWITCH] Failed to reconcile print authorization for printer %s: %s", printer_id, e
|
||||
)
|
||||
|
||||
if not kill_switch_enabled or _is_bambuddy_authorized_print(printer_id, state):
|
||||
if not kill_switch_enabled or authorization is True:
|
||||
_unauthorized_print_kill_sent.discard(printer_id)
|
||||
elif authorization is None:
|
||||
_unauthorized_print_kill_sent.discard(printer_id)
|
||||
status_logger.debug(
|
||||
"[KILL SWITCH] Deferring authorization for printer %s until archive state is reconciled",
|
||||
printer_id,
|
||||
)
|
||||
elif printer_id in _unauthorized_print_kill_sent:
|
||||
pass
|
||||
else:
|
||||
@@ -1398,11 +1463,43 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
stopped = printer_manager.stop_print(printer_id)
|
||||
if stopped:
|
||||
_unauthorized_print_kill_sent.add(printer_id)
|
||||
printer_info = printer_manager.get_printer(printer_id)
|
||||
printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
|
||||
filename = state.subtask_name or state.gcode_file or state.current_print or "Unknown"
|
||||
notification_data = {
|
||||
"status": "stopped",
|
||||
"filename": state.gcode_file or state.current_print or "",
|
||||
"subtask_name": state.subtask_name or "",
|
||||
"progress": state.progress,
|
||||
"reason": "unauthorized_print",
|
||||
}
|
||||
status_logger.warning(
|
||||
"[KILL SWITCH] Stopped unauthorized print on printer %s (state=%s)",
|
||||
printer_id,
|
||||
state.state,
|
||||
)
|
||||
try:
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "kill_switch_triggered",
|
||||
"printer_id": printer_id,
|
||||
"printer_name": printer_name,
|
||||
"filename": filename,
|
||||
"reason": "unauthorized_print",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
status_logger.warning(
|
||||
"[KILL SWITCH] WebSocket notification failed for printer %s: %s", printer_id, e
|
||||
)
|
||||
|
||||
previous_task = _kill_switch_notification_tasks.pop(printer_id, None)
|
||||
if previous_task is not None and not previous_task.done():
|
||||
previous_task.cancel()
|
||||
_kill_switch_notification_tasks[printer_id] = spawn_background_task(
|
||||
_send_kill_switch_provider_notification(printer_id, printer_name, notification_data),
|
||||
name=f"kill-switch-notification-{printer_id}",
|
||||
)
|
||||
else:
|
||||
status_logger.warning(
|
||||
"[KILL SWITCH] Could not stop unauthorized print on printer %s (state=%s)",
|
||||
@@ -2583,6 +2680,7 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
# Clear any stale user-stopped flag from previous print cycles
|
||||
_user_stopped_printers.discard(printer_id)
|
||||
_kill_switch_notification_tasks.pop(printer_id, None)
|
||||
|
||||
# #1721: drop any leftover pre-captured finish frame from a prior print
|
||||
# so a never-consumed cache entry can't bleed into the new print's photo.
|
||||
@@ -4252,16 +4350,12 @@ async def _upgrade_finish_photo_from_timelapse(archive_id: int, archive_dir: Pat
|
||||
|
||||
|
||||
async def on_print_running_observed(printer_id: int, data: dict):
|
||||
"""Restart-recovery: capture a fresh timelapse baseline for a print that
|
||||
started before Bambuddy came up.
|
||||
"""Restart-recovery for a print that started before Bambuddy came up.
|
||||
|
||||
bambu_mqtt.py suppresses ``on_print_start`` on the first RUNNING push
|
||||
after Bambuddy startup (#1304 guard, prevents duplicate archive
|
||||
creation). Without that path, ``_capture_timelapse_baseline_at_start``
|
||||
never runs and ``_scan_for_timelapse_with_retries`` falls into its
|
||||
"take baseline now" fallback at completion time — but by then the
|
||||
printer has already uploaded the in-flight MP4, so the baseline
|
||||
includes it and no diff ever matches (#1485 follow-up).
|
||||
creation). This hook restores the persisted archive into ``_active_prints``
|
||||
and captures the timelapse baseline that normally hangs off print start.
|
||||
|
||||
Fires once per session, in lieu of on_print_start when restart-recovery
|
||||
kicks in. The printer doesn't upload the timelapse until after PRINT
|
||||
@@ -4270,21 +4364,15 @@ async def on_print_running_observed(printer_id: int, data: dict):
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Avoid double-capture: on_print_start may have run earlier in this
|
||||
# Bambuddy process if the print started AFTER startup and we crashed
|
||||
# later in the same session. (Realistically this can't happen — the
|
||||
# MQTT client object would have been recreated — but the cheap guard
|
||||
# is correct regardless.)
|
||||
if printer_id in _timelapse_baselines:
|
||||
logger.debug(
|
||||
"[TIMELAPSE] on_print_running_observed: baseline already present for printer %s, skipping",
|
||||
printer_id,
|
||||
)
|
||||
return
|
||||
|
||||
async with async_session() as db:
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if state is not None:
|
||||
authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
|
||||
if authorization is True:
|
||||
logger.info("[RESTART] Restored active Bambuddy print for printer %s", printer_id)
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
@@ -4294,6 +4382,15 @@ async def on_print_running_observed(printer_id: int, data: dict):
|
||||
)
|
||||
return
|
||||
|
||||
# Avoid double-capture: ownership reconciliation above must still run when
|
||||
# a baseline already exists, but the camera work itself is one-shot.
|
||||
if printer_id in _timelapse_baselines:
|
||||
logger.debug(
|
||||
"[TIMELAPSE] on_print_running_observed: baseline already present for printer %s, skipping",
|
||||
printer_id,
|
||||
)
|
||||
return
|
||||
|
||||
await _capture_timelapse_baseline_at_start(printer, printer_id, logger)
|
||||
|
||||
|
||||
@@ -4872,6 +4969,11 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
|
||||
logger.info("[CALLBACK] on_print_complete started for printer %s", printer_id)
|
||||
|
||||
# A kill-switch stop sends its provider notification immediately. Keep the
|
||||
# task so the later notification path can await it and avoid a duplicate;
|
||||
# if that immediate attempt failed, the regular completion path retries.
|
||||
kill_switch_notification_task = _kill_switch_notification_tasks.pop(printer_id, None)
|
||||
|
||||
# Drop the 3MF download cache for this printer (#972). The print is over,
|
||||
# nothing else legitimately needs the bytes; keeping them would only risk
|
||||
# handing a stale file to the next print if it reuses the same name.
|
||||
@@ -5452,9 +5554,12 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
logger.info(
|
||||
"[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps
|
||||
)
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, p_name, ps, data, db, archive_data=no_archive_data
|
||||
)
|
||||
if not await _kill_switch_notification_already_sent(kill_switch_notification_task):
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, p_name, ps, data, db, archive_data=no_archive_data
|
||||
)
|
||||
else:
|
||||
logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
|
||||
|
||||
# Send user-specific email if we have a created_by_id
|
||||
if no_archive_data and no_archive_data.get("created_by_id"):
|
||||
@@ -6134,9 +6239,12 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
except Exception as e:
|
||||
logger.warning("[NOTIFY-BG] Failed to read finish photo bytes: %s", e)
|
||||
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, printer_name, print_status, data, db, archive_data=archive_data
|
||||
)
|
||||
if not await _kill_switch_notification_already_sent(kill_switch_notification_task):
|
||||
await notification_service.on_print_complete(
|
||||
printer_id, printer_name, print_status, data, db, archive_data=archive_data
|
||||
)
|
||||
else:
|
||||
logger.info("[NOTIFY-BG] Skipped duplicate kill-switch provider notification")
|
||||
|
||||
# Send user-specific email notification
|
||||
if archive_data:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Canonical definition and synchronization of a user's personal balance."""
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
|
||||
|
||||
|
||||
def transaction_affects_personal_balance(
|
||||
user_id: int,
|
||||
cost_center_id: int | None,
|
||||
*,
|
||||
is_private: bool = False,
|
||||
owner_user_id: int | None = None,
|
||||
) -> bool:
|
||||
"""Apply the canonical definition to already-loaded transaction data."""
|
||||
|
||||
return cost_center_id is None or (is_private and owner_user_id == user_id)
|
||||
|
||||
|
||||
def personal_balance_condition(user_id: int):
|
||||
"""Return the SQL condition for transactions in a personal wallet.
|
||||
|
||||
Unassigned transactions and transactions assigned to the user's own
|
||||
private cost center are personal. Shared cost centers are not.
|
||||
"""
|
||||
|
||||
return or_(
|
||||
WalletTransaction.cost_center_id.is_(None),
|
||||
and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
|
||||
)
|
||||
|
||||
|
||||
async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
|
||||
.select_from(WalletTransaction)
|
||||
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
||||
.where(WalletTransaction.user_id == user_id, personal_balance_condition(user_id))
|
||||
)
|
||||
return round(float(result.scalar_one() or 0.0), 2)
|
||||
|
||||
|
||||
async def is_personal_transaction(db: AsyncSession, user_id: int, cost_center_id: int | None) -> bool:
|
||||
if cost_center_id is None:
|
||||
return True
|
||||
result = await db.execute(
|
||||
select(CostCenter.is_private, CostCenter.owner_user_id).where(CostCenter.id == cost_center_id)
|
||||
)
|
||||
center = result.one_or_none()
|
||||
if center is None:
|
||||
return False
|
||||
return transaction_affects_personal_balance(
|
||||
user_id,
|
||||
cost_center_id,
|
||||
is_private=bool(center.is_private),
|
||||
owner_user_id=center.owner_user_id,
|
||||
)
|
||||
|
||||
|
||||
async def sync_personal_wallet_balance(db: AsyncSession, wallet: UserWallet) -> float:
|
||||
balance = await calculate_personal_balance(db, wallet.user_id)
|
||||
wallet.balance = balance
|
||||
db.add(wallet)
|
||||
return balance
|
||||
@@ -6,6 +6,7 @@ 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_balance import sync_personal_wallet_balance
|
||||
from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -185,10 +186,6 @@ async def apply_print_charge_for_archive(
|
||||
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 ''}"
|
||||
|
||||
@@ -219,6 +216,10 @@ async def apply_print_charge_for_archive(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Rebuild from the canonical personal-ledger definition. A shared cost
|
||||
# center charge must not debit the user's personal wallet.
|
||||
new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
|
||||
|
||||
# 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}.")
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Trusted server-side cost estimates for queued prints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.utils import threemf_tools
|
||||
from backend.app.utils.safe_path import safe_join_under
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def plate_scoped_run_estimate(
|
||||
archive: PrintArchive,
|
||||
full_path: Path | None,
|
||||
plate_id: int | None = None,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""Return trusted ``(grams, cost)`` for one run of an archive plate."""
|
||||
|
||||
whole_grams = archive.filament_used_grams
|
||||
selected_plate = archive.plate_id if plate_id is None else plate_id
|
||||
if selected_plate is None or full_path is None or not full_path.exists():
|
||||
return whole_grams, archive.cost
|
||||
try:
|
||||
plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Plate-scoped estimate failed for archive %s (plate %s): %s",
|
||||
archive.id,
|
||||
selected_plate,
|
||||
exc,
|
||||
)
|
||||
return whole_grams, archive.cost
|
||||
if not plate_grams or plate_grams <= 0:
|
||||
return whole_grams, archive.cost
|
||||
plate_cost = archive.cost
|
||||
if archive.cost and whole_grams and whole_grams > 0:
|
||||
plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
|
||||
return round(plate_grams, 2), plate_cost
|
||||
|
||||
|
||||
def _source_path(library_file: LibraryFile) -> Path:
|
||||
path = Path(library_file.file_path)
|
||||
if path.is_absolute():
|
||||
# SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
|
||||
# configured external libraries; this branch performs no path join.
|
||||
return path
|
||||
return safe_join_under(settings.base_dir, library_file.file_path, http=False)
|
||||
|
||||
|
||||
def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
|
||||
if isinstance(mapping, list):
|
||||
return mapping
|
||||
if isinstance(mapping, str):
|
||||
try:
|
||||
parsed = json.loads(mapping)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
return None
|
||||
|
||||
|
||||
def _global_tray_id(assignment: SpoolAssignment) -> int:
|
||||
if assignment.ams_id == 255:
|
||||
return 254 + assignment.tray_id
|
||||
if assignment.ams_id >= 128:
|
||||
return assignment.ams_id
|
||||
return assignment.ams_id * 4 + assignment.tray_id
|
||||
|
||||
|
||||
async def _default_cost_per_kg(db: AsyncSession) -> float:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
raw = await get_setting(db, "default_filament_cost")
|
||||
try:
|
||||
return float(raw) if raw is not None else 25.0
|
||||
except (TypeError, ValueError):
|
||||
return 25.0
|
||||
|
||||
|
||||
async def estimate_queue_source_cost(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
archive: PrintArchive | None = None,
|
||||
library_file: LibraryFile | None = None,
|
||||
plate_id: int | None = None,
|
||||
ams_mapping: list[int] | str | None = None,
|
||||
printer_id: int | None = None,
|
||||
) -> float | None:
|
||||
"""Compute a queue cost without trusting the request's display hint."""
|
||||
|
||||
if archive is not None:
|
||||
archive_path = settings.base_dir / archive.file_path
|
||||
_grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
|
||||
return float(cost) if cost is not None and cost > 0 else None
|
||||
|
||||
if library_file is None:
|
||||
return None
|
||||
|
||||
path = _source_path(library_file)
|
||||
usage: list[dict] = []
|
||||
if path.exists():
|
||||
usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
|
||||
|
||||
metadata = library_file.file_metadata or {}
|
||||
if not usage:
|
||||
try:
|
||||
grams = float(metadata.get("filament_used_grams") or 0)
|
||||
except (TypeError, ValueError):
|
||||
grams = 0
|
||||
if grams > 0:
|
||||
usage = [{"slot_id": 1, "used_g": grams}]
|
||||
|
||||
if not usage:
|
||||
return None
|
||||
|
||||
default_cost = await _default_cost_per_kg(db)
|
||||
cost_by_tray: dict[int, float | None] = {}
|
||||
mapping = _parse_mapping(ams_mapping)
|
||||
if printer_id is not None and mapping:
|
||||
assignments = (
|
||||
(
|
||||
await db.execute(
|
||||
select(SpoolAssignment)
|
||||
.options(selectinload(SpoolAssignment.spool))
|
||||
.where(SpoolAssignment.printer_id == printer_id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
|
||||
|
||||
total = 0.0
|
||||
for filament in usage:
|
||||
try:
|
||||
slot_id = int(filament.get("slot_id") or 0)
|
||||
grams = float(filament.get("used_g") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
|
||||
cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
|
||||
if cost_per_kg is None or cost_per_kg <= 0:
|
||||
cost_per_kg = default_cost
|
||||
total += (grams / 1000.0) * cost_per_kg
|
||||
|
||||
return round(total, 2) if total > 0 else None
|
||||
@@ -43,6 +43,7 @@ from backend.app.services.finance_budget import (
|
||||
)
|
||||
from backend.app.services.ha_sensor_manager import ha_sensor_manager
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.print_cost_estimate import estimate_queue_source_cost
|
||||
from backend.app.services.printer_manager import (
|
||||
printer_manager,
|
||||
supports_airduct,
|
||||
@@ -3684,6 +3685,20 @@ class PrintScheduler:
|
||||
from backend.app.models.user import User
|
||||
|
||||
queue_user = await db.get(User, item.created_by_id) if item.created_by_id is not None else None
|
||||
# Recompute at the final authorization boundary as well as enqueue
|
||||
# time. This covers rows created before the server-side estimate
|
||||
# migration and prevents any alternate write path from weakening
|
||||
# the budget reservation.
|
||||
archive = await db.get(PrintArchive, item.archive_id) if item.archive_id is not None else None
|
||||
library_file = await db.get(LibraryFile, item.library_file_id) if item.library_file_id is not None else None
|
||||
item.estimated_cost = await estimate_queue_source_cost(
|
||||
db,
|
||||
archive=archive,
|
||||
library_file=library_file,
|
||||
plate_id=item.plate_id,
|
||||
ams_mapping=item.ams_mapping,
|
||||
printer_id=item.printer_id,
|
||||
)
|
||||
await validate_print_budget(
|
||||
db,
|
||||
cost_center_id=item.cost_center_id,
|
||||
|
||||
@@ -7,8 +7,10 @@ from sqlalchemy import select
|
||||
from backend.app.core.auth import get_password_hash
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.finance_billing import apply_print_charge_for_archive
|
||||
|
||||
|
||||
class TestFinanceAPI:
|
||||
@@ -143,34 +145,30 @@ class TestFinanceAPI:
|
||||
admin_user,
|
||||
db_session,
|
||||
):
|
||||
"""Test cost-center and personal transaction handling.
|
||||
|
||||
Cost-center transactions affect cost-center balance only.
|
||||
Personal transactions (no cost_center_id) affect user wallet.
|
||||
"""
|
||||
"""A user's private cost center and unassigned entries share one wallet."""
|
||||
await self._enable_basic_user_creation(db_session)
|
||||
created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
|
||||
|
||||
shared_center = await db_session.scalar(
|
||||
private_center = await db_session.scalar(
|
||||
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
|
||||
)
|
||||
assert shared_center is not None
|
||||
assert private_center is not None
|
||||
|
||||
# Deposit to cost center: affects cost-center balance, not user wallet
|
||||
# The owner's private cost center affects both its own ledger and the wallet.
|
||||
deposit = await async_client.post(
|
||||
f"/api/v1/finance/users/{created_user['id']}/deposit",
|
||||
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": shared_center.id},
|
||||
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert deposit.status_code == 200
|
||||
assert deposit.json()["transaction"]["cost_center_id"] == shared_center.id
|
||||
assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
|
||||
assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
|
||||
assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
|
||||
|
||||
# Withdraw from cost center: affects cost-center balance only
|
||||
# A withdrawal updates both views by the same amount.
|
||||
withdraw = await async_client.post(
|
||||
f"/api/v1/finance/users/{created_user['id']}/withdraw",
|
||||
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": shared_center.id},
|
||||
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert withdraw.status_code == 200
|
||||
@@ -186,8 +184,8 @@ class TestFinanceAPI:
|
||||
)
|
||||
assert personal_deposit.status_code == 200
|
||||
assert personal_deposit.json()["transaction"]["cost_center_id"] is None
|
||||
assert personal_deposit.json()["transaction"]["balance_after"] == 30.0 # Personal balance
|
||||
assert personal_deposit.json()["balance"]["balance"] == 30.0 # User wallet updated
|
||||
assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
|
||||
assert personal_deposit.json()["balance"]["balance"] == 50.0
|
||||
|
||||
transactions_response = await async_client.get(
|
||||
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
|
||||
@@ -195,7 +193,7 @@ class TestFinanceAPI:
|
||||
assert transactions_response.status_code == 200
|
||||
transactions = transactions_response.json()
|
||||
assert len(transactions) == 3
|
||||
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == shared_center.id]
|
||||
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
|
||||
personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
|
||||
assert len(cc_txs) == 2
|
||||
assert len(personal_txs) == 1
|
||||
@@ -204,7 +202,7 @@ class TestFinanceAPI:
|
||||
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
|
||||
)
|
||||
assert balance_response.status_code == 200
|
||||
assert balance_response.json()["balance"] == 30.0 # Only personal balance
|
||||
assert balance_response.json()["balance"] == 50.0
|
||||
|
||||
# Delete personal transaction, user wallet should decrease
|
||||
personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
|
||||
@@ -217,7 +215,7 @@ class TestFinanceAPI:
|
||||
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
|
||||
)
|
||||
assert balance_after_delete.status_code == 200
|
||||
assert balance_after_delete.json()["balance"] == 0.0 # Personal balance back to 0
|
||||
assert balance_after_delete.json()["balance"] == 20.0
|
||||
|
||||
remaining = await async_client.get(
|
||||
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
|
||||
@@ -225,6 +223,74 @@ class TestFinanceAPI:
|
||||
assert remaining.status_code == 200
|
||||
assert len(remaining.json()) == 2 # 2 CC transactions remain
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
|
||||
self,
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
db_session,
|
||||
):
|
||||
created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
|
||||
user = await db_session.get(User, created_user["id"])
|
||||
operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
|
||||
assert operators is not None
|
||||
user.groups.append(operators)
|
||||
await db_session.commit()
|
||||
user_headers = await self._login_user(async_client, "balance-lifecycle")
|
||||
private_center = await db_session.scalar(
|
||||
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
|
||||
)
|
||||
assert private_center is not None
|
||||
|
||||
deposit = await async_client.post(
|
||||
f"/api/v1/finance/users/{user.id}/deposit",
|
||||
json={"amount": 20.0, "cost_center_id": private_center.id},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert deposit.status_code == 200
|
||||
|
||||
archive = PrintArchive(
|
||||
filename="charged.gcode.3mf",
|
||||
file_path="archives/test/charged.gcode.3mf",
|
||||
file_size=10,
|
||||
status="completed",
|
||||
cost=4.0,
|
||||
created_by_id=user.id,
|
||||
cost_center_id=private_center.id,
|
||||
)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
assert await apply_print_charge_for_archive(db_session, archive.id) is True
|
||||
await db_session.commit()
|
||||
|
||||
adjustment = await async_client.post(
|
||||
f"/api/v1/finance/users/{user.id}/deposit",
|
||||
json={"amount": 5.0, "description": "temporary adjustment"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert adjustment.status_code == 200
|
||||
|
||||
async def assert_views_agree(expected: float):
|
||||
admin_view = await async_client.get(
|
||||
f"/api/v1/finance/users/{user.id}/balance",
|
||||
headers=auth_headers,
|
||||
)
|
||||
user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
|
||||
assert admin_view.status_code == 200
|
||||
assert user_view.status_code == 200
|
||||
assert admin_view.json()["balance"] == expected
|
||||
assert user_view.json()["balance"] == expected
|
||||
|
||||
await assert_views_agree(21.0)
|
||||
|
||||
delete_response = await async_client.delete(
|
||||
f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert delete_response.status_code == 200
|
||||
await assert_views_agree(16.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
|
||||
@@ -283,7 +349,7 @@ class TestFinanceAPI:
|
||||
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
|
||||
)
|
||||
assert balance_response.status_code == 200
|
||||
assert balance_response.json()["balance"] == 12.0
|
||||
assert balance_response.json()["balance"] == 7.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestPrintQueueAPI:
|
||||
"""Verify item can be added to queue with cost_center_id."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
|
||||
cost_center = CostCenter(name="Queue CC", is_active=True, is_private=False)
|
||||
db_session.add(cost_center)
|
||||
await db_session.commit()
|
||||
@@ -158,7 +158,7 @@ class TestPrintQueueAPI:
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"cost_center_id": cost_center.id,
|
||||
"estimated_cost": 1.25,
|
||||
"estimated_cost": 0.01,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -175,13 +175,13 @@ class TestPrintQueueAPI:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_with_cost_center_requires_estimated_cost(
|
||||
async def test_add_to_queue_derives_cost_without_client_estimate(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Cost-center queue items require an estimated cost for budget checks."""
|
||||
"""The persisted budget estimate comes from the archive, not the request."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
|
||||
cost_center = CostCenter(name="Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
|
||||
db_session.add(cost_center)
|
||||
await db_session.commit()
|
||||
@@ -196,18 +196,18 @@ class TestPrintQueueAPI:
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Estimated cost is required" in response.json()["detail"]
|
||||
assert response.status_code == 200
|
||||
assert response.json()["estimated_cost"] == 1.25
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_rejects_when_estimated_cost_exceeds_budget(
|
||||
async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Queue creation is rejected if the estimated cost exceeds remaining budget."""
|
||||
"""A forged low client hint cannot bypass the server-derived budget check."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=2.0, filament_used_grams=50.0)
|
||||
cost_center = CostCenter(name="Tiny Budget CC", is_active=True, is_private=False, monthly_budget=1.0)
|
||||
db_session.add(cost_center)
|
||||
await db_session.commit()
|
||||
@@ -219,7 +219,7 @@ class TestPrintQueueAPI:
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"cost_center_id": cost_center.id,
|
||||
"estimated_cost": 2.0,
|
||||
"estimated_cost": 0.01,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -234,7 +234,7 @@ class TestPrintQueueAPI:
|
||||
"""Billing enforcement rejects queue jobs that omit cost center."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=1.0, filament_used_grams=50.0)
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/queue/",
|
||||
@@ -255,7 +255,7 @@ class TestPrintQueueAPI:
|
||||
"""Open queue items reserve budget until they leave pending/printing states."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
|
||||
cost_center = CostCenter(name="Reserved Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
|
||||
db_session.add(cost_center)
|
||||
await db_session.commit()
|
||||
@@ -274,7 +274,7 @@ class TestPrintQueueAPI:
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"cost_center_id": cost_center.id,
|
||||
"estimated_cost": 3.0,
|
||||
"estimated_cost": 0.01,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -345,7 +345,7 @@ class TestPrintQueueAPI:
|
||||
"""Verify a pending queue item can be updated with cost_center_id."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
|
||||
item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id)
|
||||
cost_center = CostCenter(name="Update CC", is_active=True, is_private=False)
|
||||
db_session.add(cost_center)
|
||||
@@ -354,7 +354,7 @@ class TestPrintQueueAPI:
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/queue/{item.id}",
|
||||
json={"cost_center_id": cost_center.id, "estimated_cost": 1.25},
|
||||
json={"cost_center_id": cost_center.id, "estimated_cost": 0.01},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -822,7 +822,7 @@ class TestPrintQueueAPI:
|
||||
"""Deleting a pending cost-center queue item releases its reserved budget."""
|
||||
await enable_billing(db_session)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory()
|
||||
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
|
||||
cost_center = CostCenter(
|
||||
name="Delete Releases Budget CC", is_active=True, is_private=False, monthly_budget=10.0
|
||||
)
|
||||
@@ -843,7 +843,7 @@ class TestPrintQueueAPI:
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"cost_center_id": cost_center.id,
|
||||
"estimated_cost": 3.0,
|
||||
"estimated_cost": 0.01,
|
||||
},
|
||||
)
|
||||
assert blocked.status_code == 400
|
||||
@@ -857,7 +857,7 @@ class TestPrintQueueAPI:
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"cost_center_id": cost_center.id,
|
||||
"estimated_cost": 3.0,
|
||||
"estimated_cost": 0.01,
|
||||
},
|
||||
)
|
||||
assert allowed.status_code == 200
|
||||
@@ -1730,11 +1730,12 @@ class TestBulkUpdateEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_update_cost_center_counts_pending_reservations(
|
||||
self, async_client: AsyncClient, queue_item_factory, db_session
|
||||
self, async_client: AsyncClient, queue_item_factory, archive_factory, db_session
|
||||
):
|
||||
"""Bulk updates cannot reserve more than the available cost-center budget."""
|
||||
"""A forged bulk-update hint cannot weaken the server-derived reservation."""
|
||||
await enable_billing(db_session)
|
||||
item = await queue_item_factory()
|
||||
archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
|
||||
item = await queue_item_factory(archive_id=archive.id)
|
||||
existing = await queue_item_factory()
|
||||
cost_center = CostCenter(name="Bulk Reserved CC", is_active=True, is_private=False, monthly_budget=10.0)
|
||||
db_session.add(cost_center)
|
||||
@@ -1747,7 +1748,7 @@ class TestBulkUpdateEndpoint:
|
||||
|
||||
response = await async_client.patch(
|
||||
"/api/v1/queue/bulk",
|
||||
json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 3.0},
|
||||
json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 0.01},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -60,7 +60,7 @@ class TestFinanceBilling:
|
||||
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet is not None
|
||||
assert wallet.balance == -7.5
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-1"))
|
||||
assert tx is not None
|
||||
@@ -86,7 +86,7 @@ class TestFinanceBilling:
|
||||
assert second_run is True
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet is not None
|
||||
assert wallet.balance == -15.0
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
rows = (
|
||||
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
|
||||
@@ -277,7 +277,7 @@ class TestPartialPrintCharges:
|
||||
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
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
transaction = await db_session.scalar(
|
||||
select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
|
||||
@@ -374,7 +374,7 @@ class TestPartialPrintCharges:
|
||||
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet is not None
|
||||
assert wallet.balance == -5.0 # 50% of 10.0
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
tx = await db_session.scalar(
|
||||
select(WalletTransaction)
|
||||
@@ -476,7 +476,7 @@ class TestPartialPrintCharges:
|
||||
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet is not None
|
||||
assert wallet.balance == pytest.approx(-1.0, abs=0.01) # 5% of 20.0
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_print_still_charges_full_cost(self, db_session):
|
||||
@@ -512,7 +512,7 @@ class TestPartialPrintCharges:
|
||||
assert changed is True
|
||||
|
||||
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
|
||||
assert wallet.balance == -15.0 # Full cost, not proportional
|
||||
assert wallet.balance == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_charge_with_cost_center_override(self, db_session):
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services import print_cost_estimate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_file(tmp_path):
|
||||
path = tmp_path / "queued.gcode.3mf"
|
||||
path.write_bytes(b"stub")
|
||||
return SimpleNamespace(file_path=str(path), file_metadata={})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_estimate_uses_server_default_cost(monkeypatch, library_file):
|
||||
monkeypatch.setattr(
|
||||
print_cost_estimate.threemf_tools,
|
||||
"extract_plate_metadata_from_3mf",
|
||||
lambda *_args: SimpleNamespace(
|
||||
filament_usage=[
|
||||
{"slot_id": 1, "used_g": 100.0},
|
||||
{"slot_id": 2, "used_g": 50.0},
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
|
||||
|
||||
cost = await print_cost_estimate.estimate_queue_source_cost(
|
||||
SimpleNamespace(),
|
||||
library_file=library_file,
|
||||
plate_id=1,
|
||||
)
|
||||
|
||||
assert cost == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_estimate_uses_server_spool_assignment_costs(monkeypatch, library_file):
|
||||
monkeypatch.setattr(
|
||||
print_cost_estimate.threemf_tools,
|
||||
"extract_plate_metadata_from_3mf",
|
||||
lambda *_args: SimpleNamespace(
|
||||
filament_usage=[
|
||||
{"slot_id": 1, "used_g": 100.0},
|
||||
{"slot_id": 2, "used_g": 50.0},
|
||||
]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
|
||||
|
||||
assignments = [
|
||||
SimpleNamespace(ams_id=0, tray_id=0, spool=SimpleNamespace(cost_per_kg=10.0)),
|
||||
SimpleNamespace(ams_id=0, tray_id=1, spool=SimpleNamespace(cost_per_kg=30.0)),
|
||||
]
|
||||
result = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: assignments))
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=result))
|
||||
|
||||
cost = await print_cost_estimate.estimate_queue_source_cost(
|
||||
db,
|
||||
library_file=library_file,
|
||||
plate_id=1,
|
||||
ams_mapping=[0, 1],
|
||||
printer_id=7,
|
||||
)
|
||||
|
||||
assert cost == 2.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_server_metadata_does_not_fall_back_to_client_hint(monkeypatch, library_file):
|
||||
monkeypatch.setattr(
|
||||
print_cost_estimate.threemf_tools,
|
||||
"extract_plate_metadata_from_3mf",
|
||||
lambda *_args: SimpleNamespace(filament_usage=[]),
|
||||
)
|
||||
|
||||
cost = await print_cost_estimate.estimate_queue_source_cost(
|
||||
SimpleNamespace(),
|
||||
library_file=library_file,
|
||||
plate_id=1,
|
||||
)
|
||||
|
||||
assert cost is None
|
||||
@@ -7,7 +7,11 @@ import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from backend.app.core.database import _migrate_create_finance_indexes, _migrate_create_finance_tables
|
||||
from backend.app.core.database import (
|
||||
_migrate_add_print_archive_cost_center,
|
||||
_migrate_create_finance_indexes,
|
||||
_migrate_create_finance_tables,
|
||||
)
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"cost_centers",
|
||||
@@ -66,6 +70,31 @@ async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_print_archive_cost_center_is_added_idempotently_on_sqlite():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("PRAGMA foreign_keys = ON"))
|
||||
await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY)"))
|
||||
await conn.execute(text("CREATE TABLE print_archives (id INTEGER PRIMARY KEY)"))
|
||||
|
||||
await _migrate_add_print_archive_cost_center(conn)
|
||||
await _migrate_add_print_archive_cost_center(conn)
|
||||
|
||||
columns = await conn.execute(text("PRAGMA table_info(print_archives)"))
|
||||
foreign_keys = await conn.execute(text("PRAGMA foreign_key_list(print_archives)"))
|
||||
|
||||
assert "cost_center_id" in {row[1] for row in columns}
|
||||
assert any(
|
||||
row[2] == "cost_centers" and row[3] == "cost_center_id" and row[6].upper() == "SET NULL"
|
||||
for row in foreign_keys
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_finance_ddl_uses_postgres_types():
|
||||
statements: list[str] = []
|
||||
@@ -110,6 +139,8 @@ async def test_finance_tables_are_created_idempotently_on_postgres():
|
||||
with patch("backend.app.core.database.is_sqlite", return_value=False):
|
||||
await _migrate_create_finance_tables(conn)
|
||||
await _migrate_create_finance_tables(conn)
|
||||
await _migrate_add_print_archive_cost_center(conn)
|
||||
await _migrate_add_print_archive_cost_center(conn)
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
|
||||
@@ -127,8 +158,25 @@ async def test_finance_tables_are_created_idempotently_on_postgres():
|
||||
"AND table_name = 'cost_centers' AND column_name = 'created_at'"
|
||||
)
|
||||
)
|
||||
archive_cost_center = await conn.execute(
|
||||
text(
|
||||
"SELECT c.data_type, rc.delete_rule "
|
||||
"FROM information_schema.columns c "
|
||||
"JOIN information_schema.key_column_usage kcu "
|
||||
" ON kcu.table_schema = c.table_schema "
|
||||
" AND kcu.table_name = c.table_name "
|
||||
" AND kcu.column_name = c.column_name "
|
||||
"JOIN information_schema.referential_constraints rc "
|
||||
" ON rc.constraint_schema = kcu.constraint_schema "
|
||||
" AND rc.constraint_name = kcu.constraint_name "
|
||||
"WHERE c.table_schema = 'public' "
|
||||
"AND c.table_name = 'print_archives' "
|
||||
"AND c.column_name = 'cost_center_id'"
|
||||
)
|
||||
)
|
||||
|
||||
assert {row[0] for row in rows} == EXPECTED_TABLES
|
||||
assert timestamp_type.scalar_one() == "timestamp without time zone"
|
||||
assert archive_cost_center.one() == ("integer", "SET NULL")
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,14 +9,21 @@ from backend.app import main as main_module
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_kill_switch_state():
|
||||
main_module._unauthorized_print_kill_sent.clear()
|
||||
main_module._kill_switch_notification_tasks.clear()
|
||||
main_module._expected_prints.clear()
|
||||
main_module._active_prints.clear()
|
||||
main_module._expected_print_registered_at.clear()
|
||||
main_module._printer_reconciled_since_connect.clear()
|
||||
yield
|
||||
for task in main_module._kill_switch_notification_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
main_module._unauthorized_print_kill_sent.clear()
|
||||
main_module._kill_switch_notification_tasks.clear()
|
||||
main_module._expected_prints.clear()
|
||||
main_module._active_prints.clear()
|
||||
main_module._expected_print_registered_at.clear()
|
||||
main_module._printer_reconciled_since_connect.clear()
|
||||
|
||||
|
||||
def test_gcode_3mf_status_filename_matches_registered_expected_print():
|
||||
@@ -34,6 +42,8 @@ def test_gcode_3mf_status_filename_matches_registered_expected_print():
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
stop_calls: list[int] = []
|
||||
broadcast = AsyncMock()
|
||||
provider_notification = AsyncMock(return_value=True)
|
||||
|
||||
async def fake_status(*args, **kwargs):
|
||||
return None
|
||||
@@ -41,6 +51,9 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
async def kill_switch_enabled(_db):
|
||||
return True
|
||||
|
||||
async def unauthorized(*_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
|
||||
monkeypatch.setattr(
|
||||
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
|
||||
@@ -50,6 +63,9 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
|
||||
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
|
||||
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
|
||||
monkeypatch.setattr(main_module.ws_manager, "broadcast", broadcast)
|
||||
monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
|
||||
monkeypatch.setattr(main_module, "_send_kill_switch_provider_notification", provider_notification)
|
||||
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
|
||||
|
||||
state = SimpleNamespace(
|
||||
@@ -71,6 +87,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
ams_filament_backup=False,
|
||||
current_print=None,
|
||||
subtask_name="foreign_job",
|
||||
subtask_id="external-task-1",
|
||||
gcode_file="foreign_job.gcode",
|
||||
)
|
||||
|
||||
@@ -78,6 +95,39 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
|
||||
assert stop_calls == [7]
|
||||
assert 7 in main_module._unauthorized_print_kill_sent
|
||||
broadcast.assert_awaited_once_with(
|
||||
{
|
||||
"type": "kill_switch_triggered",
|
||||
"printer_id": 7,
|
||||
"printer_name": "Printer 7",
|
||||
"filename": "foreign_job",
|
||||
"reason": "unauthorized_print",
|
||||
}
|
||||
)
|
||||
notification_task = main_module._kill_switch_notification_tasks[7]
|
||||
assert await notification_task is True
|
||||
provider_notification.assert_awaited_once_with(
|
||||
7,
|
||||
"Printer 7",
|
||||
{
|
||||
"status": "stopped",
|
||||
"filename": "foreign_job.gcode",
|
||||
"subtask_name": "foreign_job",
|
||||
"progress": 0,
|
||||
"reason": "unauthorized_print",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_immediate_notification_allows_completion_retry():
|
||||
task = main_module.spawn_background_task(_return_false(), name="test-kill-switch-notification-failure")
|
||||
|
||||
assert await main_module._kill_switch_notification_already_sent(task) is False
|
||||
|
||||
|
||||
async def _return_false():
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -141,6 +191,9 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
async def kill_switch_enabled(_db):
|
||||
return True
|
||||
|
||||
async def unauthorized(*_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
|
||||
monkeypatch.setattr(
|
||||
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
|
||||
@@ -150,6 +203,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
|
||||
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
|
||||
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
|
||||
monkeypatch.setattr(main_module, "_is_bambuddy_authorized_print", unauthorized)
|
||||
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
|
||||
|
||||
active_state = SimpleNamespace(
|
||||
@@ -171,6 +225,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
ams_filament_backup=False,
|
||||
current_print=None,
|
||||
subtask_name="foreign_job",
|
||||
subtask_id="external-task-1",
|
||||
gcode_file="foreign_job.gcode",
|
||||
)
|
||||
|
||||
@@ -193,6 +248,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
ams_filament_backup=False,
|
||||
current_print=None,
|
||||
subtask_name="",
|
||||
subtask_id=None,
|
||||
gcode_file=None,
|
||||
)
|
||||
|
||||
@@ -203,3 +259,90 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
await main_module.on_printer_status_change(7, idle_state)
|
||||
|
||||
assert 7 not in main_module._unauthorized_print_kill_sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("printer_state", ["RUNNING", "PAUSE"])
|
||||
async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_state):
|
||||
archive = SimpleNamespace(id=123, filename="owned_job.gcode.3mf")
|
||||
query_result = SimpleNamespace(scalar_one_or_none=lambda: archive)
|
||||
db = SimpleNamespace(execute=AsyncMock(return_value=query_result))
|
||||
|
||||
class FakeSessionContext:
|
||||
async def __aenter__(self):
|
||||
return db
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return False
|
||||
|
||||
stop_calls: list[int] = []
|
||||
|
||||
async def fake_status(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def kill_switch_enabled(_db):
|
||||
return True
|
||||
|
||||
def discard_background_task(coro, **_kwargs):
|
||||
coro.close()
|
||||
|
||||
monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
|
||||
monkeypatch.setattr(main_module, "spawn_background_task", discard_background_task)
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
|
||||
monkeypatch.setattr(
|
||||
main_module.printer_manager, "stop_print", lambda printer_id: stop_calls.append(printer_id) or True
|
||||
)
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_printer", lambda printer_id: None)
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_model", lambda printer_id: None)
|
||||
monkeypatch.setattr(main_module, "printer_state_to_dict", lambda *args, **kwargs: {})
|
||||
monkeypatch.setattr(main_module.mqtt_relay, "on_printer_status", fake_status)
|
||||
monkeypatch.setattr(main_module.ws_manager, "send_printer_status", fake_status)
|
||||
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
|
||||
|
||||
state = SimpleNamespace(
|
||||
connected=True,
|
||||
state=printer_state,
|
||||
progress=42,
|
||||
remaining_time=600,
|
||||
layer_num=50,
|
||||
temperatures={},
|
||||
raw_data={},
|
||||
stg_cur=0,
|
||||
cooling_fan_speed=None,
|
||||
big_fan1_speed=None,
|
||||
big_fan2_speed=None,
|
||||
chamber_light=False,
|
||||
active_extruder=0,
|
||||
tray_now=255,
|
||||
door_open=False,
|
||||
ams_filament_backup=False,
|
||||
current_print=None,
|
||||
subtask_name="owned_job",
|
||||
subtask_id="bambuddy-task-123",
|
||||
gcode_file="owned_job.gcode.3mf",
|
||||
)
|
||||
|
||||
await main_module.on_printer_status_change(7, state)
|
||||
|
||||
assert stop_calls == []
|
||||
assert (7, "owned_job.gcode.3mf") in main_module._active_prints
|
||||
assert main_module._active_prints[(7, "owned_job.gcode.3mf")] == 123
|
||||
assert 7 not in main_module._unauthorized_print_kill_sent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_switch_defers_when_restart_identity_is_not_available(monkeypatch):
|
||||
state = SimpleNamespace(
|
||||
current_print=None,
|
||||
subtask_name="owned_job",
|
||||
subtask_id=None,
|
||||
gcode_file="owned_job.gcode.3mf",
|
||||
)
|
||||
db = SimpleNamespace(execute=AsyncMock())
|
||||
|
||||
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
|
||||
|
||||
authorization = await main_module._is_bambuddy_authorized_print(7, state, db)
|
||||
|
||||
assert authorization is None
|
||||
db.execute.assert_not_awaited()
|
||||
|
||||
@@ -98,7 +98,8 @@ async def test_running_observed_skips_when_baseline_already_present():
|
||||
"""If on_print_start already ran in this Bambuddy process for the same
|
||||
printer (the realistic same-session race), a second capture would
|
||||
overwrite the correct pre-print baseline with one taken later — which
|
||||
could include the in-flight MP4. Skip when a baseline exists."""
|
||||
could include the in-flight MP4. The archive lookup still has to run so
|
||||
restart recovery can restore durable print ownership for the kill switch."""
|
||||
_timelapse_baselines[1] = {"pre_existing_a.mp4", "pre_existing_b.mp4"}
|
||||
|
||||
with (
|
||||
@@ -118,8 +119,9 @@ async def test_running_observed_skips_when_baseline_already_present():
|
||||
},
|
||||
)
|
||||
|
||||
# Neither the DB lookup nor the FTP scan should have run.
|
||||
mock_session_maker.assert_not_called()
|
||||
# Ownership reconciliation still consults the DB, but the expensive
|
||||
# timelapse scan remains one-shot.
|
||||
mock_session_maker.assert_called_once()
|
||||
mock_list.assert_not_called()
|
||||
|
||||
# Original baseline preserved.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { renderHook, waitFor, act, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ToastProvider } from '../../contexts/ToastContext';
|
||||
@@ -23,6 +23,10 @@ vi.mock('react-i18next', () => ({
|
||||
const { printer, slots } = options as { printer: string; slots: string };
|
||||
return `Missing assignments for ${printer}: ${slots}`;
|
||||
}
|
||||
if (key === 'printers.toast.killSwitchTriggered' && options) {
|
||||
const { printer, filename } = options as { printer: string; filename: string };
|
||||
return `Billing kill switch stopped ${filename} on ${printer}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
i18n: {},
|
||||
@@ -490,6 +494,28 @@ describe('useWebSocket hook', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('shows an error toast when the billing kill switch stops a print', async () => {
|
||||
const { useWebSocket } = await import('../../hooks/useWebSocket');
|
||||
|
||||
renderHook(() => useWebSocket(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
const ws = await waitForWs();
|
||||
act(() => {
|
||||
ws.open();
|
||||
ws.simulateMessage({
|
||||
type: 'kill_switch_triggered',
|
||||
printer_id: 7,
|
||||
printer_name: 'Printer B',
|
||||
filename: 'foreign_job.3mf',
|
||||
});
|
||||
});
|
||||
|
||||
const toast = screen.getByText('Billing kill switch stopped foreign_job.3mf on Printer B');
|
||||
expect(toast.parentElement).toHaveClass('bg-red-500/10');
|
||||
});
|
||||
|
||||
it('handles spool_assignment_verified messages (success and failure) without error', async () => {
|
||||
const { useWebSocket } = await import('../../hooks/useWebSocket');
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ interface WebSocketMessage {
|
||||
printer_id?: number;
|
||||
data?: Record<string, unknown>;
|
||||
printer_name?: string;
|
||||
filename?: string;
|
||||
missing_slots?: Array<{ slot?: string }>;
|
||||
// Spool-assignment read-back verification (#2582).
|
||||
slot?: string;
|
||||
@@ -338,6 +339,13 @@ export function useWebSocket() {
|
||||
debouncedInvalidate('archiveStats');
|
||||
break;
|
||||
|
||||
case 'kill_switch_triggered': {
|
||||
const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
|
||||
const filename = message.filename || t('common.unknown');
|
||||
showToast(t('printers.toast.killSwitchTriggered', { printer, filename }), 'error');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'archive_created':
|
||||
debouncedInvalidate('archives');
|
||||
debouncedInvalidate('archiveStats');
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Drucker gelöscht',
|
||||
missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
|
||||
killSwitchTriggered: 'Der Billing-Kill-Switch hat einen nicht autorisierten Druck auf {{printer}} gestoppt: {{filename}}',
|
||||
assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
|
||||
assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
|
||||
|
||||
@@ -450,6 +450,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Printer deleted',
|
||||
missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
|
||||
killSwitchTriggered: 'The billing kill switch stopped an unauthorized print on {{printer}}: {{filename}}',
|
||||
assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
|
||||
assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Impresora eliminada',
|
||||
missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
|
||||
killSwitchTriggered: 'El interruptor de seguridad de facturación detuvo una impresión no autorizada en {{printer}}: {{filename}}',
|
||||
assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
|
||||
assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Imprimante supprimée',
|
||||
missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
|
||||
killSwitchTriggered: 'Le coupe-circuit de facturation a arrêté une impression non autorisée sur {{printer}} : {{filename}}',
|
||||
assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
|
||||
assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Stampante eliminata',
|
||||
missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
|
||||
killSwitchTriggered: 'L’interruttore di sicurezza della fatturazione ha arrestato una stampa non autorizzata su {{printer}}: {{filename}}',
|
||||
assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
|
||||
assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
|
||||
|
||||
@@ -446,6 +446,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'プリンターを削除しました',
|
||||
missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
|
||||
killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
|
||||
assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
|
||||
assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
|
||||
assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
|
||||
|
||||
@@ -421,6 +421,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: '프린터가 삭제되었습니다',
|
||||
missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
|
||||
killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
|
||||
assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
|
||||
assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Impressora excluída',
|
||||
missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
|
||||
killSwitchTriggered: 'O bloqueio de segurança de cobrança interrompeu uma impressão não autorizada em {{printer}}: {{filename}}',
|
||||
assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
|
||||
assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
|
||||
|
||||
@@ -426,6 +426,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: "Принтер удалён",
|
||||
missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
|
||||
killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
|
||||
assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
|
||||
assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
|
||||
assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: 'Yazıcı silindi',
|
||||
missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
|
||||
killSwitchTriggered: 'Faturalandırma durdurma anahtarı {{printer}} üzerindeki yetkisiz baskıyı durdurdu: {{filename}}',
|
||||
assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
|
||||
assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
|
||||
assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
|
||||
|
||||
@@ -450,6 +450,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: "Принтер видалено",
|
||||
missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
|
||||
killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
|
||||
assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
|
||||
assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
|
||||
assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: '打印机已删除',
|
||||
missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
|
||||
killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
|
||||
assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
|
||||
assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
|
||||
assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
|
||||
|
||||
@@ -447,6 +447,7 @@ export default {
|
||||
toast: {
|
||||
printerDeleted: '印表機已刪除',
|
||||
missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
|
||||
killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
|
||||
assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
|
||||
assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
|
||||
assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
|
||||
|
||||
Reference in New Issue
Block a user