feat(t224): add incremental freight sync
This commit is contained in:
+14
-3
@@ -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 会话过期后需重新执行前两步。
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user