726 lines
29 KiB
Python
Executable File
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")
|