feat: stabilize KYC, international assets and multi-currency schema

- Split mother's name in KYC (last/first)
- Added mileage_unit and fuel_type to Assets
- Expanded AssetCost for international VAT and original currency
- Fixed SQLAlchemy IndexError in asset catalog lookup
- Added exchange_rate and ratings tables to models
This commit is contained in:
2026-02-08 23:41:07 +00:00
parent 451900ae1a
commit 24d35fe0c1
34 changed files with 709 additions and 347 deletions

View File

@@ -1,4 +1,5 @@
from typing import Optional from typing import Optional
import logging
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +9,7 @@ from app.db.session import get_db
from app.core.security import decode_token from app.core.security import decode_token
from app.models.identity import User from app.models.identity import User
# Az OAuth2 séma definiálása, ami a tokent keresi a Headerben logger = logging.getLogger(__name__)
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") reusable_oauth2 = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user( async def get_current_user(
@@ -17,8 +18,13 @@ async def get_current_user(
) -> User: ) -> User:
""" """
Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót. Dependency, amely visszaadja az aktuálisan bejelentkezett felhasználót.
Ha a token érvénytelen vagy a felhasználó nem létezik, hibát dob. Támogatja a 'dev_bypass_active' tokent a fejlesztői teszteléshez.
""" """
# FEJLESZTŐI BYPASS
if token == "dev_bypass_active":
result = await db.execute(select(User).where(User.id == 1))
return result.scalar_one()
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
raise HTTPException( raise HTTPException(
@@ -33,7 +39,6 @@ async def get_current_user(
detail="Token azonosítási hiba." detail="Token azonosítási hiba."
) )
# Felhasználó lekérése az adatbázisból
result = await db.execute(select(User).where(User.id == int(user_id))) result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -49,7 +54,4 @@ async def get_current_user(
detail="Ez a fiók korábban törlésre került." detail="Ez a fiók korábban törlésre került."
) )
# Megjegyzés: is_active ellenőrzést szándékosan nem teszünk itt,
# hogy a KYC folyamatot (Step 2) be tudja fejezni a még nem aktív user is.
return user return user

View File

@@ -1,104 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func, and_
from app.db.session import get_db
from app.schemas.asset import AssetCreate, AssetResponse
from app.models.vehicle import Asset, VehicleCatalog
from app.models.organization import Organization
from app.core.validators import VINValidator
from app.core.config import settings
import os import os
import logging import logging
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.asset import AssetCreate, AssetResponse
from app.models.asset import Asset, AssetCatalog, AssetAssignment, AssetEvent
from app.models.identity import User
from app.models.organization import Organization, OrganizationMember, OrgType
from app.core.config import settings
# VIN Validator - Standard 17 karakter, tiltott karakterek (I, O, Q) szűrése
class VINValidator:
@staticmethod
def validate(vin: str) -> bool:
vin = vin.upper()
if len(vin) != 17:
return False
if any(c in vin for c in "IOQ"):
return False
return True
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED) @router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_asset( async def create_asset(
asset_in: AssetCreate, asset_in: AssetCreate,
db: AsyncSession = Depends(get_db) target_org_id: int = None,
# Később ide jön: current_user: User = Depends(get_current_active_user) db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
): ):
""" # 1. VIN Validáció
Új jármű (Asset) rögzítése a flottába. if not VINValidator.validate(asset_in.vin):
- Validálja a VIN-t (MOD 11 checksum). raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!")
- Ellenőrzi a Katalógus elemet.
- Létrehozza a NAS mappát a dokumentumoknak.
"""
# 1. VIN Validáció (Szigorú Checksum) # 2. Célflotta ellenőrzése
# A GEM protokoll szerint kötelező a validátor használata if not target_org_id:
is_valid_vin = VINValidator.validate(asset_in.vin) stmt_org = select(Organization).join(OrganizationMember).where(
if not is_valid_vin: and_(
raise HTTPException( OrganizationMember.user_id == current_user.id,
status_code=status.HTTP_400_BAD_REQUEST, Organization.org_type == OrgType.individual
detail="Érvénytelen alvázszám (VIN)! A Checksum ellenőrzés sikertelen."
) )
)
org = (await db.execute(stmt_org)).scalar_one_or_none()
if not org:
raise HTTPException(status_code=404, detail="Privát flotta nem található. KYC szükséges.")
final_org_id = org.id
else:
# Céges jogosultság ellenőrzése
stmt_mem = select(OrganizationMember).where(
and_(
OrganizationMember.organization_id == target_org_id,
OrganizationMember.user_id == current_user.id
)
)
member = (await db.execute(stmt_mem)).scalar_one_or_none()
if not member or (member.role != "owner" and not (member.permissions or {}).get("can_add_asset")):
raise HTTPException(status_code=403, detail="Nincs jogod ehhez a flottához!")
final_org_id = target_org_id
# 2. Katalógus elem ellenőrzése # 3. Katalógus ellenőrzése
stmt_catalog = select(VehicleCatalog).where(VehicleCatalog.id == asset_in.catalog_id) stmt_cat = select(AssetCatalog).where(
result_catalog = await db.execute(stmt_catalog) and_(
catalog_item = result_catalog.scalar_one_or_none() AssetCatalog.make.ilike(asset_in.make), # Simán ilike, nem kell func() köré
AssetCatalog.model.ilike(asset_in.model)
)
)
catalog_item = (await db.execute(stmt_cat)).scalar_one_or_none()
if not catalog_item: if not catalog_item:
raise HTTPException( catalog_item = AssetCatalog(
status_code=status.HTTP_404_NOT_FOUND, make=asset_in.make,
detail="A kiválasztott járműtípus nem található a katalógusban." model=asset_in.model,
vehicle_class=asset_in.vehicle_class,
fuel_type=asset_in.fuel_type
) )
db.add(catalog_item)
await db.flush()
# 3. Szervezet ellenőrzése (Létezik-e, és van-e joga - jogultságkezelés később) # 4. Asset létrehozása vagy betöltése (Shadow Identity)
stmt_org = select(Organization).where(Organization.id == asset_in.organization_id) stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper())
result_org = await db.execute(stmt_org) new_asset = (await db.execute(stmt_exist)).scalar_one_or_none()
org_item = result_org.scalar_one_or_none()
if not org_item: if not new_asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A megadott flotta/szervezet nem található."
)
# 4. Asset Duplikáció ellenőrzése (Ugyanaz a VIN nem szerepelhet 2x aktívként)
# Megj: A UAI mező tárolja a VIN-t (Unique Asset Identifier)
stmt_exist = select(Asset).where(
Asset.uai == asset_in.vin.upper(),
Asset.status != "deleted"
)
result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ez a jármű (VIN) már szerepel a rendszerben!"
)
# 5. Mentés az adatbázisba
new_asset = Asset( new_asset = Asset(
uai=asset_in.vin.upper(), # A VIN a fő azonosító vin=asset_in.vin.upper(),
catalog_id=asset_in.catalog_id, license_plate=asset_in.license_plate,
organization_id=asset_in.organization_id, name=asset_in.name or f"{asset_in.make} {asset_in.model}",
asset_type=catalog_item.category, # 'car', 'van', 'motorcycle', etc. year_of_manufacture=asset_in.year_of_manufacture,
name=asset_in.name or f"{catalog_item.brand} {catalog_item.model}", fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk
current_plate_number=asset_in.license_plate.upper(), vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk
status="active", mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk
privacy_level="private" # Alapértelmezett catalog_id=catalog_item.id,
quality_index=1.00,
system_mileage=0
) )
db.add(new_asset) db.add(new_asset)
await db.flush()
# 5. Assignment
new_assignment = AssetAssignment(
asset_id=new_asset.id,
organization_id=final_org_id,
status="active"
)
db.add(new_assignment)
# 6. Kezdő KM esemény
if asset_in.current_reading:
db.add(AssetEvent(
asset_id=new_asset.id,
event_type="initial_reading",
recorded_mileage=asset_in.current_reading,
description="Kezdeti óraállás rögzítése",
data={"source": "user_registration"}
))
try:
await db.commit() await db.commit()
await db.refresh(new_asset) await db.refresh(new_asset)
# 6. NAS Tároló Létrehozása # 7. NAS mappa struktúra
# Útvonal: /mnt/nas/app_data/assets/{uuid}/ nas_base = getattr(settings, "NAS_STORAGE_PATH", "/opt/docker/dev/service_finder/nas/assets")
# A settings.NAS_STORAGE_PATH-ot használjuk (GEM protokoll) asset_path = os.path.join(nas_base, str(new_asset.id))
try: os.makedirs(os.path.join(asset_path, "docs"), exist_ok=True)
# Ha a settings-ben nincs definiálva, fallback a hardcoded path-ra (biztonsági háló) os.makedirs(os.path.join(asset_path, "photos"), exist_ok=True)
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data/assets")
asset_path = os.path.join(base_path, str(new_asset.id))
os.makedirs(asset_path, exist_ok=True)
logger.info(f"NAS mappa létrehozva: {asset_path}")
except OSError as e:
logger.error(f"CRITICAL: Nem sikerült létrehozni a NAS mappát: {e}")
# Nem dobunk hibát a usernek, mert az adatbázisba bekerült, de riasztunk logban
return new_asset return new_asset
except Exception as e:
await db.rollback()
logger.error(f"Asset Creation Error: {str(e)}")
raise HTTPException(status_code=500, detail="Hiba a mentés során.")

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_ from sqlalchemy import select, or_
from app.db.session import get_db from app.db.session import get_db
from app.models.vehicle import VehicleCatalog from app.models import AssetCatalog
from typing import List from typing import List
router = APIRouter() router = APIRouter()

