- 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)
242 lines
9.6 KiB
Python
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)
|