frontend 2026-06-10 bontva a 2 felület
This commit is contained in:
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