feat(t221): add exact-order erp connector
This commit is contained in:
+12
-7
@@ -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
@@ -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
@@ -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 完成后领取。
|
||||
|
||||
@@ -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 或抓取未知主机。
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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%
|
||||
Reference in New Issue
Block a user