feat: 快递100上门取件集成(SDS运单自动生成+面单回传)
- api/kuaidi100: 上门取件API客户端(bOrder/price/cancel/detail,MD5签名封装) - api/sds: 变体克重(children接口,24h缓存)/面单PDF上传/挂包裹/carriageNoStatus筛选 - flows/sds/waybill_autogen: 每10分钟遍历运单待生成订单(factoryIds+carriageNoStatus+orderStatusCode) 自动下极兔(returnType=20+tempid出面单),面单回调转PDF回传SDS; 幂等跳过已有运单;面单超时不自动取消(记人工跟进);schedule 默认 active:false - scripts: 手动E2E测试脚本 + 回调接收服务(验签MD5(param+salt)+JSONL落盘,配frpc 18090穿透) - 依赖 +pillow(面单PNG转PDF)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""快递100 上门取件API(线上支付) 客户端。
|
||||
|
||||
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
||||
"""
|
||||
|
||||
from api.kuaidi100.client import Kuaidi100ApiError, Kuaidi100Client
|
||||
|
||||
__all__ = ["Kuaidi100Client", "Kuaidi100ApiError"]
|
||||
@@ -0,0 +1,160 @@
|
||||
"""快递100「上门取件API(线上支付)」REST 客户端(httpx + pydantic 思路,纯客户端层,禁止 import prefect)。
|
||||
|
||||
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
||||
|
||||
鉴权与签名:
|
||||
- 公共 body 参数 method/key/sign/t/param(Content-Type: application/x-www-form-urlencoded);
|
||||
- sign = MD5(param + t + key + secret) 转 32 位大写,secret 在企业管理后台获取;
|
||||
- 凭据(key/secret/customer/tempid)由调用方(flow 层)从 Secret Block 读取后注入,本层不落盘。
|
||||
|
||||
实测注意(勿踩):
|
||||
- taskId/orderId 回传取消接口时必须原样保持类型(orderId 是字符串数字);
|
||||
- cancelMsg 含"测试"字样会被后端判"订单信息错误",用固定文案"暂时不寄件了";
|
||||
- returnType=20 出面单图片短链,经 callBackUrl 回调推送(status=200),tempid 必填。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
|
||||
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
|
||||
|
||||
CANCEL_MSG = "暂时不寄件了"
|
||||
|
||||
|
||||
class Kuaidi100ApiError(RuntimeError):
|
||||
"""快递100 业务失败(result != true 或 returnCode != 200)。"""
|
||||
|
||||
|
||||
class Kuaidi100Client:
|
||||
"""寄件下单(bOrder)/查价(price)/取消(cancel)/订单详情(detail)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
secret: str,
|
||||
*,
|
||||
base_url: str = PROD_BASE_URL,
|
||||
customer: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._key = key
|
||||
self._secret = secret
|
||||
self._customer = customer
|
||||
self._base_url = base_url
|
||||
self._http = httpx.Client(timeout=timeout)
|
||||
|
||||
# ----- 公共调用 -----
|
||||
|
||||
@staticmethod
|
||||
def make_sign(param: str, t: str, key: str, secret: str) -> str:
|
||||
return hashlib.md5(f"{param}{t}{key}{secret}".encode("utf-8")).hexdigest().upper()
|
||||
|
||||
def call(self, method: str, param: dict[str, Any]) -> dict[str, Any]:
|
||||
"""POST 公共表单,业务失败抛 Kuaidi100ApiError,成功返回整个响应 JSON。"""
|
||||
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
|
||||
t = str(int(time.time() * 1000))
|
||||
body = {
|
||||
"method": method,
|
||||
"key": self._key,
|
||||
"sign": self.make_sign(param_str, t, self._key, self._secret),
|
||||
"t": t,
|
||||
"param": param_str,
|
||||
}
|
||||
resp = self._http.post(self._base_url, data=body)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("result") is not True or str(data.get("returnCode")) != "200":
|
||||
raise Kuaidi100ApiError(f"[{method}] {json.dumps(data, ensure_ascii=False)[:500]}")
|
||||
return data
|
||||
|
||||
# ----- 业务接口 -----
|
||||
|
||||
def price(
|
||||
self,
|
||||
kuaidi_com: str,
|
||||
send_addr: str,
|
||||
rec_addr: str,
|
||||
weight: str = "1",
|
||||
service_type: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""查价(只读)。返回 data:defPrice/defFirstPrice/price/overPricePerKg/serviceType 等。"""
|
||||
param: dict[str, Any] = {
|
||||
"kuaidiCom": kuaidi_com,
|
||||
"sendManPrintAddr": send_addr,
|
||||
"recManPrintAddr": rec_addr,
|
||||
"weight": weight,
|
||||
}
|
||||
if service_type:
|
||||
param["serviceType"] = service_type
|
||||
return self.call("price", param).get("data") or {}
|
||||
|
||||
def border(
|
||||
self,
|
||||
*,
|
||||
kuaidicom: str,
|
||||
rec_man_name: str,
|
||||
rec_man_mobile: str,
|
||||
rec_man_print_addr: str,
|
||||
send_man_name: str,
|
||||
send_man_mobile: str,
|
||||
send_man_print_addr: str,
|
||||
callback_url: str,
|
||||
cargo: str | None = None,
|
||||
weight: str | None = None,
|
||||
payment: str = "SHIPPER",
|
||||
remark: str | None = None,
|
||||
salt: str | None = None,
|
||||
return_type: str | None = None,
|
||||
tempid: str | None = None,
|
||||
print_callback_url: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""寄件下单。returnType=20 + tempid 时面单短链经回调(status=200)推送。
|
||||
|
||||
返回 data:taskId/orderId/kuaidinum/pollToken/appointGotStartTime 等,
|
||||
taskId/orderId 后续回传必须保持原类型。
|
||||
"""
|
||||
param: dict[str, Any] = {
|
||||
"kuaidicom": kuaidicom,
|
||||
"recManName": rec_man_name,
|
||||
"recManMobile": rec_man_mobile,
|
||||
"recManPrintAddr": rec_man_print_addr,
|
||||
"sendManName": send_man_name,
|
||||
"sendManMobile": send_man_mobile,
|
||||
"sendManPrintAddr": send_man_print_addr,
|
||||
"callBackUrl": callback_url,
|
||||
"payment": payment,
|
||||
}
|
||||
for key, value in (
|
||||
("cargo", cargo), ("weight", weight), ("remark", remark), ("salt", salt),
|
||||
("returnType", return_type), ("tempid", tempid), ("printCallBackUrl", print_callback_url),
|
||||
):
|
||||
if value is not None:
|
||||
param[key] = value
|
||||
if extra:
|
||||
param.update(extra)
|
||||
return self.call("bOrder", param).get("data") or {}
|
||||
|
||||
def cancel(self, task_id: Any, order_id: Any, cancel_msg: str = CANCEL_MSG) -> None:
|
||||
"""取消订单(取件前)。taskId/orderId 原样回传下单返回值。"""
|
||||
self.call("cancel", {"taskId": task_id, "orderId": order_id, "cancelMsg": cancel_msg})
|
||||
|
||||
def detail(self, task_id: Any) -> dict[str, Any]:
|
||||
"""订单详情(只读):status/kuaidiNum/payStatus/preWeight 等。"""
|
||||
return self.call("detail", {"taskId": task_id}).get("data") or {}
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "Kuaidi100Client":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
@@ -23,6 +23,7 @@ from api.sds.models import (
|
||||
OrderQueryParams,
|
||||
OrderStatistics,
|
||||
OrderStatus,
|
||||
ProductChildrenResponse,
|
||||
ProductListRequest,
|
||||
ProductListResponse,
|
||||
)
|
||||
@@ -30,6 +31,7 @@ from api.sds.models import (
|
||||
BASE_URL = "https://pod-api.sdspod.com"
|
||||
COMPANY_NO = "印美达"
|
||||
KEYWORDS_BATCH_SIZE = 200
|
||||
PRODUCT_CACHE_TTL_SECONDS = 24 * 3600 # 产品变体(克重等)是主数据,长缓存
|
||||
FILTERS_FIELDS = (
|
||||
"factoryList,merchantList,logisticsList,earlyWarningList,"
|
||||
"issuingBayAreaList,logisticsSourceList,extendStatusList"
|
||||
@@ -64,6 +66,7 @@ class SdsMngClient(TokenHttpClient):
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
|
||||
self._product_cache: TTLCache[Any] = TTLCache(PRODUCT_CACHE_TTL_SECONDS)
|
||||
self._concurrency = concurrency
|
||||
|
||||
# ----- 认证 -----
|
||||
@@ -129,6 +132,8 @@ class SdsMngClient(TokenHttpClient):
|
||||
query["earlyWarningType"] = params.early_warning_type
|
||||
if params.item_num_type is not None:
|
||||
query["itemNumType"] = params.item_num_type
|
||||
if params.carriage_no_status is not None:
|
||||
query["carriageNoStatus"] = params.carriage_no_status
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
@@ -297,6 +302,98 @@ class SdsMngClient(TokenHttpClient):
|
||||
merged.extend(r.records)
|
||||
return ProductListResponse(total_count=first.total_count, records=merged)
|
||||
|
||||
# ----- 产品变体/克重(主数据,24h 缓存) -----
|
||||
|
||||
def get_product_children(self, product_id: int) -> ProductChildrenResponse:
|
||||
"""产品变体列表(含每尺码克重 weight 克/件、包装盒尺寸)。
|
||||
|
||||
GET /pod/product/{id}/children;变体克重是主数据,缓存 PRODUCT_CACHE_TTL_SECONDS。
|
||||
"""
|
||||
key = f"children:{product_id}"
|
||||
cached = self._product_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = self.get(f"/pod/product/{product_id}/children", params={"t": int(time.time() * 1000)})
|
||||
result = ProductChildrenResponse.model_validate(data)
|
||||
self._product_cache.set(key, result)
|
||||
return result
|
||||
|
||||
def get_variant_weight_map(self, product_id: int) -> dict[int, float]:
|
||||
"""{变体 id: 克重}。订单行的 product_id 即变体 id,可直接查。"""
|
||||
return {
|
||||
c.id: c.weight
|
||||
for c in self.get_product_children(product_id).records
|
||||
if c.weight is not None
|
||||
}
|
||||
|
||||
def resolve_product_id(self, code: str) -> int | None:
|
||||
"""产品编码(父 sku,如 RB9327005) -> 产品 id(keyword 精确搜索,长缓存)。"""
|
||||
key = f"pid:{code}"
|
||||
cached = self._product_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached or None
|
||||
resp = self.get_products(ProductListRequest(keyword=code, size=1))
|
||||
pid = resp.records[0].id if resp.records else 0
|
||||
self._product_cache.set(key, pid)
|
||||
return pid or None
|
||||
|
||||
def get_order_weight_grams(self, order: Any) -> tuple[float | None, list[str]]:
|
||||
"""按变体克重汇总订单克重。返回 (总克重, 未匹配行描述);None 时调用方需回退。"""
|
||||
total, missed, maps = 0.0, [], {}
|
||||
for it in order.items or []:
|
||||
supply = getattr(it, "productSupply", None) or {}
|
||||
parent_id = supply.get("productParentId")
|
||||
if parent_id is None or it.product_id is None:
|
||||
missed.append(f"行{it.no or '?'} 缺 productParentId/product_id")
|
||||
continue
|
||||
if parent_id not in maps:
|
||||
maps[parent_id] = self.get_variant_weight_map(parent_id)
|
||||
weight = maps[parent_id].get(it.product_id)
|
||||
if weight is None:
|
||||
missed.append(f"行{it.no or it.product_id} 变体无克重字段")
|
||||
continue
|
||||
total += weight * (it.num or 1)
|
||||
return (total or None), missed
|
||||
|
||||
# ----- 面单文件/包裹(运单回传) -----
|
||||
|
||||
def _send_with_token(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
files: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""基类 _send 未覆盖的请求形态(multipart 上传 / PUT),带 token + 401 重登一次。"""
|
||||
for attempt in (1, 2):
|
||||
headers = {self._token_header: self._ensure_token()}
|
||||
resp = self._http.request(method, url, files=files, json=json_body, params=params, headers=headers)
|
||||
if resp.status_code == 401 and attempt == 1:
|
||||
self._clear_token()
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else {}
|
||||
return {}
|
||||
|
||||
def upload_waybill_pdf(self, filename: str, pdf_bytes: bytes) -> dict[str, Any]:
|
||||
"""上传面单 PDF,返回含 CDN url 的文件信息。POST /files/pdf/logisticsWaybill。"""
|
||||
files = {"file": (filename, pdf_bytes, "application/pdf")}
|
||||
return self._send_with_token("POST", "/files/pdf/logisticsWaybill", files=files)
|
||||
|
||||
def attach_parcel_label(self, order_id: Any, parcel_id: Any, order_no: str, label_pdf_url: str) -> None:
|
||||
"""把面单挂到订单包裹(覆盖语义)。PUT /orders/{orderId}/logistic/parcel/{parcelId}。
|
||||
|
||||
orderNo 传该包裹运单号(原样保留或回写新单号);labelPdf 为 upload_waybill_pdf 的 url。
|
||||
"""
|
||||
self._send_with_token(
|
||||
"PUT",
|
||||
f"/orders/{order_id}/logistic/parcel/{parcel_id}",
|
||||
json_body={"orderNo": order_no, "labelPdf": label_pdf_url},
|
||||
params={"t": int(time.time() * 1000)},
|
||||
)
|
||||
|
||||
# ----- 物流 -----
|
||||
|
||||
def get_logistics_areas(self) -> IssuingBayAreaResponse:
|
||||
|
||||
@@ -219,6 +219,26 @@ class ProductListResponse(_SdsModel):
|
||||
records: list[Product] = Field(default=[], alias="list")
|
||||
|
||||
|
||||
class ProductVariant(_SdsModel):
|
||||
"""产品变体(/pod/product/{id}/children):颜色/尺码/克重/包装盒尺寸。"""
|
||||
|
||||
id: int
|
||||
parent_id: int | None = Field(default=None, alias="parentId")
|
||||
sku: str | None = None
|
||||
size: str | None = None
|
||||
color_name: str | None = Field(default=None, alias="colorName")
|
||||
color_code: str | None = Field(default=None, alias="colorCode")
|
||||
status: int | None = None
|
||||
weight: float | None = None # 克/件
|
||||
box_length: float | None = Field(default=None, alias="boxLength")
|
||||
box_width: float | None = Field(default=None, alias="boxWidth")
|
||||
box_height: float | None = Field(default=None, alias="boxHeight")
|
||||
|
||||
|
||||
class ProductChildrenResponse(_SdsModel):
|
||||
records: list[ProductVariant] = Field(default=[], alias="list")
|
||||
|
||||
|
||||
# ----- 物流域(混用 snake_case,显式 alias)-----
|
||||
|
||||
|
||||
@@ -318,6 +338,7 @@ class OrderPageRequest(BaseModel):
|
||||
extend_status: str | None = None
|
||||
early_warning_type: str | None = None
|
||||
item_num_type: str | None = None
|
||||
carriage_no_status: str | None = None # 运单号状态筛选,如 generate=运单待生成
|
||||
limit: int | None = None
|
||||
all: bool | None = None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user