mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +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,
|
||||
|
||||
Reference in New Issue
Block a user