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:
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -49,7 +54,4 @@ async def get_current_user(
|
||||
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
|
||||
Binary file not shown.
Binary file not shown.
@@ -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ó
|
||||
if not VINValidator.validate(asset_in.vin):
|
||||
raise HTTPException(status_code=400, detail="Érvénytelen alvázszám (VIN) formátum!")
|
||||
|
||||
# 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."
|
||||
# 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
|
||||
|
||||
# 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()
|
||||
# 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()
|
||||
# 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 org_item:
|
||||
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
|
||||
if not new_asset:
|
||||
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
|
||||
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()
|
||||
|
||||
# 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.refresh(new_asset)
|
||||
|
||||
# 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))
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
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.")
|
||||
@@ -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()
|
||||
|
||||
BIN
backend/app/db/__pycache__/base.cpython-312.pyc
Executable file → Normal file
BIN
backend/app/db/__pycache__/base.cpython-312.pyc
Executable file → Normal file
Binary file not shown.
BIN
backend/app/db/__pycache__/base_class.cpython-312.pyc
Normal file
BIN
backend/app/db/__pycache__/base_class.cpython-312.pyc
Normal file
Binary file not shown.
@@ -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
|
||||
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
|
||||
13
backend/app/db/base_class.py
Normal file
13
backend/app/db/base_class.py
Normal 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()
|
||||
@@ -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",
|
||||
"AssetCatalog",
|
||||
"AssetCost",
|
||||
"AssetEvent",
|
||||
"AssetRating",
|
||||
"ServiceProvider",
|
||||
"Address",
|
||||
"GeoPostalCode",
|
||||
"GeoStreet",
|
||||
"GeoStreetType",
|
||||
"UserStats",
|
||||
"PointsLedger",
|
||||
"Vehicle",
|
||||
"UserVehicle",
|
||||
"ServiceRecord",
|
||||
"VehicleOwnership"
|
||||
"VehicleCatalog",
|
||||
"ServiceRecord"
|
||||
]
|
||||
Binary file not shown.
BIN
backend/app/models/__pycache__/address.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/address.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/asset.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/asset.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
45
backend/app/models/address.py
Normal file
45
backend/app/models/address.py
Normal 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
133
backend/app/models/asset.py
Normal 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())
|
||||
@@ -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())
|
||||
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,26 +1,42 @@
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
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
|
||||
|
||||
if user:
|
||||
now = datetime.now(timezone.utc)
|
||||
# 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()
|
||||
|
||||
# --- DINAMIKUS JELSZÓ RESET LEJÁRAT ---
|
||||
reset_hours = await config.get_setting(
|
||||
"auth_password_reset_hours",
|
||||
region_code=user.region_code,
|
||||
default=2
|
||||
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) ...
|
||||
@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
|
||||
@@ -184,3 +184,16 @@ A rendszer normalizált címkezelést alkalmaz az adatminőség biztosítása é
|
||||
|
||||
### 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.
|
||||
|
||||
### 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. |
|
||||
@@ -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.
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
@@ -82,3 +82,9 @@ A fejlődés nem csak dicsőség, hanem gazdasági előny is.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
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).
|
||||
Reference in New Issue
Block a user