Files
JiriUhlir 0e05fef335 first
2026-07-16 11:56:15 +02:00

394 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)