# /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")