411 lines
18 KiB
Python
411 lines
18 KiB
Python
# /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
|