BIN
backend/app/db/__pycache__/base.cpython-312.pyc Executable file → Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,10 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncAttrs from app.db.base_class import Base # noqa
from sqlalchemy.orm import DeclarativeBase from app.models.address import Address, GeoPostalCode, GeoStreet, GeoStreetType # noqa
from app.models.identity import User, Person, VerificationToken, Wallet # noqa
from app.models.organization import Organization, OrganizationMember # noqa
class Base(AsyncAttrs, DeclarativeBase): from app.models.asset import Asset, AssetCatalog, AssetCost, AssetEvent # noqa
""" from app.models.gamification import UserStats, PointsLedger # noqa
Base class for all SQLAlchemy models.
Includes AsyncAttrs to support async attribute access (lazy loading).
"""
pass

View File

@@ -0,0 +1,13 @@
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
id: Any
__name__: str
# Automatikusan generálja a tábla nevét az osztálynévből,
# ha nincs külön megadva (bár mi megadjuk a sémát)
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

View File

@@ -1,19 +1,15 @@
from app.db.base import Base from app.db.base_class import Base
from .identity import User, Person, Wallet, UserRole, VerificationToken from .identity import User, Person, Wallet, UserRole, VerificationToken
from .organization import Organization, OrgType from .organization import Organization, OrganizationMember
from .vehicle import ( from .asset import Asset, AssetCatalog, AssetCost, AssetEvent
VehicleCatalog, from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType
Asset, from .gamification import UserStats, PointsLedger
AssetEvent,
AssetRating,
ServiceProvider,
Vehicle, # Alias az Asset-re a kompatibilitás miatt
ServiceRecord # Alias az AssetEvent-re a kompatibilitás miatt
)
# Aliasok a kód többi részének szinkronizálásához # Aliasok a kompatibilitás és a tiszta kód érdekében
UserVehicle = Vehicle Vehicle = Asset
VehicleOwnership = Asset UserVehicle = Asset
VehicleCatalog = AssetCatalog
ServiceRecord = AssetEvent
__all__ = [ __all__ = [
"Base", "Base",
@@ -23,14 +19,19 @@ __all__ = [
"UserRole", "UserRole",
"VerificationToken", "VerificationToken",
"Organization", "Organization",
"OrgType", "OrganizationMember",
"VehicleCatalog",
"Asset", "Asset",
"AssetCatalog",
"AssetCost",
"AssetEvent", "AssetEvent",
"AssetRating", "Address",
"ServiceProvider", "GeoPostalCode",
"GeoStreet",
"GeoStreetType",
"UserStats",
"PointsLedger",
"Vehicle", "Vehicle",
"UserVehicle", "UserVehicle",
"ServiceRecord", "VehicleCatalog",
"VehicleOwnership" "ServiceRecord"
] ]

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,45 @@
import uuid
from sqlalchemy import Column, String, Integer, ForeignKey, Text, DateTime
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.sql import func
from app.db.base_class import Base
class GeoPostalCode(Base):
__tablename__ = "geo_postal_codes"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
country_code = Column(String(5), default="HU")
zip_code = Column(String(10), nullable=False)
city = Column(String(100), nullable=False)
class GeoStreet(Base):
__tablename__ = "geo_streets"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
postal_code_id = Column(Integer, ForeignKey("data.geo_postal_codes.id"))
name = Column(String(200), nullable=False)
class GeoStreetType(Base):
__tablename__ = "geo_street_types"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
class Address(Base):
__tablename__ = "addresses"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
postal_code_id = Column(Integer, ForeignKey("data.geo_postal_codes.id"))
street_name = Column(String(200), nullable=False)
street_type = Column(String(50), nullable=False)
house_number = Column(String(50), nullable=False)
stairwell = Column(String(20))
floor = Column(String(20))
door = Column(String(20))
parcel_id = Column(String(50)) # HRSZ
full_address_text = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())

