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