Files
service-finder/backend/app/services/nav_service.py
2026-06-10 08:06:07 +00:00

411 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /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