egyedi jármű szerkesztés előtti mentés

This commit is contained in:
Roo
2026-06-12 07:56:15 +00:00
parent 0a3fd8de74
commit ef8df9608c
29 changed files with 3863 additions and 396 deletions

View File

@@ -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"])
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])

View File

@@ -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"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
]

View File

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

View File

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

View File

@@ -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())

View File

@@ -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")
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)

View File

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

View File

@@ -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())

View File

@@ -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())

View File

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