Files
service-finder/backend/app/api/v1/endpoints/assets.py
2026-06-23 21:11:21 +00:00

1596 lines
56 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, text, or_
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__)
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int
) -> str:
"""
Resolve the user's role within the given organization.
Returns:
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
"""
stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
return role or "MEMBER"
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability_name: str
) -> bool:
"""
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
permissions JSONB oszlopából ellenőrzi a kért képességet.
Returns:
bool: True ha a user rendelkezik a képességgel.
"""
# 1. Get user's org role
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
archive_reason: str = Field(..., min_length=1, max_length=100, description="Eltávolítás oka")
router = APIRouter()
@router.get("/vehicles/quota-status")
async def get_vehicle_quota_status(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Preemptive quota check endpoint for the frontend.
Returns whether the user can add a new vehicle BEFORE they start the wizard.
GET /api/v1/assets/vehicles/quota-status
Resolves the user's active organization:
- If scope_id is set, use that organization
- If scope_id is null, resolve the user's primary (individual) garage
from fleet.organizations via owner_id
Returns:
{ "can_add": bool, "current_count": int, "limit": int }
"""
try:
from sqlalchemy import func
from app.models.marketplace.organization import Organization
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
try:
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
else:
# Resolve the user's primary individual garage
org_stmt = select(Organization.id).where(
Organization.owner_id == current_user.id,
Organization.org_type == "individual",
Organization.is_active == True
).limit(1)
org_result = await db.execute(org_stmt)
resolved_org_id = org_result.scalar_one_or_none()
if resolved_org_id is not None:
org_id = resolved_org_id
logger.info(
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
)
# Get the user's vehicle limit
allowed_limit = await AssetService.get_user_vehicle_limit(
db, current_user.id, org_id
)
# SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1)
# Count only active vehicles in the resolved organization
if org_id is not None:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.status == "active"
)
else:
# Fallback: count by owner_person_id if no org could be resolved
count_stmt = select(func.count(Asset.id)).where(
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
current_count = (await db.execute(count_stmt)).scalar() or 0
can_add = current_count < allowed_limit
logger.info(
f"Quota check for user {current_user.id} (org={org_id}): "
f"current_count={current_count}, allowed_limit={allowed_limit}, can_add={can_add}"
)
return {
"can_add": can_add,
"current_count": current_count,
"limit": allowed_limit
}
except Exception as e:
logger.error(f"Quota status check error for user {current_user.id}: {e}")
# Fail open — if we can't check quota, allow the user to proceed
return {"can_add": True, "current_count": 0, "limit": 9999}
@router.get("/vehicles/check")
async def check_vehicle_exists(
license_plate: str,
vin: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Gyors, dedikált ellenőrző végpont a frontend számára.
Ellenőrzi, hogy a megadott rendszám VAGY alvázszám (VIN) már létezik-e
az adatbázisban, és visszaadja a tulajdonos ID-ját, ha létezik.
GET /api/v1/assets/vehicles/check?license_plate=ABC123&vin=WBA12345678901234
"""
normalized_plate = license_plate.strip().upper()
conditions = [Asset.license_plate == normalized_plate]
if vin:
normalized_vin = vin.strip().upper()
conditions.append(Asset.vin == normalized_vin)
stmt = select(Asset).where(or_(*conditions))
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
return {
"exists": True,
"owner_id": existing.owner_person_id,
"owner_org_id": existing.owner_org_id
}
return {"exists": False, "owner_id": None, "owner_org_id": None}
@router.get("/vehicles", response_model=List[AssetResponse])
async def get_user_vehicles(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get all vehicles/assets belonging to the current user or their organization.
This endpoint returns a paginated list of vehicles that the authenticated user
has access to (either as owner or through organization membership).
Garage-centric logic: In corporate mode, only returns vehicles physically
parked in the active organization's garages (branches).
Sorting: is_primary vehicles appear first (based on individual_equipment JSONB),
then ordered by created_at descending.
"""
from sqlalchemy import or_, select
# JSONB ordering: is_primary (True) first, then by created_at desc
# individual_equipment -> 'is_primary' path, cast to text then boolean for ordering
is_primary_expr = Asset.individual_equipment['is_primary'].astext.cast(
# Use SQL cast to boolean via text expression
type_=None
)
# Use a raw SQL expression for JSONB boolean ordering
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None:
# Personal mode: show vehicles in organizations where user is a member
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.current_organization_id.in_(user_org_ids),
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
else:
# No organizations — return empty list
return []
else:
# Corporate mode: show vehicles belonging to the active organization
# Uses current_organization_id (same logic as quota-status endpoint)
# for consistency — vehicles belong to organizations, not branches.
try:
scope_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
# If scope_id is not a valid integer, treat as no organization
scope_org_id = None
if scope_org_id is None:
# Fallback: no valid organization, return empty list
return []
# Query assets that belong to the active organization
stmt = (
select(Asset)
.where(
Asset.current_organization_id == scope_org_id,
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
assets = result.scalars().all()
return assets
@router.get("/{asset_id}/financial-summary", response_model=Dict[str, Any])
async def get_asset_financial_report(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
MB 2.0 Dinamikus Pénzügyi Riport.
Visszaadja a kategóriákra bontott és az összesített költségeket (Local/EUR).
"""
# 1. Jogosultság ellenőrzése (Csak a tulajdonos vagy admin láthatja)
# (Itt egy gyors check, hogy az asset az övé-e)
try:
return await cost_service.get_asset_financial_summary(db, asset_id)
except Exception as e:
raise HTTPException(status_code=500, detail="Hiba a riport generálásakor")
@router.get("/{asset_id}/costs", response_model=List[AssetCostResponse])
async def list_asset_costs(
asset_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Tételes költséglista lapozással (Pagination)."""
stmt = (
select(AssetCost)
.options(selectinload(AssetCost.category))
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
costs = res.scalars().all()
# Map DB models to response, enriching with category info and data fields
result = []
for cost in costs:
cost_dict = {
"id": cost.id,
"asset_id": cost.asset_id,
"organization_id": cost.organization_id,
"amount_gross": cost.amount_gross,
"amount_net": cost.amount_net,
"vat_rate": cost.vat_rate,
"currency": cost.currency,
"date": cost.date,
"invoice_number": cost.invoice_number,
"data": cost.data or {},
"category_id": cost.category_id,
"category_name": cost.category.name if cost.category else None,
"category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
"status": cost.status,
"linked_asset_event_id": cost.linked_asset_event_id,
}
result.append(AssetCostResponse(**cost_dict))
return result
@router.get("/{asset_id}", response_model=AssetResponse)
async def get_asset(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get detailed information about a specific vehicle/asset.
Returns the asset's full technical profile including catalog data
and vehicle model definition specifications.
"""
# Check if user has access to this asset
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Query asset with catalog and master definition
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
return asset
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
async def get_vehicle(
vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get detailed information about a specific vehicle/asset by vehicle_id.
GET /api/v1/assets/vehicles/{vehicle_id}
This endpoint is the primary single-vehicle lookup used by the frontend.
Uses Unified Workspace (OrganizationMember-based) permission check.
Returns the asset's full technical profile including catalog data
and vehicle model definition specifications.
"""
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
return asset
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_or_claim_vehicle(
payload: AssetCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új jármű hozzáadása vagy meglévő jármű igénylése a flottához.
A végpont a következőket végzi:
- Ellenőrzi a felhasználó járműlimitjét
- OKOS DUPLEX VÉDELEM: license_plate alapján ellenőrzi a duplikációt
- Szabály A: Ha a rendszám létezik ÉS a tulajdonos ugyanaz → 400 hiba
- Szabály B: Ha a rendszám létezik, de más a tulajdonos → data_status='draft'
- Ha új, létrehozza a járművet és a kapcsolódó digitális ikreket
- XP jutalom adása a felhasználónak
"""
try:
# ── OKOS DUPLEX VÉDELEM: license_plate ellenőrzés ──
license_plate_clean = payload.license_plate.strip().upper()
# Check if any existing asset has this license plate
existing_stmt = select(Asset).where(
Asset.license_plate == license_plate_clean
)
existing_result = await db.execute(existing_stmt)
existing_asset = existing_result.scalar_one_or_none()
if existing_asset:
# Szabály A: Saját jármű duplikációja — check via organization membership
# Get user's organization IDs
member_org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
member_org_result = await db.execute(member_org_stmt)
user_org_ids = [row[0] for row in member_org_result.all()]
if existing_asset.current_organization_id in user_org_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!"
)
# Szabály B: Tulajdonosváltás / Átmeneti állapot
# A rendszám létezik, de más szervezetben van
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
f"License plate {license_plate_clean} exists in different organization "
f"(current_organization_id={existing_asset.current_organization_id}). "
f"Setting data_status='draft' for transfer scenario."
)
# ── 🛡️ KETTŐS VÉDELEM: Szervezet hozzárendelés ──
# 1. PRIORITÁS: A frontend által küldött organization_id (ha van)
# 2. FALLBACK: A felhasználó aktív scope-ja (scope_id)
# 3. VASÁJTÓ: Ha egyik sincs, lekérdezzük a fleet.organization_members táblából
org_id = payload.organization_id
if org_id is None and current_user.scope_id is not None:
try:
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
# VASÁJTÓ FALLBACK: Ha még mindig nincs org_id, keressük meg a felhasználó
# első elérhető szervezetét a fleet.organization_members táblában
if org_id is None:
from app.models.marketplace.organization import OrganizationMember
member_stmt = (
select(OrganizationMember.organization_id)
.where(OrganizationMember.user_id == current_user.id)
.limit(1)
)
member_result = await db.execute(member_stmt)
member_row = member_result.first()
if member_row:
org_id = member_row[0]
logger.info(
f"Iron Door fallback: assigned org_id={org_id} from "
f"organization_members for user {current_user.id}"
)
else:
logger.warning(
f"Iron Door fallback: user {current_user.id} has no "
f"organization memberships — asset will be created without org"
)
asset = await AssetService.create_or_claim_vehicle(
db=db,
user_id=current_user.id,
org_id=org_id,
asset_data=payload
)
return asset
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error(f"Vehicle creation failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
@router.put("/vehicles/{asset_id}", response_model=AssetResponse)
async def update_vehicle(
asset_id: uuid.UUID,
payload: AssetUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Meglévő jármű adatainak frissítése.
Csak a megadott mezőket frissíti (partial update).
Ellenőrzi, hogy a felhasználónak van-e jogosultsága a járműhöz.
"""
from sqlalchemy import or_
from app.models.marketplace.organization import OrganizationMember
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Find the asset — Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(selectinload(Asset.catalog))
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(selectinload(Asset.catalog))
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to modify it"
)
try:
# Build update dict from non-None fields
update_data = payload.model_dump(exclude_unset=True)
# Handle is_primary → individual_equipment JSONB
if 'is_primary' in update_data:
equipment = dict(asset.individual_equipment or {})
equipment['is_primary'] = update_data.pop('is_primary')
update_data['individual_equipment'] = equipment
# Handle individual_equipment merge
if 'individual_equipment' in update_data and update_data['individual_equipment'] is not None:
existing_equipment = dict(asset.individual_equipment or {})
existing_equipment.update(update_data['individual_equipment'])
update_data['individual_equipment'] = existing_equipment
# Update the asset
for field, value in update_data.items():
if value is not None and hasattr(asset, field):
setattr(asset, field, value)
asset.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(asset)
return asset
except Exception as e:
await db.rollback()
logger.error(f"Vehicle update error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
@router.get("/{asset_id}/maintenance", response_model=List[AssetCostResponse])
async def list_maintenance_records(
asset_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
List maintenance records for a specific vehicle.
Returns paginated list of maintenance costs with cost_category = 'maintenance'.
"""
# Check if user has access to this asset
from sqlalchemy import or_
from app.models.marketplace.organization import OrganizationMember
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
# Query maintenance costs
stmt = (
select(AssetCost)
.where(
AssetCost.asset_id == asset_id,
AssetCost.cost_category == "maintenance"
)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
return res.scalars().all()
@router.post("/{asset_id}/maintenance", response_model=AssetCostResponse, status_code=status.HTTP_201_CREATED)
async def create_maintenance_record(
asset_id: uuid.UUID,
payload: Dict[str, Any],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Add a maintenance record for a vehicle.
Expected payload fields:
- date: ISO datetime string (required)
- odometer: integer (optional, current mileage)
- description: string (required)
- cost: float (required, net amount)
- currency: string (optional, default: "EUR")
- invoice_number: string (optional)
"""
# Get user's organization memberships (imports already at module level)
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access and get asset — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
# Validate required fields
required_fields = ["date", "description", "cost"]
for field in required_fields:
if field not in payload:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Missing required field: {field}"
)
try:
# Parse date
date = datetime.fromisoformat(payload["date"].replace("Z", "+00:00"))
# Determine organization ID: use asset's current org, owner org, or user's active organization
organization_id = asset.current_organization_id or asset.owner_org_id
if not organization_id:
# Get user's active organization from their scope_id
if current_user.scope_id:
try:
organization_id = int(current_user.scope_id)
except (ValueError, TypeError):
# If scope_id is not a valid integer, try to get from organization memberships
stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
).limit(1)
result = await db.execute(stmt)
org_row = result.first()
organization_id = org_row[0] if org_row else None
else:
# Try to get from organization memberships
stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
).limit(1)
result = await db.execute(stmt)
org_row = result.first()
organization_id = org_row[0] if org_row else None
if not organization_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot determine organization for this cost record. Please ensure you have an active organization."
)
# Create AssetCost record with MAINTENANCE category (id=2)
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
cost_gross = float(payload["cost"])
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=2,
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
currency=payload.get("currency", "EUR"),
date=date,
invoice_number=payload.get("invoice_number"),
data={
"odometer": payload.get("odometer"),
"description": payload["description"],
"type": "maintenance"
}
)
db.add(maintenance_cost)
await db.flush() # Flush to get maintenance_cost.id
# Also create an AssetEvent for the maintenance (linked via cost_id)
maintenance_event = AssetEvent(
asset_id=asset_id,
user_id=current_user.id,
organization_id=organization_id,
event_type="MAINTENANCE",
odometer_reading=payload.get("odometer"),
description=payload.get("description", "Maintenance record"),
cost_id=maintenance_cost.id,
event_date=date,
)
db.add(maintenance_event)
# Single commit for both records
await db.commit()
await db.refresh(maintenance_cost)
return maintenance_cost
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid data format: {str(e)}"
)
except Exception as e:
await db.rollback()
logger.error(f"Maintenance record creation error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error while creating maintenance record"
)
# ═══════════════════════════════════════════════════════════════════════════════
# DIGITÁLIS SZERVIZKÖNYV (Service Book) — AssetEvent végpontok
# ═══════════════════════════════════════════════════════════════════════════════
async def _check_asset_access(
db: AsyncSession,
asset_id: uuid.UUID,
current_user: User
) -> Asset:
"""
Helper: check if the current user has access to the given asset.
Returns the Asset or raises 404.
"""
from app.models.marketplace.organization import OrganizationMember
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
return asset
async def _resolve_cost_category_for_event(db: AsyncSession, event_type: str) -> int:
"""
Helper: resolve a CostCategory ID based on the AssetEvent event_type.
Maps service-book event types to cost categories:
- SERVICE, MAINTENANCE, INSPECTION, TIRE_CHANGE -> 'MAINTENANCE' (karbantartás)
- REPAIR, ACCIDENT -> 'REPAIR' (javítás)
- UPGRADE, RECALL -> 'UPGRADE' (fejlesztés)
Falls back to a generic 'MAINTENANCE' category if none found.
"""
category_map = {
"SERVICE": "MAINTENANCE",
"MAINTENANCE": "MAINTENANCE",
"INSPECTION": "MAINTENANCE",
"TIRE_CHANGE": "MAINTENANCE",
"REPAIR": "REPAIR",
"ACCIDENT": "REPAIR",
"UPGRADE": "UPGRADE",
"RECALL": "UPGRADE",
}
category_code = category_map.get(event_type, "MAINTENANCE")
stmt = select(CostCategory.id).where(
CostCategory.code == category_code
).limit(1)
result = await db.execute(stmt)
cat_id = result.scalar_one_or_none()
if cat_id is None:
# Fallback: try to find any category with 'MAINTENANCE' in name
fallback_stmt = select(CostCategory.id).where(
CostCategory.code.ilike("%MAINTENANCE%")
).limit(1)
fallback_result = await db.execute(fallback_stmt)
cat_id = fallback_result.scalar_one_or_none()
if cat_id is None:
# Ultimate fallback: pick the first available cost category
any_stmt = select(CostCategory.id).limit(1)
any_result = await db.execute(any_stmt)
cat_id = any_result.scalar_one_or_none()
if cat_id is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No cost category found in the system. Please seed cost categories first."
)
return cat_id
@router.get("/{asset_id}/events", response_model=List[AssetEventResponse])
async def list_asset_events(
asset_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Szervizkönyv események listázása.
GET /api/v1/assets/{asset_id}/events
Visszaadja a járműhöz tartozó összes AssetEvent rekordot,
dátum szerint csökkenő sorrendben (legújabb elöl).
"""
# Jogosultság ellenőrzés
await _check_asset_access(db, asset_id, current_user)
stmt = (
select(AssetEvent)
.options(selectinload(AssetEvent.cost))
.where(AssetEvent.asset_id == asset_id)
.order_by(desc(AssetEvent.event_date))
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
events = result.scalars().all()
return events
@router.post("/{asset_id}/events", response_model=AssetEventResponse, status_code=status.HTTP_201_CREATED)
async def create_asset_event(
asset_id: uuid.UUID,
payload: AssetEventCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Új szervizkönyv esemény rögzítése.
POST /api/v1/assets/{asset_id}/events
**Üzleti logika:**
- Ha a beküldött `odometer_reading` nagyobb, mint a jármű jelenlegi
`current_mileage` értéke, akkor:
1. Létrehoz egy új OdometerReading rekordot
2. Frissíti a jármű `current_mileage` mezőjét
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
**Role-Based Auto-Approval (PHASE 2):**
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
**Smart Linking (Double-Entry Avoidance):**
- When cost_amount > 0, the auto-created AssetCost is linked back
to the event via linked_expense_id / linked_asset_event_id.
"""
# Jogosultság ellenőrzés
asset = await _check_asset_access(db, asset_id, current_user)
# Dátum: ha nincs megadva, használjuk a jelenlegi időt
event_date = payload.event_date or datetime.now(timezone.utc)
# Szervezet meghatározása
organization_id = asset.current_organization_id or asset.owner_org_id
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine event and cost status based on capability
if can_approve:
event_status = "COMPLETED"
cost_status = "APPROVED"
else:
event_status = "MISSING_TECH_DATA"
cost_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, "
f"event_status={event_status}, cost_status={cost_status}"
)
try:
# ── Üzleti logika: AssetCost automatikus létrehozása ──
cost_id = payload.cost_id
auto_created_cost_id = None
if payload.cost_amount is not None and payload.cost_amount > 0:
# Költségkategória feloldása az esemény típusa alapján
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
cost_category_id = await _resolve_cost_category_for_event(db, payload.event_type)
currency = payload.currency or "HUF"
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
cost_gross = payload.cost_amount
cost_record = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=cost_category_id,
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT)
currency=currency,
date=event_date,
status=cost_status,
data={
"description": payload.description or f"Service event: {payload.event_type}",
"mileage_at_cost": payload.odometer_reading,
"source": "service_book",
"event_type": payload.event_type,
},
)
db.add(cost_record)
await db.flush() # Flush to get the cost_record.id
cost_id = cost_record.id
auto_created_cost_id = cost_record.id
logger.info(
f"AssetCost auto-created for asset {asset_id}: "
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
f"cost_id={cost_id}, status={cost_status})"
)
# Új esemény létrehozása (with status and bidirectional link)
event = AssetEvent(
asset_id=asset_id,
user_id=current_user.id,
organization_id=organization_id,
event_type=payload.event_type,
odometer_reading=payload.odometer_reading,
description=payload.description,
cost_id=cost_id,
linked_expense_id=auto_created_cost_id, # Bidirectional link
status=event_status,
event_date=event_date,
)
db.add(event)
await db.flush() # Flush to get event.id
# If we auto-created a cost, link it back to the event
if auto_created_cost_id:
# Update the cost record with the link back to the event
stmt_update = (
select(AssetCost)
.where(AssetCost.id == auto_created_cost_id)
)
result = await db.execute(stmt_update)
cost_record = result.scalar_one_or_none()
if cost_record:
cost_record.linked_asset_event_id = event.id
logger.info(
f"Smart Sync: Bidirectional link established between "
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
)
# ── Üzleti logika: Km óra állás frissítése ──
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
# 1. Új OdometerReading rekord létrehozása
odometer = OdometerReading(
asset_id=asset_id,
reading=payload.odometer_reading,
source="manual",
)
db.add(odometer)
# 2. Jármű current_mileage frissítése
asset.current_mileage = payload.odometer_reading
asset.updated_at = datetime.now(timezone.utc)
logger.info(
f"Odometer updated for asset {asset_id}: "
f"{asset.current_mileage}{payload.odometer_reading} "
f"(via event {payload.event_type})"
)
await db.commit()
await db.refresh(event, attribute_names=["cost"])
return event
except Exception as e:
await db.rollback()
logger.error(f"Asset event creation error for {asset_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a szervizkönyv esemény létrehozásakor"
)
@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
async def archive_vehicle(
vehicle_id: uuid.UUID,
payload: ArchiveVehicleRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Jármű biztonságos kivezetése (Strict Soft Delete).
POST /api/v1/assets/vehicles/{vehicle_id}/archive
A végpont:
1. Frissíti a jármű current_mileage értékét a megadott final_mileage-ra
2. Átállítja a státuszt 'archived'-ra
3. Nullázza a tulajdonosi mezőket (owner_person_id, owner_org_id)
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba
Payload:
- final_mileage: int (utolsó km óra állás)
- archive_reason: string (Eladás, Gazdasági totálkár / Bontás, Lízing/Bérlet lejárta, Téves rögzítés, Egyéb)
"""
try:
asset = await AssetService.archive_vehicle(
db=db,
asset_id=vehicle_id,
user_id=current_user.id,
final_mileage=payload.final_mileage,
archive_reason=payload.archive_reason,
)
return asset
except HTTPException:
raise
except Exception as e:
logger.error(f"Vehicle archive error for {vehicle_id}: {e}", exc_info=True)
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()