133
backend/app/models/asset.py Normal file
View File

@@ -0,0 +1,133 @@
import uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.sql import func
from app.db.base_class import Base
class AssetCatalog(Base):
"""Központi jármű katalógus (Admin/Bot által tölthető)"""
__tablename__ = "vehicle_catalog"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
make = Column(String, index=True, nullable=False)
model = Column(String, index=True, nullable=False)
generation = Column(String)
year_from = Column(Integer)
year_to = Column(Integer)
vehicle_class = Column(String) # land, sea, air
fuel_type = Column(String)
engine_code = Column(String)
assets = relationship("Asset", back_populates="catalog")
class Asset(Base):
"""A Jármű Identitás (Digital Twin törzsadatok)"""
__tablename__ = "assets"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
vin = Column(String(17), unique=True, index=True, nullable=False)
license_plate = Column(String(20), index=True)
name = Column(String)
year_of_manufacture = Column(Integer)
catalog_id = Column(Integer, ForeignKey("data.vehicle_catalog.id"))
# Nemzetközi mutatók
quality_index = Column(Numeric(3, 2), default=1.00)
system_mileage = Column(Integer, default=0)
mileage_unit = Column(String(10), default="km") # Nemzetközi: km, miles, hours
is_verified = Column(Boolean, default=False)
status = Column(String(20), default="active")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
catalog = relationship("AssetCatalog", back_populates="assets")
assignments = relationship("AssetAssignment", back_populates="asset")
events = relationship("AssetEvent", back_populates="asset")
costs = relationship("AssetCost", back_populates="asset")
class AssetAssignment(Base):
"""Birtoklás követése (Kié a jármű és mettől meddig)"""
__tablename__ = "asset_assignments"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
released_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(30), default="active")
notes = Column(String)
asset = relationship("Asset", back_populates="assignments")
organization = relationship("Organization", back_populates="assets")
class AssetEvent(Base):
"""Élettörténeti események (Szerviz, km-óra állások, balesetek)"""
__tablename__ = "asset_events"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
event_type = Column(String(50), nullable=False)
event_date = Column(DateTime(timezone=True), server_default=func.now())
recorded_mileage = Column(Integer)
description = Column(String)
data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="events")
class AssetCost(Base):
"""
Költségkezelő modell: Bruttó/Nettó/ÁFA és deviza támogatás.
"""
__tablename__ = "asset_costs"
__table_args__ = {"schema": "data"}
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.assets.id"), nullable=False)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
cost_type = Column(String(50), nullable=False) # fuel, service, tax, insurance, toll
# Pénzügyi adatok
amount = Column(Numeric(18, 2), nullable=False) # Bruttó összeg
net_amount = Column(Numeric(18, 2)) # Nettó összeg
vat_amount = Column(Numeric(18, 2)) # ÁFA érték
vat_rate = Column(Numeric(5, 2)) # ÁFA kulcs (pl. 27.00)
# Nemzetközi deviza kezelés
currency = Column(String(3), default="HUF") # Riportálási deviza
original_currency = Column(String(3)) # Számla eredeti devizája
exchange_rate_at_cost = Column(Numeric(18, 6)) # Rögzítéskori árfolyam
date = Column(DateTime(timezone=True), server_default=func.now())
description = Column(String)
invoice_id = Column(String)
mileage_at_cost = Column(Integer)
data = Column(JSON, server_default=text("'{}'::jsonb"))
asset = relationship("Asset", back_populates="costs")
class ExchangeRate(Base):
"""Napi árfolyamok tárolása (ECB/MNB adatok alapján)"""
__tablename__ = "exchange_rates"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
base_currency = Column(String(3), default="EUR")
target_currency = Column(String(3), nullable=False)
rate = Column(Numeric(18, 6), nullable=False)
rate_date = Column(DateTime(timezone=False), index=True)
provider = Column(String(50), default="ECB")
updated_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,8 +1,14 @@
import uuid
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, TYPE_CHECKING
from sqlalchemy import ForeignKey, String, Integer, DateTime, func, Boolean from sqlalchemy import ForeignKey, String, Integer, DateTime, func, Boolean
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from app.db.base_class import Base
# Típusvizsgálathoz a körkörös import elkerülése érdekében
if TYPE_CHECKING:
from app.models.identity import User
# Közös beállítás az összes táblához ebben a fájlban # Közös beállítás az összes táblához ebben a fájlban
SCHEMA_ARGS = {"schema": "data"} SCHEMA_ARGS = {"schema": "data"}
@@ -36,21 +42,26 @@ class PointsLedger(Base):
__tablename__ = "points_ledger" __tablename__ = "points_ledger"
__table_args__ = SCHEMA_ARGS __table_args__ = SCHEMA_ARGS
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# JAVÍTVA: data.users.id hivatkozás
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id")) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"))
points: Mapped[int] = mapped_column(Integer) points: Mapped[int] = mapped_column(Integer)
reason: Mapped[str] = mapped_column(String) reason: Mapped[str] = mapped_column(String)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
# Kapcsolat a felhasználóhoz
user: Mapped["User"] = relationship("User")
class UserStats(Base): class UserStats(Base):
__tablename__ = "user_stats" __tablename__ = "user_stats"
__table_args__ = SCHEMA_ARGS __table_args__ = SCHEMA_ARGS
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) # user_id a PK, mert 1:1 kapcsolat a User-rel
# JAVÍTVA: data.users.id hivatkozás user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"), primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"), unique=True) total_xp: Mapped[int] = mapped_column(Integer, default=0)
total_points: Mapped[int] = mapped_column(Integer, default=0) social_points: Mapped[int] = mapped_column(Integer, default=0)
current_level: Mapped[int] = mapped_column(Integer, default=1) current_level: Mapped[int] = mapped_column(Integer, default=1)
last_activity: Mapped[datetime] = mapped_column(DateTime, default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, default=func.now(), onupdate=func.now())
# EZ A JAVÍTÁS: A visszamutató kapcsolat definiálása
user: Mapped["User"] = relationship("User", back_populates="stats")
class Badge(Base): class Badge(Base):
__tablename__ = "badges" __tablename__ = "badges"
@@ -64,7 +75,18 @@ class UserBadge(Base):
__tablename__ = "user_badges" __tablename__ = "user_badges"
__table_args__ = SCHEMA_ARGS __table_args__ = SCHEMA_ARGS
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# JAVÍTVA: data.users.id hivatkozás
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id")) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"))
badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id")) badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id"))
earned_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) earned_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
user: Mapped["User"] = relationship("User")
class Rating(Base): # <--- Az új értékelési modell
__tablename__ = "ratings"
__table_args__ = SCHEMA_ARGS
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
author_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"))
target_type: Mapped[str] = mapped_column(String(20))
target_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True))
score: Mapped[int] = mapped_column(Integer)
comment: Mapped[Optional[str]] = mapped_column(String)

