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
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)