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