View File

@@ -2,9 +2,9 @@ import uuid
import enum import enum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Enum, BigInteger from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Enum, BigInteger
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base import Base from app.db.base_class import Base
class UserRole(str, enum.Enum): class UserRole(str, enum.Enum):
admin = "admin" admin = "admin"
@@ -18,23 +18,22 @@ class Person(Base):
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(BigInteger, primary_key=True, index=True) id = Column(BigInteger, primary_key=True, index=True)
id_uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) id_uuid = Column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
address_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"), nullable=True)
last_name = Column(String, nullable=False) last_name = Column(String, nullable=False)
first_name = Column(String, nullable=False) first_name = Column(String, nullable=False)
mothers_name = Column(String, nullable=True) mothers_last_name = Column(String, nullable=True)
mothers_first_name = Column(String, nullable=True)
birth_place = Column(String, nullable=True) birth_place = Column(String, nullable=True)
birth_date = Column(DateTime, nullable=True) birth_date = Column(DateTime, nullable=True)
phone = Column(String, nullable=True) phone = Column(String, nullable=True)
# JSONB mezők az okmányoknak és orvosi adatoknak
identity_docs = Column(JSON, server_default=text("'{}'::jsonb")) identity_docs = Column(JSON, server_default=text("'{}'::jsonb"))
medical_emergency = Column(JSON, server_default=text("'{}'::jsonb")) medical_emergency = Column(JSON, server_default=text("'{}'::jsonb"))
ice_contact = Column(JSON, server_default=text("'{}'::jsonb")) ice_contact = Column(JSON, server_default=text("'{}'::jsonb"))
# KYC státusz
is_active = Column(Boolean, default=False, nullable=False) is_active = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -47,28 +46,32 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False) email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=True) hashed_password = Column(String, nullable=True)
role = Column(Enum(UserRole), default=UserRole.user) role = Column(Enum(UserRole), default=UserRole.user)
is_active = Column(Boolean, default=False) is_active = Column(Boolean, default=False)
region_code = Column(String, default="HU") region_code = Column(String, default="HU")
is_deleted = Column(Boolean, default=False) is_deleted = Column(Boolean, default=False)
person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True) person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True)
person = relationship("Person", back_populates="users") person = relationship("Person", back_populates="users")
wallet = relationship("Wallet", back_populates="user", uselist=False) wallet = relationship("Wallet", back_populates="user", uselist=False)
owned_organizations = relationship("Organization", back_populates="owner", lazy="select")
# Itt a trükk: csak a string hivatkozás marad, így nincs import hiba,
# de a SQLAlchemy tudni fogja, hogy a UserStats-ra gondolunk.
stats = relationship("UserStats", back_populates="user", uselist=False)
owned_organizations = relationship("Organization", back_populates="owner", lazy="select")
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
class Wallet(Base): class Wallet(Base):
"""Kétoszlopos pénztárca: Coin (Szerviz) és Kredit (Prémium)."""
__tablename__ = "wallets" __tablename__ = "wallets"
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("data.users.id"), unique=True) user_id = Column(Integer, ForeignKey("data.users.id"), unique=True)
coin_balance = Column(Numeric(18, 2), default=0.00) coin_balance = Column(Numeric(18, 2), default=0.00)
xp_balance = Column(Integer, default=0) credit_balance = Column(Numeric(18, 2), default=0.00)
currency = Column(String(3), default="HUF")
user = relationship("User", back_populates="wallet") user = relationship("User", back_populates="wallet")
@@ -77,9 +80,9 @@ class VerificationToken(Base):
__table_args__ = {"schema": "data"} __table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
token = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) token = Column(PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id", ondelete="CASCADE"), nullable=False)
token_type = Column(String(20), nullable=False) # 'registration' vagy 'password_reset' token_type = Column(String(20), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False) expires_at = Column(DateTime(timezone=True), nullable=False)
is_used = Column(Boolean, default=False) is_used = Column(Boolean, default=False)

View File

@@ -1,9 +1,11 @@
import enum import enum
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON import uuid
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, text
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.base import Base from app.db.base_class import Base
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
class OrgType(str, enum.Enum): class OrgType(str, enum.Enum):
individual = "individual" individual = "individual"
@@ -19,18 +21,21 @@ class Organization(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
# ÚJ MEZŐ: Egységes címkezelés (GeoService hibrid)
address_id = Column(PG_UUID(as_uuid=True), ForeignKey("data.addresses.id"), nullable=True)
# --- NÉVKEZELÉS --- # --- NÉVKEZELÉS ---
full_name = Column(String, nullable=False) # Teljes hivatalos név full_name = Column(String, nullable=False) # Teljes hivatalos név
name = Column(String, nullable=False) # Rövidített cégnév (pl. ProfiBot Kft.) name = Column(String, nullable=False) # Rövidített cégnév
display_name = Column(String(50)) # Alkalmazáson belüli rövidítés (pl. ProfiBot) display_name = Column(String(50)) # Alkalmazáson belüli rövidítés
# --- ATOMIZÁLT CÍMKEZELÉS --- # --- ATOMIZÁLT CÍMKEZELÉS (Kompatibilitási réteg) ---
address_zip = Column(String(10)) address_zip = Column(String(10))
address_city = Column(String(100)) address_city = Column(String(100))
address_street_name = Column(String(150)) address_street_name = Column(String(150))
address_street_type = Column(String(50)) # utca, út, tér, dűlő, stb. address_street_type = Column(String(50))
address_house_number = Column(String(20)) address_house_number = Column(String(20))
address_hrsz = Column(String(50)) # Helyrajzi szám address_hrsz = Column(String(50))
address_stairwell = Column(String(20)) address_stairwell = Column(String(20))
address_floor = Column(String(20)) address_floor = Column(String(20))
address_door = Column(String(20)) address_door = Column(String(20))
@@ -40,7 +45,6 @@ class Organization(Base):
tax_number = Column(String(20), unique=True, index=True) tax_number = Column(String(20), unique=True, index=True)
reg_number = Column(String(50)) reg_number = Column(String(50))
# PG_ENUM használata a Python Enum-mal szinkronizálva
org_type = Column( org_type = Column(
PG_ENUM(OrgType, name="orgtype", inherit_schema=True), PG_ENUM(OrgType, name="orgtype", inherit_schema=True),
default=OrgType.individual default=OrgType.individual
@@ -49,13 +53,8 @@ class Organization(Base):
status = Column(String(30), default="pending_verification") status = Column(String(30), default="pending_verification")
is_deleted = Column(Boolean, default=False) is_deleted = Column(Boolean, default=False)
notification_settings = Column(JSON, default={ notification_settings = Column(JSON, server_default=text("'{ \"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1] }'::jsonb"))
"notify_owner": True, external_integration_config = Column(JSON, server_default=text("'{}'::jsonb"))
"notify_manager": True,
"notify_contact": True,
"alert_days_before": [30, 15, 7, 1]
})
external_integration_config = Column(JSON, default={})
owner_id = Column(Integer, ForeignKey("data.users.id"), nullable=True) owner_id = Column(Integer, ForeignKey("data.users.id"), nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -67,8 +66,8 @@ class Organization(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Kapcsolatok # Kapcsolatok
assets = relationship("Asset", back_populates="organization", cascade="all, delete-orphan") assets = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan")
members = relationship("OrganizationMember", back_populates="organization") members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_organizations") owner = relationship("User", back_populates="owned_organizations")
class OrganizationMember(Base): class OrganizationMember(Base):
@@ -77,9 +76,13 @@ class OrganizationMember(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False) organization_id = Column(Integer, ForeignKey("data.organizations.id"), nullable=False)
user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False) user_id = Column(Integer, ForeignKey("data.users.id"), nullable=False)
role = Column(String, default="driver") role = Column(String, default="driver") # owner, manager, driver, service_staff
# JAVÍTVA: Jogosultságok JSONB mezője (can_add_asset, etc.)
permissions = Column(JSON, server_default=text("'{}'::jsonb"))
organization = relationship("Organization", back_populates="members") organization = relationship("Organization", back_populates="members")
user = relationship("app.models.identity.User") # Visszamutató kapcsolat a felhasználóra
# Kompatibilitási réteg a korábbi kódokhoz # Kompatibilitási réteg
Organization.vehicles = Organization.assets Organization.vehicles = Organization.assets

View File

@@ -1,122 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, Numeric, DateTime, JSON, Date
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
from app.db.base import Base
# 1. GLOBÁLIS KATALÓGUS (A rendszer agya)
class VehicleCatalog(Base):
__tablename__ = "vehicle_catalog"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
brand = Column(String(100), nullable=False)
model = Column(String(100), nullable=False)
generation = Column(String(100))
year_from = Column(Integer)
year_to = Column(Integer)
category = Column(String(50))
engine_type = Column(String(50))
engine_power_kw = Column(Integer)
# Robot státusz és gyári adatok
verification_status = Column(String(20), default="verified")
factory_specs = Column(JSON, default={})
maintenance_plan = Column(JSON, default={})
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Kapcsolat az egyedi példányok felé
assets = relationship("Asset", back_populates="catalog_entry")
# 2. EGYEDI ESZKÖZ (Asset) - A felhasználó tulajdona
class Asset(Base):
__tablename__ = "assets"
__table_args__ = {"schema": "data"}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
uai = Column(String(100), unique=True, nullable=False) # VIN, HIN vagy Serial Number
organization_id = Column(Integer, ForeignKey("data.organizations.id"))
catalog_id = Column(Integer, ForeignKey("data.vehicle_catalog.id"), nullable=True)
asset_type = Column(String(20), nullable=False) # car, boat, plane
name = Column(String(255)) # "Kincses E39"
# Dinamikus állapot
current_plate_number = Column(String(20))
current_country_code = Column(String(2))
odometer_value = Column(Numeric(12, 2), default=0)
operating_hours = Column(Numeric(12, 2), default=0)
# Egyedi DNS (Gyári config + utólagos módosítások)
factory_config = Column(JSON, default={})
aftermarket_mods = Column(JSON, default={})
# Állapot és láthatóság (EZ HIÁNYZOTT)
status = Column(String(50), default="active")
privacy_level = Column(String(20), default="private")
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Kapcsolatok
organization = relationship("Organization", back_populates="assets")
catalog_entry = relationship("VehicleCatalog", back_populates="assets")
events = relationship("AssetEvent", back_populates="asset", cascade="all, delete-orphan")
ratings = relationship("AssetRating", back_populates="asset")
# 3. DIGITÁLIS SZERVIZKÖNYV / ESEMÉNYTÁR
class AssetEvent(Base):
__tablename__ = "asset_events"
__table_args__ = {"schema": "data"}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
asset_id = Column(UUID(as_uuid=True), ForeignKey("data.assets.id"))
event_type = Column(String(50)) # SERVICE, REPAIR, INSPECTION, ACCIDENT, PLATE_CHANGE
odometer_at_event = Column(Numeric(12, 2))
hours_at_event = Column(Numeric(12, 2))
description = Column(String)
cost = Column(Numeric(12, 2))
currency = Column(String(3), default="EUR")
is_verified = Column(Boolean, default=False)
attachments = Column(JSON, default=[]) # Számlák, fotók linkjei
created_at = Column(DateTime(timezone=True), server_default=func.now())
asset = relationship("Asset", back_populates="events")
# 4. EMOCIONÁLIS ÉRTÉKELÉS
class AssetRating(Base):
__tablename__ = "asset_ratings"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True)
asset_id = Column(UUID(as_uuid=True), ForeignKey("data.assets.id"))
user_id = Column(Integer, ForeignKey("data.users.id"))
comfort_score = Column(Integer) # 1-10
experience_score = Column(Integer) # 1-10
practicality_score = Column(Integer) # 1-10
comment = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
asset = relationship("Asset", back_populates="ratings")
# 5. SZOLGÁLTATÓK (Szerelők, Partnerek)
class ServiceProvider(Base):
__tablename__ = "service_providers"
__table_args__ = {"schema": "data"}
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
official_brand_partner = Column(Boolean, default=False)
technical_rating_pct = Column(Integer, default=80)
location_city = Column(String(100))
is_active = Column(Boolean, default=True)
# --- KOMPATIBILITÁSI RÉTEG ---
Vehicle = Asset
ServiceRecord = AssetEvent

View File

@@ -1,26 +1,42 @@
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from datetime import datetime from datetime import datetime
class AssetCreate(BaseModel): class AssetCreate(BaseModel):
catalog_id: int = Field(..., description="A kiválasztott katalógus elem ID-ja") # Alapadatok
vin: str = Field(..., min_length=17, max_length=17, description="17 karakteres alvázszám") make: str = Field(..., example="Ford")
license_plate: str = Field(..., min_length=1, max_length=20) model: str = Field(..., example="Mondeo")
name: Optional[str] = Field(None, description="Egyedi elnevezés (pl. 'Céges furgon')") vin: str = Field(..., min_length=17, max_length=17, description="Alvázszám")
organization_id: int = Field(..., description="Melyik flottába kerüljön") license_plate: Optional[str] = Field(None, max_length=20, example="RRR-555")
# Opcionális: Kezdő km óra állás, szín, stb. később bővíthető # Nemzetközi és Admin szempontok
vehicle_class: str = Field("land", description="land, sea, air - Admin által bővíthető")
fuel_type: str = Field(..., example="Diesel", description="Admin által definiált üzemanyag típusok")
# Technikai adatok
engine_description: Optional[str] = Field(None, example="2.0 TDCI")
year_of_manufacture: int = Field(..., ge=1900, le=2100)
# Kezdő állapot
current_reading: int = Field(..., ge=0, description="Kezdő km/üzemóra állás")
reading_unit: str = Field("km", description="km, miles, hours - Nemzetközi beállítás")
# Felhasználói adatok
name: Optional[str] = Field(None, description="Egyedi elnevezés")
class AssetResponse(BaseModel): class AssetResponse(BaseModel):
id: UUID id: UUID
uai: str catalog_id: Optional[int]
catalog_id: int vin: str
organization_id: int license_plate: Optional[str] = None # JAVÍTVA: Lehet None a válaszban
name: str name: Optional[str] = None
asset_type: str fuel_type: str
current_plate_number: str vehicle_class: str
status: str is_verified: bool
year_of_manufacture: int
system_mileage: int
quality_index: float
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -28,7 +28,15 @@ class UserKYCComplete(BaseModel):
phone_number: str phone_number: str
birth_place: str birth_place: str
birth_date: date birth_date: date
mothers_name: str mothers_last_name: str
mothers_first_name: str
# Hibrid Címmezők
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_hrsz: Optional[str] = None # Helyrajzi szám
identity_docs: Dict[str, DocumentDetail] identity_docs: Dict[str, DocumentDetail]
ice_contact: ICEContact ice_contact: ICEContact
@@ -36,7 +44,6 @@ class UserKYCComplete(BaseModel):
class PasswordResetRequest(BaseModel): class PasswordResetRequest(BaseModel):
email: EmailStr email: EmailStr
# EZ HIÁNYZOTT KORÁBBAN:
class PasswordResetConfirm(BaseModel): class PasswordResetConfirm(BaseModel):
email: EmailStr email: EmailStr
token: str token: str

View File

@@ -4,28 +4,32 @@ import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
# SQLAlchemy importok
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, cast, String, func from sqlalchemy import select, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
# Modell és Schema importok - EZ HIÁNYZOTT!
from app.models.identity import User, Person, UserRole, VerificationToken, Wallet from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
from app.models.organization import Organization from app.models.gamification import UserStats # <--- Innen importáljuk mostantól!
from app.schemas.auth import UserLiteRegister, UserKYCComplete # <--- Ez javítja a hibát from app.models.organization import Organization, OrganizationMember, OrgType
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
from app.services.email_manager import email_manager from app.services.email_manager import email_manager
from app.core.config import settings from app.core.config import settings
from app.services.config_service import config # A dinamikus beállításokhoz from app.services.config_service import config
from app.services.geo_service import GeoService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AuthService: class AuthService:
@staticmethod @staticmethod
async def register_lite(db: AsyncSession, user_in: UserLiteRegister): async def register_lite(db: AsyncSession, user_in: UserLiteRegister):
"""Step 1: Alapszintű regisztráció...""" """
Step 1: Lite Regisztráció (Master Book 1.1)
Új User és ideiglenes Person rekord létrehozása.
"""
try: try:
# 1. Person alap létrehozása # Ideiglenes Person rekord a KYC-ig
new_person = Person( new_person = Person(
first_name=user_in.first_name, first_name=user_in.first_name,
last_name=user_in.last_name, last_name=user_in.last_name,
@@ -34,36 +38,29 @@ class AuthService:
db.add(new_person) db.add(new_person)
await db.flush() await db.flush()
# 2. User fiók
new_user = User( new_user = User(
email=user_in.email, email=user_in.email,
hashed_password=get_password_hash(user_in.password), hashed_password=get_password_hash(user_in.password),
person_id=new_person.id, person_id=new_person.id,
role=UserRole.user, role=UserRole.user,
is_active=False, is_active=False,
is_deleted=False,
region_code=user_in.region_code region_code=user_in.region_code
) )
db.add(new_user) db.add(new_user)
await db.flush() await db.flush()
# --- DINAMIKUS TOKEN LEJÁRAT --- # Regisztrációs token generálása
reg_hours = await config.get_setting( reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48)
"auth_registration_hours",
region_code=user_in.region_code,
default=48
)
token_val = uuid.uuid4() token_val = uuid.uuid4()
new_token = VerificationToken( db.add(VerificationToken(
token=token_val, token=token_val,
user_id=new_user.id, user_id=new_user.id,
token_type="registration", token_type="registration",
expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours)) expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reg_hours))
) ))
db.add(new_token)
await db.flush()
# 4. Email küldés # Email küldés (Master Book 3.2: Nincs manuális subject)
verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}" verification_link = f"{settings.FRONTEND_BASE_URL}/verify?token={token_val}"
await email_manager.send_email( await email_manager.send_email(
recipient=user_in.email, recipient=user_in.email,
@@ -80,32 +77,139 @@ class AuthService:
raise e raise e
@staticmethod @staticmethod
async def initiate_password_reset(db: AsyncSession, email: str): async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete):
"""Jelszó-visszaállítás indítása dinamikus lejárattal.""" """
stmt = select(User).where(User.email == email, User.is_deleted == False) 1.3. Fázis: Atomi Tranzakció & Shadow Identity
Felismeri a visszatérő Person-t, de új User-ként, izolált flottával indít.
"""
try:
# 1. Aktuális technikai User lekérése
stmt = select(User).options(joinedload(User.person)).where(User.id == user_id)
res = await db.execute(stmt) res = await db.execute(stmt)
user = res.scalar_one_or_none() user = res.scalar_one_or_none()
if not user: return None
if user: # 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján)
now = datetime.now(timezone.utc) # Globális keresés, régiótól függetlenül
identity_stmt = select(Person).where(and_(
Person.mothers_last_name == kyc_in.mothers_last_name,
Person.mothers_first_name == kyc_in.mothers_first_name,
Person.birth_place == kyc_in.birth_place,
Person.birth_date == kyc_in.birth_date
))
existing_person = (await db.execute(identity_stmt)).scalar_one_or_none()
# --- DINAMIKUS JELSZÓ RESET LEJÁRAT --- if existing_person:
reset_hours = await config.get_setting( # Visszatérő identitás: A User-t a régi Person-hoz kötjük
"auth_password_reset_hours", user.person_id = existing_person.id
region_code=user.region_code, active_person = existing_person
default=2 logger.info(f"Shadow Identity linked: User {user_id} -> Person {existing_person.id}")
else:
active_person = user.person
# 3. Címkezelés
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=kyc_in.address_zip,
city=kyc_in.address_city,
street_name=kyc_in.address_street_name,
street_type=kyc_in.address_street_type,
house_number=kyc_in.address_house_number,
parcel_id=kyc_in.address_hrsz
) )
# ... (Rate limit ellenőrzés marad változatlan) ... # 4. Person adatok frissítése (mindig a legfrissebbet tároljuk)
active_person.mothers_last_name = kyc_in.mothers_last_name
active_person.mothers_first_name = kyc_in.mothers_first_name
active_person.birth_place = kyc_in.birth_place
active_person.birth_date = kyc_in.birth_date
active_person.phone = kyc_in.phone_number
active_person.address_id = addr_id
active_person.identity_docs = jsonable_encoder(kyc_in.identity_docs)
active_person.ice_contact = jsonable_encoder(kyc_in.ice_contact)
active_person.is_active = True
# 5. Új, izolált INDIVIDUAL szervezet (4.2.3)
new_org = Organization(
full_name=f"{active_person.last_name} {active_person.first_name} Egyéni Flotta",
name=f"{active_person.last_name} Flotta",
org_type=OrgType.individual,
owner_id=user.id,
is_transferable=False,
is_active=True,
status="verified"
)
db.add(new_org)
await db.flush()
# 6. Tagság és Jogosultságok
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
role="owner",
permissions={"can_add_asset": True, "can_view_costs": True, "is_admin": True}
))
# 7. Wallet & Stats (Friss kezdés 0 ponttal)
db.add(Wallet(user_id=user.id, coin_balance=0, credit_balance=0))
db.add(UserStats(user_id=user.id, total_xp=0, current_level=1))
# 8. Aktiválás
user.is_active = True
await db.commit()
await db.refresh(user)
return user
except Exception as e:
await db.rollback()
logger.error(f"KYC Atomi Tranzakció Hiba: {str(e)}")
raise e
@staticmethod
async def verify_email(db: AsyncSession, token_str: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).where(
and_(
VerificationToken.token == token_uuid,
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
res = await db.execute(stmt)
token = res.scalar_one_or_none()
if not token: return False
token.is_used = True
await db.commit()
return True
except:
return False
@staticmethod
async def authenticate(db: AsyncSession, email: str, password: str):
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
res = await db.execute(stmt)
user = res.scalar_one_or_none()
if user and verify_password(password, user.hashed_password):
return user
return None
@staticmethod
async def initiate_password_reset(db: AsyncSession, email: str):
# Csak aktív (nem törölt) felhasználónak engedünk jelszót resetelni
stmt = select(User).where(and_(User.email == email, User.is_deleted == False))
user = (await db.execute(stmt)).scalar_one_or_none()
if user:
reset_hours = await config.get_setting("auth_password_reset_hours", region_code=user.region_code, default=2)
token_val = uuid.uuid4() token_val = uuid.uuid4()
new_token = VerificationToken( db.add(VerificationToken(
token=token_val, token=token_val,
user_id=user.id, user_id=user.id,
token_type="password_reset", token_type="password_reset",
expires_at=now + timedelta(hours=int(reset_hours)) expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours))
) ))
db.add(new_token)
reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
await email_manager.send_email( await email_manager.send_email(
@@ -115,7 +219,30 @@ class AuthService:
) )
await db.commit() await db.commit()
return "success" return "success"
return "not_found" return "not_found"
# ... (többi metódus: verify_email, complete_kyc, authenticate, reset_password maradnak) ... @staticmethod
async def reset_password(db: AsyncSession, email: str, token_str: str, new_password: str):
try:
token_uuid = uuid.UUID(token_str)
stmt = select(VerificationToken).join(User).where(
and_(
User.email == email,
VerificationToken.token == token_uuid,
VerificationToken.token_type == "password_reset",
VerificationToken.is_used == False,
VerificationToken.expires_at > datetime.now(timezone.utc)
)
)
token_rec = (await db.execute(stmt)).scalar_one_or_none()
if not token_rec: return False
user_stmt = select(User).where(User.id == token_rec.user_id)
user = (await db.execute(user_stmt)).scalar_one()
user.hashed_password = get_password_hash(new_password)
token_rec.is_used = True
await db.commit()
return True
except:
return False

