Files
service-finder/backend/app/api/v1/endpoints/expenses.py

182 lines
7.3 KiB
Python
Executable File

# /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, AssetEvent, OrganizationMember, SystemParameter
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
router = APIRouter()
@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.
**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)
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.")
# 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
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,
}
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")