Files
inkreach-prefect/flows/sds/waybill_autogen.py
T
yeuimu 5f4e1ffa1f 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)
2026-11-03 08:02:39 +00:00

242 lines
9.6 KiB
Python

"""SDS 国内工厂运单自动生成:遍历「运单待生成」订单 → 自动下极兔 → 面单回传 SDS。
筛选参数: carriageNoStatus=generate(运单待生成) + factoryIds=1161(印美达国内工厂)
+ orderStatusCode=2(订单状态)。
链路: SDS 订单 → 变体克重(children 接口,客户端 24h 缓存)
→ 快递100 bOrder(极兔,returnType=20+tempid 出面单,回调走 frp 公网地址)
→ 回调服务 /list 轮询取面单短链(status=200)
→ PNG 转 PDF → SDS /files/pdf/logisticsWaybill 上传 → PUT 挂包裹(orderNo=新运单号)。
幂等与安全:
- 已有运单号的包裹跳过(10 分钟轮询天然去重);
- 面单等待超时只记录待人工跟进,不自动取消(快递员已接单时取消会扣其绩效);
- max_orders 限制单轮下单量(首轮积压保护);
- 改动必须先在测试环境/本地模拟验证,再真实下单(运营约定);
- ⚠️ 真实下单 flow:prefect.yaml 中本 deployment 的 schedule 默认 active:false,
上线前人工核对后改 true 并 `uv run prefect deploy --all` 才会开始调度。
对应 sds-waybill-autogen。
"""
from __future__ import annotations
import io
import json
import time
from typing import Any
import httpx
from PIL import Image
from prefect import flow
from prefect.blocks.system import Secret
from api.kuaidi100 import Kuaidi100Client
from api.sds.models import OrderPageRequest
from flows.shared import create_sds_client
# 寄件人固定为广州发货仓联系人
SEND_MAN_NAME = "张文风"
SEND_MAN_MOBILE = "18320690872"
SEND_MAN_ADDR = "广东省广州市番禺区南村镇新基村新盈科技园3栋2楼"
CALLBACK_BASE = "http://8.138.172.239:18090/kuaidi100" # 回调服务(frp 公网): /order 收推送, /list 查历史
CALLBACK_SALT = "inkreach-test" # 须与回调服务 kuaidi100-callback.service 的 --salt 一致
LABEL_POLL_INTERVAL_SECONDS = 5
CARGO_DEFAULT = "日用品"
def _load_secret(name: str) -> dict[str, str]:
value = Secret.load(name).get()
return json.loads(value) if isinstance(value, str) else value
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
detail = detail or ""
prefix = ""
for part in (province or "", city or ""):
if part and part not in detail and part not in prefix:
prefix += part
return prefix + detail
def _parcel_info(order: Any) -> tuple[Any, str]:
"""取首个包裹 (parcel_id, 当前运单号,可能为空)。多包裹订单只处理包裹1并提示。"""
parcels = order.parcel_list or []
if len(parcels) > 1:
print(f"[autogen] 订单 {order.no} 有 {len(parcels)} 个包裹,仅处理包裹1")
if not parcels:
return None, ""
parcel = parcels[0] or {}
carriage = (parcel.get("logistics") or {}).get("carriage") or {}
return parcel.get("id"), str(carriage.get("no") or "")
def _wait_label_url(order_id: Any, callback_base: str, timeout_seconds: int) -> str | None:
"""轮询回调服务 /list,取该订单 status=200 回调里的面单短链。"""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
items = httpx.get(f"{callback_base}/list", params={"n": 50}, timeout=10).json().get("items") or []
for item in items:
param = item.get("param")
data = (param.get("data") or {}) if isinstance(param, dict) else {}
if str(data.get("orderId")) == str(order_id) and data.get("status") == 200 and data.get("label"):
return str(data["label"])
except Exception as err: # noqa: BLE001
print(f"[autogen] 回调服务查询失败(继续等待): {err}")
time.sleep(LABEL_POLL_INTERVAL_SECONDS)
return None
def _label_to_pdf(label_url: str) -> bytes:
resp = httpx.get(label_url, timeout=60, follow_redirects=True)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content)).convert("RGB")
buf = io.BytesIO()
img.save(buf, "PDF", resolution=150.0)
return buf.getvalue()
def _process_one(
sds: Any,
kd: Kuaidi100Client,
order: Any,
*,
courier: str,
tempid: str | None,
callback_base: str,
label_wait_seconds: int,
) -> dict[str, Any]:
"""单订单全链路:下单 → 等面单 → 回传。失败尽力取消快递单,返回结果记录。"""
order_no = order.no or order.id
record: dict[str, Any] = {"orderNo": order_no, "result": "fail"}
parcel_id, current_no = _parcel_info(order)
if not parcel_id:
record["error"] = "订单无包裹"
return record
if current_no:
record.update(result="skip", waybill=current_no, reason="已有运单号")
return record
addr = order.address
if not addr or not addr.consignee or not addr.mobile_phone:
record["error"] = "订单缺收件人信息"
return record
grams, missed = sds.get_order_weight_grams(order)
weight = f"{grams / 1000:.2f}" if grams else "1"
if missed:
print(f"[autogen] 订单 {order_no} 克重未完全匹配({missed}),申报重量 {weight}kg")
data = kd.border(
kuaidicom=courier,
rec_man_name=addr.consignee,
rec_man_mobile=addr.mobile_phone,
rec_man_print_addr=_join_addr(addr.province, addr.city, addr.detail),
send_man_name=SEND_MAN_NAME,
send_man_mobile=SEND_MAN_MOBILE,
send_man_print_addr=SEND_MAN_ADDR,
callback_url=f"{callback_base}/order",
print_callback_url=f"{callback_base}/order",
cargo=(order.items[0].product_name if order.items else None) or CARGO_DEFAULT,
weight=weight,
remark=f"SDS-{order_no} autogen",
salt=CALLBACK_SALT,
return_type="20",
tempid=tempid,
extra={"op": 0, "resultv2": "0"},
)
waybill = str(data.get("kuaidinum") or "")
record.update(kuaidiOrderId=data.get("orderId"), waybill=waybill)
print(f"[autogen] 订单 {order_no} 下单成功 {waybill},等待面单回调…")
label_url = _wait_label_url(data.get("orderId"), callback_base, label_wait_seconds)
if not label_url:
# 不自动取消:快递员可能已接单,取消会扣其绩效;记录待人工跟进(后台补打面单或人工取消)。
record["error"] = f"面单回调等待超时({label_wait_seconds}s),需人工跟进"
record["manualFollowUp"] = True
print(f"[autogen] 订单 {order_no} 面单超时,保留快递单 {waybill} 待人工处理(不自动取消)")
return record
pdf_bytes = _label_to_pdf(label_url)
upload = sds.upload_waybill_pdf(f"{order_no}.pdf", pdf_bytes)
sds.attach_parcel_label(order.id, parcel_id, waybill, upload["url"])
record.update(result="ok", labelPdf=upload["url"], labelImage=label_url, weightKg=weight)
print(f"[autogen] 订单 {order_no} 完成: {waybill} → {upload['url']}")
return record
@flow(name="sds-waybill-autogen", log_prints=True)
def waybill_autogen(
enabled: bool = True,
courier: str = "jtexpress",
factory_ids: list[int] | None = None,
order_status_code: str = "2",
max_orders: int = 20,
label_wait_seconds: int = 120,
kuaidi100_block: str = "kuaidi100-prod",
callback_base: str = CALLBACK_BASE,
) -> dict[str, Any]:
"""运单待生成订单 → 极兔下单 + 面单回传。prefect.yaml 默认 schedule active:false。"""
factory_ids = factory_ids or [1161]
if not enabled:
print("[autogen] enabled=false,本轮跳过(不查询不下单)")
return {"enabled": False, "processed": 0}
cred = _load_secret(kuaidi100_block)
if not cred.get("tempid"):
raise ValueError(f"Secret Block {kuaidi100_block} 缺 tempid(面单模板编码),无法出面单")
sds = create_sds_client()
kd = Kuaidi100Client(cred["key"], cred["secret"], customer=cred.get("customer"))
try:
resp = sds.get_orders(
OrderPageRequest(
factory_ids=factory_ids,
carriage_no_status="generate",
order_status_code=order_status_code,
size=min(max_orders, 200),
page=1,
)
)
print(
f"[autogen] 待处理订单 {resp.total_count} 条"
f"(本轮上限 {max_orders},快递={courier},orderStatusCode={order_status_code})"
)
records = []
for order in resp.records[:max_orders]:
try:
records.append(
_process_one(
sds, kd, order,
courier=courier, tempid=cred["tempid"],
callback_base=callback_base, label_wait_seconds=label_wait_seconds,
)
)
except Exception as err: # noqa: BLE001
print(f"[autogen] 订单 {order.no} 处理异常: {err}")
records.append({"orderNo": order.no, "result": "fail", "error": str(err)[:300]})
finally:
kd.close()
sds.close()
summary = {
"enabled": True,
"pendingTotal": resp.total_count,
"processed": len(records),
"ok": sum(1 for r in records if r["result"] == "ok"),
"skip": sum(1 for r in records if r["result"] == "skip"),
"fail": sum(1 for r in records if r["result"] == "fail"),
"records": records,
}
print(
f"[autogen] 本轮完成: 成功 {summary['ok']} / 跳过 {summary['skip']} / 失败 {summary['fail']}"
f"(待生成共 {resp.total_count})"
)
return summary
if __name__ == "__main__":
# 本地直接运行默认不下单;要真实执行请带 enabled=True 显式运行或部署后由调度触发。
waybill_autogen(enabled=False)