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
+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: