init: Prefect ETL sync project (RIIN/SDS → PG) with 12 sync flows
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Inkreach 外部系统 REST API 客户端集合。
|
||||
|
||||
子模块:
|
||||
- api.riin : RIIN 分销平台(RiinDisClient)
|
||||
- api.sds : SDS POD 平台(SdsMngClient)
|
||||
- api._common : 共享 HTTP / 缓存 / 并发 / 批量 工具
|
||||
|
||||
这些客户端是纯 httpx + pydantic 实现,不依赖 Prefect。
|
||||
Prefect 编排在项目根的 flows/ 目录。
|
||||
"""
|
||||
|
||||
from api.riin import RiinDisClient
|
||||
from api.sds import SdsMngClient
|
||||
|
||||
__all__ = ["RiinDisClient", "SdsMngClient"]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""共享基础设施工具:HTTP 基类、TTL 缓存、并发、批量切分。"""
|
||||
|
||||
from api._common.batch import chunk, parse_id_text, split_lines
|
||||
from api._common.cache import TTLCache
|
||||
from api._common.concurrency import run_concurrent
|
||||
from api._common.http import TokenHttpClient
|
||||
|
||||
__all__ = [
|
||||
"TokenHttpClient",
|
||||
"TTLCache",
|
||||
"run_concurrent",
|
||||
"parse_id_text",
|
||||
"split_lines",
|
||||
"chunk",
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""批量切分辅助:对应 TS SDK 里订单 OID/快递单号、SDS 订单关键词的批量拆分。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_MULTI_SEP = re.compile(r"[,,\s\n\r]+")
|
||||
|
||||
|
||||
def parse_id_text(text: str) -> list[str]:
|
||||
"""把逗号/空白/换行分隔的字符串解析为去重的 ID 列表(RIIN 用)。"""
|
||||
items = _MULTI_SEP.split(text or "")
|
||||
seen: dict[str, None] = {}
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if item and item not in seen:
|
||||
seen[item] = None
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def split_lines(text: str) -> list[str]:
|
||||
"""把换行分隔的字符串解析为去重的列表(SDS 订单号 keywords 用)。"""
|
||||
items = re.split(r"[\n\r]+", text or "")
|
||||
seen: dict[str, None] = {}
|
||||
for item in items:
|
||||
item = item.strip()
|
||||
if item and item not in seen:
|
||||
seen[item] = None
|
||||
return list(seen.keys())
|
||||
|
||||
|
||||
def chunk(items: list[str], size: int) -> list[list[str]]:
|
||||
"""把列表按 size 切成多批。"""
|
||||
if not items:
|
||||
return []
|
||||
return [items[i : i + size] for i in range(0, len(items), size)]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""简单的 TTL 内存缓存(纯 Python,无第三方依赖)。
|
||||
|
||||
对应 TS SDK 的 MemoryCache,用于主数据等就近缓存,生命周期跟随 client 实例。
|
||||
注意:这是客户端层缓存,与 Prefect 的 task 缓存(cache_key_fn)是两回事。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_TTL_SECONDS = 300
|
||||
|
||||
|
||||
class TTLCache(Generic[T]):
|
||||
def __init__(self, ttl_seconds: int | float = _DEFAULT_TTL_SECONDS) -> None:
|
||||
self._ttl = ttl_seconds
|
||||
self._store: dict[str, tuple[T, float]] = {}
|
||||
|
||||
def get(self, key: str) -> T | None:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
value, expires_at = item
|
||||
if time.monotonic() > expires_at:
|
||||
self._store.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: T) -> None:
|
||||
self._store[key] = (value, time.monotonic() + self._ttl)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""并发执行辅助:线程池 + 信号量,对应 TS SDK 的 ConcurrentLimiter(默认 10)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_DEFAULT_CONCURRENCY = 10
|
||||
|
||||
|
||||
def run_concurrent(
|
||||
funcs: list[Callable[[], T]],
|
||||
limit: int = _DEFAULT_CONCURRENCY,
|
||||
) -> list[T]:
|
||||
"""并发执行一批无参 callable(每个返回一个结果),保持输入顺序返回结果。"""
|
||||
if not funcs:
|
||||
return []
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(limit, len(funcs)))) as pool:
|
||||
return list(pool.map(lambda fn: fn(), funcs))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""基础 HTTP 客户端:token 注入 + 401 自动重登。
|
||||
|
||||
两个外部系统(RIIN / SDS)共享这套行为,仅以下差异由子类配置:
|
||||
- base_url
|
||||
- token 写入的请求头名(RIIN: Authorization,SDS: access-token)
|
||||
- 固定请求头(RIIN 带 X-Time-Zone / X-Requested-With,SDS 无)
|
||||
- 登录实现(子类实现 _login)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class TokenHttpClient(ABC):
|
||||
"""带 token 鉴权的 HTTP 客户端基类(组合式,非强制耦合)。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
token_header: str,
|
||||
fixed_headers: dict[str, str] | None = None,
|
||||
login_markers: tuple[str, ...] = (),
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self._token: str | None = None
|
||||
self._token_header = token_header
|
||||
self._login_markers = login_markers
|
||||
self._http = httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=fixed_headers or {},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _login(self) -> str:
|
||||
"""执行登录并返回新 token。子类实现具体认证逻辑。"""
|
||||
|
||||
# ----- token 生命周期 -----
|
||||
|
||||
def _ensure_token(self) -> str:
|
||||
if not self._token:
|
||||
self._token = self._login()
|
||||
return self._token
|
||||
|
||||
def _clear_token(self) -> None:
|
||||
self._token = None
|
||||
|
||||
def _is_login_call(self, url: str) -> bool:
|
||||
return any(marker in url for marker in self._login_markers)
|
||||
|
||||
# ----- 请求 -----
|
||||
|
||||
def _send(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
_retry: bool = True,
|
||||
) -> Any:
|
||||
headers: dict[str, str] = {}
|
||||
if not self._is_login_call(url):
|
||||
headers[self._token_header] = self._ensure_token()
|
||||
|
||||
response = self._http.request(method, url, params=params, json=body, headers=headers)
|
||||
|
||||
if response.status_code == 401 and _retry and not self._is_login_call(url):
|
||||
self._clear_token()
|
||||
return self._send(method, url, params=params, body=body, _retry=False)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
return self._send("GET", url, params=params)
|
||||
|
||||
def post(
|
||||
self,
|
||||
url: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
return self._send("POST", url, body=body, params=params)
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "TokenHttpClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""RIIN 分销平台 REST API 客户端。"""
|
||||
|
||||
from api.riin.client import RiinDisClient
|
||||
from api.riin.models import OrderPageRequest
|
||||
|
||||
__all__ = ["RiinDisClient", "OrderPageRequest"]
|
||||
@@ -0,0 +1,329 @@
|
||||
"""RIIN 分销平台 REST API 客户端(移植自 @inkreach/riin-dis)。
|
||||
|
||||
Base URL: https://tshirt.riin.com
|
||||
认证: username + MD5(password) -> Bearer token(Authorization 头),401 自动重登。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
from api._common import TTLCache, chunk, parse_id_text, run_concurrent
|
||||
from api._common.http import TokenHttpClient
|
||||
from api.riin.models import (
|
||||
Article,
|
||||
Color,
|
||||
CustomerListResponse,
|
||||
CustomerShopListResponse,
|
||||
ExpressInfo,
|
||||
FactoryInfoListResponse,
|
||||
LoginResult,
|
||||
OrderGoodsResponse,
|
||||
OrderListCountResponse,
|
||||
OrderLogResponse,
|
||||
OrderPageRequest,
|
||||
OrderPageResponse,
|
||||
Size,
|
||||
ThirdInterfaceCustomer,
|
||||
)
|
||||
|
||||
BASE_URL = "https://tshirt.riin.com"
|
||||
BATCH_SIZE = 500
|
||||
|
||||
|
||||
class _ListResponse(BaseModel):
|
||||
"""通用列表响应壳(辅助主数据用)。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True, alias_generator=to_camel)
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[Any] = []
|
||||
|
||||
|
||||
class _PageResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True, alias_generator=to_camel)
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: dict[str, Any] = {}
|
||||
|
||||
|
||||
class RiinDisClient(TokenHttpClient):
|
||||
"""RIIN 分销系统客户端:认证 / 订单 / 店铺 / 辅助主数据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
cache_ttl_seconds: int | float = 300,
|
||||
concurrency: int = 10,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
BASE_URL,
|
||||
token_header="Authorization",
|
||||
fixed_headers={"X-Time-Zone": "UTC+8", "X-Requested-With": "XMLHttpRequest"},
|
||||
login_markers=("auth/api/auth/login",),
|
||||
)
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
|
||||
self._concurrency = concurrency
|
||||
|
||||
# ----- 认证 -----
|
||||
|
||||
def _login(self) -> str:
|
||||
md5_password = hashlib.md5(self._password.encode()).hexdigest()
|
||||
data = self.post("auth/api/auth/login", body={"username": self._username, "password": md5_password})
|
||||
result = LoginResult.model_validate(data["data"])
|
||||
self._token = result.token
|
||||
return self._token
|
||||
|
||||
# ----- 订单 -----
|
||||
|
||||
def _build_order_page_body(self, params: OrderPageRequest) -> dict[str, Any]:
|
||||
has_oid_or_courier = bool(params.platform_oid_list or params.courier_number_list)
|
||||
|
||||
body: dict[str, Any] = {
|
||||
"pageIndex": params.page_index if params.page_index is not None else 1,
|
||||
"pageSize": params.page_size if params.page_size is not None else 20,
|
||||
"shopCodeList": params.shop_code_list or [],
|
||||
"orderStatus": params.order_status if params.order_status is not None else None,
|
||||
"dayRangeGroup": params.day_range_group if params.day_range_group is not None else None,
|
||||
"hasRefund": params.has_refund if params.has_refund is not None else False,
|
||||
}
|
||||
|
||||
if not has_oid_or_courier:
|
||||
body["timeType"] = params.time_type if params.time_type is not None else 3
|
||||
if params.start_time:
|
||||
body["startTime"] = params.start_time
|
||||
if params.end_time:
|
||||
body["endTime"] = params.end_time
|
||||
elif params.time_type is not None:
|
||||
body["timeType"] = params.time_type
|
||||
|
||||
if params.platform_oid_list:
|
||||
body["platformOidList"] = parse_id_text(params.platform_oid_list)
|
||||
if params.courier_number_list:
|
||||
body["courierNumberList"] = parse_id_text(params.courier_number_list)
|
||||
if params.sub_id_list:
|
||||
body["subIdList"] = params.sub_id_list
|
||||
if params.goods_title:
|
||||
body["goodsTitle"] = params.goods_title
|
||||
if params.mapping_sku_code:
|
||||
body["mappingSkuCode"] = params.mapping_sku_code
|
||||
if params.platform_type is not None:
|
||||
body["platformType"] = params.platform_type
|
||||
if params.order_source is not None:
|
||||
body["orderSource"] = params.order_source
|
||||
if params.factory_ids:
|
||||
body["factoryIds"] = params.factory_ids
|
||||
if params.express_source is not None:
|
||||
body["expressSource"] = params.express_source
|
||||
if params.delivery_courier:
|
||||
body["deliveryCourier"] = params.delivery_courier
|
||||
if params.receiver_country:
|
||||
body["receiverCountry"] = params.receiver_country
|
||||
if params.region:
|
||||
body["region"] = params.region
|
||||
if params.receiver_province:
|
||||
body["receiverProvince"] = params.receiver_province
|
||||
if params.craft_type is not None:
|
||||
body["craftType"] = params.craft_type
|
||||
if params.style_code_list:
|
||||
body["styleCodeList"] = params.style_code_list
|
||||
if params.color_code_list:
|
||||
body["colorCodeList"] = params.color_code_list
|
||||
if params.size_code_list:
|
||||
body["sizeCodeList"] = params.size_code_list
|
||||
if params.seller_remark:
|
||||
body["sellerRemark"] = params.seller_remark
|
||||
if params.urgent_order is not None:
|
||||
body["urgentOrder"] = params.urgent_order
|
||||
if params.third_interface_customer_id:
|
||||
body["thirdInterfaceCustomerId"] = params.third_interface_customer_id
|
||||
if params.platform_status_list:
|
||||
body["platformStatusList"] = params.platform_status_list
|
||||
if params.is_third_flag is not None:
|
||||
body["isThirdFlag"] = params.is_third_flag
|
||||
if params.push_failed is not None:
|
||||
body["pushFailed"] = params.push_failed
|
||||
if params.assigned_fail_reason:
|
||||
body["assignedFailReason"] = params.assigned_fail_reason
|
||||
if params.push_fail_reason:
|
||||
body["pushFailReason"] = params.push_fail_reason
|
||||
if params.factory_remark_param:
|
||||
body["factoryRemarkParam"] = params.factory_remark_param
|
||||
if params.distributor_remark_param:
|
||||
body["distributorRemarkParam"] = params.distributor_remark_param
|
||||
return body
|
||||
|
||||
def _get_single_page(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
body = self._build_order_page_body(
|
||||
params.model_copy(update={"page_index": params.page_index or 1, "page_size": params.page_size or 20})
|
||||
)
|
||||
data = self.post("distribution/order/page", body=body)
|
||||
return OrderPageResponse.model_validate(data)
|
||||
|
||||
def _get_all_pages(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
page_size = params.page_size or 200
|
||||
first = self._get_single_page(params.model_copy(update={"page_index": 1, "page_size": page_size}))
|
||||
total_count = first.data.total
|
||||
total_pages = -(-total_count // page_size) # ceil
|
||||
if total_pages <= 1:
|
||||
return first
|
||||
|
||||
remaining_indices = list(range(2, total_pages + 1))
|
||||
|
||||
def fetch(i: int) -> OrderPageResponse:
|
||||
return self._get_single_page(params.model_copy(update={"page_index": i, "page_size": page_size}))
|
||||
|
||||
remaining = run_concurrent([lambda i=i: fetch(i) for i in remaining_indices], limit=self._concurrency)
|
||||
records = list(first.data.records)
|
||||
for r in remaining:
|
||||
records.extend(r.data.records)
|
||||
return OrderPageResponse(
|
||||
successful=True,
|
||||
data={"records": records, "total": total_count},
|
||||
)
|
||||
|
||||
def get_orders(self, params: OrderPageRequest | None = None) -> OrderPageResponse:
|
||||
"""获取订单。支持三种模式:limit(取首页 N 条)/ all(全量分页)/ 单页。"""
|
||||
params = params or OrderPageRequest()
|
||||
|
||||
needs_batching = (
|
||||
(params.platform_oid_list and len(parse_id_text(params.platform_oid_list)) > BATCH_SIZE)
|
||||
or (params.courier_number_list and len(parse_id_text(params.courier_number_list)) > BATCH_SIZE)
|
||||
)
|
||||
if needs_batching:
|
||||
return self._get_orders_batched(params)
|
||||
|
||||
if params.limit:
|
||||
return self._get_single_page(params.model_copy(update={"page_index": 1, "page_size": params.limit}))
|
||||
if params.all:
|
||||
return self._get_all_pages(params)
|
||||
return self._get_single_page(params)
|
||||
|
||||
def _get_orders_batched(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
oid_batches = chunk(parse_id_text(params.platform_oid_list), BATCH_SIZE) if params.platform_oid_list else [None]
|
||||
courier_batches = (
|
||||
chunk(parse_id_text(params.courier_number_list), BATCH_SIZE) if params.courier_number_list else [None]
|
||||
)
|
||||
|
||||
def fetch(oid_batch: list[str] | None, courier_batch: list[str] | None) -> OrderPageResponse:
|
||||
update: dict[str, Any] = {}
|
||||
if oid_batch is not None:
|
||||
update["platform_oid_list"] = ",".join(oid_batch)
|
||||
if courier_batch is not None:
|
||||
update["courier_number_list"] = ",".join(courier_batch)
|
||||
batch_params = params.model_copy(update=update)
|
||||
if params.limit:
|
||||
return self._get_single_page(
|
||||
batch_params.model_copy(update={"page_index": 1, "page_size": params.limit})
|
||||
)
|
||||
return self._get_all_pages(batch_params)
|
||||
|
||||
calls = [
|
||||
(lambda o=o, c=c: fetch(o, c)) for o in oid_batches for c in courier_batches
|
||||
]
|
||||
results = run_concurrent(calls, limit=self._concurrency)
|
||||
|
||||
records: list[Any] = []
|
||||
total = 0
|
||||
for r in results:
|
||||
records.extend(r.data.records)
|
||||
total += r.data.total
|
||||
if params.limit:
|
||||
records = records[: params.limit]
|
||||
return OrderPageResponse(successful=True, data={"records": records, "total": total})
|
||||
|
||||
def get_order_goods(self, order_id: str) -> OrderGoodsResponse:
|
||||
data = self.post("distribution/order/goods", body={"orderId": order_id})
|
||||
return OrderGoodsResponse.model_validate(data)
|
||||
|
||||
def get_order_count(
|
||||
self, params: OrderPageRequest | None = None
|
||||
) -> OrderListCountResponse:
|
||||
params = params or OrderPageRequest()
|
||||
cache_key = f"orderCount:{params.model_dump_json(exclude_none=True)}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
body = self._build_order_page_body(params)
|
||||
body.pop("pageIndex", None)
|
||||
body.pop("pageSize", None)
|
||||
data = self.post("distribution/order/list/count", body=body)
|
||||
result = OrderListCountResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_order_logs(self, order_id: str, platform_oid: str) -> OrderLogResponse:
|
||||
data = self.post("distribution/order/logs", body={"orderId": order_id, "platformOid": platform_oid})
|
||||
return OrderLogResponse.model_validate(data)
|
||||
|
||||
# ----- 店铺 / 工厂 / 客户 -----
|
||||
|
||||
def get_customer_shop_list(self) -> CustomerShopListResponse:
|
||||
cache_key = "shops:customerShopList"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = self.post("distribution/order/customer/shop/list", body={})
|
||||
result = CustomerShopListResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_factory_list(self) -> FactoryInfoListResponse:
|
||||
cache_key = "shops:factoryList"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = self.get("distribution/tenant/getFactoryInfoList")
|
||||
result = FactoryInfoListResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_customer_list(self) -> CustomerListResponse:
|
||||
cache_key = "shops:customerList"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = self.post("distribution/customer/listTenantNoDisable", body={})
|
||||
result = CustomerListResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
# ----- 辅助主数据 -----
|
||||
|
||||
def get_articles(self, params: dict[str, Any] | None = None) -> list[Article]:
|
||||
data = self.post("distribution/articleManagement/list", body=params or {})
|
||||
resp = _ListResponse.model_validate(data)
|
||||
return [Article.model_validate(item) for item in resp.data]
|
||||
|
||||
def get_sizes(self, params: dict[str, Any] | None = None) -> list[Size]:
|
||||
data = self.post("distribution/sizeManagement/size/list", body=params or {})
|
||||
resp = _ListResponse.model_validate(data)
|
||||
return [Size.model_validate(item) for item in resp.data]
|
||||
|
||||
def get_colors(self, params: dict[str, Any] | None = None) -> list[Color]:
|
||||
data = self.post("distribution/colorManagement/list", body=params or {})
|
||||
resp = _ListResponse.model_validate(data)
|
||||
return [Color.model_validate(item) for item in resp.data]
|
||||
|
||||
def get_third_party_customers(
|
||||
self, params: dict[str, Any] | None = None
|
||||
) -> list[ThirdInterfaceCustomer]:
|
||||
data = self.post("distribution/thirdInterfaceCustomer/page", body=params or {})
|
||||
resp = _PageResponse.model_validate(data)
|
||||
records = resp.data.get("records", []) if isinstance(resp.data, dict) else []
|
||||
return [ThirdInterfaceCustomer.model_validate(item) for item in records]
|
||||
|
||||
def get_express_info(self) -> list[ExpressInfo]:
|
||||
data = self.post("distribution/order/tenant/delivery/express", body={})
|
||||
resp = _ListResponse.model_validate(data)
|
||||
return [ExpressInfo.model_validate(item) for item in resp.data]
|
||||
@@ -0,0 +1,409 @@
|
||||
"""RIIN 分销平台 —— 数据模型(pydantic v2)。
|
||||
|
||||
忠实还原 @inkreach/riin-dis 的 TypeScript 类型,extra='allow' 兼容未知字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class PlatformType(IntEnum):
|
||||
TIKTOK = 11
|
||||
TEMU = 13
|
||||
SHEIN = 17
|
||||
WALMART = 19
|
||||
|
||||
|
||||
class ShopType(IntEnum):
|
||||
TIKTOK = 11
|
||||
TEMU = 13
|
||||
SHEIN = 17
|
||||
WALMART = 19
|
||||
|
||||
|
||||
class OrderStatusValue(IntEnum):
|
||||
NOT_PUSHED = -4
|
||||
PENDING_REVIEW = -5
|
||||
ASSIGN_FAILED = -6
|
||||
UNDER_REVIEW = -3
|
||||
PENDING_PUSH = 1
|
||||
UNDER_REVIEW_DISTRIBUTOR = 3
|
||||
PUSHED = 4
|
||||
IN_PRODUCTION = 5
|
||||
SHIPPED = 12
|
||||
COMPLETED = 13
|
||||
CANCELLED = 14
|
||||
CLOSED = 15
|
||||
DAY_RANGE_3 = 101
|
||||
DAY_RANGE_3_7 = 102
|
||||
DAY_RANGE_7_15 = 103
|
||||
DAY_RANGE_15_PLUS = 104
|
||||
|
||||
|
||||
class ExpressSource(IntEnum):
|
||||
FACTORY = 1
|
||||
PLATFORM_LABEL = 2
|
||||
SELF_LABEL = 3
|
||||
|
||||
|
||||
class TimeType(IntEnum):
|
||||
ORDER_CREATE = 1
|
||||
PUSH_FACTORY = 5
|
||||
MERCHANT_PUSH = 3
|
||||
SHIP = 4
|
||||
|
||||
|
||||
class _RiinModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
populate_by_name=True,
|
||||
alias_generator=to_camel,
|
||||
)
|
||||
|
||||
|
||||
# ----- 认证 -----
|
||||
|
||||
|
||||
class LoginResult(_RiinModel):
|
||||
id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
sub_id: str | None = None
|
||||
tenant_name: str | None = None
|
||||
username: str | None = None
|
||||
nickname: str | None = None
|
||||
user_type: str | None = None
|
||||
token: str
|
||||
token_flag: str | None = None
|
||||
invite_code: str | None = None
|
||||
main_account: bool | None = None
|
||||
factory: bool | None = None
|
||||
distribution: bool | None = None
|
||||
sub_distribution: bool | None = None
|
||||
|
||||
|
||||
# ----- 订单 -----
|
||||
|
||||
|
||||
class ExpressItem(_RiinModel):
|
||||
express_name: str | None = None
|
||||
ship_logistics_type: str | None = None
|
||||
|
||||
|
||||
class Order(_RiinModel):
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
sub_id: str | None = None
|
||||
sub_name: str | None = None
|
||||
source_platform_oid: str | None = None
|
||||
platform_oid: str | None = None
|
||||
platform_order_name: str | None = None
|
||||
order_status: int | None = None
|
||||
order_type: int | None = None
|
||||
goods_total_qty: int | None = None
|
||||
platform_order_status: str | None = None
|
||||
platform_refund_status: str | None = None
|
||||
platform_shop_code: str | None = None
|
||||
platform_shop_id: str | None = None
|
||||
platform_shop_name: str | None = None
|
||||
platform_type: int | None = None
|
||||
sub_platform_type: int | None = None
|
||||
seller_flag: str | None = None
|
||||
factory_remark: str | None = None
|
||||
distributor_remark: str | None = None
|
||||
seller_remark: str | None = None
|
||||
buyer_remark: str | None = None
|
||||
delivery_courier: str | None = None
|
||||
express_code: str | None = None
|
||||
express_fee: float | None = None
|
||||
express_source: int | None = None
|
||||
courier_number: str | None = None
|
||||
consignee_name: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
address: str | None = None
|
||||
receiver_country: str | None = None
|
||||
receiver_province: str | None = None
|
||||
receiver_city: str | None = None
|
||||
receiver_district: str | None = None
|
||||
receiver_town: str | None = None
|
||||
post_code: str | None = None
|
||||
currency: str | None = None
|
||||
currency_type: str | None = None
|
||||
total_fee: float | None = None
|
||||
payment: float | None = None
|
||||
received_payment: float | None = None
|
||||
total_price: float | None = None
|
||||
total_sell_price: float | None = None
|
||||
post_fee: float | None = None
|
||||
order_create_time: str | None = None
|
||||
pre_shipping_time: str | None = None
|
||||
shipping_time: str | None = None
|
||||
order_pay_time: str | None = None
|
||||
order_time: str | None = None
|
||||
finish_time: str | None = None
|
||||
cancel_time: str | None = None
|
||||
audit_time: str | None = None
|
||||
push_time: str | None = None
|
||||
distributor_push_time: str | None = None
|
||||
distributor_push_target_name: str | None = None
|
||||
except_last_shipping_time: str | None = None
|
||||
push_msg: str | None = None
|
||||
pushed_failed: str | None = None
|
||||
assigned_fail_reason: str | None = None
|
||||
urgent_order: int | None = None
|
||||
is_third_flag: bool | None = None
|
||||
is_custom_product: bool | None = None
|
||||
express_list: list[ExpressItem] = []
|
||||
|
||||
|
||||
class OrderPageData(_RiinModel):
|
||||
records: list[Order] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class OrderPageResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: OrderPageData = OrderPageData()
|
||||
|
||||
|
||||
class PrintingImage(_RiinModel):
|
||||
id: str | None = None
|
||||
type: int | None = None
|
||||
url: str | None = None
|
||||
name: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class OrderGoodsImageObjs(_RiinModel):
|
||||
sku_image: str | None = None
|
||||
printing_image: str | None = None
|
||||
printing_images: list[PrintingImage] = []
|
||||
sku_images: list[str] | None = None
|
||||
print_urls: list[str] = []
|
||||
sku_urls: list[str] = []
|
||||
|
||||
|
||||
class OrderGoods(_RiinModel):
|
||||
id: str
|
||||
order_id: str | None = None
|
||||
platform_oid: str | None = None
|
||||
platform_spu_id: str | None = None
|
||||
platform_sku_id: str | None = None
|
||||
title: str | None = None
|
||||
specification: str | None = None
|
||||
goods_status: str | None = None
|
||||
refund_status: str | None = None
|
||||
price: float | None = None
|
||||
sell_price: float | None = None
|
||||
num: int | None = None
|
||||
total_price: float | None = None
|
||||
total_sell_price: float | None = None
|
||||
sku_image: str | None = None
|
||||
mapping_sku_code: str | None = None
|
||||
factory_mapping_sku_code: str | None = None
|
||||
style_code: str | None = None
|
||||
image_code: str | None = None
|
||||
size_code: str | None = None
|
||||
color_code: str | None = None
|
||||
style_name: str | None = None
|
||||
image_name: str | None = None
|
||||
size_name: str | None = None
|
||||
color_name: str | None = None
|
||||
product_code: str | None = None
|
||||
craft_type: int | None = None
|
||||
print_position: str | None = None
|
||||
image_objs: Any | None = None
|
||||
|
||||
|
||||
class OrderGoodsResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[OrderGoods] = []
|
||||
|
||||
|
||||
class OrderListCount(_RiinModel):
|
||||
assigned_suceessed: int = 0
|
||||
factory_auditing: int = 0
|
||||
in_producing: int = 0
|
||||
shipped: int = 0
|
||||
closed: int = 0
|
||||
refunding: int = 0
|
||||
refunded: int = 0
|
||||
rejecting: int = 0
|
||||
rejected_to_shop: int = 0
|
||||
day_range_group1: int = 0
|
||||
day_range_group2: int = 0
|
||||
day_range_group3: int = 0
|
||||
day_range_group4: int = 0
|
||||
|
||||
|
||||
class OrderListCountResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: OrderListCount = OrderListCount()
|
||||
|
||||
|
||||
class OrderLog(_RiinModel):
|
||||
id: str | None = None
|
||||
order_id: str | None = None
|
||||
platform_oid: str | None = None
|
||||
operate_type: str | None = None
|
||||
operate_msg: str | None = None
|
||||
operator_name: str | None = None
|
||||
operate_time: str | None = None
|
||||
|
||||
|
||||
class OrderLogResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[OrderLog] = []
|
||||
|
||||
|
||||
# ----- 店铺 / 工厂 / 客户 -----
|
||||
|
||||
|
||||
class CustomerShop(_RiinModel):
|
||||
tenant_id: str | None = None
|
||||
tenant_name: str | None = None
|
||||
shop_id: str | None = None
|
||||
shop_code: str | None = None
|
||||
shop_name: str | None = None
|
||||
shop_type: int | None = None
|
||||
sub_id: str | None = None
|
||||
|
||||
|
||||
class CustomerShopListResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[CustomerShop] = []
|
||||
|
||||
|
||||
class FactoryInfo(_RiinModel):
|
||||
factory_id: str | None = None
|
||||
phone: str | None = None
|
||||
contact: str | None = None
|
||||
address: str | None = None
|
||||
name: str | None = None
|
||||
logo_url: str | None = None
|
||||
|
||||
|
||||
class FactoryInfoListResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[FactoryInfo] = []
|
||||
|
||||
|
||||
class Customer(_RiinModel):
|
||||
id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
customer_tenant_id: str | None = None
|
||||
customer_name: str | None = None
|
||||
username: str | None = None
|
||||
contacts: str | None = None
|
||||
mobile: str | None = None
|
||||
settlement_currency: int | None = None
|
||||
disable: int | None = None
|
||||
|
||||
|
||||
class CustomerListResponse(_RiinModel):
|
||||
successful: bool | None = None
|
||||
message: str | None = None
|
||||
error_code: str | None = None
|
||||
data: list[Customer] = []
|
||||
|
||||
|
||||
# ----- 辅助主数据 -----
|
||||
|
||||
|
||||
class Article(_RiinModel):
|
||||
id: str | None = None
|
||||
code: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class Size(_RiinModel):
|
||||
id: str | None = None
|
||||
code: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class Color(_RiinModel):
|
||||
id: str | None = None
|
||||
code: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ThirdInterfaceCustomer(_RiinModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ExpressInfo(_RiinModel):
|
||||
id: str | None = None
|
||||
name: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class PageData(_RiinModel):
|
||||
records: list[Any] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ----- 查询参数(请求 schema)-----
|
||||
|
||||
|
||||
class OrderPageRequest(BaseModel):
|
||||
"""RIIN 订单分页查询参数。timeType/startTime/endTime 等可选。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
time_type: int | None = None
|
||||
start_time: str | None = None
|
||||
end_time: str | None = None
|
||||
platform_oid_list: str | None = None
|
||||
shop_code_list: list[str] | None = None
|
||||
courier_number_list: str | None = None
|
||||
sub_id_list: list[str] | None = None
|
||||
goods_title: str | None = None
|
||||
mapping_sku_code: str | None = None
|
||||
platform_type: int | None = None
|
||||
order_source: int | None = None
|
||||
factory_ids: list[str] | None = None
|
||||
express_source: int | None = None
|
||||
delivery_courier: str | None = None
|
||||
receiver_country: str | None = None
|
||||
region: str | None = None
|
||||
receiver_province: str | None = None
|
||||
craft_type: int | None = None
|
||||
style_code_list: list[str] | None = None
|
||||
color_code_list: list[str] | None = None
|
||||
size_code_list: list[str] | None = None
|
||||
seller_remark: str | None = None
|
||||
urgent_order: int | None = None
|
||||
third_interface_customer_id: str | None = None
|
||||
platform_status_list: list[str] | None = None
|
||||
is_third_flag: bool | None = None
|
||||
page_index: int | None = None
|
||||
page_size: int | None = None
|
||||
order_status: int | None = None
|
||||
day_range_group: int | None = None
|
||||
has_refund: bool | None = None
|
||||
push_failed: bool | None = None
|
||||
assigned_fail_reason: str | None = None
|
||||
push_fail_reason: str | None = None
|
||||
factory_remark_param: str | None = None
|
||||
distributor_remark_param: str | None = None
|
||||
limit: int | None = None
|
||||
all: bool | None = None
|
||||
@@ -0,0 +1,6 @@
|
||||
"""SDS POD 平台 REST API 客户端。"""
|
||||
|
||||
from api.sds.client import SdsMngClient
|
||||
from api.sds.models import OrderPageRequest
|
||||
|
||||
__all__ = ["SdsMngClient", "OrderPageRequest"]
|
||||
@@ -0,0 +1,354 @@
|
||||
"""SDS POD 平台 REST API 客户端(移植自 @inkreach/sds-mng)。
|
||||
|
||||
Base URL: https://pod-api.sdspod.com
|
||||
认证: username + password + 公司名(印美达) -> access-token 头,401 自动重登。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from api._common import TTLCache, chunk, run_concurrent, split_lines
|
||||
from api._common.http import TokenHttpClient
|
||||
from api.sds.models import (
|
||||
FiltratePanelResponse,
|
||||
IssuingBayAreaResponse,
|
||||
LogisticsItem,
|
||||
LogisticsPageRequest,
|
||||
LogisticsPageResponse,
|
||||
LoginResult,
|
||||
OrderPageRequest,
|
||||
OrderPageResponse,
|
||||
OrderQueryParams,
|
||||
OrderStatistics,
|
||||
OrderStatus,
|
||||
ProductListRequest,
|
||||
ProductListResponse,
|
||||
)
|
||||
|
||||
BASE_URL = "https://pod-api.sdspod.com"
|
||||
COMPANY_NO = "印美达"
|
||||
KEYWORDS_BATCH_SIZE = 200
|
||||
FILTERS_FIELDS = (
|
||||
"factoryList,merchantList,logisticsList,earlyWarningList,"
|
||||
"issuingBayAreaList,logisticsSourceList,extendStatusList"
|
||||
)
|
||||
EXTRA_INFO = (
|
||||
"eyJpcCI6IjM4LjcxLjEyNy4xODkiLCJsb2NhdGlvbiI6eyJsYXQiOjM4Ljg4MzMsImxuZyI6LTc3fSwiYWRfaW5mbyI6"
|
||||
"eyJuYXRpb24iOiLnrKw0OSIsInByb3ZpbmNlIjoiIiwiY2l0eSI6IiIsImRpc3RyaWN0IjoiIiwiYWRjb2RlIjotMSwi"
|
||||
"bmF0aW9uX2NvZGUiOjg0MH0sImZpbmdlcnByaW50SWQiOiI3YzdjOWUwNTFhZTY0OTM5MTMwZjk5YjQ2OGJjYWFhNSIs"
|
||||
"InVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUz"
|
||||
"Ny4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xMzguMC4wLjAgU2FmYXJpLzUzNy4zNiIsImhyZWYiOiJodHRw"
|
||||
"czovL21hbmFnZS5zZHNwb2QuY29tL3VzZXIvbG9naW4ifQ=="
|
||||
)
|
||||
|
||||
|
||||
class SdsMngClient(TokenHttpClient):
|
||||
"""SDS POD 系统客户端:认证 / 订单 / 产品 / 物流。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
*,
|
||||
cache_ttl_seconds: int | float = 300,
|
||||
concurrency: int = 10,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
BASE_URL,
|
||||
token_header="access-token",
|
||||
fixed_headers=None,
|
||||
login_markers=("/pod/auth/login",),
|
||||
)
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
|
||||
self._concurrency = concurrency
|
||||
|
||||
# ----- 认证 -----
|
||||
|
||||
def _login(self) -> str:
|
||||
data = self.post(
|
||||
f"/pod/auth/login?t={int(time.time() * 1000)}",
|
||||
body={
|
||||
"no": COMPANY_NO,
|
||||
"password": self._password,
|
||||
"username": self._username,
|
||||
"extraInfo": EXTRA_INFO,
|
||||
},
|
||||
)
|
||||
result = LoginResult.model_validate(data["data"])
|
||||
self._token = result.access_token
|
||||
return self._token
|
||||
|
||||
# ----- 订单 -----
|
||||
|
||||
@staticmethod
|
||||
def _build_order_params(params: OrderPageRequest) -> dict[str, Any]:
|
||||
query: dict[str, Any] = {"productionType": 2, "t": int(time.time() * 1000)}
|
||||
if params.size is not None:
|
||||
query["size"] = params.size
|
||||
if params.page is not None:
|
||||
query["page"] = params.page
|
||||
if params.keywords is not None:
|
||||
query["keywords"] = params.keywords
|
||||
if params.created_date_begin is not None:
|
||||
query["createdDateBegin"] = params.created_date_begin
|
||||
if params.created_date_end is not None:
|
||||
query["createdDateEnd"] = params.created_date_end
|
||||
if params.pay_date_begin is not None:
|
||||
query["payDateBegin"] = params.pay_date_begin
|
||||
if params.pay_date_end is not None:
|
||||
query["payDateEnd"] = params.pay_date_end
|
||||
if params.confirm_date_begin is not None:
|
||||
query["confirmDateBegin"] = params.confirm_date_begin
|
||||
if params.confirm_date_end is not None:
|
||||
query["confirmDateEnd"] = params.confirm_date_end
|
||||
if params.finish_date_begin is not None:
|
||||
query["finishDateBegin"] = params.finish_date_begin
|
||||
if params.finish_date_end is not None:
|
||||
query["finishDateEnd"] = params.finish_date_end
|
||||
if params.order_status_code is not None:
|
||||
query["orderStatusCode"] = params.order_status_code
|
||||
if params.product_keywords is not None:
|
||||
query["productKeywords"] = params.product_keywords
|
||||
if params.order_time_range_type is not None:
|
||||
query["orderTimeRangeType"] = params.order_time_range_type
|
||||
if params.factory_ids is not None:
|
||||
query["factoryIds"] = params.factory_ids
|
||||
if params.merchant_ids is not None:
|
||||
query["merchantIds"] = params.merchant_ids
|
||||
if params.logistics_ids is not None:
|
||||
query["logisticsIds"] = params.logistics_ids
|
||||
if params.issuing_bay_area_id is not None:
|
||||
query["issuingBayAreaId"] = params.issuing_bay_area_id
|
||||
if params.extend_status is not None:
|
||||
query["extendStatus"] = params.extend_status
|
||||
if params.early_warning_type is not None:
|
||||
query["earlyWarningType"] = params.early_warning_type
|
||||
if params.item_num_type is not None:
|
||||
query["itemNumType"] = params.item_num_type
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _build_query_params(params: OrderQueryParams | None) -> dict[str, Any]:
|
||||
params = params or OrderQueryParams()
|
||||
query: dict[str, Any] = {"t": int(time.time() * 1000)}
|
||||
for py_name, api_name in [
|
||||
("created_date_begin", "createdDateBegin"),
|
||||
("created_date_end", "createdDateEnd"),
|
||||
("pay_date_begin", "payDateBegin"),
|
||||
("pay_date_end", "payDateEnd"),
|
||||
("confirm_date_begin", "confirmDateBegin"),
|
||||
("confirm_date_end", "confirmDateEnd"),
|
||||
("finish_date_begin", "finishDateBegin"),
|
||||
("finish_date_end", "finishDateEnd"),
|
||||
("order_time_range_type", "orderTimeRangeType"),
|
||||
("product_keywords", "productKeywords"),
|
||||
("factory_ids", "factoryIds"),
|
||||
("merchant_ids", "merchantIds"),
|
||||
("logistics_ids", "logisticsIds"),
|
||||
("issuing_bay_area_id", "issuingBayAreaId"),
|
||||
("extend_status", "extendStatus"),
|
||||
("early_warning_type", "earlyWarningType"),
|
||||
("item_num_type", "itemNumType"),
|
||||
]:
|
||||
value = getattr(params, py_name, None)
|
||||
if value:
|
||||
query[api_name] = value
|
||||
return query
|
||||
|
||||
def _get_single_page(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
query = self._build_order_params(
|
||||
params.model_copy(update={"size": params.size or 50, "page": params.page or 1})
|
||||
)
|
||||
data = self.get("/pod/orders/page", params=query)
|
||||
return OrderPageResponse.model_validate(data)
|
||||
|
||||
def _get_all_pages(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
page_size = params.size or 200
|
||||
first = self._get_single_page(params.model_copy(update={"size": page_size, "page": 1}))
|
||||
total_pages = -(-first.total_count // page_size)
|
||||
if total_pages <= 1:
|
||||
return first
|
||||
|
||||
def fetch(i: int) -> OrderPageResponse:
|
||||
return self._get_single_page(params.model_copy(update={"size": page_size, "page": i}))
|
||||
|
||||
remaining = run_concurrent(
|
||||
[lambda i=i: fetch(i) for i in range(2, total_pages + 1)], limit=self._concurrency
|
||||
)
|
||||
merged = list(first.records)
|
||||
for r in remaining:
|
||||
merged.extend(r.records)
|
||||
return OrderPageResponse(total_count=first.total_count, records=merged)
|
||||
|
||||
def get_orders(self, params: OrderPageRequest | None = None) -> OrderPageResponse:
|
||||
"""获取订单。keywords>200 自动分批;limit 取首页 N 条;all 全量分页。"""
|
||||
params = params or OrderPageRequest()
|
||||
|
||||
if params.keywords:
|
||||
return self._get_orders_with_keywords(params)
|
||||
|
||||
if params.limit:
|
||||
return self._get_single_page(params.model_copy(update={"size": params.limit, "page": 1}))
|
||||
if params.all:
|
||||
return self._get_all_pages(params)
|
||||
return self._get_single_page(params)
|
||||
|
||||
def _get_orders_with_keywords(self, params: OrderPageRequest) -> OrderPageResponse:
|
||||
batches = chunk(split_lines(params.keywords or ""), KEYWORDS_BATCH_SIZE) or [[]]
|
||||
|
||||
def fetch(batch: list[str]) -> OrderPageResponse:
|
||||
batch_params = params.model_copy(update={"keywords": "\n".join(batch)})
|
||||
if params.limit:
|
||||
return self._get_single_page(
|
||||
batch_params.model_copy(update={"size": params.limit, "page": 1})
|
||||
)
|
||||
return self._get_all_pages(batch_params)
|
||||
|
||||
results = run_concurrent([lambda b=b: fetch(b) for b in batches], limit=self._concurrency)
|
||||
merged: list[Any] = []
|
||||
total = 0
|
||||
for r in results:
|
||||
merged.extend(r.records)
|
||||
total += r.total_count
|
||||
if params.limit:
|
||||
merged = merged[: params.limit]
|
||||
return OrderPageResponse(total_count=total, list=merged)
|
||||
|
||||
def get_filters(self, params: OrderQueryParams | None = None) -> FiltratePanelResponse:
|
||||
cache_key = f"filters:{(params.model_dump_json(exclude_none=True)) if params else '{}'}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
query = {"fields": FILTERS_FIELDS, **self._build_query_params(params)}
|
||||
data = self.get("/pod/orders/filtratePanel", params=query)
|
||||
result = FiltratePanelResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_status(self, params: OrderQueryParams | None = None) -> list[OrderStatus]:
|
||||
cache_key = f"status:{(params.model_dump_json(exclude_none=True)) if params else '{}'}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
query = {"productionType": 2, **self._build_query_params(params)}
|
||||
data = self.get("/pod/orders/statistics", params=query)
|
||||
result = [OrderStatus.model_validate(item) for item in data]
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_statistics(
|
||||
self, params: OrderQueryParams | None = None, production_type: int = 2
|
||||
) -> list[OrderStatistics]:
|
||||
params = params or OrderQueryParams()
|
||||
cache_key = f"statistics:{production_type}:{params.model_dump_json(exclude_none=True)}"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
query = {
|
||||
"fields": FILTERS_FIELDS,
|
||||
"productionType": production_type,
|
||||
**self._build_query_params(params),
|
||||
}
|
||||
data = self.get("/pod/orders/statistics", params=query)
|
||||
result = [OrderStatistics.model_validate(item) for item in data]
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
# ----- 产品 -----
|
||||
|
||||
def get_products(self, params: ProductListRequest | None = None) -> ProductListResponse:
|
||||
params = params or ProductListRequest()
|
||||
if params.limit:
|
||||
return self._get_products_page(params.model_copy(update={"size": params.limit, "page": 1}))
|
||||
if params.all:
|
||||
return self._get_all_products(params)
|
||||
return self._get_products_page(params)
|
||||
|
||||
def _get_products_page(self, params: ProductListRequest) -> ProductListResponse:
|
||||
query = {
|
||||
"keyword": params.keyword or "",
|
||||
"page": params.page or 1,
|
||||
"size": params.size or 50,
|
||||
"publicStatus": params.public_status if params.public_status is not None else 1,
|
||||
"t": int(time.time() * 1000),
|
||||
}
|
||||
data = self.get("/pod/product/self", params=query)
|
||||
return ProductListResponse.model_validate(data)
|
||||
|
||||
def _get_all_products(self, params: ProductListRequest) -> ProductListResponse:
|
||||
page_size = params.size or 200
|
||||
first = self._get_products_page(params.model_copy(update={"size": page_size, "page": 1}))
|
||||
total_pages = -(-first.total_count // page_size)
|
||||
if total_pages <= 1:
|
||||
return first
|
||||
remaining = run_concurrent(
|
||||
[
|
||||
lambda i=i: self._get_products_page(params.model_copy(update={"size": page_size, "page": i}))
|
||||
for i in range(2, total_pages + 1)
|
||||
],
|
||||
limit=self._concurrency,
|
||||
)
|
||||
merged = list(first.records)
|
||||
for r in remaining:
|
||||
merged.extend(r.records)
|
||||
return ProductListResponse(total_count=first.total_count, records=merged)
|
||||
|
||||
# ----- 物流 -----
|
||||
|
||||
def get_logistics_areas(self) -> IssuingBayAreaResponse:
|
||||
cache_key = "logistics:areas"
|
||||
cached = self._cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = self.get("/pod/issuing_bay_areas", params={"t": int(time.time() * 1000)})
|
||||
result = IssuingBayAreaResponse.model_validate(data)
|
||||
self._cache.set(cache_key, result)
|
||||
return result
|
||||
|
||||
def get_logistics_page(self, params: LogisticsPageRequest) -> LogisticsPageResponse:
|
||||
if params.limit:
|
||||
return self._get_logistics_single(
|
||||
params.model_copy(update={"size": params.limit, "page": 1})
|
||||
)
|
||||
if params.all:
|
||||
return self._get_logistics_all(params)
|
||||
return self._get_logistics_single(params)
|
||||
|
||||
def _get_logistics_single(self, params: LogisticsPageRequest) -> LogisticsPageResponse:
|
||||
query = {
|
||||
"keyword": params.keyword or "",
|
||||
"deliveryMethod": params.delivery_method or "FBM",
|
||||
"issuingBayAreaId": params.issuing_bay_area_id,
|
||||
"page": params.page or 1,
|
||||
"size": params.size or 50,
|
||||
"t": int(time.time() * 1000),
|
||||
}
|
||||
data = self.get("/pod/logistics/page", params=query)
|
||||
return LogisticsPageResponse.model_validate(data)
|
||||
|
||||
def _get_logistics_all(self, params: LogisticsPageRequest) -> LogisticsPageResponse:
|
||||
page_size = params.size or 200
|
||||
first = self._get_logistics_single(
|
||||
params.model_copy(update={"size": page_size, "page": 1})
|
||||
)
|
||||
total_count = first.total_count or first.total_count_alt or 0
|
||||
total_pages = -(-total_count // page_size)
|
||||
if total_pages <= 1:
|
||||
return first
|
||||
remaining = run_concurrent(
|
||||
[
|
||||
lambda i=i: self._get_logistics_single(params.model_copy(update={"size": page_size, "page": i}))
|
||||
for i in range(2, total_pages + 1)
|
||||
],
|
||||
limit=self._concurrency,
|
||||
)
|
||||
merged: list[LogisticsItem] = list(first.items)
|
||||
for r in remaining:
|
||||
merged.extend(r.items)
|
||||
return LogisticsPageResponse(
|
||||
items=merged, page=1, size=len(merged), total_count=total_count
|
||||
)
|
||||
@@ -0,0 +1,364 @@
|
||||
"""SDS POD 平台 —— 数据模型(pydantic v2)。
|
||||
|
||||
移植自 @inkreach/sds-mng。订单域为 camelCase;物流域混用 snake_case,
|
||||
对 snake_case 字段显式标注 alias(alias 优先于 alias_generator)。
|
||||
所有模型 extra='allow',不丢字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
class _SdsModel(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
populate_by_name=True,
|
||||
alias_generator=to_camel,
|
||||
)
|
||||
|
||||
|
||||
# ----- 认证 -----
|
||||
|
||||
|
||||
class LoginResult(_SdsModel):
|
||||
access_token: str
|
||||
|
||||
|
||||
# ----- 订单域(camelCase)-----
|
||||
|
||||
|
||||
class MerchantStore(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
platform_name: str | None = None
|
||||
platform_code: str | None = None
|
||||
status: int | None = None
|
||||
|
||||
|
||||
class Address(_SdsModel):
|
||||
id: int | None = None
|
||||
country: str | None = None
|
||||
consignee: str | None = None
|
||||
city: str | None = None
|
||||
detail: str | None = None
|
||||
province: str | None = None
|
||||
mobile_phone: str | None = None
|
||||
postcode: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IssuingBayArea(_SdsModel):
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
id: int | None = None
|
||||
|
||||
|
||||
class IssuingBayRef(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
city: str | None = None
|
||||
province: str | None = None
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class ImportProduct(_SdsModel):
|
||||
id: str | None = None
|
||||
import_sku: str | None = None
|
||||
import_product_name: str | None = None
|
||||
import_color: str | None = None
|
||||
import_size: str | None = None
|
||||
import_img: str | None = None
|
||||
import_style_number: str | None = None
|
||||
import_sku_id: str | None = None
|
||||
|
||||
|
||||
class OrderItem(_SdsModel):
|
||||
id: str | None = None
|
||||
order_id: str | None = None
|
||||
no: str | None = None
|
||||
out_order_item_id: str | None = None
|
||||
import_product: ImportProduct | None = None
|
||||
product_id: int | None = None
|
||||
factory_id: int | None = None
|
||||
factory: Any | None = None
|
||||
seller_sku: str | None = None
|
||||
product_name: str | None = None
|
||||
img_url: str | None = None
|
||||
product_color_block: Any | None = None
|
||||
product_size: str | None = None
|
||||
num: int | None = None
|
||||
status: int | None = None
|
||||
origin_price: float | None = None
|
||||
price: float | None = None
|
||||
supply_price: float | None = None
|
||||
amount: float | None = None
|
||||
profit: float | None = None
|
||||
refund_status: str | None = None
|
||||
design_status: str | None = None
|
||||
|
||||
|
||||
class Merchant(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class StatusHistory(_SdsModel):
|
||||
status: int | None = None
|
||||
gmt_update_time: str | None = None
|
||||
|
||||
|
||||
class Compensation(_SdsModel):
|
||||
status: int | None = None
|
||||
amount: float | None = None
|
||||
|
||||
|
||||
class Order(_SdsModel):
|
||||
id: str
|
||||
store_id: int | None = None
|
||||
customer_id: int | None = None
|
||||
no: str | None = None
|
||||
out_order_no: str | None = None
|
||||
username: str | None = None
|
||||
origin: str | None = None
|
||||
total_amount: float | None = None
|
||||
product_amount: float | None = None
|
||||
cost_amount: float | None = None
|
||||
status: int | None = None
|
||||
online_order_status_name: str | None = None
|
||||
online_order_status_code: str | None = None
|
||||
gmt_created: str | None = None
|
||||
gmt_pay: str | None = None
|
||||
pay_status: int | None = None
|
||||
latest_ship_time: Any | None = None
|
||||
refund_status: int | None = None
|
||||
be_after_service_order: bool | None = None
|
||||
fba_type: str | None = None
|
||||
remark: str | None = None
|
||||
carriage_name: str | None = None
|
||||
parcel_list: list[Any] = []
|
||||
merchant_store: MerchantStore | None = None
|
||||
address: Address | None = None
|
||||
issuing_bay_area: IssuingBayArea | None = None
|
||||
issuing_bay: IssuingBayRef | None = None
|
||||
items: list[OrderItem] = []
|
||||
logistics_id: int | None = None
|
||||
delivery_type: int | None = None
|
||||
merchant: Merchant | None = None
|
||||
finish_time: Any | None = None
|
||||
status_history: list[StatusHistory] = []
|
||||
compensation: Compensation | None = None
|
||||
compensation_status: int | None = None
|
||||
express_type: int | None = None
|
||||
|
||||
|
||||
class OrderPageResponse(_SdsModel):
|
||||
total_count: int = Field(default=0, alias="totalCount")
|
||||
current_page: int | None = None
|
||||
page_size: int | None = None
|
||||
records: list[Order] = Field(default=[], alias="list")
|
||||
|
||||
|
||||
class OrderStatus(_SdsModel):
|
||||
name: str | None = None
|
||||
order_status_code: str | None = None
|
||||
sort: int | None = None
|
||||
|
||||
|
||||
class OrderStatistics(_SdsModel):
|
||||
name: str | None = None
|
||||
order_num: int | None = None
|
||||
order_status_code: str | None = None
|
||||
sort: int | None = None
|
||||
|
||||
|
||||
# ----- 产品域(camelCase)-----
|
||||
|
||||
|
||||
class ProductCategory(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ProductSupply(_SdsModel):
|
||||
factory_id: int | None = None
|
||||
name: str | None = None
|
||||
supply_price: str | None = None
|
||||
max_price: float | None = None
|
||||
min_price: float | None = None
|
||||
online_supply_num: int | None = None
|
||||
|
||||
|
||||
class Product(_SdsModel):
|
||||
id: int
|
||||
img_url: str | None = None
|
||||
thumb_img_url: str | None = None
|
||||
category_id: int | None = None
|
||||
category_list: list[ProductCategory] = []
|
||||
on_sale_status: int | None = None
|
||||
public_status: int | None = None
|
||||
platform_price: str | None = None
|
||||
on_sale_price: str | None = None
|
||||
status: int | None = None
|
||||
sku: str | None = None
|
||||
name: str | None = None
|
||||
english_name: str | None = None
|
||||
product_supply: list[ProductSupply] = []
|
||||
|
||||
|
||||
class ProductListResponse(_SdsModel):
|
||||
total_count: int = Field(default=0, alias="totalCount")
|
||||
records: list[Product] = Field(default=[], alias="list")
|
||||
|
||||
|
||||
# ----- 物流域(混用 snake_case,显式 alias)-----
|
||||
|
||||
|
||||
class IssuingBay(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
country: str | None = None
|
||||
country_name: str | None = Field(default=None, alias="country_name")
|
||||
province: str | None = None
|
||||
province_code: str | None = Field(default=None, alias="province_code")
|
||||
city: str | None = None
|
||||
city_code: str | None = Field(default=None, alias="city_code")
|
||||
detail: str | None = None
|
||||
priority: int | None = None
|
||||
status: int | None = None
|
||||
issuing_bay_area_id: int | None = Field(default=None, alias="issuing_bay_area_id")
|
||||
created_time: int | None = Field(default=None, alias="created_time")
|
||||
updated_time: int | None = Field(default=None, alias="updated_time")
|
||||
|
||||
|
||||
class IssuingBayAreaDetail(_SdsModel):
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
type: str | None = None
|
||||
country_code: str | None = None
|
||||
address: str | None = None
|
||||
icon_url: str | None = None
|
||||
status: int | None = None
|
||||
priority: int | None = None
|
||||
created_time: int | None = Field(default=None, alias="created_time")
|
||||
updated_time: int | None = Field(default=None, alias="updated_time")
|
||||
issuing_bays: list[IssuingBay] = []
|
||||
|
||||
|
||||
class IssuingBayAreaResponse(_SdsModel):
|
||||
items: list[IssuingBayAreaDetail] = []
|
||||
|
||||
|
||||
class LogisticsItem(IssuingBay):
|
||||
factory_num: int | None = None
|
||||
account_num: int | None = None
|
||||
|
||||
|
||||
class LogisticsPageResponse(_SdsModel):
|
||||
items: list[LogisticsItem] = []
|
||||
page: int | None = None
|
||||
size: int | None = None
|
||||
total_count: int = Field(default=0, alias="totalCount")
|
||||
total_count_alt: int | None = Field(default=None, alias="total_count")
|
||||
|
||||
|
||||
# ----- 筛选面板(camelCase)-----
|
||||
|
||||
|
||||
class FilterItem(_SdsModel):
|
||||
name: str | None = None
|
||||
id: Any | None = None
|
||||
fp: Any | None = None
|
||||
|
||||
|
||||
class FiltratePanelResponse(_SdsModel):
|
||||
factory_list: list[FilterItem] = []
|
||||
merchant_list: list[FilterItem] = []
|
||||
logistics_list: list[FilterItem] = []
|
||||
early_warning_list: list[FilterItem] = []
|
||||
issuing_bay_area_list: list[FilterItem] = []
|
||||
extend_status_list: list[FilterItem] = []
|
||||
|
||||
|
||||
# ----- 查询参数(请求 schema)-----
|
||||
|
||||
|
||||
class OrderPageRequest(BaseModel):
|
||||
"""SDS 订单分页查询参数。keywords(换行分隔订单号)/时间范围/状态等可选。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
size: int | None = None
|
||||
page: int | None = None
|
||||
keywords: str | None = None
|
||||
created_date_begin: str | None = None
|
||||
created_date_end: str | None = None
|
||||
pay_date_begin: str | None = None
|
||||
pay_date_end: str | None = None
|
||||
confirm_date_begin: str | None = None
|
||||
confirm_date_end: str | None = None
|
||||
finish_date_begin: str | None = None
|
||||
finish_date_end: str | None = None
|
||||
order_status_code: str | None = None
|
||||
product_keywords: str | None = None
|
||||
order_time_range_type: str | None = None
|
||||
factory_ids: list[int] | None = None
|
||||
merchant_ids: list[int] | None = None
|
||||
logistics_ids: list[int] | None = None
|
||||
issuing_bay_area_id: int | None = None
|
||||
extend_status: str | None = None
|
||||
early_warning_type: str | None = None
|
||||
item_num_type: str | None = None
|
||||
limit: int | None = None
|
||||
all: bool | None = None
|
||||
|
||||
|
||||
class OrderQueryParams(BaseModel):
|
||||
"""SDS 订单筛选/状态/统计查询参数(不含分页)。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
created_date_begin: str | None = None
|
||||
created_date_end: str | None = None
|
||||
pay_date_begin: str | None = None
|
||||
pay_date_end: str | None = None
|
||||
confirm_date_begin: str | None = None
|
||||
confirm_date_end: str | None = None
|
||||
finish_date_begin: str | None = None
|
||||
finish_date_end: str | None = None
|
||||
order_time_range_type: str | None = None
|
||||
product_keywords: str | None = None
|
||||
factory_ids: list[int] | None = None
|
||||
merchant_ids: list[int] | None = None
|
||||
logistics_ids: list[int] | None = None
|
||||
issuing_bay_area_id: int | None = None
|
||||
extend_status: str | None = None
|
||||
early_warning_type: str | None = None
|
||||
item_num_type: str | None = None
|
||||
|
||||
|
||||
class ProductListRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
keyword: str | None = None
|
||||
page: int | None = None
|
||||
size: int | None = None
|
||||
public_status: int | None = None
|
||||
limit: int | None = None
|
||||
all: bool | None = None
|
||||
|
||||
|
||||
class LogisticsPageRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
keyword: str | None = None
|
||||
delivery_method: str | None = None
|
||||
issuing_bay_area_id: int
|
||||
page: int | None = None
|
||||
size: int | None = None
|
||||
limit: int | None = None
|
||||
all: bool | None = None
|
||||
Reference in New Issue
Block a user