frontend 2026-06-10 bontva a 2 felület

This commit is contained in:
Roo
2026-06-10 08:06:07 +00:00
parent b84b1bab41
commit 90e3173fbc
59 changed files with 8616 additions and 1412 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 = ""

View File

@@ -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"))

View File

@@ -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"))

View File

@@ -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)

View File

@@ -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):

View File

@@ -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,

View File

@@ -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:

View 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