Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f4e1ffa1f | ||
|
|
d60a99ab4e | ||
|
|
4a7c44a354 | ||
|
|
40288c2cd6 | ||
|
|
740bd803a9 | ||
|
|
a7fd330622 | ||
|
|
f87662ba4b | ||
|
|
eb97ee1647 | ||
|
|
0baac665da | ||
|
|
1a13075926 | ||
|
|
019cbadd1e | ||
|
|
e6fdda0b3f | ||
|
|
e4885803df | ||
|
|
2321e8ae9f | ||
|
|
fdcf107993 | ||
|
|
e5175a0504 | ||
|
|
489dacca49 | ||
|
|
5eacfefb6c | ||
|
|
63d30d8f25 | ||
|
|
c030eca178 | ||
|
|
fff5b707ac | ||
|
|
c6be5bacf6 | ||
|
|
2259bce7c4 | ||
|
|
d07c67327e | ||
|
|
10d3c5f278 | ||
|
|
6007b2c34b | ||
|
|
d8e4adb0b5 | ||
|
|
9c76772969 | ||
|
|
f37ef24cf0 | ||
|
|
1393b2a86b | ||
|
|
5aaddbbf7c | ||
|
|
a83b89b08a |
@@ -12,7 +12,10 @@ jobs:
|
||||
- name: 检出代码
|
||||
run: |
|
||||
REF="${{ github.event.pull_request.head.ref || github.ref_name }}"
|
||||
git clone --depth 1 --branch "$REF" "${{ github.server_url }}/${{ github.repository }}.git" .
|
||||
git clone --depth 1 "${{ github.server_url }}/${{ github.repository }}.git" .
|
||||
# PR head 可能是完整 ref(AGit 建的 PR 为 refs/pull/N/head),clone --branch 只认分支名,改 fetch 后 checkout
|
||||
git fetch --depth 1 origin "$REF"
|
||||
git checkout --detach FETCH_HEAD
|
||||
- name: 安装 uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
- name: 安装依赖
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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()
|
||||
+3
-1
@@ -37,14 +37,16 @@ class OcrResult(BaseModel):
|
||||
|
||||
|
||||
# OCR 服务已有分类器模型的国家(版面见 api.md §4)
|
||||
SUPPORTED_COUNTRIES = {"US", "UK", "DE", "MX", "SA"}
|
||||
SUPPORTED_COUNTRIES = {"US", "UK", "DE", "MX", "SA", "CA", "ES", "IT", "PL"}
|
||||
|
||||
# ISO 国家代码 / 中文国名 → OCR 国家代码
|
||||
_COUNTRY_MAP: dict[str, str] = {
|
||||
# 代码
|
||||
"GB": "UK", "UK": "UK", "US": "US", "DE": "DE", "MX": "MX", "SA": "SA",
|
||||
"CA": "CA", "ES": "ES", "IT": "IT", "PL": "PL",
|
||||
# 中文名
|
||||
"英国": "UK", "美国": "US", "德国": "DE", "墨西哥": "MX", "沙特": "SA",
|
||||
"加拿大": "CA", "西班牙": "ES", "意大利": "IT", "波兰": "PL",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -246,6 +246,14 @@ class RiinDisClient(TokenHttpClient):
|
||||
data = self.post("distribution/order/goods", body={"orderId": order_id})
|
||||
return OrderGoodsResponse.model_validate(data)
|
||||
|
||||
def get_order_goods_batch(self, ids: list[str]) -> OrderGoodsResponse:
|
||||
"""批量获取订单商品(distribution/order/goods/list)。
|
||||
|
||||
上游要求:单批 ≤200 个 id 且串行调用;高频/并发会被软限流(成功但空 data)。
|
||||
"""
|
||||
data = self.post("distribution/order/goods/list", body={"ids": ids})
|
||||
return OrderGoodsResponse.model_validate(data)
|
||||
|
||||
def get_order_count(
|
||||
self, params: OrderPageRequest | None = None
|
||||
) -> OrderListCountResponse:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ services:
|
||||
prefect-worker:
|
||||
build: .
|
||||
container_name: prefect-worker
|
||||
# init 进程(tini)回收孤儿/僵尸:pull 步骤的 git 子进程被杀后没人 wait 会变僵尸,
|
||||
# 堆满 cgroup PID 配额后容器内无法再 fork(2026-09-17 事故根因)
|
||||
init: true
|
||||
environment:
|
||||
PREFECT_API_URL: http://prefect-server:4200/api
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 2026-09-28 Goods.status 约束新增允许 '暂停'
|
||||
-- 背景:RIIN 商品级 goodsStatus 复用平台状态枚举,存在 PAUSE(暂停),
|
||||
-- 原约束未包含该值,导致映射层只能落 NULL(chk_goods_status 拒绝其余新值)。
|
||||
-- 配套:mappers.py 的 RIIN_GOODS_STATUS_MAP 补 COMPLETE/PAUSE。
|
||||
-- 执行方式(两步,避免阻塞 ETL 写入;VALIDATE 只锁 SHARE UPDATE EXCLUSIVE):
|
||||
ALTER TABLE "Goods" DROP CONSTRAINT chk_goods_status;
|
||||
ALTER TABLE "Goods" ADD CONSTRAINT chk_goods_status CHECK (
|
||||
status IS NULL OR status = ANY (ARRAY[
|
||||
'备货中', '已删除', '已取消', '已完成', '待付款', '待确认',
|
||||
'搁置中', '待发货', '已发货', '退款中', '暂停'
|
||||
])
|
||||
) NOT VALID;
|
||||
ALTER TABLE "Goods" VALIDATE CONSTRAINT chk_goods_status;
|
||||
+33
-9
@@ -7,6 +7,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from prefect import flow, task
|
||||
@@ -16,6 +17,8 @@ from api.ocr import OcrClient, country_to_ocr_code
|
||||
from flows.shared import SyncDB, build_stats_rows, emit_sync_summary
|
||||
|
||||
BATCH_SIZE = 200
|
||||
BATCH_RETRIES = 3 # 批量请求级重试(OCR 服务偶发秒级重启)
|
||||
BATCH_RETRY_BACKOFF = [5, 15] # 重试间隔(秒)
|
||||
|
||||
_OCR_FIELD_MAP = [
|
||||
("layout", "ocrLayout"), ("courier", "ocrCourier"), ("tracking", "ocrTracking"),
|
||||
@@ -26,13 +29,20 @@ _OCR_FIELD_MAP = [
|
||||
]
|
||||
|
||||
|
||||
# 单号不需要 "-" 的承运商(Evri 单号两段式带 "-",下游查询用无 "-" 形式)
|
||||
_DASH_STRIP_COURIERS = {"EVRI"}
|
||||
|
||||
|
||||
def _result_to_fields(r) -> dict[str, str | None]:
|
||||
if not r.ok:
|
||||
err = (r.error or "").lower()
|
||||
if "reject" in err or "not a recognized layout" in err:
|
||||
return {"ocrLayout": "UNSUPPORTED"}
|
||||
return {"ocrLayout": "ERROR"}
|
||||
return {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP}
|
||||
fields = {col: (getattr(r, attr) or "") for attr, col in _OCR_FIELD_MAP}
|
||||
if (r.courier or "").strip().upper() in _DASH_STRIP_COURIERS:
|
||||
fields["ocrTracking"] = fields["ocrTracking"].replace("-", "")
|
||||
return fields
|
||||
|
||||
|
||||
def _has_running_peer() -> bool:
|
||||
@@ -63,7 +73,7 @@ def _has_running_peer() -> bool:
|
||||
def process_all(pending: list) -> dict[str, int]:
|
||||
db = SyncDB()
|
||||
ocr = OcrClient()
|
||||
stats = {"success": 0, "error": 0, "unsupported": 0, "batches": 0}
|
||||
stats = {"success": 0, "error": 0, "unsupported": 0, "batches": 0, "deferred": 0}
|
||||
try:
|
||||
# 1. 按国家分组;不支持的国家直接标 UNSUPPORTED
|
||||
by_country: dict[str, list[tuple[str, object, str]]] = {}
|
||||
@@ -89,12 +99,24 @@ def process_all(pending: list) -> dict[str, int]:
|
||||
urls = [{"url": url, "filename": no} for no, _, url in batch]
|
||||
batch_no = i // BATCH_SIZE + 1
|
||||
total_batches = -(-total // BATCH_SIZE)
|
||||
results = None
|
||||
for attempt in range(BATCH_RETRIES):
|
||||
try:
|
||||
results = ocr.recognize_batch(urls, country_code)
|
||||
break
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} 请求失败: {str(err)[:100]}")
|
||||
db.update_ocr_batch([(no, created_map[no], {"ocrLayout": "ERROR"}) for no in created_map])
|
||||
stats["error"] += len(batch)
|
||||
if attempt < BATCH_RETRIES - 1:
|
||||
wait = BATCH_RETRY_BACKOFF[min(attempt, len(BATCH_RETRY_BACKOFF) - 1)]
|
||||
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} 请求失败"
|
||||
f"({str(err)[:80]}),{wait}s 后重试({attempt + 2}/{BATCH_RETRIES})")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# OCR 服务端短暂不可用(容器秒级重启类故障):订单保持未处理,
|
||||
# 留给下个小时的 run 自动重试,不落 ERROR(那会是永久终态)。
|
||||
print(f"[OCR] {country_code} batch {batch_no}/{total_batches} "
|
||||
f"重试 {BATCH_RETRIES} 次仍失败,留待下次 run: {str(err)[:100]}")
|
||||
if results is None:
|
||||
stats["deferred"] += len(batch)
|
||||
continue
|
||||
|
||||
updates = []
|
||||
@@ -122,12 +144,12 @@ def label_ocr(
|
||||
courier_source: str | None = None,
|
||||
days: int = 7,
|
||||
) -> dict:
|
||||
"""面单 OCR enrichment。默认处理英国+美国,近 7 天下单的订单。
|
||||
"""面单 OCR enrichment。默认处理全部已支持国家(见 api.ocr.models.SUPPORTED_COUNTRIES),近 7 天下单的订单。
|
||||
|
||||
手动跑其他国家: label_ocr(country_regions=['德国'])
|
||||
手动跑指定国家: label_ocr(country_regions=['德国'])
|
||||
"""
|
||||
if country_regions is None:
|
||||
country_regions = ['英国', '美国', '墨西哥', '德国', '沙特']
|
||||
country_regions = ['英国', '美国', '墨西哥', '德国', '沙特', '加拿大', '西班牙', '意大利', '波兰']
|
||||
if _has_running_peer():
|
||||
print("已有 label-ocr 在运行,跳过本次")
|
||||
return {"skipped": True}
|
||||
@@ -152,11 +174,13 @@ def label_ocr(
|
||||
after = db.count_ocr_status()
|
||||
total_processed = stats["success"] + stats["error"] + stats["unsupported"]
|
||||
print(f"完成{scope}: 处理 {total_processed} 单 / {stats['batches']} 批 | "
|
||||
f"成功 {stats['success']} 失败 {stats['error']} 不支持 {stats['unsupported']}")
|
||||
f"成功 {stats['success']} 失败 {stats['error']} 不支持 {stats['unsupported']} "
|
||||
f"遗留待重试 {stats['deferred']}")
|
||||
print(f"累计: {after}")
|
||||
emit_sync_summary(f"面单 OCR{scope}", build_stats_rows("ALL", {}, [
|
||||
("本批处理", total_processed), ("成功", stats["success"]),
|
||||
("失败", stats["error"]), ("不支持", stats["unsupported"]),
|
||||
("遗留待重试", stats["deferred"]),
|
||||
("累计已完成", after.get("done", 0)), ("累计待处理", after.get("pending", 0)),
|
||||
]))
|
||||
return {"processed": total_processed, **stats, **after}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""平台订单 platform 字段大写同步:把 platform_order_data.platform 统一转为大写。
|
||||
|
||||
优化自 platform_order_upper_sync.py,对应 platform-order-upper-sync。
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import psycopg2
|
||||
from prefect import flow
|
||||
|
||||
# 数据库连接参数
|
||||
conn_params = {
|
||||
"host": "192.168.124.92",
|
||||
"port": 5432,
|
||||
"user": "postgres",
|
||||
"password": "Inkreach123456.",
|
||||
"database": "smt",
|
||||
}
|
||||
|
||||
|
||||
def log_msg(msg, level="info"):
|
||||
ts = time.strftime('%H:%M:%S')
|
||||
tag = {"success": "[OK]", "error": "[ERR]"}.get(level, "")
|
||||
print(f"[{ts}] {tag} {msg}")
|
||||
|
||||
|
||||
def update_platform_upper() -> int:
|
||||
conn = None
|
||||
cur = None
|
||||
try:
|
||||
conn = psycopg2.connect(**conn_params)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 仅更新需要变更的行,避免每次都全表写入
|
||||
update_sql = """
|
||||
UPDATE platform_order_data
|
||||
SET platform = UPPER(platform)
|
||||
WHERE platform IS NOT NULL
|
||||
AND platform <> UPPER(platform);
|
||||
"""
|
||||
cur.execute(update_sql)
|
||||
affected_rows = cur.rowcount
|
||||
conn.commit()
|
||||
log_msg(f"platform 大写转换完成,共 {affected_rows} 行受影响", "success")
|
||||
return affected_rows
|
||||
except Exception as e:
|
||||
log_msg(f"platform 大写转换失败:{e}", "error")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
if cur:
|
||||
cur.close()
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
@flow(name="platform-order-upper-sync", log_prints=True)
|
||||
def platform_order_upper_sync() -> int:
|
||||
return update_platform_upper()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
platform_order_upper_sync.serve(name="platform-order-upper-sync", cron="*/2 * * * *")
|
||||
@@ -22,7 +22,7 @@ RIIN_LOGIN_URL = "http://tshirt.riin.com/auth/api/auth/login"
|
||||
RIIN_ORDER_URL = "https://tshirt.riin.com/distribution/order/page"
|
||||
USERNAME_RIIN = "预上网"
|
||||
PASSWORD_RIIN = "Aa123456."
|
||||
SUB_ID_LIST = ["10368", "10283", "16905", "11502", "13615", "11531"]
|
||||
SUB_ID_LIST = ["10368", "10283", "16905", "11502", "13615", "11531","12279"]
|
||||
MAX_RETRY = 3; REQUEST_TIMEOUT = 30; PAGE_SIZE_RIIN = 200
|
||||
|
||||
LOOKBACK_DAYS = 6 # 回查14天避免漏单
|
||||
@@ -208,4 +208,5 @@ async def riin_platform_sync():
|
||||
log_msg("===== 完成 =====")
|
||||
|
||||
if __name__ == "__main__":
|
||||
riin_platform_sync.serve(name="platform-order-riin", cron="15 * * * *")
|
||||
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
|
||||
riin_platform_sync()
|
||||
|
||||
@@ -273,4 +273,5 @@ def rouhui_platform_sync():
|
||||
log_msg("===== 提取完成 =====")
|
||||
|
||||
if __name__ == "__main__":
|
||||
rouhui_platform_sync.serve(name="platform-order-rouhui", cron="*/15 * * * *")
|
||||
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
|
||||
rouhui_platform_sync()
|
||||
@@ -359,10 +359,18 @@ def sd_format_item(item):
|
||||
create_time = item.get("gmtCreated", "")
|
||||
parcel_list = item.get("parcelList", [])
|
||||
if parcel_list and isinstance(parcel_list, list):
|
||||
qty = sum(p.get("totalQty", 0) for p in parcel_list)
|
||||
def safe_int(val):
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
qty = sum(safe_int(p.get("totalQty", 0)) for p in parcel_list)
|
||||
else:
|
||||
qty = 0
|
||||
|
||||
|
||||
|
||||
return [
|
||||
to_chinese_country(country),
|
||||
to_chinese_country(country),
|
||||
@@ -471,4 +479,5 @@ async def sdspod_platform_sync():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sdspod_platform_sync.serve(name="platform-order-sdspod", cron="*/15 * * * *")
|
||||
# 直跑一次用于本地调试;勿改回 serve():同名 upsert 会覆盖 server 上挂 work pool 的正式 deployment
|
||||
sdspod_platform_sync()
|
||||
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""shop_dim 同步 flow:teable(源) -> 本地 smt 库(目标)。
|
||||
|
||||
最小包装自 sync_shop_dim.py,对应 shop-dim-sync,每 5 分钟调度一次。
|
||||
源: PostgreSQL @ table.inkreach.cc:10432 db=teable schema=bsehWCxpH6HoHUdkjUd
|
||||
物理表 tblUF7OrBxeRKUyWBc2 (teable 里名为 "shop_dim" 的表)
|
||||
目标: PostgreSQL @ 192.168.124.92:5432 db=smt public.shop_dim
|
||||
|
||||
本地直接跑: python flows/platform_order/sync_shop_dim.py
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
from prefect import flow
|
||||
|
||||
SRC = dict(host="table.inkreach.cc", port=10432, dbname="teable",
|
||||
user="read_only_role_bsehWCxpH6HoHUdkjUd",
|
||||
password="ygBuh8Au5J-PjuNQASos0", connect_timeout=25)
|
||||
SRC_TABLE = '"bsehWCxpH6HoHUdkjUd"."tblUF7OrBxeRKUyWBc2"'
|
||||
|
||||
DST = dict(host="192.168.124.92", port=5432, dbname="smt",
|
||||
user="postgres", password="Inkreach123456.", connect_timeout=15)
|
||||
|
||||
COL_MAP = [
|
||||
("Guo_Jia", "country"),
|
||||
("Dian_Pu_Ming_Cheng", "shop_name"),
|
||||
("DBDian_Pu_Ming", "db_store_name"),
|
||||
("Ping_Tai", "platform"),
|
||||
("Lei_Xing", "shop_type"),
|
||||
("Deng_Ji", "tier"),
|
||||
("Fu_Ze_Ren", "owner"),
|
||||
("Pin_Lei", "category"),
|
||||
("Xia_Dian_Shi_Jian", "opened_at"),
|
||||
("Mu_Biao_Jian_Shu", "sep_target_qty"),
|
||||
]
|
||||
|
||||
|
||||
def log_msg(msg):
|
||||
print(msg)
|
||||
|
||||
|
||||
def connect(cfg, tries=5):
|
||||
last = None
|
||||
for i in range(tries):
|
||||
try:
|
||||
return psycopg2.connect(**cfg)
|
||||
except Exception as e:
|
||||
last = e
|
||||
log_msg(f" 连接重试 {i+1}/{tries}: {e}")
|
||||
time.sleep(3)
|
||||
raise ConnectionError(f"连接失败: {last}")
|
||||
|
||||
|
||||
def norm(v, col):
|
||||
if v is None:
|
||||
return None
|
||||
if col == "opened_at":
|
||||
return v.date() if hasattr(v, "date") else v
|
||||
if col == "sep_target_qty":
|
||||
try:
|
||||
return int(round(float(v)))
|
||||
except Exception:
|
||||
return None
|
||||
s = str(v).strip()
|
||||
return s if s != "" else None
|
||||
|
||||
|
||||
@flow(name="shop-dim-sync", log_prints=True)
|
||||
def shop_dim_sync(dry_run: bool = False, prune: bool = True) -> dict[str, Any]:
|
||||
log_msg(f"[1/4] 读取源表 {SRC_TABLE} ...")
|
||||
sc = connect(SRC)
|
||||
scur = sc.cursor()
|
||||
sel = ", ".join('"%s"' % c for c, _ in COL_MAP)
|
||||
scur.execute("SELECT %s FROM %s" % (sel, SRC_TABLE))
|
||||
raw = scur.fetchall()
|
||||
scur.close(); sc.close()
|
||||
src_rows = [
|
||||
dict(zip([d for _, d in COL_MAP], [norm(v, c) for v, (_, c) in zip(row, COL_MAP)]))
|
||||
for row in raw
|
||||
]
|
||||
log_msg(f" 源记录: {len(src_rows)} 行")
|
||||
|
||||
log_msg("[2/4] 连接目标 smt.shop_dim ...")
|
||||
dc = connect(DST)
|
||||
dcur = dc.cursor()
|
||||
dcur.execute("SELECT country, db_store_name FROM shop_dim WHERE db_store_name IS NOT NULL")
|
||||
existing = {(r[0], r[1]) for r in dcur.fetchall()}
|
||||
log_msg(f" 目标现有: {len(existing)} 个 (country, db_store_name)")
|
||||
|
||||
src_keys = {(r["country"], r["db_store_name"]) for r in src_rows}
|
||||
to_update = src_keys & existing
|
||||
to_insert = src_keys - existing
|
||||
to_delete = existing - src_keys
|
||||
log_msg(f"[3/4] 计划: 更新 {len(to_update)} | 新增 {len(to_insert)} | 待删 {len(to_delete)}")
|
||||
|
||||
if dry_run:
|
||||
log_msg("[dry-run] 不执行写入。")
|
||||
if to_delete:
|
||||
log_msg(" 待删示例: " + str(list(to_delete)[:10]))
|
||||
dcur.close(); dc.close()
|
||||
return {"sourceRows": len(src_rows), "dryRun": True}
|
||||
|
||||
cols = [d for _, d in COL_MAP]
|
||||
upd = 0
|
||||
ins = 0
|
||||
for r in src_rows:
|
||||
vals = [r[c] for c in cols]
|
||||
if (r["country"], r["db_store_name"]) in existing:
|
||||
set_clause = ", ".join(f"{c}=%s" for c in cols)
|
||||
dcur.execute(
|
||||
f"UPDATE shop_dim SET {set_clause} WHERE country=%s AND db_store_name=%s",
|
||||
vals + [r["country"], r["db_store_name"]])
|
||||
upd += dcur.rowcount
|
||||
else:
|
||||
ph = ", ".join(["%s"] * len(cols))
|
||||
dcur.execute(f"INSERT INTO shop_dim ({', '.join(cols)}) VALUES ({ph})", vals)
|
||||
ins += dcur.rowcount
|
||||
|
||||
dele = 0
|
||||
if prune and to_delete:
|
||||
for country, dbn in to_delete:
|
||||
dcur.execute("DELETE FROM shop_dim WHERE country=%s AND db_store_name=%s", (country, dbn))
|
||||
dele += dcur.rowcount
|
||||
|
||||
dc.commit()
|
||||
log_msg(f"[4/4] 完成: 更新 {upd} | 新增 {ins} | 删除 {dele}")
|
||||
dcur.execute("SELECT COUNT(*) FROM shop_dim WHERE db_store_name IS NOT NULL")
|
||||
total = dcur.fetchone()[0]
|
||||
log_msg(f" 目标现有总数: {total}")
|
||||
dcur.close(); dc.close()
|
||||
|
||||
return {
|
||||
"sourceRows": len(src_rows),
|
||||
"updated": upd,
|
||||
"inserted": ins,
|
||||
"deleted": dele,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
shop_dim_sync.serve(name="shop-dim-sync", cron="*/5 * * * *")
|
||||
@@ -1,10 +1,10 @@
|
||||
"""RIIN 同步公共管道:映射订单 → 并发拉商品 → upsert 到 PG。"""
|
||||
"""RIIN 同步公共管道:映射订单 → 批量拉商品 → upsert 到 PG。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flows.shared import fetch_goods_concurrent
|
||||
from flows.shared import fetch_goods_for_orders
|
||||
from flows.shared.db import SyncDB
|
||||
from flows.shared.mappers import map_riin_goods, map_riin_order
|
||||
from api import RiinDisClient
|
||||
@@ -31,7 +31,7 @@ def sync_riin_records(raw_orders: list[Any], client: RiinDisClient, db: SyncDB)
|
||||
|
||||
goods_inputs: list[Any] = []
|
||||
if order_meta:
|
||||
goods_map = fetch_goods_concurrent(client, list(order_meta.keys()))
|
||||
goods_map = fetch_goods_for_orders(client, list(order_meta.keys()))
|
||||
for oid, (order_no, created_at) in order_meta.items():
|
||||
items = goods_map.get(oid, [])
|
||||
if not items:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""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)
|
||||
@@ -5,7 +5,7 @@ from flows.shared.artifacts import build_stats_rows, emit_sync_summary, emit_ver
|
||||
from flows.shared.clients import (
|
||||
create_riin_client,
|
||||
create_sds_client,
|
||||
fetch_goods_concurrent,
|
||||
fetch_goods_for_orders,
|
||||
fetch_riin_all_pages,
|
||||
fetch_sds_all_pages,
|
||||
floor_to_hour,
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
"clients", "db", "mappers", "SyncDB",
|
||||
"OrderUpsertInput", "GoodsUpsertInput",
|
||||
"create_riin_client", "create_sds_client",
|
||||
"fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_concurrent",
|
||||
"fetch_riin_all_pages", "fetch_sds_all_pages", "fetch_goods_for_orders",
|
||||
"get_channel_type_map", "refresh_channel_type_map", "resolve_courier_source",
|
||||
"format_date", "start_of_day", "floor_to_hour",
|
||||
"build_stats_rows", "emit_sync_summary", "emit_verify_report",
|
||||
|
||||
+42
-24
@@ -21,7 +21,6 @@ from flows.shared.mappers import map_sds_channel_type
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
GOODS_CONCURRENCY = 20
|
||||
PAGE_DELAY_MS = 500
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY_MS = 3000
|
||||
@@ -144,39 +143,58 @@ def fetch_sds_all_pages(client: SdsMngClient, params: dict[str, Any]) -> list[An
|
||||
|
||||
# ---------- RIIN 商品 ----------
|
||||
|
||||
def fetch_goods_for_order(client: RiinDisClient, order_id: str) -> list[Any]:
|
||||
try:
|
||||
resp = client.get_order_goods(order_id)
|
||||
return list(resp.data)
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[RIIN sync] 订单 {order_id} 商品拉取失败: {err}")
|
||||
return []
|
||||
# 批量接口 distribution/order/goods/list:单批 ≤200 个 id,且要求串行调用(分销侧约定)。
|
||||
# 相比旧逐单接口(软限流:高频返回成功但空 data),调用量从日均 ~1 万次降到 ~50 次。
|
||||
GOODS_BATCH_SIZE = 200
|
||||
GOODS_BATCH_INTERVAL_MS = 2000 # 相邻两批的串行间隔
|
||||
|
||||
|
||||
def fetch_goods_concurrent(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]:
|
||||
"""以 GOODS_CONCURRENCY 并发拉取多订单商品,返回 {order_id: [goods...]}。"""
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
def fetch_goods_for_orders(client: RiinDisClient, order_ids: list[str]) -> dict[str, list[Any]]:
|
||||
"""按 goods/list 批量接口串行拉取多订单商品,返回 {order_id: [goods...]}。
|
||||
|
||||
results: dict[str, list[Any]] = {}
|
||||
单批失败(网络/业务错误)整批重试最多 MAX_RETRIES 次,仍失败则该批订单记空列表,
|
||||
由调用方按"无商品订单"计数(与旧逐单路径的容错语义一致)。
|
||||
"""
|
||||
results: dict[str, list[Any]] = {oid: [] for oid in order_ids}
|
||||
if not order_ids:
|
||||
return results
|
||||
concurrency = min(GOODS_CONCURRENCY, len(order_ids))
|
||||
print(f"[RIIN sync] 商品拉取开始: {len(order_ids)} 个订单 (并发={concurrency})")
|
||||
|
||||
def make(oid: str) -> Callable[[], None]:
|
||||
def run() -> None:
|
||||
results[oid] = fetch_goods_for_order(client, oid)
|
||||
return run
|
||||
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||
list(pool.map(lambda fn: fn(), [make(oid) for oid in order_ids]))
|
||||
total_batches = -(-len(order_ids) // GOODS_BATCH_SIZE)
|
||||
print(f"[RIIN sync] 商品批量拉取开始: {len(order_ids)} 个订单 / {total_batches} 批"
|
||||
f" (goods/list 串行, 批间 {GOODS_BATCH_INTERVAL_MS}ms)")
|
||||
for bi in range(total_batches):
|
||||
chunk = order_ids[bi * GOODS_BATCH_SIZE : (bi + 1) * GOODS_BATCH_SIZE]
|
||||
items = _fetch_goods_batch_with_retry(client, chunk, bi + 1)
|
||||
for g in items:
|
||||
if g.order_id in results:
|
||||
results[g.order_id].append(g)
|
||||
if bi < total_batches - 1:
|
||||
time.sleep(GOODS_BATCH_INTERVAL_MS / 1000)
|
||||
total_goods = sum(len(v) for v in results.values())
|
||||
empty_orders = sum(1 for v in results.values() if not v)
|
||||
print(f"[RIIN sync] 商品拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品")
|
||||
print(f"[RIIN sync] 商品批量拉取完成: 共 {total_goods} 条商品, {empty_orders} 个订单无商品")
|
||||
return results
|
||||
|
||||
|
||||
def _fetch_goods_batch_with_retry(client: RiinDisClient, chunk: list[str], batch_no: int) -> list[Any]:
|
||||
last_err: Exception | None = None
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = fetch_with_retry(
|
||||
lambda: client.get_order_goods_batch(chunk), f"RIIN goods 批次 {batch_no}"
|
||||
)
|
||||
if resp.successful is False:
|
||||
raise RuntimeError(f"goods/list 业务失败: {resp.message}")
|
||||
return list(resp.data)
|
||||
except Exception as err: # noqa: BLE001
|
||||
last_err = err
|
||||
print(f"[RIIN sync] 商品批次 {batch_no} 第 {attempt}/{MAX_RETRIES} 次失败: {str(err)[:120]}")
|
||||
if attempt < MAX_RETRIES:
|
||||
time.sleep(RETRY_DELAY_MS * attempt / 1000)
|
||||
assert last_err is not None
|
||||
print(f"[RIIN sync] 商品批次 {batch_no} 放弃, {len(chunk)} 个订单本轮无商品")
|
||||
return []
|
||||
|
||||
|
||||
# ---------- SDS 物流渠道字典 ----------
|
||||
|
||||
_channel_cache: dict[str, Any] = {}
|
||||
|
||||
+7
-4
@@ -25,8 +25,9 @@ LOOKUP_BATCH = 10000
|
||||
# 单锁无环,写事务之间从结构上不可能死锁。1768851058 = "inkr" 的 ASCII 整数表示。
|
||||
DB_WRITE_LOCK_KEY = 1768851058
|
||||
LOCK_TIMEOUT = "15s" # 等锁超时 → LockNotAvailable → 交给重试兜底
|
||||
WRITE_RETRIES = 3 # 死锁/等锁超时的整事务重试次数
|
||||
WRITE_RETRY_BACKOFF = [1, 3, 7] # 重试退避(秒)
|
||||
WRITE_RETRIES = 5 # 死锁/等锁超时的整事务重试次数(外部写者如 17track
|
||||
# 物流回写不走 advisory 锁,冲突窗口可达分钟级,重试需够深)
|
||||
WRITE_RETRY_BACKOFF = [1, 3, 7, 15, 30] # 重试退避(秒;末位预留,最后一次失败直接抛出)
|
||||
WRITE_RETRYABLE_ERRORS = (pg_errors.DeadlockDetected, pg_errors.LockNotAvailable)
|
||||
|
||||
|
||||
@@ -459,9 +460,11 @@ class SyncDB:
|
||||
stats = {"ordersCreated": 0, "ordersUpdated": 0, "goodsCreated": 0, "goodsUpdated": 0, "goodsRemoved": 0}
|
||||
with conn.cursor() as cur:
|
||||
# 统计已存在 → created/updated
|
||||
# 注:必须 cast 成 timestamptz——入参是带时区偏移的 tz-aware datetime,
|
||||
# cast 成 timestamp 会丢弃偏移,在会话时区非 +08 时匹配不到既有行,导致 created/updated 统计全错。
|
||||
pairs = [(o.orderNo, o.createdAt) for o in orders]
|
||||
cur.execute(
|
||||
"SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamp[]) AS t(order_no,created_at) "
|
||||
"SELECT t.order_no, t.created_at FROM unnest(%s::text[], %s::timestamptz[]) AS t(order_no,created_at) "
|
||||
"JOIN \"Orders\" o ON o.\"orderNo\"=t.order_no AND o.\"createdAt\"=t.created_at",
|
||||
_unnest_pairs(pairs),
|
||||
)
|
||||
@@ -622,6 +625,6 @@ class SyncDB:
|
||||
def _unnest_pairs(pairs: list[tuple]) -> tuple[list, list]:
|
||||
"""构造 psycopg2 unnest 双数组参数:返回 (col_a_list, col_b_list)。
|
||||
|
||||
配合 SQL:unnest(%s::text[], %s::timestamp[]) 或 unnest(%s::text[], %s::text[])。
|
||||
配合 SQL:unnest(%s::text[], %s::timestamptz[]) 或 unnest(%s::text[], %s::text[])。
|
||||
"""
|
||||
return [p[0] for p in pairs], [p[1] for p in pairs]
|
||||
|
||||
+51
-8
@@ -33,6 +33,10 @@ RIIN_EXPRESS_SOURCE_MAP: dict[int, str] = {
|
||||
RIIN_GOODS_STATUS_MAP: dict[str, str] = {
|
||||
"NOT_SHIPPED": "待发货", "SHIPPED": "已发货", "CANCEL": "已取消",
|
||||
"CANCELLED": "已取消", "REFUNDING": "退款中",
|
||||
# 商品级状态复用平台状态枚举,COMPLETE/PAUSE 缺失曾导致 1/3 商品行 status 为 NULL
|
||||
"COMPLETE": "已完成", "PAUSE": "暂停",
|
||||
# CLOSE 同为商品终态(Goods 约束无"已关闭",取"已取消");UNKNOWN 保留 NULL 并由告警暴露
|
||||
"CLOSE": "已取消",
|
||||
}
|
||||
|
||||
RIIN_PLATFORM_STATUS_MAP: dict[str, str] = {
|
||||
@@ -55,6 +59,17 @@ SDS_CHANNEL_TYPE_LABEL_MAP: dict[str, str] = {
|
||||
|
||||
# ---------- 日期解析 ----------
|
||||
|
||||
# 未知枚举值告警:同值只报一次,避免批量同步刷屏(对齐 TS 版 warnUnknown)
|
||||
_warned_values: set[str] = set()
|
||||
|
||||
|
||||
def _warn_unknown(map_name: str, value: object) -> None:
|
||||
key = f"{map_name}:{value!r}"
|
||||
if key not in _warned_values:
|
||||
_warned_values.add(key)
|
||||
print(f"[mapper] unknown {map_name} value: {value!r}")
|
||||
|
||||
|
||||
def safe_date(value: Any, context: str = "") -> datetime:
|
||||
"""解析日期字符串(无时区视为 +08:00),返回 tz-aware datetime。"""
|
||||
if not value or not isinstance(value, str):
|
||||
@@ -188,15 +203,20 @@ class GoodsUpsertInput:
|
||||
|
||||
# ---------- RIIN 映射 ----------
|
||||
|
||||
def map_riin_system_status(order_status: int) -> str | None:
|
||||
return RIIN_SYSTEM_STATUS_MAP.get(order_status)
|
||||
def map_riin_system_status(order_status: int | None) -> str | None:
|
||||
if order_status is None:
|
||||
return None
|
||||
v = RIIN_SYSTEM_STATUS_MAP.get(order_status)
|
||||
if v is None:
|
||||
_warn_unknown("RIIN systemStatus", order_status)
|
||||
return v
|
||||
|
||||
|
||||
def map_riin_order(raw: Any) -> OrderUpsertInput:
|
||||
platform_oid = raw.platform_oid
|
||||
ordered_at = _parse(raw.push_time, f"RIIN order {platform_oid} pushTime")
|
||||
confirmed_at = _parse(raw.distributor_push_time, f"RIIN order {platform_oid} distributorPushTime")
|
||||
system_status = map_riin_system_status(raw.order_status) if raw.order_status is not None else None
|
||||
system_status = map_riin_system_status(raw.order_status)
|
||||
is_terminal = system_status in ("已发货", "已关闭", "已取消")
|
||||
completed_at: datetime | None = None
|
||||
if is_terminal:
|
||||
@@ -236,6 +256,15 @@ def map_riin_order(raw: Any) -> OrderUpsertInput:
|
||||
)
|
||||
|
||||
|
||||
def map_riin_goods_status(goods_status: str | None) -> str | None:
|
||||
if not goods_status:
|
||||
return None
|
||||
v = RIIN_GOODS_STATUS_MAP.get(goods_status)
|
||||
if v is None:
|
||||
_warn_unknown("RIIN goodsStatus", goods_status)
|
||||
return v
|
||||
|
||||
|
||||
def map_riin_goods(raw: Any, parent_order_no: str, parent_created_at: datetime) -> GoodsUpsertInput:
|
||||
product_code = raw.product_code
|
||||
item_name = product_code.split("-")[0] if product_code else None
|
||||
@@ -250,7 +279,7 @@ def map_riin_goods(raw: Any, parent_order_no: str, parent_created_at: datetime)
|
||||
itemSize=raw.size_code,
|
||||
itemStyleNo=style_no,
|
||||
quantity=raw.num if raw.num is not None else 1,
|
||||
status=RIIN_GOODS_STATUS_MAP.get(raw.goods_status) if raw.goods_status else None,
|
||||
status=map_riin_goods_status(raw.goods_status),
|
||||
)
|
||||
|
||||
|
||||
@@ -262,13 +291,27 @@ def map_sds_channel_type(channel_type: Any) -> str | None:
|
||||
return SDS_CHANNEL_TYPE_LABEL_MAP.get(channel_type)
|
||||
|
||||
|
||||
def map_sds_system_status(status: int) -> str | None:
|
||||
return SDS_SYSTEM_STATUS_MAP.get(status)
|
||||
def map_sds_system_status(status: int | None) -> str | None:
|
||||
if status is None:
|
||||
return None
|
||||
v = SDS_SYSTEM_STATUS_MAP.get(status)
|
||||
if v is None:
|
||||
_warn_unknown("SDS systemStatus", status)
|
||||
return v
|
||||
|
||||
|
||||
def map_sds_goods_status(status: int | None) -> str | None:
|
||||
if status is None:
|
||||
return None
|
||||
v = SDS_GOODS_STATUS_MAP.get(status)
|
||||
if v is None:
|
||||
_warn_unknown("SDS goodsStatus", status)
|
||||
return v
|
||||
|
||||
|
||||
def map_sds_order(raw: Any, channel_type_map: dict[int, str | None], pre_courier: str | None = None) -> OrderUpsertInput:
|
||||
ordered_at = _parse(raw.gmt_pay, f"SDS order {raw.no} gmtPay")
|
||||
system_status = map_sds_system_status(raw.status) if raw.status is not None else None
|
||||
system_status = map_sds_system_status(raw.status)
|
||||
is_terminal = system_status in ("已完成", "已取消")
|
||||
completed_at: datetime | None = None
|
||||
if is_terminal and raw.finish_time and raw.finish_time > 0:
|
||||
@@ -369,7 +412,7 @@ def map_sds_goods(raw: Any, parent_order_no: str, parent_created_at: datetime) -
|
||||
itemSize=raw.product_size,
|
||||
itemStyleNo=_parse_sds_style_no(product_name),
|
||||
quantity=raw.num if raw.num is not None else 1,
|
||||
status=SDS_GOODS_STATUS_MAP.get(raw.status) if raw.status is not None else None,
|
||||
status=map_sds_goods_status(raw.status),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+25
-1
@@ -21,6 +21,7 @@ deployments:
|
||||
schedules:
|
||||
- cron: 30 * * * *
|
||||
timezone: Asia/Shanghai
|
||||
active: false # 暂停:每小时重拉近7天全部订单商品,对 RIIN distribution/order/goods 调用量过大(增量/刷新未完结/日报核验已覆盖)
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: riin-refresh-pending
|
||||
@@ -39,6 +40,7 @@ deployments:
|
||||
schedules:
|
||||
- cron: 0 6 * * *
|
||||
timezone: Asia/Shanghai
|
||||
active: false # 暂停:与 daily-report 18:30 的核验子流程重复,保留日报一处即可
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: sds-order-sync
|
||||
@@ -80,6 +82,14 @@ deployments:
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: sds-waybill-autogen
|
||||
entrypoint: flows/sds/waybill_autogen.py:waybill_autogen
|
||||
schedules:
|
||||
- cron: '*/10 * * * *'
|
||||
timezone: Asia/Shanghai
|
||||
active: false # 暂停:真实下单 flow(极兔+面单回传)。链路人工核对后改 true 并 `uv run prefect deploy --all` 生效
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: platform-order-riin
|
||||
entrypoint: flows/platform_order/riin.py:riin_platform_sync
|
||||
schedules:
|
||||
@@ -97,7 +107,21 @@ deployments:
|
||||
- name: platform-order-sdspod
|
||||
entrypoint: flows/platform_order/sdspod.py:sdspod_platform_sync
|
||||
schedules:
|
||||
- cron: '*/30 * * * *'
|
||||
- cron: 0 * * * *
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: platform-order-upper-sync
|
||||
entrypoint: flows/platform_order/platform_order_upper_sync.py:platform_order_upper_sync
|
||||
schedules:
|
||||
- cron: '*/2 * * * *'
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: shop-dim-sync
|
||||
entrypoint: flows/platform_order/sync_shop_dim.py:shop_dim_sync
|
||||
schedules:
|
||||
- cron: '*/5 * * * *'
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"psutil>=7.2.2",
|
||||
"requests>=2.34.2",
|
||||
"aiohttp>=3.14.3",
|
||||
"pillow>=13.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""一次性回填:RIIN Goods.status 为 NULL 的历史行(终态"已发货"订单)。
|
||||
|
||||
背景:RIIN 商品级 goods_status 复用平台状态枚举,COMPLETE/PAUSE 此前无映射,
|
||||
约 1/3 商品行 status 被写为 NULL(mappers.py 已补映射)。本脚本按订单终态
|
||||
将近似值回填;"已关闭"与非终态订单不在此列,走 riin-by-orders 重拉取真值。
|
||||
幂等:只更新 status IS NULL 的行。分批提交,避免长事务。
|
||||
|
||||
运行:uv run python scripts/backfill_riin_goods_status.py [--dry-run] [--batch N]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
|
||||
|
||||
DEFAULT_BATCH = 50_000
|
||||
|
||||
SQL_COUNT = """
|
||||
SELECT count(*) FROM "Goods" g
|
||||
JOIN "Orders" o ON o."orderNo" = g."orderNo" AND o."createdAt" = g."createdAt"
|
||||
WHERE o."systemName" = 'RIIN' AND o."systemStatus" = '已发货' AND g."status" IS NULL
|
||||
"""
|
||||
|
||||
# ctid 分批:PostgreSQL 的 UPDATE 不支持 LIMIT,用子查询圈定每批行
|
||||
SQL_BACKFILL_BATCH = """
|
||||
UPDATE "Goods" g SET "status" = '已发货'
|
||||
WHERE g.ctid IN (
|
||||
SELECT g2.ctid FROM "Goods" g2
|
||||
JOIN "Orders" o ON o."orderNo" = g2."orderNo" AND o."createdAt" = g2."createdAt"
|
||||
WHERE o."systemName" = 'RIIN' AND o."systemStatus" = '已发货' AND g2."status" IS NULL
|
||||
LIMIT %s
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="回填 RIIN Goods.status NULL(已发货订单)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只统计待回填行数,不写入")
|
||||
parser.add_argument("--batch", type=int, default=DEFAULT_BATCH, help="每事务回填行数")
|
||||
args = parser.parse_args()
|
||||
|
||||
conn = psycopg2.connect(os.environ["DB_DSN"])
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SQL_COUNT)
|
||||
(pending,) = cur.fetchone()
|
||||
print(f"[backfill] 待回填(status IS NULL 且订单已发货): {pending} 行")
|
||||
if args.dry_run or pending == 0:
|
||||
return
|
||||
|
||||
total = 0
|
||||
while True:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(SQL_BACKFILL_BATCH, (args.batch,))
|
||||
updated = cur.rowcount
|
||||
conn.commit()
|
||||
total += updated
|
||||
print(f"[backfill] 本批 {updated} 行,累计 {total}")
|
||||
if updated < args.batch:
|
||||
break
|
||||
print(f"[backfill] 完成,共回填 {total} 行")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""快递100 上门取件API 回调接收服务(本机常驻,经 frpc 暴露公网)。
|
||||
|
||||
接收两类推送并验签落盘(MD5(param + salt)):
|
||||
- POST /kuaidi100/order 下单状态回调(callBackUrl)与面单短链/打印回调(printCallBackUrl)
|
||||
- POST /kuaidi100/poll 轨迹推送(pollCallBackUrl)
|
||||
|
||||
应答格式必须是 {"result":true,"returnCode":"200","message":"成功"},
|
||||
否则快递100会以 30 分钟间隔重推 3 次。
|
||||
|
||||
查看:
|
||||
- GET / 状态与最近回调摘要
|
||||
- GET /list?n=20 最近 n 条回调明细(JSON)
|
||||
|
||||
落盘: <repo>/data/kuaidi100/callbacks.jsonl,每行 {ts,path,verified,taskId,param}
|
||||
|
||||
用法: uv run python scripts/kuaidi100_callback_server.py [--port 9802] [--salt inkreach-test]
|
||||
salt 需与下单 param.salt 一致;也可用环境变量 KUAIDI100_CALLBACK_SALT 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
LOG_FILE = REPO_ROOT / "data" / "kuaidi100" / "callbacks.jsonl"
|
||||
MAX_BODY = 1 << 20 # 1MB 上限,防异常大包
|
||||
|
||||
_state = {"started_at": time.time(), "received": 0, "verified_ok": 0}
|
||||
|
||||
|
||||
def _append(record: dict) -> None:
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _recent(n: int = 20) -> list[dict]:
|
||||
if not LOG_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
lines = LOG_FILE.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
out = []
|
||||
for line in lines[-n:]:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
salt: str = os.environ.get("KUAIDI100_CALLBACK_SALT", "inkreach-test")
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None: # 静默默认访问日志,落盘代替
|
||||
pass
|
||||
|
||||
# ----- POST 回调 -----
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if not path.startswith("/kuaidi100/"):
|
||||
self._json(404, {"result": False, "returnCode": "404", "message": "not found"})
|
||||
return
|
||||
length = min(int(self.headers.get("Content-Length") or 0), MAX_BODY)
|
||||
body = self.rfile.read(length).decode("utf-8", errors="replace")
|
||||
form = parse_qs(body, keep_blank_values=True)
|
||||
param_raw = (form.get("param") or [""])[0]
|
||||
sign = (form.get("sign") or [""])[0].upper()
|
||||
task_id = (form.get("taskId") or [""])[0]
|
||||
expect = hashlib.md5(f"{param_raw}{self.salt}".encode("utf-8")).hexdigest().upper()
|
||||
verified = bool(param_raw) and sign == expect
|
||||
|
||||
parsed: dict | None = None
|
||||
if param_raw:
|
||||
try:
|
||||
parsed = json.loads(param_raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
|
||||
_state["received"] += 1
|
||||
_state["verified_ok"] += int(verified)
|
||||
_append(
|
||||
{
|
||||
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"path": path,
|
||||
"verified": verified,
|
||||
"sign": sign,
|
||||
"taskId": task_id or None,
|
||||
"param": parsed if parsed is not None else param_raw[:2000],
|
||||
"client": self.client_address[0],
|
||||
}
|
||||
)
|
||||
# 验签失败也先按成功应答(已落盘标记 verified=false),避免测试环境反复重推干扰;
|
||||
# 生产可改为拒绝: {"result": false, ...}
|
||||
self._json(200, {"result": True, "returnCode": "200", "message": "成功"})
|
||||
|
||||
# ----- GET 查看 -----
|
||||
|
||||
def do_GET(self) -> None:
|
||||
url = urlparse(self.path)
|
||||
# 兼容两种路径:直连 /list 与经 frp/反代的 /kuaidi100/list(与 POST 路由前缀对齐)
|
||||
if url.path in ("/list", "/kuaidi100/list"):
|
||||
qs = parse_qs(url.query)
|
||||
n = min(int((qs.get("n") or ["20"])[0]), 200)
|
||||
self._json(200, {"count": n, "items": list(reversed(_recent(n)))})
|
||||
return
|
||||
recent = _recent(5)
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"service": "kuaidi100-callback",
|
||||
"listen": "127.0.0.1 (via frpc)",
|
||||
"started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_state["started_at"])),
|
||||
"uptime_s": int(time.time() - _state["started_at"]),
|
||||
"received": _state["received"],
|
||||
"verified_ok": _state["verified_ok"],
|
||||
"log_file": str(LOG_FILE),
|
||||
"recent": [
|
||||
{"ts": r.get("ts"), "path": r.get("path"), "verified": r.get("verified"),
|
||||
"status": (r.get("param") or {}).get("data", {}).get("status")
|
||||
if isinstance(r.get("param"), dict) else None}
|
||||
for r in recent
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _json(self, code: int, payload: dict) -> None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="快递100回调接收服务")
|
||||
parser.add_argument("--port", type=int, default=9802)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--salt", default=Handler.salt, help="验签 salt,须与下单 param.salt 一致")
|
||||
args = parser.parse_args()
|
||||
Handler.salt = args.salt
|
||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"[kuaidi100-callback] listening on http://{args.host}:{args.port} (salt={args.salt})")
|
||||
print(f"[kuaidi100-callback] log -> {LOG_FILE}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,407 @@
|
||||
"""快递100「上门取件API(线上支付)」端到端测试脚本。
|
||||
|
||||
文档: https://api.kuaidi100.com/document/603cb649a62a19500e19866b
|
||||
|
||||
流程:
|
||||
1. 从 SDS 取一单国内工厂订单(factoryIds=1161),提取收/寄件人地址(收件人=订单 address,寄件人=发货仓 issuing_bay)
|
||||
2. 逐家查价(method=price),选最便宜的快递公司
|
||||
3. 下单(method=bOrder)拿 taskId/orderId/kuaidinum
|
||||
4. 取消订单(method=cancel)收尾,避免遗留测试单
|
||||
|
||||
鉴权: 公共参数 method/key/sign/t,param 为 JSON 字符串;
|
||||
sign = MD5(param + t + key + secret) 转 32 位大写。
|
||||
测试环境: http://e-test.kuaidilab.com/api/order/borderapi.do(测试 key/secret 需向快递100技术支持申请)
|
||||
|
||||
用法:
|
||||
uv run python scripts/kuaidi100_pickup_test.py --key KEY --secret SECRET [--customer CUSTOMER]
|
||||
# 或把 JSON {"key":"...","secret":"...","customer":"..."} 存进 Prefect Secret Block kuaidi100-test 后:
|
||||
uv run python scripts/kuaidi100_pickup_test.py --from-secret
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
TEST_BASE_URL = "http://e-test.kuaidilab.com/api/order/borderapi.do"
|
||||
PROD_BASE_URL = "https://poll.kuaidi100.com/order/borderapi.do"
|
||||
|
||||
# 文档列出的本 API 支持的快递公司编码(小写)
|
||||
SUPPORTED_COURIERS = [
|
||||
"jd",
|
||||
"debangkuaidi",
|
||||
"shunfeng",
|
||||
"jtexpress",
|
||||
"yuantong",
|
||||
"shentong",
|
||||
"zhongtong",
|
||||
"zhongtongkuaiyun",
|
||||
"yunda",
|
||||
"kuayue",
|
||||
"shunfengkuaiyun",
|
||||
"ems",
|
||||
"yimidida",
|
||||
"kuaidi100zhixuan",
|
||||
]
|
||||
|
||||
COURIER_NAMES = {
|
||||
"jd": "京东",
|
||||
"debangkuaidi": "德邦快递",
|
||||
"shunfeng": "顺丰速运",
|
||||
"jtexpress": "极兔速递",
|
||||
"yuantong": "圆通速递",
|
||||
"shentong": "申通快递",
|
||||
"zhongtong": "中通快递",
|
||||
"zhongtongkuaiyun": "中通快运",
|
||||
"yunda": "韵达快递",
|
||||
"kuayue": "跨越速运",
|
||||
"shunfengkuaiyun": "顺丰快运",
|
||||
"ems": "EMS",
|
||||
"yimidida": "壹米滴答",
|
||||
"kuaidi100zhixuan": "快递100智选",
|
||||
}
|
||||
|
||||
|
||||
# ---------- 快递100 公共调用 ----------
|
||||
|
||||
|
||||
def make_sign(param: str, t: str, key: str, secret: str) -> str:
|
||||
"""文档签名算法: MD5(param + t + key + secret) 转 32 位大写。"""
|
||||
raw = f"{param}{t}{key}{secret}"
|
||||
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
|
||||
def call_api(
|
||||
method: str,
|
||||
param: dict[str, Any],
|
||||
auth: dict[str, str],
|
||||
base_url: str = TEST_BASE_URL,
|
||||
timeout: float = 30.0,
|
||||
) -> dict[str, Any]:
|
||||
"""POST application/x-www-form-urlencoded,公共参数 method/key/sign/t/param。"""
|
||||
param_str = json.dumps(param, ensure_ascii=False, separators=(",", ":"))
|
||||
t = str(int(time.time() * 1000))
|
||||
body = {
|
||||
"method": method,
|
||||
"key": auth["key"],
|
||||
"sign": make_sign(param_str, t, auth["key"], auth["secret"]),
|
||||
"t": t,
|
||||
"param": param_str,
|
||||
}
|
||||
resp = httpx.post(base_url, data=body, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def check(resp: dict[str, Any], action: str) -> dict[str, Any]:
|
||||
if resp.get("result") is not True or str(resp.get("returnCode")) != "200":
|
||||
raise RuntimeError(f"[{action}] 失败: {json.dumps(resp, ensure_ascii=False)[:500]}")
|
||||
print(f"[{action}] 成功: {resp.get('message')}")
|
||||
return resp
|
||||
|
||||
|
||||
# ---------- SDS 取单 ----------
|
||||
|
||||
|
||||
def calc_order_weight_grams(client: Any, order: Any) -> tuple[float | None, list[str]]:
|
||||
"""委托 SdsMngClient.get_order_weight_grams(children 变体克重,24h 缓存)。"""
|
||||
return client.get_order_weight_grams(order)
|
||||
|
||||
|
||||
def fetch_sds_domestic_order(client: Any | None = None) -> dict[str, str]:
|
||||
"""取一单国内工厂(factoryIds=1161)订单,返回寄件/收件人字段+结构化克重。"""
|
||||
if client is None:
|
||||
from api import SdsMngClient
|
||||
from prefect.blocks.system import Secret
|
||||
|
||||
value = Secret.load("sds-credentials").get()
|
||||
cred = json.loads(value) if isinstance(value, str) else value
|
||||
client = SdsMngClient(cred["username"], cred["password"])
|
||||
from api.sds.models import OrderPageRequest
|
||||
|
||||
resp = client.get_orders(OrderPageRequest(factory_ids=[1161], size=1, page=1))
|
||||
if not resp.records:
|
||||
raise RuntimeError("SDS 未找到 factoryIds=1161 的订单")
|
||||
order = resp.records[0]
|
||||
addr = order.address
|
||||
bay = order.issuing_bay
|
||||
if not addr or not bay:
|
||||
raise RuntimeError("订单缺少收件地址或发货仓信息")
|
||||
|
||||
send_print_addr = _join_addr(bay.province, bay.city, bay.detail)
|
||||
rec_print_addr = _join_addr(addr.province, addr.city, addr.detail)
|
||||
cargo = order.items[0].product_name or "日用品" if order.items else "日用品"
|
||||
|
||||
info = {
|
||||
"order_no": order.no or order.id,
|
||||
"send_name": bay.name or "印美达",
|
||||
"send_addr": send_print_addr,
|
||||
"rec_name": addr.consignee or "收件人",
|
||||
"rec_mobile": addr.mobile_phone or "",
|
||||
"rec_addr": rec_print_addr,
|
||||
"cargo": cargo,
|
||||
}
|
||||
grams, missed = calc_order_weight_grams(client, order)
|
||||
if grams:
|
||||
info["weight_kg"] = f"{grams / 1000:.2f}"
|
||||
print(f"[weight] 结构化克重合计 {round(grams)}g = {info['weight_kg']}kg")
|
||||
else:
|
||||
print(f"[weight] 未能取到结构化克重({'; '.join(missed) or '无商品行'}),下单重量回退为默认 1kg")
|
||||
print(
|
||||
f"[SDS] 选中订单 {info['order_no']}\n"
|
||||
f" 寄件: {info['send_name']} / {info['send_addr']}\n"
|
||||
f" 收件: {info['rec_name']} {info['rec_mobile']} / {info['rec_addr']}\n"
|
||||
f" 物品: {info['cargo']}"
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
# ---------- 业务步骤 ----------
|
||||
|
||||
|
||||
def compare_prices(auth: dict[str, str], order_info: dict[str, str], base_url: str, weight: str) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""逐家查价,按 defPrice(默认总价)升序,返回 (最便宜编码, 全部结果)。"""
|
||||
results: list[dict[str, Any]] = []
|
||||
for com in SUPPORTED_COURIERS:
|
||||
param = {
|
||||
"kuaidiCom": com,
|
||||
"sendManPrintAddr": order_info["send_addr"],
|
||||
"recManPrintAddr": order_info["rec_addr"],
|
||||
"weight": weight,
|
||||
}
|
||||
try:
|
||||
resp = call_api("price", param, auth, base_url)
|
||||
if resp.get("result") is True:
|
||||
data = resp.get("data", {})
|
||||
results.append({"com": com, **data})
|
||||
print(
|
||||
f"[price] {COURIER_NAMES.get(com, com):8s} 首重={data.get('defFirstPrice')} "
|
||||
f"续重={data.get('defOverPrice')} 总价={data.get('defPrice')}"
|
||||
)
|
||||
else:
|
||||
print(f"[price] {COURIER_NAMES.get(com, com):8s} 不支持: {resp.get('message')}")
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[price] {COURIER_NAMES.get(com, com):8s} 查询异常: {str(err)[:120]}")
|
||||
time.sleep(0.3)
|
||||
|
||||
priced = [r for r in results if str(r.get("defPrice", "") not in ("", None)) and _to_float(r.get("defPrice")) is not None]
|
||||
if not priced:
|
||||
raise RuntimeError("没有任何快递公司返回有效价格")
|
||||
priced.sort(key=lambda r: _to_float(r["defPrice"]))
|
||||
best = priced[0]
|
||||
print(
|
||||
f"[price] 共 {len(priced)} 家报价,最便宜: {COURIER_NAMES.get(best['com'], best['com'])} "
|
||||
f"({best['com']}) 总价 {best['defPrice']} 元"
|
||||
)
|
||||
return best["com"], results
|
||||
|
||||
|
||||
def _join_addr(province: str | None, city: str | None, detail: str | None) -> str:
|
||||
"""拼省+市+详细地址;detail 已含省/市名时跳过对应前缀,避免重复。"""
|
||||
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 _to_float(v: Any) -> float | None:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def place_order(
|
||||
auth: dict[str, str], order_info: dict[str, str], base_url: str, kuaidicom: str, weight: str,
|
||||
sender_mobile: str, callback_url: str, dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
param = {
|
||||
"kuaidicom": kuaidicom,
|
||||
"recManName": order_info["rec_name"],
|
||||
"recManMobile": order_info["rec_mobile"],
|
||||
"recManPrintAddr": order_info["rec_addr"],
|
||||
"sendManName": order_info["send_name"],
|
||||
"sendManMobile": sender_mobile,
|
||||
"sendManPrintAddr": order_info["send_addr"],
|
||||
"callBackUrl": callback_url,
|
||||
"cargo": order_info["cargo"],
|
||||
"weight": weight,
|
||||
"payment": "SHIPPER",
|
||||
"remark": f"kuaidi100-e2e-test {order_info['order_no']}",
|
||||
"salt": "inkreach-test",
|
||||
"op": 0,
|
||||
"resultv2": "0",
|
||||
}
|
||||
if kuaidicom in ("shunfeng", "shunfengkuaiyun"): # 文档要求顺丰必填预约取件时间窗(间隔≥1小时)
|
||||
now = time.localtime()
|
||||
start_h = (now.tm_hour + 1) % 24
|
||||
param.update(
|
||||
dayType="1",
|
||||
pickupStartTime=f"{start_h:02d}:00",
|
||||
pickupEndTime=f"{(start_h + 1) % 24:02d}:00",
|
||||
)
|
||||
if dry_run:
|
||||
print("[bOrder dry-run] 以下为将提交的完整 param(未发送请求):")
|
||||
print(json.dumps(param, ensure_ascii=False, indent=2))
|
||||
return {}
|
||||
resp = check(call_api("bOrder", param, auth, base_url), "bOrder 下单")
|
||||
data = resp.get("data") or {}
|
||||
print(
|
||||
f"[bOrder] taskId={data.get('taskId')} orderId={data.get('orderId')} "
|
||||
f"kuaidinum={data.get('kuaidinum')} pollToken={str(data.get('pollToken'))[:20]}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def query_detail(auth: dict[str, str], base_url: str, task_id: Any) -> dict[str, Any] | None:
|
||||
"""订单详情查询(method=detail,只传 taskId),校验订单已正确落库。"""
|
||||
try:
|
||||
resp = check(call_api("detail", {"taskId": task_id}, auth, base_url), "detail 订单详情")
|
||||
data = resp.get("data") or {}
|
||||
print(
|
||||
f"[detail] status={data.get('status')} kuaidinum={data.get('kuaidiNum')} "
|
||||
f"serviceType={data.get('serviceType')} payStatus={data.get('payStatus')}"
|
||||
)
|
||||
return data
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[detail] 查询失败(不中断测试): {str(err)[:160]}")
|
||||
return None
|
||||
|
||||
|
||||
def cancel_order(auth: dict[str, str], base_url: str, task_id: Any, order_id: Any) -> None:
|
||||
"""取消订单。taskId/orderId 原样回传;cancelMsg 用文档示例文案(实测含"测试"字样会被判订单信息错误)。"""
|
||||
param = {"taskId": task_id, "orderId": order_id, "cancelMsg": "暂时不寄件了"}
|
||||
check(call_api("cancel", param, auth, base_url), "cancel 取消")
|
||||
|
||||
|
||||
def track_query(auth: dict[str, str], com: str, num: str, phone: str = "") -> dict[str, Any] | None:
|
||||
"""实时轨迹查询(poll/query.do): sign=MD5(param+key+customer),需 customer 参数。"""
|
||||
if not auth.get("customer"):
|
||||
print("[track] 未提供 customer,跳过轨迹查询")
|
||||
return None
|
||||
url = base_query_url(auth)
|
||||
param = json.dumps({"com": com, "num": num, "phone": phone}, ensure_ascii=False, separators=(",", ":"))
|
||||
customer = auth["customer"]
|
||||
sign = hashlib.md5(f"{param}{auth['key']}{customer}".encode()).hexdigest().upper()
|
||||
resp = httpx.post(
|
||||
url,
|
||||
data={"customer": customer, "sign": sign, "param": param},
|
||||
timeout=30,
|
||||
).json()
|
||||
print(f"[track] {json.dumps(resp, ensure_ascii=False)[:300]}")
|
||||
return resp
|
||||
|
||||
|
||||
def base_query_url(auth: dict[str, str]) -> str:
|
||||
base = TEST_BASE_URL.replace("/order/borderapi.do", "")
|
||||
return f"{base}/poll/query.do"
|
||||
|
||||
|
||||
# ---------- 入口 ----------
|
||||
|
||||
|
||||
def load_auth(args: argparse.Namespace) -> dict[str, str]:
|
||||
if args.from_secret:
|
||||
from prefect.blocks.system import Secret
|
||||
|
||||
value = Secret.load(args.secret_block).get()
|
||||
auth = json.loads(value) if isinstance(value, str) else value
|
||||
else:
|
||||
auth = {"key": args.key or "", "secret": args.secret or "", "customer": args.customer or ""}
|
||||
missing = [k for k in ("key", "secret") if not auth.get(k)]
|
||||
if missing:
|
||||
raise SystemExit(f"缺少快递100鉴权参数: {missing}。用 --key/--secret 或 --from-secret 提供。")
|
||||
return auth
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="快递100上门取件API端到端测试")
|
||||
parser.add_argument("--key", help="企业版授权码 key")
|
||||
parser.add_argument("--secret", help="签名密钥 secret")
|
||||
parser.add_argument("--customer", default="", help="轨迹查询用 customer(可选)")
|
||||
parser.add_argument("--from-secret", action="store_true", help="从 Prefect Secret Block 读鉴权")
|
||||
parser.add_argument("--secret-block", default="kuaidi100-test", help="Secret Block 名(默认 kuaidi100-test)")
|
||||
parser.add_argument("--base-url", default=TEST_BASE_URL, help=f"API 地址(默认测试环境 {TEST_BASE_URL})")
|
||||
parser.add_argument("--courier", default="yuantong", help="快递编码(默认固定圆通 yuantong)")
|
||||
parser.add_argument("--auto-cheapest", action="store_true", help="按比价结果自动选最便宜(默认用 --courier 固定值)")
|
||||
parser.add_argument("--skip-price", action="store_true", help="跳过比价查询")
|
||||
parser.add_argument("--weight", default=None, help="重量 KG;默认用 SDS 变体克重自动汇总,取不到回退 1")
|
||||
parser.add_argument(
|
||||
"--sender-name", default="张文风",
|
||||
help="寄件人姓名(固定,覆盖 SDS 发货仓名)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sender-mobile", default="18320690872",
|
||||
help="寄件人手机号(固定)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sender-addr", default="广东省广州市番禺区南村镇新基村新盈科技园3栋2楼",
|
||||
help="寄件地址(固定,覆盖 SDS 发货仓地址)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="只构造并打印下单参数,不实际下单")
|
||||
parser.add_argument(
|
||||
"--callback-url",
|
||||
default="http://8.138.172.239:18090/kuaidi100/order",
|
||||
help="订单回调地址(默认走专属 frpc 实例 kuaidi100 穿透到本机回调服务)",
|
||||
)
|
||||
parser.add_argument("--keep-order", action="store_true", help="下单后不取消")
|
||||
args = parser.parse_args()
|
||||
|
||||
auth = load_auth(args)
|
||||
order_info = fetch_sds_domestic_order()
|
||||
|
||||
# 寄件人固定为广州仓联系人(命令行可覆盖),收件人保持 SDS 订单动态获取
|
||||
order_info["send_name"] = args.sender_name
|
||||
order_info["send_addr"] = args.sender_addr
|
||||
print(f"[sender] 寄件人固定: {order_info['send_name']} {args.sender_mobile} / {order_info['send_addr']}")
|
||||
|
||||
weight = args.weight or order_info.get("weight_kg") or "1"
|
||||
print(f"[weight] 下单申报重量: {weight}kg")
|
||||
|
||||
kuaidicom = args.courier
|
||||
if not args.skip_price:
|
||||
cheapest, _ = compare_prices(auth, order_info, args.base_url, weight) # 比价仅作参考
|
||||
if args.auto_cheapest:
|
||||
kuaidicom = cheapest
|
||||
print(f"[price] --auto-cheapest 生效,切到最便宜: {COURIER_NAMES.get(cheapest, cheapest)} ({cheapest})")
|
||||
print(f"[courier] 固定使用: {COURIER_NAMES.get(kuaidicom, kuaidicom)} ({kuaidicom})")
|
||||
|
||||
data = place_order(
|
||||
auth, order_info, args.base_url, kuaidicom, weight,
|
||||
args.sender_mobile, args.callback_url, dry_run=args.dry_run,
|
||||
)
|
||||
if args.dry_run:
|
||||
print("[done] dry-run 结束,未下单 ✓")
|
||||
return
|
||||
task_id = data.get("taskId") # 保持原始类型回传,cancel 接口对类型敏感
|
||||
order_id = data.get("orderId")
|
||||
kuaidinum = str(data.get("kuaidinum") or "")
|
||||
|
||||
if kuaidinum:
|
||||
track_query(auth, kuaidicom, kuaidinum, order_info["rec_mobile"])
|
||||
|
||||
if task_id:
|
||||
query_detail(auth, args.base_url, task_id)
|
||||
|
||||
if not args.keep_order and task_id and order_id:
|
||||
cancel_order(auth, args.base_url, task_id, order_id)
|
||||
else:
|
||||
print(f"[done] 保留测试单: taskId={task_id} orderId={order_id}")
|
||||
|
||||
print("[done] 端到端测试完成 ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""mappers 状态映射单测。
|
||||
|
||||
运行:uv run python -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from flows.shared import mappers
|
||||
from flows.shared.mappers import (
|
||||
map_riin_goods,
|
||||
map_riin_goods_status,
|
||||
map_riin_system_status,
|
||||
map_sds_goods_status,
|
||||
map_sds_system_status,
|
||||
)
|
||||
|
||||
TZ_CN = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class WarnUnknownTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mappers._warned_values.clear()
|
||||
|
||||
def _capture(self):
|
||||
return io.StringIO()
|
||||
|
||||
def test_未知值告警且同值只报一次(self):
|
||||
buf = self._capture()
|
||||
with redirect_stdout(buf):
|
||||
self.assertIsNone(map_riin_goods_status("SOMETHING_NEW"))
|
||||
self.assertIsNone(map_riin_goods_status("SOMETHING_NEW")) # 第二次不再报
|
||||
lines = [ln for ln in buf.getvalue().splitlines() if "SOMETHING_NEW" in ln]
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertIn("[mapper] unknown RIIN goodsStatus", lines[0])
|
||||
|
||||
|
||||
class RiinGoodsStatusTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mappers._warned_values.clear()
|
||||
|
||||
def test_全部已知枚举可映射(self):
|
||||
cases = {
|
||||
"NOT_SHIPPED": "待发货",
|
||||
"SHIPPED": "已发货",
|
||||
"CANCEL": "已取消",
|
||||
"CANCELLED": "已取消",
|
||||
"REFUNDING": "退款中",
|
||||
"COMPLETE": "已完成", # 本次补齐(曾导致 NULL)
|
||||
"PAUSE": "暂停", # 本次补齐(曾导致 NULL)
|
||||
"CLOSE": "已取消", # 回填重拉时告警发现的新枚举
|
||||
}
|
||||
for raw, want in cases.items():
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(map_riin_goods_status(raw), want)
|
||||
|
||||
def test_空值返回None(self):
|
||||
self.assertIsNone(map_riin_goods_status(None))
|
||||
self.assertIsNone(map_riin_goods_status(""))
|
||||
|
||||
def test_map_riin_goods_接线_PAUSE与COMPLETE不再落NULL(self):
|
||||
# 复现线上订单 GSU1S555400MFLL:4 行商品 goods_status=PAUSE/COMPLETE
|
||||
created = datetime(2026, 9, 14, 9, 39, 24, tzinfo=TZ_CN)
|
||||
base = dict(
|
||||
platform_oid="GSU1S555400MFLL", product_code="DG001-BK",
|
||||
color_code="BK", size_code="L", style_code=None, num=1,
|
||||
)
|
||||
g1 = map_riin_goods(SimpleNamespace(id="P1", goods_status="PAUSE", **base),
|
||||
"GSU1S555400MFLL", created)
|
||||
g2 = map_riin_goods(SimpleNamespace(id="P2", goods_status="COMPLETE", **base),
|
||||
"GSU1S555400MFLL", created)
|
||||
self.assertEqual(g1.status, "暂停")
|
||||
self.assertEqual(g2.status, "已完成")
|
||||
self.assertEqual(g1.productionNo, "P1")
|
||||
self.assertEqual(g1.quantity, 1)
|
||||
self.assertEqual(g1.itemName, "DG001")
|
||||
|
||||
|
||||
class RiinSystemStatusTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mappers._warned_values.clear()
|
||||
|
||||
def test_已知与空值(self):
|
||||
self.assertEqual(map_riin_system_status(12), "已发货")
|
||||
self.assertEqual(map_riin_system_status(-4), "待推送")
|
||||
self.assertIsNone(map_riin_system_status(None))
|
||||
|
||||
def test_未知值返回None并告警(self):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
self.assertIsNone(map_riin_system_status(999))
|
||||
self.assertIn("unknown RIIN systemStatus", buf.getvalue())
|
||||
|
||||
|
||||
class SdsStatusTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mappers._warned_values.clear()
|
||||
|
||||
def test_商品状态含已删除(self):
|
||||
self.assertEqual(map_sds_goods_status(99), "已删除")
|
||||
self.assertEqual(map_sds_goods_status(98), "已取消")
|
||||
self.assertEqual(map_sds_goods_status(4), "已完成")
|
||||
self.assertIsNone(map_sds_goods_status(None))
|
||||
|
||||
def test_未知商品状态返回None并告警(self):
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
self.assertIsNone(map_sds_goods_status(6))
|
||||
self.assertIn("unknown SDS goodsStatus", buf.getvalue())
|
||||
|
||||
def test_订单状态与未知告警(self):
|
||||
self.assertEqual(map_sds_system_status(3), "备货中")
|
||||
self.assertIsNone(map_sds_system_status(None))
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
self.assertIsNone(map_sds_system_status(42))
|
||||
self.assertIn("unknown SDS systemStatus", buf.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -952,6 +952,7 @@ dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pillow" },
|
||||
{ name = "prefect" },
|
||||
{ name = "prefect-sqlalchemy" },
|
||||
{ name = "psutil" },
|
||||
@@ -965,6 +966,7 @@ requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.14.3" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pillow", specifier = ">=13.0.0" },
|
||||
{ name = "prefect", specifier = ">=3.4" },
|
||||
{ name = "prefect-sqlalchemy" },
|
||||
{ name = "psutil", specifier = ">=7.2.2" },
|
||||
@@ -1483,8 +1485,8 @@ name = "pendulum"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil", marker = "python_full_version < '3.13'" },
|
||||
{ name = "tzdata", marker = "python_full_version < '3.13'" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" }
|
||||
wheels = [
|
||||
@@ -1521,6 +1523,82 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "13.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -2691,8 +2769,8 @@ name = "whenever"
|
||||
version = "0.10.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "python_full_version >= '3.13' and sys_platform == 'win32'" },
|
||||
{ name = "tzlocal", marker = "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux'" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
{ name = "tzlocal", marker = "sys_platform != 'darwin' and sys_platform != 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/40/94856fe2439c4b8162d1a23b4cf3a48bb00bed0ed7b8d10add40988fa555/whenever-0.10.3.tar.gz", hash = "sha256:af8958c870bd178742f0089c3552d5702241cab1f4b6bdfd3b03111db4bc2150", size = 435946, upload-time = "2026-07-17T07:35:25.734Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in New Issue
Block a user