FEAT: Corporate onboarding implemented with Tax ID validation (HU) and isolated NAS storage

This commit is contained in:
2026-02-07 13:42:46 +00:00
parent c59c441a40
commit 7249aa5809
23 changed files with 399 additions and 29 deletions

View File

@@ -1,10 +1,16 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, catalog # <--- Hozzáadtuk a catalog-ot
from app.api.v1.endpoints import auth, catalog, assets, organizations
api_router = APIRouter()
# Autentikáció és KYC végpontok
# Felhasználó és Identitás
api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
# Jármű Katalógus és Robot kereső végpontok
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
# Katalógus és Jármű Robotok
api_router.include_router(catalog.router, prefix="/catalog", tags=["Vehicle Catalog"])
# Egyedi Eszközök (Assets)
api_router.include_router(assets.router, prefix="/assets", tags=["Assets"])
# Szervezetek és Onboarding
api_router.include_router(organizations.router, prefix="/organizations", tags=["Organizations"])

View File

@@ -0,0 +1,104 @@
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
import os
import logging
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)
):
"""
Ú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."
)
# 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()
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."
)
# 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 Duplikáció ellenőrzése (Ugyanaz a VIN nem szerepelhet 2x aktívként)
# Megj: A UAI mező tárolja a VIN-t (Unique Asset Identifier)
stmt_exist = select(Asset).where(
Asset.uai == asset_in.vin.upper(),
Asset.status != "deleted"
)
result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ez a jármű (VIN) már szerepel a rendszerben!"
)
# 5. Mentés az adatbázisba
new_asset = Asset(
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. 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
return new_asset

View File

@@ -0,0 +1,70 @@
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.organization import CorpOnboardIn, CorpOnboardResponse
from app.models.organization import Organization, OrgType
from app.core.config import settings
import os
import re
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/onboard", response_model=CorpOnboardResponse, status_code=status.HTTP_201_CREATED)
async def onboard_organization(
org_in: CorpOnboardIn,
db: AsyncSession = Depends(get_db)
):
"""
Új szervezet (cég/szerviz) rögzítése.
- Magyar adószám validáció (XXXXXXXX-Y-ZZ).
- Duplikáció ellenőrzés adószám alapján.
- NAS mappa és DB rekord létrehozása.
"""
# 1. Magyar adószám validáció
if org_in.country_code == "HU":
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Érvénytelen magyar adószám formátum! (Példa: 12345678-1-12)"
)
# 2. Duplikáció ellenőrzés
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ezzel az adószámmal már regisztráltak céget!"
)
# 3. Mentés (Dinamikus státusszal és kisbetűs Enummal)
new_org = Organization(
name=org_in.name,
tax_number=org_in.tax_number,
reg_number=org_in.reg_number,
headquarters_address=org_in.headquarters_address,
country_code=org_in.country_code,
org_type=OrgType.business, # Most már kisbetűs 'business' kerül beküldésre
status="pending_verification"
)
db.add(new_org)
await db.flush() # ID generálás a NAS-hoz
# 4. NAS Mappa létrehozása
try:
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
org_path = os.path.join(base_path, "organizations", str(new_org.id))
os.makedirs(org_path, exist_ok=True)
logger.info(f"NAS mappa létrehozva szervezetnek: {org_path}")
except Exception as e:
logger.error(f"NAS hiba az onboardingnál: {e}")
await db.commit()
await db.refresh(new_org)
return {"organization_id": new_org.id, "status": new_org.status}

View File

@@ -0,0 +1,47 @@
import re
class VINValidator:
@staticmethod
def validate(vin: str) -> bool:
"""VIN (Vehicle Identification Number) ellenőrzése ISO 3779 szerint."""
vin = vin.upper().strip()
# Alapvető formátum: 17 karakter, tiltott betűk (I, O, Q) nélkül
if not re.match(r"^[A-Z0-9]{17}$", vin) or any(c in vin for c in "IOQ"):
return False
# Karakterértékek táblázata
values = {
'A':1, 'B':2, 'C':3, 'D':4, 'E':5, 'F':6, 'G':7, 'H':8, 'J':1, 'K':2, 'L':3, 'M':4,
'N':5, 'P':7, 'R':9, 'S':2, 'T':3, 'U':4, 'V':5, 'W':6, 'X':7, 'Y':8, 'Z':9,
'0':0, '1':1, '2':2, '3':3, '4':4, '5':5, '6':6, '7':7, '8':8, '9':9
}
# Súlyozás a pozíciók alapján
weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
try:
# 1. Összegzés: érték * súly
total = sum(values[vin[i]] * weights[i] for i in range(17))
# 2. Maradék számítás 11-el
check_digit = total % 11
# 3. A 10-es maradékot 'X'-nek jelöljük
expected = 'X' if check_digit == 10 else str(check_digit)
# 4. Összevetés a 9. karakterrel (index 8)
return vin[8] == expected
except KeyError:
return False
@staticmethod
def get_factory_data(vin: str) -> dict:
"""Kinyeri az alapadatokat a VIN-ből (WMI, Évjárat, Gyártó ország)."""
# Ez a 'Mágikus Gomb' alapja
countries = {"1": "USA", "2": "Kanada", "J": "Japán", "W": "Németország", "S": "Anglia"}
return {
"country": countries.get(vin[0], "Ismeretlen"),
"year_code": vin[9], # Modellév kódja
"wmi": vin[0:3] # World Manufacturer Identifier
}

View File

@@ -1,14 +1,18 @@
import enum
from sqlalchemy import Column, Integer, String, Boolean, Enum, DateTime, ForeignKey
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, JSON
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
class OrgType(str, enum.Enum):
INDIVIDUAL = "individual"
SERVICE = "service"
FLEET_OWNER = "fleet_owner"
CLUB = "club"
# A tagok neveit kisbetűre állítjuk, hogy egyezzenek a Postgres Enum értékekkel
individual = "individual"
service = "service"
service_provider = "service_provider"
fleet_owner = "fleet_owner"
club = "club"
business = "business"
class Organization(Base):
__tablename__ = "organizations"
@@ -16,24 +20,38 @@ class Organization(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
# A stabilitás miatt VARCHAR-ként kezeljük a DB-ben
org_type = Column(String(50), default="individual")
# A flotta technikai tulajdonosa (User)
# PG_ENUM használata a Python Enum-mal szinkronizálva
org_type = Column(
PG_ENUM(OrgType, name="orgtype", inherit_schema=True),
default=OrgType.individual
)
tax_number = Column(String(20), unique=True, index=True)
reg_number = Column(String(50))
headquarters_address = Column(String(255))
country_code = Column(String(2), default="HU")
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={})
owner_id = Column(Integer, ForeignKey("data.users.id"), nullable=True)
# Üzleti szabályok
is_active = Column(Boolean, default=True)
is_transferable = Column(Boolean, default=True)
# Verifikáció
is_verified = Column(Boolean, default=False)
verification_expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Kapcsolatok (Asset-re hivatkozunk a Vehicle helyett)
assets = relationship("Asset", back_populates="organization", cascade="all, delete-orphan")
members = relationship("OrganizationMember", back_populates="organization")
owner = relationship("User", back_populates="owned_organizations")
@@ -48,5 +66,5 @@ class OrganizationMember(Base):
organization = relationship("Organization", back_populates="members")
# --- EZT A SORT TEDD KÍVÜLRE, A MARGÓRA ---
# Kompatibilitási réteg a korábbi kódokhoz
Organization.vehicles = Organization.assets

View File

@@ -20,15 +20,17 @@ class VehicleCatalog(Base):
engine_type = Column(String(50))
engine_power_kw = Column(Integer)
# --- EZ A SOR HIÁNYZOTT ---
# 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"
@@ -52,13 +54,16 @@ class Asset(Base):
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")
catalog_entry = relationship("VehicleCatalog", back_populates="assets")
events = relationship("AssetEvent", back_populates="asset", cascade="all, delete-orphan")
ratings = relationship("AssetRating", back_populates="asset")

Binary file not shown.

View File

@@ -0,0 +1,27 @@
from pydantic import BaseModel, Field, field_validator
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")
# Opcionális: Kezdő km óra állás, szín, stb. később bővíthető
class AssetResponse(BaseModel):
id: UUID
uai: str
catalog_id: int
organization_id: int
name: str
asset_type: str
current_plate_number: str
status: str
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel, Field
from typing import Optional, List
class ContactCreate(BaseModel):
full_name: str
email: str
phone: Optional[str]
contact_type: str = "primary"
class CorpOnboardIn(BaseModel):
name: str
tax_number: str
country_code: str = "HU"
reg_number: Optional[str]
headquarters_address: str
contacts: Optional[List[ContactCreate]] = []
class CorpOnboardResponse(BaseModel):
organization_id: int
status: str = "pending_verification"

View File

@@ -1,5 +1,7 @@
import asyncio
from .harvester_robot import CarHarvester # A korábbi CarHarvester-t nevezzük át így
from datetime import datetime
# Frissített importok az új fájlnevekhez:
from .harvester_cars import CarHarvester
from .harvester_bikes import BikeHarvester
from .harvester_trucks import TruckHarvester
@@ -7,6 +9,8 @@ class RobotManager:
@staticmethod
async def run_full_sync(db):
"""Sorban lefuttatja az összes robotot."""
print(f"🕒 Szinkronizáció indítva: {datetime.now()}")
robots = [
CarHarvester(),
BikeHarvester(),
@@ -15,8 +19,22 @@ class RobotManager:
for robot in robots:
try:
await robot.run(db)
# Pihenő a kategóriák között, hogy ne kapjunk IP-tiltást
# Itt a robot lekéri az API-tól az ÖSSZES márkát és frissít
await robot.run(db)
await asyncio.sleep(5)
except Exception as e:
print(f"❌ Hiba a {robot.category} robotnál: {e}")
print(f"❌ Hiba a {robot.category} robotnál: {e}")
@staticmethod
async def schedule_nightly_run(db):
"""
Egyszerű ciklus, ami figyeli az időt.
Ha éjjel 2 óra van, elindítja a teljes szinkront.
"""
while True:
now = datetime.now()
# Ha hajnali 2 és 2:01 között vagyunk, indítás
if now.hour == 2 and now.minute == 0:
await RobotManager.run_full_sync(db)
await asyncio.sleep(70) # Várunk, hogy ne induljon el többször ugyanabban a percben
await asyncio.sleep(30) # 30 másodpercenként ellenőrizzük az időt