feat(t224): add incremental freight sync
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user