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

@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services
admin_packages, admin_services, constants, providers
)
api_router = APIRouter()
@@ -32,4 +32,7 @@ api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])

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"

View File

@@ -1,13 +1,13 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/billing.py
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, Dict, Any
from sqlalchemy import select, func, and_
from typing import Optional, Dict, Any, List
import logging
import uuid
from app.api.deps import get_db, get_current_user
from app.models.identity import User, Wallet, UserRole
from app.models import FinancialLedger, WalletType
from app.models import FinancialLedger, WalletType, LedgerEntryType, LedgerStatus
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
from app.services.config_service import config
from app.services.payment_router import PaymentRouter
@@ -311,4 +311,99 @@ async def get_wallet_balance(
except Exception as e:
logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}")
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
@router.get("/wallet/transactions")
async def get_wallet_transactions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
page: int = Query(1, ge=1, description="Oldalszám"),
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
wallet_type: Optional[str] = Query(None, description="Szűrés pénztárca típusra (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)"),
entry_type: Optional[str] = Query(None, description="Szűrés bejegyzés típusra (DEBIT, CREDIT)"),
):
"""
Bejelentkezett felhasználó tranzakciótörténetének lekérdezése (FinancialLedger).
Visszaadja a főkönyvi bejegyzéseket lapozható formában, időrendben csökkenő sorrendben.
"""
try:
# Base query
conditions = [FinancialLedger.user_id == current_user.id]
# Optional wallet_type filter
if wallet_type:
try:
wt = WalletType(wallet_type.upper())
conditions.append(FinancialLedger.wallet_type == wt)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Érvénytelen wallet_type: {wallet_type}. Használd: {[wt.value for wt in WalletType]}"
)
# Optional entry_type filter
if entry_type:
try:
et = LedgerEntryType(entry_type.upper())
conditions.append(FinancialLedger.entry_type == et)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Érvénytelen entry_type: {entry_type}. Használd: {[et.value for et in LedgerEntryType]}"
)
# Count total matching records
count_stmt = select(func.count(FinancialLedger.id)).where(and_(*conditions))
count_result = await db.execute(count_stmt)
total_count = count_result.scalar() or 0
# Fetch paginated results
offset = (page - 1) * page_size
stmt = (
select(FinancialLedger)
.where(and_(*conditions))
.order_by(FinancialLedger.created_at.desc())
.offset(offset)
.limit(page_size)
)
result = await db.execute(stmt)
entries = result.scalars().all()
# Build response
transactions = []
for entry in entries:
description = ""
if entry.details and isinstance(entry.details, dict):
description = entry.details.get("description", "")
transactions.append({
"id": entry.id,
"transaction_id": str(entry.transaction_id),
"amount": float(entry.amount),
"entry_type": entry.entry_type.value,
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
"transaction_type": entry.transaction_type,
"description": description,
"status": entry.status.value if entry.status else None,
"balance_after": float(entry.balance_after) if entry.balance_after else None,
"currency": entry.currency or "EUR",
"created_at": entry.created_at.isoformat() if entry.created_at else None,
})
return {
"success": True,
"data": transactions,
"pagination": {
"page": page,
"page_size": page_size,
"total_count": total_count,
"total_pages": max(1, (total_count + page_size - 1) // page_size),
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Tranzakció történet lekérdezési hiba: {e}")
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")

View File

@@ -0,0 +1,87 @@
"""
Constants API Endpoint
======================
Serves static dictionaries (vehicle features, drive types, etc.)
to the frontend so it doesn't need to hardcode them.
"""
from fastapi import APIRouter, Query
from app.constants.vehicle_features import (
CAR_FEATURES,
CAR_DRIVE_TYPES,
CAR_TRANSMISSION_TYPES,
CAR_AC_TYPES,
CAR_BODY_TYPES,
AC_CONNECTOR_TYPES,
DC_CONNECTOR_TYPES,
MOTORCYCLE_STROKES,
MOTORCYCLE_COOLING,
MOTORCYCLE_MIXTURE,
MOTORCYCLE_FEATURES,
LCV_BODY_TYPES,
LCV_FEATURES,
HGV_TRANSMISSION_TYPES,
HGV_PTO_TYPES,
HGV_BODY_TYPES,
HGV_FEATURES,
)
router = APIRouter()
@router.get("/vehicle-features")
async def get_vehicle_features(
type: str = Query(None, description="Filter by vehicle type: 'car', 'motorcycle', 'lcv' or 'hgv'")
):
"""Return all vehicle feature dictionaries for frontend use.
If ?type=motorcycle is provided, returns motorcycle-specific data.
If ?type=lcv is provided, returns LCV-specific data.
If ?type=hgv is provided, returns HGV-specific data.
Otherwise returns car-specific data.
"""
if type == "motorcycle":
return {
**MOTORCYCLE_FEATURES, # Spread categories directly: technical, frame_body, luggage, multimedia, other
"drive_types": CAR_DRIVE_TYPES,
"transmission_types": CAR_TRANSMISSION_TYPES,
"ac_types": {}, # No AC types for motorcycles
"body_types": {}, # No body types for motorcycles
"ac_connector_types": AC_CONNECTOR_TYPES,
"dc_connector_types": DC_CONNECTOR_TYPES,
"motorcycle_strokes": MOTORCYCLE_STROKES,
"motorcycle_cooling": MOTORCYCLE_COOLING,
"motorcycle_mixture": MOTORCYCLE_MIXTURE,
}
if type == "lcv":
return {
**LCV_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia
"drive_types": CAR_DRIVE_TYPES,
"transmission_types": CAR_TRANSMISSION_TYPES,
"ac_types": CAR_AC_TYPES,
"body_types": LCV_BODY_TYPES,
"ac_connector_types": AC_CONNECTOR_TYPES,
"dc_connector_types": DC_CONNECTOR_TYPES,
}
if type == "hgv":
return {
**HGV_FEATURES, # Spread categories directly: technical_work, cabin, multimedia
"drive_types": CAR_DRIVE_TYPES,
"transmission_types": HGV_TRANSMISSION_TYPES,
"pto_types": HGV_PTO_TYPES,
"ac_types": CAR_AC_TYPES,
"body_types": HGV_BODY_TYPES,
"ac_connector_types": AC_CONNECTOR_TYPES,
"dc_connector_types": DC_CONNECTOR_TYPES,
}
return {
**CAR_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia, other
"drive_types": CAR_DRIVE_TYPES,
"transmission_types": CAR_TRANSMISSION_TYPES,
"ac_types": CAR_AC_TYPES,
"body_types": CAR_BODY_TYPES,
"ac_connector_types": AC_CONNECTOR_TYPES,
"dc_connector_types": DC_CONNECTOR_TYPES,
}

View File

@@ -1,11 +1,18 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
# Cost category IDs that are service/maintenance/repair related
# These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
router = APIRouter()
@@ -18,6 +25,10 @@ async def create_expense(
"""
Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
**Bidirectional Sync:**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
an AssetEvent is automatically created and linked to the cost via cost_id.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id)
@@ -85,32 +96,87 @@ async def create_expense(
if expense.description:
data["description"] = expense.description
# Create AssetCost instance
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_net,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
data=data
)
try:
# Create AssetCost instance
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_net,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
data=data
)
db.add(new_cost)
await db.flush() # Flush to get new_cost.id
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type
event_type = _map_category_to_event_type(expense.category_id)
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
mileage = expense.mileage_at_cost
new_event = AssetEvent(
asset_id=expense.asset_id,
user_id=getattr(current_user, 'id', None),
organization_id=organization_id,
event_type=event_type,
odometer_reading=mileage,
description=description,
cost_id=new_cost.id,
event_date=expense.date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id
event_id = new_event.id
logger.info(
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
)
# Update Asset.current_mileage if mileage_at_cost is higher
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
asset.current_mileage = expense.mileage_at_cost
await db.commit()
await db.refresh(new_cost)
return {
"status": "success",
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date,
"event_id": str(event_id) if event_id else None,
}
db.add(new_cost)
except Exception as e:
await db.rollback()
logger.error(f"Expense creation error for asset {expense.asset_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a költség rögzítésekor"
)
def _map_category_to_event_type(category_id: int) -> str:
"""
Map a cost category ID to the appropriate AssetEvent event_type.
# Update Asset.current_mileage if mileage_at_cost is higher
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
asset.current_mileage = expense.mileage_at_cost
await db.commit()
await db.refresh(new_cost)
return {
"status": "success",
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date
}
Returns:
str: The event type string (SERVICE, REPAIR, etc.)
"""
category_event_map = {
2: "MAINTENANCE", # MAINTENANCE
16: "SERVICE", # MAINT_SERVICE
17: "SERVICE", # MAINT_OIL
18: "REPAIR", # MAINT_BRAKES
}
return category_event_map.get(category_id, "MAINTENANCE")

View File

@@ -1,18 +1,165 @@
from fastapi import APIRouter, Depends
"""
Provider végpontok Szolgáltató keresés és gyors felvétel (crowdsourced).
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse
from app.services.social_service import create_service_provider
from app.api import deps
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.marketplace.service import ExpertiseTag
from app.schemas.provider import (
ProviderSearchResponse,
ProviderQuickAddIn,
ProviderQuickAddResponse,
ProviderUpdateIn,
ProviderUpdateResponse,
ExpertiseCategoryOut,
)
from app.services.provider_service import search_providers, quick_add_provider, update_provider
router = APIRouter()
logger = logging.getLogger(__name__)
# Secured endpoint: Closed premium ecosystem
@router.post("/", response_model=ServiceProviderResponse)
async def add_provider(
provider_data: ServiceProviderCreate,
@router.get("/categories", response_model=List[ExpertiseCategoryOut])
async def list_expertise_categories(
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_user)
current_user: User = Depends(get_current_user),
):
user_id = current_user.id
return await create_service_provider(db, provider_data, user_id)
"""
Visszaadja az összes expertise kategóriát a frontend dropdown számára.
"""
try:
stmt = select(ExpertiseTag).order_by(ExpertiseTag.id)
result = await db.execute(stmt)
tags = result.scalars().all()
return [
ExpertiseCategoryOut(
id=t.id,
key=t.key,
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,
)
for t in tags
]
except Exception as e:
logger.error(f"Hiba a kategóriák lekérése során: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a kategóriák lekérése során.",
)
@router.get("/search", response_model=ProviderSearchResponse)
async def search_service_providers(
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"),
offset: int = Query(0, ge=0, description="Lapozási offset"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Szolgáltatók keresése egyesített forrásokból.
Három forrásból keres:
- **verified_org**: Verifikált szervezetek (fleet.organizations, org_type='service_provider')
- **staged_data**: Robotok által gyűjtött adatok (marketplace.service_staging)
- **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers)
A találatok forrás szerint csoportosítva, lapozva érkeznek.
"""
try:
result = await search_providers(
db=db,
q=q,
category=category,
city=city,
limit=limit,
offset=offset,
)
return result
except Exception as e:
logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a keresés során.",
)
@router.post("/quick-add", response_model=ProviderQuickAddResponse, status_code=status.HTTP_201_CREATED)
async def quick_add_service_provider(
data: ProviderQuickAddIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Gyors szolgáltató felvétel crowdsourcingból.
Létrehoz egy új Organization-t (is_verified=False, org_type='service_provider'),
hozzáköt egy ServiceProfile-t, a megadott kategóriát (expertise_tags),
létrehoz egy Branch-t a címmel, és a felhasználót beállítja OWNER-nek.
A szolgáltató 'pending_verification' státuszba kerül, amíg a robotok
vagy az adminok nem ellenőrzik.
"""
try:
result = await quick_add_provider(
db=db,
data=data,
user_id=current_user.id,
)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.error(f"Hiba a gyors szolgáltató felvétel során: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a szolgáltató rögzítése során.",
)
@router.put("/{provider_id}", response_model=ProviderUpdateResponse)
async def update_service_provider(
provider_id: int,
data: ProviderUpdateIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Meglévő szolgáltató (Organization) adatainak szerkesztése.
Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó
ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék).
"""
try:
result = await update_provider(
db=db,
provider_id=provider_id,
data=data,
user_id=current_user.id,
)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba történt a szolgáltató frissítése során.",
)