View File

@@ -184,3 +184,16 @@ A rendszer normalizált címkezelést alkalmaz az adatminőség biztosítása é
### 5.2 GIS Adatok ### 5.2 GIS Adatok
Minden telephely koordinátája `GEOGRAPHY(POINT, 4326)` típusként van tárolva, amely lehetővé teszi a PostGIS alapú távolságmérést. Minden telephely koordinátája `GEOGRAPHY(POINT, 4326)` típusként van tárolva, amely lehetővé teszi a PostGIS alapú távolságmérést.
### 5. Hibrid Címkezelési Modell
A rendszer az adatintegritás és a sebesség érdekében hibrid modellt használ.
- **Centralizált adattárolás**: A `data.addresses` tábla tárolja a normalizált címeket (UUID alapú).
- **Öntanuló szótárak**: A `data.geo_postal_codes` és `data.geo_streets` táblák automatikusan bővülnek minden új rögzítésnél.
- **Denormalizált GPS adatok**: Az `organization_locations` tábla közvetlenül tárolja a koordinátákat a JOIN-nélküli PostGIS lekérdezésekhez.
| Tábla | Funkció |
| :--- | :--- |
| `data.addresses` | Konkrét házszám szintű címek (Hibrid hivatkozási pont). |
| `data.geo_postal_codes` | Irányítószám és város kapcsolata (HU/EU támogatás). |
| `data.user_stats` | Felhasználói XP, szintek és strike-ok tárolása. |

View File

@@ -65,3 +65,14 @@ Az e-mail küldési folyamatokra az alábbi korlátok vonatkoznak:
- **Óránkénti Limit:** Maximum 3 kérelem / e-mail cím. - **Óránkénti Limit:** Maximum 3 kérelem / e-mail cím.
- **Napi Limit:** Maximum 10 kérelem / e-mail cím. - **Napi Limit:** Maximum 10 kérelem / e-mail cím.
- **Zárolás:** A napi limit túllépése esetén a fiók biztonsági okokból 24 órára zárolja a küldési funkciót az adott címre. - **Zárolás:** A napi limit túllépése esetén a fiók biztonsági okokból 24 órára zárolja a küldési funkciót az adott címre.
### 4. Geo és Kereső Végpontok
#### GET `/api/v1/services/suggest-street`
- **Cél**: Autocomplete támogatás a frontendnek.
- **Paraméterek**: `zip_code` (string), `q` (részleges utcanév).
#### GET `/api/v1/services/search`
- **Cél**: Kétlépcsős szervizkereső.
- **Free mód**: Légvonalbeli távolságmérés (Radius).
- **Premium mód**: Útvonal-idő alapú kalkuláció és forgalmi becslés.

View File

@@ -82,3 +82,9 @@ A fejlődés nem csak dicsőség, hanem gazdasági előny is.
### 6.3 Éves/Havi Szezonok ### 6.3 Éves/Havi Szezonok
Minden hónap végén az első 3 helyezett extra Kreditet vagy "Voucher"-t kap, amit a partnereinknél (szervizeknél) válthat be. Minden hónap végén az első 3 helyezett extra Kreditet vagy "Voucher"-t kap, amit a partnereinknél (szervizeknél) válthat be.
### 3. Jutalmazási Szabályok (Social Points)
- **Célcsoport**: Kizárólag természetes személyek (`role: user`, `driver`).
- **Kizárások**: Szervezetek (Organizations) és Adminisztrátorok nem gyűjtenek XP-t.
- **Logika**: Minden `PointsLedger` bejegyzés kötelezően hivatkozik egy `user_id`-ra.
- **Mezőnevek**: Adatbázis szinten a pontok az `id`, `user_id`, `points`, `reason` mezőkben tárolódnak.

