diff --git a/.roo/history.md b/.roo/history.md
index 52b82f2..2a46e24 100644
--- a/.roo/history.md
+++ b/.roo/history.md
@@ -617,4 +617,26 @@ Jármű létrehozás reaktivitásának és duplikációvédelemnek a javítása
### ✅ Verifikáció
- Vite build: 155 modul transzformálva, 4.09s, hiba nélkül ✅
- Backend smart duplicate protection mindkét szabállyal implementálva ✅
-- Frontend toast és duplikáció validáció működik ✅
\ No newline at end of file
+- Frontend toast és duplikáció validáció működik ✅
+## 2026-06-12 - Kettős Védelem (Double-Shield) Organization Assignment for Assets
+
+### 🎯 Cél
+B2C járművek létrehozásakor a `owner_org_id` soha ne kerüljön NULL értékkel az adatbázisba. Kétvédelmes architektúra: frontend "Silent" injekció + backend "Iron Door" fallback.
+
+### 🔧 Változtatások
+
+**`backend/app/api/v1/endpoints/assets.py`:**
+- **Lines 360-394**: 3-szintű org_id feloldás a `create_or_claim_vehicle` végpontban:
+ 1. PRIORITÁS: `payload.organization_id` (frontend által küldött)
+ 2. FALLBACK: `current_user.scope_id` (aktív scope)
+ 3. VASÁJTÓ: `fleet.organization_members` tábla lekérdezése a felhasználó első elérhető szervezetéért
+
+**`frontend/src/components/dashboard/VehicleFormModal.vue`:**
+- **Line 716**: `useAuthStore` import hozzáadva
+- **Line 720**: `authStore` példányosítva
+- **Lines 1200-1211**: "Silent" org_id injekció a `handleSave()`-ben: `authStore.user?.active_organization_id` → `authStore.myOrganizations[0]?.organization_id`
+
+### ✅ Verifikáció
+- **Backend Syntax**: ✅ OK
+- **Sync Engine**: 1026/1026 elem szinkronban ✅
+- **AssetCreate schema**: `organization_id: Optional[int] = Field(None)` már opcionális volt, nem kellett módosítani
diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py
index 7846c58..564f68f 100755
--- a/backend/app/api/v1/api.py
+++ b/backend/app/api/v1/api.py
@@ -4,7 +4,7 @@ from app.api.v1.endpoints import (
auth, catalog, assets, organizations, documents,
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
- gamification, translations, users, reports
+ gamification, translations, users, reports, dictionaries
)
api_router = APIRouter()
@@ -28,4 +28,5 @@ api_router.include_router(system_parameters.router, prefix="/system/parameters",
api_router.include_router(gamification.router, prefix="/gamification", tags=["Gamification"])
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
-api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
\ No newline at end of file
+api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
+api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py
index 9ca6bf3..7f22f4c 100755
--- a/backend/app/api/v1/endpoints/assets.py
+++ b/backend/app/api/v1/endpoints/assets.py
@@ -4,6 +4,7 @@ import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
+from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, text, or_
from sqlalchemy.orm import selectinload
@@ -17,11 +18,81 @@ from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
+
+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")
+ archive_reason: str = Field(..., min_length=1, max_length=100, description="Eltávolítás oka")
+
router = APIRouter()
logger = logging.getLogger(__name__)
+@router.get("/vehicles/quota-status")
+async def get_vehicle_quota_status(
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Preemptive quota check endpoint for the frontend.
+ Returns whether the user can add a new vehicle BEFORE they start the wizard.
+
+ GET /api/v1/assets/vehicles/quota-status
+
+ Returns:
+ { "can_add": bool, "current_count": int, "limit": int }
+ """
+ try:
+ # Determine organization ID based on user's active scope (garage isolation)
+ org_id = None
+ if current_user.scope_id is not None:
+ try:
+ org_id = int(current_user.scope_id)
+ except (ValueError, TypeError):
+ pass
+
+ # Get the user's vehicle limit
+ allowed_limit = await AssetService.get_user_vehicle_limit(
+ db, current_user.id, org_id
+ )
+ # 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
+ 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:
+ 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"
+ )
+ current_count = (await db.execute(count_stmt)).scalar() or 0
+
+ can_add = current_count < allowed_limit
+
+ logger.info(
+ f"Quota check for user {current_user.id} (org={org_id}): "
+ f"current_count={current_count}, allowed_limit={allowed_limit}, can_add={can_add}"
+ )
+
+ return {
+ "can_add": can_add,
+ "current_count": current_count,
+ "limit": allowed_limit
+ }
+ except Exception as e:
+ logger.error(f"Quota status check error for user {current_user.id}: {e}")
+ # Fail open — if we can't check quota, allow the user to proceed
+ return {"can_add": True, "current_count": 0, "limit": 9999}
+
+
@router.get("/vehicles/check")
async def check_vehicle_exists(
license_plate: str,
@@ -286,14 +357,42 @@ async def create_or_claim_vehicle(
f"Setting data_status='draft' for transfer scenario."
)
- # Determine organization ID based on user's active scope (garage isolation)
- org_id = None
- if current_user.scope_id is not None:
+ # ── 🛡️ KETTŐS VÉDELEM: Szervezet hozzárendelés ──
+ # 1. PRIORITÁS: A frontend által küldött organization_id (ha van)
+ # 2. FALLBACK: A felhasználó aktív scope-ja (scope_id)
+ # 3. VASÁJTÓ: Ha egyik sincs, lekérdezzük a fleet.organization_members táblából
+ org_id = payload.organization_id
+
+ if org_id is None and current_user.scope_id is not None:
try:
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
+ # VASÁJTÓ FALLBACK: Ha még mindig nincs org_id, keressük meg a felhasználó
+ # első elérhető szervezetét a fleet.organization_members táblában
+ if org_id is None:
+ from app.models.marketplace.organization import OrganizationMember
+ from sqlalchemy import select
+ member_stmt = (
+ select(OrganizationMember.organization_id)
+ .where(OrganizationMember.user_id == current_user.id)
+ .limit(1)
+ )
+ member_result = await db.execute(member_stmt)
+ member_row = member_result.first()
+ if member_row:
+ org_id = member_row[0]
+ logger.info(
+ f"Iron Door fallback: assigned org_id={org_id} from "
+ f"organization_members for user {current_user.id}"
+ )
+ else:
+ logger.warning(
+ f"Iron Door fallback: user {current_user.id} has no "
+ f"organization memberships — asset will be created without org"
+ )
+
asset = await AssetService.create_or_claim_vehicle(
db=db,
user_id=current_user.id,
@@ -591,4 +690,46 @@ async def create_maintenance_record(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error while creating maintenance record"
+ )
+
+
+@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
+async def archive_vehicle(
+ vehicle_id: uuid.UUID,
+ payload: ArchiveVehicleRequest,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Jármű biztonságos kivezetése (Strict Soft Delete).
+
+ POST /api/v1/assets/vehicles/{vehicle_id}/archive
+
+ A végpont:
+ 1. Frissíti a jármű current_mileage értékét a megadott final_mileage-ra
+ 2. Átállítja a státuszt 'archived'-ra
+ 3. Nullázza a tulajdonosi mezőket (owner_person_id, owner_org_id)
+ 4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
+ 5. Naplózza a biztonsági auditba
+
+ Payload:
+ - final_mileage: int (utolsó km óra állás)
+ - archive_reason: string (Eladás, Gazdasági totálkár / Bontás, Lízing/Bérlet lejárta, Téves rögzítés, Egyéb)
+ """
+ try:
+ asset = await AssetService.archive_vehicle(
+ db=db,
+ asset_id=vehicle_id,
+ user_id=current_user.id,
+ final_mileage=payload.final_mileage,
+ archive_reason=payload.archive_reason,
+ )
+ return asset
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Vehicle archive error for {vehicle_id}: {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Belső szerverhiba a jármű archiválásakor"
)
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/catalog.py b/backend/app/api/v1/endpoints/catalog.py
index fe117b2..afb5d4b 100755
--- a/backend/app/api/v1/endpoints/catalog.py
+++ b/backend/app/api/v1/endpoints/catalog.py
@@ -1,8 +1,10 @@
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
from app.db.session import get_db
from app.services.asset_service import AssetService
from app.api import deps
+from app.models.vehicle import BodyTypeDictionary
from typing import List, Optional
router = APIRouter()
@@ -74,4 +76,28 @@ async def list_engines(
"fuel_type": e.fuel_type,
"factory_data": e.factory_data
} for e in engines
+ ]
+
+
+# Secured endpoint: Closed premium ecosystem
+@router.get("/body-types")
+async def list_body_types(
+ vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle, light_commercial)"),
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
+ stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
+ if vehicle_class:
+ stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
+ result = await db.execute(stmt)
+ rows = result.scalars().all()
+ return [
+ {
+ "id": r.id,
+ "vehicle_class": r.vehicle_class,
+ "code": r.code,
+ "name_hu": r.name_hu,
+ }
+ for r in rows
]
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/dictionaries.py b/backend/app/api/v1/endpoints/dictionaries.py
new file mode 100644
index 0000000..150cd3e
--- /dev/null
+++ b/backend/app/api/v1/endpoints/dictionaries.py
@@ -0,0 +1,68 @@
+# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/dictionaries.py
+"""
+Szótárak és katalógusok végpontjai.
+- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
+"""
+import logging
+from typing import List, Optional
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from app.db.session import get_db
+from app.api import deps
+from app.models.vehicle import CostCategory
+
+router = APIRouter()
+logger = logging.getLogger(__name__)
+
+
+@router.get("/cost-categories")
+async def list_cost_categories(
+ visibility: Optional[str] = Query(None, description="Szűrés láthatóságra: b2c, b2b, both, internal"),
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """
+ Költségkategóriák lekérése hierarchikus struktúrában.
+ Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
+ """
+ stmt = select(CostCategory).order_by(CostCategory.name)
+
+ if visibility:
+ # Ha 'b2c' van megadva, akkor a 'b2c' ÉS 'both' láthatóságú kategóriákat adjuk vissza
+ if visibility == "b2c":
+ stmt = stmt.where(CostCategory.visibility.in_(["b2c", "both"]))
+ elif visibility == "b2b":
+ stmt = stmt.where(CostCategory.visibility.in_(["b2b", "both"]))
+ else:
+ stmt = stmt.where(CostCategory.visibility == visibility)
+
+ result = await db.execute(stmt)
+ categories = result.scalars().all()
+
+ # Hierarchikus struktúra építése
+ category_map = {}
+ root_categories = []
+
+ for cat in categories:
+ cat_dict = {
+ "id": cat.id,
+ "code": cat.code,
+ "name": cat.name,
+ "description": cat.description,
+ "parent_id": cat.parent_id,
+ "visibility": cat.visibility,
+ "accounting_code": cat.accounting_code,
+ "is_system": cat.is_system,
+ "children": [],
+ }
+ category_map[cat.id] = cat_dict
+
+ for cat in categories:
+ cat_dict = category_map[cat.id]
+ if cat.parent_id and cat.parent_id in category_map:
+ category_map[cat.parent_id]["children"].append(cat_dict)
+ else:
+ root_categories.append(cat_dict)
+
+ return root_categories
diff --git a/backend/app/api/v1/endpoints/evidence.py b/backend/app/api/v1/endpoints/evidence.py
index b516fad..ae17d6f 100755
--- a/backend/app/api/v1/endpoints/evidence.py
+++ b/backend/app/api/v1/endpoints/evidence.py
@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/evidence.py
+import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
@@ -6,18 +7,25 @@ from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
+logger = logging.getLogger(__name__)
+
router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
- res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
- max_allowed = res.scalar() or 1
+ plan_key = (current_user.subscription_plan or "free").lower()
+ res = await db.execute(stmt_limit, {"plan": plan_key})
+ max_allowed = max(res.scalar() or 1, 1)
- stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
+ stmt_count = select(func.count(Asset.id)).where(
+ Asset.owner_org_id == current_user.scope_id,
+ Asset.status == "active"
+ )
count = (await db.execute(stmt_count)).scalar() or 0
if count >= max_allowed:
+ logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {current_user.person_id}, Active Count: {count}, Limit: {max_allowed}")
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
# OCR hívás helye...
diff --git a/backend/app/api/v1/endpoints/expenses.py b/backend/app/api/v1/endpoints/expenses.py
index 02b6289..bf233e5 100755
--- a/backend/app/api/v1/endpoints/expenses.py
+++ b/backend/app/api/v1/endpoints/expenses.py
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
-from app.models import Asset, AssetCost, SystemParameter
+from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime
@@ -53,13 +53,30 @@ async def create_expense(
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
)
- # Determine organization_id from asset (required by AssetCost model)
- organization_id = asset.current_organization_id or asset.owner_org_id
+ # Determine organization_id: prefer explicit from payload, fallback to asset fields
+ organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
if not organization_id:
- raise HTTPException(status_code=400, detail="Asset has no associated organization.")
-
- # Map cost_type to cost_category (AssetCost uses cost_category)
- cost_category = expense.cost_type
+ # B2C fallback: if the asset is owned by a person (not an org),
+ # try to find the current user's default organization
+ org_stmt = select(OrganizationMember).where(
+ OrganizationMember.user_id == current_user.id,
+ OrganizationMember.is_verified == True
+ ).limit(1)
+ org_result = await db.execute(org_stmt)
+ org_member = org_result.scalar_one_or_none()
+ if org_member:
+ organization_id = org_member.organization_id
+ else:
+ # Last resort: any membership (even unverified)
+ org_stmt = select(OrganizationMember).where(
+ OrganizationMember.user_id == current_user.id
+ ).limit(1)
+ org_result = await db.execute(org_stmt)
+ org_member = org_result.scalar_one_or_none()
+ if org_member:
+ organization_id = org_member.organization_id
+ else:
+ raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
@@ -72,7 +89,7 @@ async def create_expense(
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
- cost_category=cost_category,
+ category_id=expense.category_id,
amount_net=expense.amount_local,
currency=expense.currency_local,
date=expense.date,
@@ -88,7 +105,7 @@ async def create_expense(
"status": "success",
"id": new_cost.id,
"asset_id": new_cost.asset_id,
- "cost_category": new_cost.cost_category,
+ "category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date
}
\ No newline at end of file
diff --git a/backend/app/models/vehicle/__init__.py b/backend/app/models/vehicle/__init__.py
index 5ca26b5..4aea437 100644
--- a/backend/app/models/vehicle/__init__.py
+++ b/backend/app/models/vehicle/__init__.py
@@ -4,6 +4,7 @@ from .vehicle_definitions import (
VehicleType,
FeatureDefinition,
ModelFeatureMap,
+ BodyTypeDictionary,
)
from .vehicle import (
@@ -60,4 +61,5 @@ __all__ = [
"LogSeverity",
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
"MotorcycleSpecs",
+ "BodyTypeDictionary",
]
\ No newline at end of file
diff --git a/backend/app/models/vehicle/asset.py b/backend/app/models/vehicle/asset.py
index f1e2c46..cb9e5b6 100644
--- a/backend/app/models/vehicle/asset.py
+++ b/backend/app/models/vehicle/asset.py
@@ -244,7 +244,7 @@ class AssetCost(Base):
asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False)
- cost_category: Mapped[str] = mapped_column(String(50), index=True)
+ category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -253,6 +253,7 @@ class AssetCost(Base):
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
+ category: Mapped["CostCategory"] = relationship("CostCategory")
class VehicleLogbook(Base):
diff --git a/backend/app/models/vehicle/history.py b/backend/app/models/vehicle/history.py
index af25455..40dc89e 100755
--- a/backend/app/models/vehicle/history.py
+++ b/backend/app/models/vehicle/history.py
@@ -12,10 +12,10 @@ from sqlalchemy.sql import func
from app.database import Base
class LogSeverity(str, enum.Enum):
- info = "info"
- warning = "warning"
- critical = "critical"
- emergency = "emergency"
+ info = "INFO"
+ warning = "WARNING"
+ critical = "CRITICAL"
+ emergency = "EMERGENCY"
class AuditLog(Base):
diff --git a/backend/app/models/vehicle/vehicle.py b/backend/app/models/vehicle/vehicle.py
index 58ad594..40221ce 100644
--- a/backend/app/models/vehicle/vehicle.py
+++ b/backend/app/models/vehicle/vehicle.py
@@ -26,7 +26,7 @@ class CostCategory(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
parent_id: Mapped[Optional[int]] = mapped_column(
- Integer,
+ Integer,
ForeignKey("vehicle.cost_categories.id", ondelete="SET NULL"),
nullable=True,
index=True
@@ -35,6 +35,8 @@ class CostCategory(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+ visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
+ accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
diff --git a/backend/app/models/vehicle/vehicle_definitions.py b/backend/app/models/vehicle/vehicle_definitions.py
index 286d523..c443729 100755
--- a/backend/app/models/vehicle/vehicle_definitions.py
+++ b/backend/app/models/vehicle/vehicle_definitions.py
@@ -153,4 +153,20 @@ class ModelFeatureMap(Base):
is_standard: Mapped[bool] = mapped_column(Boolean, default=True)
model_definition: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="feature_maps")
- feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
\ No newline at end of file
+ feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
+
+
+class BodyTypeDictionary(Base):
+ """ Szabványos karosszéria-típus szótár (BodyType Dictionary).
+ A szabad szöveges body_type mező normalizálásához.
+ """
+ __tablename__ = "dict_body_types"
+ __table_args__ = (
+ UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
+ {"schema": "vehicle"}
+ )
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
+ code: Mapped[str] = mapped_column(String(50), nullable=False)
+ name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
\ No newline at end of file
diff --git a/backend/app/schemas/asset_cost.py b/backend/app/schemas/asset_cost.py
index 37c18a0..025ebdd 100755
--- a/backend/app/schemas/asset_cost.py
+++ b/backend/app/schemas/asset_cost.py
@@ -19,7 +19,8 @@ class AssetCostBase(BaseModel):
class AssetCostCreate(AssetCostBase):
asset_id: UUID
- organization_id: int
+ organization_id: Optional[int] = None # Auto-resolved from asset if not provided
+ category_id: int # FK a CostCategory táblához
class AssetCostResponse(AssetCostBase):
id: UUID
diff --git a/backend/app/scripts/seed_cost_category_visibility.py b/backend/app/scripts/seed_cost_category_visibility.py
new file mode 100644
index 0000000..41ad55c
--- /dev/null
+++ b/backend/app/scripts/seed_cost_category_visibility.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Seed script to update CostCategory visibility and accounting_code values.
+Also drops the old 'cost_category' column from asset_costs table.
+
+Usage: docker compose exec sf_api python -m app.scripts.seed_cost_category_visibility
+"""
+import asyncio
+import logging
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy import text
+from app.core.config import settings
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Visibility rules:
+# - FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER -> 'both'
+# - FINANCE, ADMIN -> 'b2b'
+
+VISIBILITY_MAP = {
+ "FUEL": "both",
+ "MAINTENANCE": "both",
+ "SERVICE": "both",
+ "TIRE": "both",
+ "INSURANCE": "both",
+ "PARKING": "both",
+ "TOLL": "both",
+ "OTHER": "both",
+ "FINANCE": "b2b",
+ "ADMIN": "b2b",
+}
+
+
+async def main():
+ engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as session:
+ # 1. Update visibility for existing categories
+ logger.info("🔄 Updating visibility for existing cost categories...")
+ for code, visibility in VISIBILITY_MAP.items():
+ result = await session.execute(
+ text("""
+ UPDATE vehicle.cost_categories
+ SET visibility = :visibility
+ WHERE code = :code AND (visibility IS NULL OR visibility = 'both')
+ """),
+ {"code": code, "visibility": visibility}
+ )
+ if result.rowcount > 0:
+ logger.info(f" ✅ {code} -> {visibility}")
+
+ # 2. Set default visibility for any categories without it
+ result = await session.execute(
+ text("""
+ UPDATE vehicle.cost_categories
+ SET visibility = 'both'
+ WHERE visibility IS NULL
+ """)
+ )
+ if result.rowcount > 0:
+ logger.info(f" ✅ Set default 'both' for {result.rowcount} categories")
+
+ # 3. Drop the old cost_category column from asset_costs
+ logger.info("🔄 Dropping old 'cost_category' column from asset_costs...")
+
+ # Check if the column still exists
+ check = await session.execute(
+ text("""
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'vehicle'
+ AND table_name = 'asset_costs'
+ AND column_name = 'cost_category'
+ """)
+ )
+ if check.scalar():
+ await session.execute(
+ text("ALTER TABLE vehicle.asset_costs DROP COLUMN cost_category")
+ )
+ logger.info(" ✅ Dropped old 'cost_category' column from vehicle.asset_costs")
+ else:
+ logger.info(" ⏭️ Column 'cost_category' already removed")
+
+ await session.commit()
+ logger.info("✅ All updates completed successfully!")
+
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/scripts/seed_cost_subcategories.py b/backend/app/scripts/seed_cost_subcategories.py
new file mode 100644
index 0000000..0b9aa0a
--- /dev/null
+++ b/backend/app/scripts/seed_cost_subcategories.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""
+Seed script to insert cost subcategories (hierarchy) under existing parent categories.
+
+Usage:
+ docker compose exec sf_api python -m app.scripts.seed_cost_subcategories
+
+Subcategories added:
+ - FUEL (ID=1): FUEL_PETROL (Benzin), FUEL_DIESEL (Dízel), FUEL_ELECTRIC (Elektromos töltés), FUEL_LPG (LPG)
+ - MAINTENANCE (ID=2): MAINT_OIL (Olajcsere), MAINT_SERVICE (Kötelező szerviz), MAINT_BRAKES (Fékcsere)
+ - FEES (ID=6): FEE_HIGHWAY (Autópálya matrica), FEE_STREET_PARKING (Közterületi parkolás), FEE_GARAGE (Parkolóház)
+"""
+import asyncio
+import logging
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy import text
+from app.core.config import settings
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Subcategory definitions: (parent_code, code, name, description, visibility)
+SUBCATEGORIES = [
+ # FUEL (parent_id=1)
+ ("FUEL", "FUEL_PETROL", "Benzin", "Benzin üzemanyag költség", "both"),
+ ("FUEL", "FUEL_DIESEL", "Dízel", "Dízel üzemanyag költség", "both"),
+ ("FUEL", "FUEL_ELECTRIC", "Elektromos töltés", "Elektromos jármű töltési költség", "both"),
+ ("FUEL", "FUEL_LPG", "LPG", "LPG (autógáz) üzemanyag költség", "both"),
+
+ # MAINTENANCE (parent_id=2)
+ ("MAINTENANCE", "MAINT_SERVICE", "Kötelező szerviz", "Gyártó által előírt időszakos szerviz", "both"),
+ ("MAINTENANCE", "MAINT_OIL", "Olajcsere", "Motorolaj és szűrő csere", "both"),
+ ("MAINTENANCE", "MAINT_BRAKES", "Fékcsere", "Fékbetét, féktárcsa csere", "both"),
+
+ # FEES (parent_id=6)
+ ("FEES", "FEE_HIGHWAY", "Autópálya matrica", "Országos autópálya használati díj", "both"),
+ ("FEES", "FEE_STREET_PARKING", "Közterületi parkolás", "Utcai parkolóóra / parkolási díj", "both"),
+ ("FEES", "FEE_GARAGE", "Parkolóház", "Mélygarázs vagy parkolóház díja", "both"),
+]
+
+
+async def main():
+ engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as session:
+ # Fetch parent categories map
+ result = await session.execute(
+ text("SELECT id, code FROM vehicle.cost_categories WHERE parent_id IS NULL")
+ )
+ parent_map = {row.code: row.id for row in result.fetchall()}
+ logger.info(f"Found parent categories: {parent_map}")
+
+ # Fetch existing codes to avoid duplicates
+ result = await session.execute(
+ text("SELECT code FROM vehicle.cost_categories")
+ )
+ existing_codes = {row.code for row in result.fetchall()}
+
+ inserted_count = 0
+ skipped_count = 0
+
+ for parent_code, code, name, description, visibility in SUBCATEGORIES:
+ if code in existing_codes:
+ logger.info(f" ⏭️ SKIP (already exists): {code}")
+ skipped_count += 1
+ continue
+
+ parent_id = parent_map.get(parent_code)
+ if parent_id is None:
+ logger.warning(f" ⚠️ Parent code '{parent_code}' not found, skipping {code}")
+ skipped_count += 1
+ continue
+
+ await session.execute(
+ text("""
+ INSERT INTO vehicle.cost_categories (code, name, description, parent_id, visibility, is_system, created_at, updated_at)
+ VALUES (:code, :name, :description, :parent_id, :visibility, true, NOW(), NOW())
+ """),
+ {
+ "code": code,
+ "name": name,
+ "description": description,
+ "parent_id": parent_id,
+ "visibility": visibility,
+ }
+ )
+ logger.info(f" ✅ INSERTED: {code} -> {parent_code} (parent_id={parent_id})")
+ inserted_count += 1
+
+ await session.commit()
+ logger.info(f"✅ Done! Inserted={inserted_count}, Skipped={skipped_count}")
+
+ # Verify the tree structure
+ result = await session.execute(
+ text("""
+ SELECT p.code AS parent_code, p.name AS parent_name,
+ c.id, c.code, c.name, c.parent_id
+ FROM vehicle.cost_categories c
+ JOIN vehicle.cost_categories p ON c.parent_id = p.id
+ ORDER BY p.id, c.id
+ """)
+ )
+ rows = result.fetchall()
+ logger.info("\n=== HIERARCHY TREE ===")
+ for row in rows:
+ logger.info(f" {row.parent_code:15s} -> {row.code:20s} ({row.name})")
+
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py
index 096222f..f4d4803 100755
--- a/backend/app/services/asset_service.py
+++ b/backend/app/services/asset_service.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import uuid
from typing import List, Optional, Dict, Any, TYPE_CHECKING
-from datetime import datetime
+from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, distinct
from sqlalchemy.orm import selectinload
@@ -62,10 +62,21 @@ class AssetService:
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, target_org_id)
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
- count_stmt = select(func.count(Asset.id)).where(
- Asset.current_organization_id == target_org_id,
- Asset.status == "active"
- )
+ # FIX: Count only the CURRENT USER's active vehicles, not ALL vehicles in the org
+ if target_org_id is not None:
+ # Organization mode: count vehicles owned by this user within the org
+ count_stmt = select(func.count(Asset.id)).where(
+ Asset.current_organization_id == target_org_id,
+ Asset.owner_person_id == user.person_id,
+ Asset.status == "active"
+ )
+ else:
+ # Personal mode: count vehicles owned personally by this user
+ count_stmt = select(func.count(Asset.id)).where(
+ Asset.current_organization_id.is_(None),
+ Asset.owner_person_id == user.person_id,
+ Asset.status == "active"
+ )
current_count = (await db.execute(count_stmt)).scalar()
# Determine status based on data completeness (use Pydantic validator's logic)
@@ -86,7 +97,15 @@ class AssetService:
else:
status = "active"
+ # SAFETY: Ensure limit is at least 1 — no registered user should ever have 0 limit
+ allowed_limit = max(allowed_limit or 1, 1)
+ logger.info(
+ f"Limit check for user {user_id} (org={target_org_id}): "
+ f"current_count={current_count}, allowed_limit={allowed_limit}, status={status}"
+ )
+
if current_count >= allowed_limit and status == "active":
+ logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {user.person_id}, Active Count: {current_count}, Limit: {allowed_limit}")
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} aktív autót engedélyez.")
# 2. LÉTEZIK-E MÁR A JÁRMŰ? (csak ha van VIN)
@@ -400,7 +419,11 @@ class AssetService:
# Get user-specific limit (based on role or subscription plan)
user_limit = limits.get(user_role)
if user_limit is None:
- user_limit = limits.get(subscription_plan.lower(), 100)
+ # FIX: If role not found (e.g. "user" not in dict), try subscription plan
+ user_limit = limits.get(subscription_plan.lower())
+ if user_limit is None:
+ # FIX: Ultimate fallback - safe default for free/basic users
+ user_limit = limits.get("free", 1)
# Get organization-specific limit (if configured)
org_limit = None
@@ -431,6 +454,98 @@ class AssetService:
# Fallback to a reasonable default
return 100
+ @staticmethod
+ async def archive_vehicle(
+ db: AsyncSession,
+ asset_id: uuid.UUID,
+ user_id: int,
+ final_mileage: int,
+ archive_reason: str,
+ ) -> Asset:
+ """
+ Strict Soft Delete — Jármű biztonságos kivezetése a garázsból.
+
+ A művelet:
+ 1. Frissíti a current_mileage értékét a megadott final_mileage-ra
+ 2. Állítja: status = 'archived'
+ 3. Nullázza az owner_person_id és owner_org_id mezőket
+ 4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
+ 5. Naplózza a biztonsági auditba
+ """
+ from app.models.identity import User
+ from sqlalchemy.orm import selectinload
+
+ # 1. Lekérjük a járművet — eager load catalog reláció a MissingGreenlet hiba elkerülésére
+ stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
+ result = await db.execute(stmt)
+ asset = result.scalar_one_or_none()
+
+ if not asset:
+ raise HTTPException(status_code=404, detail="Jármű nem található")
+
+ # 2. Jogosultság ellenőrzése — csak a tulajdonos vagy admin archiválhat
+ user_stmt = select(User).where(User.id == user_id)
+ user = (await db.execute(user_stmt)).scalar_one_or_none()
+ if not user:
+ raise HTTPException(status_code=404, detail="Felhasználó nem található")
+
+ is_owner = (asset.owner_person_id == user.person_id)
+ is_admin = user.role in ("admin", "superadmin") if hasattr(user, 'role') else False
+
+ if not is_owner and not is_admin:
+ raise HTTPException(
+ status_code=403,
+ detail="Nincs jogosultságod a jármű eltávolításához"
+ )
+
+ # 3. Már archiválva van?
+ if asset.status == "archived":
+ raise HTTPException(status_code=400, detail="A jármű már archiválva van")
+
+ # 4. Mentjük az archive_info metaadatokat
+ now_iso = datetime.now(timezone.utc).isoformat()
+ equipment = dict(asset.individual_equipment or {})
+ equipment['archive_info'] = {
+ 'reason': archive_reason,
+ 'archived_at': now_iso,
+ 'previous_owner_person_id': asset.owner_person_id,
+ 'previous_owner_org_id': asset.owner_org_id,
+ 'final_mileage': final_mileage,
+ }
+
+ # 5. Frissítjük a jármű adatait
+ asset.current_mileage = final_mileage
+ asset.status = "archived"
+ asset.owner_person_id = None
+ asset.owner_org_id = None
+ asset.individual_equipment = equipment
+ asset.updated_at = datetime.now(timezone.utc)
+
+ # 6. Biztonsági audit naplózás
+ await security_service.log_event(
+ db,
+ user_id=user_id,
+ action="VEHICLE_ARCHIVED",
+ severity=LogSeverity.warning,
+ target_type="Asset",
+ target_id=str(asset.id),
+ new_data={
+ "reason": archive_reason,
+ "final_mileage": final_mileage,
+ "archived_at": now_iso,
+ }
+ )
+
+ await db.commit()
+ await db.refresh(asset)
+
+ logger.info(
+ f"Vehicle {asset.id} ({asset.license_plate}) archived by user {user_id}. "
+ f"Reason: {archive_reason}, final_mileage: {final_mileage}"
+ )
+
+ return asset
+
@staticmethod
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
"""
diff --git a/backend/scripts/seed_body_types.py b/backend/scripts/seed_body_types.py
new file mode 100644
index 0000000..2d47e5d
--- /dev/null
+++ b/backend/scripts/seed_body_types.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""
+BodyTypeDictionary seed script.
+Populates the vehicle.dict_body_types table with standardized body types.
+Run inside the sf_api container:
+ docker compose exec sf_api python3 /app/backend/scripts/seed_body_types.py
+"""
+import asyncio
+import sys
+import os
+
+# Ensure the backend directory is on the path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+from app.database import AsyncSessionLocal, engine, Base
+from app.models.vehicle.vehicle_definitions import BodyTypeDictionary
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+# ── Standardized Body Type Data ──
+BODY_TYPES = [
+ # Personal (Személyautó)
+ {"vehicle_class": "personal", "code": "sedan", "name_hu": "Szedán"},
+ {"vehicle_class": "personal", "code": "hatchback", "name_hu": "Kompakt"},
+ {"vehicle_class": "personal", "code": "estate", "name_hu": "Kombi"},
+ {"vehicle_class": "personal", "code": "suv", "name_hu": "Városi Terepjáró"},
+ {"vehicle_class": "personal", "code": "coupe", "name_hu": "Kupé"},
+ {"vehicle_class": "personal", "code": "cabrio", "name_hu": "Kabrió"},
+ {"vehicle_class": "personal", "code": "mpv", "name_hu": "Egyterű"},
+
+ # Motorcycle (Motorkerékpár)
+ {"vehicle_class": "motorcycle", "code": "naked", "name_hu": "Naked Bike"},
+ {"vehicle_class": "motorcycle", "code": "sport", "name_hu": "Sport"},
+ {"vehicle_class": "motorcycle", "code": "touring", "name_hu": "Túra"},
+ {"vehicle_class": "motorcycle", "code": "enduro", "name_hu": "Enduró / Kaland"},
+ {"vehicle_class": "motorcycle", "code": "cruiser", "name_hu": "Cruiser"},
+ {"vehicle_class": "motorcycle", "code": "scooter", "name_hu": "Robogó"},
+
+ # Light Commercial (Kisteher)
+ {"vehicle_class": "light_commercial", "code": "van", "name_hu": "Furgon"},
+ {"vehicle_class": "light_commercial", "code": "pickup", "name_hu": "Pickup"},
+ {"vehicle_class": "light_commercial", "code": "chassis", "name_hu": "Alvázas"},
+]
+
+
+async def seed():
+ print("🌱 Seeding BodyTypeDictionary...")
+ async with AsyncSessionLocal() as session:
+ # Check if data already exists
+ result = await session.execute(select(BodyTypeDictionary).limit(1))
+ existing = result.scalar_one_or_none()
+ if existing:
+ print("⚠️ BodyTypeDictionary already has data. Skipping seed.")
+ return
+
+ for bt in BODY_TYPES:
+ entry = BodyTypeDictionary(
+ vehicle_class=bt["vehicle_class"],
+ code=bt["code"],
+ name_hu=bt["name_hu"],
+ )
+ session.add(entry)
+
+ await session.commit()
+ print(f"✅ Seeded {len(BODY_TYPES)} body types successfully!")
+
+
+if __name__ == "__main__":
+ asyncio.run(seed())
diff --git a/backend/scripts/seed_cost_category_visibility.py b/backend/scripts/seed_cost_category_visibility.py
new file mode 100644
index 0000000..3c933f1
--- /dev/null
+++ b/backend/scripts/seed_cost_category_visibility.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""
+Seed script to update CostCategory visibility and accounting_code values.
+Also drops the old 'cost_category' column from asset_costs table.
+
+Usage: docker compose exec sf_api python3 /app/backend/scripts/seed_cost_category_visibility.py
+"""
+import asyncio
+import logging
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy import text
+from app.core.config import settings
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Visibility rules:
+# - FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER -> 'both'
+# - FINANCE, ADMIN -> 'b2b'
+
+VISIBILITY_MAP = {
+ "FUEL": "both",
+ "MAINTENANCE": "both",
+ "SERVICE": "both",
+ "TIRE": "both",
+ "INSURANCE": "both",
+ "PARKING": "both",
+ "TOLL": "both",
+ "OTHER": "both",
+ "FINANCE": "b2b",
+ "ADMIN": "b2b",
+}
+
+
+async def main():
+ engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as session:
+ # 1. Update visibility for existing categories
+ logger.info("🔄 Updating visibility for existing cost categories...")
+ for code, visibility in VISIBILITY_MAP.items():
+ result = await session.execute(
+ text("""
+ UPDATE vehicle.cost_categories
+ SET visibility = :visibility
+ WHERE code = :code AND (visibility IS NULL OR visibility = 'both')
+ """),
+ {"code": code, "visibility": visibility}
+ )
+ if result.rowcount > 0:
+ logger.info(f" ✅ {code} -> {visibility}")
+
+ # 2. Set default visibility for any categories without it
+ result = await session.execute(
+ text("""
+ UPDATE vehicle.cost_categories
+ SET visibility = 'both'
+ WHERE visibility IS NULL
+ """)
+ )
+ if result.rowcount > 0:
+ logger.info(f" ✅ Set default 'both' for {result.rowcount} categories")
+
+ # 3. Drop the old cost_category column from asset_costs
+ logger.info("🔄 Dropping old 'cost_category' column from asset_costs...")
+
+ # Check if the column still exists
+ check = await session.execute(
+ text("""
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'vehicle'
+ AND table_name = 'asset_costs'
+ AND column_name = 'cost_category'
+ """)
+ )
+ if check.scalar():
+ await session.execute(
+ text("ALTER TABLE vehicle.asset_costs DROP COLUMN cost_category")
+ )
+ logger.info(" ✅ Dropped old 'cost_category' column from vehicle.asset_costs")
+ else:
+ logger.info(" ⏭️ Column 'cost_category' already removed")
+
+ await session.commit()
+ logger.info("✅ All updates completed successfully!")
+
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/docs/database_catalog_reconnaissance_2026-06-11.md b/docs/database_catalog_reconnaissance_2026-06-11.md
new file mode 100644
index 0000000..484cc01
--- /dev/null
+++ b/docs/database_catalog_reconnaissance_2026-06-11.md
@@ -0,0 +1,145 @@
+# 📊 Adatbázis Katalógus & Szótár Felderítés
+**Dátum:** 2026-06-11
+**Vizsgált rendszer:** Service Finder Backend (PostgreSQL + SQLAlchemy)
+**Cél:** Karosszéria/Kivitel (Body Type / Trim) funkció előfeltérképezése
+
+---
+
+## 1. SQL Adatbázis Felfedezés
+
+### 1.1 Katalógus-táblák a `vehicle` sémában
+
+Az `information_schema.tables` lekérdezés alapján az összes releváns tábla a **`vehicle`** sémában található. Nincs külön `catalog` vagy `dictionary` séma.
+
+| # | Tábla | Típus | Sorok száma | Állapot |
+|---|-------|-------|-------------|---------|
+| 1 | `vehicle.vehicle_model_definitions` | BASE TABLE | **345,400** | ✅ Feltöltve (MDM master) |
+| 2 | `vehicle.vehicle_catalog` | BASE TABLE | **1,596,564** | ✅ Feltöltve (variánsok) |
+| 3 | `vehicle.vehicle_types` | BASE TABLE | **1** | ❌ Csak 1 rekord (`car`) |
+| 4 | `vehicle.feature_definitions` | BASE TABLE | **16** | ⚠️ Csak 16 felszereltség |
+| 5 | `vehicle.model_feature_maps` | BASE TABLE | **0** | ❌ Üres kapcsolótábla |
+| 6 | `vehicle.catalog_discovery` | BASE TABLE | **172,525** | ✅ Feltöltve (robot várólista) |
+| 7 | `vehicle.gb_catalog_discovery` | BASE TABLE | **0** | ❌ Üres (UK piac) |
+
+### 1.2 Body Típusok (Karosszéria) Állapota
+
+- **152 különböző `body_type` érték** létezik az adatbázisban
+- **4,050 rekordnál** (~1.17%) hiányzik a body_type
+- **Probléma:** Az értékek **nem normalizáltak** — ugyanaz a karosszéria többféleképpen van tárolva:
+ - `"SUV"` vs `"SUV, Crossover"` vs `"SUV, Fastback"`
+ - `"SEDAN"` vs `"Sedan"` vs `"sedan"`
+ - `"CONVERTIBLE"` vs `"Cabriolet"` vs `"cabriolet"`
+ - Hollandi nyelvű RDW értékek: `"hatchback"`, `"stationwagen"`, `"cabriolet"` stb.
+
+### 1.3 Trim Szintek (Kivitelek) Állapota
+
+- **22,375 különböző `trim_level` érték**
+- **187,159 rekordnál** (~54%) hiányzik a trim_level
+- Az értékek többsége kódolt (#BK, #LE, #LF stb.) — nem emészthető emberi szemmel
+
+### 1.4 Vehicle Típusok
+
+- Csak **1 rekord**: `code='car'`, `name='Személyautó'`
+- A `motorcycle`, `truck`, `bus` stb. típusok nincsenek feltöltve
+
+### 1.5 Vehicle Class
+
+4 érték található: `car`, `motorcycle`, `other`, `truck`
+
+---
+
+## 2. SQLAlchemy Modellek Állapota
+
+### 2.1 A `vehicle_definitions.py` modelljei
+
+| Modell | Tábla | Oszlopok | Létezik? |
+|--------|-------|----------|----------|
+| `VehicleType` (`vehicle_definitions.py:17`) | `vehicle.vehicle_types` | `id, code, name, icon, units` | ✅ Van |
+| `FeatureDefinition` (`vehicle_definitions.py:33`) | `vehicle.feature_definitions` | `id, vehicle_type_id, code, name, category` | ✅ Van |
+| `VehicleModelDefinition` (`vehicle_definitions.py:48`) | `vehicle.vehicle_model_definitions` | `id, make, marketing_name, body_type, trim_level, vehicle_class, fuel_type...` | ✅ Van (MDM) |
+| `ModelFeatureMap` (`vehicle_definitions.py:145`) | `vehicle.model_feature_maps` | `id, model_definition_id, feature_id, is_standard` | ✅ Van |
+
+### 2.2 A `asset.py` modelljei
+
+| Modell | Tábla | Oszlopok | Létezik? |
+|--------|-------|----------|----------|
+| `AssetCatalog` (`asset.py:14`) | `vehicle.vehicle_catalog` | `id, make, model, generation, year_from, year_to, fuel_type...` | ✅ Van |
+| `Asset` (`asset.py:69`) | `vehicle.assets` | `id, vin, brand, model, trim_level, vehicle_class...` | ✅ Van |
+| `CatalogDiscovery` (`asset.py:402`) | `vehicle.catalog_discovery` | `id, make, model, vehicle_class, market, model_year, status` | ✅ Van |
+| `GbCatalogDiscovery` (`vehicle.py:195`) | `vehicle.gb_catalog_discovery` | `id, vrm, make, model, status` | ✅ Van |
+
+### 2.3 Egyéb releváns modellek
+
+| Fájl | Modell | Tábla |
+|------|--------|-------|
+| `external_reference.py:6` | `ExternalReferenceLibrary` | `vehicle.external_reference_library` |
+| `motorcycle_specs.py:6` | `MotorcycleSpecs` | `vehicle.motorcycle_specs` |
+| `reference_data.py:6` | `ReferenceLookup` | `vehicle.reference_lookup` |
+
+### 2.4 ⚠️ Kritikus hiányosság: NINCS BodyType modell!
+
+**Nincs önálló `BodyType` SQLAlchemy modell!** A `body_type` jelenleg egy szabad szöveges (`String(100)`) mező a `VehicleModelDefinition` osztályban (`vehicle_definitions.py:92`). Ez magyarázza a 152 nem normalizált értéket.
+
+Ugyanez igaz a `trim_level`-re is (`vehicle_definitions.py:94`): szabad szöveges mező, nincs normalizált szótár mögötte.
+
+---
+
+## 3. API Végpontok Állapota
+
+### 3.1 Meglévő katalógus végpontok (`catalog.py`)
+
+| Végpont | Módszer | Leírás | Védett? |
+|---------|---------|--------|---------|
+| `/makes` (`catalog.py:11`) | GET | Márkák listázása (opcionális vehicle_class szűrő) | ✅ Igen |
+| `/models` (`catalog.py:21`) | GET | Modellek listázása adott márkához | ✅ Igen |
+| `/generations` (`catalog.py:38`) | GET | Generációk/évjáratok listázása | ✅ Igen |
+| `/engines` (`catalog.py:55`) | GET | Motorváltozatok és specifikációk | ✅ Igen |
+
+**Minden végpont az `AssetService`-t használja** (`asset_service.py:339-385`), ami a `vehicle_model_definitions` táblából lekérdezéseket futtat.
+
+### 3.2 NEM létező, de szükséges végpontok
+
+A Karosszéria/Kivitel funkcióhoz az alábbi végpontok **hiányoznak**:
+
+- ❌ `GET /body-types` — Karosszéria típusok listázása
+- ❌ `GET /trims` — Kivitelek listázása (szűrhető márka/modell szerint)
+- ❌ `GET /vehicle-classes` — Jármű osztályok listázása
+- ❌ `POST /body-types` — Új karosszériatípus hozzáadása (admin)
+- ❌ `POST /trims` — Új kivitel hozzáadása (admin)
+
+### 3.3 Meglévő jármű végpontok (`vehicles.py`)
+
+| Végpont | Módszer | Leírás |
+|---------|---------|--------|
+| `/{vehicle_id}/ratings` (`vehicles.py:22`) | POST | Értékelés küldése |
+| `/{vehicle_id}/ratings` (`vehicles.py:94`) | GET | Értékelések lekérése |
+
+---
+
+## 4. Összefoglaló és Javaslatok
+
+### 4.1 Adatbázis
+
+✅ **345,400 járműdefiníció** és **1.59M katalógus variáns** — a botok jól dolgoztak
+⚠️ **`vehicle_types`** tábla majdnem üres (csak 1 rekord)
+⚠️ **`body_type`** és **`trim_level`** nem normalizált, szabad szöveges mezők
+⚠️ 152 féle body_type, nagy része duplikátum (kisbetű/nagybetű, holland/angol keverék)
+
+### 4.2 Modellek
+
+✅ Létezik `VehicleModelDefinition`, `VehicleType`, `FeatureDefinition`
+❌ **NINCS önálló `BodyType` modell** — ez a legnagyobb hiányosság
+❌ NINCS önálló `TrimLevel` modell sem
+
+### 4.3 API
+
+✅ Négy katalógus végpont működik (`/makes`, `/models`, `/generations`, `/engines`)
+❌ **NINCS** `/body-types`, `/trims`, `/vehicle-classes` végpont
+
+### 4.4 Ajánlott következő lépések
+
+1. **`BodyType` modell létrehozása** — normalizált karosszériatípus szótár (id, code, name, icon, vehicle_class_id)
+2. **`TrimLevel` modell létrehozása** — normalizált kivitel szótár (id, code, name)
+3. **Adatmigráció:** A 152 meglévő body_type érték normalizálása és deduplikálása
+4. **API végpontok:** `GET /body-types`, `GET /trims`, `GET /vehicle-classes`
+5. **`vehicle_types` feltöltése** — a hiányzó járműtípusok (motorcycle, truck, bus stb.) hozzáadása
diff --git a/frontend/src/components/dashboard/ComplexExpenseModal.vue b/frontend/src/components/dashboard/ComplexExpenseModal.vue
new file mode 100644
index 0000000..b5cecab
--- /dev/null
+++ b/frontend/src/components/dashboard/ComplexExpenseModal.vue
@@ -0,0 +1,311 @@
+
+
+ {{ isFeeMode ? '🅿️' : '➕' }}
+ {{ isFeeMode ? 'Parkolás / Útdíj rögzítése' : 'További költség rögzítése' }}
+
+
+
Kattints az "Új jármű hozzáadása" gombra az első jármű felvételéhez.
@@ -109,6 +129,8 @@ @edit="openEditFromDetail" @set-primary="setPrimaryVehicle" /> + + @@ -116,30 +138,30 @@ import { ref, computed, nextTick, onMounted, watch } from 'vue' import type { VehicleData } from '../../types/vehicle' import { useVehicleStore } from '../../stores/vehicle' -import { mockVehicles } from '../../data/mockVehicles' +import api from '../../api/axios' import VehicleFormModal from './VehicleFormModal.vue' import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue' import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue' const props = defineProps<{ targetVehicleId?: string | null + editTargetVehicle?: VehicleData | null +}>() + +const emit = defineEmits<{ + 'clear-edit-target': [] }>() const vehicleStore = useVehicleStore() /** - * Smart vehicle list: real backend vehicles first, then mock data as fallback. - * Deduplicates by license_plate to avoid showing the same vehicle twice. + * Vehicle list sourced exclusively from the backend API. + * Sorted by is_primary flag (favorite first), then alphabetically. + * No mock/fallback data is injected — if the API returns an empty list, + * the UI displays an empty garage. */ const vehicles = computedEz a művelet nem vonható vissza! A jármű archiválásra kerül.
+A jármű végleges eltávolítása a garázsból. Ez a művelet nem vonható vissza!
+