233 lines
9.6 KiB
Markdown
233 lines
9.6 KiB
Markdown
# logic_spec_opten_integration.md — Opten REST API Integrációs Terv
|
||
|
||
## Modul Célja
|
||
A jelenlegi mock `GET /lookup-tax/{tax_number}` végpont lecserélése valós [Opten REST API](https://www.opten.hu/) hívásra. A frontend [`CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) `lookupTaxNumber()` függvénye már készen áll a valós adatok fogadására — csak a backend oldali integráció hiányzik.
|
||
|
||
## Masterbook 2.0 Illeszkedés
|
||
- **DDD Séma:** `identity.organizations` — a cégadatok automatikus kitöltése csökkenti a hibalehetőséget.
|
||
- **Geo-logika:** Csak magyar (HU) adószámok esetén aktív. Más országok esetén a mock marad, vagy külön integráció később.
|
||
|
||
---
|
||
|
||
## Érintett Fájlok és Műveletek
|
||
|
||
| # | Fájl | Művelet | Leírás |
|
||
|---|------|---------|--------|
|
||
| 1 | [`backend/app/core/config.py`](backend/app/core/config.py:11) | ✏️ Módosítás | `OPTEN_API_KEY`, `OPTEN_API_BASE_URL`, `OPTEN_CACHE_TTL` hozzáadása a `Settings` osztályhoz |
|
||
| 2 | [`backend/app/services/opten_service.py`](backend/app/services/opten_service.py) | ➕ **ÚJ fájl** | `OptenService` osztály: `httpx.AsyncClient` + `tenacity` retry logika |
|
||
| 3 | [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py) | ➕ **ÚJ fájl** | `TaxLookupResponse` Pydantic séma |
|
||
| 4 | [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:125) | ✏️ Módosítás | Mock végpont → valós `OptenService.lookup_by_tax_number()` hívás |
|
||
| 5 | [`backend/requirements.txt`](backend/requirements.txt:17) | 🟢 **Nincs teendő** | `httpx` már szerepel. `tenacity` opcionális (retry logikához) |
|
||
| 6 | [`frontend/src/views/organization/CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) | 🟢 **Már kész** | `lookupTaxNumber()` teljes mértékben használható |
|
||
|
||
---
|
||
|
||
## Adatmodell — TaxLookupResponse
|
||
|
||
```python
|
||
# backend/app/schemas/organization.py
|
||
from pydantic import BaseModel
|
||
from typing import Optional
|
||
|
||
class TaxLookupResponse(BaseModel):
|
||
full_name: str
|
||
name: str
|
||
display_name: Optional[str] = None
|
||
address_zip: str
|
||
address_city: str
|
||
address_street_name: str
|
||
address_street_type: Optional[str] = None
|
||
address_house_number: str
|
||
legal_form: Optional[str] = None # pl. "Kft.", "Bt.", "Zrt."
|
||
status: Optional[str] = None # pl. "active", "dissolved"
|
||
```
|
||
|
||
### Frontend Kompatibilitás
|
||
A fenti mezők teljes mértékben lefedik a frontend [`CompanyOnboardingView.vue:654-684`](frontend/src/views/organization/CompanyOnboardingView.vue:654) által várt mezőket. Az új `legal_form` és `status` mezők a frontend `tax_lookup_result` objektumban tárolódhatnak későbbi felhasználásra.
|
||
|
||
---
|
||
|
||
## Service Réteg — OptenService
|
||
|
||
### Osztály Felépítése
|
||
|
||
```python
|
||
# backend/app/services/opten_service.py
|
||
import logging
|
||
from typing import Optional
|
||
import httpx
|
||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class OptenService:
|
||
def __init__(self, api_key: str, base_url: str = "https://api.opten.hu/v2"):
|
||
self.api_key = api_key
|
||
self.base_url = base_url
|
||
self.client = httpx.AsyncClient(
|
||
base_url=self.base_url,
|
||
headers={"X-Api-Key": self.api_key, "Accept": "application/json"},
|
||
timeout=10.0
|
||
)
|
||
|
||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5))
|
||
async def lookup_by_tax_number(self, tax_number: str) -> dict:
|
||
"""Lekérdezi az Opten API-t adószám alapján."""
|
||
normalized = self._normalize_tax_number(tax_number)
|
||
response = await self.client.get(f"/company/{normalized}")
|
||
response.raise_for_status()
|
||
return self._map_opten_response(response.json())
|
||
|
||
def _normalize_tax_number(self, raw: str) -> str:
|
||
"""Csak számjegyeket tart meg (8 vagy 10 vagy 11 karakter)."""
|
||
digits = "".join(c for c in raw if c.isdigit())
|
||
# HU adószám: 8 számjegy (egyéni) vagy 10-11 számjegy (cég, áfával)
|
||
return digits[:11]
|
||
|
||
def _map_opten_response(self, data: dict) -> dict:
|
||
"""Opten API válasz → TaxLookupResponse formátumba."""
|
||
return {
|
||
"full_name": data.get("cegnev", ""),
|
||
"name": data.get("rovitett_cegnev", data.get("cegnev", "")),
|
||
"display_name": data.get("rovitett_cegnev"),
|
||
"address_zip": data.get("iranyitoszam", ""),
|
||
"address_city": data.get("telepules", ""),
|
||
"address_street_name": data.get("kozt_nev", ""),
|
||
"address_street_type": data.get("kozt_jelleg", ""),
|
||
"address_house_number": str(data.get("hazszam", "")),
|
||
"legal_form": data.get("cegforma", ""),
|
||
"status": data.get("ceg_statusz", ""),
|
||
}
|
||
|
||
async def close(self):
|
||
await self.client.aclose()
|
||
```
|
||
|
||
### Gráciális Fallback
|
||
Ha az `OPTEN_API_KEY` nincs beállítva (üres string), a végpont NEM dob hibát, hanem visszaadja a mock adatokat:
|
||
|
||
```python
|
||
# backend/app/api/v1/endpoints/organizations.py
|
||
@router.get("/lookup-tax/{tax_number}", response_model=TaxLookupResponse)
|
||
async def lookup_tax_number(tax_number: str, db: AsyncSession = Depends(get_db)):
|
||
settings = get_settings()
|
||
if not settings.OPTEN_API_KEY:
|
||
logger.warning("OPTEN_API_KEY not set — returning mock data")
|
||
return await _mock_lookup(tax_number)
|
||
service = OptenService(settings.OPTEN_API_KEY, settings.OPTEN_API_BASE_URL)
|
||
try:
|
||
result = await service.lookup_by_tax_number(tax_number)
|
||
return result
|
||
except httpx.HTTPStatusError as e:
|
||
return await _handle_opten_error(e)
|
||
finally:
|
||
await service.close()
|
||
```
|
||
|
||
---
|
||
|
||
## Geo-Logika
|
||
|
||
| Ország | Forrás | Státusz |
|
||
|--------|--------|---------|
|
||
| Magyarország (HU) | Opten REST API | ✅ Ezen terv |
|
||
| Más EU országok | Mock (később: saját provider) | ⏳ Tervezett |
|
||
|
||
Csak magyar adószámok esetén aktív az Opten hívás. A `_normalize_tax_number()` metódus alapján: ha a szám nem felel meg a HU formátumnak (8-11 számjegy), a végpont mock adatokat ad vissza.
|
||
|
||
---
|
||
|
||
## Hibakezelési Mátrix
|
||
|
||
| Opten Hiba | HTTP Státusz | Felhasználói Üzenet |
|
||
|------------|-------------|---------------------|
|
||
| 404 — Nincs ilyen cég | 404 | "Nem található cég ezzel az adószámmal." |
|
||
| 403 — Rossz API kulcs | 500 | "Belső szolgáltatói hiba. Kérjük, próbáld később." |
|
||
| 429 — Rate limit | 429 | "Túl sok lekérdezés. Kérjük, várj egy percet." |
|
||
| 422 — Érvénytelen adószám | 422 | "Érvénytelen adószám formátum." |
|
||
| 504 — Gateway timeout | 504 | "A szolgáltató nem válaszol. Próbáld újra később." |
|
||
| Ismeretlen hiba | 502 | "Váratlan hiba történt. Próbáld újra később." |
|
||
|
||
### `_handle_opten_error()` pszeudokód
|
||
```python
|
||
async def _handle_opten_error(e: httpx.HTTPStatusError) -> dict:
|
||
status_map = {
|
||
404: (404, "Nem található cég ezzel az adószámmal."),
|
||
403: (500, "Belső szolgáltatói hiba."),
|
||
429: (429, "Túl sok lekérdezés. Várj egy percet."),
|
||
422: (422, "Érvénytelen adószám formátum."),
|
||
504: (504, "A szolgáltató nem válaszol."),
|
||
}
|
||
http_code, msg = status_map.get(e.response.status_code, (502, "Váratlan hiba."))
|
||
raise HTTPException(status_code=http_code, detail=msg)
|
||
```
|
||
|
||
---
|
||
|
||
## Biztonsági Megfontolások
|
||
|
||
| # | Intézkedés | Leírás |
|
||
|---|-----------|--------|
|
||
| 1 | `.env`-ben tartás | `OPTEN_API_KEY` csak a `.env` fájlban, SOHOHA hardcode-olva |
|
||
| 2 | Rate limiting | Nginx/API Gateway szintű korlátozás a `/lookup-tax/` végpontra |
|
||
| 3 | Audit log | Minden Opten hívás naplózása: tax_number, timestamp, sikeresség |
|
||
| 4 | Cache | `OPTEN_CACHE_TTL` (alapértelmezett: 3600s) — redis cache a gyakori ismétlődésekhez |
|
||
| 5 | Adatvédelem | A visszaadott adatok csak a szervezet létrehozásához használhatók |
|
||
|
||
---
|
||
|
||
## Teszt Terv
|
||
|
||
### Unit tesztek
|
||
| Teszt | Leírás |
|
||
|-------|--------|
|
||
| `test_normalize_tax_number` | 8, 10, 11 jegyű, kötőjeles, szóközös bemenetek |
|
||
| `test_map_opten_response` | Opten JSON → TaxLookupResponse mezőtérkép |
|
||
| `test_mock_fallback` | Ha nincs API kulcs, mock adat jön |
|
||
| `test_error_mapping` | Minden hibaeset lefedése |
|
||
|
||
### Integrációs tesztek
|
||
```bash
|
||
# Teszt élő Opten API-val (ha van API kulcs)
|
||
docker compose exec sf_api python3 -c "
|
||
from app.services.opten_service import OptenService
|
||
import asyncio
|
||
async def test():
|
||
s = OptenService('teszt_kulcs')
|
||
result = await s.lookup_by_tax_number('12345678')
|
||
print(result)
|
||
await s.close()
|
||
asyncio.run(test())
|
||
"
|
||
```
|
||
|
||
---
|
||
|
||
## Végrehajtási Terv (6 Kártya)
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[1. Config: OPTEN_API_KEY] --> B[2. Séma: TaxLookupResponse]
|
||
A --> C[3. Service: OptenService]
|
||
B --> D[4. Endpoint: Mock → Valós]
|
||
C --> D
|
||
D --> E[5. Tesztek]
|
||
E --> F[6. Dokumentáció]
|
||
```
|
||
|
||
| # | Kártya Név | Függőség | Becsült Méret |
|
||
|---|-----------|----------|---------------|
|
||
| 1 | Config: `OPTEN_API_KEY` hozzáadása | Nincs | S (< 10 sor) |
|
||
| 2 | Séma: `TaxLookupResponse` Pydantic | 1 | S (< 20 sor) |
|
||
| 3 | Service: `OptenService` osztály | Nincs | M (~60 sor) |
|
||
| 4 | Endpoint: Mock → valós hívás | 2, 3 | S (~20 sor) |
|
||
| 5 | Tesztek (unit + smoke) | 4 | M (~50 sor) |
|
||
| 6 | Dokumentáció frissítése | 5 | S (< 20 sor) |
|
||
|
||
---
|
||
|
||
## Kockázatok
|
||
- **Opten API kulcs beszerzése** — a fejlesztés mock módban is végezhető.
|
||
- **Opten API változás** — a `_map_opten_response()` metódus könnyen adaptálható.
|
||
- **Éles rate limit** — szükség esetén Opten előfizetés frissítése.
|