RABC fejlesztése és beélesítése hibák kijavításával
This commit is contained in:
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
|
||||
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
|
||||
from app.models.identity import User
|
||||
from app.services.cost_service import cost_service
|
||||
from app.services.asset_service import AssetService
|
||||
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ArchiveVehicleRequest(BaseModel):
|
||||
"""Payload a jármű archiválásához (Soft Delete)."""
|
||||
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
|
||||
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
|
||||
|
||||
GET /api/v1/assets/vehicles/quota-status
|
||||
|
||||
Resolves the user's active organization:
|
||||
- If scope_id is set, use that organization
|
||||
- If scope_id is null, resolve the user's primary (individual) garage
|
||||
from fleet.organizations via owner_id
|
||||
|
||||
Returns:
|
||||
{ "can_add": bool, "current_count": int, "limit": int }
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# Determine organization ID based on user's active scope (garage isolation)
|
||||
org_id = None
|
||||
if current_user.scope_id is not None:
|
||||
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
|
||||
org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
# Resolve the user's primary individual garage
|
||||
org_stmt = select(Organization.id).where(
|
||||
Organization.owner_id == current_user.id,
|
||||
Organization.org_type == "individual",
|
||||
Organization.is_active == True
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
resolved_org_id = org_result.scalar_one_or_none()
|
||||
if resolved_org_id is not None:
|
||||
org_id = resolved_org_id
|
||||
logger.info(
|
||||
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
|
||||
)
|
||||
|
||||
# Get the user's vehicle limit
|
||||
allowed_limit = await AssetService.get_user_vehicle_limit(
|
||||
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
|
||||
# SAFETY: Ensure limit is at least 1
|
||||
allowed_limit = max(allowed_limit or 1, 1)
|
||||
|
||||
# Count only active vehicles (draft vehicles don't count toward the limit)
|
||||
from sqlalchemy import func
|
||||
# Count only active vehicles in the resolved organization
|
||||
if org_id is not None:
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == org_id,
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
else:
|
||||
# Fallback: count by owner_person_id if no org could be resolved
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id.is_(None),
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
@@ -158,29 +251,35 @@ async def get_user_vehicles(
|
||||
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
|
||||
|
||||
if current_user.scope_id is None:
|
||||
# Personal mode: only show vehicles owned/operated personally (no organization)
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
or_(
|
||||
Asset.owner_org_id.is_(None),
|
||||
Asset.operator_org_id.is_(None)
|
||||
),
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.operator_person_id == current_user.person_id
|
||||
# Personal mode: show vehicles in organizations where user is a member
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.current_organization_id.in_(user_org_ids),
|
||||
Asset.status == "active"
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.options(
|
||||
selectinload(Asset.catalog),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# No organizations — return empty list
|
||||
return []
|
||||
else:
|
||||
# Corporate mode: only show vehicles belonging to the active organization's garages
|
||||
# Corporate mode: show vehicles belonging to the active organization
|
||||
# Uses current_organization_id (same logic as quota-status endpoint)
|
||||
# for consistency — vehicles belong to organizations, not branches.
|
||||
try:
|
||||
scope_org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
@@ -191,25 +290,11 @@ async def get_user_vehicles(
|
||||
# Fallback: no valid organization, return empty list
|
||||
return []
|
||||
|
||||
# First, get all branch IDs (garages) for this organization
|
||||
from app.models.marketplace.organization import Branch
|
||||
branch_stmt = select(Branch.id).where(
|
||||
Branch.organization_id == scope_org_id,
|
||||
Branch.is_deleted == False,
|
||||
Branch.status == "active"
|
||||
)
|
||||
branch_result = await db.execute(branch_stmt)
|
||||
branch_ids = [row[0] for row in branch_result.all()]
|
||||
|
||||
if not branch_ids:
|
||||
# Organization has no active garages, return empty list
|
||||
return []
|
||||
|
||||
# Query assets that are in any of the organization's garages
|
||||
# Query assets that belong to the active organization
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.branch_id.in_(branch_ids),
|
||||
Asset.current_organization_id == scope_org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
.order_by(order_expr, Asset.created_at.desc())
|
||||
@@ -271,7 +356,9 @@ async def list_asset_costs(
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"organization_id": cost.organization_id,
|
||||
"amount_gross": cost.amount_gross,
|
||||
"amount_net": cost.amount_net,
|
||||
"vat_rate": cost.vat_rate,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date,
|
||||
"invoice_number": cost.invoice_number,
|
||||
@@ -281,6 +368,8 @@ async def list_asset_costs(
|
||||
"category_code": cost.category.code if cost.category else None,
|
||||
"description": (cost.data or {}).get("description"),
|
||||
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
|
||||
"status": cost.status,
|
||||
"linked_asset_event_id": cost.linked_asset_event_id,
|
||||
}
|
||||
result.append(AssetCostResponse(**cost_dict))
|
||||
|
||||
@@ -308,22 +397,32 @@ async def get_asset(
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Query asset with catalog and master definition
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||
# where the current user is an active member
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
@@ -337,6 +436,69 @@ async def get_asset(
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
|
||||
async def get_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed information about a specific vehicle/asset by vehicle_id.
|
||||
|
||||
GET /api/v1/assets/vehicles/{vehicle_id}
|
||||
|
||||
This endpoint is the primary single-vehicle lookup used by the frontend.
|
||||
Uses Unified Workspace (OrganizationMember-based) permission check.
|
||||
|
||||
Returns the asset's full technical profile including catalog data
|
||||
and vehicle model definition specifications.
|
||||
"""
|
||||
# Get user's organization memberships
|
||||
org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||
# where the current user is an active member
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == vehicle_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == vehicle_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(
|
||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||
selectinload(Asset.odometer_readings)
|
||||
)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found or you don't have permission to access it"
|
||||
)
|
||||
|
||||
return asset
|
||||
|
||||
|
||||
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_or_claim_vehicle(
|
||||
payload: AssetCreate,
|
||||
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
|
||||
existing_asset = existing_result.scalar_one_or_none()
|
||||
|
||||
if existing_asset:
|
||||
# Szabály A: Saját jármű duplikációja
|
||||
if existing_asset.owner_person_id == current_user.person_id:
|
||||
# Szabály A: Saját jármű duplikációja — check via organization membership
|
||||
# Get user's organization IDs
|
||||
member_org_stmt = select(OrganizationMember.organization_id).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
)
|
||||
member_org_result = await db.execute(member_org_stmt)
|
||||
user_org_ids = [row[0] for row in member_org_result.all()]
|
||||
|
||||
if existing_asset.current_organization_id in user_org_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ez a jármű már szerepel a garázsodban!"
|
||||
)
|
||||
|
||||
# Szabály B: Tulajdonosváltás / Átmeneti állapot
|
||||
# A rendszám létezik, de más a tulajdonosa
|
||||
# A rendszám létezik, de más szervezetben van
|
||||
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
|
||||
payload.data_status = "draft"
|
||||
logger.info(
|
||||
f"License plate {license_plate_clean} exists with different owner "
|
||||
f"(owner_person_id={existing_asset.owner_person_id} != "
|
||||
f"current_user.person_id={current_user.person_id}). "
|
||||
f"License plate {license_plate_clean} exists in different organization "
|
||||
f"(current_organization_id={existing_asset.current_organization_id}). "
|
||||
f"Setting data_status='draft' for transfer scenario."
|
||||
)
|
||||
|
||||
@@ -458,20 +626,25 @@ async def update_vehicle(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Find the asset
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.person_id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Find the asset — Unified Workspace: access via organization membership only
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
.options(selectinload(Asset.catalog))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
@@ -540,16 +713,17 @@ async def list_maintenance_records(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Check asset access
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Check asset access — Unified Workspace: via organization membership only
|
||||
if user_org_ids:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
else:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
asset_result = await db.execute(asset_stmt)
|
||||
asset = asset_result.scalar_one_or_none()
|
||||
|
||||
@@ -599,16 +773,17 @@ async def create_maintenance_record(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
# Check asset access and get asset
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Check asset access and get asset — Unified Workspace: via organization membership only
|
||||
if user_org_ids:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
else:
|
||||
asset_stmt = select(Asset).where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
asset_result = await db.execute(asset_stmt)
|
||||
asset = asset_result.scalar_one_or_none()
|
||||
|
||||
@@ -663,11 +838,14 @@ async def create_maintenance_record(
|
||||
)
|
||||
|
||||
# Create AssetCost record with MAINTENANCE category (id=2)
|
||||
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
|
||||
cost_gross = float(payload["cost"])
|
||||
maintenance_cost = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=2,
|
||||
amount_net=float(payload["cost"]),
|
||||
amount_gross=cost_gross,
|
||||
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
|
||||
currency=payload.get("currency", "EUR"),
|
||||
date=date,
|
||||
invoice_number=payload.get("invoice_number"),
|
||||
@@ -736,18 +914,23 @@ async def _check_asset_access(
|
||||
org_result = await db.execute(org_stmt)
|
||||
user_org_ids = [row[0] for row in org_result.all()]
|
||||
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
or_(
|
||||
Asset.owner_person_id == current_user.id,
|
||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
||||
Asset.operator_person_id == current_user.id,
|
||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
||||
# Unified Workspace: access via organization membership only
|
||||
if user_org_ids:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.in_(user_org_ids)
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(Asset)
|
||||
.where(
|
||||
Asset.id == asset_id,
|
||||
Asset.current_organization_id.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
@@ -863,6 +1046,14 @@ async def create_asset_event(
|
||||
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
||||
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
||||
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
||||
|
||||
**Role-Based Auto-Approval (PHASE 2):**
|
||||
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
|
||||
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
|
||||
|
||||
**Smart Linking (Double-Entry Avoidance):**
|
||||
- When cost_amount > 0, the auto-created AssetCost is linked back
|
||||
to the event via linked_expense_id / linked_asset_event_id.
|
||||
"""
|
||||
# Jogosultság ellenőrzés
|
||||
asset = await _check_asset_access(db, asset_id, current_user)
|
||||
@@ -873,9 +1064,30 @@ async def create_asset_event(
|
||||
# Szervezet meghatározása
|
||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||
|
||||
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
|
||||
|
||||
# Check if user has can_approve_expense capability
|
||||
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||
|
||||
# Determine event and cost status based on capability
|
||||
if can_approve:
|
||||
event_status = "COMPLETED"
|
||||
cost_status = "APPROVED"
|
||||
else:
|
||||
event_status = "MISSING_TECH_DATA"
|
||||
cost_status = "PENDING_APPROVAL"
|
||||
|
||||
logger.info(
|
||||
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
|
||||
f"in org {organization_id}: can_approve_expense={can_approve}, "
|
||||
f"event_status={event_status}, cost_status={cost_status}"
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
||||
cost_id = payload.cost_id
|
||||
auto_created_cost_id = None
|
||||
if payload.cost_amount is not None and payload.cost_amount > 0:
|
||||
# Költségkategória feloldása az esemény típusa alapján
|
||||
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
||||
@@ -883,13 +1095,17 @@ async def create_asset_event(
|
||||
|
||||
currency = payload.currency or "HUF"
|
||||
|
||||
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
|
||||
cost_gross = payload.cost_amount
|
||||
cost_record = AssetCost(
|
||||
asset_id=asset_id,
|
||||
organization_id=organization_id,
|
||||
category_id=cost_category_id,
|
||||
amount_net=payload.cost_amount,
|
||||
amount_gross=cost_gross,
|
||||
amount_net=cost_gross, # Default: net = gross (0% VAT)
|
||||
currency=currency,
|
||||
date=event_date,
|
||||
status=cost_status,
|
||||
data={
|
||||
"description": payload.description or f"Service event: {payload.event_type}",
|
||||
"mileage_at_cost": payload.odometer_reading,
|
||||
@@ -900,13 +1116,15 @@ async def create_asset_event(
|
||||
db.add(cost_record)
|
||||
await db.flush() # Flush to get the cost_record.id
|
||||
cost_id = cost_record.id
|
||||
auto_created_cost_id = cost_record.id
|
||||
|
||||
logger.info(
|
||||
f"AssetCost auto-created for asset {asset_id}: "
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
|
||||
f"cost_id={cost_id}, status={cost_status})"
|
||||
)
|
||||
|
||||
# Új esemény létrehozása
|
||||
# Új esemény létrehozása (with status and bidirectional link)
|
||||
event = AssetEvent(
|
||||
asset_id=asset_id,
|
||||
user_id=current_user.id,
|
||||
@@ -915,9 +1133,28 @@ async def create_asset_event(
|
||||
odometer_reading=payload.odometer_reading,
|
||||
description=payload.description,
|
||||
cost_id=cost_id,
|
||||
linked_expense_id=auto_created_cost_id, # Bidirectional link
|
||||
status=event_status,
|
||||
event_date=event_date,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush() # Flush to get event.id
|
||||
|
||||
# If we auto-created a cost, link it back to the event
|
||||
if auto_created_cost_id:
|
||||
# Update the cost record with the link back to the event
|
||||
stmt_update = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.id == auto_created_cost_id)
|
||||
)
|
||||
result = await db.execute(stmt_update)
|
||||
cost_record = result.scalar_one_or_none()
|
||||
if cost_record:
|
||||
cost_record.linked_asset_event_id = event.id
|
||||
logger.info(
|
||||
f"Smart Sync: Bidirectional link established between "
|
||||
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
|
||||
)
|
||||
|
||||
# ── Üzleti logika: Km óra állás frissítése ──
|
||||
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
||||
|
||||
Reference in New Issue
Block a user