""" 🏢 Admin Garages (Organizations) CRM API Végpontok: GET /admin/organizations — Garázsok listázása előfizetési adatokkal GET /admin/organizations/{org_id}/details — Garázs részletes adatai (General Tab) PATCH /admin/organizations/{org_id}/subscription — Előfizetés módosítása POST /admin/organizations/{org_id}/members — Új tag hozzáadása a garázshoz PUT /admin/organizations/{org_id}/members/{member_id} — Tag adatainak módosítása DELETE /admin/organizations/{org_id}/members/{member_id} — Tag eltávolítása """ from __future__ import annotations import logging from datetime import datetime from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, func, or_, update as sa_update, delete as sa_delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload from pydantic import BaseModel, Field from app.api import deps from app.db.session import get_db from app.models.core_logic import SubscriptionTier, OrganizationSubscription from app.models.marketplace.organization import Organization, OrganizationMember from app.models.fleet.organization import ContactPerson from app.models.identity import User, Person from app.schemas.organization import ( OrganizationMemberCreate, OrganizationMemberUpdate, OrganizationMemberResponse, PersonBrief, ) logger = logging.getLogger("admin-organizations") router = APIRouter() # ============================================================================= # Pydantic Schemas # ============================================================================= class OrgSubscriptionUpdate(BaseModel): """Előfizetés módosításának kérése.""" tier_id: int = Field(..., description="Az új SubscriptionTier ID-ja") expires_at: Optional[datetime] = Field( default=None, description="Egyedi lejárati dátum (pl. 2-5 éves B2B deal esetén). " "Ha None, a csomag duration.days alapján számolódik." ) # ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ── extra_vehicles: int = Field( default=0, ge=0, description="Extra jármű kvóta a csomag keretein felül (add-on)." ) extra_branches: int = Field( default=0, ge=0, description="Extra telephely kvóta a csomag keretein felül (add-on)." ) extra_users: int = Field( default=0, ge=0, description="Extra dolgozó kvóta a csomag keretein felül (add-on)." ) class OrgSubscriptionResponse(BaseModel): """Előfizetés adatainak válasza.""" id: int org_id: int tier_id: int tier_name: str = "" valid_from: Optional[str] = None valid_until: Optional[str] = None is_active: bool = True extra_allowances: dict = {} class GarageListItem(BaseModel): """Egy garázs adatai a listában.""" id: int name: str full_name: str display_name: Optional[str] = None email: Optional[str] = None status: str is_active: bool is_verified: bool is_deleted: bool org_type: str city: Optional[str] = None region: Optional[str] = None segment: Optional[str] = None member_count: int = 0 created_at: Optional[str] = None # Előfizetési adatok subscription: Optional[OrgSubscriptionResponse] = None subscription_tier_name: str = "Free/Fallback" subscription_expires_at: Optional[str] = None class GarageListResponse(BaseModel): """Garázsok listájának válasza.""" total: int skip: int limit: int garages: List[GarageListItem] # ============================================================================= # Pydantic Schemas — Garage Details # ============================================================================= class ContactPersonInfo(BaseModel): """Kapcsolattartó személy adatai.""" id: int full_name: str = "" role: str = "" department: Optional[str] = None phone: Optional[str] = None email: Optional[str] = None is_primary: bool = False class SubscriptionSummary(BaseModel): """Előfizetés összefoglaló.""" tier_name: str = "Free/Fallback" tier_level: int = 0 valid_from: Optional[str] = None expires_at: Optional[str] = None is_active: bool = True asset_count: int = 0 asset_limit: int = 1 # ── P0 ADD-ON UPGRADE: Branch & User limits ── branch_limit: int = 0 user_limit: int = 0 # ── P0 ADD-ON UPGRADE: Extra allowances (add-ons) ── extra_allowances: dict = {} class BranchBrief(BaseModel): """Rövid telephely adatok a frontend számára.""" id: int name: str city: Optional[str] = None is_active: bool = True class GarageDetailsResponse(BaseModel): """Garázs részletes adatai (General Tab) — kibővített CRM profil.""" # Szervezet adatok id: int name: str full_name: str display_name: Optional[str] = None status: str is_active: bool is_verified: bool org_type: str # Cím adatok (elsődleges) address_city: Optional[str] = None address_zip: Optional[str] = None address_street_name: Optional[str] = None address_street_type: Optional[str] = None address_house_number: Optional[str] = None tax_number: Optional[str] = None reg_number: Optional[str] = None # ── Kapcsolattartó (Contact Person) ── contact_person_name: Optional[str] = None contact_email: Optional[str] = None contact_phone: Optional[str] = None # ── Számlázási cím (Billing Address) ── billing_zip: Optional[str] = None billing_city: Optional[str] = None billing_street_name: Optional[str] = None billing_street_type: Optional[str] = None billing_house_number: Optional[str] = None # ── Értesítési cím (Notification Address) ── notification_zip: Optional[str] = None notification_city: Optional[str] = None notification_street_name: Optional[str] = None notification_street_type: Optional[str] = None notification_house_number: Optional[str] = None # Előfizetés subscription: Optional[SubscriptionSummary] = None # Kapcsolattartó (ContactPerson modellből) primary_contact: Optional[ContactPersonInfo] = None # Meta created_at: Optional[str] = None member_count: int = 0 # P0: Owner user ID for frontend display owner_user_id: Optional[int] = None # P0: Owner person ID for frontend display owner_person_id: Optional[int] = None # P0: Full member list with nested person data members: List[OrganizationMemberResponse] = [] # ── P0 ADD-ON UPGRADE: Branches list for utilization display ── branches: List[BranchBrief] = [] # ============================================================================= # GET / — Garázsok listázása # ============================================================================= @router.get( "", response_model=GarageListResponse, summary="Garázsok listázása", description="Visszaadja az összes szervezetet (garázst) előfizetési adatokkal és taglétszámmal.", ) async def list_organizations( skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"), limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"), search_term: Optional[str] = Query(None, description="Keresés név vagy e-mail alapján"), status_filter: Optional[str] = Query(None, description="Szűrés státusz szerint (active/inactive/suspended/pending_verification)"), org_type_filter: Optional[str] = Query(None, description="Szűrés szervezet típus szerint"), tier_name_filter: Optional[str] = Query(None, description="Szűrés előfizetési csomag neve alapján (pl. 'Premium', 'Free/Fallback')"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:view")), ) -> GarageListResponse: """ Garázsok / Szervezetek listázása lapozással és kereséssel. Minden garázshoz visszaadja: - Alapadatokat (név, státusz, típus, város) - Taglétszámot (OrganizationMember-ek száma) - Aktív előfizetés adatait (tier név, lejárati dátum) """ # Alap lekérdezés — outerjoin owner (User) hogy a tulajdonos emailjét is kereshessük base_query = select(Organization.id).outerjoin(Organization.owner) # Szűrés if search_term: like_pattern = f"%{search_term}%" base_query = base_query.where( Organization.full_name.ilike(like_pattern) | Organization.name.ilike(like_pattern) | Organization.display_name.ilike(like_pattern) | Organization.contact_email.ilike(like_pattern) | Organization.contact_person_name.ilike(like_pattern) | User.email.ilike(like_pattern) ) if status_filter: base_query = base_query.where(Organization.status == status_filter) if org_type_filter: if org_type_filter == "corporate": # P0 HOTFIX: "corporate" is a UI category, not a DB enum value. # The DB enum (fleet.orgtype) has: individual, business, fleet_owner, # service, service_provider, club. "corporate" means all non-individual types. from app.models.marketplace.organization import OrgType corporate_types = [ t.value for t in OrgType if t.value != OrgType.individual.value ] base_query = base_query.where(Organization.org_type.in_(corporate_types)) else: base_query = base_query.where(Organization.org_type == org_type_filter) if tier_name_filter: from app.models.core_logic import SubscriptionTier base_query = base_query.join(SubscriptionTier, Organization.subscription_tier_id == SubscriptionTier.id) base_query = base_query.where(SubscriptionTier.name.ilike(f"%{tier_name_filter}%")) # Teljes számolás count_query = select(func.count()).select_from(base_query.subquery()) total_result = await db.execute(count_query) total = total_result.scalar() or 0 # Lapozás — get IDs first, then fetch full objects id_query = base_query.order_by(Organization.id.desc()).offset(skip).limit(limit) id_result = await db.execute(id_query) org_ids_subset = [row[0] for row in id_result.all()] # Fetch full Organization objects by IDs with joinedload to avoid MissingGreenlet if org_ids_subset: from sqlalchemy.orm import joinedload query = select(Organization).options( joinedload(Organization.subscription_tier), joinedload(Organization.owner), ).where(Organization.id.in_(org_ids_subset)) result = await db.execute(query) # Use unique() because joinedload may produce duplicate rows organizations = result.unique().scalars().all() # Preserve the ordering from the id_query org_map = {org.id: org for org in organizations} organizations = [org_map[oid] for oid in org_ids_subset if oid in org_map] else: organizations = [] # Összes org_id begyűjtése a tömeges subscription lekérdezéshez org_ids = [org.id for org in organizations] # Tömeges subscription lekérdezés subscriptions_map: Dict[int, OrganizationSubscription] = {} if org_ids: sub_stmt = ( select(OrganizationSubscription) .options(selectinload(OrganizationSubscription.tier)) .where( OrganizationSubscription.org_id.in_(org_ids), OrganizationSubscription.is_active == True, ) ) sub_result = await db.execute(sub_stmt) for sub in sub_result.scalars().all(): # Ha több subscription is van, a legutolsót vegyük if sub.org_id not in subscriptions_map: subscriptions_map[sub.org_id] = sub # Tömeges taglétszám lekérdezés member_counts: Dict[int, int] = {} if org_ids: from sqlalchemy import func as sa_func member_stmt = ( select( OrganizationMember.organization_id, sa_func.count(OrganizationMember.id) ) .where( OrganizationMember.organization_id.in_(org_ids), OrganizationMember.status == "active", ) .group_by(OrganizationMember.organization_id) ) member_result = await db.execute(member_stmt) for row in member_result.all(): member_counts[row[0]] = row[1] # Válasz összeállítása garage_list = [] for org in organizations: sub = subscriptions_map.get(org.id) sub_response = None tier_name = "Free/Fallback" expires_at = None if sub and sub.tier: tier_name = sub.tier.name expires_at = sub.valid_until.isoformat() if sub.valid_until else None sub_response = OrgSubscriptionResponse( id=sub.id, org_id=sub.org_id, tier_id=sub.tier_id, tier_name=sub.tier.name, valid_from=sub.valid_from.isoformat() if sub.valid_from else None, valid_until=expires_at, is_active=sub.is_active, extra_allowances=sub.extra_allowances or {}, ) elif org.subscription_tier: # Fallback: Organization.subscription_tier_id kapcsolat tier_name = org.subscription_tier.name expires_at = org.subscription_expires_at.isoformat() if org.subscription_expires_at else None # Resolve email: prefer contact_email, fallback to owner's email resolved_email = org.contact_email or (org.owner.email if org.owner else None) garage_list.append(GarageListItem( id=org.id, name=org.name, full_name=org.full_name, display_name=org.display_name, email=resolved_email, status=org.status, is_active=org.is_active, is_verified=org.is_verified, is_deleted=org.is_deleted, org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type), city=org.address_city, region=org.region, segment=org.segment, member_count=member_counts.get(org.id, 0), created_at=org.created_at.isoformat() if org.created_at else None, subscription=sub_response, subscription_tier_name=tier_name, subscription_expires_at=expires_at, )) return GarageListResponse( total=total, skip=skip, limit=limit, garages=garage_list, ) # ============================================================================= # P0 ARCHITECTURE UNIFICATION: _extract_limit() helper # ============================================================================= def _extract_limit( rules: dict, keys: list[str], default: int = 0, ) -> int: """ P0 ARCHITECTURE UNIFICATION: Egyetlen, robusztus függvény a limit értékek kinyerésére a SubscriptionTier.rules JSONB objektumból. Két rétegben keres: 1. Közvetlenül a rules objektumban: rules["max_vehicles"] 2. A rules["allowances"] nested objektumban: rules["allowances"]["max_vehicles"] Ez biztosítja, hogy a JSONB struktúra változásai (pl. 'allowances' nested object) ne törjék meg a limit számításokat. Args: rules: A SubscriptionTier.rules JSONB dict-je. keys: A keresendő kulcsok listája (pl. ["max_vehicles", "max_assets"]). Az első találat értéke kerül visszaadásra. default: Alapértelmezett érték, ha egyik kulcs sem található. Returns: int: A talált limit érték, vagy a default. """ if not rules or not isinstance(rules, dict): return default # 1. szint: Közvetlen keresés a rules objektumban for key in keys: value = rules.get(key) if value is not None and isinstance(value, (int, float)): return int(value) # 2. szint: Keresés a rules["allowances"] nested objektumban allowances = rules.get("allowances") if allowances and isinstance(allowances, dict): for key in keys: value = allowances.get(key) if value is not None and isinstance(value, (int, float)): return int(value) # 3. szint: Keresés a rules["limits"] nested objektumban (alternatív név) limits = rules.get("limits") if limits and isinstance(limits, dict): for key in keys: value = limits.get(key) if value is not None and isinstance(value, (int, float)): return int(value) return default # ============================================================================= # GET /{org_id}/details — Garázs részletes adatai (General Tab) # ============================================================================= @router.get( "/{org_id}/details", response_model=GarageDetailsResponse, summary="Garázs részletes adatai", description="Visszaadja egy garázs teljes adatait, előfizetési összefoglalóját, " "elsődleges kapcsolattartóját és taglistáját a General Tab számára.", ) async def get_organization_details( org_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:view")), ) -> GarageDetailsResponse: """ Garázs részletes adatainak lekérése. Visszaadja: - Teljes szervezet adatokat (név, cím, adószám, státusz) - Előfizetés összefoglalót (tier név, lejárat, járműszám/korlát) - Elsődleges kapcsolattartó adatait (fleet.contact_persons) - Tagok teljes listáját (OrganizationMember) nested Person adatokkal P0 Zero-Duplication: - Ha contact_email/phone/person_name NULL, az owner adatai kerülnek bele. - Az owner mindig szerepel a members listában (OWNER role). """ # 1. Szervezet lekérése subscription_tier kapcsolattal + owner org_stmt = ( select(Organization) .options( selectinload(Organization.subscription_tier), selectinload(Organization.owner).selectinload(User.person), ) .where(Organization.id == org_id) ) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Aktív előfizetések lekérése (P0 ARCHITECTURE UNIFICATION: .all() instead of .limit(1)) # Több subscription is lehet (1 Base + N Add-on). Minden aktív sort lekérünk, # majd aggregáljuk a limit értékeket. now = datetime.utcnow() sub_stmt = ( select(OrganizationSubscription) .options(selectinload(OrganizationSubscription.tier)) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, OrganizationSubscription.valid_until >= now, ) .order_by(OrganizationSubscription.id.desc()) ) sub_result = await db.execute(sub_stmt) active_subs = sub_result.scalars().all() # 2b. P0 ADD-ON: Valós járműszám lekérése a fleet táblából from app.models.fleet.vehicle import Vehicle # noqa: E402 vehicle_count_stmt = select(func.count(Vehicle.id)).where( Vehicle.organization_id == org_id, Vehicle.is_deleted == False, ) vehicle_count_result = await db.execute(vehicle_count_stmt) asset_count = vehicle_count_result.scalar() or 0 # 2c. P0 ADD-ON: Telephelyek (branches) lekérése from app.models.fleet.organization import Branch # noqa: E402 branches_stmt = select(Branch).where( Branch.organization_id == org_id, Branch.is_deleted == False, ).order_by(Branch.name) branches_result = await db.execute(branches_stmt) branch_rows = branches_result.scalars().all() branches_list = [ BranchBrief( id=b.id, name=b.name, city=b.city, is_active=b.is_active, ) for b in branch_rows ] # 3. Tagok lekérése eager loading-gal (User + Person) members_stmt = ( select(OrganizationMember) .options( selectinload(OrganizationMember.user).selectinload(User.person), selectinload(OrganizationMember.person), ) .where( OrganizationMember.organization_id == org_id, ) .order_by(OrganizationMember.id) ) members_result = await db.execute(members_stmt) member_rows = members_result.scalars().all() # Taglétszám (aktív tagok száma) member_count = sum(1 for m in member_rows if m.status == "active") # 4. Elsődleges kapcsolattartó lekérése contact_stmt = ( select(ContactPerson) .options(selectinload(ContactPerson.person)) .where( ContactPerson.organization_id == org_id, ContactPerson.is_primary == True, ) .limit(1) ) contact_result = await db.execute(contact_stmt) contact = contact_result.scalar_one_or_none() # 5. Subscription összefoglaló — P0 ARCHITECTURE UNIFICATION # Unified limit extraction via _extract_limit() helper. # Multi-subscription aggregation: Base + Add-on tiers. # Path A and Path B are now ONE unified code path. subscription_summary = None if active_subs: # ── P0 ARCHITECTURE UNIFICATION: Aggregate across ALL active subscriptions ── # Separate Base subscriptions from Add-on subscriptions base_subs = [s for s in active_subs if s.tier and s.tier.type in (None, '', 'base')] addon_subs = [s for s in active_subs if s.tier and s.tier.type == 'addon'] # Use the highest-tier Base subscription for tier metadata # (sort by tier_level descending, pick the first) base_subs_sorted = sorted( base_subs, key=lambda s: s.tier.tier_level if s.tier else 0, reverse=True, ) primary_sub = base_subs_sorted[0] if base_subs_sorted else (active_subs[0] if active_subs else None) if primary_sub and primary_sub.tier: # ── Base limits from primary subscription (using _extract_limit) ── primary_rules = primary_sub.tier.rules or {} primary_fc = primary_sub.tier.feature_capabilities or {} # vehicle_limit_keys: search in rules, then allowances, then feature_capabilities base_vehicles = _extract_limit( primary_rules, ["max_vehicles", "max_assets"], _extract_limit(primary_fc, ["max_vehicles", "max_assets"], 1), ) base_branches = _extract_limit( primary_rules, ["max_branches", "max_garages"], _extract_limit(primary_fc, ["max_branches", "max_garages"], 0), ) base_users = _extract_limit( primary_rules, ["max_users", "max_members"], _extract_limit(primary_fc, ["max_users", "max_members"], 0), ) # ── Aggregate extra_allowances from ALL active subscriptions ── total_extra_vehicles = 0 total_extra_branches = 0 total_extra_users = 0 aggregated_extra = {} for sub in active_subs: extra = sub.extra_allowances or {} total_extra_vehicles += extra.get("extra_vehicles", 0) or 0 total_extra_branches += extra.get("extra_branches", 0) or 0 total_extra_users += extra.get("extra_users", 0) or 0 # Merge extra_allowances dicts (later subs overwrite earlier ones for same keys) aggregated_extra.update(extra) # ── Also aggregate add-on tier base limits ── for addon_sub in addon_subs: if addon_sub.tier: addon_rules = addon_sub.tier.rules or {} addon_fc = addon_sub.tier.feature_capabilities or {} base_vehicles += _extract_limit( addon_rules, ["max_vehicles", "max_assets"], _extract_limit(addon_fc, ["max_vehicles", "max_assets"], 0), ) base_branches += _extract_limit( addon_rules, ["max_branches", "max_garages"], _extract_limit(addon_fc, ["max_branches", "max_garages"], 0), ) base_users += _extract_limit( addon_rules, ["max_users", "max_members"], _extract_limit(addon_fc, ["max_users", "max_members"], 0), ) # Total = Base (primary + addon) + Extra (from all subscriptions) asset_limit = base_vehicles + total_extra_vehicles branch_limit = base_branches + total_extra_branches user_limit = base_users + total_extra_users # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even if all sums are 0 asset_limit = max(asset_limit, 1) subscription_summary = SubscriptionSummary( tier_name=primary_sub.tier.name, tier_level=primary_sub.tier.tier_level, valid_from=primary_sub.valid_from.isoformat() if primary_sub.valid_from else None, expires_at=primary_sub.valid_until.isoformat() if primary_sub.valid_until else None, is_active=primary_sub.is_active, asset_count=asset_count, asset_limit=asset_limit, branch_limit=branch_limit, user_limit=user_limit, extra_allowances=aggregated_extra, ) elif org.subscription_tier: # Fallback: Organization.subscription_tier_id kapcsolat (nincs OrganizationSubscription) rules = org.subscription_tier.rules or {} fc = org.subscription_tier.feature_capabilities or {} base_vehicles = _extract_limit( rules, ["max_vehicles", "max_assets"], _extract_limit(fc, ["max_vehicles", "max_assets"], 1), ) base_branches = _extract_limit( rules, ["max_branches", "max_garages"], _extract_limit(fc, ["max_branches", "max_garages"], 0), ) base_users = _extract_limit( rules, ["max_users", "max_members"], _extract_limit(fc, ["max_users", "max_members"], 0), ) # P0 HOTFIX: Clamping — ensure asset_limit >= 1 even for fallback path base_vehicles = max(base_vehicles, 1) subscription_summary = SubscriptionSummary( tier_name=org.subscription_tier.name, tier_level=org.subscription_tier.tier_level, expires_at=org.subscription_expires_at.isoformat() if org.subscription_expires_at else None, asset_count=asset_count, asset_limit=base_vehicles, branch_limit=base_branches, user_limit=base_users, ) # 6. Kapcsolattartó adatok primary_contact = None if contact and contact.person: primary_contact = ContactPersonInfo( id=contact.id, full_name=f"{contact.person.last_name} {contact.person.first_name}", role=contact.role, department=contact.department, phone=contact.person.phone, is_primary=contact.is_primary, ) # ───────────────────────────────────────────────────────────────────── # P0: Owner adatok betöltése a Zero-Duplication Contact Logic-hoz # ───────────────────────────────────────────────────────────────────── owner: Optional[User] = org.owner owner_person: Optional[Person] = None owner_full_name: Optional[str] = None owner_email: Optional[str] = None owner_phone: Optional[str] = None if owner: owner_email = owner.email owner_person = owner.person if owner_person: owner_full_name = f"{owner_person.last_name} {owner_person.first_name}".strip() owner_phone = owner_person.phone # ───────────────────────────────────────────────────────────────────── # P0: Zero-Duplication Override Pattern # Ha a contact mező NULL, az owner adatait injectáljuk # ───────────────────────────────────────────────────────────────────── resolved_contact_person_name = org.contact_person_name or owner_full_name resolved_contact_email = org.contact_email or owner_email resolved_contact_phone = org.contact_phone or owner_phone # 7. Tagok összeállítása nested Person adatokkal members_list: List[OrganizationMemberResponse] = [] owner_found_in_members = False for m in member_rows: person_data = None # Try to get Person from the member's direct person relationship first person_obj = m.person # Fallback: get Person through the User relationship if not person_obj and m.user and m.user.person: person_obj = m.user.person if person_obj: # Get email from the user record email = m.user.email if m.user else None person_data = PersonBrief( id=person_obj.id, first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=email, phone=person_obj.phone, ) # Check if this member is the owner if owner and m.user_id == owner.id and m.role == "OWNER": owner_found_in_members = True members_list.append(OrganizationMemberResponse( id=m.id, user_id=m.user_id, organization_id=m.organization_id, role=m.role, status=m.status or "active", is_verified=m.is_verified, person=person_data, created_at=m.created_at.isoformat() if m.created_at else None, updated_at=m.updated_at.isoformat() if m.updated_at else None, )) # ───────────────────────────────────────────────────────────────────── # P0: Ha az owner NEM szerepel a members listában, dinamikusan hozzáfűzzük # ───────────────────────────────────────────────────────────────────── if owner and not owner_found_in_members: # Build a fully-formed PersonBrief — even if owner_person is None, # we still provide an empty PersonBrief so the frontend never crashes # on null person access. owner_person_data = PersonBrief( id=owner_person.id if owner_person else None, first_name=owner_person.first_name or "" if owner_person else "", last_name=owner_person.last_name or "" if owner_person else "", email=owner_email, phone=owner_phone, ) # Create a synthetic member entry for the owner. # P0 HOTFIX: The synthetic member MUST include display_name/name/email # at the top level so the frontend memberFullName() helper can render # a human-readable name instead of falling back to user_id. synthetic_owner = OrganizationMemberResponse( id=0, # synthetic marker user_id=owner.id, organization_id=org_id, role="OWNER", status="active", is_verified=True, person=owner_person_data, created_at=org.created_at.isoformat() if org.created_at else None, updated_at=None, ) # Inject top-level display fields for frontend memberFullName() # (the Pydantic model doesn't have these fields, so we use object.__setattr__) object.__setattr__(synthetic_owner, 'display_name', owner_full_name or owner_email or f"Owner #{owner.id}") object.__setattr__(synthetic_owner, 'name', owner_full_name or owner_email or f"Owner #{owner.id}") object.__setattr__(synthetic_owner, 'email', owner_email) # Prepend the owner so they appear first in the list members_list.insert(0, synthetic_owner) # Also bump the member_count member_count += 1 logger.info( f"P0: Dynamically prepended owner user_id={owner.id} to members list " f"for org {org_id} ({org.full_name})" ) logger.info( f"Admin {current_user.id} ({current_user.email}) fetched details for org {org_id} " f"({org.full_name}) — {len(members_list)} members" ) return GarageDetailsResponse( id=org.id, name=org.name, full_name=org.full_name, display_name=org.display_name, status=org.status, is_active=org.is_active, is_verified=org.is_verified, org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type), address_city=org.address_city, address_zip=org.address_zip, address_street_name=org.address_street_name, address_street_type=org.address_street_type, address_house_number=org.address_house_number, tax_number=org.tax_number, reg_number=org.reg_number, # ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ── contact_person_name=resolved_contact_person_name, contact_email=resolved_contact_email, contact_phone=resolved_contact_phone, # ── Számlázási cím ── billing_zip=org.billing_zip, billing_city=org.billing_city, billing_street_name=org.billing_street_name, billing_street_type=org.billing_street_type, billing_house_number=org.billing_house_number, # ── Értesítési cím ── notification_zip=org.notification_zip, notification_city=org.notification_city, notification_street_name=org.notification_street_name, notification_street_type=org.notification_street_type, notification_house_number=org.notification_house_number, subscription=subscription_summary, primary_contact=primary_contact, created_at=org.created_at.isoformat() if org.created_at else None, member_count=member_count, # P0: Expose owner user ID and person ID for frontend display owner_user_id=owner.id if owner else None, owner_person_id=owner_person.id if owner_person else None, members=members_list, # ── P0 ADD-ON UPGRADE: Branches list ── branches=branches_list, ) # ============================================================================= # PUT /{org_id}/status — Garázs státuszának módosítása # ============================================================================= class OrgStatusUpdate(BaseModel): """Státusz módosításának kérése.""" status: str = Field(..., description="Új státusz: active, inactive, suspended, pending_verification") @router.put( "/{org_id}/status", summary="Garázs státuszának módosítása", description="Frissíti egy garázs/szervezet státuszát. " "Lehetséges értékek: active, inactive, suspended, pending_verification. " "A státuszváltás hatással van az is_active mezőre is.", ) async def update_org_status( org_id: int, payload: OrgStatusUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ Garázs státuszának módosítása. - Ellenőrzi, hogy a szervezet létezik-e. - Frissíti a status mezőt a megadott értékre. - Az is_active mezőt automatikusan szinkronizálja: - 'active' → is_active = True - 'inactive' vagy 'suspended' → is_active = False """ # 1. Ellenőrizzük a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Validáljuk a státusz értéket valid_statuses = {"active", "inactive", "suspended", "pending_verification"} new_status = payload.status.lower() if new_status not in valid_statuses: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Érvénytelen státusz: '{payload.status}'. " f"Lehetséges értékek: {', '.join(sorted(valid_statuses))}", ) # 3. Mentjük a régi státuszt naplózáshoz old_status = org.status # 4. Frissítjük a státuszt és az is_active mezőt org.status = new_status org.is_active = (new_status == "active") # Ha inaktiváljuk, jegyezzük fel az időpontot if new_status in ("inactive", "suspended") and old_status == "active": from datetime import datetime as dt org.last_deactivated_at = dt.utcnow() await db.commit() await db.refresh(org) logger.info( f"Admin {current_user.id} ({current_user.email}) changed status for org {org_id} " f"({org.full_name}): '{old_status}' → '{new_status}'" ) return { "status": "success", "message": f"A(z) '{org.full_name}' garázs státusza frissítve: '{old_status}' → '{new_status}'.", "org_id": org.id, "old_status": old_status, "new_status": new_status, "is_active": org.is_active, } # ============================================================================= # PUT /{org_id} — Garázs adatainak szerkesztése # ============================================================================= class OrganizationUpdate(BaseModel): """Garázs adatainak szerkesztésére szolgáló séma (kibővített CRM).""" name: Optional[str] = Field(None, min_length=1, max_length=255, description="Rövid név (slug-szerű)") full_name: Optional[str] = Field(None, min_length=1, max_length=255, description="Teljes cégnév") display_name: Optional[str] = Field(None, max_length=50, description="Megjelenítési név") tax_number: Optional[str] = Field(None, max_length=20, description="Adószám") reg_number: Optional[str] = Field(None, max_length=50, description="Cégjegyzékszám") # ── Org-level email/phone (used for individual garages to propagate to Person/User) ── email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)") phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)") # ── Elsődleges cím (Primary Address) ── address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám") address_city: Optional[str] = Field(None, max_length=100, description="Város") address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve") address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)") address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám") # ── Kapcsolattartó (Contact Person) ── contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve") contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe") contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma") # ── Számlázási cím (Billing Address) ── billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám") billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város") billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve") billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa") billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám") # ── Értesítési cím (Notification Address) ── notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám") notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város") notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve") notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa") notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám") @router.put( "/{org_id}", summary="Garázs adatainak szerkesztése", description="Frissíti egy garázs alapadatait (név, adószám, cím, stb.). " "Csak a megadott mezők frissülnek; a None értékű mezők változatlanok maradnak.", ) async def update_organization( org_id: int, payload: OrganizationUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ Garázs adatainak szerkesztése. - Ellenőrzi, hogy a szervezet létezik-e. - Csak a megadott (nem None) mezőket frissíti. - Visszaadja a frissített adatokat. """ # 1. Ellenőrizzük a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Összegyűjtjük a változásokat update_fields = {} for field_name in payload.model_dump(exclude_none=True): value = getattr(payload, field_name) if value is not None and hasattr(org, field_name): setattr(org, field_name, value) update_fields[field_name] = value if not update_fields: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Nem adtál meg egyetlen módosítandó mezőt sem.", ) # 3. Individual (private) garages: propagate contact info to Person/User if org.org_type == 'individual' and org.legal_owner_id: person_stmt = select(Person).where(Person.id == org.legal_owner_id) person_result = await db.execute(person_stmt) person = person_result.scalar_one_or_none() if person: person_updated = False # Propagate phone to Person record (from either contact_phone or phone field) phone_value = update_fields.get('contact_phone') or update_fields.get('phone') if phone_value: person.phone = phone_value person_updated = True # Propagate email to the linked User record (from either contact_email or email field) email_value = update_fields.get('contact_email') or update_fields.get('email') if email_value and person.user_id: user_stmt = select(User).where(User.id == person.user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if user: user.email = email_value logger.info( f"Propagated email to user {user.id} for individual org {org_id}" ) if person_updated: logger.info( f"Propagated phone to person {person.id} for individual org {org_id}" ) await db.commit() await db.refresh(org) logger.info( f"Admin {current_user.id} ({current_user.email}) updated org {org_id} " f"({org.full_name}): fields={list(update_fields.keys())}" ) return { "status": "success", "message": f"A(z) '{org.full_name}' garázs adatai frissítve.", "org_id": org.id, "updated_fields": list(update_fields.keys()), "organization": { "id": org.id, "name": org.name, "full_name": org.full_name, "display_name": org.display_name, "tax_number": org.tax_number, "reg_number": org.reg_number, "address_zip": org.address_zip, "address_city": org.address_city, "address_street_name": org.address_street_name, "address_street_type": org.address_street_type, "address_house_number": org.address_house_number, "status": org.status, "is_active": org.is_active, "contact_email": org.contact_email, "contact_phone": org.contact_phone, "contact_person_name": org.contact_person_name, }, } # ============================================================================= # PATCH /{org_id}/subscription — Előfizetés módosítása # ============================================================================= @router.patch( "/{org_id}/subscription", summary="Garázs előfizetésének módosítása", description="Frissíti vagy létrehozza egy garázs OrganizationSubscription rekordját. " "Lehetőség van egyedi lejárati dátum megadására (pl. 2-5 éves B2B deal).", ) async def update_org_subscription( org_id: int, payload: OrgSubscriptionUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:edit")), ) -> Dict[str, Any]: """ Garázs előfizetésének módosítása. - Ellenőrzi, hogy a szervezet létezik-e. - Ellenőrzi, hogy a SubscriptionTier létezik-e. - Ha van aktív OrganizationSubscription, inaktiválja (is_active=False). - Létrehoz egy új OrganizationSubscription rekordot a megadott tier_id-val és opcionális expires_at dátummal. - Ha expires_at nincs megadva, a csomag rules.duration.days alapján számol. """ # 1. Ellenőrizzük a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Ellenőrizzük a SubscriptionTier-t tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == payload.tier_id) tier_result = await db.execute(tier_stmt) tier = tier_result.scalar_one_or_none() if not tier: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Előfizetési csomag nem található ID-vel: {payload.tier_id}", ) # 3. Inaktiváljuk a meglévő aktív subscription-öket await db.execute( sa_update(OrganizationSubscription) .where( OrganizationSubscription.org_id == org_id, OrganizationSubscription.is_active == True, ) .values(is_active=False) ) # 4. Számoljuk a lejárati dátumot now = datetime.utcnow() if payload.expires_at: valid_until = payload.expires_at else: # Ha nincs megadva, a csomag duration.days alapján számolunk rules = tier.rules or {} duration = rules.get("duration", {}) days = duration.get("days", 30) if isinstance(duration, dict) else 30 from datetime import timedelta valid_until = now + timedelta(days=days) # 5. P0 ADD-ON: Összeállítjuk az extra_allowances JSONB-t extra_allowances = {} if payload.extra_vehicles > 0: extra_allowances["extra_vehicles"] = payload.extra_vehicles if payload.extra_branches > 0: extra_allowances["extra_branches"] = payload.extra_branches if payload.extra_users > 0: extra_allowances["extra_users"] = payload.extra_users # 6. Létrehozzuk az új subscription rekordot new_sub = OrganizationSubscription( org_id=org_id, tier_id=payload.tier_id, valid_from=now, valid_until=valid_until, is_active=True, extra_allowances=extra_allowances, ) db.add(new_sub) # 7. Frissítjük a Organization denormalizált mezőit is org.subscription_tier_id = payload.tier_id org.subscription_expires_at = valid_until org.subscription_plan = tier.name await db.commit() await db.refresh(new_sub) logger.info( f"Admin {current_user.id} ({current_user.email}) updated subscription for org {org_id}: " f"tier_id={payload.tier_id}, tier_name={tier.name}, " f"valid_until={valid_until.isoformat()}, " f"extra_allowances={extra_allowances}" ) return { "status": "success", "message": f"Előfizetés frissítve a '{org.full_name}' garázs számára.", "subscription": { "id": new_sub.id, "org_id": new_sub.org_id, "tier_id": new_sub.tier_id, "tier_name": tier.name, "valid_from": new_sub.valid_from.isoformat() if new_sub.valid_from else None, "valid_until": new_sub.valid_until.isoformat() if new_sub.valid_until else None, "is_active": new_sub.is_active, "extra_allowances": extra_allowances, }, } # ============================================================================= # POST /{org_id}/members — Új tag hozzáadása a garázshoz # ============================================================================= @router.post( "/{org_id}/members", response_model=OrganizationMemberResponse, status_code=status.HTTP_201_CREATED, summary="Új tag hozzáadása", description="Hozzáad egy felhasználót a garázshoz a megadott szerepkörrel. " "Támogatja a user_id és email alapú hozzáadást is.", ) async def add_organization_member( org_id: int, payload: OrganizationMemberCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> OrganizationMemberResponse: """ Új tag hozzáadása egy garázshoz. Két mód támogatott: 1. user_id alapján (közvetlen) 2. email alapján (a rendszer megkeresi a felhasználót) - Ellenőrzi, hogy a szervezet létezik-e. - Ellenőrzi, hogy a felhasználó létezik-e az identity.users táblában. - Ellenőrzi, hogy a felhasználó még nem tagja a szervezetnek. - Létrehozza az OrganizationMember rekordot. """ # 1. Ellenőrizzük a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Felhasználó azonosítása: user_id vagy email alapján resolved_user_id = payload.user_id if resolved_user_id is None and payload.email: # Email-based lookup user_stmt = select(User).where(User.email == payload.email) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Felhasználó nem található e-mail címmel: {payload.email}", ) resolved_user_id = user.id elif resolved_user_id is not None: # user_id-based lookup user_stmt = select(User).where(User.id == resolved_user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Felhasználó nem található ID-vel: {resolved_user_id}", ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Meg kell adni a user_id vagy email mezőt.", ) # 3. Ellenőrizzük, hogy a felhasználó még nem tagja a szervezetnek existing_stmt = select(OrganizationMember).where( OrganizationMember.organization_id == org_id, OrganizationMember.user_id == resolved_user_id, OrganizationMember.status != "archived", ) existing_result = await db.execute(existing_stmt) existing = existing_result.scalar_one_or_none() if existing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A felhasználó (ID: {resolved_user_id}) már tagja ennek a szervezetnek.", ) # 4. Létrehozzuk az új tag rekordot person_id = user.person_id new_member = OrganizationMember( organization_id=org_id, user_id=resolved_user_id, person_id=person_id, role=payload.role, status=payload.status, is_verified=False, ) db.add(new_member) await db.commit() await db.refresh(new_member) # 5. Visszatöltjük a kapcsolódó adatokat a válaszhoz await db.refresh(new_member, attribute_names=["user", "person"]) # Person adatok összeállítása person_data = None person_obj = new_member.person if not person_obj and new_member.user and new_member.user.person: person_obj = new_member.user.person if person_obj: person_data = PersonBrief( first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=user.email, phone=person_obj.phone, ) logger.info( f"Admin {current_user.id} ({current_user.email}) added member user_id={resolved_user_id} " f"to org {org_id} ({org.full_name}) with role={payload.role}" ) return OrganizationMemberResponse( id=new_member.id, user_id=new_member.user_id, organization_id=new_member.organization_id, role=new_member.role, status=new_member.status or "active", is_verified=new_member.is_verified, person=person_data, created_at=new_member.created_at.isoformat() if new_member.created_at else None, updated_at=new_member.updated_at.isoformat() if new_member.updated_at else None, ) # ============================================================================= # PUT /{org_id}/members/{member_id} — Tag adatainak módosítása # ============================================================================= @router.put( "/{org_id}/members/{member_id}", response_model=OrganizationMemberResponse, summary="Tag adatainak módosítása", description="Frissíti egy meglévő tag szerepkörét és/vagy státuszát.", ) async def update_organization_member( org_id: int, member_id: int, payload: OrganizationMemberUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> OrganizationMemberResponse: """ Meglévő tag adatainak módosítása. - Ellenőrzi, hogy a tag rekord létezik és a megadott szervezethez tartozik. - Frissíti a role és/vagy status mezőket. """ # 1. Ellenőrizzük a tag rekordot member_stmt = ( select(OrganizationMember) .options( selectinload(OrganizationMember.user).selectinload(User.person), selectinload(OrganizationMember.person), ) .where( OrganizationMember.id == member_id, OrganizationMember.organization_id == org_id, ) ) member_result = await db.execute(member_stmt) member = member_result.scalar_one_or_none() if not member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).", ) # 2. Frissítjük a mezőket if payload.role is not None: member.role = payload.role if payload.status is not None: member.status = payload.status await db.commit() await db.refresh(member) # 3. Person adatok összeállítása person_data = None person_obj = member.person if not person_obj and member.user and member.user.person: person_obj = member.user.person if person_obj: email = member.user.email if member.user else None person_data = PersonBrief( first_name=person_obj.first_name or "", last_name=person_obj.last_name or "", email=email, phone=person_obj.phone, ) logger.info( f"Admin {current_user.id} ({current_user.email}) updated member {member_id} in org {org_id}: " f"role={payload.role}, status={payload.status}" ) return OrganizationMemberResponse( id=member.id, user_id=member.user_id, organization_id=member.organization_id, role=member.role, status=member.status or "active", is_verified=member.is_verified, person=person_data, created_at=member.created_at.isoformat() if member.created_at else None, updated_at=member.updated_at.isoformat() if member.updated_at else None, ) # ============================================================================= # DELETE /{org_id}/members/{member_id} — Tag eltávolítása # ============================================================================= @router.delete( "/{org_id}/members/{member_id}", status_code=status.HTTP_200_OK, summary="Tag eltávolítása", description="Eltávolít egy tagot a garázsból (soft delete: status → archived).", ) async def remove_organization_member( org_id: int, member_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("org:manage-members")), ) -> Dict[str, Any]: """ Tag eltávolítása a garázsból. Soft delete megközelítés: a rekord nem törlődik, csak a status mező 'archived' értékre változik. Ez megőrzi a történeti adatokat. """ # 1. Ellenőrizzük a tag rekordot member_stmt = select(OrganizationMember).where( OrganizationMember.id == member_id, OrganizationMember.organization_id == org_id, ) member_result = await db.execute(member_stmt) member = member_result.scalar_one_or_none() if not member: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag nem található (ID: {member_id}) a szervezetben (ID: {org_id}).", ) # 2. Soft delete: archived státusz member.status = "archived" await db.commit() logger.info( f"Admin {current_user.id} ({current_user.email}) removed member {member_id} from org {org_id} " f"(status → archived)" ) return { "status": "success", "message": f"Tag (ID: {member_id}) eltávolítva a szervezetből (ID: {org_id}).", } # ============================================================================= # GET /{org_id}/vehicles — Garázs járműveinek listázása (Flotta Tab) # ============================================================================= class GarageVehicleItem(BaseModel): """Egy jármű adatai a garázs flotta listájában.""" id: str license_plate: Optional[str] = None vin: Optional[str] = None brand: Optional[str] = None model: Optional[str] = None year: Optional[int] = None status: str = "active" branch_name: Optional[str] = None created_at: Optional[str] = None class GarageFleetResponse(BaseModel): """Garázs flotta járműveinek válasza.""" total: int vehicles: List[GarageVehicleItem] @router.get( "/{org_id}/vehicles", response_model=GarageFleetResponse, summary="Garázs járműveinek listázása", description="Visszaadja egy garázshoz tartozó összes járművet a Flotta Tab számára.", ) async def get_organization_vehicles( org_id: int, skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"), limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"), db: AsyncSession = Depends(get_db), current_user: User = Depends(deps.get_current_active_user), _ = Depends(deps.RequirePermission("fleet:view")), ) -> GarageFleetResponse: """ Garázs járműveinek lekérése. Két forrásból keres: 1. vehicle.assets.current_organization_id (elsődleges) 2. fleet.asset_assignments (történeti kapcsolatok) """ # 1. Ellenőrizzük a szervezetet org_stmt = select(Organization).where(Organization.id == org_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Szervezet nem található ID-vel: {org_id}", ) # 2. Járművek lekérése a vehicle.assets táblából from app.models.vehicle.asset import Asset from app.models.marketplace.organization import Branch from sqlalchemy import or_ # P0 DIRECT OWNERSHIP: Query by both owner_org_id AND current_organization_id # A jármű akkor tartozik a garázshoz, ha: # - közvetlenül a tulajdonában van (owner_org_id), VAGY # - jelenleg oda van rendelve (current_organization_id) # NEM kell AssetAssignment JOIN — a direkt ownership mezők az Asset táblában vannak. ownership_filter = or_( Asset.owner_org_id == org_id, Asset.current_organization_id == org_id, ) # P0 HOTFIX: Asset.branch relationship nem létezik a modellben. # Explicit LEFT JOIN a fleet.branches táblához a branch_name lekéréséhez. vehicle_stmt = ( select(Asset, Branch.name.label("branch_name")) .outerjoin(Branch, Asset.branch_id == Branch.id) .where(ownership_filter) .order_by(Asset.created_at.desc()) .offset(skip) .limit(limit) ) vehicle_result = await db.execute(vehicle_stmt) rows = vehicle_result.all() # Teljes számolás count_stmt = select(func.count()).select_from( select(Asset).where(ownership_filter).subquery() ) count_result = await db.execute(count_stmt) total = count_result.scalar() or 0 # 3. Válasz összeállítása vehicle_list = [] for row in rows: v = row[0] # Asset object branch_name = row.branch_name # from the LEFT JOIN label vehicle_list.append(GarageVehicleItem( id=str(v.id), license_plate=v.license_plate, vin=v.vin, brand=v.brand, model=v.model, year=v.year_of_manufacture, status=v.status or "active", branch_name=branch_name, created_at=v.created_at.isoformat() if v.created_at else None, )) logger.info( f"Admin {current_user.id} ({current_user.email}) fetched fleet for org {org_id} " f"({org.full_name}) — {len(vehicle_list)} vehicles" ) return GarageFleetResponse( total=total, vehicles=vehicle_list, )