RABC fejlesztése és beélesítése hibák kijavításával

This commit is contained in:
Roo
2026-06-18 18:09:51 +00:00
parent 611307a24b
commit fe3c32597d
57 changed files with 4689 additions and 655 deletions

View File

@@ -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: