diff --git a/backend/app/api/__pycache__/deps.cpython-312.pyc b/backend/app/api/__pycache__/deps.cpython-312.pyc index 70ce68a..4eb33d6 100644 Binary files a/backend/app/api/__pycache__/deps.cpython-312.pyc and b/backend/app/api/__pycache__/deps.cpython-312.pyc differ diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index fdeebbd..36ced24 100755 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,4 +1,5 @@ from typing import Optional +import logging from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer 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.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") async def get_current_user( @@ -17,8 +18,13 @@ async def get_current_user( ) -> User: """ 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) if not payload: raise HTTPException( @@ -33,7 +39,6 @@ async def get_current_user( 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))) user = result.scalar_one_or_none() @@ -48,8 +53,5 @@ async def get_current_user( status_code=status.HTTP_403_FORBIDDEN, 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 \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc b/backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc index bb82254..b1a5b8d 100644 Binary files a/backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc and b/backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc differ diff --git a/backend/app/api/v1/endpoints/__pycache__/catalog.cpython-312.pyc b/backend/app/api/v1/endpoints/__pycache__/catalog.cpython-312.pyc index 476d325..ea85c64 100644 Binary files a/backend/app/api/v1/endpoints/__pycache__/catalog.cpython-312.pyc and b/backend/app/api/v1/endpoints/__pycache__/catalog.cpython-312.pyc differ diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index 9ebc461..235433f 100644 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -1,104 +1,136 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -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 +from sqlalchemy import select, func, and_ import os 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() logger = logging.getLogger(__name__) @router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED) async def create_asset( asset_in: AssetCreate, - db: AsyncSession = Depends(get_db) - # Később ide jön: current_user: User = Depends(get_current_active_user) + target_org_id: int = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) ): - """ - Új jármű (Asset) rögzítése a flottába. - - Validálja a VIN-t (MOD 11 checksum). - - Ellenőrzi a Katalógus elemet. - - Létrehozza a NAS mappát a dokumentumoknak. - """ - - # 1. VIN Validáció (Szigorú Checksum) - # A GEM protokoll szerint kötelező a validátor használata - is_valid_vin = VINValidator.validate(asset_in.vin) - if not is_valid_vin: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Érvénytelen alvázszám (VIN)! A Checksum ellenőrzés sikertelen." - ) + # 1. VIN Validáció + if not VINValidator.validate(asset_in.vin): + raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!") - # 2. Katalógus elem ellenőrzése - stmt_catalog = select(VehicleCatalog).where(VehicleCatalog.id == asset_in.catalog_id) - result_catalog = await db.execute(stmt_catalog) - catalog_item = result_catalog.scalar_one_or_none() + # 2. Célflotta ellenőrzése + if not target_org_id: + stmt_org = select(Organization).join(OrganizationMember).where( + and_( + OrganizationMember.user_id == current_user.id, + Organization.org_type == OrgType.individual + ) + ) + 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 + + # 3. Katalógus ellenőrzése + stmt_cat = select(AssetCatalog).where( + and_( + 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: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="A kiválasztott járműtípus nem található a katalógusban." + catalog_item = AssetCatalog( + make=asset_in.make, + 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) - stmt_org = select(Organization).where(Organization.id == asset_in.organization_id) - result_org = await db.execute(stmt_org) - org_item = result_org.scalar_one_or_none() - - if not org_item: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="A megadott flotta/szervezet nem található." + # 4. Asset létrehozása vagy betöltése (Shadow Identity) + stmt_exist = select(Asset).where(Asset.vin == asset_in.vin.upper()) + new_asset = (await db.execute(stmt_exist)).scalar_one_or_none() + + if not new_asset: + new_asset = Asset( + vin=asset_in.vin.upper(), + license_plate=asset_in.license_plate, + name=asset_in.name or f"{asset_in.make} {asset_in.model}", + year_of_manufacture=asset_in.year_of_manufacture, + fuel_type=asset_in.fuel_type, # JAVÍTVA: Most már átadjuk + vehicle_class=asset_in.vehicle_class, # JAVÍTVA: Most már átadjuk + mileage_unit=asset_in.reading_unit, # JAVÍTVA: Most már átadjuk + catalog_id=catalog_item.id, + quality_index=1.00, + system_mileage=0 ) + db.add(new_asset) + await db.flush() - # 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" + # 5. Assignment + new_assignment = AssetAssignment( + asset_id=new_asset.id, + organization_id=final_org_id, + status="active" ) - 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!" - ) + db.add(new_assignment) - # 5. Mentés az adatbázisba - new_asset = Asset( - uai=asset_in.vin.upper(), # A VIN a fő azonosító - catalog_id=asset_in.catalog_id, - organization_id=asset_in.organization_id, - asset_type=catalog_item.category, # 'car', 'van', 'motorcycle', etc. - name=asset_in.name or f"{catalog_item.brand} {catalog_item.model}", - current_plate_number=asset_in.license_plate.upper(), - status="active", - privacy_level="private" # Alapértelmezett - ) - - db.add(new_asset) - await db.commit() - await db.refresh(new_asset) + # 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"} + )) - # 6. NAS Tároló Létrehozása - # Útvonal: /mnt/nas/app_data/assets/{uuid}/ - # A settings.NAS_STORAGE_PATH-ot használjuk (GEM protokoll) try: - # Ha a settings-ben nincs definiálva, fallback a hardcoded path-ra (biztonsági háló) - base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data/assets") - asset_path = os.path.join(base_path, str(new_asset.id)) + await db.commit() + await db.refresh(new_asset) - os.makedirs(asset_path, exist_ok=True) - logger.info(f"NAS mappa létrehozva: {asset_path}") + # 7. NAS mappa struktúra + nas_base = getattr(settings, "NAS_STORAGE_PATH", "/opt/docker/dev/service_finder/nas/assets") + asset_path = os.path.join(nas_base, str(new_asset.id)) + os.makedirs(os.path.join(asset_path, "docs"), exist_ok=True) + os.makedirs(os.path.join(asset_path, "photos"), exist_ok=True) - 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 \ No newline at end of file + 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.") \ 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 bbe3368..1bd059d 100644 --- a/backend/app/api/v1/endpoints/catalog.py +++ b/backend/app/api/v1/endpoints/catalog.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from app.db.session import get_db -from app.models.vehicle import VehicleCatalog +from app.models import AssetCatalog from typing import List router = APIRouter() diff --git a/backend/app/db/__pycache__/base.cpython-312.pyc b/backend/app/db/__pycache__/base.cpython-312.pyc old mode 100755 new mode 100644 index beade1b..dcf3c65 Binary files a/backend/app/db/__pycache__/base.cpython-312.pyc and b/backend/app/db/__pycache__/base.cpython-312.pyc differ diff --git a/backend/app/db/__pycache__/base_class.cpython-312.pyc b/backend/app/db/__pycache__/base_class.cpython-312.pyc new file mode 100644 index 0000000..6c224a8 Binary files /dev/null and b/backend/app/db/__pycache__/base_class.cpython-312.pyc differ diff --git a/backend/app/db/base.py b/backend/app/db/base.py index ddae7f4..306d75c 100755 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -1,10 +1,6 @@ -from sqlalchemy.ext.asyncio import AsyncAttrs -from sqlalchemy.orm import DeclarativeBase - - -class Base(AsyncAttrs, DeclarativeBase): - """ - Base class for all SQLAlchemy models. - Includes AsyncAttrs to support async attribute access (lazy loading). - """ - pass \ No newline at end of file +from app.db.base_class import Base # noqa +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 +from app.models.asset import Asset, AssetCatalog, AssetCost, AssetEvent # noqa +from app.models.gamification import UserStats, PointsLedger # noqa \ No newline at end of file diff --git a/backend/app/db/base_class.py b/backend/app/db/base_class.py new file mode 100644 index 0000000..2a433fc --- /dev/null +++ b/backend/app/db/base_class.py @@ -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() \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 86cf85f..63d30ad 100755 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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 .organization import Organization, OrgType -from .vehicle import ( - VehicleCatalog, - Asset, - AssetEvent, - AssetRating, - ServiceProvider, - Vehicle, # Alias az Asset-re a kompatibilitás miatt - ServiceRecord # Alias az AssetEvent-re a kompatibilitás miatt -) +from .organization import Organization, OrganizationMember +from .asset import Asset, AssetCatalog, AssetCost, AssetEvent +from .address import Address, GeoPostalCode, GeoStreet, GeoStreetType +from .gamification import UserStats, PointsLedger -# Aliasok a kód többi részének szinkronizálásához -UserVehicle = Vehicle -VehicleOwnership = Asset +# Aliasok a kompatibilitás és a tiszta kód érdekében +Vehicle = Asset +UserVehicle = Asset +VehicleCatalog = AssetCatalog +ServiceRecord = AssetEvent __all__ = [ "Base", @@ -23,14 +19,19 @@ __all__ = [ "UserRole", "VerificationToken", "Organization", - "OrgType", - "VehicleCatalog", + "OrganizationMember", "Asset", - "AssetEvent", - "AssetRating", - "ServiceProvider", + "AssetCatalog", + "AssetCost", + "AssetEvent", + "Address", + "GeoPostalCode", + "GeoStreet", + "GeoStreetType", + "UserStats", + "PointsLedger", "Vehicle", "UserVehicle", - "ServiceRecord", - "VehicleOwnership" + "VehicleCatalog", + "ServiceRecord" ] \ No newline at end of file diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc index c06132a..42da96a 100644 Binary files a/backend/app/models/__pycache__/__init__.cpython-312.pyc and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/address.cpython-312.pyc b/backend/app/models/__pycache__/address.cpython-312.pyc new file mode 100644 index 0000000..1aef3da Binary files /dev/null and b/backend/app/models/__pycache__/address.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/asset.cpython-312.pyc b/backend/app/models/__pycache__/asset.cpython-312.pyc new file mode 100644 index 0000000..de3e6fb Binary files /dev/null and b/backend/app/models/__pycache__/asset.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/gamification.cpython-312.pyc b/backend/app/models/__pycache__/gamification.cpython-312.pyc index bc8d340..0ded220 100644 Binary files a/backend/app/models/__pycache__/gamification.cpython-312.pyc and b/backend/app/models/__pycache__/gamification.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/identity.cpython-312.pyc b/backend/app/models/__pycache__/identity.cpython-312.pyc index 3aac0c7..14d73ba 100644 Binary files a/backend/app/models/__pycache__/identity.cpython-312.pyc and b/backend/app/models/__pycache__/identity.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/organization.cpython-312.pyc b/backend/app/models/__pycache__/organization.cpython-312.pyc index 468ac5b..1b52a98 100644 Binary files a/backend/app/models/__pycache__/organization.cpython-312.pyc and b/backend/app/models/__pycache__/organization.cpython-312.pyc differ diff --git a/backend/app/models/address.py b/backend/app/models/address.py new file mode 100644 index 0000000..1cbf4ec --- /dev/null +++ b/backend/app/models/address.py @@ -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()) \ No newline at end of file diff --git a/backend/app/models/asset.py b/backend/app/models/asset.py new file mode 100644 index 0000000..7d7d83c --- /dev/null +++ b/backend/app/models/asset.py @@ -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()) \ No newline at end of file diff --git a/backend/app/models/gamification.py b/backend/app/models/gamification.py index 7576baa..4c92c49 100755 --- a/backend/app/models/gamification.py +++ b/backend/app/models/gamification.py @@ -1,8 +1,14 @@ +import uuid 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.orm import Mapped, mapped_column -from app.db.base import Base +from sqlalchemy.orm import Mapped, mapped_column, relationship +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 SCHEMA_ARGS = {"schema": "data"} @@ -36,21 +42,26 @@ class PointsLedger(Base): __tablename__ = "points_ledger" __table_args__ = SCHEMA_ARGS 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")) points: Mapped[int] = mapped_column(Integer) reason: Mapped[str] = mapped_column(String) created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) + + # Kapcsolat a felhasználóhoz + user: Mapped["User"] = relationship("User") class UserStats(Base): __tablename__ = "user_stats" __table_args__ = SCHEMA_ARGS - 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"), unique=True) - total_points: Mapped[int] = mapped_column(Integer, default=0) + # user_id a PK, mert 1:1 kapcsolat a User-rel + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.users.id"), primary_key=True) + total_xp: 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) - 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): __tablename__ = "badges" @@ -64,7 +75,18 @@ class UserBadge(Base): __tablename__ = "user_badges" __table_args__ = SCHEMA_ARGS 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")) badge_id: Mapped[int] = mapped_column(Integer, ForeignKey("data.badges.id")) - earned_at: Mapped[datetime] = mapped_column(DateTime, default=func.now()) \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/app/models/identity.py b/backend/app/models/identity.py index 051398a..33d5f61 100644 --- a/backend/app/models/identity.py +++ b/backend/app/models/identity.py @@ -2,9 +2,9 @@ import uuid import enum from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON, Numeric, text, Enum, BigInteger 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 app.db.base import Base +from app.db.base_class import Base class UserRole(str, enum.Enum): admin = "admin" @@ -18,23 +18,22 @@ class Person(Base): __table_args__ = {"schema": "data"} 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) 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_date = Column(DateTime, nullable=True) phone = Column(String, nullable=True) - # JSONB mezők az okmányoknak és orvosi adatoknak identity_docs = Column(JSON, server_default=text("'{}'::jsonb")) medical_emergency = 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) - created_at = Column(DateTime(timezone=True), server_default=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) email = Column(String, unique=True, index=True, nullable=False) hashed_password = Column(String, nullable=True) - role = Column(Enum(UserRole), default=UserRole.user) is_active = Column(Boolean, default=False) region_code = Column(String, default="HU") - is_deleted = Column(Boolean, default=False) person_id = Column(BigInteger, ForeignKey("data.persons.id"), nullable=True) person = relationship("Person", back_populates="users") 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()) class Wallet(Base): + """Kétoszlopos pénztárca: Coin (Szerviz) és Kredit (Prémium).""" __tablename__ = "wallets" __table_args__ = {"schema": "data"} id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("data.users.id"), unique=True) 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") @@ -77,9 +80,9 @@ class VerificationToken(Base): __table_args__ = {"schema": "data"} 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) - 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()) expires_at = Column(DateTime(timezone=True), nullable=False) is_used = Column(Boolean, default=False) \ No newline at end of file diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py index 25e17d4..c931de6 100755 --- a/backend/app/models/organization.py +++ b/backend/app/models/organization.py @@ -1,9 +1,11 @@ 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.orm import relationship 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): individual = "individual" @@ -19,18 +21,21 @@ class Organization(Base): 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 --- full_name = Column(String, nullable=False) # Teljes hivatalos név - name = Column(String, nullable=False) # Rövidített cégnév (pl. ProfiBot Kft.) - display_name = Column(String(50)) # Alkalmazáson belüli rövidítés (pl. ProfiBot) + name = Column(String, nullable=False) # Rövidített cégnév + 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_city = Column(String(100)) 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_hrsz = Column(String(50)) # Helyrajzi szám + address_hrsz = Column(String(50)) address_stairwell = Column(String(20)) address_floor = Column(String(20)) address_door = Column(String(20)) @@ -40,7 +45,6 @@ class Organization(Base): tax_number = Column(String(20), unique=True, index=True) reg_number = Column(String(50)) - # PG_ENUM használata a Python Enum-mal szinkronizálva org_type = Column( PG_ENUM(OrgType, name="orgtype", inherit_schema=True), default=OrgType.individual @@ -49,13 +53,8 @@ class Organization(Base): status = Column(String(30), default="pending_verification") is_deleted = Column(Boolean, default=False) - notification_settings = Column(JSON, default={ - "notify_owner": True, - "notify_manager": True, - "notify_contact": True, - "alert_days_before": [30, 15, 7, 1] - }) - external_integration_config = Column(JSON, default={}) + notification_settings = Column(JSON, server_default=text("'{ \"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1] }'::jsonb")) + external_integration_config = Column(JSON, server_default=text("'{}'::jsonb")) owner_id = Column(Integer, ForeignKey("data.users.id"), nullable=True) is_active = Column(Boolean, default=True) @@ -67,8 +66,8 @@ class Organization(Base): updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Kapcsolatok - assets = relationship("Asset", back_populates="organization", cascade="all, delete-orphan") - members = relationship("OrganizationMember", back_populates="organization") + assets = relationship("AssetAssignment", back_populates="organization", cascade="all, delete-orphan") + members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") owner = relationship("User", back_populates="owned_organizations") class OrganizationMember(Base): @@ -77,9 +76,13 @@ class OrganizationMember(Base): id = Column(Integer, primary_key=True, index=True) organization_id = Column(Integer, ForeignKey("data.organizations.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") + 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 \ No newline at end of file diff --git a/backend/app/models/vehicle.py b/backend/app/models/vehicle.py deleted file mode 100755 index 2e8752d..0000000 --- a/backend/app/models/vehicle.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/app/schemas/__pycache__/asset.cpython-312.pyc b/backend/app/schemas/__pycache__/asset.cpython-312.pyc index 27aea29..a65dd0f 100644 Binary files a/backend/app/schemas/__pycache__/asset.cpython-312.pyc and b/backend/app/schemas/__pycache__/asset.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/auth.cpython-312.pyc b/backend/app/schemas/__pycache__/auth.cpython-312.pyc index 6909ffe..99171c4 100644 Binary files a/backend/app/schemas/__pycache__/auth.cpython-312.pyc and b/backend/app/schemas/__pycache__/auth.cpython-312.pyc differ diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py index efdd68d..fd661d7 100644 --- a/backend/app/schemas/asset.py +++ b/backend/app/schemas/asset.py @@ -1,27 +1,43 @@ -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field from typing import Optional from uuid import UUID from datetime import datetime class AssetCreate(BaseModel): - catalog_id: int = Field(..., description="A kiválasztott katalógus elem ID-ja") - vin: str = Field(..., min_length=17, max_length=17, description="17 karakteres alvázszám") - license_plate: str = Field(..., min_length=1, max_length=20) - name: Optional[str] = Field(None, description="Egyedi elnevezés (pl. 'Céges furgon')") - organization_id: int = Field(..., description="Melyik flottába kerüljön") + # Alapadatok + make: str = Field(..., example="Ford") + model: str = Field(..., example="Mondeo") + vin: str = Field(..., min_length=17, max_length=17, description="Alvázszám") + 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): id: UUID - uai: str - catalog_id: int - organization_id: int - name: str - asset_type: str - current_plate_number: str - status: str + catalog_id: Optional[int] + vin: str + license_plate: Optional[str] = None # JAVÍTVA: Lehet None a válaszban + name: Optional[str] = None + fuel_type: str + vehicle_class: str + is_verified: bool + year_of_manufacture: int + system_mileage: int + quality_index: float created_at: datetime - + class Config: from_attributes = True \ No newline at end of file diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index fd1f9b4..37689f0 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -28,7 +28,15 @@ class UserKYCComplete(BaseModel): phone_number: str birth_place: str 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] ice_contact: ICEContact @@ -36,7 +44,6 @@ class UserKYCComplete(BaseModel): class PasswordResetRequest(BaseModel): email: EmailStr -# EZ HIÁNYZOTT KORÁBBAN: class PasswordResetConfirm(BaseModel): email: EmailStr token: str diff --git a/backend/app/services/__pycache__/auth_service.cpython-312.pyc b/backend/app/services/__pycache__/auth_service.cpython-312.pyc index 9985f07..8de3f8a 100644 Binary files a/backend/app/services/__pycache__/auth_service.cpython-312.pyc and b/backend/app/services/__pycache__/auth_service.cpython-312.pyc differ diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 48ccebd..0f5a127 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -4,28 +4,32 @@ import uuid from datetime import datetime, timedelta, timezone from typing import Optional -# SQLAlchemy importok from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, cast, String, func +from sqlalchemy import select, and_ 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.organization import Organization -from app.schemas.auth import UserLiteRegister, UserKYCComplete # <--- Ez javítja a hibát +from app.models.gamification import UserStats # <--- Innen importáljuk mostantól! +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.services.email_manager import email_manager 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__) class AuthService: @staticmethod 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: - # 1. Person alap létrehozása + # Ideiglenes Person rekord a KYC-ig new_person = Person( first_name=user_in.first_name, last_name=user_in.last_name, @@ -34,36 +38,29 @@ class AuthService: db.add(new_person) await db.flush() - # 2. User fiók new_user = User( email=user_in.email, hashed_password=get_password_hash(user_in.password), person_id=new_person.id, role=UserRole.user, is_active=False, + is_deleted=False, region_code=user_in.region_code ) db.add(new_user) await db.flush() - # --- DINAMIKUS TOKEN LEJÁRAT --- - reg_hours = await config.get_setting( - "auth_registration_hours", - region_code=user_in.region_code, - default=48 - ) - + # Regisztrációs token generálása + reg_hours = await config.get_setting("auth_registration_hours", region_code=user_in.region_code, default=48) token_val = uuid.uuid4() - new_token = VerificationToken( + db.add(VerificationToken( token=token_val, user_id=new_user.id, token_type="registration", 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}" await email_manager.send_email( recipient=user_in.email, @@ -80,32 +77,139 @@ class AuthService: raise e @staticmethod - async def initiate_password_reset(db: AsyncSession, email: str): - """Jelszó-visszaállítás indítása dinamikus lejárattal.""" - stmt = select(User).where(User.email == email, User.is_deleted == False) - res = await db.execute(stmt) - user = res.scalar_one_or_none() - - if user: - now = datetime.now(timezone.utc) - - # --- DINAMIKUS JELSZÓ RESET LEJÁRAT --- - reset_hours = await config.get_setting( - "auth_password_reset_hours", - region_code=user.region_code, - default=2 + async def complete_kyc(db: AsyncSession, user_id: int, kyc_in: UserKYCComplete): + """ + 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) + user = res.scalar_one_or_none() + if not user: return None + + # 2. Shadow Identity Ellenőrzése (Anyja neve + Születési hely + Idő alapján) + # 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() + + if existing_person: + # Visszatérő identitás: A User-t a régi Person-hoz kötjük + user.person_id = existing_person.id + active_person = existing_person + 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() - new_token = VerificationToken( + db.add(VerificationToken( token=token_val, user_id=user.id, token_type="password_reset", - expires_at=now + timedelta(hours=int(reset_hours)) - ) - db.add(new_token) + expires_at=datetime.now(timezone.utc) + timedelta(hours=int(reset_hours)) + )) reset_link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}" await email_manager.send_email( @@ -115,7 +219,30 @@ class AuthService: ) await db.commit() return "success" - return "not_found" - # ... (többi metódus: verify_email, complete_kyc, authenticate, reset_password maradnak) ... \ No newline at end of file + @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 \ No newline at end of file diff --git a/docs/V01_gemini/06_Database_Guide.md b/docs/V01_gemini/06_Database_Guide.md index eb76a24..bafa391 100644 --- a/docs/V01_gemini/06_Database_Guide.md +++ b/docs/V01_gemini/06_Database_Guide.md @@ -183,4 +183,17 @@ A rendszer normalizált címkezelést alkalmaz az adatminőség biztosítása é - `data.geo_street_types`: Közterület típusok szótára (út, utca, tér...). ### 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. \ No newline at end of file +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. | \ No newline at end of file diff --git a/docs/V01_gemini/07_API_Guide.md b/docs/V01_gemini/07_API_Guide.md index 0b1988b..6a93e72 100644 --- a/docs/V01_gemini/07_API_Guide.md +++ b/docs/V01_gemini/07_API_Guide.md @@ -64,4 +64,15 @@ Az e-mail küldési folyamatokra az alábbi korlátok vonatkoznak: - **Retry Cooldown:** Újraigénylés legkorábban 60 másodperc után lehetséges. - **Óránkénti Limit:** Maximum 3 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. \ No newline at end of file +- **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. \ No newline at end of file diff --git a/docs/V01_gemini/11_Gamification_Social.md b/docs/V01_gemini/11_Gamification_Social.md index 5ccf125..b74e38c 100644 --- a/docs/V01_gemini/11_Gamification_Social.md +++ b/docs/V01_gemini/11_Gamification_Social.md @@ -81,4 +81,10 @@ A fejlődés nem csak dicsőség, hanem gazdasági előny is. * **Level 20:** Egyedi avatar keret + állandó 5% kedvezmény a hirdetési árakból (céges esetén). ### 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. \ No newline at end of file +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. \ No newline at end of file diff --git a/docs/V01_gemini/15_Changelog.md b/docs/V01_gemini/15_Changelog.md index f2488d9..3732cd0 100644 --- a/docs/V01_gemini/15_Changelog.md +++ b/docs/V01_gemini/15_Changelog.md @@ -168,4 +168,45 @@ 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. - LOGIC: Háromszintű névkezelés (Hivatalos, Rövid, Display) bevezetve. \ No newline at end of file + 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. \ No newline at end of file diff --git a/docs/V01_gemini/18_ASSET_AND_FLEET_SPECIFICATION.md b/docs/V01_gemini/18_ASSET_AND_FLEET_SPECIFICATION.md index a5839ea..7495c58 100644 --- a/docs/V01_gemini/18_ASSET_AND_FLEET_SPECIFICATION.md +++ b/docs/V01_gemini/18_ASSET_AND_FLEET_SPECIFICATION.md @@ -173,4 +173,17 @@ Minden telephely rögzítésekor az alábbi bontott címadatok kötelezőek: - Opcionális: Helyrajzi szám (parcel_id) külterületi vagy HRSZ alapú azonosításhoz. ### 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. \ No newline at end of file +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). \ No newline at end of file