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
+12 -7
View File
@@ -5,7 +5,7 @@
## 当前快照
- 日期:2026-07-28
- 阶段:T-220 已完成,T-221 顺运宝精确单号 Connector 已领取
- 阶段:T-221 已完成,T-222 货运信息存储 API 与 Admin 页面已领取
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-219
均按文档提交、实现提交的顺序纳入历史
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
@@ -84,6 +84,10 @@
对账和唯一对账成功;成功态展示完整订单号、平台时间、授权 SKU/数量/金额及受控
证据,只提醒采购员打开拼多多人工核对付款。Roubao 显示相同终态;Web/App 均没有
付款、重复提交或自动支付动作。隔离数据库真实 SSR 已通过三个响应式视口验收。
- T-220/T-221 ERP 来源:字段契约已固定 stock/detail/item 外部身份、PII 最小化和
缺图边界;Python Connector 只监听 loopback,服务密钥鉴权,受控验证码登录后按
完整单号读取全部商品并输出 allowlist schema。16 项伪响应测试和真实 HTTP 健康
smoke 通过,未访问线上 ERP。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -152,8 +156,8 @@
| `docs/tasks/T-218.md` | DONE | 单次提交围栏、最多一次提交动作和待付款订单唯一对账 |
| `docs/tasks/T-219.md` | DONE | Admin 待付款提醒、Roubao 终态和跨端闭环验收 |
| `docs/tasks/T-220.md` | DONE | 顺运宝 ERP 字段契约与凭证安全基线 |
| `docs/tasks/T-221.md` | DOING | 顺运宝精确单号 Connector |
| `docs/tasks/T-222.md` | TODO | 货运信息存储、API 与 Admin 页面 |
| `docs/tasks/T-221.md` | DONE | 顺运宝精确单号 Connector |
| `docs/tasks/T-222.md` | DOING | 货运信息存储、API 与 Admin 页面 |
| `docs/tasks/T-223.md` | TODO | 待采购需求提取与任务生成 |
| `docs/tasks/T-224.md` | TODO | ERP 日期增量同步 |
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
@@ -162,15 +166,16 @@
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI |
| `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration |
| `erp-connector/` | 已有 | 顺运宝 loopback Python Connector 与无网络伪响应测试 |
| `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 |
## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-219。
- 已完成:另含 T-220 顺运宝 ERP 字段契约与凭证安全基线。
- 进行中:T-221 顺运宝精确单号 Connector。
- 下一步:完成 T-222 货运模块、T-223 采购需求生成和 T-224 日期增量同步,再进入
T-301 P0 UI 完整交互验收。
- 已完成:另含 T-220、T-221 ERP 安全字段契约与精确单号 Connector。
- 进行中:T-222 货运信息存储、API 与 Admin 页面。
- 下一步:完成 T-223 采购需求生成和 T-224 日期增量同步,再进入 T-301 P0 UI
完整交互验收。
## 当前可运行内容
+16 -5
View File
@@ -4,7 +4,7 @@ title: 顺运宝精确单号 Connector
phase: 2
deps:
- T-220
status: DOING
status: DONE
created: 2026-07-28
context_ref: 78dc595
work_branch: null
@@ -44,10 +44,10 @@ Go 后端不应在 Gin 请求里启动 Python,也不应保存 ERP 密码、Coo
## 验收要点
- [ ] 伪 ERP 响应覆盖验证码约束、登录、分页、重试、安全上限、多货运头和多商品。
- [ ] 精确单号响应具有稳定 schema,商品不被 dict 覆盖或错误关联。
- [ ] API Key 缺失/错误、未登录、未找到、ERP 超时和协议异常返回稳定状态。
- [ ] `pytest`/编译和无网络单测通过;启动脚本不包含秘密。
- [x] 伪 ERP 响应覆盖验证码约束、登录、分页、重试、安全上限、多货运头和多商品。
- [x] 精确单号响应具有稳定 schema,商品不被 dict 覆盖或错误关联。
- [x] API Key 缺失/错误、未登录、未找到、ERP 超时和协议异常返回稳定状态。
- [x] `pytest`/编译和无网络单测通过;启动脚本不包含秘密。
## 边界
@@ -58,3 +58,14 @@ Go 后端不应在 Gin 请求里启动 Python,也不应保存 ERP 密码、Coo
## 执行记录
- 2026-07-28:任务合约已冻结;T-220 完成后领取。
- 2026-07-28:`erp-connector/` 接入 Python 3.10+ 客户端、FastAPI 内部适配、
`normalize_freight_result` 和 loopback 启动脚本。源代码来自同一开发机已用私有
HAR 验证的参考工程;只复制源代码和伪响应测试,没有复制 HAR/真实响应。
- 2026-07-28:API Key 最少 32 UTF-8 字节;captcha/login/query 返回稳定错误码。
query 响应只含 allowlist 字段,多商品按 external item id 稳定排序,冲突重复身份
整批失败。收件人、电话、地址、查询原值和原始对象有负向测试。
- 2026-07-28:在 `erp-connector/` 执行
`$env:PYTHONPATH=(Resolve-Path .\src).Path; python -m pytest -q`,16 项通过;
`python -m compileall -q src tests` 通过。uvicorn 在 `127.0.0.1:18092` 启动,
`curl --noproxy "*" /health` 返回 200 后已停止。根 `.\init.ps1` 通过。
全部验证使用伪凭证且未访问线上 ERP。
+2 -2
View File
@@ -4,7 +4,7 @@ title: 货运信息存储 API 与 Admin 页面
phase: 2
deps:
- T-221
status: TODO
status: DOING
created: 2026-07-28
context_ref: 78dc595
work_branch: null
@@ -65,4 +65,4 @@ write_paths:
## 执行记录
- 2026-07-28:任务合约已冻结,等待 T-221。
- 2026-07-28:任务合约已冻结;T-221 完成后领取。
+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)
+26
View File
@@ -0,0 +1,26 @@
@echo off
setlocal
if "%SHUNYUNBAO_USERNAME%"=="" (
echo Missing SHUNYUNBAO_USERNAME.
exit /b 1
)
if "%SHUNYUNBAO_PASSWORD%"=="" (
echo Missing SHUNYUNBAO_PASSWORD.
exit /b 1
)
if "%SHUNYUNBAO_SERVICE_API_KEY%"=="" (
echo Missing SHUNYUNBAO_SERVICE_API_KEY.
exit /b 1
)
set "CONNECTOR_ROOT=%~dp0..\erp-connector"
pushd "%CONNECTOR_ROOT%" || exit /b 1
python -m uvicorn shunyunbaoerp.api:app ^
--app-dir src ^
--host 127.0.0.1 ^
--port 8091 ^
--no-access-log
set "EXIT_CODE=%ERRORLEVEL%"
popd
exit /b %EXIT_CODE%