From ef8df9608c445cf3846ff3b1618fcc4b76e13c66 Mon Sep 17 00:00:00 2001 From: Roo Date: Fri, 12 Jun 2026 07:56:15 +0000 Subject: [PATCH] =?UTF-8?q?egyedi=20j=C3=A1rm=C5=B1=20szerkeszt=C3=A9s=20e?= =?UTF-8?q?l=C5=91tti=20ment=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/history.md | 24 +- backend/app/api/v1/api.py | 5 +- backend/app/api/v1/endpoints/assets.py | 147 ++- backend/app/api/v1/endpoints/catalog.py | 28 +- backend/app/api/v1/endpoints/dictionaries.py | 68 + backend/app/api/v1/endpoints/evidence.py | 14 +- backend/app/api/v1/endpoints/expenses.py | 35 +- backend/app/models/vehicle/__init__.py | 2 + backend/app/models/vehicle/asset.py | 3 +- backend/app/models/vehicle/history.py | 8 +- backend/app/models/vehicle/vehicle.py | 4 +- .../app/models/vehicle/vehicle_definitions.py | 18 +- backend/app/schemas/asset_cost.py | 3 +- .../scripts/seed_cost_category_visibility.py | 93 ++ .../app/scripts/seed_cost_subcategories.py | 114 ++ backend/app/services/asset_service.py | 127 +- backend/scripts/seed_body_types.py | 70 + .../scripts/seed_cost_category_visibility.py | 93 ++ ...abase_catalog_reconnaissance_2026-06-11.md | 145 +++ .../dashboard/ComplexExpenseModal.vue | 311 +++++ .../dashboard/PrivateVehicleManager.vue | 106 +- .../components/dashboard/SimpleFuelModal.vue | 200 +++ .../components/dashboard/VehicleFormModal.vue | 526 +++++--- .../dashboard/VehicleFormModal_original.vue | 1159 +++++++++++++++++ .../components/vehicle/VehicleDetailModal.vue | 507 ++++++- frontend/src/data/mockVehicles.ts | 50 - frontend/src/stores/cost.ts | 104 ++ frontend/src/stores/vehicle.ts | 66 +- frontend/src/views/DashboardView.vue | 229 +++- 29 files changed, 3863 insertions(+), 396 deletions(-) create mode 100644 backend/app/api/v1/endpoints/dictionaries.py create mode 100644 backend/app/scripts/seed_cost_category_visibility.py create mode 100644 backend/app/scripts/seed_cost_subcategories.py create mode 100644 backend/scripts/seed_body_types.py create mode 100644 backend/scripts/seed_cost_category_visibility.py create mode 100644 docs/database_catalog_reconnaissance_2026-06-11.md create mode 100644 frontend/src/components/dashboard/ComplexExpenseModal.vue create mode 100644 frontend/src/components/dashboard/SimpleFuelModal.vue create mode 100644 frontend/src/components/dashboard/VehicleFormModal_original.vue delete mode 100644 frontend/src/data/mockVehicles.ts create mode 100644 frontend/src/stores/cost.ts 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 @@ + + + + + diff --git a/frontend/src/components/vehicle/VehicleDetailModal.vue b/frontend/src/components/vehicle/VehicleDetailModal.vue index 888a83b..cf78bfe 100644 --- a/frontend/src/components/vehicle/VehicleDetailModal.vue +++ b/frontend/src/components/vehicle/VehicleDetailModal.vue @@ -7,9 +7,11 @@ >
- +
@@ -50,71 +52,374 @@
- -
- -
- + +
+ +
+ + +
+
+
+
- -
- -
-

Márka

-

{{ vehicle?.brand || '—' }}

-
+ +
+ +
+ +
+ +
+

Motor

+

{{ engineDisplay || '—' }}

+

{{ vehicle.power_kw }} kW ({{ hpDisplay }})

+
- -
-

Modell

-

{{ vehicle?.model || '—' }}

-
+ +
+

Karosszéria

+

{{ vehicle?.vehicle_class || vehicle?.body_type || '—' }}

+
- -
-

Évjárat

-

{{ vehicle?.year || vehicle?.year_of_manufacture || '—' }}

-
+ +
+

Alvázszám (VIN)

+

{{ vehicle?.vin || '—' }}

+
- -
-

Km óra állás

-

{{ (vehicle?.current_mileage ?? vehicle?.mileage)?.toLocaleString() || '—' }} km

-
+ +
+

Jelenlegi km óra állás

+

{{ (vehicle?.current_mileage ?? vehicle?.mileage)?.toLocaleString() || '—' }} km

+
- -
-

Motor

-

{{ vehicle?.engine || '—' }}

-
+ +
+

Váltó

+

{{ vehicle?.transmission_type || '—' }}

+
- -
-

Szín

-
- {{ vehicle?.individual_equipment?.color || vehicle?.color || '—' }} - + +
+

Hajtás

+

{{ vehicle?.drive_type || '—' }}

+
+ + +
+

Üzemanyag

+

{{ vehicle?.fuel_type || '—' }}

+
+ + +
+

Felszereltség

+

{{ vehicle?.trim_level || '—' }}

- -
-

Következő szerviz

-

{{ vehicle?.nextService || '—' }}

+ +
+
+

Gépjármű Állapot

+ + {{ trustScoreDisplay }} + +
+
+
+
+
+ Kopó alkatrészek + Karosszéria + Motor & Erőátvitel + Elektronika +
+
+
+ + +
+ +
+

Éves Összköltség (TCO)

+

1,245,000 Ft

+

~ 3,411 Ft / nap

+
+ + + + + + + + + +
+

+ TCO / km: ~ 62 Ft/km + | + Havi átlag: ~ 103,750 Ft +

+
+
+ + +
+ +
+
+

Átlagos Fogyasztás

+

+ {{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} {{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }} +

+
+
+

Átlagos Km Költség

+

62 Ft/km

+
+
+ +

Idővonal — Közelgő események

+ +
+ +
+ 📋 +
+
+

Kötelező biztosítás fordulónap

+ 30 nap +
+

Lemondási határidő: 2026. 07. 12. — Jelenlegi díj: 85,000 Ft/év

+
+ ⚠️ Lemondási határidő +
+
+
+ + +
+ 🔧 +
+
+

Műszaki vizsga lejárata

+ 14 nap +
+

Lejárat: 2026. 06. 26. — Hátralévő napok: 14

+
+ 🔴 Sürgős +
+
+
+ + +
+ +
+
+

Következő szerviz

+ 45 nap +
+

+ {{ vehicle?.current_mileage + ? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km` + : '15,000 km vagy 1 év' }} +

+
+ 📅 Tervezett +
+
+
+ + +
+ 🛞 +
+
+

Szezonális gumicsere

+ 90 nap +
+

Téli → Nyári gumi: 2026. 09. 10. (ajánlott időszak)

+
+
- +
+ + + + + + +
- -
- +