FEAT: Corporate onboarding implemented with Tax ID validation (HU) and isolated NAS storage
This commit is contained in:
Binary file not shown.
@@ -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"])
|
||||
BIN
backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc
Normal file
BIN
backend/app/api/v1/endpoints/__pycache__/assets.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
104
backend/app/api/v1/endpoints/assets.py
Normal file
104
backend/app/api/v1/endpoints/assets.py
Normal 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
|
||||
70
backend/app/api/v1/endpoints/organizations.py
Normal file
70
backend/app/api/v1/endpoints/organizations.py
Normal 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}
|
||||
Binary file not shown.
BIN
backend/app/core/__pycache__/validators.cpython-312.pyc
Normal file
BIN
backend/app/core/__pycache__/validators.cpython-312.pyc
Normal file
Binary file not shown.
47
backend/app/core/validators.py
Normal file
47
backend/app/core/validators.py
Normal 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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
BIN
backend/app/schemas/__pycache__/asset.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/asset.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/organization.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/organization.cpython-312.pyc
Normal file
Binary file not shown.
27
backend/app/schemas/asset.py
Normal file
27
backend/app/schemas/asset.py
Normal 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
|
||||
20
backend/app/schemas/organization.py
Normal file
20
backend/app/schemas/organization.py
Normal 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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user