first
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
"""Zásilky — tvorba (batch), stav importu, etikety, tracking, storno, úpravy.
|
||||
|
||||
Tok tvorby zásilky v CPL API je asynchronní:
|
||||
1. POST /shipments/batch -> PPL vrátí batchId (z Location hlavičky)
|
||||
2. GET /shipments/batch/{batchId} -> polling importState (Accepted/InProcess/Complete/Error)
|
||||
3. GET /shipments/batch/{batchId}/labels -> binární etikety (PDF/ZPL/JPG...)
|
||||
|
||||
Pro typické použití je k dispozici POST /shipments/create-and-wait, který celý
|
||||
tok provede v jednom requestu (vytvoří, počká na zpracování, volitelně vrátí
|
||||
etikety v base64).
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Query, UploadFile
|
||||
|
||||
from ..config import (
|
||||
BATCH_POLL_INTERVAL_SECONDS,
|
||||
BATCH_WAIT_TIMEOUT_SECONDS,
|
||||
TRANSLITERATE_DEFAULT,
|
||||
)
|
||||
from ..cpl_client import (
|
||||
batch_id_from_location,
|
||||
cpl_request,
|
||||
ensure_success,
|
||||
relay_binary,
|
||||
relay_json,
|
||||
)
|
||||
from ..credentials import Credentials, get_credentials
|
||||
from ..errors import BadRequestError
|
||||
from ..logging_config import get_logger
|
||||
from ..transliterate import transliterate_json
|
||||
|
||||
log = get_logger("pplcpl.shipments")
|
||||
|
||||
router = APIRouter(prefix="/shipments", tags=["shipments"])
|
||||
|
||||
_EXAMPLE_SHIPMENT_BODY = {
|
||||
"shipments": [
|
||||
{
|
||||
"referenceId": "REF-0001",
|
||||
"productType": "BUSS",
|
||||
"note": "Volitelna poznamka",
|
||||
"sender": {
|
||||
"name": "Firma s.r.o.",
|
||||
"street": "Prazska 123/4",
|
||||
"city": "Praha",
|
||||
"zipCode": "10000",
|
||||
"country": "CZ",
|
||||
"phone": "+420601123456",
|
||||
"email": "odesilatel@example.com",
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Jan Novak",
|
||||
"street": "Brnenska 10",
|
||||
"city": "Brno",
|
||||
"zipCode": "60200",
|
||||
"country": "CZ",
|
||||
"phone": "+420602123456",
|
||||
"email": "prijemce@example.com",
|
||||
},
|
||||
}
|
||||
],
|
||||
"labelSettings": {
|
||||
"format": "Pdf",
|
||||
"completeLabelSettings": {"isCompleteLabelRequested": True, "pageSize": "A4"},
|
||||
},
|
||||
}
|
||||
|
||||
_PENDING_STATES = ("Accepted", "InProcess")
|
||||
|
||||
|
||||
def _maybe_transliterate(body: dict, transliterate: bool | None) -> dict:
|
||||
apply = TRANSLITERATE_DEFAULT if transliterate is None else transliterate
|
||||
return transliterate_json(body) if apply else body
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
status_code=201,
|
||||
summary="Vytvoření zásilky / sady zásilek (asynchronní)",
|
||||
)
|
||||
async def create_shipment_batch(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]),
|
||||
transliterate: bool | None = Query(
|
||||
default=None,
|
||||
description=(
|
||||
"Převést diakritiku na ASCII (CPL přijímá jen Latin znaky). "
|
||||
"Bez zadání se použije default služby."
|
||||
),
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Odešle `POST shipment/batch` do PPL. Vrací `batchId` z Location hlavičky —
|
||||
tím se následně dotazuje stav importu a stahují etikety."""
|
||||
resp = await cpl_request(
|
||||
creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(resp)
|
||||
batch_id = batch_id_from_location(resp)
|
||||
return {
|
||||
"batchId": batch_id,
|
||||
"location": resp.headers.get("location"),
|
||||
"correlationId": resp.headers.get("x-correlation-id"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/batch/{batch_id}", summary="Stav importu zásilek v batchi")
|
||||
async def get_shipment_batch_status(
|
||||
batch_id: str,
|
||||
order_by: str | None = Query(
|
||||
default=None,
|
||||
alias="orderBy",
|
||||
description="Řazení: ShipmentNumber nebo ReferenceId, prefix `-` = sestupně.",
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment/batch/{batchId}` — importState položek: Accepted, InProcess,
|
||||
Complete (etikety připraveny), Error (viz errorMessage/errorCode)."""
|
||||
params = {"OrderBy": order_by} if order_by else None
|
||||
resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/batch/{batch_id}/labels",
|
||||
summary="Stažení etiket batche (binární PDF/ZPL/JPG...)",
|
||||
)
|
||||
async def get_shipment_batch_labels(
|
||||
batch_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
page_size: str | None = Query(
|
||||
default=None, alias="pageSize", description="Default nebo A4."
|
||||
),
|
||||
position: int | None = Query(
|
||||
default=None, ge=1, le=4, description="Pozice etikety na A4 (1–4)."
|
||||
),
|
||||
order_by: str | None = Query(default=None, alias="orderBy"),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment/batch/{batchId}/label` — vrací etikety jako binární soubor
|
||||
ve formátu nastaveném při vytvoření batche (labelSettings.format)."""
|
||||
params: dict[str, Any] = {"Limit": limit, "Offset": offset}
|
||||
if page_size:
|
||||
params["PageSize"] = page_size
|
||||
if position is not None:
|
||||
params["Position"] = position
|
||||
if order_by:
|
||||
params["OrderBy"] = order_by
|
||||
resp = await cpl_request(
|
||||
creds, "GET", f"/shipment/batch/{batch_id}/label", params=params
|
||||
)
|
||||
return relay_binary(resp)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/batch/{batch_id}/label-settings",
|
||||
status_code=204,
|
||||
summary="Úprava výstupního formátu etikety batche",
|
||||
)
|
||||
async def update_label_settings(
|
||||
batch_id: str,
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"labelSettings": {"format": "Zpl", "dpi": 300},
|
||||
"returnChannel": {"type": "None"},
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`PUT shipment/batch/{batchId}` — změna formátu (Pdf/Zpl/Jpeg/Png/Svg),
|
||||
DPI a returnChannel. Pozn.: PPL cachuje etikety 60 s (A4 formáty 5 min)."""
|
||||
resp = await cpl_request(
|
||||
creds, "PUT", f"/shipment/batch/{batch_id}", json_body=body
|
||||
)
|
||||
ensure_success(resp)
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/batch/connect-set", summary="Spojení zásilek do sady")
|
||||
async def connect_shipment_set(
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"externalSetNumber": "SET-0001",
|
||||
"shipmentNumbers": ["40950000001", "40950000002"],
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/batch/connectSet` — spojí min. 2 zásilky stejného productType
|
||||
do sady (nelze s dobírkou, jen před fyzickým naskladněním)."""
|
||||
resp = await cpl_request(creds, "POST", "/shipment/batch/connectSet", json_body=body)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/create-and-wait",
|
||||
summary="Vytvoření zásilky a počkání na zpracování (synchronní obálka)",
|
||||
)
|
||||
async def create_shipment_and_wait(
|
||||
body: dict = Body(..., examples=[_EXAMPLE_SHIPMENT_BODY]),
|
||||
timeout_seconds: float = Query(
|
||||
default=BATCH_WAIT_TIMEOUT_SECONDS, ge=1, le=120,
|
||||
description="Maximální doba čekání na zpracování batche.",
|
||||
),
|
||||
include_labels: bool = Query(
|
||||
default=False,
|
||||
description="Po dokončení stáhnout etikety a vrátit je v base64.",
|
||||
),
|
||||
label_page_size: str | None = Query(
|
||||
default=None, description="PageSize etiket při include_labels (Default/A4)."
|
||||
),
|
||||
transliterate: bool | None = Query(default=None),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""Convenience endpoint: provede celý asynchronní tok CPL v jednom requestu —
|
||||
POST shipment/batch, polling stavu, volitelně stažení etiket.
|
||||
|
||||
Odpověď obsahuje `completed` (false = vypršel timeout, zpracování běží dál,
|
||||
stav lze dál sledovat přes GET /shipments/batch/{batchId})."""
|
||||
create_resp = await cpl_request(
|
||||
creds, "POST", "/shipment/batch", json_body=_maybe_transliterate(body, transliterate)
|
||||
)
|
||||
ensure_success(create_resp)
|
||||
batch_id = batch_id_from_location(create_resp)
|
||||
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
status_data: dict = {}
|
||||
completed = False
|
||||
while True:
|
||||
status_resp = await cpl_request(creds, "GET", f"/shipment/batch/{batch_id}")
|
||||
ensure_success(status_resp)
|
||||
status_data = status_resp.json() or {}
|
||||
items = status_data.get("items") or []
|
||||
pending = [i for i in items if i.get("importState") in _PENDING_STATES]
|
||||
if items and not pending:
|
||||
completed = True
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
log.warning(
|
||||
"Batch %s nebyl zpracován do %ss, vracím completed=false.",
|
||||
batch_id,
|
||||
timeout_seconds,
|
||||
)
|
||||
break
|
||||
await asyncio.sleep(BATCH_POLL_INTERVAL_SECONDS)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"batchId": batch_id,
|
||||
"completed": completed,
|
||||
**status_data,
|
||||
}
|
||||
|
||||
has_ok_item = any(
|
||||
i.get("importState") == "Complete" for i in (status_data.get("items") or [])
|
||||
)
|
||||
if include_labels and completed and has_ok_item:
|
||||
params: dict[str, Any] = {"Limit": 200, "Offset": 0}
|
||||
if label_page_size:
|
||||
params["PageSize"] = label_page_size
|
||||
label_resp = await cpl_request(
|
||||
creds, "GET", f"/shipment/batch/{batch_id}/label", params=params
|
||||
)
|
||||
ensure_success(label_resp)
|
||||
result["label"] = {
|
||||
"contentType": label_resp.headers.get("content-type"),
|
||||
"base64": base64.b64encode(label_resp.content).decode("ascii"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", summary="Tracking / vyhledání zásilek")
|
||||
async def track_shipments(
|
||||
shipment_numbers: list[str] | None = Query(
|
||||
default=None, alias="shipmentNumbers", description="Čísla zásilek (max 50)."
|
||||
),
|
||||
invoice_numbers: list[str] | None = Query(
|
||||
default=None, alias="invoiceNumbers", description="Čísla zakázek (max 50)."
|
||||
),
|
||||
customer_references: list[str] | None = Query(
|
||||
default=None, alias="customerReferences", description="Zákaznické reference (max 50)."
|
||||
),
|
||||
variable_symbols: list[str] | None = Query(
|
||||
default=None, alias="variableSymbols", description="Variabilní symboly (max 50)."
|
||||
),
|
||||
date_from: str | None = Query(default=None, alias="dateFrom"),
|
||||
date_to: str | None = Query(default=None, alias="dateTo"),
|
||||
shipment_states: str | None = Query(
|
||||
default=None,
|
||||
alias="shipmentStates",
|
||||
description="Filtr stavu (např. Delivered, OutForDelivery, NotDelivered).",
|
||||
),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`GET shipment` — informace a tracking události k zásilkám."""
|
||||
params: dict[str, Any] = {"Limit": limit, "Offset": offset}
|
||||
if shipment_numbers:
|
||||
params["ShipmentNumbers"] = shipment_numbers
|
||||
if invoice_numbers:
|
||||
params["InvoiceNumbers"] = invoice_numbers
|
||||
if customer_references:
|
||||
params["CustomerReferences"] = customer_references
|
||||
if variable_symbols:
|
||||
params["VariableSymbols"] = variable_symbols
|
||||
if date_from:
|
||||
params["DateFrom"] = date_from
|
||||
if date_to:
|
||||
params["DateTo"] = date_to
|
||||
if shipment_states:
|
||||
params["ShipmentStates"] = shipment_states
|
||||
resp = await cpl_request(creds, "GET", "/shipment", params=params)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{shipment_number}/cancel", status_code=202, summary="Storno zásilky"
|
||||
)
|
||||
async def cancel_shipment(
|
||||
shipment_number: str,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/cancel` — PPL vrací 202 (přijato ke zpracování)."""
|
||||
resp = await cpl_request(creds, "POST", f"/shipment/{shipment_number}/cancel")
|
||||
ensure_success(resp)
|
||||
return {"shipmentNumber": shipment_number, "accepted": True}
|
||||
|
||||
|
||||
@router.post("/{shipment_number}/redirect", summary="Úprava kontaktu příjemce")
|
||||
async def redirect_shipment(
|
||||
shipment_number: str,
|
||||
body: dict = Body(
|
||||
...,
|
||||
examples=[
|
||||
{
|
||||
"address": {
|
||||
"contact": "Jan Novak",
|
||||
"phone": "+420602123456",
|
||||
"email": "prijemce@example.com",
|
||||
}
|
||||
}
|
||||
],
|
||||
),
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/redirect` — úprava kontaktních údajů příjemce."""
|
||||
resp = await cpl_request(
|
||||
creds, "POST", f"/shipment/{shipment_number}/redirect", json_body=body
|
||||
)
|
||||
return relay_json(resp)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{shipment_number}/documents", summary="Uložení celních dokumentů k zásilce"
|
||||
)
|
||||
async def upload_customs_documents(
|
||||
shipment_number: str,
|
||||
document_file_type: str = Query(
|
||||
...,
|
||||
alias="documentFileType",
|
||||
description="Typ dokumentu dle číselníku /codelists/documentFileType.",
|
||||
),
|
||||
files: list[UploadFile] = ...,
|
||||
creds: Credentials = Depends(get_credentials),
|
||||
):
|
||||
"""`POST shipment/{shipmentNumber}/documents` — upload celních dokumentů
|
||||
(pdf, doc(x), xls(x), jpg, jpeg, png, odt, ods, txt; max 5 souborů, 1 MB celkem)."""
|
||||
if not files:
|
||||
raise BadRequestError("Nebyl nahrán žádný soubor.")
|
||||
if len(files) > 5:
|
||||
raise BadRequestError("PPL přijímá maximálně 5 souborů v jednom requestu.")
|
||||
upload = [
|
||||
("files", (f.filename, await f.read(), f.content_type or "application/octet-stream"))
|
||||
for f in files
|
||||
]
|
||||
resp = await cpl_request(
|
||||
creds,
|
||||
"POST",
|
||||
f"/shipment/{shipment_number}/documents",
|
||||
params={"documentFileType": document_file_type},
|
||||
files=upload,
|
||||
)
|
||||
return relay_json(resp)
|
||||
Reference in New Issue
Block a user