frontend 2026-06-10 bontva a 2 felület
This commit is contained in:
@@ -60,7 +60,7 @@ async def upload_document(
|
||||
if doc.status == "processing":
|
||||
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
|
||||
else:
|
||||
response_data["message"] = "Dokumentum sikeresen archiválva a széfben."
|
||||
response_data["message"] = "Dokumentum sikeresen archiválva a garázsban."
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy import select
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse
|
||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
|
||||
from app.models.identity import User # JAVÍTVA: Központi Identity modell
|
||||
from app.core.config import settings
|
||||
@@ -39,13 +39,13 @@ async def onboard_organization(
|
||||
detail="Érvénytelen magyar adószám formátum!"
|
||||
)
|
||||
|
||||
# 2. Duplikáció ellenőrzés
|
||||
# 2. Duplikáció ellenőrzés – adószám alapján
|
||||
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!"
|
||||
detail="Ez a cég (adószám) már regisztrálva van a rendszerben."
|
||||
)
|
||||
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
@@ -121,6 +121,56 @@ async def onboard_organization(
|
||||
|
||||
return {"organization_id": new_org.id, "status": new_org.status}
|
||||
|
||||
@router.get("/lookup-tax/{tax_number}")
|
||||
async def lookup_tax_number(
|
||||
tax_number: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Adószám alapú cégadat lekérdezés a hivatalos NAV Online Számla API v3 rendszerén keresztül.
|
||||
A queryTaxpayer végpontot használja valós idejű cégadatok lekérésére.
|
||||
|
||||
Duplikáció ellenőrzés: Mielőtt a NAV-hoz fordulnánk, ellenőrizzük, hogy az adószám
|
||||
első 8 számjegye (törzsszám) alapján létezik-e már aktív cég az adatbázisban.
|
||||
|
||||
Hibakezelés:
|
||||
- 409: Az adószám már regisztrálva van a rendszerben
|
||||
- 503: NAV rendszer nem elérhető (hálózati hiba, DNS, timeout)
|
||||
- 404: Adószám nem található a NAV adatbázisában
|
||||
"""
|
||||
from app.services.nav_service import NavService, NavServiceUnavailableError
|
||||
|
||||
# Duplikáció ellenőrzés: adószám első 8 számjegye (törzsszám) alapján
|
||||
tax_core = tax_number.strip().replace("-", "").replace(" ", "")[:8]
|
||||
stmt_existing = select(Organization).where(
|
||||
Organization.tax_number.like(f"{tax_core}%"),
|
||||
Organization.is_deleted == False,
|
||||
)
|
||||
result_existing = await db.execute(stmt_existing)
|
||||
existing_org = result_existing.scalar_one_or_none()
|
||||
if existing_org:
|
||||
logger.warning(f"Duplikáció észlelve: adószám {tax_core} már létezik (org_id={existing_org.id})")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Ez az adószám már regisztrálva van a rendszerben."
|
||||
)
|
||||
|
||||
try:
|
||||
result = await NavService.query_taxpayer(tax_number)
|
||||
except NavServiceUnavailableError as e:
|
||||
logger.error(f"NAV szolgáltatás hiba: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="A NAV rendszere jelenleg nem elérhető."
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Cég nem található a NAV adatbázisában"
|
||||
)
|
||||
return result
|
||||
|
||||
@router.get("/my", response_model=List[dict])
|
||||
async def get_my_organizations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -147,10 +197,84 @@ async def get_my_organizations(
|
||||
"country_code": o.country_code,
|
||||
"is_active": o.is_active,
|
||||
"is_deleted": o.is_deleted,
|
||||
"subscription_plan": o.subscription_plan
|
||||
"subscription_plan": o.subscription_plan,
|
||||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{org_id}/visual-settings", response_model=OrganizationResponse)
|
||||
async def update_organization_visual_settings(
|
||||
org_id: int,
|
||||
update_data: OrganizationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Szervezet vizuális beállításainak részleges frissítése (JSONB merge).
|
||||
|
||||
A frontend küldhet csak egyetlen kulcsot is (pl. {"theme": "dark"}),
|
||||
és az nem írja felül null-lal a többi mezőt. A meglévő visual_settings
|
||||
objektum mélyen merge-elődik a beérkező adatokkal.
|
||||
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
stmt_member = select(OrganizationMember).where(
|
||||
(OrganizationMember.organization_id == org_id) &
|
||||
(OrganizationMember.user_id == current_user.id) &
|
||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||
)
|
||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod a szervezet beállításainak módosításához (csak OWNER/ADMIN)."
|
||||
)
|
||||
|
||||
# 2. Szervezet lekérése
|
||||
stmt_org = select(Organization).where(
|
||||
Organization.id == org_id,
|
||||
Organization.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt_org)
|
||||
org = result.scalar_one_or_none()
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Szervezet nem található."
|
||||
)
|
||||
|
||||
# 3. JSONB merge: csak a kapott kulcsokat frissítjük, a többit megtartjuk
|
||||
update_dict = update_data.dict(exclude_unset=True)
|
||||
|
||||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||||
incoming_vs = update_dict["visual_settings"]
|
||||
current_vs = dict(org.visual_settings) if org.visual_settings else {}
|
||||
# Mély merge: a meglévő kulcsok megmaradnak, csak a kapottak frissülnek
|
||||
current_vs.update(incoming_vs)
|
||||
org.visual_settings = current_vs
|
||||
del update_dict["visual_settings"]
|
||||
|
||||
# 4. Egyéb mezők frissítése (display_name, language, default_currency)
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(org)
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Adatbázis hiba: {str(e)}"
|
||||
)
|
||||
|
||||
return OrganizationResponse.model_validate(org)
|
||||
# --- B2B MEGHÍVÓ LOGIKA ---
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
@@ -402,8 +402,8 @@ async def update_active_organization(
|
||||
detail="You are not a member of this organization"
|
||||
)
|
||||
|
||||
# Update user's scope_id
|
||||
current_user.scope_id = org_id
|
||||
# Update user's scope_id (stored as string in DB, convert from int)
|
||||
current_user.scope_id = str(org_id) if org_id is not None else None
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
@@ -419,7 +419,7 @@ async def update_active_organization(
|
||||
"role": role_key,
|
||||
"rank": DEFAULT_RANK_MAP.get(role_key, "user"),
|
||||
"scope_level": "organization" if org_id else "personal",
|
||||
"scope_id": org_id,
|
||||
"scope_id": str(org_id) if org_id is not None else None,
|
||||
"person_id": str(current_user.person_id) if current_user.person_id else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,23 @@ class Settings(BaseSettings):
|
||||
return parts
|
||||
return v
|
||||
|
||||
# --- NAV Online Számla API v3 ---
|
||||
NAV_API_USER: str = ""
|
||||
NAV_API_PASSWORD: str = ""
|
||||
NAV_API_SIGN_KEY: str = ""
|
||||
NAV_API_EXCHANGE_KEY: str = ""
|
||||
NAV_API_CALLER_TAX_NUMBER: str = ""
|
||||
NAV_API_BASE_URL: str = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3"
|
||||
|
||||
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v:
|
||||
v = v.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
|
||||
v = v[1:-1]
|
||||
return v
|
||||
|
||||
# --- Google OAuth ---
|
||||
GOOGLE_CLIENT_ID: str = ""
|
||||
GOOGLE_CLIENT_SECRET: str = ""
|
||||
|
||||
@@ -154,6 +154,13 @@ class User(Base):
|
||||
preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF")
|
||||
ui_mode: Mapped[str] = mapped_column(String(20), server_default="personal")
|
||||
|
||||
visual_settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
|
||||
scope_id: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -106,6 +106,13 @@ class Organization(Base):
|
||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
|
||||
visual_settings: Mapped[dict] = mapped_column(
|
||||
JSONB,
|
||||
nullable=False,
|
||||
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
|
||||
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
|
||||
)
|
||||
|
||||
# A technikai tulajdonos (User fiók - törölhető)
|
||||
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Any
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
full_name: str
|
||||
@@ -35,4 +35,34 @@ class CorpOnboardIn(BaseModel):
|
||||
class CorpOnboardResponse(BaseModel):
|
||||
organization_id: int
|
||||
status: str
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
|
||||
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
|
||||
visual_settings: Optional[dict] = None
|
||||
display_name: Optional[str] = None
|
||||
default_currency: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
"""Szervezet adatainak visszaadása (DTO).
|
||||
Tartalmazza a visual_settings mezőt a frontend számára."""
|
||||
id: int
|
||||
name: str
|
||||
full_name: str
|
||||
display_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
country_code: str = "HU"
|
||||
status: str
|
||||
org_type: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_deleted: bool = False
|
||||
subscription_plan: str = "FREE"
|
||||
visual_settings: Optional[dict] = None
|
||||
notification_settings: Optional[Any] = None
|
||||
external_integration_config: Optional[Any] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -59,6 +59,7 @@ class UserResponse(UserBase):
|
||||
scope_level: str
|
||||
scope_id: Optional[str] = None
|
||||
ui_mode: str = "personal"
|
||||
visual_settings: Optional[dict] = {"theme": "default", "primary_color": None, "wall_logo_url": None}
|
||||
active_organization_id: Optional[int] = None
|
||||
preferred_language: str = "hu"
|
||||
# Beágyazott személyes adatok (Person + Address)
|
||||
@@ -102,10 +103,11 @@ class UserUpdate(BaseModel):
|
||||
last_name: Optional[str] = None
|
||||
preferred_language: Optional[str] = None
|
||||
ui_mode: Optional[str] = None
|
||||
visual_settings: Optional[dict] = None
|
||||
|
||||
|
||||
class ActiveOrganizationUpdate(BaseModel):
|
||||
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode
|
||||
organization_id: Optional[int] = None # Integer ID or None to revert to personal mode
|
||||
|
||||
|
||||
class UserWithTokenResponse(BaseModel):
|
||||
|
||||
@@ -250,7 +250,7 @@ class AuthService:
|
||||
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
|
||||
new_org = Organization(
|
||||
full_name=org_full_name,
|
||||
name=f"{p.last_name} Széfe",
|
||||
name=f"{p.last_name} Garázsa",
|
||||
folder_slug=generate_secure_slug(12),
|
||||
org_type=OrgType.individual,
|
||||
owner_id=user.id,
|
||||
|
||||
@@ -44,15 +44,15 @@ class GeoService:
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_full_address(
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
street_name: str,
|
||||
street_type: str,
|
||||
house_number: str,
|
||||
stairwell: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
door: Optional[str] = None,
|
||||
db: AsyncSession,
|
||||
zip_code: str,
|
||||
city: str,
|
||||
street_name: Optional[str] = None,
|
||||
street_type: Optional[str] = None,
|
||||
house_number: Optional[str] = None,
|
||||
stairwell: Optional[str] = None,
|
||||
floor: Optional[str] = None,
|
||||
door: Optional[str] = None,
|
||||
parcel_id: Optional[str] = None,
|
||||
user_id: Optional[int] = None # A régió-alapú felülbíráláshoz
|
||||
) -> uuid.UUID:
|
||||
|
||||
410
backend/app/services/nav_service.py
Normal file
410
backend/app/services/nav_service.py
Normal file
@@ -0,0 +1,410 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/nav_service.py
|
||||
"""
|
||||
NAV Online Számla API v3 integráció – queryTaxpayer végpont.
|
||||
Hivatalos magyar adószám alapú cégadat-lekérdezés XML/SOAP alapon.
|
||||
"""
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NavServiceUnavailableError(Exception):
|
||||
"""
|
||||
Kivétel, amikor a NAV API szerver nem elérhető hálózati hiba miatt
|
||||
(DNS, timeout, kapcsolódási hiba). Ezt a végpont 503-as hibaként kezeli.
|
||||
"""
|
||||
def __init__(self, message: str = "NAV szerver nem elérhető"):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
# --- NAV API v3 XML sablonok ---
|
||||
|
||||
QUERY_TAXPAYER_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<QueryTaxpayerRequest xmlns="http://schemas.nav.gov.hu/OSA/3.0/api" xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common">
|
||||
<common:header>
|
||||
<common:requestId>{request_id}</common:requestId>
|
||||
<common:timestamp>{timestamp}</common:timestamp>
|
||||
<common:requestVersion>3.0</common:requestVersion>
|
||||
<common:headerVersion>1.0</common:headerVersion>
|
||||
</common:header>
|
||||
<common:user>
|
||||
<common:login>{user}</common:login>
|
||||
<common:passwordHash cryptoType="SHA-512">{password_hash}</common:passwordHash>
|
||||
<common:taxNumber>{caller_tax_number}</common:taxNumber>
|
||||
<common:requestSignature cryptoType="SHA3-512">{request_signature}</common:requestSignature>
|
||||
</common:user>
|
||||
<software>
|
||||
<softwareId>{software_id}</softwareId>
|
||||
<softwareName>ServiceFinder</softwareName>
|
||||
<softwareOperation>LOCAL_SOFTWARE</softwareOperation>
|
||||
<softwareMainVersion>1.0</softwareMainVersion>
|
||||
<softwareDevName>ServiceFinder</softwareDevName>
|
||||
<softwareDevContact>dev@servicefinder.hu</softwareDevContact>
|
||||
<softwareDevCountryCode>HU</softwareDevCountryCode>
|
||||
<softwareDevTaxNumber>{caller_tax_number}</softwareDevTaxNumber>
|
||||
</software>
|
||||
<taxNumber>{tax_number}</taxNumber>
|
||||
</QueryTaxpayerRequest>
|
||||
"""
|
||||
|
||||
|
||||
class NavService:
|
||||
"""
|
||||
NAV Online Számla API v3 szolgáltatás.
|
||||
Dokumentáció: https://onlineszamla.nav.gov.hu/
|
||||
"""
|
||||
|
||||
# Gyakori magyar cégforma rövidítések szótára
|
||||
COMPANY_FORM_SHORT_MAP = {
|
||||
"Korlátolt Felelősségű Társaság": "Kft.",
|
||||
"Betéti Társaság": "Bt.",
|
||||
"Zártkörűen Működő Részvénytársaság": "Zrt.",
|
||||
"Nyilvánosan Működő Részvénytársaság": "Nyrt.",
|
||||
"Közkereseti Társaság": "Kkt.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sha512_hex(value: str) -> str:
|
||||
"""SHA-512 hash hex formátumban, felsőoktatással (uppercase)."""
|
||||
return hashlib.sha512(value.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
@staticmethod
|
||||
def _title_case(text: str) -> str:
|
||||
"""
|
||||
Csupa nagybetűs szöveg átalakítása Title Case formátumra.
|
||||
Példa: 'DUNAKESZI' -> 'Dunakeszi', 'BUDAPEST' -> 'Budapest'
|
||||
Kisbetűs szavakat (pl. 'és', 'a', 'az') meghagyja.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
# Ha nem csupa nagybetűs, akkor nem alakítjuk át
|
||||
if not text.isupper():
|
||||
return text
|
||||
words = text.split()
|
||||
result_words = []
|
||||
for word in words:
|
||||
# Ha a szó rövid (1-2 karakter), vagy kötőjeles, kezeljük külön
|
||||
if "-" in word:
|
||||
# Kötőjeles szavak: minden tagot külön kezelünk
|
||||
parts = word.split("-")
|
||||
titled_parts = [p.capitalize() for p in parts]
|
||||
result_words.append("-".join(titled_parts))
|
||||
else:
|
||||
result_words.append(word.capitalize())
|
||||
return " ".join(result_words)
|
||||
|
||||
@staticmethod
|
||||
def _format_company_names(raw_name: str) -> tuple[str, str]:
|
||||
"""
|
||||
Cégnév okos formázása.
|
||||
A NAV-tól kapott csupa nagybetűs névből készít:
|
||||
- full_name: Title Case formátumú teljes név
|
||||
- short_name: Rövidített név (pl. 'Kft.'-re cserélt), töltelékszavak eltávolításával
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: (full_name, short_name)
|
||||
"""
|
||||
if not raw_name:
|
||||
return "", ""
|
||||
|
||||
# 1. Title Case formázás
|
||||
full_name = NavService._title_case(raw_name)
|
||||
|
||||
# 2. Cégforma rövidítésének meghatározása
|
||||
detected_short_form = None
|
||||
short_name_base = full_name
|
||||
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
|
||||
if long_form in short_name_base:
|
||||
# Eltávolítjuk a hosszú formát a névből a szűrés előtt
|
||||
short_name_base = short_name_base.replace(long_form, "")
|
||||
detected_short_form = short_form
|
||||
break
|
||||
|
||||
# 3. Töltelékszavak szűrése a rövid névből
|
||||
filler_words = [
|
||||
"és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó",
|
||||
"ipari", "építőipari", "termelő", "fejlesztő", "kivitelező",
|
||||
"általános"
|
||||
]
|
||||
|
||||
# Szavakra bontás és töltelékszavak eltávolítása
|
||||
words = short_name_base.split()
|
||||
filtered_words = [w for w in words if w.lower() not in filler_words]
|
||||
|
||||
# Ha minden szó töltelék volt, tartsuk meg az eredeti nevet
|
||||
if filtered_words:
|
||||
short_name = " ".join(filtered_words).strip()
|
||||
else:
|
||||
short_name = full_name
|
||||
# Ilyenkor újra megkeressük a cégformát a teljes névből
|
||||
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
|
||||
if long_form in short_name:
|
||||
short_name = short_name.replace(long_form, short_form)
|
||||
break
|
||||
|
||||
# 4. Cégforma hozzáfűzése a rövid név végére
|
||||
if detected_short_form:
|
||||
short_name = f"{short_name} {detected_short_form}".strip()
|
||||
|
||||
return full_name, short_name
|
||||
|
||||
@staticmethod
|
||||
def _sha3_512_hex(value: str) -> str:
|
||||
"""SHA3-512 hash hex formátumban, felsőoktatással (uppercase)."""
|
||||
return hashlib.sha3_512(value.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
@classmethod
|
||||
def _build_header(cls) -> Dict[str, str]:
|
||||
"""
|
||||
NAV API v3 header összeállítása a specifikáció szerint.
|
||||
- passwordHash: NAV_API_PASSWORD felsőoktatású SHA-512 hash-e
|
||||
- requestId: UUID, max 30 karakter
|
||||
- timestamp: UTC időpont YYYY-MM-DDTHH:MM:SS.000Z formátumban
|
||||
- requestSignature (HTTP header): requestId + timestamp + exchangeKey SHA3-512 hash-e (uppercase)
|
||||
- xmlRequestSignature (XML body): requestId + timestamp + signKey SHA3-512 hash-e (uppercase)
|
||||
|
||||
Fontos: Minden hitelesítő adatot megtisztítunk a whitespace-ektől és idézőjelektől,
|
||||
mert a .env fájlból beolvasott értékek tartalmazhatnak láthatatlan szóközöket,
|
||||
amelyek elrontják a hash-eket (SHA-512 / SHA3-512).
|
||||
"""
|
||||
# Tisztított változók – eltávolítjuk a whitespace-t és az idézőjeleket
|
||||
clean_password = settings.NAV_API_PASSWORD.strip().replace('"', '').replace("'", "")
|
||||
clean_sign_key = settings.NAV_API_SIGN_KEY.strip().replace('"', '').replace("'", "")
|
||||
clean_exchange_key = settings.NAV_API_EXCHANGE_KEY.strip().replace('"', '').replace("'", "")
|
||||
clean_user = settings.NAV_API_USER.strip().replace('"', '').replace("'", "")
|
||||
|
||||
password_hash = cls._sha512_hex(clean_password)
|
||||
request_id = str(uuid.uuid4()).replace("-", "").upper()[:30]
|
||||
|
||||
# Kétféle timestamp formátum a NAV API specifikáció szerint:
|
||||
# - timestamp_xml: az XML body-ba kerül, ISO 8601 formátumban (kötőjellel, T-vel, Z-vel)
|
||||
# - timestamp_hash: a hash számításhoz, MASZKOLT formátumban (NINCS kötőjel, NINCS T, NINCS Z)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
timestamp_xml = now_utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||
timestamp_hash = now_utc.strftime("%Y%m%d%H%M%S")
|
||||
|
||||
# HTTP header RequestSignature = SHA3-512(requestId + timestamp_hash + exchangeKey)
|
||||
http_sig_raw = request_id + timestamp_hash + clean_exchange_key
|
||||
http_request_signature = cls._sha3_512_hex(http_sig_raw)
|
||||
|
||||
# XML body user.requestSignature = SHA3-512(requestId + timestamp_hash + signKey)
|
||||
xml_sig_raw = request_id + timestamp_hash + clean_sign_key
|
||||
xml_request_signature = cls._sha3_512_hex(xml_sig_raw)
|
||||
|
||||
# Debug logok a NAV API v3 signature számítás ellenőrzéséhez
|
||||
logger.info(f"NAV DEBUG - Request ID: {request_id}")
|
||||
logger.info(f"NAV DEBUG - Timestamp XML: {timestamp_xml}")
|
||||
logger.info(f"NAV DEBUG - Timestamp Hash: {timestamp_hash}")
|
||||
logger.info(f"NAV DEBUG - Clean Sign Key: {clean_sign_key}")
|
||||
logger.info(f"NAV DEBUG - RAW Sign String: {xml_sig_raw}")
|
||||
logger.info(f"NAV DEBUG - Generated SHA3-512: {xml_request_signature}")
|
||||
|
||||
return {
|
||||
"requestId": request_id,
|
||||
"timestamp": timestamp_xml,
|
||||
"requestSignature": http_request_signature,
|
||||
"xmlRequestSignature": xml_request_signature,
|
||||
"passwordHash": password_hash,
|
||||
"exchangeKey": clean_exchange_key,
|
||||
"user": clean_user,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_query_taxpayer_xml(cls, tax_number: str, header: Dict[str, str]) -> str:
|
||||
"""
|
||||
QueryTaxpayerRequest XML body összeállítása a NAV API v3 specifikáció szerint.
|
||||
|
||||
Két külön adószámot használ:
|
||||
- caller_tax_number: a hívó fél adószáma (settings.NAV_API_CALLER_TAX_NUMBER-ból),
|
||||
az autentikációhoz a <user><taxNumber> mezőben.
|
||||
- tax_number: a lekérdezni kívánt cég adószáma (a metódus argumentuma),
|
||||
a tényleges lekérdezéshez az outer <taxNumber> mezőben.
|
||||
|
||||
A header paraméter a _build_header() által előállított dict, amely tartalmazza
|
||||
a requestId-t, timestamp-ot és xmlRequestSignature-t. Ezt kívülről kapja meg,
|
||||
hogy a HTTP fejléc és a XML body AZONOS requestId-t és timestamp-ot használjon.
|
||||
|
||||
Fontos: A HTTP header RequestSignature az exchangeKey-kel, míg a XML body
|
||||
user.requestSignature a signKey-kel számolódik (NAV v3 specifikáció).
|
||||
"""
|
||||
|
||||
# A hívó (caller) adószámának első 8 számjegye (törzsszám) – autentikációhoz
|
||||
caller_tax_number_core = (
|
||||
settings.NAV_API_CALLER_TAX_NUMBER
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# A lekérdezni kívánt (target) adószám első 8 számjegye (törzsszám) – lekérdezéshez
|
||||
target_tax_number_core = (
|
||||
tax_number
|
||||
.strip()
|
||||
.replace("-", "")
|
||||
.replace(" ", "")[:8]
|
||||
)
|
||||
|
||||
# A user.requestSignature = SHA3-512(requestId + timestamp + signKey)
|
||||
# a cryptoType="SHA3-512" attribútummal
|
||||
# EZT a header dict-ben xmlRequestSignature-ként tároljuk
|
||||
user_request_signature = header["xmlRequestSignature"]
|
||||
|
||||
xml_body = QUERY_TAXPAYER_XML_TEMPLATE.format(
|
||||
request_id=header["requestId"],
|
||||
timestamp=header["timestamp"],
|
||||
caller_tax_number=caller_tax_number_core,
|
||||
tax_number=target_tax_number_core,
|
||||
user=header["user"],
|
||||
password_hash=header["passwordHash"],
|
||||
request_signature=user_request_signature,
|
||||
software_id="SF-NAV-API-001-000",
|
||||
)
|
||||
return xml_body
|
||||
|
||||
@classmethod
|
||||
def _parse_taxpayer_response(cls, xml_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
NAV API válasz feldolgozása – névtér-független parser.
|
||||
A NAV a funcCode-ot a common, az adatokat az api névtérben küldi,
|
||||
ezért a root.iter()-en végigiterálva, a tag név névtérrészének
|
||||
levágásával (el.tag.split("}")[-1]) keressük az elemeket.
|
||||
"""
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
root = ET.fromstring(xml_text)
|
||||
|
||||
def get_text(tag_name: str) -> Optional[str]:
|
||||
"""
|
||||
Végigiterál a root.iter()-en, és visszaadja az első olyan elem
|
||||
szöveges tartalmát, amelynek a neve (névtér prefix levágása után)
|
||||
megegyezik a tag_name paraméterrel.
|
||||
"""
|
||||
for el in root.iter():
|
||||
local_tag = el.tag.split("}")[-1]
|
||||
if local_tag == tag_name:
|
||||
return el.text if el.text else None
|
||||
return None
|
||||
|
||||
# funcCode ellenőrzés – a NAV a common névtérben küldi
|
||||
func_code = get_text("funcCode")
|
||||
if func_code is None:
|
||||
logger.warning("NAV válasz: funcCode nem található")
|
||||
return None
|
||||
|
||||
if func_code != "OK":
|
||||
# Hibaüzenet kiolvasása – a NAV a message tag-et a common névtérben küldi
|
||||
error_msg = get_text("message") or "Ismeretlen hiba"
|
||||
logger.error(f"NAV válasz hibakód: {func_code} – {error_msg}")
|
||||
return None
|
||||
|
||||
# Adózói adatok kinyerése – névtérfüggetlenül
|
||||
taxpayer_name = get_text("taxpayerName") or ""
|
||||
city = get_text("city") or ""
|
||||
postal_code = get_text("postalCode") or ""
|
||||
street_name = get_text("streetName") or ""
|
||||
public_place_category = get_text("publicPlaceCategory") or ""
|
||||
number = get_text("number") or ""
|
||||
|
||||
# --- Cégnév okos formázása ---
|
||||
full_name, short_name = cls._format_company_names(taxpayer_name)
|
||||
|
||||
# --- Címek Title Case formázása ---
|
||||
formatted_city = cls._title_case(city)
|
||||
formatted_street_name = cls._title_case(street_name)
|
||||
formatted_public_place = cls._title_case(public_place_category)
|
||||
|
||||
# Utcanév + közterület jellege összefűzése
|
||||
full_street = formatted_street_name
|
||||
if formatted_public_place:
|
||||
full_street = f"{formatted_street_name} {formatted_public_place}"
|
||||
|
||||
result = {
|
||||
"full_name": full_name,
|
||||
"name": short_name,
|
||||
"display_name": short_name,
|
||||
"address_zip": postal_code,
|
||||
"address_city": formatted_city,
|
||||
"address_street_name": full_street.strip(),
|
||||
"address_street_type": formatted_public_place,
|
||||
"address_house_number": number,
|
||||
}
|
||||
logger.info(f"NAV API sikeres lekérdezés: {full_name}, {formatted_city}")
|
||||
return result
|
||||
|
||||
except ET.ParseError as e:
|
||||
logger.error(f"NAV válasz XML parse hiba: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"NAV válasz feldolgozási hiba: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def query_taxpayer(cls, tax_number: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Adószám alapú cégadat lekérdezés a NAV Online Számla API v3 queryTaxpayer végpontján keresztül.
|
||||
|
||||
Args:
|
||||
tax_number: Magyar adószám (XXXXXXXX-Y-ZZ vagy XXXXXXXX formátumban)
|
||||
|
||||
Returns:
|
||||
Dict a cég adataival (full_name, name, display_name, address_zip, address_city,
|
||||
address_street_name, address_street_type, address_house_number)
|
||||
vagy None, ha a NAV nem találja az adószámot.
|
||||
|
||||
Raises:
|
||||
NavServiceUnavailableError: Ha hálózati hiba (DNS, timeout, kapcsolódási) történik.
|
||||
"""
|
||||
# EGYSZER hívjuk a _build_header()-t, hogy a requestId, timestamp és
|
||||
# requestSignature AZONOS legyen mind a XML body-ban, mind a HTTP fejlécben
|
||||
header = cls._build_header()
|
||||
xml_body = cls._build_query_taxpayer_xml(tax_number, header)
|
||||
|
||||
# NAV API fejléc (HTTP header) – csak a szükséges fejlécek
|
||||
http_headers = {
|
||||
"Content-Type": "application/xml; charset=UTF-8",
|
||||
"Accept": "application/xml; charset=UTF-8",
|
||||
}
|
||||
|
||||
url = f"{settings.NAV_API_BASE_URL}/queryTaxpayer"
|
||||
|
||||
logger.info(f"NAV API hívás: queryTaxpayer, adószám törzs: {tax_number[:8]}...")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
|
||||
response = await client.post(url, content=xml_body, headers=http_headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
# Próbáljuk kinyerni a NAV által küldött hibaüzenetet a válaszból
|
||||
error_detail = cls._parse_taxpayer_response(response.text)
|
||||
if error_detail:
|
||||
logger.error(
|
||||
f"NAV API HTTP hiba: {response.status_code} – {error_detail}"
|
||||
)
|
||||
else:
|
||||
# Ha a parser nem tudta kiolvasni, logoljuk a teljes választ
|
||||
logger.error(
|
||||
f"NAV API HTTP hiba: {response.status_code} – Teljes válasz: {response.text}"
|
||||
)
|
||||
return None
|
||||
|
||||
result = cls._parse_taxpayer_response(response.text)
|
||||
return result
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül")
|
||||
raise NavServiceUnavailableError("NAV API timeout – a szerver nem válaszolt 30 másodpercen belül")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"NAV API kapcsolódási hiba: {e}")
|
||||
raise NavServiceUnavailableError(f"NAV szerver nem elérhető: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"NAV API váratlan hiba: {e}", exc_info=True)
|
||||
return None
|
||||
Reference in New Issue
Block a user