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

594 lines
22 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
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, AssetCost
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
router = APIRouter()
logger = logging.getLogger(__name__)
@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: only show vehicles owned/operated personally (no organization)
stmt = (
select(Asset)
.where(
or_(
Asset.owner_org_id.is_(None),
Asset.operator_org_id.is_(None)
),
or_(
Asset.owner_person_id == current_user.person_id,
Asset.operator_person_id == current_user.person_id
)
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(selectinload(Asset.catalog))
)
else:
# Corporate mode: only show vehicles belonging to the active organization's garages
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 []
# First, get all branch IDs (garages) for this organization
from app.models.marketplace.organization import Branch
branch_stmt = select(Branch.id).where(
Branch.organization_id == scope_org_id,
Branch.is_deleted == False,
Branch.status == "active"
)
branch_result = await db.execute(branch_stmt)
branch_ids = [row[0] for row in branch_result.all()]
if not branch_ids:
# Organization has no active garages, return empty list
return []
# Query assets that are in any of the organization's garages
stmt = (
select(Asset)
.where(
Asset.branch_id.in_(branch_ids),
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(selectinload(Asset.catalog))
)
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)
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
return res.scalars().all()
@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
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()]
# Query asset with catalog and master definition
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition)
)
)
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
if existing_asset.owner_person_id == current_user.person_id:
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 a tulajdonosa
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
f"License plate {license_plate_clean} exists with different owner "
f"(owner_person_id={existing_asset.owner_person_id} != "
f"current_user.person_id={current_user.person_id}). "
f"Setting data_status='draft' for transfer scenario."
)
# 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
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 = logging.getLogger(__name__)
logger.error(f"Vehicle creation error: {e}")
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
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.person_id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.person_id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
)
)
.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 = logging.getLogger(__name__)
logger.error(f"Vehicle update error: {e}")
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
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
)
)
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)
"""
# 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 and get asset
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
)
)
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
from datetime import datetime
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
from sqlalchemy import select
from app.models.marketplace.organization import OrganizationMember
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
from sqlalchemy import select
from app.models.marketplace.organization import OrganizationMember
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
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
cost_category="maintenance",
amount_net=float(payload["cost"]),
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.commit()
await db.refresh(maintenance_cost)
# Also create an AssetEvent for the maintenance
from app.models.vehicle import AssetEvent
maintenance_event = AssetEvent(
asset_id=asset_id,
event_type="maintenance"
)
db.add(maintenance_event)
await db.commit()
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 = logging.getLogger(__name__)
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"
)