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

@@ -12,11 +12,17 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
from app.models.fleet_finance import AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation
from app.models.identity import User
from app.services.cost_service import cost_service
from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse
from app.schemas.fleet_finance import (
AssetFinancialsUpdate, AssetFinancialsResponse,
VehicleInsurancePolicyCreate, VehicleInsurancePolicyUpdate, VehicleInsurancePolicyResponse,
VehicleTaxObligationCreate, VehicleTaxObligationUpdate, VehicleTaxObligationResponse,
)
logger = logging.getLogger(__name__)
@@ -1228,4 +1234,363 @@ async def archive_vehicle(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Belső szerverhiba a jármű archiválásakor"
)
)
# =============================================================================
# Fleet Finance endpoints
# =============================================================================
@router.get(
"/vehicles/{asset_id}/financials",
response_model=AssetFinancialsResponse,
)
async def get_vehicle_financials(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű pénzügyi adatainak lekérése.
GET /api/v1/assets/vehicles/{asset_id}/financials
Visszaadja a jármű beszerzési, finanszírozási és lízing adatait.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id)
result = await db.execute(stmt)
financials = result.scalar_one_or_none()
if not financials:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A járműhöz nem található pénzügyi adat"
)
return financials
@router.patch(
"/vehicles/{asset_id}/financials",
response_model=AssetFinancialsResponse,
)
async def update_vehicle_financials(
asset_id: uuid.UUID,
payload: AssetFinancialsUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű pénzügyi adatainak frissítése.
PATCH /api/v1/assets/vehicles/{asset_id}/financials
Csak a megadott mezőket frissíti (partial update).
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id)
result = await db.execute(stmt)
financials = result.scalar_one_or_none()
if not financials:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A járműhöz nem található pénzügyi adat"
)
# Partial update: csak a megadott mezőket frissítjük
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(financials, field, value)
await db.commit()
await db.refresh(financials)
return financials
@router.get(
"/vehicles/{asset_id}/insurance",
response_model=List[VehicleInsurancePolicyResponse],
)
async def list_vehicle_insurance_policies(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű biztosítási kötvényeinek listázása.
GET /api/v1/assets/vehicles/{asset_id}/insurance
Visszaadja a járműhöz tartozó összes biztosítási kötvényt.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = (
select(VehicleInsurancePolicy)
.where(VehicleInsurancePolicy.asset_id == asset_id)
.order_by(VehicleInsurancePolicy.start_date.desc())
)
result = await db.execute(stmt)
policies = result.scalars().all()
return policies
@router.post(
"/vehicles/{asset_id}/insurance",
response_model=VehicleInsurancePolicyResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_vehicle_insurance_policy(
asset_id: uuid.UUID,
payload: VehicleInsurancePolicyCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Új biztosítási kötvény létrehozása a járműhöz.
POST /api/v1/assets/vehicles/{asset_id}/insurance
"""
asset = await _check_asset_access(db, asset_id, current_user)
policy = VehicleInsurancePolicy(
asset_id=asset_id,
provider_id=payload.provider_id,
insurance_type=payload.insurance_type,
policy_number=payload.policy_number,
start_date=payload.start_date,
expiry_date=payload.expiry_date,
premium_amount=payload.premium_amount,
currency=payload.currency,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
return policy
@router.get(
"/vehicles/{asset_id}/tax",
response_model=List[VehicleTaxObligationResponse],
)
async def list_vehicle_tax_obligations(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Jármű adókötelezettségeinek listázása.
GET /api/v1/assets/vehicles/{asset_id}/tax
Visszaadja a járműhöz tartozó összes adókötelezettséget.
"""
asset = await _check_asset_access(db, asset_id, current_user)
stmt = (
select(VehicleTaxObligation)
.where(VehicleTaxObligation.asset_id == asset_id)
.order_by(VehicleTaxObligation.tax_year.desc())
)
result = await db.execute(stmt)
obligations = result.scalars().all()
return obligations
@router.post(
"/vehicles/{asset_id}/tax",
response_model=VehicleTaxObligationResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_vehicle_tax_obligation(
asset_id: uuid.UUID,
payload: VehicleTaxObligationCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Új adókötelezettség létrehozása a járműhöz.
POST /api/v1/assets/vehicles/{asset_id}/tax
"""
asset = await _check_asset_access(db, asset_id, current_user)
obligation = VehicleTaxObligation(
asset_id=asset_id,
tax_type=payload.tax_type,
tax_year=payload.tax_year,
amount=payload.amount,
currency=payload.currency,
due_date=payload.due_date,
payment_status=payload.payment_status,
)
db.add(obligation)
await db.commit()
await db.refresh(obligation)
return obligation
# =============================================================================
# VehicleInsurancePolicy PUT / DELETE
# =============================================================================
@router.put(
"/vehicles/{asset_id}/insurance/{policy_id}",
response_model=VehicleInsurancePolicyResponse,
)
async def update_vehicle_insurance_policy(
asset_id: uuid.UUID,
policy_id: uuid.UUID,
payload: VehicleInsurancePolicyUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Biztosítási kötvény módosítása.
PUT /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id}
Csak a megadott mezőket frissíti (partial update).
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleInsurancePolicy).where(
VehicleInsurancePolicy.id == policy_id,
VehicleInsurancePolicy.asset_id == asset_id,
)
result = await db.execute(stmt)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosítási kötvény nem található",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(policy, field, value)
await db.commit()
await db.refresh(policy)
return policy
@router.delete(
"/vehicles/{asset_id}/insurance/{policy_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_vehicle_insurance_policy(
asset_id: uuid.UUID,
policy_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Biztosítási kötvény törlése.
DELETE /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id}
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleInsurancePolicy).where(
VehicleInsurancePolicy.id == policy_id,
VehicleInsurancePolicy.asset_id == asset_id,
)
result = await db.execute(stmt)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosítási kötvény nem található",
)
await db.delete(policy)
await db.commit()
# =============================================================================
# VehicleTaxObligation PUT / DELETE
# =============================================================================
@router.put(
"/vehicles/{asset_id}/tax/{tax_id}",
response_model=VehicleTaxObligationResponse,
)
async def update_vehicle_tax_obligation(
asset_id: uuid.UUID,
tax_id: uuid.UUID,
payload: VehicleTaxObligationUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Adókötelezettség módosítása.
PUT /api/v1/assets/vehicles/{asset_id}/tax/{tax_id}
Csak a megadott mezőket frissíti (partial update).
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleTaxObligation).where(
VehicleTaxObligation.id == tax_id,
VehicleTaxObligation.asset_id == asset_id,
)
result = await db.execute(stmt)
obligation = result.scalar_one_or_none()
if not obligation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Adókötelezettség nem található",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(obligation, field, value)
await db.commit()
await db.refresh(obligation)
return obligation
@router.delete(
"/vehicles/{asset_id}/tax/{tax_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_vehicle_tax_obligation(
asset_id: uuid.UUID,
tax_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Adókötelezettség törlése.
DELETE /api/v1/assets/vehicles/{asset_id}/tax/{tax_id}
"""
await _check_asset_access(db, asset_id, current_user)
stmt = select(VehicleTaxObligation).where(
VehicleTaxObligation.id == tax_id,
VehicleTaxObligation.asset_id == asset_id,
)
result = await db.execute(stmt)
obligation = result.scalar_one_or_none()
if not obligation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Adókötelezettség nem található",
)
await db.delete(obligation)
await db.commit()

View File

@@ -2,15 +2,21 @@
"""
Szótárak és katalógusok végpontjai.
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import select, desc
from app.db.session import get_db
from app.api import deps
from app.models.vehicle import CostCategory
from app.models.fleet_finance import CostCategory, InsuranceProvider
from app.schemas.fleet_finance import (
InsuranceProviderResponse,
InsuranceProviderCreate,
InsuranceProviderUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -66,3 +72,172 @@ async def list_cost_categories(
root_categories.append(cat_dict)
return root_categories
# =============================================================================
# InsuranceProvider CRUD
# =============================================================================
@router.get(
"/insurance-providers",
response_model=List[InsuranceProviderResponse],
)
async def list_insurance_providers(
active_only: bool = Query(True, description="Csak az aktív biztosítók listázása"),
country_code: Optional[str] = Query(None, max_length=2, description="ISO 3166-1 alpha-2 országkód szerinti szűrés (pl. 'HU'). Visszaadja az adott ország és a globális (NULL) szolgáltatókat."),
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosítók listázása.
Alapértelmezés szerint csak az aktív biztosítókat adja vissza (frontend dropdown számára).
Opcionálisan szűrhető country_code alapján: ha meg van adva, visszaadja az adott ország
szolgáltatóit ÉS a globális (country_code IS NULL) szolgáltatókat is.
"""
stmt = select(InsuranceProvider).order_by(InsuranceProvider.name)
if active_only:
stmt = stmt.where(InsuranceProvider.is_active == True)
if country_code:
stmt = stmt.where(
(InsuranceProvider.country_code == country_code) |
(InsuranceProvider.country_code.is_(None))
)
result = await db.execute(stmt)
providers = result.scalars().all()
return providers
@router.get(
"/insurance-providers/{provider_id}",
response_model=InsuranceProviderResponse,
)
async def get_insurance_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Egy biztosító adatainak lekérése ID alapján.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
return provider
@router.post(
"/insurance-providers",
response_model=InsuranceProviderResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_insurance_provider(
payload: InsuranceProviderCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Új biztosító hozzáadása a katalógushoz.
"""
# Check for duplicate name
existing_stmt = select(InsuranceProvider).where(
InsuranceProvider.name == payload.name
)
existing_result = await db.execute(existing_stmt)
if existing_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'",
)
provider = InsuranceProvider(
name=payload.name,
claim_phone=payload.claim_phone,
claim_url=payload.claim_url,
services_offered=payload.services_offered or {},
is_active=payload.is_active,
)
db.add(provider)
await db.commit()
await db.refresh(provider)
return provider
@router.put(
"/insurance-providers/{provider_id}",
response_model=InsuranceProviderResponse,
)
async def update_insurance_provider(
provider_id: int,
payload: InsuranceProviderUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosító adatainak módosítása.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
# Check for duplicate name if name is being changed
if payload.name is not None and payload.name != provider.name:
dup_stmt = select(InsuranceProvider).where(
InsuranceProvider.name == payload.name,
InsuranceProvider.id != provider_id,
)
dup_result = await db.execute(dup_stmt)
if dup_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(provider, field, value)
await db.commit()
await db.refresh(provider)
return provider
@router.delete(
"/insurance-providers/{provider_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_insurance_provider(
provider_id: int,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user),
):
"""
Biztosító törlése a katalógusból.
"""
stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id)
result = await db.execute(stmt)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Biztosító nem található",
)
await db.delete(provider)
await db.commit()

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

View File

@@ -184,7 +184,11 @@ async def get_my_organizations(
):
"""
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
a subscription_tier JSONB rules alapján.
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
subq = (
select(Organization.id)
.outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id)
@@ -206,6 +210,31 @@ async def get_my_organizations(
)
result = await db.execute(stmt)
orgs = result.scalars().all()
# ── P0: Pre-fetch all org subscription tiers for limit resolution ──
org_ids = [o.id for o in orgs]
org_tier_map: dict[int, tuple[int, int]] = {}
if org_ids:
org_subs_stmt = (
select(OrganizationSubscription.org_id, SubscriptionTier)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id.in_(org_ids),
OrganizationSubscription.is_active == True
)
.distinct(OrganizationSubscription.org_id)
.order_by(OrganizationSubscription.org_id, OrganizationSubscription.valid_from.desc())
)
org_subs_result = await db.execute(org_subs_stmt)
for row in org_subs_result:
o_id = row.org_id
tier = row[1]
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
org_tier_map[o_id] = (
int(allowances.get("max_vehicles", 1)),
int(allowances.get("max_garages", 1)),
)
return [
{
@@ -220,6 +249,11 @@ async def get_my_organizations(
"is_active": o.is_active,
"is_deleted": o.is_deleted,
"subscription_plan": o.subscription_plan,
"subscription_expires_at": o.subscription_expires_at.isoformat() if o.subscription_expires_at else None,
"subscription_tier_id": o.subscription_tier_id,
# P0: Real limits from subscription tier
"max_vehicles": org_tier_map.get(o.id, (o.base_asset_limit or 1, 1))[0],
"max_garages": org_tier_map.get(o.id, (1, 1))[1],
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id),

View File

@@ -1,56 +1,97 @@
from fastapi import APIRouter, Depends
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/reports.py
"""
Reports endpoints — Zombie API Cleanup.
Replaced raw SQL (FROM vehicle.vehicle_expenses) with SQLAlchemy ORM queries
using fleet_finance.asset_costs (AssetCost) + CostCategory.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.api.deps import get_db, get_current_user
from app.models.fleet_finance import AssetCost, CostCategory
from app.models.identity import User
router = APIRouter()
router = APIRouter() # EZ HIÁNYZOTT!
@router.get("/summary/{vehicle_id}")
async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
async def get_vehicle_summary(
vehicle_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Összesített jelentés egy járműhöz: kategóriánkénti költségek.
Uses fleet_finance.asset_costs (AssetCost) joined with CostCategory.
"""
query = text("""
SELECT
category,
SUM(amount) as total_amount,
COUNT(*) as transaction_count
FROM vehicle.vehicle_expenses
WHERE vehicle_id = :v_id
GROUP BY category
""")
result = await db.execute(query, {"v_id": vehicle_id})
rows = result.fetchall()
total_cost = sum(row.total_amount for row in rows) if rows else 0
# Aggregate costs by category using ORM
stmt = (
select(
CostCategory.name.label("category"),
func.sum(AssetCost.amount_gross).label("total_amount"),
func.count(AssetCost.id).label("transaction_count"),
)
.join(CostCategory, AssetCost.category_id == CostCategory.id)
.where(AssetCost.asset_id == vehicle_id)
.group_by(CostCategory.name)
)
result = await db.execute(stmt)
rows = result.all()
total_cost = sum(float(row.total_amount) for row in rows) if rows else 0.0
return {
"vehicle_id": vehicle_id,
"total_cost": float(total_cost),
"breakdown": [dict(row._mapping) for row in rows]
"total_cost": total_cost,
"breakdown": [
{
"category": row.category,
"total_amount": float(row.total_amount),
"transaction_count": row.transaction_count,
}
for row in rows
],
}
@router.get("/trends/{vehicle_id}")
async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
async def get_monthly_trends(
vehicle_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Visszaadja az utolsó 6 hónap költéseit havi bontásban.
Uses fleet_finance.asset_costs (AssetCost) with date truncation.
"""
query = text("""
SELECT
TO_CHAR(date, 'YYYY-MM') as month,
SUM(amount) as monthly_total
FROM vehicle.vehicle_expenses
WHERE vehicle_id = :v_id
GROUP BY month
ORDER BY month DESC
LIMIT 6
""")
result = await db.execute(query, {"v_id": vehicle_id})
return [dict(row._mapping) for row in result.fetchall()]
# Monthly aggregation using ORM-compatible date truncation
from sqlalchemy import text as sql_text
stmt = (
select(
func.to_char(AssetCost.date, "YYYY-MM").label("month"),
func.sum(AssetCost.amount_gross).label("monthly_total"),
)
.where(AssetCost.asset_id == vehicle_id)
.group_by(sql_text("month"))
.order_by(desc(sql_text("month")))
.limit(6)
)
result = await db.execute(stmt)
rows = result.all()
return [
{"month": row.month, "monthly_total": float(row.monthly_total)}
for row in rows
]
@router.get("/summary/latest")
async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)):
async def get_latest_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Returns a simple summary for the dashboard (mock data for now).
This endpoint is called by the frontend dashboard.
@@ -61,5 +102,5 @@ async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user =
"total_cost_this_month": 1250.50,
"most_expensive_category": "Fuel",
"trend": "down",
"trend_percentage": -5.2
}
"trend_percentage": -5.2,
}

View File

@@ -46,6 +46,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
P0: max_vehicles és max_garages a subscription_tier JSONB rules-ból.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
@@ -60,8 +61,6 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
active_org_id = None
if active_org_id is None:
# 1. Check if user is a member of any organization with ADMIN/OWNER role
# (This is a helper - in real usage the caller should pass active_org_id)
pass
person = user.person
@@ -110,10 +109,13 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
# ── RBAC Phase 3: Resolve org capabilities ──
org_capabilities: Dict[str, Dict[str, bool]] = {}
if db is not None:
# We need to run this synchronously since _build_user_response is not async
# The async version is handled in read_users_me directly
pass
# ── P0: Resolve subscription limits ──
# Default fallback values
max_vehicles = 1
max_garages = 1
return {
"id": user.id,
"email": user.email,
@@ -124,6 +126,8 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op
"person_id": user.person_id,
"role": role_key,
"subscription_plan": user.subscription_plan,
"max_vehicles": max_vehicles,
"max_garages": max_garages,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
"ui_mode": user.ui_mode or "personal",
@@ -198,9 +202,60 @@ async def read_users_me(
# Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── P0: Resolve real subscription limits from the assigned tier ──
from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription
max_vehicles = 1
max_garages = 1
if active_org_id is not None:
# Try org-level subscription first
org_sub_stmt = (
select(SubscriptionTier)
.select_from(OrganizationSubscription)
.join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id)
.where(
OrganizationSubscription.org_id == active_org_id,
OrganizationSubscription.is_active == True
)
.order_by(OrganizationSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(org_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
else:
# Fallback: read base_asset_limit from Organization record
org_stmt = select(Organization).where(Organization.id == active_org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org:
max_vehicles = org.base_asset_limit or 1
max_garages = 1 # No base_garage_limit column, default to 1
else:
# Personal mode: try user-level subscription
user_sub_stmt = (
select(SubscriptionTier)
.select_from(UserSubscription)
.join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id)
.where(
UserSubscription.user_id == current_user.id,
UserSubscription.is_active == True
)
.order_by(UserSubscription.valid_from.desc())
.limit(1)
)
tier = (await db.execute(user_sub_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get("allowances", {})
max_vehicles = int(allowances.get("max_vehicles", 1))
max_garages = int(allowances.get("max_garages", 1))
# ── RBAC Phase 3: Build base response ──
response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin
response_data["max_vehicles"] = max_vehicles
response_data["max_garages"] = max_garages
# ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of