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:
@@ -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