994 lines
36 KiB
Python
Executable File
994 lines
36 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
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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
|
|
|
|
Returns:
|
|
{ "can_add": bool, "current_count": int, "limit": int }
|
|
"""
|
|
try:
|
|
# 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
|
|
|
|
# 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 (draft vehicles don't count toward the limit)
|
|
from sqlalchemy import func
|
|
if org_id is not None:
|
|
count_stmt = select(func.count(Asset.id)).where(
|
|
Asset.current_organization_id == org_id,
|
|
Asset.owner_person_id == current_user.person_id,
|
|
Asset.status == "active"
|
|
)
|
|
else:
|
|
count_stmt = select(func.count(Asset.id)).where(
|
|
Asset.current_organization_id.is_(None),
|
|
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: 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),
|
|
selectinload(Asset.odometer_readings)
|
|
)
|
|
)
|
|
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),
|
|
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_net": cost.amount_net,
|
|
"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"),
|
|
}
|
|
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
|
|
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),
|
|
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
|
|
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."
|
|
)
|
|
|
|
# ── 🛡️ 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
|
|
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.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
|
|
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)
|
|
"""
|
|
# 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
|
|
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
|
|
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)
|
|
maintenance_cost = AssetCost(
|
|
asset_id=asset_id,
|
|
organization_id=organization_id,
|
|
category_id=2,
|
|
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.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()]
|
|
|
|
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
|
|
)
|
|
)
|
|
)
|
|
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.
|
|
"""
|
|
# 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
|
|
|
|
try:
|
|
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
|
cost_id = payload.cost_id
|
|
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"
|
|
|
|
cost_record = AssetCost(
|
|
asset_id=asset_id,
|
|
organization_id=organization_id,
|
|
category_id=cost_category_id,
|
|
amount_net=payload.cost_amount,
|
|
currency=currency,
|
|
date=event_date,
|
|
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
|
|
|
|
logger.info(
|
|
f"AssetCost auto-created for asset {asset_id}: "
|
|
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
|
)
|
|
|
|
# Új esemény létrehozása
|
|
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,
|
|
event_date=event_date,
|
|
)
|
|
db.add(event)
|
|
|
|
# ── Ü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"
|
|
) |