Files
service-finder/backend/app/api/v1/endpoints/expenses.py
2026-07-24 09:56:21 +00:00

726 lines
29 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
"""
Expense (AssetCost) API endpoints.
P0 Smart Expense Workflow (2026-06-20):
- POST /expenses/ — Create expense with odometer normalization, provider validation,
smart linking to AssetEvent, and gamification hooks.
- GET /expenses/ — List all expenses (admin).
- GET /expenses/{asset_id} — List expenses for a specific asset.
- PUT /expenses/{expense_id} — Update an existing expense.
GROSS-FIRST (Masterbook 2.0.1):
- amount_gross is the primary field (Bruttó).
- amount_net is optional — calculated back from gross + vat_rate.
- All VAT calculations happen in the service layer, not in the schema.
P0 HYBRID VENDOR REFACTOR (2026-07-01):
- service_provider_id (marketplace.service_providers) is the primary vendor FK.
- vendor_organization_id (fleet.organizations) is secondary (B2B).
- external_vendor_name is the fallback for free-text typed names.
P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation.
- A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
ami FK violation-t okoz. Itt validáljuk, hogy a megadott ID létezik-e
a fleet.organizations táblában. Ha nem, átirányítjuk service_provider_id-ra.
"""
import uuid
import logging
from datetime import datetime, timezone, date
from decimal import Decimal
from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, func, and_, or_, cast, Text, case, literal, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.models.fleet_finance.models import AssetCost, CostCategory
from app.models.vehicle.asset import Asset
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
from app.services.gamification_service import GamificationService
from app.services.provider_service import find_or_create_provider_by_name
logger = logging.getLogger(__name__)
router = APIRouter()
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
FUEL_CATEGORY_IDS = {1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
# ── SERVICE-RELATED CATEGORY IDS ──
# These categories trigger automatic AssetEvent creation (Smart Linking).
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18}
gamification_service = GamificationService()
# ── HELPER FUNCTIONS ──
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int,
) -> Optional[str]:
"""Resolve the user's role in the given organization.
Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found.
"""
stmt = select(OrganizationMember).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
result = await db.execute(stmt)
member = result.scalar_one_or_none()
if member:
return member.role
return None
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability: str,
) -> bool:
"""Check if a user has a specific capability in an organization.
Uses the JSONB permissions field from fleet.org_roles.
Returns True if the user's role has the capability, False otherwise.
"""
from app.models.fleet.org_role import OrgRole
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and isinstance(row, dict):
return row.get(capability, False)
return False
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
"""Calculate net amount from gross amount and VAT rate.
Formula: net = gross / (1 + vat_rate/100)
Args:
amount_gross: The gross amount (Bruttó)
vat_rate: The VAT rate in percent (e.g., 27.00 for 27%)
Returns:
The net amount (Nettó)
"""
if vat_rate == 0:
return amount_gross
divisor = Decimal("1") + (vat_rate / Decimal("100"))
return (amount_gross / divisor).quantize(Decimal("0.01"))
# ── ENDPOINTS ──
@router.get("")
async def list_all_expenses(
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
asset_id: Optional[uuid.UUID] = Query(None),
organization_id: Optional[int] = Query(None),
category_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc, amount_desc, amount_asc"),
):
"""
List all expenses (admin endpoint).
Supports filtering by asset_id, organization_id, category_id, status.
Supports sorting by date or amount.
"""
stmt = select(AssetCost)
if asset_id:
stmt = stmt.where(AssetCost.asset_id == asset_id)
if organization_id:
stmt = stmt.where(AssetCost.organization_id == organization_id)
if category_id:
stmt = stmt.where(AssetCost.category_id == category_id)
if status:
stmt = stmt.where(AssetCost.status == status)
# Sorting
if sort == "date_asc":
stmt = stmt.order_by(AssetCost.date.asc())
elif sort == "amount_desc":
stmt = stmt.order_by(AssetCost.amount_gross.desc())
elif sort == "amount_asc":
stmt = stmt.order_by(AssetCost.amount_gross.asc())
else:
stmt = stmt.order_by(AssetCost.date.desc())
# Count total
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Paginate
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
expenses = result.scalars().all()
return {
"status": "success",
"total": total,
"offset": offset,
"limit": limit,
"data": expenses,
}
@router.put("/{expense_id}", status_code=200)
async def update_expense(
expense_id: uuid.UUID,
expense_update: AssetCostUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""
Update an existing expense (PUT /expenses/{expense_id}).
All fields in AssetCostUpdate are optional — only provided fields will be updated.
The expense_id is passed via path parameter.
"""
# Fetch existing expense
stmt = select(AssetCost).where(AssetCost.id == expense_id)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
raise HTTPException(status_code=404, detail="Expense not found.")
# Update only provided fields
update_data = expense_update.model_dump(exclude_unset=True)
# Handle special fields
if "date" in update_data:
update_data["date"] = update_data.pop("date")
if "mileage_at_cost" in update_data:
# mileage_at_cost is stored inside data JSONB
if existing.data is None:
existing.data = {}
existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost")
if "description" in update_data:
if existing.data is None:
existing.data = {}
existing.data["description"] = update_data.pop("description")
# Apply updates
for field, value in update_data.items():
setattr(existing, field, value)
await db.commit()
await db.refresh(existing)
return {
"status": "success",
"id": existing.id,
"message": "Expense updated successfully.",
}
@router.get("/{asset_id}")
async def list_asset_expenses(
asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc"),
):
"""
List expenses for a specific asset.
Supports pagination and sorting by date.
"""
stmt = (
select(AssetCost)
.where(AssetCost.asset_id == asset_id)
)
# Sorting
if sort == "date_asc":
stmt = stmt.order_by(AssetCost.date.asc())
else:
stmt = stmt.order_by(AssetCost.date.desc())
# Count total
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# Paginate
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
expenses = result.scalars().all()
return {
"status": "success",
"total": total,
"offset": offset,
"limit": limit,
"data": expenses,
}
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user)
):
"""
Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
**Role-Based Auto-Approval:**
- OWNER/ADMIN → status = APPROVED
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
**Smart Linking (Double-Entry Avoidance):**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
an AssetEvent is automatically created with MISSING_TECH_DATA status
and linked via linked_asset_event_id / linked_expense_id.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found.")
# Dynamic Gatekeeper: Check draft expense limit
if asset.status == "draft":
# 1. Get VEHICLE_DRAFT_MAX_EXPENSES parameter
param_stmt = select(SystemParameter).where(
SystemParameter.key == "VEHICLE_DRAFT_MAX_EXPENSES",
SystemParameter.scope_level == "global"
)
param_result = await db.execute(param_stmt)
param = param_result.scalar_one_or_none()
if param:
limit = param.value.get("limit", 10) # Default to 10 if not found
else:
limit = 10 # Default fallback
# 2. Count existing expenses for this asset
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset.id)
count_result = await db.execute(count_stmt)
expense_count = count_result.scalar()
# 3. Check if limit reached
if expense_count >= limit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
)
# P0 ARCHITECTURE FIX: organization_id MUST follow the Asset, not the user.
# The cost belongs to the asset's current organization (e.g., Aprilia garage = org 21).
# The vendor is stored separately via vendor_organization_id.
#
# Resolution priority:
# 1. asset.current_organization_id (the asset's current fleet/garage)
# 2. asset.owner_org_id (the asset's owning organization)
# 3. Fallback: user's active organization from membership
if asset.current_organization_id:
organization_id = asset.current_organization_id
elif asset.owner_org_id:
organization_id = asset.owner_org_id
else:
# Fallback: user's active organization from membership
org_stmt = select(OrganizationMember).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
).limit(1)
org_result = await db.execute(org_stmt)
org_member = org_result.scalar_one_or_none()
if org_member:
organization_id = org_member.organization_id
else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability → auto-approved
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine expense status based on capabilities
if can_approve:
# User has can_approve_expense → auto-approved
expense_status = "APPROVED"
elif expense.category_id in FUEL_CATEGORY_IDS:
# Fuel costs are auto-approved even without can_approve_expense
expense_status = "APPROVED"
else:
# User lacks can_approve_expense → pending approval
expense_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
)
# ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
# In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
amount_gross = expense.amount_gross
vat_rate = expense.vat_rate
amount_net = expense.amount_net
# Auto-calculate net from gross if vat_rate is provided but net is not
if vat_rate is not None and amount_net is None:
amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
# Auto-calculate vat_rate if net is provided but vat_rate is not
elif amount_net is not None and vat_rate is None and amount_gross > 0:
# vat_rate = ((gross / net) - 1) * 100
vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
vat_rate = vat_rate.quantize(Decimal("0.01"))
# If only gross is provided (no net, no vat) → net = gross (0% VAT)
elif amount_net is None and vat_rate is None:
amount_net = amount_gross
vat_rate = Decimal("0")
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
if expense.mileage_at_cost is not None:
data["mileage_at_cost"] = expense.mileage_at_cost
if expense.description:
data["description"] = expense.description
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
# automatikusan felfedezzük vagy létrehozzuk a providert.
#
# P0 ARCHITECTURE CLEANUP (2026-07-01):
# A gamification/XP logika ELTÁVOLÍTVA a service rétegből.
# A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza.
# Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg
# a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel.
resolved_provider_id = expense.service_provider_id
if expense.external_vendor_name and not expense.service_provider_id:
try:
provider, created = await find_or_create_provider_by_name(
db=db,
name=expense.external_vendor_name,
added_by_user_id=current_user.id,
)
resolved_provider_id = provider.id
# P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben
# Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True)
if created:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="PROVIDER_DISCOVERY",
commit=False, # A hívó (create_expense) kezeli a commit-ot
action_key="PROVIDER_DISCOVERY",
)
logger.info(
f"Gamification XP awarded for PROVIDER_DISCOVERY: "
f"name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, user_id={current_user.id}"
)
logger.info(
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, created={created}, "
f"user_id={current_user.id}"
)
except Exception as e:
# Ha a provider felderítés hibázik, ne blokkolja a költség rögzítését
logger.warning(
f"Provider auto-discovery failed for '{expense.external_vendor_name}': {e}. "
f"Expense will be created without provider link."
)
try:
# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ──
# A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
# ami FK violation-t okoz, mert az FK a fleet.organizations táblára hivatkozik.
# Itt validáljuk, hogy a megadott vendor_organization_id létezik-e.
resolved_vendor_org_id = expense.vendor_organization_id
if resolved_vendor_org_id is not None:
try:
org_check_stmt = select(Organization.id).where(
Organization.id == resolved_vendor_org_id,
Organization.is_deleted == False,
)
org_check_result = await db.execute(org_check_stmt)
org_exists = org_check_result.scalar_one_or_none()
if org_exists is None:
# A megadott ID nem létezik fleet.organizations-ben.
# Valószínűleg ServiceProvider.id-t küldött a frontend.
logger.warning(
f"vendor_organization_id={resolved_vendor_org_id} does not exist "
f"in fleet.organizations. Setting to None and using service_provider_id."
)
resolved_vendor_org_id = None
# Ha nincs resolved_provider_id, akkor a vendor_organization_id-t
# használjuk service_provider_id-ként (P0 Hybrid Vendor Refactor)
if resolved_provider_id is None:
resolved_provider_id = expense.vendor_organization_id
except Exception as org_check_e:
logger.warning(
f"vendor_organization_id validation failed for "
f"id={resolved_vendor_org_id}: {org_check_e}. Setting to None."
)
resolved_vendor_org_id = None
# ── PHASE 1: Create AssetCost instance with new fields ──
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=amount_net,
amount_gross=amount_gross,
vat_rate=vat_rate,
currency=expense.currency,
date=expense.cost_date,
invoice_number=data.get("invoice_number"),
status=expense_status,
data=data,
# === B2B VENDOR FIELDS ===
vendor_organization_id=resolved_vendor_org_id,
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
# Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk
service_provider_id=resolved_provider_id,
external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES ===
invoice_date=expense.invoice_date,
fulfillment_date=expense.fulfillment_date,
)
db.add(new_cost)
await db.flush() # Flush to get new_cost.id
# ── PHASE 2: ODOMETER NORMALIZATION ──
# P0 Smart Expense Workflow: Create a dedicated OdometerReading record
# linked to the asset_id and the newly created cost_id.
odometer_record = None
if expense.mileage_at_cost is not None:
odometer_record = OdometerReading(
asset_id=expense.asset_id,
reading=expense.mileage_at_cost,
source="expense",
cost_id=new_cost.id,
)
db.add(odometer_record)
await db.flush()
logger.info(
f"Odometer normalization: created reading {odometer_record.id} "
f"for asset {expense.asset_id}, reading={expense.mileage_at_cost}, "
f"linked to cost {new_cost.id}"
)
# ── PHASE 3: PROVIDER VALIDATION SCORE INCREMENT ──
# P0 Smart Expense Workflow: If service_provider_id is provided,
# increment validation_score by 10. If it reaches 100, set is_verified.
provider_validated = False
provider_id_for_gamification = resolved_provider_id or expense.service_provider_id
if provider_id_for_gamification is not None:
try:
provider_stmt = select(ServiceProvider).where(
ServiceProvider.id == provider_id_for_gamification
)
provider_result = await db.execute(provider_stmt)
provider = provider_result.scalar_one_or_none()
if provider:
old_score = provider.validation_score or 0
if old_score < 100:
new_score = min(old_score + 10, 100)
provider.validation_score = new_score
provider_validated = True
logger.info(
f"Provider validation score incremented: provider_id={provider.id}, "
f"old_score={old_score}, new_score={new_score}"
)
# If validation_score reaches 100, mark the provider as verified
if new_score >= 100:
# Update ServiceProvider status to approved
from app.models.identity.social import ModerationStatus
provider.status = ModerationStatus.approved
# Also update the linked ServiceProfile.is_verified if exists
profile_stmt = select(ServiceProfile).where(
ServiceProfile.organization_id == provider.id
)
profile_result = await db.execute(profile_stmt)
profile = profile_result.scalar_one_or_none()
if profile:
profile.is_verified = True
logger.info(
f"Provider fully verified: provider_id={provider.id}, "
f"profile_id={profile.id}"
)
except Exception as prov_e:
# Provider validation failure should not block expense creation
logger.warning(
f"Provider validation score increment failed for "
f"provider_id={provider_id_for_gamification}: {prov_e}"
)
# ── PHASE 4: SMART LINKING - 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
# Determine event status based on capability
# can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
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,
linked_expense_id=new_cost.id, # Bidirectional link
status=event_status,
event_date=expense.cost_date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id
event_id = new_event.id
# Update the cost record with the link back to the event
new_cost.linked_asset_event_id = new_event.id
logger.info(
f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
f"Bidirectional link established."
)
# 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
# ── PHASE 5: GAMIFICATION HOOKS ──
# P0 Smart Expense Workflow: Award XP for expense logging and provider validation.
# All gamification calls use commit=False to stay within the outer transaction.
# 5a. Always award APP_USAGE_EXPENSE for successful expense logging
try:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="APP_USAGE_EXPENSE",
commit=False,
action_key="APP_USAGE_EXPENSE",
)
logger.info(
f"Gamification XP awarded for APP_USAGE_EXPENSE: "
f"user_id={current_user.id}, cost_id={new_cost.id}"
)
except Exception as gam_e:
logger.warning(
f"Gamification APP_USAGE_EXPENSE failed for user {current_user.id}: {gam_e}"
)
# 5b. If provider validation score was incremented, award PROVIDER_VALIDATION_HELP
if provider_validated:
try:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="PROVIDER_VALIDATION_HELP",
commit=False,
action_key="PROVIDER_VALIDATION_HELP",
)
logger.info(
f"Gamification XP awarded for PROVIDER_VALIDATION_HELP: "
f"user_id={current_user.id}, provider_id={provider_id_for_gamification}"
)
except Exception as gam_e:
logger.warning(
f"Gamification PROVIDER_VALIDATION_HELP failed for "
f"user {current_user.id}: {gam_e}"
)
# ── FINAL COMMIT: Single atomic transaction ──
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_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
"amount_net": str(new_cost.amount_net),
"vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
"expense_status": new_cost.status,
"date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id else None,
"odometer_reading_id": str(odometer_record.id) if odometer_record else None,
"provider_validated": provider_validated,
}
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.
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")