1596 lines
56 KiB
Python
Executable File
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() |