szolgáltatók beálltásai, szerkesztése , létrehozása

This commit is contained in:
Roo
2026-06-17 11:52:25 +00:00
parent 213ba3b0f1
commit bf3a971ff1
56 changed files with 14421 additions and 1512 deletions

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py
import uuid
import logging
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
@@ -11,12 +11,12 @@ 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 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
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse
logger = logging.getLogger(__name__)
@@ -174,7 +174,10 @@ async def get_user_vehicles(
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(selectinload(Asset.catalog))
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
else:
# Corporate mode: only show vehicles belonging to the active organization's garages
@@ -212,7 +215,10 @@ async def get_user_vehicles(
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(selectinload(Asset.catalog))
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
@@ -294,9 +300,6 @@ async def get_asset(
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
@@ -317,7 +320,8 @@ async def get_asset(
)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition)
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
@@ -427,7 +431,7 @@ async def create_or_claim_vehicle(
except HTTPException:
raise
except Exception as e:
logger.error(f"Vehicle creation error: {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")
@@ -508,7 +512,7 @@ async def update_vehicle(
except Exception as e:
await db.rollback()
logger.error(f"Vehicle update error: {e}")
logger.error(f"Vehicle update error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
@@ -588,11 +592,7 @@ async def create_maintenance_record(
- 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
# Get user's organization memberships (imports already at module level)
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
@@ -629,7 +629,6 @@ async def create_maintenance_record(
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
@@ -642,8 +641,6 @@ async def create_maintenance_record(
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)
@@ -652,8 +649,6 @@ async def create_maintenance_record(
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)
@@ -667,11 +662,11 @@ async def create_maintenance_record(
detail="Cannot determine organization for this cost record. Please ensure you have an active organization."
)
# Create AssetCost record
# Create AssetCost record with MAINTENANCE category (id=2)
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
cost_category="maintenance",
category_id=2,
amount_net=float(payload["cost"]),
currency=payload.get("currency", "EUR"),
date=date,
@@ -684,17 +679,24 @@ async def create_maintenance_record(
)
db.add(maintenance_cost)
await db.commit()
await db.refresh(maintenance_cost)
await db.flush() # Flush to get maintenance_cost.id
# Also create an AssetEvent for the maintenance
from app.models.vehicle import AssetEvent
# Also create an AssetEvent for the maintenance (linked via cost_id)
maintenance_event = AssetEvent(
asset_id=asset_id,
event_type="maintenance"
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
@@ -712,6 +714,244 @@ async def create_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,
@@ -747,7 +987,7 @@ async def archive_vehicle(
except HTTPException:
raise
except Exception as e:
logger.error(f"Vehicle archive error for {vehicle_id}: {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"