From c524e734fbc76c65472fd434a53f13c35a05b36a Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 22:52:52 +0800 Subject: [PATCH] feat(t221): add exact-order erp connector --- docs/current-state.md | 19 +- docs/tasks/T-221.md | 21 +- docs/tasks/T-222.md | 4 +- erp-connector/README.md | 56 ++ erp-connector/pyproject.toml | 38 ++ erp-connector/src/shunyunbaoerp/__init__.py | 25 + erp-connector/src/shunyunbaoerp/api.py | 149 ++++++ erp-connector/src/shunyunbaoerp/cli.py | 99 ++++ erp-connector/src/shunyunbaoerp/client.py | 487 ++++++++++++++++++ erp-connector/src/shunyunbaoerp/constants.py | 117 +++++ erp-connector/src/shunyunbaoerp/errors.py | 29 ++ erp-connector/src/shunyunbaoerp/normalizer.py | 159 ++++++ erp-connector/tests/test_api.py | 58 +++ erp-connector/tests/test_client.py | 185 +++++++ erp-connector/tests/test_normalizer.py | 141 +++++ scripts/start-erp-connector.bat | 26 + 16 files changed, 1599 insertions(+), 14 deletions(-) create mode 100644 erp-connector/README.md create mode 100644 erp-connector/pyproject.toml create mode 100644 erp-connector/src/shunyunbaoerp/__init__.py create mode 100644 erp-connector/src/shunyunbaoerp/api.py create mode 100644 erp-connector/src/shunyunbaoerp/cli.py create mode 100644 erp-connector/src/shunyunbaoerp/client.py create mode 100644 erp-connector/src/shunyunbaoerp/constants.py create mode 100644 erp-connector/src/shunyunbaoerp/errors.py create mode 100644 erp-connector/src/shunyunbaoerp/normalizer.py create mode 100644 erp-connector/tests/test_api.py create mode 100644 erp-connector/tests/test_client.py create mode 100644 erp-connector/tests/test_normalizer.py create mode 100644 scripts/start-erp-connector.bat diff --git a/docs/current-state.md b/docs/current-state.md index ddc05ea..09cc1a4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 + 完整交互验收。 ## 当前可运行内容 diff --git a/docs/tasks/T-221.md b/docs/tasks/T-221.md index 1f077e0..dc32d58 100644 --- a/docs/tasks/T-221.md +++ b/docs/tasks/T-221.md @@ -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。 diff --git a/docs/tasks/T-222.md b/docs/tasks/T-222.md index 7e918ff..333e3f0 100644 --- a/docs/tasks/T-222.md +++ b/docs/tasks/T-222.md @@ -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 完成后领取。 diff --git a/erp-connector/README.md b/erp-connector/README.md new file mode 100644 index 0000000..b1430c1 --- /dev/null +++ b/erp-connector/README.md @@ -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 或抓取未知主机。 diff --git a/erp-connector/pyproject.toml b/erp-connector/pyproject.toml new file mode 100644 index 0000000..6a741a8 --- /dev/null +++ b/erp-connector/pyproject.toml @@ -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" diff --git a/erp-connector/src/shunyunbaoerp/__init__.py b/erp-connector/src/shunyunbaoerp/__init__.py new file mode 100644 index 0000000..b1e9523 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/__init__.py @@ -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" diff --git a/erp-connector/src/shunyunbaoerp/api.py b/erp-connector/src/shunyunbaoerp/api.py new file mode 100644 index 0000000..65627fc --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/api.py @@ -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 diff --git a/erp-connector/src/shunyunbaoerp/cli.py b/erp-connector/src/shunyunbaoerp/cli.py new file mode 100644 index 0000000..48a4214 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/cli.py @@ -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()) diff --git a/erp-connector/src/shunyunbaoerp/client.py b/erp-connector/src/shunyunbaoerp/client.py new file mode 100644 index 0000000..38ca1a5 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/client.py @@ -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) diff --git a/erp-connector/src/shunyunbaoerp/constants.py b/erp-connector/src/shunyunbaoerp/constants.py new file mode 100644 index 0000000..008f741 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/constants.py @@ -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, + } diff --git a/erp-connector/src/shunyunbaoerp/errors.py b/erp-connector/src/shunyunbaoerp/errors.py new file mode 100644 index 0000000..fa5e7d8 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/errors.py @@ -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): + """未查到对应货运记录。""" diff --git a/erp-connector/src/shunyunbaoerp/normalizer.py b/erp-connector/src/shunyunbaoerp/normalizer.py new file mode 100644 index 0000000..513e235 --- /dev/null +++ b/erp-connector/src/shunyunbaoerp/normalizer.py @@ -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) diff --git a/erp-connector/tests/test_api.py b/erp-connector/tests/test_api.py new file mode 100644 index 0000000..32a314b --- /dev/null +++ b/erp-connector/tests/test_api.py @@ -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 diff --git a/erp-connector/tests/test_client.py b/erp-connector/tests/test_client.py new file mode 100644 index 0000000..175b95c --- /dev/null +++ b/erp-connector/tests/test_client.py @@ -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") diff --git a/erp-connector/tests/test_normalizer.py b/erp-connector/tests/test_normalizer.py new file mode 100644 index 0000000..8ed3dff --- /dev/null +++ b/erp-connector/tests/test_normalizer.py @@ -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) diff --git a/scripts/start-erp-connector.bat b/scripts/start-erp-connector.bat new file mode 100644 index 0000000..a7f03f3 --- /dev/null +++ b/scripts/start-erp-connector.bat @@ -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%