garázs fejlesztés
This commit is contained in:
@@ -1,440 +1,292 @@
|
||||
# /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 Optional
|
||||
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 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, AssetCostUpdate
|
||||
|
||||
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_event import AssetEvent
|
||||
from app.models.vehicle.odometer import OdometerReading
|
||||
from app.models.fleet.organization import Organization
|
||||
from app.models.fleet.org_member import OrganizationMember
|
||||
from app.models.marketplace.service import ServiceProvider, ServiceProfile, ServiceExpertise
|
||||
from app.models.system.system_parameter 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
|
||||
from app.services.gamification_service import gamification_service
|
||||
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()
|
||||
|
||||
# ── 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
|
||||
) -> str:
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
OrganizationMember.status == "active",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
return role or "MEMBER"
|
||||
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_name: str
|
||||
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.
|
||||
"""
|
||||
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(
|
||||
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"
|
||||
).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)
|
||||
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ó)
|
||||
"""
|
||||
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:
|
||||
if vat_rate == 0:
|
||||
return amount_gross
|
||||
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
||||
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),
|
||||
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."),
|
||||
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 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.
|
||||
List all expenses (admin endpoint).
|
||||
Supports filtering by asset_id, organization_id, category_id, status.
|
||||
Supports sorting by date or amount.
|
||||
"""
|
||||
# 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
|
||||
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:
|
||||
# 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
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 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)
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
expense_id: uuid.UUID,
|
||||
expense_update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
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 the existing expense
|
||||
# Fetch existing expense
|
||||
stmt = select(AssetCost).where(AssetCost.id == expense_id)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.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 organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
|
||||
|
||||
# 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(cost, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"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,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
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,
|
||||
asset_id: uuid.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"),
|
||||
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 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.
|
||||
List expenses for a specific asset.
|
||||
Supports pagination and sorting by date.
|
||||
"""
|
||||
# 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)
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.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)
|
||||
# Sorting
|
||||
if sort == "date_asc":
|
||||
stmt = stmt.order_by(AssetCost.date.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Extract description and mileage from data JSONB
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
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()
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +294,7 @@ async def list_asset_expenses(
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
@@ -614,7 +466,39 @@ async def create_expense(
|
||||
)
|
||||
|
||||
try:
|
||||
# Create AssetCost instance with new fields
|
||||
# ── 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,
|
||||
@@ -628,7 +512,7 @@ async def create_expense(
|
||||
status=expense_status,
|
||||
data=data,
|
||||
# === B2B VENDOR FIELDS ===
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
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,
|
||||
@@ -641,7 +525,76 @@ async def create_expense(
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
# ── 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
|
||||
@@ -683,6 +636,51 @@ async def create_expense(
|
||||
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)
|
||||
|
||||
@@ -697,6 +695,8 @@ async def create_expense(
|
||||
"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:
|
||||
|
||||
Reference in New Issue
Block a user