This commit is contained in:
JiriUhlir
2026-07-16 11:56:15 +02:00
parent b650357194
commit 0e05fef335
23 changed files with 1900 additions and 15 deletions
+47
View File
@@ -1,3 +1,50 @@
# PPL CPL API # 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. Generated by AppFactory.
View File
+45
View File
@@ -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",
)
+240
View File
@@ -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]
+80
View File
@@ -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
View File
@@ -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."},
)
+16
View File
@@ -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
View File
@@ -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 import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
APP_NAME = os.getenv("APP_NAME", "PPL CPL API") from .config import APP_NAME, APP_VERSION, ROOT_PATH
APP_VERSION = os.getenv("APP_VERSION", "1.0.0") from .cpl_client import close_client
ROOT_PATH = os.getenv("ROOT_PATH", "") 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( app = FastAPI(
title=APP_NAME, title=APP_NAME,
version=APP_VERSION, version=APP_VERSION,
root_path=ROOT_PATH description=DESCRIPTION,
root_path=ROOT_PATH,
lifespan=lifespan,
) )
@app.get("/health") register_exception_handlers(app)
def health():
return {"status": "ok"}
@app.get("/version") app.include_router(meta.router)
def version(): app.include_router(shipments.router)
return { app.include_router(orders.router)
"app": APP_NAME, app.include_router(codelists.router)
"version": APP_VERSION, app.include_router(lookups.router)
"language": "python", app.include_router(customer.router)
"root_path": ROOT_PATH 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")),
)
View File
+69
View File
@@ -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)
+35
View File
@@ -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
(packNumberFrompackNumberTo) pro daný productType."""
resp = await cpl_request(creds, "POST", "/customer/numberRange", json_body=body)
return relay_json(resp)
+126
View File
@@ -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)
+21
View File
@@ -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,
}
+224
View File
@@ -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)
+40
View File
@@ -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)
+393
View File
@@ -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 (14)."
),
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)
+51
View File
@@ -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)
+20
View File
@@ -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
+67
View File
@@ -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)
```
+38
View File
@@ -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` (150 znaků), `shipmentCount` (150), `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"}`.
+94
View File
@@ -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}
```
+67
View File
@@ -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 2031200). 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`.
+3
View File
@@ -1,2 +1,5 @@
fastapi fastapi
uvicorn[standard] uvicorn[standard]
httpx
unidecode
python-multipart