578 lines
23 KiB
Python
Executable File
578 lines
23 KiB
Python
Executable File
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
|
import logging
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
from uuid import UUID
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, desc
|
|
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
|
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
|
from app.schemas.asset_cost import AssetCostCreate
|
|
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
|
|
|
|
# Fuel category IDs - auto-approved even for DRIVER role
|
|
FUEL_CATEGORY_IDS = {1} # FUEL
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def _resolve_user_role_in_org(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
organization_id: int
|
|
) -> str:
|
|
"""
|
|
Resolve the user's role within the given organization.
|
|
|
|
Returns:
|
|
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
|
Returns 'MEMBER' as default if no membership found.
|
|
"""
|
|
stmt = select(OrganizationMember.role).where(
|
|
OrganizationMember.user_id == user_id,
|
|
OrganizationMember.organization_id == organization_id,
|
|
OrganizationMember.status == "active"
|
|
).limit(1)
|
|
result = await db.execute(stmt)
|
|
role = result.scalar_one_or_none()
|
|
return role or "MEMBER"
|
|
|
|
|
|
async def _check_org_capability(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
organization_id: int,
|
|
capability_name: str
|
|
) -> bool:
|
|
"""
|
|
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
|
|
|
|
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
|
|
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
|
|
|
Returns:
|
|
bool: True ha a user rendelkezik a képességgel.
|
|
"""
|
|
# 1. Get user's org role
|
|
member_stmt = select(OrganizationMember.role).where(
|
|
OrganizationMember.user_id == user_id,
|
|
OrganizationMember.organization_id == organization_id,
|
|
OrganizationMember.status == "active"
|
|
).limit(1)
|
|
member_result = await db.execute(member_stmt)
|
|
org_role_name = member_result.scalar_one_or_none()
|
|
|
|
if not org_role_name:
|
|
return False
|
|
|
|
# 2. Get permissions from fleet.org_roles table
|
|
role_stmt = select(OrgRole.permissions).where(
|
|
OrgRole.name_key == org_role_name,
|
|
OrgRole.is_active == True
|
|
).limit(1)
|
|
role_result = await db.execute(role_stmt)
|
|
permissions = role_result.scalar_one_or_none()
|
|
|
|
if not permissions:
|
|
# Fallback to OrganizationMember.permissions
|
|
member_perm_stmt = select(OrganizationMember.permissions).where(
|
|
OrganizationMember.user_id == user_id,
|
|
OrganizationMember.organization_id == organization_id,
|
|
OrganizationMember.status == "active"
|
|
).limit(1)
|
|
member_perm_result = await db.execute(member_perm_stmt)
|
|
permissions = member_perm_result.scalar_one_or_none() or {}
|
|
|
|
if not isinstance(permissions, dict):
|
|
permissions = {}
|
|
|
|
return permissions.get(capability_name, False)
|
|
|
|
|
|
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
|
|
"""
|
|
Calculate net amount from gross amount and VAT rate.
|
|
|
|
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
|
|
is the absolute Source of Truth. Net is calculated back from gross.
|
|
|
|
Formula: net = gross / (1 + vat_rate / 100)
|
|
|
|
If vat_rate is 0 or None, net = gross (0% VAT content).
|
|
"""
|
|
if vat_rate is None or vat_rate == 0:
|
|
return amount_gross
|
|
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
|
|
|
|
|
@router.get("")
|
|
async def list_all_expenses(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user = Depends(get_current_user),
|
|
page: int = Query(1, ge=1, description="Page number"),
|
|
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
|
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"),
|
|
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."),
|
|
):
|
|
"""
|
|
List all expenses across all assets for the current user's organization,
|
|
ordered by date descending with pagination.
|
|
Supports optional asset_id filter for vehicle-specific cost views.
|
|
Supports optional organization_id filter for cross-org views (user must be a member).
|
|
Returns enriched expense data including vehicle info, category name, and vendor.
|
|
"""
|
|
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org
|
|
if organization_id is not None:
|
|
# Verify user is a member of the requested organization
|
|
org_stmt = select(OrganizationMember).where(
|
|
OrganizationMember.user_id == current_user.id,
|
|
OrganizationMember.organization_id == organization_id,
|
|
OrganizationMember.status == "active"
|
|
).limit(1)
|
|
org_result = await db.execute(org_stmt)
|
|
membership = org_result.scalar_one_or_none()
|
|
if not membership:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="You are not an active member of the requested organization."
|
|
)
|
|
org_id = organization_id
|
|
else:
|
|
# Fallback: resolve user's first active organization
|
|
org_stmt = select(OrganizationMember).where(
|
|
OrganizationMember.user_id == current_user.id,
|
|
OrganizationMember.status == "active"
|
|
).limit(1)
|
|
org_result = await db.execute(org_stmt)
|
|
membership = org_result.scalar_one_or_none()
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="No active organization membership found.")
|
|
org_id = membership.organization_id
|
|
|
|
# Calculate offset
|
|
offset = (page - 1) * page_size
|
|
|
|
# Build base query with joins
|
|
base_select = (
|
|
select(
|
|
AssetCost,
|
|
CostCategory.code,
|
|
CostCategory.name,
|
|
Organization.name,
|
|
Asset.license_plate,
|
|
Asset.brand,
|
|
Asset.model,
|
|
)
|
|
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
|
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
|
.join(Asset, AssetCost.asset_id == Asset.id)
|
|
)
|
|
|
|
# Apply filters
|
|
filters = [AssetCost.organization_id == org_id]
|
|
if asset_id:
|
|
filters.append(AssetCost.asset_id == asset_id)
|
|
|
|
expense_stmt = (
|
|
base_select
|
|
.where(*filters)
|
|
.order_by(desc(AssetCost.date))
|
|
.offset(offset)
|
|
.limit(page_size)
|
|
)
|
|
expense_result = await db.execute(expense_stmt)
|
|
rows = expense_result.all()
|
|
|
|
# Build enriched response
|
|
expenses = []
|
|
for row in rows:
|
|
cost = row[0]
|
|
cat_code = row[1]
|
|
cat_name = row[2]
|
|
vendor_name = row[3]
|
|
license_plate = row[4]
|
|
brand = row[5]
|
|
model = row[6]
|
|
|
|
data = cost.data or {}
|
|
description = data.get("description")
|
|
mileage_at_cost = data.get("mileage_at_cost")
|
|
|
|
# Build vehicle display name
|
|
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
|
|
|
|
expenses.append({
|
|
"id": str(cost.id),
|
|
"asset_id": str(cost.asset_id),
|
|
"organization_id": cost.organization_id,
|
|
"category_id": cost.category_id,
|
|
"category_code": cat_code,
|
|
"category_name": cat_name,
|
|
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
|
"amount_net": str(cost.amount_net),
|
|
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
|
"currency": cost.currency,
|
|
"date": cost.date.isoformat() if cost.date else None,
|
|
"status": cost.status,
|
|
"description": description,
|
|
"mileage_at_cost": mileage_at_cost,
|
|
"invoice_number": cost.invoice_number,
|
|
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
|
"vendor_organization_id": cost.vendor_organization_id,
|
|
"external_vendor_name": cost.external_vendor_name,
|
|
"vendor_name": vendor_name,
|
|
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
|
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
|
# Vehicle info
|
|
"vehicle_name": vehicle_name,
|
|
"license_plate": license_plate,
|
|
})
|
|
|
|
# Count total for pagination (respect asset_id filter)
|
|
count_filters = [AssetCost.organization_id == org_id]
|
|
if asset_id:
|
|
count_filters.append(AssetCost.asset_id == asset_id)
|
|
count_stmt = (
|
|
select(func.count(AssetCost.id))
|
|
.where(*count_filters)
|
|
)
|
|
count_result = await db.execute(count_stmt)
|
|
total = count_result.scalar()
|
|
|
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
|
|
|
return {
|
|
"data": expenses,
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total_pages": total_pages,
|
|
}
|
|
|
|
|
|
@router.get("/{asset_id}")
|
|
async def list_asset_expenses(
|
|
asset_id: UUID,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user = Depends(get_current_user),
|
|
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"),
|
|
offset: int = Query(0, ge=0, description="Number of expenses to skip"),
|
|
):
|
|
"""
|
|
List all expenses for a specific asset, ordered by date descending.
|
|
Returns enriched expense data including category name, code, and vendor info.
|
|
Used by the OverviewTab and CostManagerModal to display real expense data.
|
|
"""
|
|
# Validate asset exists
|
|
stmt = select(Asset).where(Asset.id == 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.")
|
|
|
|
# Fetch expenses with category join and vendor organization join
|
|
expense_stmt = (
|
|
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
|
|
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
|
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
|
.where(AssetCost.asset_id == asset_id)
|
|
.order_by(desc(AssetCost.date))
|
|
.offset(offset)
|
|
.limit(limit)
|
|
)
|
|
expense_result = await db.execute(expense_stmt)
|
|
rows = expense_result.all()
|
|
|
|
# Build enriched response
|
|
expenses = []
|
|
for row in rows:
|
|
cost = row[0] # AssetCost instance
|
|
cat_code = row[1] # CostCategory.code
|
|
cat_name = row[2] # CostCategory.name
|
|
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
|
|
|
|
# Extract description and mileage from data JSONB
|
|
data = cost.data or {}
|
|
description = data.get("description")
|
|
mileage_at_cost = data.get("mileage_at_cost")
|
|
|
|
expenses.append({
|
|
"id": str(cost.id),
|
|
"asset_id": str(cost.asset_id),
|
|
"organization_id": cost.organization_id,
|
|
"category_id": cost.category_id,
|
|
"category_code": cat_code,
|
|
"category_name": cat_name,
|
|
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
|
"amount_net": str(cost.amount_net),
|
|
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
|
"currency": cost.currency,
|
|
"date": cost.date.isoformat() if cost.date else None,
|
|
"status": cost.status,
|
|
"description": description,
|
|
"mileage_at_cost": mileage_at_cost,
|
|
"invoice_number": cost.invoice_number,
|
|
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
|
# === B2B VENDOR FIELDS ===
|
|
"vendor_organization_id": cost.vendor_organization_id,
|
|
"external_vendor_name": cost.external_vendor_name,
|
|
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
|
|
# === INVOICE DATES ===
|
|
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
|
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
|
})
|
|
|
|
# Count total for pagination
|
|
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
|
|
count_result = await db.execute(count_stmt)
|
|
total = count_result.scalar()
|
|
|
|
return {
|
|
"data": expenses,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
@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."
|
|
)
|
|
|
|
# Determine organization_id: prefer explicit from payload, fallback to asset fields
|
|
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
|
|
if not organization_id:
|
|
# B2C fallback: if the asset is owned by a person (not an org),
|
|
# try to find the current user's default organization
|
|
org_stmt = select(OrganizationMember).where(
|
|
OrganizationMember.user_id == current_user.id,
|
|
OrganizationMember.is_verified == True
|
|
).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:
|
|
# Last resort: any membership (even unverified)
|
|
org_stmt = select(OrganizationMember).where(
|
|
OrganizationMember.user_id == current_user.id
|
|
).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
|
|
|
|
try:
|
|
# 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=expense.vendor_organization_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: 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
|
|
|
|
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,
|
|
}
|
|
|
|
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")
|