feat: implement pivot-currency model, rbac smart tokens & fix circular imports

This commit is contained in:
2026-02-10 10:20:45 +00:00
parent 24d35fe0c1
commit e255fea3a5
117 changed files with 2247 additions and 3542 deletions

View File

@@ -1,136 +1,129 @@
import uuid
from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
import os
import logging
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.asset import AssetCreate, AssetResponse
from app.models.asset import Asset, AssetCatalog, AssetAssignment, AssetEvent
from app.models.asset import Asset, AssetCost, AssetTelemetry
from app.models.identity import User
from app.models.organization import Organization, OrganizationMember, OrgType
from app.core.config import settings
# VIN Validator - Standard 17 karakter, tiltott karakterek (I, O, Q) szűrése
class VINValidator:
@staticmethod
def validate(vin: str) -> bool:
vin = vin.upper()
if len(vin) != 17:
return False
if any(c in vin for c in "IOQ"):
return False
return True
from app.services.cost_service import cost_service
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_asset(
asset_in: AssetCreate,
target_org_id: int = None,
# --- 1. MODUL: IDENTITÁS (Alapadatok) ---
@router.get("/{asset_id}", response_model=Dict[str, Any])
async def get_asset_identity(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# 1. VIN Validáció
if not VINValidator.validate(asset_in.vin):
raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!")
# 2. Célflotta ellenőrzése
if not target_org_id:
stmt_org = select(Organization).join(OrganizationMember).where(
and_(
OrganizationMember.user_id == current_user.id,
Organization.org_type == OrgType.individual
)
)
org = (await db.execute(stmt_org)).scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Privát flotta nem található. KYC szükséges.")
final_org_id = org.id
else:
# Céges jogosultság ellenőrzése
stmt_mem = select(OrganizationMember).where(
and_(
OrganizationMember.organization_id == target_org_id,
OrganizationMember.user_id == current_user.id
)
)
member = (await db.execute(stmt_mem)).scalar_one_or_none()
if not member or (member.role != "owner" and not (member.permissions or {}).get("can_add_asset")):
raise HTTPException(status_code=403, detail="Nincs jogod ehhez a flottához!")
final_org_id = target_org_id
# 3. Katalógus ellenőrzése
stmt_cat = select(AssetCatalog).where(
and_(
AssetCatalog.make.ilike(asset_in.make), # Simán ilike, nem kell func() köré
AssetCatalog.model.ilike(asset_in.model)
)
)
catalog_item = (await db.execute(stmt_cat)).scalar_one_or_none()
"""Csak a jármű alapadatai és katalógus információi."""
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
asset = (await db.execute(stmt)).scalar_one_or_none()
if not catalog_item:
catalog_item = AssetCatalog(
make=asset_in.make,
model=asset_in.model,
vehicle_class=asset_in.vehicle_class,
fuel_type=asset_in.fuel_type
)
db.add(catalog_item)
await db.flush()
if not asset:
raise HTTPException(status_code=404, detail="Jármű nem található")
return {
"id": asset.id,
"vin": asset.vin,
"license_plate": asset.license_plate,
"name": asset.name,
"catalog": {
"make": asset.catalog.make,
"model": asset.catalog.model,
"type": asset.catalog.vehicle_class,
"factory_data": getattr(asset.catalog, 'factory_data', {})
}
}
# 4. Asset létrehozása vagy betöltése (Shadow Identity)
stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper())
new_asset = (await db.execute(stmt_exist)).scalar_one_or_none()
if not new_asset:
new_asset = Asset(
vin=asset_in.vin.upper(),
license_plate=asset_in.license_plate,
name=asset_in.name or f"{asset_in.make} {asset_in.model}",
year_of_manufacture=asset_in.year_of_manufacture,
fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk
vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk
mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk
catalog_id=catalog_item.id,
quality_index=1.00,
system_mileage=0
)
db.add(new_asset)
await db.flush()
# 5. Assignment
new_assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=final_org_id,
status="active"
)
db.add(new_assignment)
# 6. Kezdő KM esemény
if asset_in.current_reading:
db.add(AssetEvent(
asset_id=new_asset.id,
event_type="initial_reading",
recorded_mileage=asset_in.current_reading,
description="Kezdeti óraállás rögzítése",
data={"source": "user_registration"}
))
# --- 2. MODUL: PÉNZÜGY (Költségek) ---
@router.get("/{asset_id}/costs", response_model=Dict[str, Any])
async def get_asset_costs(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Pénzügyi modul: Helyi és EUR alapú összesítő, tételes lista."""
stmt = select(AssetCost).where(AssetCost.asset_id == asset_id)
costs = (await db.execute(stmt)).scalars().all()
summary_local = {}
summary_eur = {}
history = []
for c in costs:
cat = c.cost_type or "OTHER"
amt_local = float(c.amount_local)
amt_eur = float(c.amount_eur) if c.amount_eur else 0.0
summary_local[cat] = summary_local.get(cat, 0) + amt_local
summary_eur[cat] = summary_eur.get(cat, 0) + amt_eur
history.append({
"id": c.id,
"category": cat,
"amount_local": amt_local,
"currency_local": c.currency_local,
"amount_eur": amt_eur,
"exchange_rate": float(c.exchange_rate_used) if c.exchange_rate_used else 1.0,
"date": c.date
})
return {
"total_gross_local": sum(summary_local.values()),
"total_gross_eur": sum(summary_eur.values()),
"summary_local": summary_local,
"summary_eur": summary_eur,
"history": history
}
@router.post("/{asset_id}/costs", response_model=AssetCostResponse, status_code=status.HTTP_201_CREATED)
async def create_asset_cost(
asset_id: uuid.UUID,
cost_in: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új költség rögzítése.
Automatikus: EUR konverzió, Telemetria frissítés, XP jóváírás.
"""
# Validáció: az asset_id-nak egyeznie kell a path-szal
if cost_in.asset_id != asset_id:
raise HTTPException(status_code=400, detail="Asset ID mismatch")
try:
await db.commit()
await db.refresh(new_asset)
# 7. NAS mappa struktúra
nas_base = getattr(settings, "NAS_STORAGE_PATH", "/opt/docker/dev/service_finder/nas/assets")
asset_path = os.path.join(nas_base, str(new_asset.id))
os.makedirs(os.path.join(asset_path, "docs"), exist_ok=True)
os.makedirs(os.path.join(asset_path, "photos"), exist_ok=True)
return new_asset
new_cost = await cost_service.record_cost(
db=db,
cost_in=cost_in,
user_id=current_user.id
)
return new_cost
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise HTTPException(status_code=500, detail="Hiba a mentés során.")
raise HTTPException(status_code=500, detail=str(e))
# --- 3. MODUL: TELEMETRIA (Állapot) ---
@router.get("/{asset_id}/telemetry", response_model=Dict[str, Any])
async def get_asset_telemetry(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Műszaki állapot: KM óra, VQI (Quality) és DBS (Driving) pontszámok."""
stmt = select(AssetTelemetry).where(AssetTelemetry.asset_id == asset_id)
tel = (await db.execute(stmt)).scalar_one_or_none()
if not tel:
return {"current_mileage": 0, "vqi_score": 100.0, "dbs_score": 100.0}
return {
"current_mileage": tel.current_mileage,
"vqi_score": float(tel.vqi_score),
"dbs_score": float(tel.dbs_score),
"last_update": tel.updated_at if hasattr(tel, 'updated_at') else None
}