szolgáltatók beálltásai, szerkesztése , létrehozása
This commit is contained in:
@@ -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"])
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
87
backend/app/api/v1/endpoints/constants.py
Normal file
87
backend/app/api/v1/endpoints/constants.py
Normal 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,
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user