frontend költség beállítások
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user