feat(t221): add exact-order erp connector

This commit is contained in:
QiuSW
2026-07-28 22:52:52 +08:00
parent 305dd9f513
commit c524e734fb
16 changed files with 1599 additions and 14 deletions
+56
View File
@@ -0,0 +1,56 @@
# 顺运宝 ERP Connector
内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号查询货运列表和详情,
再输出不含收件人、电话、地址、Cookie、JWT 或原始响应的规范化 JSON。
协议客户端基于同一开发机上已经用本地 HAR 验证的 `shunyunbaoerp 0.1.0` 代码收敛;
本目录不包含 HAR、真实响应、账号、密码或订单号。
## 安装和测试
```powershell
Set-Location erp-connector
python -m pip install -e ".[api,dev]"
pytest
```
测试只使用伪响应,不访问线上 ERP。
## 启动
```powershell
$env:SHUNYUNBAO_USERNAME = "your_username"
$env:SHUNYUNBAO_PASSWORD = "your_password"
$env:SHUNYUNBAO_SERVICE_API_KEY = "at-least-32-utf8-bytes"
..\scripts\start-erp-connector.bat
```
服务固定监听 `127.0.0.1:8091` 并关闭访问日志。不要把环境变量写入脚本、Git 或命令行
参数;生产前应改用受控秘密注入。
## 受控会话
1. `GET /v1/session/captcha`,请求头携带 `X-API-Key`。
2. 人员查看验证码后调用 `POST /v1/session/login`:
```json
{"captcha_code":"abcd"}
```
3. 调用 `POST /v1/freight/query`:
```json
{"order_number":"完整单号"}
```
Connector 不自动 OCR 绕过验证码。服务重启或 ERP 会话过期后需重新执行前两步。
## 输出边界
响应只包含:
- `external_stock_id`、受控来源单号、店铺、ERP 时间和原始状态。
- 全部商品的 `external_item_id`、标题、规格、SKU、数量、`productThumb` 引用和状态。
`productThumb` 当前可能是数字引用,不是公开图片 URL。下游必须保持 `NEEDS_IMAGE`,
不能拼接 URL 或抓取未知主机。
+38
View File
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "shunyunbaoerp"
version = "0.1.0"
description = "顺运宝 ERP 货运信息查询客户端"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"requests>=2.31,<3",
]
[project.optional-dependencies]
api = [
"fastapi>=0.110,<1",
"uvicorn[standard]>=0.27,<1",
]
redis = [
"redis>=5,<7",
]
dev = [
"pytest>=8,<9",
]
[project.scripts]
shunyunbaoerp = "shunyunbaoerp.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
@@ -0,0 +1,25 @@
"""顺运宝 ERP 客户端。"""
from .client import ERPClient
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
from .normalizer import normalize_freight_result
__all__ = [
"ERPClient",
"ERPError",
"ERPTransportError",
"ERPProtocolError",
"ERPAPIError",
"ERPAuthenticationError",
"ERPNotFoundError",
"normalize_freight_result",
]
__version__ = "0.1.0"
+149
View File
@@ -0,0 +1,149 @@
"""可选 FastAPI 包装层,供其他项目通过 HTTP 调用。"""
from __future__ import annotations
import os
import secrets
import threading
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Response, status
from pydantic import BaseModel, Field
from .client import ERPClient
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
from .normalizer import normalize_freight_result
app = FastAPI(
title="顺运宝 ERP 货运查询服务",
version="0.1.0",
description="单租户、受控会话的 ERP 查询适配层",
)
_client: ERPClient | None = None
_client_lock = threading.Lock()
class LoginRequest(BaseModel):
captcha_code: str = Field(min_length=1, max_length=16)
class FreightQueryRequest(BaseModel):
order_number: str = Field(min_length=1, max_length=128)
def get_client() -> ERPClient:
global _client
with _client_lock:
if _client is None:
_client = ERPClient(
os.getenv(
"SHUNYUNBAO_BASE_URL",
"https://www.shunyunbaoerp.com",
)
)
return _client
def require_api_key(
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
) -> None:
expected = os.getenv("SHUNYUNBAO_SERVICE_API_KEY")
if not expected or len(expected.encode("utf-8")) < 32:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="CONNECTOR_API_KEY_NOT_CONFIGURED",
)
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="CONNECTOR_API_KEY_INVALID",
)
APIKeyDependency = Annotated[None, Depends(require_api_key)]
@app.get("/health")
def health() -> dict[str, object]:
client = get_client()
expires = client.token_expires_at
return {
"status": "ok",
"loggedIn": client.is_logged_in,
"tokenExpiresAt": expires.isoformat() if expires else None,
}
@app.get("/v1/session/captcha")
def captcha(_: APIKeyDependency) -> Response:
try:
client = get_client()
content = client.fetch_captcha()
return Response(
content=content,
media_type=client.last_captcha_content_type,
headers={"Cache-Control": "no-store"},
)
except (ERPTransportError, ERPProtocolError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_CAPTCHA_UNAVAILABLE",
) from exc
@app.post("/v1/session/login")
def login(body: LoginRequest, _: APIKeyDependency) -> dict[str, object]:
username = os.getenv("SHUNYUNBAO_USERNAME")
password = os.getenv("SHUNYUNBAO_PASSWORD")
if not username or not password:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ERP_CREDENTIALS_NOT_CONFIGURED",
)
try:
get_client().login(username, password, body.captcha_code)
return {"status": "ok"}
except ERPAuthenticationError as exc:
raise HTTPException(status_code=401, detail="ERP_LOGIN_FAILED") from exc
except (ERPTransportError, ERPProtocolError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_UPSTREAM_UNAVAILABLE",
) from exc
@app.post("/v1/freight/query")
def query_freight(
body: FreightQueryRequest,
_: APIKeyDependency,
) -> dict[str, object]:
try:
result = get_client().get_freight_details(body.order_number)
return normalize_freight_result(result)
except ERPAuthenticationError as exc:
raise HTTPException(
status_code=401,
detail="ERP_SESSION_REQUIRED",
) from exc
except ERPNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="ERP_FREIGHT_NOT_FOUND",
) from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="ERP_QUERY_INVALID",
) from exc
except (ERPTransportError, ERPProtocolError, ERPAPIError) as exc:
raise HTTPException(
status_code=502,
detail="ERP_UPSTREAM_UNAVAILABLE",
) from exc
+99
View File
@@ -0,0 +1,99 @@
"""命令行入口,用于人工验证码登录和查询验证。"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import webbrowser
from pathlib import Path
from .client import ERPClient
from .errors import ERPError
from .normalizer import normalize_freight_result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="顺运宝 ERP 货运查询")
parser.add_argument(
"--base-url",
default=os.getenv("SHUNYUNBAO_BASE_URL", "https://www.shunyunbaoerp.com"),
help="ERP 根地址",
)
parser.add_argument(
"--username",
default=os.getenv("SHUNYUNBAO_USERNAME"),
help="ERP 用户名;默认读取 SHUNYUNBAO_USERNAME",
)
parser.add_argument(
"--captcha-file",
type=Path,
default=Path(".local/shunyunbao/captcha.png"),
help="验证码图片保存路径",
)
parser.add_argument(
"--open-captcha",
action="store_true",
help="保存后使用系统默认图片查看器打开验证码",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("login", help="验证登录链路")
query = subparsers.add_parser("query", help="按单号查询货运详情")
query.add_argument("order_number", help="ERP 页面“全部单号”查询框中的单号")
query.add_argument(
"--compact",
action="store_true",
help="输出紧凑 JSON",
)
return parser
def _credentials(args: argparse.Namespace) -> tuple[str, str]:
username = args.username or input("ERP 用户名: ").strip()
password = os.getenv("SHUNYUNBAO_PASSWORD") or getpass.getpass("ERP 密码: ")
if not username or not password:
raise ValueError("用户名和密码不能为空")
return username, password
def _interactive_login(client: ERPClient, args: argparse.Namespace) -> None:
image = client.fetch_captcha()
args.captcha_file.parent.mkdir(parents=True, exist_ok=True)
args.captcha_file.write_bytes(image)
absolute_path = args.captcha_file.resolve()
print(f"验证码已保存到: {absolute_path}", file=sys.stderr)
if args.open_captcha:
webbrowser.open(absolute_path.as_uri())
username, password = _credentials(args)
code = input("验证码: ").strip()
client.login(username, password, code)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
with ERPClient(args.base_url) as client:
_interactive_login(client, args)
if args.command == "login":
expires = client.token_expires_at
suffix = f",JWT 提示过期时间 {expires.isoformat()}" if expires else ""
print(f"登录成功{suffix}")
return 0
result = normalize_freight_result(
client.get_freight_details(args.order_number)
)
indent = None if args.compact else 2
print(json.dumps(result, ensure_ascii=False, indent=indent, default=str))
return 0
except (ERPError, ValueError, OSError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+487
View File
@@ -0,0 +1,487 @@
"""顺运宝 ERP 登录与货运详情查询客户端。"""
from __future__ import annotations
import base64
import json
import random
import threading
import time
from collections.abc import Iterable, Mapping
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urljoin, urlparse
import requests
from .constants import (
CAPTCHA_PATH,
LOGIN_PATH,
STOCK_DETAIL_PATH,
STOCK_LIST_PATH,
STOCK_LIST_TOTAL_PATH,
order_number_query,
stock_columns,
)
from .errors import (
ERPAPIError,
ERPAuthenticationError,
ERPNotFoundError,
ERPProtocolError,
ERPTransportError,
)
class ERPClient:
"""保持 Cookie 会话的同步客户端。
一个实例对应一个 ERP 登录会话。requests.Session 不是线程安全的,
因此所有会话操作都由实例锁串行化。
"""
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
def __init__(
self,
base_url: str = "https://www.shunyunbaoerp.com",
*,
timeout: tuple[float, float] = (5.0, 30.0),
max_retries: int = 2,
page_size: int = 20,
max_matches: int = 100,
session: requests.Session | None = None,
allow_insecure_http: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
parsed = urlparse(self.base_url)
is_local = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if parsed.scheme != "https" and not (allow_insecure_http or is_local):
raise ValueError("ERP 登录包含口令,非本地地址必须使用 HTTPS")
if not parsed.netloc:
raise ValueError("base_url 必须是完整 URL")
if any(value <= 0 for value in timeout):
raise ValueError("timeout 必须为正数")
if max_retries < 0:
raise ValueError("max_retries 不能小于 0")
if not 1 <= page_size <= 500:
raise ValueError("page_size 必须在 1..500 之间")
if max_matches < 1:
raise ValueError("max_matches 必须大于 0")
self.timeout = timeout
self.max_retries = max_retries
self.page_size = page_size
self.max_matches = max_matches
self.session = session or requests.Session()
origin = f"{parsed.scheme}://{parsed.netloc}"
self.session.headers.update(
{
"Accept": "application/json, text/plain, */*",
"Origin": origin,
"Referer": urljoin(self.base_url, "sys/login"),
"User-Agent": "shunyunbaoerp-client/0.1",
"X-Requested-With": "XMLHttpRequest",
}
)
self._lock = threading.RLock()
self._captcha_fetched = False
self._token: str | None = None
self._token_claims: dict[str, Any] = {}
self._user: dict[str, Any] | None = None
self.last_captcha_content_type = "image/png"
@property
def user(self) -> dict[str, Any] | None:
"""登录用户的副本,不包含口令。"""
return dict(self._user) if self._user else None
@property
def is_logged_in(self) -> bool:
"""本地是否持有登录结果;最终有效性仍由服务端会话决定。"""
return self._user is not None
@property
def token_expires_at(self) -> datetime | None:
"""JWT 中的提示性过期时间;不用于验证签名或替代 Cookie。"""
exp = self._token_claims.get("exp")
if not isinstance(exp, (int, float)):
return None
try:
return datetime.fromtimestamp(exp, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
def fetch_captcha(self) -> bytes:
"""获取验证码图片,并在当前 Session 中保留服务端 Cookie。"""
with self._lock:
url = self._url(CAPTCHA_PATH)
try:
response = self.session.get(
url,
params={"_": int(time.time() * 1000)},
timeout=self.timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
raise ERPTransportError(f"获取验证码失败: {exc}") from exc
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip()
if not content_type.startswith("image/"):
raise ERPProtocolError(
f"验证码接口返回了非图片内容: {content_type or 'unknown'}"
)
if not response.content:
raise ERPProtocolError("验证码接口返回了空图片")
self.last_captcha_content_type = content_type
self._captcha_fetched = True
return response.content
def login(
self,
username: str,
password: str,
captcha_code: str,
*,
require_fetched_captcha: bool = True,
) -> dict[str, Any]:
"""使用同一 Session 中取得的验证码登录。
登录失败后验证码通常失效,调用方应重新 fetch_captcha。
"""
username = username.strip()
captcha_code = captcha_code.strip()
if not username:
raise ValueError("username 不能为空")
if not password:
raise ValueError("password 不能为空")
if not captcha_code:
raise ValueError("captcha_code 不能为空")
with self._lock:
if require_fetched_captcha and not self._captcha_fetched:
raise ERPProtocolError("必须先用同一个 ERPClient 实例获取验证码")
try:
data = self._request_json(
"POST",
LOGIN_PATH,
json_body={
"username": username,
"password": password,
"code": captcha_code,
},
retryable=False,
authentication_request=True,
)
finally:
# 验证码按一次性使用处理,无论成功失败都要求重新获取。
self._captcha_fetched = False
if not isinstance(data, Mapping):
raise ERPProtocolError("登录成功响应缺少 data 对象")
user = data.get("user")
token = data.get("token")
if not isinstance(user, Mapping):
raise ERPProtocolError("登录成功响应缺少 user 对象")
self._user = dict(user)
self._token = token if isinstance(token, str) else None
self._token_claims = self._decode_jwt_claims(self._token)
return dict(self._user)
def query_stock_by_order_number(self, order_number: str) -> list[dict[str, Any]]:
"""按 HAR 中的“全部单号”条件查询货运列表。
注意:HAR 样本的输入值匹配返回字段 code,而不是 orderCode。
"""
normalized = self._validate_order_number(order_number)
with self._lock:
first_payload = self._stock_payload(normalized, start=0, page_index=1)
total_raw = self._request_json(
"POST",
STOCK_LIST_TOTAL_PATH,
json_body=first_payload,
retryable=True,
)
try:
total = int(total_raw or 0)
except (TypeError, ValueError) as exc:
raise ERPProtocolError("listTotal 返回值不是整数") from exc
if total < 0:
raise ERPProtocolError("listTotal 返回了负数")
if total == 0:
return []
if total > self.max_matches:
raise ERPProtocolError(
f"单号查询返回 {total} 条,超过安全上限 {self.max_matches};"
"请确认查询条件或显式调高 max_matches"
)
rows: list[dict[str, Any]] = []
for start in range(0, total, self.page_size):
page_index = start // self.page_size + 1
payload = self._stock_payload(
normalized,
start=start,
page_index=page_index,
)
page_data = self._request_json(
"POST",
STOCK_LIST_PATH,
json_body=payload,
retryable=True,
)
page_rows = self._extract_list(page_data, endpoint=STOCK_LIST_PATH)
rows.extend(page_rows)
if not page_rows:
break
# 服务端分页变化时避免重复记录,并将数量严格限制在已报告总数内。
unique: list[dict[str, Any]] = []
seen_ids: set[object] = set()
for row in rows:
row_id = row.get("id")
key = row_id if row_id is not None else json.dumps(row, sort_keys=True, default=str)
if key not in seen_ids:
seen_ids.add(key)
unique.append(row)
return unique[:total]
def get_stock_details(self, stock_ids: Iterable[int]) -> list[dict[str, Any]]:
"""批量查询货运详情;HAR 证明详情 id 与列表 id 一致。"""
normalized_ids: list[int] = []
seen: set[int] = set()
for value in stock_ids:
if isinstance(value, bool):
raise ValueError("stock id 必须是正整数")
try:
stock_id = int(value)
except (TypeError, ValueError) as exc:
raise ValueError("stock id 必须是正整数") from exc
if stock_id <= 0:
raise ValueError("stock id 必须是正整数")
if stock_id not in seen:
seen.add(stock_id)
normalized_ids.append(stock_id)
if not normalized_ids:
return []
details: list[dict[str, Any]] = []
with self._lock:
for offset in range(0, len(normalized_ids), 100):
chunk = normalized_ids[offset : offset + 100]
data = self._request_json(
"POST",
STOCK_DETAIL_PATH,
params={"hist": 0},
json_body={"ids": chunk},
retryable=True,
)
details.extend(self._extract_list(data, endpoint=STOCK_DETAIL_PATH))
return details
def get_freight_details(self, order_number: str) -> dict[str, Any]:
"""查询单号并合并列表概要和详情,返回可直接 JSON 序列化的对象。"""
normalized = self._validate_order_number(order_number)
with self._lock:
stocks = self.query_stock_by_order_number(normalized)
if not stocks:
raise ERPNotFoundError("未找到对应货运记录")
ids = [row.get("id") for row in stocks if row.get("id") is not None]
if len(ids) != len(stocks):
raise ERPProtocolError("货运列表存在缺少 id 的记录")
details = self.get_stock_details(ids)
details_by_id: dict[object, dict[str, Any]] = {}
for item in details:
item_id = item.get("id")
if item_id is None:
raise ERPProtocolError("货运详情存在缺少 id 的记录")
existing = details_by_id.get(item_id)
if existing is not None and existing != item:
raise ERPProtocolError("货运详情存在冲突的重复 id")
details_by_id[item_id] = item
records = [
{
"stock": stock,
"detail": details_by_id.get(stock["id"]),
}
for stock in stocks
]
return {
"query": {
"orderNumber": normalized,
"matchField": "allcode",
},
"count": len(records),
"records": records,
}
def close(self) -> None:
self.session.close()
def __enter__(self) -> "ERPClient":
return self
def __exit__(self, *_: object) -> None:
self.close()
def _stock_payload(
self,
order_number: str,
*,
start: int,
page_index: int,
) -> dict[str, Any]:
return {
"history": 0,
"length": self.page_size,
"start": start,
"pageTotal": 0,
"pageIndex": page_index,
"store": False,
"columns": stock_columns(),
"queries": [order_number_query(order_number)],
}
def _request_json(
self,
method: str,
path: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Mapping[str, Any] | None = None,
retryable: bool,
authentication_request: bool = False,
) -> Any:
url = self._url(path)
attempts = self.max_retries + 1 if retryable else 1
response: requests.Response | None = None
for attempt in range(attempts):
try:
response = self.session.request(
method,
url,
params=params,
json=json_body,
timeout=self.timeout,
)
except requests.RequestException as exc:
if attempt + 1 >= attempts:
raise ERPTransportError(f"ERP 请求失败: {exc}") from exc
self._backoff(attempt, retry_after=None)
continue
if response.status_code in self.RETRYABLE_STATUS_CODES and attempt + 1 < attempts:
self._backoff(attempt, retry_after=response.headers.get("Retry-After"))
continue
try:
response.raise_for_status()
except requests.RequestException as exc:
raise ERPTransportError(
f"ERP 返回 HTTP {response.status_code}: {path}"
) from exc
break
if response is None:
raise ERPTransportError("ERP 请求未产生响应")
refreshed_token = response.headers.get("X-Requested-With")
if refreshed_token and refreshed_token.count(".") == 2:
self._token = refreshed_token
self._token_claims = self._decode_jwt_claims(refreshed_token)
try:
envelope = response.json()
except (requests.JSONDecodeError, ValueError) as exc:
raise ERPProtocolError(f"ERP 返回了非 JSON 内容: {path}") from exc
if not isinstance(envelope, Mapping):
raise ERPProtocolError(f"ERP JSON 顶层不是对象: {path}")
if envelope.get("status") is not True:
code = envelope.get("code")
message = str(envelope.get("msg") or "ERP 请求失败")
is_auth_error = (
authentication_request
or str(code) == "-2"
or "未登录" in message
or "登录过期" in message
)
error_type = ERPAuthenticationError if is_auth_error else ERPAPIError
if not authentication_request and is_auth_error:
self._user = None
self._token = None
self._token_claims = {}
raise error_type(message, code=code)
if "data" not in envelope:
raise ERPProtocolError(f"ERP 成功响应缺少 data: {path}")
return envelope["data"]
@staticmethod
def _extract_list(data: Any, *, endpoint: str) -> list[dict[str, Any]]:
if not isinstance(data, Mapping):
raise ERPProtocolError(f"{endpoint} 的 data 不是对象")
values = data.get("list")
if not isinstance(values, list):
raise ERPProtocolError(f"{endpoint} 的 data.list 不是数组")
if not all(isinstance(value, Mapping) for value in values):
raise ERPProtocolError(f"{endpoint} 的 data.list 含非对象元素")
return [dict(value) for value in values]
def _url(self, path: str) -> str:
return urljoin(self.base_url, path.lstrip("/"))
@staticmethod
def _validate_order_number(value: str) -> str:
if not isinstance(value, str):
raise ValueError("order_number 必须是字符串")
normalized = value.strip()
if not normalized:
raise ValueError("order_number 不能为空")
if len(normalized) > 128:
raise ValueError("order_number 不能超过 128 个字符")
if any(ord(char) < 32 or ord(char) == 127 for char in normalized):
raise ValueError("order_number 不能包含控制字符")
return normalized
@staticmethod
def _decode_jwt_claims(token: str | None) -> dict[str, Any]:
"""仅解码 JWT payload 供过期时间展示,不验证其真实性。"""
if not token:
return {}
parts = token.split(".")
if len(parts) != 3:
return {}
payload = parts[1] + "=" * (-len(parts[1]) % 4)
try:
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
claims = json.loads(decoded.decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return claims if isinstance(claims, dict) else {}
@staticmethod
def _backoff(attempt: int, *, retry_after: str | None) -> None:
delay: float
if retry_after:
try:
delay = min(max(float(retry_after), 0.0), 10.0)
except ValueError:
delay = 0.0
else:
delay = 0.0
if delay == 0.0:
delay = min(0.5 * (2**attempt) + random.uniform(0.0, 0.25), 5.0)
time.sleep(delay)
@@ -0,0 +1,117 @@
"""从 HAR 提取的稳定接口常量和列表列定义。"""
from __future__ import annotations
from typing import Any
CAPTCHA_PATH = "/api/p/code1"
LOGIN_PATH = "/am/auth/login"
STOCK_LIST_TOTAL_PATH = "/am/stock/listTotal"
STOCK_LIST_PATH = "/am/stock/list"
STOCK_DETAIL_PATH = "/am/stock/detail/listByStock"
# (tableName, colName, fieldName, hasAlias, tableAlias)
_STOCK_COLUMN_SPECS = (
("t_stock", "created", "created", 0, "t"),
("t_stock", "order_code", "orderCode", 0, "t"),
("t_stock", "printer", "printer", 0, "t"),
("t_stock", "weight_time", "weightTime", 0, "t"),
("t_stock", "weight_inputer", "weightInputer", 0, "t"),
("t_stock", "pkg_time", "pkgTime", 0, "t"),
("t_stock", "code", "code", 0, "t"),
("t_stock", "status", "status", 0, "t"),
("t_stock", "order_status", "orderStatus", 0, "t"),
("t_stock", "purchase_status", "purchaseStatus", 0, "t"),
("t_stock", "exp_code", "expCode", 0, "t"),
("t_stock", "exp_page_code", "expPageCode", 0, "t"),
("t_stock", "exp_allow_print", "expAllowPrint", 0, "t"),
("t_stock", "exp_page_status", "expPageStatus", 0, "t"),
("t_stock", "page_id", "pageId", 0, "t"),
("t_stock", "upload_time", "uploadTime", 0, "t"),
("t_stock", "pay_time", "payTime", 0, "t"),
("t_stock", "shop_day_to_ship", "shopDayToShip", 0, "t"),
("t_stock", "remark9", "tsremark9", 1, "t"),
("t_stock", "remark9", "remark9", 0, "t"),
("t_stock", "detail_qty", "detailQty", 0, "t"),
("t_stock", "order_qty", "orderQty", 0, "t"),
("t_stock_detail", "inner_exp_code", "innerExpCode", 0, "t7"),
("t_stock_detail", "shelf_code", "shelfCode", 0, "t7"),
("t_stock", "shelf_code", "tsshelfCode", 1, "t"),
("t_stock", "store_type", "storeType", 0, "t"),
("t_stock", "order_bag_code", "orderBagCode", 0, "t"),
("t_stock", "weight_cust_pkg", "weightCustPkg", 0, "t"),
("t_stock", "weight_consign", "weightConsign", 0, "t"),
("t_stock", "amt_order", "amtOrder", 0, "t"),
("t_stock_append", "offline_amount", "offlineAmount", 0, "t8"),
("t_stock_append", "escrow_amount", "escrowAmount", 0, "t8"),
("t_stock", "exp_cod", "expCod", 0, "t"),
("t_stock", "exp_company", "expCompany", 0, "t"),
("t_stock", "transport", "transport", 0, "t"),
("t_stock", "order_origin", "orderOrigin", 0, "t"),
("t_stock", "exp_out_type", "expOutType", 0, "t"),
("t_stock", "order_platform", "orderPlatform", 0, "t"),
("t_stock", "exp_pkg_type", "expPkgType", 0, "t"),
("t_stock", "exp_pkg_code", "expPkgCode", 0, "t"),
("t_stock", "exp_batch", "expBatch", 0, "t"),
("t_stock", "exp_ti_huo", "expTiHuo", 0, "t"),
("t_stock", "print_time", "printTime", 0, "t"),
("t_stock", "receiver", "receiver", 0, "t"),
("t_stock", "receiver_tel", "receiverTel", 0, "t"),
("t_stock", "receiver_addr", "receiverAddr", 0, "t"),
("t_stock", "receiver_shop_name", "receiverShopName", 0, "t"),
("t_stock", "receiver_shop_code", "receiverShopCode", 0, "t"),
("t_stock", "product_name", "productName", 0, "t"),
("t_stock", "is_cancel", "isCancel", 0, "t"),
("t_stock", "err_msg", "errMsg", 0, "t"),
("t_stock", "remark2", "remark2", 0, "t"),
("t_stock", "remark1", "remark1", 0, "t"),
("t_stock", "note", "note", 0, "t"),
("t_store", "name", "name", 0, "t1"),
("sys_user", "fullname", "sufullname", 1, "t3"),
("sys_user", "dept_label_path", "deptLabelPath", 0, "t3"),
("t_stock", "shop_name", "shopName", 0, "t"),
("t_shop", "shop_id", "shopId", 0, "t6"),
("t_stock", "remark4", "remark4", 0, "t"),
("t_stock", "remark7", "remark7", 0, "t"),
("t_stock", "package_time", "packageTime", 0, "t"),
("t_stock", "packer", "packer", 0, "t"),
("t_stock", "pack_type", "packType", 0, "t"),
("t_stock", "track_status", "trackStatus", 0, "t"),
("t_stock", "track_desc", "trackDesc", 0, "t"),
("t_stock", "err_status", "errStatus", 0, "t"),
("t_stock", "err_time", "errTime", 0, "t"),
("t_stock", "err_msg", "tserrMsg", 1, "t"),
("t_stock", "remark2", "tsremark2", 1, "t"),
("t_stock", "remark1", "tsremark1", 1, "t"),
("t_stock", "product_volume_str", "productVolumeStr", 0, "t"),
)
def stock_columns() -> list[dict[str, Any]]:
"""返回新的列定义列表,避免调用方修改全局模板。"""
return [
{
"tableName": table_name,
"colName": column_name,
"fieldName": field_name,
"hasAlias": has_alias,
"tableAlias": table_alias,
}
for table_name, column_name, field_name, has_alias, table_alias in _STOCK_COLUMN_SPECS
]
def order_number_query(order_number: str) -> dict[str, Any]:
"""生成 HAR 中“全部单号”精确查询条件。"""
return {
"dvalue": order_number,
"tableName": "t_stock",
"colName": "allcode",
"op": 6,
"type": 0,
"tableAlias": "t",
"optType": 1,
}
+29
View File
@@ -0,0 +1,29 @@
"""客户端异常定义。"""
class ERPError(RuntimeError):
"""所有顺运宝客户端异常的基类。"""
class ERPTransportError(ERPError):
"""网络、超时或 HTTP 状态异常。"""
class ERPProtocolError(ERPError):
"""ERP 响应不符合预期协议。"""
class ERPAPIError(ERPError):
"""ERP 返回 status=false。"""
def __init__(self, message: str, *, code: object = None) -> None:
super().__init__(message)
self.code = code
class ERPAuthenticationError(ERPAPIError):
"""登录失败、验证码错误或会话过期。"""
class ERPNotFoundError(ERPError):
"""未查到对应货运记录。"""
@@ -0,0 +1,159 @@
"""把 ERP 原始对象缩减为下游允许保存的货运字段。"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from .errors import ERPProtocolError
def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]:
records = result.get("records")
if not isinstance(records, list):
raise ERPProtocolError("货运查询结果缺少 records 数组")
orders: list[dict[str, Any]] = []
seen_orders: dict[str, dict[str, Any]] = {}
for record in records:
if not isinstance(record, Mapping):
raise ERPProtocolError("货运查询结果包含非对象记录")
stock = record.get("stock")
detail = record.get("detail")
if not isinstance(stock, Mapping) or not isinstance(detail, Mapping):
raise ERPProtocolError("货运记录缺少 stock 或 detail 对象")
stock_id = _external_id(stock.get("id"), "货运单")
detail_id = _external_id(detail.get("id"), "货运详情")
if stock_id != detail_id:
raise ERPProtocolError("货运列表和详情身份不一致")
raw_items = detail.get("details")
if not isinstance(raw_items, list):
raise ERPProtocolError("货运详情缺少 details 数组")
item_by_id: dict[str, dict[str, Any]] = {}
for raw_item in raw_items:
if not isinstance(raw_item, Mapping):
raise ERPProtocolError("货运商品明细包含非对象元素")
item = _normalize_item(raw_item)
existing = item_by_id.get(item["external_item_id"])
if existing is not None and existing != item:
raise ERPProtocolError("货运商品明细存在冲突的重复身份")
item_by_id[item["external_item_id"]] = item
order = {
"external_stock_id": stock_id,
"source_code": _text(stock.get("code")),
"platform_order_no": _optional_text(stock.get("orderCode")),
"shop_name": _optional_text(
detail.get("shopName")
if _text(detail.get("shopName"))
else stock.get("shopName")
),
"source_created_at": _optional_text(
detail.get("created")
if _text(detail.get("created"))
else stock.get("created")
),
"order_status": _optional_scalar(
stock.get("orderStatus")
if stock.get("orderStatus") is not None
else detail.get("status")
),
"purchase_status": _optional_scalar(stock.get("purchaseStatus")),
"is_canceled": _optional_bool(stock.get("isCancel")),
"items": sorted(
item_by_id.values(),
key=lambda value: _identity_sort_key(
value["external_item_id"]
),
),
}
existing_order = seen_orders.get(stock_id)
if existing_order is not None and existing_order != order:
raise ERPProtocolError("货运查询结果存在冲突的重复货运单")
seen_orders[stock_id] = order
orders.extend(
sorted(
seen_orders.values(),
key=lambda value: _identity_sort_key(
value["external_stock_id"]
),
)
)
return {
"schema_version": 1,
"query": {"mode": "ORDER_NUMBER"},
"orders": orders,
}
def _normalize_item(item: Mapping[str, Any]) -> dict[str, Any]:
title = _text(item.get("productTitle"))
if not title:
title = _text(item.get("detailProductName"))
sku = _text(item.get("sku"))
if not sku:
sku = _text(item.get("variationSku"))
return {
"external_item_id": _external_id(item.get("id"), "货运商品"),
"title": title,
"product_spec": _text(item.get("productSpec")),
"sku": sku,
"quantity": _positive_int_or_none(item.get("productQty")),
"product_thumb_ref": _optional_scalar(item.get("productThumb")),
"purchase_status": _optional_scalar(item.get("purchaseStatus")),
}
def _external_id(value: Any, label: str) -> str:
if isinstance(value, bool):
raise ERPProtocolError(f"{label}外部身份无效")
try:
normalized = int(value)
except (TypeError, ValueError) as exc:
raise ERPProtocolError(f"{label}外部身份无效") from exc
if normalized <= 0:
raise ERPProtocolError(f"{label}外部身份无效")
return str(normalized)
def _positive_int_or_none(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
normalized = int(value)
except (TypeError, ValueError):
return None
return normalized if normalized > 0 else None
def _text(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _optional_text(value: Any) -> str | None:
normalized = _text(value)
return normalized or None
def _optional_scalar(value: Any) -> str | None:
if value is None or isinstance(value, (dict, list, tuple, set)):
return None
normalized = str(value).strip()
return normalized or None
def _optional_bool(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value in (0, "0"):
return False
if value in (1, "1"):
return True
return None
def _identity_sort_key(value: str) -> tuple[int, str]:
return (int(value), value)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
import pytest
from fastapi import HTTPException
from shunyunbaoerp import api
from test_normalizer import raw_result
VALID_KEY = "k" * 32
class FakeClient:
is_logged_in = True
token_expires_at = None
def get_freight_details(self, order_number: str) -> dict:
assert order_number == "SOURCE-12"
return raw_result()
def test_query_requires_configured_service_key(monkeypatch) -> None:
monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False)
with pytest.raises(HTTPException) as raised:
api.require_api_key(VALID_KEY)
assert raised.value.status_code == 503
assert raised.value.detail == "CONNECTOR_API_KEY_NOT_CONFIGURED"
def test_query_rejects_wrong_service_key(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
with pytest.raises(HTTPException) as raised:
api.require_api_key("x" * 32)
assert raised.value.status_code == 401
assert raised.value.detail == "CONNECTOR_API_KEY_INVALID"
def test_query_returns_only_normalized_fields(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
api.require_api_key(VALID_KEY)
response = api.query_freight(
api.FreightQueryRequest(order_number="SOURCE-12"),
None,
)
assert len(response["orders"][0]["items"]) == 2
encoded = json.dumps(response, ensure_ascii=False)
assert "receiverTel" not in encoded
assert "receiverAddr" not in encoded
assert "PRIVATE-QUERY" not in encoded
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import base64
import json
from urllib.parse import urlparse
import pytest
import requests
from shunyunbaoerp import (
ERPAuthenticationError,
ERPClient,
ERPNotFoundError,
ERPProtocolError,
)
class FakeResponse:
def __init__(
self,
payload=None,
*,
status_code: int = 200,
content: bytes = b"",
headers: dict[str, str] | None = None,
) -> None:
self._payload = payload
self.status_code = status_code
self.content = content
self.headers = headers or {"Content-Type": "application/json"}
def json(self):
return self._payload
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise requests.HTTPError(str(self.status_code))
class FakeSession:
def __init__(self, responses: list[FakeResponse]) -> None:
self.responses = list(responses)
self.headers: dict[str, str] = {}
self.calls: list[dict[str, object]] = []
self.closed = False
def get(self, url: str, **kwargs):
self.calls.append({"method": "GET", "url": url, **kwargs})
return self.responses.pop(0)
def request(self, method: str, url: str, **kwargs):
self.calls.append({"method": method, "url": url, **kwargs})
return self.responses.pop(0)
def close(self) -> None:
self.closed = True
def envelope(data, *, status=True, msg="成功", code=None):
return {"status": status, "msg": msg, "data": data, "code": code}
def fake_jwt(exp: int = 2_000_000_000) -> str:
encode = lambda value: base64.urlsafe_b64encode(
json.dumps(value, separators=(",", ":")).encode()
).decode().rstrip("=")
return f"{encode({'alg': 'none'})}.{encode({'exp': exp, 'username': 'demo'})}.signature"
def test_login_keeps_captcha_and_login_in_same_session() -> None:
session = FakeSession(
[
FakeResponse(
content=b"image",
headers={"Content-Type": "image/png", "Set-Cookie": "omitted"},
),
FakeResponse(
envelope(
{
"user": {"id": 1, "username": "demo"},
"token": fake_jwt(),
}
)
),
]
)
client = ERPClient(session=session)
assert client.fetch_captcha() == b"image"
user = client.login("demo", "secret", "abcd")
assert user["id"] == 1
assert client.is_logged_in is True
assert client.token_expires_at is not None
assert urlparse(session.calls[0]["url"]).path == "/api/p/code1"
assert urlparse(session.calls[1]["url"]).path == "/am/auth/login"
assert session.calls[1]["json"] == {
"username": "demo",
"password": "secret",
"code": "abcd",
}
def test_login_requires_captcha_from_same_client() -> None:
client = ERPClient(session=FakeSession([]))
with pytest.raises(ERPProtocolError):
client.login("demo", "secret", "abcd")
def test_query_and_detail_reproduce_har_contract() -> None:
stock = {
"id": 99001122,
"code": "FREIGHT-001",
"orderCode": "PLATFORM-001",
}
detail = {
"id": 99001122,
"code": "FREIGHT-001",
"details": [{"id": 2, "productQty": 1}],
}
session = FakeSession(
[
FakeResponse(envelope(1)),
FakeResponse(envelope({"total": 1, "list": [stock]})),
FakeResponse(envelope({"total": 1, "list": [detail]})),
]
)
client = ERPClient(session=session)
result = client.get_freight_details("FREIGHT-001")
assert result["count"] == 1
assert result["records"][0] == {"stock": stock, "detail": detail}
assert [urlparse(call["url"]).path for call in session.calls] == [
"/am/stock/listTotal",
"/am/stock/list",
"/am/stock/detail/listByStock",
]
query = session.calls[0]["json"]["queries"][0]
assert query == {
"dvalue": "FREIGHT-001",
"tableName": "t_stock",
"colName": "allcode",
"op": 6,
"type": 0,
"tableAlias": "t",
"optType": 1,
}
assert len(session.calls[0]["json"]["columns"]) == 72
assert session.calls[2]["params"] == {"hist": 0}
assert session.calls[2]["json"] == {"ids": [99001122]}
def test_not_found_stops_before_detail_request() -> None:
session = FakeSession([FakeResponse(envelope(0))])
client = ERPClient(session=session)
with pytest.raises(ERPNotFoundError):
client.get_freight_details("missing")
assert len(session.calls) == 1
def test_expired_session_becomes_authentication_error() -> None:
session = FakeSession(
[
FakeResponse(
envelope(None, status=False, msg="未登录或登录过期", code="-2")
)
]
)
client = ERPClient(session=session)
with pytest.raises(ERPAuthenticationError):
client.query_stock_by_order_number("FREIGHT-001")
def test_rejects_unexpected_bulk_match() -> None:
session = FakeSession([FakeResponse(envelope(101))])
client = ERPClient(session=session, max_matches=100)
with pytest.raises(ERPProtocolError, match="安全上限"):
client.query_stock_by_order_number("too-broad")
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import pytest
from shunyunbaoerp import ERPProtocolError, normalize_freight_result
def raw_result() -> dict:
return {
"count": 1,
"query": {
"orderNumber": "PRIVATE-QUERY",
"matchField": "allcode",
},
"records": [
{
"stock": {
"id": 12,
"code": "SOURCE-12",
"orderCode": "PLATFORM-12",
"shopName": "来源店铺",
"created": "2026-07-28 10:00:00",
"orderStatus": 2,
"purchaseStatus": 0,
"isCancel": 0,
"receiver": "不得返回",
"receiverTel": "不得返回",
"receiverAddr": "不得返回",
},
"detail": {
"id": 12,
"shopName": "来源店铺",
"created": "2026-07-28 10:00:00",
"details": [
{
"id": 102,
"productTitle": "第二件商品",
"productSpec": "蓝色,L",
"sku": "",
"variationSku": "BLUE-L",
"productQty": 2,
"productThumb": 190000002,
"purchaseStatus": 0,
"cost": "不得返回",
},
{
"id": 101,
"productTitle": "第一件商品",
"productSpec": "灰色,2XL",
"sku": "GRAY-2XL",
"variationSku": "IGNORED",
"productQty": 1,
"productThumb": 190000001,
"purchaseStatus": 0,
},
],
"receiver": "不得返回",
},
}
],
}
def test_normalizes_all_items_and_excludes_private_fields() -> None:
normalized = normalize_freight_result(raw_result())
assert normalized["schema_version"] == 1
assert normalized["query"] == {"mode": "ORDER_NUMBER"}
assert len(normalized["orders"]) == 1
order = normalized["orders"][0]
assert order["external_stock_id"] == "12"
assert [item["external_item_id"] for item in order["items"]] == [
"101",
"102",
]
assert order["items"][1]["sku"] == "BLUE-L"
assert order["items"][1]["quantity"] == 2
assert order["items"][0]["product_thumb_ref"] == "190000001"
encoded = json.dumps(normalized, ensure_ascii=False)
for forbidden in (
"PRIVATE-QUERY",
"receiver",
"receiverTel",
"receiverAddr",
"cost",
"不得返回",
):
assert forbidden not in encoded
def test_invalid_procurement_fields_remain_reviewable() -> None:
source = raw_result()
item = source["records"][0]["detail"]["details"][0]
item["productTitle"] = None
item["detailProductName"] = None
item["sku"] = ""
item["variationSku"] = ""
item["productQty"] = 0
item["productThumb"] = None
normalized = normalize_freight_result(source)
result = normalized["orders"][0]["items"][1]
assert result["title"] == ""
assert result["sku"] == ""
assert result["quantity"] is None
assert result["product_thumb_ref"] is None
def test_conflicting_duplicate_item_is_rejected() -> None:
source = raw_result()
duplicate = dict(source["records"][0]["detail"]["details"][0])
duplicate["productTitle"] = "冲突内容"
source["records"][0]["detail"]["details"].append(duplicate)
with pytest.raises(ERPProtocolError, match="重复身份"):
normalize_freight_result(source)
@pytest.mark.parametrize(
"mutation",
[
lambda source: source["records"][0].update(detail=None),
lambda source: source["records"][0]["detail"].update(id=99),
lambda source: source["records"][0]["detail"].update(details=None),
lambda source: source["records"][0]["detail"]["details"][0].update(
id=None
),
],
)
def test_identity_or_detail_shape_failure_rejects_whole_batch(
mutation,
) -> None:
source = raw_result()
mutation(source)
with pytest.raises(ERPProtocolError):
normalize_freight_result(source)