View File

@@ -169,3 +169,44 @@ A fejlesztések rendben tartásához javaslom a **`17_DEVELOPER_NOTES_AND_PITFAL
DATA: Atomizált címmezők hozzáadva a data.organizations táblához. DATA: Atomizált címmezők hozzáadva a data.organizations táblához.
LOGIC: Háromszintű névkezelés (Hivatalos, Rövid, Display) bevezetve. LOGIC: Háromszintű névkezelés (Hivatalos, Rövid, Display) bevezetve.
## [2.1.0] - 2026-02-08
### Hozzáadva (Added)
- **Hibrid Címkezelő Rendszer**: Új `data.addresses` központi tábla, amely UUID alapú azonosítással köti össze a személyeket, cégeket és szervizeket.
- **Öntanuló GeoService**: A rendszer rögzítéskor automatikusan bővíti a ZIP-kód, város és utcanév szótárakat.
- **Autocomplete API**: `/api/v1/services/suggest-street` végpont a frontend gépelés közbeni támogatásához.
- **Kétlépcsős Keresés**: `/api/v1/services/search` végpont, amely megkülönbözteti a légvonalbeli (Free) és útvonal/idő alapú (Premium) kalkulációt.
- **PostGIS Integráció**: Tűpontos GPS távolságmérés `geography` casting használatával.
### Javítva (Fixed)
- **Gamifikációs hiba**: A `PointsLedger` modellben javítva a `points` mezőnév (TypeError: points_change fix).
- **Adatbázis Inkonzisztencia**: Létrehozva a hiányzó `data.user_stats` tábla.
- **Import hibák**: `Optional`, `UploadFile` és `File` hiányzó importok pótolva a szolgáltatás végpontokon.
### Változtatva (Changed)
- **Címkezelési Logika**: A cégek és magánszemélyek mostantól egységes, bontott címstruktúrát (zip, city, street, street_type, house_number, parcel_id) használnak.
- **XP Szűrés**: A szervizek és cégek rögzítésekor a rendszer mostantól csak a természetes személyeknek (User/Driver) oszt social pontokat.
## [2.1.0] - 2026-02-08
- **Feat**: Hibrid címkezelő rendszer bevezetése (UUID alapú `data.addresses`).
- **Feat**: Öntanuló Geo-logika (Auto-create ZIP/Street).
- **Feat**: Kétlépcsős (Free/Premium) szervizkereső API.
- **Fix**: `points_ledger` mezőnév szinkronizáció.
- **Fix**: `data.user_stats` tábla inicializálása.
## [2026-02-08] - Nemzetközi Asset és KYC Stabilizáció
### Hozzáadva
- **Nemzetközi paraméterek**: `mileage_unit` (km/miles/hours) és `reading_unit` támogatása az Asset modellben.
- **Pénzügyi alapok**: `AssetCost` modell bővítése nettó/bruttó összeggel, ÁFA kulccsal és deviza-kezeléssel.
- **Árfolyamkezelés**: `exchange_rates` tábla implementálása MNB/ECB alapú napi frissítéshez.
- **Értékelési rendszer**: `ratings` tábla létrehozva (user, asset, service_provider szinten).
### Javítva
- **KYC Folyamat**: Anyja neve bontása vezetéknévre és keresztnévre a magyar/nemzetközi szabványok szerint.
- **Database Schema**: Számos hiányzó oszlop pótolva (`asset_events.data`, `asset_events.event_date`, `user_stats.total_xp`).
- **SQLAlchemy hiba**: Javítva az `IndexError` a katalógus lekérdezésnél és az import hiba a `PG_UUID` kapcsán.
### Megjegyzés
A rendszer most már képes egyetlen KYC folyamat alatt aktiválni a felhasználót, létrehozni az egyéni flottáját, inicializálni a pénztárcáját (Kredit/Coin) és rögzíteni az első járművét kezdő km-óra állással.

View File

@@ -174,3 +174,16 @@ Minden telephely rögzítésekor az alábbi bontott címadatok kötelezőek:
### 4.2 Validációs Folyamat ### 4.2 Validációs Folyamat
A rögzített címek automatikusan bekerülnek a Master Geo adatbázisba, építve a rendszer globális címjegyzékét. A rögzített címek automatikusan bekerülnek a Master Geo adatbázisba, építve a rendszer globális címjegyzékét.
## 5. Járművek és Költségek (MVP)
A járműadatok kezelése hibrid módon történik.
### 5.1 Jármű Katalógus
- A rendszer egy központi katalógust (`asset_catalog`) épít.
- Új rögzítéskor a rendszer először a katalógusból kínál fel opciókat (Dropdown).
- Ha a modell nem létezik, a rendszer automatikusan felveszi (Self-learning catalog).
### 5.2 Költségkövetés (TCO)
- Minden Asset-hez rögzíthető költség (`asset_costs`).
- Kötelező adatok: Kategória, Összeg, Dátum.
- Opcionális: Km óra állása (az amortizáció és szervizintervallum számításához).