first
This commit is contained in:
@@ -1,3 +1,50 @@
|
||||
# PPL CPL API
|
||||
|
||||
Stateless multi-tenant proxy nad **PPL CPL API** (Create Package Label) běžící
|
||||
v AppFactory. Umožňuje tvorbu zásilek a tisk etiket, tracking, objednávky
|
||||
svozu/přepravy, číselníky, výdejní místa a našeptávač adres.
|
||||
|
||||
## Přihlašovací údaje
|
||||
|
||||
Předávají se **per-request v hlavičkách** (nikdy v těle, URL ani konfiguraci):
|
||||
|
||||
| Hlavička | Význam |
|
||||
|---|---|
|
||||
| `X-Client-Id` | PPL CPL ClientId (přiděluje PPL) |
|
||||
| `X-Client-Secret` | PPL CPL ClientSecret |
|
||||
| `X-Environment` | volitelně `production` (default) / `test` |
|
||||
|
||||
Služba si sama vyžádá OAuth Bearer token (client_credentials, scope `myapi2`)
|
||||
a cachuje ho in-memory podle hashe údajů — PPL limituje vydávání tokenů na
|
||||
12/min a token platí 30 minut.
|
||||
|
||||
## Hlavní endpointy
|
||||
|
||||
- `POST /shipments/batch` → vytvoření zásilek, vrací `batchId`
|
||||
- `GET /shipments/batch/{batchId}` → stav importu (Accepted/InProcess/Complete/Error)
|
||||
- `GET /shipments/batch/{batchId}/labels` → binární etikety (PDF/ZPL/JPG…)
|
||||
- `POST /shipments/create-and-wait` → celý tok v jednom requestu (volitelně etikety v base64)
|
||||
- `GET /shipments` → tracking
|
||||
- `POST /shipments/{n}/cancel`, `POST /shipments/{n}/redirect`, `POST /shipments/{n}/documents`
|
||||
- `POST /orders/batch`, `GET /orders/batch/{batchId}`, `POST /orders/create-and-wait`, `GET /orders`, `POST /orders/cancel`
|
||||
- `GET /codelists/{name}`, `GET /access-points`, `GET /address-whisper`, `GET /routing`
|
||||
- `GET /customer`, `GET /customer/addresses`, `POST /customer/number-range`
|
||||
- `/proxy/{cesta}` → generické volání libovolné metody CPL API 1:1
|
||||
|
||||
Kompletní popis viz [documentation/](documentation/) a Swagger na `/docs`.
|
||||
|
||||
## Poznámky
|
||||
|
||||
- CPL API přijímá jen Latin znaky bez diakritiky — texty v create requestech se
|
||||
defaultně transliterují (`transliterate=false` to vypne).
|
||||
- Služba dodržuje minimální rozestup requestů na PPL (40 ms).
|
||||
- Chyby: JSON `{error, message, detail}`, `detail` nese problem+json z PPL.
|
||||
|
||||
## Lokální spuštění
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Generated by AppFactory.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Konfigurace čtená z environment variables (AppFactory runtime .env).
|
||||
|
||||
Žádné secrets zde nejsou — přihlašovací údaje (ClientId/ClientSecret) se předávají
|
||||
per-request přes X- hlavičky (viz app/credentials.py). Zde jsou pouze veřejné
|
||||
defaulty, base URL adresy PPL CPL API a nastavení proxy.
|
||||
"""
|
||||
import os
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "PPL CPL API")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
ROOT_PATH = os.getenv("ROOT_PATH", "")
|
||||
|
||||
# Base URL adresy CPL API (přepsatelné přes env, kdyby se změnily).
|
||||
CPL_PRODUCTION_BASE_URL = os.getenv(
|
||||
"CPL_PRODUCTION_BASE_URL", "https://api.dhl.com/ecs/ppl/myapi2"
|
||||
)
|
||||
CPL_TEST_BASE_URL = os.getenv(
|
||||
"CPL_TEST_BASE_URL", "https://api-dev.dhl.com/ecs/ppl/myapi2"
|
||||
)
|
||||
|
||||
# Které prostředí se použije, když request nepošle hlavičku X-Environment.
|
||||
CPL_DEFAULT_ENVIRONMENT = os.getenv("CPL_DEFAULT_ENVIRONMENT", "production")
|
||||
|
||||
# OAuth2 client_credentials scope dle dokumentace CPL.
|
||||
CPL_OAUTH_SCOPE = os.getenv("CPL_OAUTH_SCOPE", "myapi2")
|
||||
|
||||
# Timeout pro upstream volání.
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "60"))
|
||||
|
||||
# Token platí 30 minut; obnovujeme ho s předstihem, ať nikdy nepošleme prošlý.
|
||||
TOKEN_REFRESH_MARGIN_SECONDS = float(os.getenv("TOKEN_REFRESH_MARGIN_SECONDS", "60"))
|
||||
|
||||
# PPL vyžaduje min. 40 ms rozestup mezi po sobě jdoucími requesty.
|
||||
MIN_REQUEST_INTERVAL_SECONDS = float(os.getenv("MIN_REQUEST_INTERVAL_SECONDS", "0.04"))
|
||||
|
||||
# Polling stavu batch importu u convenience endpointů create-and-wait.
|
||||
BATCH_POLL_INTERVAL_SECONDS = float(os.getenv("BATCH_POLL_INTERVAL_SECONDS", "1.0"))
|
||||
BATCH_WAIT_TIMEOUT_SECONDS = float(os.getenv("BATCH_WAIT_TIMEOUT_SECONDS", "30"))
|
||||
|
||||
# CPL přijímá pouze Latin znaky bez diakritiky — defaultně texty transliterujeme.
|
||||
TRANSLITERATE_DEFAULT = os.getenv("CPL_TRANSLITERATE", "true").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""HTTP klient pro PPL CPL API.
|
||||
|
||||
Zajišťuje:
|
||||
- získání a cachování OAuth Bearer tokenu (client_credentials, scope myapi2),
|
||||
- minimální rozestup mezi requesty (PPL vyžaduje >= 40 ms),
|
||||
- jeden retry s čerstvým tokenem, pokud PPL vrátí 401 (token mohl být revokován),
|
||||
- pomocné funkce pro předání odpovědi PPL klientovi (JSON / binární etikety).
|
||||
|
||||
Secrets se nikdy nelogují — loguje se pouze metoda, cesta a status.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from . import token_cache
|
||||
from .config import (
|
||||
CPL_OAUTH_SCOPE,
|
||||
MIN_REQUEST_INTERVAL_SECONDS,
|
||||
UPSTREAM_TIMEOUT_SECONDS,
|
||||
)
|
||||
from .credentials import Credentials
|
||||
from .errors import CredentialsError, UpstreamError, raise_for_upstream
|
||||
from .logging_config import get_logger
|
||||
|
||||
log = get_logger("pplcpl.client")
|
||||
|
||||
TOKEN_PATH = "/login/getAccessToken"
|
||||
|
||||
# Hlavičky PPL, které má smysl předat klientovi (paging, korelace, Location).
|
||||
_RELAY_HEADERS = (
|
||||
"location",
|
||||
"x-correlation-id",
|
||||
"x-paging-total-items-count",
|
||||
"x-paging-offset",
|
||||
"x-paging-limit",
|
||||
"content-disposition",
|
||||
)
|
||||
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
_throttle_lock = asyncio.Lock()
|
||||
_last_request_at = 0.0
|
||||
|
||||
|
||||
def _http() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
async def _throttle() -> None:
|
||||
"""PPL vyžaduje minimálně 40 ms rozestup mezi po sobě jdoucími requesty."""
|
||||
global _last_request_at
|
||||
async with _throttle_lock:
|
||||
now = time.monotonic()
|
||||
wait = MIN_REQUEST_INTERVAL_SECONDS - (now - _last_request_at)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
_last_request_at = time.monotonic()
|
||||
|
||||
|
||||
async def _fetch_token(creds: Credentials) -> str:
|
||||
await _throttle()
|
||||
try:
|
||||
resp = await _http().post(
|
||||
creds.base_url + TOKEN_PATH,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": creds.client_id.strip(),
|
||||
"client_secret": creds.client_secret.strip(),
|
||||
"scope": CPL_OAUTH_SCOPE,
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.error("Token endpoint PPL nedostupný (%s): %s", creds.environment, exc.__class__.__name__)
|
||||
raise UpstreamError("PPL CPL API (token endpoint) je nedostupné.") from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.warning(
|
||||
"PPL odmítlo vydání tokenu (%s): HTTP %s", creds.environment, resp.status_code
|
||||
)
|
||||
raise CredentialsError(
|
||||
f"PPL odmítlo přihlašovací údaje při vydávání tokenu (HTTP {resp.status_code}).",
|
||||
detail=(resp.text or "")[:500],
|
||||
)
|
||||
|
||||
payload = resp.json()
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
log.error("Token endpoint PPL vrátil 200 bez access_token.")
|
||||
raise UpstreamError("PPL vrátilo neplatnou odpověď z token endpointu.")
|
||||
|
||||
expires_in = float(payload.get("expires_in") or 1800)
|
||||
token_cache.store(creds.cache_key, token, expires_in)
|
||||
log.info("Vydán nový PPL token (%s), platnost %ss.", creds.environment, int(expires_in))
|
||||
return token
|
||||
|
||||
|
||||
async def get_access_token(creds: Credentials, force_refresh: bool = False) -> str:
|
||||
creds.require()
|
||||
if not force_refresh:
|
||||
cached = token_cache.get_cached(creds.cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
lock = await token_cache.acquire_lock(creds.cache_key)
|
||||
async with lock:
|
||||
if not force_refresh:
|
||||
cached = token_cache.get_cached(creds.cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
return await _fetch_token(creds)
|
||||
|
||||
|
||||
async def cpl_request(
|
||||
creds: Credentials,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Any = None,
|
||||
json_body: Any = None,
|
||||
content: bytes | None = None,
|
||||
content_type: str | None = None,
|
||||
files: Any = None,
|
||||
) -> httpx.Response:
|
||||
"""Provede autentizovaný request na PPL CPL API a vrátí surovou odpověď.
|
||||
|
||||
Na 401 zkusí jednou obnovit token a request zopakovat. Chybové statusy
|
||||
NEvyhazuje — o mapování rozhoduje volající (typované endpointy mapují,
|
||||
generická proxy předává 1:1).
|
||||
"""
|
||||
token = await get_access_token(creds)
|
||||
|
||||
for attempt in (1, 2):
|
||||
headers: dict[str, str] = {"Authorization": f"Bearer {token}"}
|
||||
if creds.accept_language:
|
||||
headers["Accept-Language"] = creds.accept_language
|
||||
if content_type and content is not None:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
await _throttle()
|
||||
try:
|
||||
resp = await _http().request(
|
||||
method,
|
||||
creds.base_url + path,
|
||||
params=params,
|
||||
json=json_body,
|
||||
content=content,
|
||||
files=files,
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
log.error("Timeout při volání PPL %s %s", method, path)
|
||||
raise UpstreamError(f"PPL CPL API neodpovědělo včas ({method} {path}).") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
log.error(
|
||||
"Chyba spojení na PPL %s %s: %s", method, path, exc.__class__.__name__
|
||||
)
|
||||
raise UpstreamError(f"PPL CPL API je nedostupné ({method} {path}).") from exc
|
||||
|
||||
if resp.status_code == 401 and attempt == 1:
|
||||
log.info("PPL vrátilo 401, obnovuji token a opakuji request.")
|
||||
token_cache.invalidate(creds.cache_key)
|
||||
token = await get_access_token(creds, force_refresh=True)
|
||||
continue
|
||||
|
||||
if resp.status_code >= 400:
|
||||
log.warning("PPL %s %s -> HTTP %s", method, path, resp.status_code)
|
||||
else:
|
||||
log.info("PPL %s %s -> HTTP %s", method, path, resp.status_code)
|
||||
return resp
|
||||
|
||||
raise UpstreamError("PPL CPL API opakovaně odmítlo request.") # pragma: no cover
|
||||
|
||||
|
||||
def _relay_headers(resp: httpx.Response) -> dict[str, str]:
|
||||
return {
|
||||
name: value
|
||||
for name, value in resp.headers.items()
|
||||
if name.lower() in _RELAY_HEADERS
|
||||
}
|
||||
|
||||
|
||||
def ensure_success(resp: httpx.Response) -> None:
|
||||
"""Vyhodí typovanou chybu služby, pokud PPL vrátilo chybový status."""
|
||||
if resp.status_code >= 400:
|
||||
raise_for_upstream(
|
||||
resp.status_code, resp.text, resp.headers.get("content-type", "")
|
||||
)
|
||||
|
||||
|
||||
def relay_json(resp: httpx.Response) -> JSONResponse:
|
||||
"""Předá JSON odpověď PPL klientovi vč. paging hlaviček. Chyby mapuje."""
|
||||
ensure_success(resp)
|
||||
body = None
|
||||
if resp.content:
|
||||
body = resp.json()
|
||||
return JSONResponse(
|
||||
status_code=resp.status_code, content=body, headers=_relay_headers(resp)
|
||||
)
|
||||
|
||||
|
||||
def relay_binary(resp: httpx.Response) -> Response:
|
||||
"""Předá binární odpověď PPL (etikety PDF/ZPL/JPG...) klientovi. Chyby mapuje."""
|
||||
ensure_success(resp)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
headers=_relay_headers(resp),
|
||||
)
|
||||
|
||||
|
||||
def relay_raw(resp: httpx.Response) -> Response:
|
||||
"""Předá odpověď PPL 1:1 (vč. chybových statusů) — pro generickou proxy."""
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type"),
|
||||
headers=_relay_headers(resp),
|
||||
)
|
||||
|
||||
|
||||
def batch_id_from_location(resp: httpx.Response) -> str:
|
||||
"""Vytáhne batchId z Location hlavičky odpovědi POST shipment/order batch."""
|
||||
location = resp.headers.get("location", "")
|
||||
if not location:
|
||||
log.error("PPL nevrátilo Location hlavičku u batch requestu.")
|
||||
raise UpstreamError("PPL nevrátilo Location hlavičku s batchId.")
|
||||
return location.rstrip("/").split("/")[-1]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Extrakce per-request přihlašovacích údajů z X- hlaviček.
|
||||
|
||||
Secrets (ClientId/ClientSecret vydané PPL) chodí VÝHRADNĚ v hlavičkách,
|
||||
nikdy v těle requestu ani v URL. Nic se neukládá — služba je stateless;
|
||||
jedinou výjimkou je in-memory cache OAuth tokenů (viz token_cache.py),
|
||||
klíčovaná hashem údajů, protože PPL limituje vydávání tokenů na 12/min.
|
||||
"""
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Header
|
||||
|
||||
from .config import CPL_DEFAULT_ENVIRONMENT, CPL_PRODUCTION_BASE_URL, CPL_TEST_BASE_URL
|
||||
from .errors import BadRequestError, CredentialsError
|
||||
|
||||
_ENVIRONMENTS = ("production", "test")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
client_id: str | None
|
||||
client_secret: str | None
|
||||
environment: str
|
||||
accept_language: str | None = None
|
||||
|
||||
def require(self) -> None:
|
||||
if not self.client_id or not self.client_id.strip():
|
||||
raise CredentialsError("Chybí hlavička X-Client-Id.")
|
||||
if not self.client_secret or not self.client_secret.strip():
|
||||
raise CredentialsError("Chybí hlavička X-Client-Secret.")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
if self.environment == "test":
|
||||
return CPL_TEST_BASE_URL
|
||||
return CPL_PRODUCTION_BASE_URL
|
||||
|
||||
@property
|
||||
def cache_key(self) -> str:
|
||||
"""Klíč do token cache — hash, aby se secrets nikde neobjevily v paměti navíc."""
|
||||
raw = f"{self.client_id}|{self.client_secret}|{self.environment}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def get_credentials(
|
||||
x_client_id: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Client-Id",
|
||||
description="PPL CPL ClientId (secret, přiděluje PPL).",
|
||||
),
|
||||
x_client_secret: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Client-Secret",
|
||||
description="PPL CPL ClientSecret (secret, přiděluje PPL).",
|
||||
),
|
||||
x_environment: str | None = Header(
|
||||
default=None,
|
||||
alias="X-Environment",
|
||||
description=(
|
||||
"Cílové prostředí PPL: `production` nebo `test`. "
|
||||
f"Bez hlavičky se použije `{CPL_DEFAULT_ENVIRONMENT}`."
|
||||
),
|
||||
),
|
||||
accept_language: str | None = Header(
|
||||
default=None,
|
||||
alias="Accept-Language",
|
||||
description="Volitelný jazyk odpovědí PPL (např. `cs-CZ`). Předává se dál.",
|
||||
),
|
||||
) -> Credentials:
|
||||
environment = (x_environment or CPL_DEFAULT_ENVIRONMENT).strip().lower()
|
||||
if environment not in _ENVIRONMENTS:
|
||||
raise BadRequestError(
|
||||
f"Neplatná hodnota X-Environment '{environment}'. Povolené: production, test."
|
||||
)
|
||||
return Credentials(
|
||||
client_id=x_client_id,
|
||||
client_secret=x_client_secret,
|
||||
environment=environment,
|
||||
accept_language=accept_language,
|
||||
)
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
"""Typované výjimky + centrální exception handlery.
|
||||
|
||||
Platí pravidlo: žádná tichá selhání — každá chyba se loguje (bez secrets).
|
||||
Chyby upstreamu (PPL CPL API) se mapují na stejné/odpovídající HTTP statusy,
|
||||
tělo problem+json z PPL se předává v poli `detail`, ať klient vidí přesnou příčinu.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .logging_config import get_logger
|
||||
|
||||
log = get_logger("pplcpl.errors")
|
||||
|
||||
|
||||
class CplServiceError(Exception):
|
||||
"""Základní chyba služby s HTTP status kódem."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None, detail: Any = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class CredentialsError(CplServiceError):
|
||||
"""Chybějící / PPL odmítnuté přihlašovací údaje (X- hlavičky)."""
|
||||
|
||||
status_code = 401
|
||||
|
||||
|
||||
class ForbiddenError(CplServiceError):
|
||||
"""PPL odmítlo přístup (chybí oprávnění / role k dané metodě)."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class BadRequestError(CplServiceError):
|
||||
"""Neplatný vstup — validační chyba na straně PPL nebo této služby."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class NotFoundError(CplServiceError):
|
||||
"""Záznam (batch, zásilka, objednávka) v PPL neexistuje."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
class RateLimitError(CplServiceError):
|
||||
"""Upstream rate limit (HTTP 429)."""
|
||||
|
||||
status_code = 429
|
||||
|
||||
|
||||
class UpstreamError(CplServiceError):
|
||||
"""Výpadek / neočekávaná chyba PPL CPL API."""
|
||||
|
||||
status_code = 502
|
||||
|
||||
|
||||
def _parse_detail(body: str, content_type: str) -> Any:
|
||||
"""Problem+json z PPL vracíme jako objekt, jiná těla jako ořezaný text."""
|
||||
if "json" in (content_type or ""):
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
log.warning("PPL vrátilo nevalidní JSON v chybové odpovědi.")
|
||||
return (body or "")[:1000]
|
||||
|
||||
|
||||
def raise_for_upstream(status: int, body: str, content_type: str = "") -> None:
|
||||
"""Zmapuje chybový HTTP status z PPL CPL API na správnou chybu služby.
|
||||
|
||||
- 400/422 → 400 (validační chyba — detail obsahuje problem+json z PPL)
|
||||
- 401 → 401 (PPL odmítlo token / přihlašovací údaje)
|
||||
- 403 → 403 (chybějící oprávnění k metodě)
|
||||
- 404 → 404 (batch / zásilka / objednávka neexistuje)
|
||||
- 429 → 429 (rate limit)
|
||||
- jinak → 502 (výpadek / neočekávaná chyba PPL)
|
||||
"""
|
||||
detail = _parse_detail(body, content_type)
|
||||
if status in (400, 422):
|
||||
raise BadRequestError(
|
||||
f"PPL CPL API odmítlo požadavek (HTTP {status}) — validační chyba.",
|
||||
detail=detail,
|
||||
)
|
||||
if status == 401:
|
||||
raise CredentialsError(
|
||||
"PPL CPL API odmítlo přihlašovací údaje / token (HTTP 401).",
|
||||
detail=detail,
|
||||
)
|
||||
if status == 403:
|
||||
raise ForbiddenError(
|
||||
"PPL CPL API odmítlo přístup (HTTP 403) — chybí oprávnění k metodě.",
|
||||
detail=detail,
|
||||
)
|
||||
if status == 404:
|
||||
raise NotFoundError(
|
||||
"Záznam v PPL CPL API neexistuje (HTTP 404).",
|
||||
detail=detail,
|
||||
)
|
||||
if status == 429:
|
||||
raise RateLimitError("PPL CPL API rate limit (HTTP 429).", detail=detail)
|
||||
raise UpstreamError(f"PPL CPL API vrátilo chybu {status}.", detail=detail)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(CplServiceError)
|
||||
async def _handle_service_error(request: Request, exc: CplServiceError):
|
||||
log.warning(
|
||||
"%s on %s: %s",
|
||||
exc.__class__.__name__,
|
||||
request.url.path,
|
||||
exc.message,
|
||||
)
|
||||
body = {"error": exc.__class__.__name__, "message": exc.message}
|
||||
if exc.detail is not None:
|
||||
body["detail"] = exc.detail
|
||||
return JSONResponse(status_code=exc.status_code, content=body)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _handle_unexpected(request: Request, exc: Exception):
|
||||
# Nelogujeme celý stack s možnými secrets ve vstupu; logujeme typ + zprávu.
|
||||
log.error(
|
||||
"Unhandled %s on %s: %s",
|
||||
exc.__class__.__name__,
|
||||
request.url.path,
|
||||
exc,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "InternalError", "message": "Neočekávaná chyba serveru."},
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Centrální logging. Nikdy nelogujeme secrets (ClientId/ClientSecret, tokeny)."""
|
||||
import logging
|
||||
import os
|
||||
|
||||
_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=_LEVEL,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
+84
-15
@@ -1,25 +1,94 @@
|
||||
"""Vstupní bod aplikace pplcplapi.
|
||||
|
||||
Stateless FastAPI služba běžící v AppFactory za reverse proxy `/apps/<app-id>`.
|
||||
Multi-tenant proxy nad PPL CPL API (Create Package Label): tvorba zásilek
|
||||
a tisk etiket, tracking, objednávky svozu, číselníky, výdejní místa.
|
||||
|
||||
Přihlašovací údaje PPL se předávají per-request v X- hlavičkách, nikdy se
|
||||
neukládají ani nelogují. Jedinou výjimkou je in-memory cache OAuth tokenů
|
||||
(PPL limituje vydávání tokenů na 12/min), klíčovaná hashem údajů.
|
||||
"""
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "PPL CPL API")
|
||||
APP_VERSION = os.getenv("APP_VERSION", "1.0.0")
|
||||
ROOT_PATH = os.getenv("ROOT_PATH", "")
|
||||
from .config import APP_NAME, APP_VERSION, ROOT_PATH
|
||||
from .cpl_client import close_client
|
||||
from .errors import register_exception_handlers
|
||||
from .logging_config import configure_logging
|
||||
from .routers import codelists, customer, lookups, meta, orders, proxy, shipments
|
||||
|
||||
configure_logging()
|
||||
|
||||
DESCRIPTION = """
|
||||
Proxy nad **PPL CPL API** (Create Package Label) — tvorba zásilek a etiket,
|
||||
tracking, objednávky svozu, číselníky, výdejní místa a našeptávač adres.
|
||||
|
||||
### Přihlašovací údaje (hlavičky)
|
||||
Secrets se předávají v hlavičkách u každého requestu — nikdy v těle ani v URL:
|
||||
|
||||
- `X-Client-Id` — PPL CPL ClientId
|
||||
- `X-Client-Secret` — PPL CPL ClientSecret
|
||||
- `X-Environment` — volitelně `production` (default) nebo `test`
|
||||
|
||||
Služba si sama vyžádá a cachuje OAuth Bearer token (platnost 30 min,
|
||||
PPL limit 12 tokenů/min) a dodržuje minimální rozestup requestů 40 ms.
|
||||
|
||||
### Asynchronní tok zásilek
|
||||
CPL API zpracovává zásilky i objednávky dávkově:
|
||||
`POST /shipments/batch` vrátí `batchId` → stav se sleduje přes
|
||||
`GET /shipments/batch/{batchId}` → etikety přes `GET /shipments/batch/{batchId}/labels`.
|
||||
Pro jednoduché použití slouží `POST /shipments/create-and-wait`
|
||||
(a `POST /orders/create-and-wait`), které celý tok provedou v jednom requestu.
|
||||
|
||||
### Diakritika
|
||||
CPL API přijímá pouze Latin znaky bez diakritiky. Texty v tělech create
|
||||
requestů se defaultně transliterují (`Jiří` → `Jiri`); vypnout lze query
|
||||
parametrem `transliterate=false`.
|
||||
|
||||
### Generická proxy
|
||||
Cokoliv, co nemá vlastní endpoint: `/proxy/{cesta}` předá request 1:1 na CPL
|
||||
API s doplněnou autentizací (např. `GET /proxy/codelist/product?Limit=10&Offset=0`).
|
||||
|
||||
### Chyby
|
||||
JSON `{error, message, detail}` — `detail` obsahuje problem+json z PPL.
|
||||
400 = validační chyba, 401 = chybějící/odmítnuté přihlašovací údaje,
|
||||
403 = chybí oprávnění, 404 = záznam neexistuje, 429 = rate limit,
|
||||
502 = výpadek PPL.
|
||||
"""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await close_client()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=APP_NAME,
|
||||
version=APP_VERSION,
|
||||
root_path=ROOT_PATH
|
||||
description=DESCRIPTION,
|
||||
root_path=ROOT_PATH,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
register_exception_handlers(app)
|
||||
|
||||
@app.get("/version")
|
||||
def version():
|
||||
return {
|
||||
"app": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"language": "python",
|
||||
"root_path": ROOT_PATH
|
||||
}
|
||||
app.include_router(meta.router)
|
||||
app.include_router(shipments.router)
|
||||
app.include_router(orders.router)
|
||||
app.include_router(codelists.router)
|
||||
app.include_router(lookups.router)
|
||||
app.include_router(customer.router)
|
||||
app.include_router(proxy.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=int(os.getenv("PORT", "8000")),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Číselníky CPL API + informace o verzích a stavu API.
|
||||
|
||||
Všechny číselníky jsou GET s povinným stránkováním (Limit/Offset) a vracejí
|
||||
X-Paging-* hlavičky, které služba předává dál.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..cpl_client import cpl_request, relay_json
|
||||
from ..credentials import Credentials, get_credentials
|
||||
|
||||
router = APIRouter(tags=["codelists"])
|
||||
|
||||
|
||||
class Codelist(str, Enum):
|
||||
"""Názvy číselníků dle CPL API (tvoří cestu /codelist/{name})."""
|
||||
|
||||
ageCheck = "ageCheck"
|
||||
product = "product"
|
||||
externalNumber = "externalNumber"
|
||||
country = "country"
|
||||
currency = "currency"
|
||||
service = "service"
|
||||
servicePriceLimit = "servicePriceLimit"
|
||||
shipmentPhase = "shipmentPhase"
|
||||
status = "status"
|
||||
validationMessage = "validationMessage"
|
||||
proofOfIdentityType = "proofOfIdentityType"
|
||||
documentFileType = "documentFileType"
|
||||
|
||||
|
||||
@router.get("/codelists/{codelist}", summary="Číselník CPL API")
|
||||
async def get_codelist(
|
||||
codelist: Codelist,
|
||||
limit: int = Query(default=1000, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET codelist/{name}` — např. product (produkty), country (země + povolení COD),
|
||||
currency (měny), service (služby), servicePriceLimit (min/max hodnoty služeb),
|
||||
status (statusy zásilky), validationMessage (chybové kódy)."""
|
||||
resp = await cpl_request(
|
||||
creds,
|
||||
"GET",
|
||||
f"/codelist/{codelist.value}",
|
||||
params={"Limit": limit, "Offset": offset},
|
||||
)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/version-information", summary="Novinky a změny verzí CPL API")
|
||||
async def version_information(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET versionInformation` — přehled novinek/změn API publikovaných PPL."""
|
||||
resp = await cpl_request(
|
||||
creds, "GET", "/versionInformation", params={"Limit": limit, "Offset": offset}
|
||||
)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/cpl-info", summary="Stav a verze CPL API (upstream /info)")
|
||||
async def cpl_info(creds: Credentials = Depends(get_credentials)):
|
||||
"""`GET info` — rychlé ověření, že PPL CPL API běží (verze, stav, čas serveru)."""
|
||||
resp = await cpl_request(creds, "GET", "/info")
|
||||
return relay_json(resp)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Zákaznická data: bankovní účty (měny pro dobírku), adresy, číselné řady."""
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from ..cpl_client import cpl_request, relay_json
|
||||
from ..credentials import Credentials, get_credentials
|
||||
|
||||
router = APIRouter(prefix="/customer", tags=["customer"])
|
||||
|
||||
|
||||
@router.get("", summary="Informace k zákazníkovi (účty, měny pro dobírku)")
|
||||
async def customer_info(creds: Credentials = Depends(get_credentials)):
|
||||
"""`GET customer` — registrované bankovní účty vč. měny, země a SWIFT."""
|
||||
resp = await cpl_request(creds, "GET", "/customer")
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/addresses", summary="Registrované adresy zákazníka")
|
||||
async def customer_addresses(creds: Credentials = Depends(get_credentials)):
|
||||
"""`GET customer/address` — adresy registrované u PPL (kód, adresa, default)."""
|
||||
resp = await cpl_request(creds, "GET", "/customer/address")
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post("/number-range", summary="Založení číselné řady zásilek")
|
||||
async def create_number_range(
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[{"productType": "BUSS", "quantity": 100}],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST customer/numberRange` — přidělí rozsah čísel zásilek
|
||||
(packNumberFrom–packNumberTo) pro daný productType."""
|
||||
resp = await cpl_request(creds, "POST", "/customer/numberRange", json_body=body)
|
||||
return relay_json(resp)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Vyhledávací metody: výdejní místa, našeptávač adres, routing, tisková data."""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from ..cpl_client import cpl_request, relay_binary, relay_json
|
||||
from ..credentials import Credentials, get_credentials
|
||||
|
||||
router = APIRouter(tags=["lookups"])
|
||||
|
||||
|
||||
@router.get("/access-points", summary="Seznam výdejních míst (ParcelShop/ParcelBox/AlzaBox)")
|
||||
async def access_points(
|
||||
country_code: str = Query(..., alias="countryCode", description="Kód země, např. CZ."),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
access_point_code: str | None = Query(default=None, alias="accessPointCode"),
|
||||
zip_code: str | None = Query(default=None, alias="zipCode"),
|
||||
city: str | None = Query(default=None),
|
||||
access_point_types: list[str] | None = Query(
|
||||
default=None,
|
||||
alias="accessPointTypes",
|
||||
description="ParcelShop, ParcelBox, AlzaBox.",
|
||||
),
|
||||
latitude: float | None = Query(default=None),
|
||||
longitude: float | None = Query(default=None),
|
||||
radius: float | None = Query(default=None, description="Poloměr hledání v km."),
|
||||
pickup_enabled: bool | None = Query(default=None, alias="pickupEnabled"),
|
||||
active_card_payment: bool | None = Query(default=None, alias="activeCardPayment"),
|
||||
active_cash_payment: bool | None = Query(default=None, alias="activeCashPayment"),
|
||||
sizes: list[str] | None = Query(default=None, description="S, M, L, XL."),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET accessPoint` — výdejní místa vč. otevírací doby, GPS a kapacit."""
|
||||
params: dict[str, Any] = {
|
||||
"CountryCode": country_code,
|
||||
"Limit": limit,
|
||||
"Offset": offset,
|
||||
}
|
||||
if access_point_code:
|
||||
params["AccessPointCode"] = access_point_code
|
||||
if zip_code:
|
||||
params["ZipCode"] = zip_code
|
||||
if city:
|
||||
params["City"] = city
|
||||
if access_point_types:
|
||||
params["AccessPointTypes"] = access_point_types
|
||||
if latitude is not None:
|
||||
params["Latitude"] = latitude
|
||||
if longitude is not None:
|
||||
params["Longitude"] = longitude
|
||||
if radius is not None:
|
||||
params["Radius"] = radius
|
||||
if pickup_enabled is not None:
|
||||
params["PickupEnabled"] = pickup_enabled
|
||||
if active_card_payment is not None:
|
||||
params["ActiveCardPayment"] = active_card_payment
|
||||
if active_cash_payment is not None:
|
||||
params["ActiveCashPayment"] = active_cash_payment
|
||||
if sizes:
|
||||
params["Sizes"] = sizes
|
||||
resp = await cpl_request(creds, "GET", "/accessPoint", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/address-whisper", summary="Našeptávač adres")
|
||||
async def address_whisper(
|
||||
street: str | None = Query(default=None),
|
||||
zip_code: str | None = Query(default=None, alias="zipCode"),
|
||||
city: str | None = Query(default=None),
|
||||
called_from: str | None = Query(
|
||||
default=None,
|
||||
alias="calledFrom",
|
||||
description="Které pole vyvolalo dotaz: Street, ZipCode nebo City.",
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET addressWhisper` — validace/doplnění adresy (vrací i pole `valid`)."""
|
||||
params: dict[str, Any] = {}
|
||||
if street:
|
||||
params["Street"] = street
|
||||
if zip_code:
|
||||
params["ZipCode"] = zip_code
|
||||
if city:
|
||||
params["City"] = city
|
||||
if called_from:
|
||||
params["CalledFrom"] = called_from
|
||||
resp = await cpl_request(creds, "GET", "/addressWhisper", params=params or None)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/routing", summary="Směrovací informace pro etiketu")
|
||||
async def routing(
|
||||
country: str = Query(..., description="Kód země (povinný), např. CZ."),
|
||||
street: str | None = Query(default=None),
|
||||
city: str | None = Query(default=None),
|
||||
zip_code: str | None = Query(default=None, alias="zipCode"),
|
||||
product_type: str | None = Query(default=None, alias="productType"),
|
||||
parcel_shop_code: str | None = Query(default=None, alias="parcelShopCode"),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET routing` — kód trasy, depo a pozice v depu pro danou adresu."""
|
||||
params: dict[str, Any] = {"Country": country}
|
||||
if street:
|
||||
params["Street"] = street
|
||||
if city:
|
||||
params["City"] = city
|
||||
if zip_code:
|
||||
params["ZipCode"] = zip_code
|
||||
if product_type:
|
||||
params["ProductType"] = product_type
|
||||
if parcel_shop_code:
|
||||
params["ParcelShopCode"] = parcel_shop_code
|
||||
resp = await cpl_request(creds, "GET", "/routing", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get("/data/{data_guid}", summary="Tisková data etikety (binární)")
|
||||
async def get_data(
|
||||
data_guid: str,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET data/{dataGuid}` — stažení jednotlivé etikety přes labelUrl guid
|
||||
(používá se hlavně po změně formátu Pdf -> Zpl)."""
|
||||
resp = await cpl_request(creds, "GET", f"/data/{data_guid}")
|
||||
return relay_binary(resp)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Povinné meta endpointy: /health a /version."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from ..config import APP_NAME, APP_VERSION, ROOT_PATH
|
||||
|
||||
router = APIRouter(tags=["meta"])
|
||||
|
||||
|
||||
@router.get("/health", summary="Health check")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/version", summary="Verze a runtime informace")
|
||||
def version():
|
||||
return {
|
||||
"app": APP_NAME,
|
||||
"version": APP_VERSION,
|
||||
"language": "python",
|
||||
"root_path": ROOT_PATH,
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Objednávky přepravy / svozu — tvorba (batch), stav, vyhledání, zrušení.
|
||||
|
||||
Stejně jako zásilky jsou objednávky asynchronní: POST order/batch vrátí batchId
|
||||
(Location hlavička), stav zpracování se polluje přes GET order/batch/{batchId}.
|
||||
|
||||
Typy objednávek (pole orderType v těle):
|
||||
- CollectionOrder — svoz z registrované adresy zákazníka (bez recipient)
|
||||
- TransportOrder — přeprava z libovolné adresy (sender i recipient povinné)
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
|
||||
from ..config import (
|
||||
BATCH_POLL_INTERVAL_SECONDS,
|
||||
BATCH_WAIT_TIMEOUT_SECONDS,
|
||||
TRANSLITERATE_DEFAULT,
|
||||
)
|
||||
from ..cpl_client import (
|
||||
batch_id_from_location,
|
||||
cpl_request,
|
||||
ensure_success,
|
||||
relay_json,
|
||||
)
|
||||
from ..credentials import Credentials, get_credentials
|
||||
from ..logging_config import get_logger
|
||||
from ..transliterate import transliterate_json
|
||||
|
||||
log = get_logger("pplcpl.orders")
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["orders"])
|
||||
|
||||
_EXAMPLE_ORDER_BODY = {
|
||||
"orders": [
|
||||
{
|
||||
"orderType": "TransportOrder",
|
||||
"referenceId": "ORD-0001",
|
||||
"productType": "BUSS",
|
||||
"shipmentCount": 1,
|
||||
"sendDate": "2026-07-17",
|
||||
"customerReference": "Zakazka 123",
|
||||
"email": "odesilatel@example.com",
|
||||
"sender": {
|
||||
"name": "Firma s.r.o.",
|
||||
"street": "Prazska 123/4",
|
||||
"city": "Praha",
|
||||
"zipCode": "10000",
|
||||
"country": "CZ",
|
||||
"phone": "+420601123456",
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Jan Novak",
|
||||
"street": "Brnenska 10",
|
||||
"city": "Brno",
|
||||
"zipCode": "60200",
|
||||
"country": "CZ",
|
||||
"phone": "+420602123456",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
_PENDING_STATES = ("Accepted", "InProcess")
|
||||
|
||||
|
||||
def _maybe_transliterate(body: dict, transliterate: bool | None) -> dict:
|
||||
apply = TRANSLITERATE_DEFAULT if transliterate is None else transliterate
|
||||
return transliterate_json(body) if apply else body
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
status_code=201,
|
||||
summary="Vytvoření objednávky přepravy / svozu (asynchronní)",
|
||||
)
|
||||
async def create_order_batch(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_ORDER_BODY]),
|
||||
transliterate: bool | None = Query(default=None),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST order/batch` — vrací batchId z Location hlavičky (max 100 objednávek)."""
|
||||
resp = await cpl_request(
|
||||
creds, "POST", "/order/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(resp)
|
||||
return {
|
||||
"batchId": batch_id_from_location(resp),
|
||||
"location": resp.headers.get("location"),
|
||||
"correlationId": resp.headers.get("x-correlation-id"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/batch/{batch_id}", summary="Stav zpracování objednávky (batch)")
|
||||
async def get_order_batch_status(
|
||||
batch_id: str,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET order/batch/{batchId}` — importState: Accepted/InProcess/Complete/Error."""
|
||||
resp = await cpl_request(creds, "GET", f"/order/batch/{batch_id}")
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/create-and-wait",
|
||||
summary="Vytvoření objednávky a počkání na zpracování (synchronní obálka)",
|
||||
)
|
||||
async def create_order_and_wait(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_ORDER_BODY]),
|
||||
timeout_seconds: float = Query(default=BATCH_WAIT_TIMEOUT_SECONDS, ge=1, le=120),
|
||||
transliterate: bool | None = Query(default=None),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Convenience: POST order/batch + polling stavu v jednom requestu.
|
||||
`completed=false` znamená timeout — zpracování v PPL běží dál."""
|
||||
create_resp = await cpl_request(
|
||||
creds, "POST", "/order/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(create_resp)
|
||||
batch_id = batch_id_from_location(create_resp)
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
status_data: dict = {}
|
||||
completed = False
|
||||
while True:
|
||||
status_resp = await cpl_request(creds, "GET", f"/order/batch/{batch_id}")
|
||||
ensure_success(status_resp)
|
||||
status_data = status_resp.json() or {}
|
||||
items = status_data.get("items") or []
|
||||
pending = [i for i in items if i.get("importState") in _PENDING_STATES]
|
||||
if items and not pending:
|
||||
completed = True
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
log.warning(
|
||||
"Order batch %s nebyl zpracován do %ss, vracím completed=false.",
|
||||
batch_id,
|
||||
timeout_seconds,
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(BATCH_POLL_INTERVAL_SECONDS)
|
||||
|
||||
return {"batchId": batch_id, "completed": completed, **status_data}
|
||||
|
||||
|
||||
@router.get("", summary="Vyhledání objednávek přepravy")
|
||||
async def find_orders(
|
||||
shipment_numbers: list[str] | None = Query(default=None, alias="shipmentNumbers"),
|
||||
customer_references: list[str] | None = Query(
|
||||
default=None, alias="customerReferences"
|
||||
),
|
||||
order_references: list[str] | None = Query(
|
||||
default=None,
|
||||
alias="orderReferences",
|
||||
description="referenceId hodnoty z POST order/batch.",
|
||||
),
|
||||
order_numbers: list[str] | None = Query(default=None, alias="orderNumbers"),
|
||||
order_ids: list[int] | None = Query(default=None, alias="orderIds"),
|
||||
date_from: str | None = Query(default=None, alias="dateFrom"),
|
||||
date_to: str | None = Query(default=None, alias="dateTo"),
|
||||
send_date: str | None = Query(default=None, alias="sendDate"),
|
||||
product_type: str | None = Query(default=None, alias="productType"),
|
||||
order_states: str | None = Query(
|
||||
default=None,
|
||||
alias="orderStates",
|
||||
description="None, Created, PickedUp, NotPickedUp, Canceled.",
|
||||
),
|
||||
order_type: str | None = Query(
|
||||
default=None, alias="orderType", description="CollectionOrder / TransportOrder."
|
||||
),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET order` — informace o objednávkách vč. stavu a přidělených čísel zásilek."""
|
||||
params: dict[str, Any] = {"Limit": limit, "Offset": offset}
|
||||
if shipment_numbers:
|
||||
params["ShipmentNumbers"] = shipment_numbers
|
||||
if customer_references:
|
||||
params["CustomerReferences"] = customer_references
|
||||
if order_references:
|
||||
params["OrderReferences"] = order_references
|
||||
if order_numbers:
|
||||
params["OrderNumbers"] = order_numbers
|
||||
if order_ids:
|
||||
params["OrderIds"] = order_ids
|
||||
if date_from:
|
||||
params["DateFrom"] = date_from
|
||||
if date_to:
|
||||
params["DateTo"] = date_to
|
||||
if send_date:
|
||||
params["SendDate"] = send_date
|
||||
if product_type:
|
||||
params["ProductType"] = product_type
|
||||
if order_states:
|
||||
params["OrderStates"] = order_states
|
||||
if order_type:
|
||||
params["OrderType"] = order_type
|
||||
resp = await cpl_request(creds, "GET", "/order", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post("/cancel", summary="Zrušení objednávky svozu / přepravy")
|
||||
async def cancel_order(
|
||||
customer_reference: str | None = Query(
|
||||
default=None, alias="customerReference", description="Reference odesílatele."
|
||||
),
|
||||
order_reference: str | None = Query(
|
||||
default=None, alias="orderReference", description="Reference objednávky."
|
||||
),
|
||||
body: dict = Body(default={}, examples=[{"note": "Zrušeno zákazníkem"}]),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST order/cancel` — identifikace přes customerReference nebo orderReference."""
|
||||
params: dict[str, Any] = {}
|
||||
if customer_reference:
|
||||
params["customerReference"] = customer_reference
|
||||
if order_reference:
|
||||
params["orderReference"] = order_reference
|
||||
resp = await cpl_request(
|
||||
creds, "POST", "/order/cancel", params=params or None, json_body=body or {}
|
||||
)
|
||||
return relay_json(resp)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Generická proxy na libovolnou metodu PPL CPL API.
|
||||
|
||||
Pokrývá i endpointy, které nemají vlastní typovanou obálku, a budoucí novinky
|
||||
API bez nutnosti upravovat tuto službu. Služba doplní OAuth token, dodrží
|
||||
rozestup requestů a odpověď PPL vrátí 1:1 (status, tělo, content-type,
|
||||
Location a X-Paging-* hlavičky).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from ..cpl_client import cpl_request, relay_raw
|
||||
from ..credentials import Credentials, get_credentials
|
||||
|
||||
router = APIRouter(tags=["proxy"])
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/proxy/{cpl_path:path}",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
summary="Generické volání libovolné metody CPL API",
|
||||
)
|
||||
async def proxy(
|
||||
cpl_path: str,
|
||||
request: Request,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Příklad: `GET /proxy/codelist/product?Limit=10&Offset=0` zavolá
|
||||
`GET {base}/codelist/product?Limit=10&Offset=0` s doplněným Bearer tokenem.
|
||||
|
||||
Tělo requestu (JSON i jiné) se předává beze změny; transliterace diakritiky
|
||||
se zde NEaplikuje — za obsah odpovídá volající."""
|
||||
body = await request.body()
|
||||
resp = await cpl_request(
|
||||
creds,
|
||||
request.method,
|
||||
"/" + cpl_path.lstrip("/"),
|
||||
params=list(request.query_params.multi_items()) or None,
|
||||
content=body if body else None,
|
||||
content_type=request.headers.get("content-type") if body else None,
|
||||
)
|
||||
return relay_raw(resp)
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Zásilky — tvorba (batch), stav importu, etikety, tracking, storno, úpravy.
|
||||
|
||||
Tok tvorby zásilky v CPL API je asynchronní:
|
||||
1. POST /shipments/batch -> PPL vrátí batchId (z Location hlavičky)
|
||||
2. GET /shipments/batch/{batchId} -> polling importState (Accepted/InProcess/Complete/Error)
|
||||
3. GET /shipments/batch/{batchId}/labels -> binární etikety (PDF/ZPL/JPG...)
|
||||
|
||||
Pro typické použití je k dispozici POST /shipments/create-and-wait, který celý
|
||||
tok provede v jednom requestu (vytvoří, počká na zpracování, volitelně vrátí
|
||||
etikety v base64).
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, UploadFile
|
||||
|
||||
from ..config import (
|
||||
BATCH_POLL_INTERVAL_SECONDS,
|
||||
BATCH_WAIT_TIMEOUT_SECONDS,
|
||||
TRANSLITERATE_DEFAULT,
|
||||
)
|
||||
from ..cpl_client import (
|
||||
batch_id_from_location,
|
||||
cpl_request,
|
||||
ensure_success,
|
||||
relay_binary,
|
||||
relay_json,
|
||||
)
|
||||
from ..credentials import Credentials, get_credentials
|
||||
from ..errors import BadRequestError
|
||||
from ..logging_config import get_logger
|
||||
from ..transliterate import transliterate_json
|
||||
|
||||
log = get_logger("pplcpl.shipments")
|
||||
|
||||
router = APIRouter(prefix="/shipments", tags=["shipments"])
|
||||
|
||||
_EXAMPLE_SHIPMENT_BODY = {
|
||||
"shipments": [
|
||||
{
|
||||
"referenceId": "REF-0001",
|
||||
"productType": "BUSS",
|
||||
"note": "Volitelna poznamka",
|
||||
"sender": {
|
||||
"name": "Firma s.r.o.",
|
||||
"street": "Prazska 123/4",
|
||||
"city": "Praha",
|
||||
"zipCode": "10000",
|
||||
"country": "CZ",
|
||||
"phone": "+420601123456",
|
||||
"email": "odesilatel@example.com",
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Jan Novak",
|
||||
"street": "Brnenska 10",
|
||||
"city": "Brno",
|
||||
"zipCode": "60200",
|
||||
"country": "CZ",
|
||||
"phone": "+420602123456",
|
||||
"email": "prijemce@example.com",
|
||||
},
|
||||
}
|
||||
],
|
||||
"labelSettings": {
|
||||
"format": "Pdf",
|
||||
"completeLabelSettings": {"isCompleteLabelRequested": True, "pageSize": "A4"},
|
||||
},
|
||||
}
|
||||
|
||||
_PENDING_STATES = ("Accepted", "InProcess")
|
||||
|
||||
|
||||
def _maybe_transliterate(body: dict, transliterate: bool | None) -> dict:
|
||||
apply = TRANSLITERATE_DEFAULT if transliterate is None else transliterate
|
||||
return transliterate_json(body) if apply else body
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
status_code=201,
|
||||
summary="Vytvoření zásilky / sady zásilek (asynchronní)",
|
||||
)
|
||||
async def create_shipment_batch(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]),
|
||||
transliterate: bool | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Převést diakritiku na ASCII (CPL přijímá jen Latin znaky). "
|
||||
"Bez zadání se použije default služby."
|
||||
),
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Odešle `POST shipment/batch` do PPL. Vrací `batchId` z Location hlavičky —
|
||||
tím se následně dotazuje stav importu a stahují etikety."""
|
||||
resp = await cpl_request(
|
||||
creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(resp)
|
||||
batch_id = batch_id_from_location(resp)
|
||||
return {
|
||||
"batchId": batch_id,
|
||||
"location": resp.headers.get("location"),
|
||||
"correlationId": resp.headers.get("x-correlation-id"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/batch/{batch_id}", summary="Stav importu zásilek v batchi")
|
||||
async def get_shipment_batch_status(
|
||||
batch_id: str,
|
||||
order_by: str | None = Query(
|
||||
default=None,
|
||||
alias="orderBy",
|
||||
description="Řazení: ShipmentNumber nebo ReferenceId, prefix `-` = sestupně.",
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment/batch/{batchId}` — importState položek: Accepted, InProcess,
|
||||
Complete (etikety připraveny), Error (viz errorMessage/errorCode)."""
|
||||
params = {"OrderBy": order_by} if order_by else None
|
||||
resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/batch/{batch_id}/labels",
|
||||
summary="Stažení etiket batche (binární PDF/ZPL/JPG...)",
|
||||
)
|
||||
async def get_shipment_batch_labels(
|
||||
batch_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
page_size: str | None = Query(
|
||||
default=None, alias="pageSize", description="Default nebo A4."
|
||||
),
|
||||
position: int | None = Query(
|
||||
default=None, ge=1, le=4, description="Pozice etikety na A4 (1–4)."
|
||||
),
|
||||
order_by: str | None = Query(default=None, alias="orderBy"),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment/batch/{batchId}/label` — vrací etikety jako binární soubor
|
||||
ve formátu nastaveném při vytvoření batche (labelSettings.format)."""
|
||||
params: dict[str, Any] = {"Limit": limit, "Offset": offset}
|
||||
if page_size:
|
||||
params["PageSize"] = page_size
|
||||
if position is not None:
|
||||
params["Position"] = position
|
||||
if order_by:
|
||||
params["OrderBy"] = order_by
|
||||
resp = await cpl_request(
|
||||
creds, "GET", f"/shipment/batch/{batch_id}/label", params=params
|
||||
)
|
||||
return relay_binary(resp)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/batch/{batch_id}/label-settings",
|
||||
status_code=204,
|
||||
summary="Úprava výstupního formátu etikety batche",
|
||||
)
|
||||
async def update_label_settings(
|
||||
batch_id: str,
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"labelSettings": {"format": "Zpl", "dpi": 300},
|
||||
"returnChannel": {"type": "None"},
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`PUT shipment/batch/{batchId}` — změna formátu (Pdf/Zpl/Jpeg/Png/Svg),
|
||||
DPI a returnChannel. Pozn.: PPL cachuje etikety 60 s (A4 formáty 5 min)."""
|
||||
resp = await cpl_request(
|
||||
creds, "PUT", f"/shipment/batch/{batch_id}", json_body=body
|
||||
)
|
||||
ensure_success(resp)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/batch/connect-set", summary="Spojení zásilek do sady")
|
||||
async def connect_shipment_set(
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"externalSetNumber": "SET-0001",
|
||||
"shipmentNumbers": ["40950000001", "40950000002"],
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/batch/connectSet` — spojí min. 2 zásilky stejného productType
|
||||
do sady (nelze s dobírkou, jen před fyzickým naskladněním)."""
|
||||
resp = await cpl_request(creds, "POST", "/shipment/batch/connectSet", json_body=body)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/create-and-wait",
|
||||
summary="Vytvoření zásilky a počkání na zpracování (synchronní obálka)",
|
||||
)
|
||||
async def create_shipment_and_wait(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]),
|
||||
timeout_seconds: float = Query(
|
||||
default=BATCH_WAIT_TIMEOUT_SECONDS, ge=1, le=120,
|
||||
description="Maximální doba čekání na zpracování batche.",
|
||||
),
|
||||
include_labels: bool = Query(
|
||||
default=False,
|
||||
description="Po dokončení stáhnout etikety a vrátit je v base64.",
|
||||
),
|
||||
label_page_size: str | None = Query(
|
||||
default=None, description="PageSize etiket při include_labels (Default/A4)."
|
||||
),
|
||||
transliterate: bool | None = Query(default=None),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Convenience endpoint: provede celý asynchronní tok CPL v jednom requestu —
|
||||
POST shipment/batch, polling stavu, volitelně stažení etiket.
|
||||
|
||||
Odpověď obsahuje `completed` (false = vypršel timeout, zpracování běží dál,
|
||||
stav lze dál sledovat přes GET /shipments/batch/{batchId})."""
|
||||
create_resp = await cpl_request(
|
||||
creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(create_resp)
|
||||
batch_id = batch_id_from_location(create_resp)
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
status_data: dict = {}
|
||||
completed = False
|
||||
while True:
|
||||
status_resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}")
|
||||
ensure_success(status_resp)
|
||||
status_data = status_resp.json() or {}
|
||||
items = status_data.get("items") or []
|
||||
pending = [i for i in items if i.get("importState") in _PENDING_STATES]
|
||||
if items and not pending:
|
||||
completed = True
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
log.warning(
|
||||
"Batch %s nebyl zpracován do %ss, vracím completed=false.",
|
||||
batch_id,
|
||||
timeout_seconds,
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(BATCH_POLL_INTERVAL_SECONDS)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"batchId": batch_id,
|
||||
"completed": completed,
|
||||
**status_data,
|
||||
}
|
||||
|
||||
has_ok_item = any(
|
||||
i.get("importState") == "Complete" for i in (status_data.get("items") or [])
|
||||
)
|
||||
if include_labels and completed and has_ok_item:
|
||||
params: dict[str, Any] = {"Limit": 200, "Offset": 0}
|
||||
if label_page_size:
|
||||
params["PageSize"] = label_page_size
|
||||
label_resp = await cpl_request(
|
||||
creds, "GET", f"/shipment/batch/{batch_id}/label", params=params
|
||||
)
|
||||
ensure_success(label_resp)
|
||||
result["label"] = {
|
||||
"contentType": label_resp.headers.get("content-type"),
|
||||
"base64": base64.b64encode(label_resp.content).decode("ascii"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", summary="Tracking / vyhledání zásilek")
|
||||
async def track_shipments(
|
||||
shipment_numbers: list[str] | None = Query(
|
||||
default=None, alias="shipmentNumbers", description="Čísla zásilek (max 50)."
|
||||
),
|
||||
invoice_numbers: list[str] | None = Query(
|
||||
default=None, alias="invoiceNumbers", description="Čísla zakázek (max 50)."
|
||||
),
|
||||
customer_references: list[str] | None = Query(
|
||||
default=None, alias="customerReferences", description="Zákaznické reference (max 50)."
|
||||
),
|
||||
variable_symbols: list[str] | None = Query(
|
||||
default=None, alias="variableSymbols", description="Variabilní symboly (max 50)."
|
||||
),
|
||||
date_from: str | None = Query(default=None, alias="dateFrom"),
|
||||
date_to: str | None = Query(default=None, alias="dateTo"),
|
||||
shipment_states: str | None = Query(
|
||||
default=None,
|
||||
alias="shipmentStates",
|
||||
description="Filtr stavu (např. Delivered, OutForDelivery, NotDelivered).",
|
||||
),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment` — informace a tracking události k zásilkám."""
|
||||
params: dict[str, Any] = {"Limit": limit, "Offset": offset}
|
||||
if shipment_numbers:
|
||||
params["ShipmentNumbers"] = shipment_numbers
|
||||
if invoice_numbers:
|
||||
params["InvoiceNumbers"] = invoice_numbers
|
||||
if customer_references:
|
||||
params["CustomerReferences"] = customer_references
|
||||
if variable_symbols:
|
||||
params["VariableSymbols"] = variable_symbols
|
||||
if date_from:
|
||||
params["DateFrom"] = date_from
|
||||
if date_to:
|
||||
params["DateTo"] = date_to
|
||||
if shipment_states:
|
||||
params["ShipmentStates"] = shipment_states
|
||||
resp = await cpl_request(creds, "GET", "/shipment", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{shipment_number}/cancel", status_code=202, summary="Storno zásilky"
|
||||
)
|
||||
async def cancel_shipment(
|
||||
shipment_number: str,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/cancel` — PPL vrací 202 (přijato ke zpracování)."""
|
||||
resp = await cpl_request(creds, "POST", f"/shipment/{shipment_number}/cancel")
|
||||
ensure_success(resp)
|
||||
return {"shipmentNumber": shipment_number, "accepted": True}
|
||||
|
||||
|
||||
@router.post("/{shipment_number}/redirect", summary="Úprava kontaktu příjemce")
|
||||
async def redirect_shipment(
|
||||
shipment_number: str,
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"address": {
|
||||
"contact": "Jan Novak",
|
||||
"phone": "+420602123456",
|
||||
"email": "prijemce@example.com",
|
||||
}
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/redirect` — úprava kontaktních údajů příjemce."""
|
||||
resp = await cpl_request(
|
||||
creds, "POST", f"/shipment/{shipment_number}/redirect", json_body=body
|
||||
)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{shipment_number}/documents", summary="Uložení celních dokumentů k zásilce"
|
||||
)
|
||||
async def upload_customs_documents(
|
||||
shipment_number: str,
|
||||
document_file_type: str = Query(
|
||||
...,
|
||||
alias="documentFileType",
|
||||
description="Typ dokumentu dle číselníku /codelists/documentFileType.",
|
||||
),
|
||||
files: list[UploadFile] = ...,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/documents` — upload celních dokumentů
|
||||
(pdf, doc(x), xls(x), jpg, jpeg, png, odt, ods, txt; max 5 souborů, 1 MB celkem)."""
|
||||
if not files:
|
||||
raise BadRequestError("Nebyl nahrán žádný soubor.")
|
||||
if len(files) > 5:
|
||||
raise BadRequestError("PPL přijímá maximálně 5 souborů v jednom requestu.")
|
||||
upload = [
|
||||
("files", (f.filename, await f.read(), f.content_type or "application/octet-stream"))
|
||||
for f in files
|
||||
]
|
||||
resp = await cpl_request(
|
||||
creds,
|
||||
"POST",
|
||||
f"/shipment/{shipment_number}/documents",
|
||||
params={"documentFileType": document_file_type},
|
||||
files=upload,
|
||||
)
|
||||
return relay_json(resp)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""In-memory cache OAuth Bearer tokenů PPL CPL API.
|
||||
|
||||
PPL vydá max. 12 tokenů za minutu a token platí 30 minut — generovat token
|
||||
per-request nelze. Cache je klíčovaná SHA-256 hashem přihlašovacích údajů
|
||||
(samotné údaje se neukládají) a token se obnovuje s předstihem před expirací.
|
||||
|
||||
Per-key asyncio.Lock brání souběžnému vyžádání tokenu pro stejné údaje
|
||||
(thundering herd při paralelních requestech).
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from .config import TOKEN_REFRESH_MARGIN_SECONDS
|
||||
|
||||
# cache_key -> (access_token, expires_at_monotonic)
|
||||
_tokens: dict[str, tuple[str, float]] = {}
|
||||
_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _lock_for(cache_key: str) -> asyncio.Lock:
|
||||
lock = _locks.get(cache_key)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_locks[cache_key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def get_cached(cache_key: str) -> str | None:
|
||||
entry = _tokens.get(cache_key)
|
||||
if entry is None:
|
||||
return None
|
||||
token, expires_at = entry
|
||||
if time.monotonic() >= expires_at:
|
||||
_tokens.pop(cache_key, None)
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def store(cache_key: str, token: str, expires_in_seconds: float) -> None:
|
||||
expires_at = time.monotonic() + max(
|
||||
expires_in_seconds - TOKEN_REFRESH_MARGIN_SECONDS, 30.0
|
||||
)
|
||||
_tokens[cache_key] = (token, expires_at)
|
||||
|
||||
|
||||
def invalidate(cache_key: str) -> None:
|
||||
_tokens.pop(cache_key, None)
|
||||
|
||||
|
||||
async def acquire_lock(cache_key: str) -> asyncio.Lock:
|
||||
return _lock_for(cache_key)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Transliterace diakritiky pro PPL CPL API.
|
||||
|
||||
CPL API přijímá pouze Latin znaky bez diakritiky (A-Z, 0-9, základní interpunkce).
|
||||
České texty (jména, adresy, poznámky) proto před odesláním převádíme přes
|
||||
unidecode ("Jiří Dvořák" -> "Jiri Dvorak"). Aplikuje se rekurzivně na všechny
|
||||
stringové hodnoty v JSON těle; klíče se nemění.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
def transliterate_json(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return unidecode(value)
|
||||
if isinstance(value, list):
|
||||
return [transliterate_json(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: transliterate_json(item) for key, item in value.items()}
|
||||
return value
|
||||
@@ -0,0 +1,67 @@
|
||||
# Číselníky, výdejní místa a pomocné metody
|
||||
|
||||
## Číselníky
|
||||
|
||||
`GET /codelists/{name}?limit=1000&offset=0` → `GET codelist/{name}` v PPL.
|
||||
Podporované názvy:
|
||||
|
||||
| Název | Obsah |
|
||||
|---|---|
|
||||
| `product` | produkty/služby (BUSS, PRIV, COPL, ...) |
|
||||
| `country` | země + povolení dobírky (COD) |
|
||||
| `currency` | povolené měny |
|
||||
| `service` | doplňkové služby k zásilkám |
|
||||
| `servicePriceLimit` | min/max hodnoty služeb (dobírka, připojištění) |
|
||||
| `ageCheck` | varianty kontroly věku příjemce (15/18+) |
|
||||
| `externalNumber` | typy externích čísel (např. CUST) |
|
||||
| `shipmentPhase` | fáze zásilky |
|
||||
| `status` | statusy zásilky (tracking) |
|
||||
| `validationMessage` | chybové kódy a hlášení |
|
||||
| `proofOfIdentityType` | typy osobních dokladů |
|
||||
| `documentFileType` | typy dokumentů (celní dokumenty) |
|
||||
|
||||
Stránkování se předává v hlavičkách `X-Paging-Total-Items-Count`,
|
||||
`X-Paging-Offset`, `X-Paging-Limit`.
|
||||
|
||||
## Výdejní místa
|
||||
|
||||
`GET /access-points?countryCode=CZ&limit=100&offset=0` → `GET accessPoint`.
|
||||
Filtry: `zipCode`, `city`, `accessPointCode`, `accessPointTypes`
|
||||
(ParcelShop/ParcelBox/AlzaBox), GPS `latitude`+`longitude`+`radius` (km),
|
||||
`pickupEnabled`, `activeCardPayment`, `activeCashPayment`, `sizes` (S/M/L/XL).
|
||||
Odpověď: adresa, otevírací doba, GPS, kapacity dle velikostí.
|
||||
|
||||
## Našeptávač adres
|
||||
|
||||
`GET /address-whisper?street=...&city=...&zipCode=...&calledFrom=Street`
|
||||
→ `GET addressWhisper`. Vrací kandidáty adres vč. pole `valid` —
|
||||
vhodné pro validaci adresy před vytvořením zásilky.
|
||||
|
||||
## Routing
|
||||
|
||||
`GET /routing?country=CZ&zipCode=60200&...` → `GET routing`.
|
||||
Vrací `routeCode`, `depotCode`, `depotPosition`, `region`, `secondWave`.
|
||||
|
||||
## Zákazník
|
||||
|
||||
- `GET /customer` → `GET customer` — bankovní účty (měny pro dobírku, SWIFT)
|
||||
- `GET /customer/addresses` → `GET customer/address` — registrované adresy
|
||||
- `POST /customer/number-range` → `POST customer/numberRange` — přidělení
|
||||
číselné řady zásilek `{productType, quantity}` → `packNumberFrom/To`
|
||||
|
||||
## Ostatní
|
||||
|
||||
- `GET /cpl-info` → `GET info` — stav/verze CPL API
|
||||
- `GET /version-information` → `GET versionInformation` — novinky API
|
||||
- `GET /data/{dataGuid}` → `GET data/{dataGuid}` — binární tisková data etikety
|
||||
|
||||
## Generická proxy
|
||||
|
||||
`/proxy/{cesta}` (GET/POST/PUT/PATCH/DELETE) předá request 1:1 na CPL API
|
||||
s doplněným Bearer tokenem — pokrývá vše, co nemá typovaný endpoint,
|
||||
i budoucí metody. Příklad:
|
||||
|
||||
```
|
||||
GET /proxy/codelist/product?Limit=10&Offset=0
|
||||
POST /proxy/shipment/batch (tělo 1:1 dle PPL dokumentace, bez transliterace)
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
# Objednávky přepravy a svozu
|
||||
|
||||
| Endpoint služby | Upstream CPL | Popis |
|
||||
|---|---|---|
|
||||
| `POST /orders/batch` | `POST order/batch` | vytvoření objednávek (max 100), vrací `batchId` |
|
||||
| `GET /orders/batch/{batchId}` | `GET order/batch/{batchId}` | stav zpracování |
|
||||
| `POST /orders/create-and-wait` | (kombinace) | synchronní obálka |
|
||||
| `GET /orders` | `GET order` | vyhledání objednávek |
|
||||
| `POST /orders/cancel` | `POST order/cancel` | zrušení objednávky |
|
||||
|
||||
## Typy objednávek (`orderType`)
|
||||
|
||||
- **CollectionOrder** — svoz z registrované adresy zákazníka (pravidelní
|
||||
odesílatelé). `recipient` se neuvádí.
|
||||
- **TransportOrder** — vyzvednutí z libovolné adresy; `sender` i `recipient`
|
||||
jsou povinné.
|
||||
|
||||
Povinná pole: `referenceId` (1–50 znaků), `shipmentCount` (1–50), `sendDate`.
|
||||
Volitelné: `productType` (BUSS domácí / IMPO mezinárodní), `customerReference`,
|
||||
`email`, `note`, `sendTimeFrom`, `sendTimeTo`.
|
||||
|
||||
## Stav zpracování
|
||||
|
||||
`GET /orders/batch/{batchId}` vrací `items[]` s `importState`
|
||||
(Accepted/InProcess/Complete/Error) a případným `errorMessage`/`errorCode`.
|
||||
|
||||
## Vyhledání
|
||||
|
||||
`GET /orders?limit=100&offset=0` — filtry: `orderNumbers`, `orderReferences`
|
||||
(= referenceId z batch requestu), `customerReferences`, `shipmentNumbers`,
|
||||
`orderIds`, `dateFrom/dateTo`, `sendDate`, `productType`,
|
||||
`orderStates` (Created, PickedUp, NotPickedUp, Canceled), `orderType`.
|
||||
Odpověď vrací i přidělená čísla zásilek (`shipmentNumbers`).
|
||||
|
||||
## Zrušení
|
||||
|
||||
`POST /orders/cancel?orderReference=ORD-0001` (nebo `customerReference=...`),
|
||||
volitelné tělo `{"note": "důvod"}`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# PPL CPL API — přehled služby
|
||||
|
||||
Stateless multi-tenant proxy nad PPL CPL API (Create Package Label).
|
||||
Upstream: `https://api.dhl.com/ecs/ppl/myapi2` (production),
|
||||
`https://api-dev.dhl.com/ecs/ppl/myapi2` (test).
|
||||
Dokumentace PPL: https://ppl-cpl-api.apidog.io/
|
||||
|
||||
## Autentizace
|
||||
|
||||
Každý request nese hlavičky:
|
||||
|
||||
- `X-Client-Id` — PPL ClientId (secret)
|
||||
- `X-Client-Secret` — PPL ClientSecret (secret)
|
||||
- `X-Environment` — `production` (default) nebo `test`
|
||||
- `Accept-Language` — volitelně, předává se do PPL (např. `cs-CZ`)
|
||||
|
||||
Služba z údajů získá OAuth Bearer token (`POST {base}/login/getAccessToken`,
|
||||
grant `client_credentials`, scope `myapi2`) a **cachuje ho in-memory** pod
|
||||
SHA-256 hashem údajů. Důvod: PPL vydá max. 12 tokenů/min a token platí 30 minut.
|
||||
Token se obnovuje 60 s před expirací; na HTTP 401 z PPL se jednou obnoví
|
||||
a request se zopakuje.
|
||||
|
||||
## Limity PPL, které služba respektuje
|
||||
|
||||
- min. 40 ms rozestup mezi requesty (globální throttle v procesu),
|
||||
- max. 1000 zásilek / 100 objednávek v jednom batchi,
|
||||
- batchId platí 30 dní,
|
||||
- pouze Latin znaky bez diakritiky — viz transliterace níže.
|
||||
|
||||
## Transliterace diakritiky
|
||||
|
||||
CPL API odmítá texty s diakritikou. Těla `POST /shipments/batch`,
|
||||
`/shipments/create-and-wait`, `/orders/batch` a `/orders/create-and-wait`
|
||||
se defaultně rekurzivně transliterují přes unidecode (`Jiří Dvořák` →
|
||||
`Jiri Dvorak`). Chování:
|
||||
|
||||
- query parametr `transliterate=true|false` na requestu má přednost,
|
||||
- jinak platí env `CPL_TRANSLITERATE` (default `true`),
|
||||
- generická `/proxy/...` NEtransliteruje nikdy (předává 1:1).
|
||||
|
||||
## Chybové odpovědi
|
||||
|
||||
Jednotný JSON `{error, message, detail}`; `detail` obsahuje původní
|
||||
problem+json z PPL (typ, title, errors...).
|
||||
|
||||
| Status | Význam |
|
||||
|---|---|
|
||||
| 400 | validační chyba (vstup zde nebo v PPL) |
|
||||
| 401 | chybějící X- hlavičky nebo PPL odmítlo údaje/token |
|
||||
| 403 | chybí oprávnění (role) k metodě v PPL |
|
||||
| 404 | batch/zásilka/objednávka neexistuje |
|
||||
| 429 | rate limit PPL |
|
||||
| 502 | výpadek / neočekávaná chyba PPL |
|
||||
|
||||
Všechny chyby se logují (bez secrets) — žádná tichá selhání.
|
||||
|
||||
## Konfigurace (environment variables)
|
||||
|
||||
| Proměnná | Default | Význam |
|
||||
|---|---|---|
|
||||
| `ROOT_PATH` | `""` | prefix za reverse proxy (`/apps/pplcplapi`) |
|
||||
| `CPL_DEFAULT_ENVIRONMENT` | `production` | prostředí bez hlavičky X-Environment |
|
||||
| `CPL_PRODUCTION_BASE_URL` / `CPL_TEST_BASE_URL` | viz výše | base URL upstreamu |
|
||||
| `CPL_OAUTH_SCOPE` | `myapi2` | OAuth scope |
|
||||
| `CPL_TRANSLITERATE` | `true` | default transliterace diakritiky |
|
||||
| `UPSTREAM_TIMEOUT_SECONDS` | `60` | timeout volání PPL |
|
||||
| `TOKEN_REFRESH_MARGIN_SECONDS` | `60` | předstih obnovy tokenu |
|
||||
| `MIN_REQUEST_INTERVAL_SECONDS` | `0.04` | rozestup requestů na PPL |
|
||||
| `BATCH_POLL_INTERVAL_SECONDS` | `1.0` | interval pollingu create-and-wait |
|
||||
| `BATCH_WAIT_TIMEOUT_SECONDS` | `30` | default timeout create-and-wait |
|
||||
| `LOG_LEVEL` | `INFO` | úroveň logování |
|
||||
|
||||
Žádné secrets se nekonfigurují přes env — vše chodí per-request v hlavičkách.
|
||||
|
||||
## Struktura kódu
|
||||
|
||||
```
|
||||
app/
|
||||
main.py — FastAPI aplikace, root_path, popis pro Swagger
|
||||
config.py — env konfigurace
|
||||
credentials.py — X- hlavičky -> Credentials (dependency)
|
||||
token_cache.py — in-memory cache OAuth tokenů
|
||||
cpl_client.py — autentizovaný httpx klient, throttle, relay helpery
|
||||
transliterate.py — rekurzivní unidecode JSON těl
|
||||
errors.py — typované chyby + handlery
|
||||
routers/
|
||||
meta.py — /health, /version
|
||||
shipments.py — zásilky, etikety, tracking, storno, celní dokumenty
|
||||
orders.py — objednávky svozu/přepravy
|
||||
codelists.py — číselníky, /version-information, /cpl-info
|
||||
lookups.py — /access-points, /address-whisper, /routing, /data/{guid}
|
||||
customer.py — /customer, /customer/addresses, /customer/number-range
|
||||
proxy.py — generická proxy /proxy/{cesta}
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
# Zásilky
|
||||
|
||||
Tok CPL API je asynchronní (dávkový). Mapování na upstream metody:
|
||||
|
||||
| Endpoint služby | Upstream CPL | Popis |
|
||||
|---|---|---|
|
||||
| `POST /shipments/batch` | `POST shipment/batch` | vytvoření zásilek (max 1000), vrací `batchId` |
|
||||
| `GET /shipments/batch/{batchId}` | `GET shipment/batch/{batchId}` | stav importu |
|
||||
| `GET /shipments/batch/{batchId}/labels` | `GET shipment/batch/{batchId}/label` | binární etikety |
|
||||
| `PUT /shipments/batch/{batchId}/label-settings` | `PUT shipment/batch/{batchId}` | změna formátu etiket |
|
||||
| `POST /shipments/batch/connect-set` | `POST shipment/batch/connectSet` | spojení zásilek do sady |
|
||||
| `POST /shipments/create-and-wait` | (kombinace výše) | synchronní obálka |
|
||||
| `GET /shipments` | `GET shipment` | tracking / vyhledání |
|
||||
| `POST /shipments/{n}/cancel` | `POST shipment/{n}/cancel` | storno (PPL vrací 202) |
|
||||
| `POST /shipments/{n}/redirect` | `POST shipment/{n}/redirect` | úprava kontaktu příjemce |
|
||||
| `POST /shipments/{n}/documents` | `POST shipment/{n}/documents` | celní dokumenty (multipart) |
|
||||
|
||||
## Vytvoření zásilky
|
||||
|
||||
`POST /shipments/batch` — tělo se předává 1:1 do PPL (viz příklad ve Swaggeru).
|
||||
Povinné: `shipments[].referenceId`, `shipments[].productType`,
|
||||
`shipments[].recipient.zipCode` + `country`. Odpověď:
|
||||
|
||||
```json
|
||||
{ "batchId": "1c37...", "location": "https://.../shipment/batch/1c37...", "correlationId": "..." }
|
||||
```
|
||||
|
||||
Query parametr `transliterate` řídí převod diakritiky (default dle služby).
|
||||
|
||||
### Stav importu
|
||||
|
||||
`GET /shipments/batch/{batchId}` vrací `items[]` s `importState`:
|
||||
`Accepted` → `InProcess` → `Complete` (etikety připraveny) / `Error`
|
||||
(`errorMessage`, `errorCode`). Po dokončení obsahuje `labelUrl` per zásilka,
|
||||
příp. `completeLabel.labelUrls` (souhrnné PDF).
|
||||
|
||||
### Etikety
|
||||
|
||||
`GET /shipments/batch/{batchId}/labels?limit=200&offset=0&pageSize=A4&position=1`
|
||||
vrací binární soubor ve formátu z `labelSettings.format`
|
||||
(Pdf / Zpl / Jpeg / Png / Svg; DPI 203–1200). Jednotlivé etikety lze stáhnout
|
||||
i přes `GET /data/{dataGuid}` (guid z `labelUrl`).
|
||||
|
||||
## create-and-wait (doporučeno pro e-shop scénář)
|
||||
|
||||
`POST /shipments/create-and-wait?timeout_seconds=30&include_labels=true`
|
||||
|
||||
1. vytvoří batch,
|
||||
2. polluje stav (interval 1 s) dokud vše není Complete/Error nebo nevyprší timeout,
|
||||
3. s `include_labels=true` stáhne etikety a vrátí je v base64.
|
||||
|
||||
Odpověď: `{batchId, completed, items[], completeLabel, label?: {contentType, base64}}`.
|
||||
`completed=false` = timeout; zpracování v PPL běží dál, stav lze dosledovat
|
||||
přes `GET /shipments/batch/{batchId}` (batchId platí 30 dní).
|
||||
|
||||
## Tracking
|
||||
|
||||
`GET /shipments?shipmentNumbers=...&limit=100&offset=0` — filtry:
|
||||
`shipmentNumbers`, `invoiceNumbers`, `customerReferences`, `variableSymbols`
|
||||
(vše max 50 hodnot, lze opakovat), `dateFrom`, `dateTo`, `shipmentStates`.
|
||||
Odpověď obsahuje tracking události; paging v hlavičkách `X-Paging-*`.
|
||||
|
||||
## Celní dokumenty
|
||||
|
||||
`POST /shipments/{n}/documents?documentFileType=...` — multipart pole `files`
|
||||
(max 5 souborů, 1 MB celkem; pdf, doc(x), xls(x), jpg, jpeg, png, odt, ods, txt).
|
||||
Typy viz `GET /codelists/documentFileType`.
|
||||
@@ -1,2 +1,5 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
httpx
|
||||
unidecode
|
||||
python-multipart
|
||||
|
||||
Reference in New Issue
Block a user