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:
yeuimu
2026-11-03 08:02:39 +00:00
parent d60a99ab4e
commit 5f4e1ffa1f
10 changed files with 1184 additions and 4 deletions
+97
View File
@@ -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: