feat(t224): add incremental freight sync

This commit is contained in:
QiuSW
2026-07-29 00:26:05 +08:00
parent 433db45100
commit c69879650e
35 changed files with 2085 additions and 156 deletions
+14 -3
View File
@@ -1,7 +1,8 @@
# 顺运宝 ERP Connector
内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号查询货运列表和详情,
再输出不含收件人、电话、地址、Cookie、JWT 或原始响应的规范化 JSON。
内部只读适配器:保持顺运宝验证码/Cookie 会话,按完整单号或最多 7 个自然日的
创建日期闭区间查询货运列表和详情,再输出不含收件人、电话、地址、Cookie、JWT
或原始响应的规范化 JSON。
协议客户端基于同一开发机上已经用本地 HAR 验证的 `shunyunbaoerp 0.1.0` 代码收敛;
本目录不包含 HAR、真实响应、账号、密码或订单号。
@@ -40,7 +41,17 @@ $env:SHUNYUNBAO_SERVICE_API_KEY = "at-least-32-utf8-bytes"
3. 调用 `POST /v1/freight/query`:
```json
{"order_number":"完整单号"}
{"mode":"ORDER_NUMBER","order_number":"完整单号"}
```
日期查询严格使用 Asia/Shanghai 的 `YYYY-MM-DD`:
```json
{
"mode":"CREATED_RANGE",
"created_from":"2026-07-22",
"created_to":"2026-07-28"
}
```
Connector 不自动 OCR 绕过验证码。服务重启或 ERP 会话过期后需重新执行前两步。
+20 -2
View File
@@ -35,7 +35,10 @@ class LoginRequest(BaseModel):
class FreightQueryRequest(BaseModel):
order_number: str = Field(min_length=1, max_length=128)
mode: str = Field(default="ORDER_NUMBER", max_length=32)
order_number: str | None = Field(default=None, min_length=1, max_length=128)
created_from: str | None = Field(default=None, min_length=10, max_length=10)
created_to: str | None = Field(default=None, min_length=10, max_length=10)
def get_client() -> ERPClient:
@@ -125,7 +128,22 @@ def query_freight(
_: APIKeyDependency,
) -> dict[str, object]:
try:
result = get_client().get_freight_details(body.order_number)
if body.mode == "ORDER_NUMBER" and body.order_number:
if body.created_from is not None or body.created_to is not None:
raise ValueError("ORDER_NUMBER 不能包含日期范围")
result = get_client().get_freight_details(body.order_number)
elif (
body.mode == "CREATED_RANGE"
and body.order_number is None
and body.created_from
and body.created_to
):
result = get_client().get_freight_details_by_created_range(
body.created_from,
body.created_to,
)
else:
raise ValueError("查询模式与参数不匹配")
return normalize_freight_result(result)
except ERPAuthenticationError as exc:
raise HTTPException(
+101 -30
View File
@@ -8,7 +8,7 @@ import random
import threading
import time
from collections.abc import Iterable, Mapping
from datetime import datetime, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any
from urllib.parse import urljoin, urlparse
@@ -20,6 +20,7 @@ from .constants import (
STOCK_DETAIL_PATH,
STOCK_LIST_PATH,
STOCK_LIST_TOTAL_PATH,
created_range_query,
order_number_query,
stock_columns,
)
@@ -202,8 +203,21 @@ class ERPClient:
"""
normalized = self._validate_order_number(order_number)
return self._query_stock(order_number_query(normalized))
def query_stock_by_created_range(
self,
created_from: date | str,
created_to: date | str,
) -> list[dict[str, Any]]:
"""按 Asia/Shanghai 自然日创建时间闭区间查询货运列表。"""
start, end = self._validate_created_range(created_from, created_to)
return self._query_stock(created_range_query(start.isoformat(), end.isoformat()))
def _query_stock(self, query: Mapping[str, Any]) -> list[dict[str, Any]]:
with self._lock:
first_payload = self._stock_payload(normalized, start=0, page_index=1)
first_payload = self._stock_payload(query, start=0, page_index=1)
total_raw = self._request_json(
"POST",
STOCK_LIST_TOTAL_PATH,
@@ -228,7 +242,7 @@ class ERPClient:
for start in range(0, total, self.page_size):
page_index = start // self.page_size + 1
payload = self._stock_payload(
normalized,
query,
start=start,
page_index=page_index,
)
@@ -297,36 +311,65 @@ class ERPClient:
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 = [
return self._join_freight_details(
stocks,
{
"stock": stock,
"detail": details_by_id.get(stock["id"]),
}
for stock in stocks
]
return {
"query": {
"mode": "ORDER_NUMBER",
"orderNumber": normalized,
"matchField": "allcode",
},
"count": len(records),
"records": records,
)
def get_freight_details_by_created_range(
self,
created_from: date | str,
created_to: date | str,
) -> dict[str, Any]:
"""查询一个最多七天的创建日期窗口并合并详情。"""
start, end = self._validate_created_range(created_from, created_to)
with self._lock:
stocks = self.query_stock_by_created_range(start, end)
return self._join_freight_details(
stocks,
{
"mode": "CREATED_RANGE",
"createdFrom": start.isoformat(),
"createdTo": end.isoformat(),
},
)
def _join_freight_details(
self,
stocks: list[dict[str, Any]],
query: Mapping[str, Any],
) -> dict[str, Any]:
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": dict(query),
"count": len(records),
"records": records,
}
def close(self) -> None:
self.session.close()
@@ -339,7 +382,7 @@ class ERPClient:
def _stock_payload(
self,
order_number: str,
query: Mapping[str, Any],
*,
start: int,
page_index: int,
@@ -352,7 +395,7 @@ class ERPClient:
"pageIndex": page_index,
"store": False,
"columns": stock_columns(),
"queries": [order_number_query(order_number)],
"queries": [dict(query)],
}
def _request_json(
@@ -455,6 +498,34 @@ class ERPClient:
raise ValueError("order_number 不能包含控制字符")
return normalized
@staticmethod
def _validate_created_range(
created_from: date | str,
created_to: date | str,
) -> tuple[date, date]:
def parse(value: date | str, field: str) -> date:
if isinstance(value, datetime):
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
if isinstance(value, date):
return value
if not isinstance(value, str):
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
try:
parsed = date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期") from exc
if parsed.isoformat() != value:
raise ValueError(f"{field} 必须是 YYYY-MM-DD 日期")
return parsed
start = parse(created_from, "created_from")
end = parse(created_to, "created_to")
if end < start:
raise ValueError("created_to 不能早于 created_from")
if end - start > timedelta(days=6):
raise ValueError("创建日期闭区间不能超过 7 天")
return start, end
@staticmethod
def _decode_jwt_claims(token: str | None) -> dict[str, Any]:
"""仅解码 JWT payload 供过期时间展示,不验证其真实性。"""
@@ -115,3 +115,17 @@ def order_number_query(order_number: str) -> dict[str, Any]:
"tableAlias": "t",
"optType": 1,
}
def created_range_query(created_from: str, created_to: str) -> dict[str, Any]:
"""生成 HAR 中“创建时间”闭区间查询条件。"""
return {
"dvalue": f"{created_from},{created_to}",
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"optType": 0,
}
+21 -1
View File
@@ -9,6 +9,7 @@ from .errors import ERPProtocolError
def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]:
normalized_query = _normalize_query(result.get("query"))
records = result.get("records")
if not isinstance(records, list):
raise ERPProtocolError("货运查询结果缺少 records 数组")
@@ -84,11 +85,30 @@ def normalize_freight_result(result: Mapping[str, Any]) -> dict[str, Any]:
)
return {
"schema_version": 1,
"query": {"mode": "ORDER_NUMBER"},
"query": normalized_query,
"orders": orders,
}
def _normalize_query(value: Any) -> dict[str, Any]:
if not isinstance(value, Mapping):
raise ERPProtocolError("货运查询结果缺少 query 对象")
mode = value.get("mode", "ORDER_NUMBER")
if mode == "ORDER_NUMBER":
return {"mode": "ORDER_NUMBER"}
if mode != "CREATED_RANGE":
raise ERPProtocolError("货运查询模式无效")
created_from = _text(value.get("createdFrom"))
created_to = _text(value.get("createdTo"))
if len(created_from) != 10 or len(created_to) != 10:
raise ERPProtocolError("货运日期范围无效")
return {
"mode": "CREATED_RANGE",
"created_from": created_from,
"created_to": created_to,
}
def _normalize_item(item: Mapping[str, Any]) -> dict[str, Any]:
title = _text(item.get("productTitle"))
if not title:
+53
View File
@@ -21,6 +21,20 @@ class FakeClient:
assert order_number == "SOURCE-12"
return raw_result()
def get_freight_details_by_created_range(
self,
created_from: str,
created_to: str,
) -> dict:
assert (created_from, created_to) == ("2026-07-22", "2026-07-28")
result = raw_result()
result["query"] = {
"mode": "CREATED_RANGE",
"createdFrom": created_from,
"createdTo": created_to,
}
return result
def test_query_requires_configured_service_key(monkeypatch) -> None:
monkeypatch.delenv("SHUNYUNBAO_SERVICE_API_KEY", raising=False)
@@ -56,3 +70,42 @@ def test_query_returns_only_normalized_fields(monkeypatch) -> None:
assert "receiverTel" not in encoded
assert "receiverAddr" not in encoded
assert "PRIVATE-QUERY" not in encoded
def test_created_range_query_returns_allowlisted_range(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
response = api.query_freight(
api.FreightQueryRequest(
mode="CREATED_RANGE",
created_from="2026-07-22",
created_to="2026-07-28",
),
None,
)
assert response["query"] == {
"mode": "CREATED_RANGE",
"created_from": "2026-07-22",
"created_to": "2026-07-28",
}
def test_created_range_rejects_mixed_mode_parameters(monkeypatch) -> None:
monkeypatch.setenv("SHUNYUNBAO_SERVICE_API_KEY", VALID_KEY)
monkeypatch.setattr(api, "get_client", lambda: FakeClient())
with pytest.raises(HTTPException) as raised:
api.query_freight(
api.FreightQueryRequest(
mode="CREATED_RANGE",
order_number="SOURCE-12",
created_from="2026-07-22",
created_to="2026-07-28",
),
None,
)
assert raised.value.status_code == 422
assert raised.value.detail == "ERP_QUERY_INVALID"
+57
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import json
from datetime import date
from urllib.parse import urlparse
import pytest
@@ -153,6 +154,62 @@ def test_query_and_detail_reproduce_har_contract() -> None:
assert session.calls[2]["json"] == {"ids": [99001122]}
def test_created_range_query_reproduces_har_contract_and_deduplicates() -> None:
stock = {"id": 99001122, "code": "FREIGHT-001"}
detail = {"id": 99001122, "details": []}
session = FakeSession(
[
FakeResponse(envelope(2)),
FakeResponse(envelope({"total": 2, "list": [stock, stock]})),
FakeResponse(envelope({"total": 1, "list": [detail]})),
]
)
client = ERPClient(session=session)
result = client.get_freight_details_by_created_range(
date(2026, 7, 22),
date(2026, 7, 28),
)
assert result["count"] == 1
assert result["query"] == {
"mode": "CREATED_RANGE",
"createdFrom": "2026-07-22",
"createdTo": "2026-07-28",
}
assert session.calls[0]["json"]["queries"] == [
{
"dvalue": "2026-07-22,2026-07-28",
"tableName": "t_stock",
"colName": "created",
"op": 0,
"type": 3,
"tableAlias": "t",
"optType": 0,
}
]
def test_created_range_rejects_more_than_seven_inclusive_days() -> None:
client = ERPClient(session=FakeSession([]))
with pytest.raises(ValueError, match="7 天"):
client.query_stock_by_created_range("2026-07-21", "2026-07-28")
def test_empty_created_range_does_not_request_details() -> None:
session = FakeSession([FakeResponse(envelope(0))])
client = ERPClient(session=session)
result = client.get_freight_details_by_created_range(
"2026-07-28",
"2026-07-28",
)
assert result["records"] == []
assert len(session.calls) == 1
def test_not_found_stops_before_detail_request() -> None:
session = FakeSession([FakeResponse(envelope(0))])
client = ERPClient(session=session)
+19
View File
@@ -110,6 +110,25 @@ def test_invalid_procurement_fields_remain_reviewable() -> None:
assert result["product_thumb_ref"] is None
def test_normalizes_created_range_without_exposing_raw_query() -> None:
source = raw_result()
source["query"] = {
"mode": "CREATED_RANGE",
"createdFrom": "2026-07-22",
"createdTo": "2026-07-28",
"private": "must-not-leak",
}
normalized = normalize_freight_result(source)
assert normalized["query"] == {
"mode": "CREATED_RANGE",
"created_from": "2026-07-22",
"created_to": "2026-07-28",
}
assert "must-not-leak" not in json.dumps(normalized)
def test_conflicting_duplicate_item_is_rejected() -> None:
source = raw_result()
duplicate = dict(source["records"][0]["detail"]["details"][0])