frontend költség beállítások

This commit is contained in:
Roo
2026-06-23 21:11:21 +00:00
parent 5b437b220d
commit 71ef33bb85
90 changed files with 11850 additions and 1053 deletions

View File

@@ -1,11 +1,13 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone
@@ -110,6 +112,236 @@ def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decim
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
@router.get("")
async def list_all_expenses(
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"),
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."),
):
"""
List all expenses across all assets for the current user's organization,
ordered by date descending with pagination.
Supports optional asset_id filter for vehicle-specific cost views.
Supports optional organization_id filter for cross-org views (user must be a member).
Returns enriched expense data including vehicle info, category name, and vendor.
"""
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org
if organization_id is not None:
# Verify user is a member of the requested organization
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(
status_code=403,
detail="You are not an active member of the requested organization."
)
org_id = organization_id
else:
# Fallback: resolve user's first active organization
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
membership = org_result.scalar_one_or_none()
if not membership:
raise HTTPException(status_code=403, detail="No active organization membership found.")
org_id = membership.organization_id
# Calculate offset
offset = (page - 1) * page_size
# Build base query with joins
base_select = (
select(
AssetCost,
CostCategory.code,
CostCategory.name,
Organization.name,
Asset.license_plate,
Asset.brand,
Asset.model,
)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.join(Asset, AssetCost.asset_id == Asset.id)
)
# Apply filters
filters = [AssetCost.organization_id == org_id]
if asset_id:
filters.append(AssetCost.asset_id == asset_id)
expense_stmt = (
base_select
.where(*filters)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(page_size)
)
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response
expenses = []
for row in rows:
cost = row[0]
cat_code = row[1]
cat_name = row[2]
vendor_name = row[3]
license_plate = row[4]
brand = row[5]
model = row[6]
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
# Build vehicle display name
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
expenses.append({
"id": str(cost.id),
"asset_id": str(cost.asset_id),
"organization_id": cost.organization_id,
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name,
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
# Vehicle info
"vehicle_name": vehicle_name,
"license_plate": license_plate,
})
# Count total for pagination (respect asset_id filter)
count_filters = [AssetCost.organization_id == org_id]
if asset_id:
count_filters.append(AssetCost.asset_id == asset_id)
count_stmt = (
select(func.count(AssetCost.id))
.where(*count_filters)
)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
total_pages = max(1, (total + page_size - 1) // page_size)
return {
"data": expenses,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
@router.get("/{asset_id}")
async def list_asset_expenses(
asset_id: UUID,
db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user),
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"),
offset: int = Query(0, ge=0, description="Number of expenses to skip"),
):
"""
List all expenses for a specific asset, ordered by date descending.
Returns enriched expense data including category name, code, and vendor info.
Used by the OverviewTab and CostManagerModal to display real expense data.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == asset_id)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found.")
# Fetch expenses with category join and vendor organization join
expense_stmt = (
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(offset)
.limit(limit)
)
expense_result = await db.execute(expense_stmt)
rows = expense_result.all()
# Build enriched response
expenses = []
for row in rows:
cost = row[0] # AssetCost instance
cat_code = row[1] # CostCategory.code
cat_name = row[2] # CostCategory.name
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
# Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
expenses.append({
"id": str(cost.id),
"asset_id": str(cost.asset_id),
"organization_id": cost.organization_id,
"category_id": cost.category_id,
"category_code": cat_code,
"category_name": cat_name,
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
"amount_net": str(cost.amount_net),
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
"currency": cost.currency,
"date": cost.date.isoformat() if cost.date else None,
"status": cost.status,
"description": description,
"mileage_at_cost": mileage_at_cost,
"invoice_number": cost.invoice_number,
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
# === B2B VENDOR FIELDS ===
"vendor_organization_id": cost.vendor_organization_id,
"external_vendor_name": cost.external_vendor_name,
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
# === INVOICE DATES ===
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
})
# Count total for pagination
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
count_result = await db.execute(count_stmt)
total = count_result.scalar()
return {
"data": expenses,
"total": total,
"limit": limit,
"offset": offset,
}
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
@@ -247,10 +479,16 @@ async def create_expense(
amount_gross=amount_gross,
vat_rate=vat_rate,
currency=expense.currency,
date=expense.date,
date=expense.cost_date,
invoice_number=data.get("invoice_number"),
status=expense_status,
data=data
data=data,
# === B2B VENDOR FIELDS ===
vendor_organization_id=expense.vendor_organization_id,
external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES ===
invoice_date=expense.invoice_date,
fulfillment_date=expense.fulfillment_date,
)
db.add(new_cost)
@@ -279,7 +517,7 @@ async def create_expense(
cost_id=new_cost.id,
linked_expense_id=new_cost.id, # Bidirectional link
status=event_status,
event_date=expense.date or datetime.now(timezone.utc),
event_date=expense.cost_date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id