feat: OCR 面单 enrichment flow (api/ocr/ + flows/sds/label_ocr.py + db OCR 方法)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""OCR 面单识别服务客户端(无鉴权内部服务)。
|
||||
|
||||
Inference API: http://192.168.124.92:9801
|
||||
"""
|
||||
|
||||
from api.ocr.client import OcrClient
|
||||
from api.ocr.models import OcrResult, SUPPORTED_COUNTRIES, country_to_ocr_code
|
||||
|
||||
__all__ = ["OcrClient", "OcrResult", "SUPPORTED_COUNTRIES", "country_to_ocr_code"]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""OCR 面单识别 Inference API 客户端(httpx,无鉴权)。
|
||||
|
||||
端点: POST /recognize_url JSON {url, country}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from api.ocr.models import OcrResult
|
||||
|
||||
DEFAULT_BASE_URL = "http://192.168.124.92:9801"
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class OcrClient:
|
||||
"""OCR 推理服务客户端。"""
|
||||
|
||||
def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT) -> None:
|
||||
self._http = httpx.Client(base_url=base_url, timeout=timeout)
|
||||
|
||||
def recognize_url(self, url: str, country: str = "US") -> OcrResult:
|
||||
"""通过 PDF URL 识别面单,返回 OcrResult。"""
|
||||
resp = self._http.post("/recognize_url", json={"url": url, "country": country})
|
||||
resp.raise_for_status()
|
||||
return OcrResult.model_validate(resp.json())
|
||||
|
||||
def health(self) -> bool:
|
||||
"""健康检查。"""
|
||||
try:
|
||||
resp = self._http.get("/health")
|
||||
return resp.status_code == 200
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "OcrClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""OCR 面单识别响应模型 + 国家代码映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class OcrResult(BaseModel):
|
||||
"""单张面单 OCR 识别结果。"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
file: str | None = None
|
||||
layout: str | None = None
|
||||
courier: str | None = None
|
||||
courier_name: str | None = None
|
||||
country: str | None = None
|
||||
error: str | None = None
|
||||
tracking: str | None = None
|
||||
s_name: str | None = None
|
||||
s_street: str | None = None
|
||||
s_city: str | None = None
|
||||
s_state: str | None = None
|
||||
s_zip: str | None = None
|
||||
r_name: str | None = None
|
||||
r_street: str | None = None
|
||||
r_city: str | None = None
|
||||
r_state: str | None = None
|
||||
r_zip: str | None = None
|
||||
marker: str | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.error
|
||||
|
||||
|
||||
# OCR 服务已有分类器模型的国家(版面见 api.md §4)
|
||||
SUPPORTED_COUNTRIES = {"US", "UK", "DE", "MX", "PL"}
|
||||
|
||||
# ISO 国家代码 / 中文国名 → OCR 国家代码
|
||||
_COUNTRY_MAP: dict[str, str] = {
|
||||
# 代码
|
||||
"GB": "UK", "UK": "UK", "US": "US", "DE": "DE", "MX": "MX", "PL": "PL",
|
||||
# 中文名(SDS countryName 可能用)
|
||||
"英国": "UK", "美国": "US", "德国": "DE", "墨西哥": "MX", "波兰": "PL",
|
||||
}
|
||||
|
||||
|
||||
def country_to_ocr_code(country: Any) -> str | None:
|
||||
"""将 ISO 代码或中文国名转为 OCR 服务支持的国家代码,不支持则返回 None。"""
|
||||
if not country or not isinstance(country, str):
|
||||
return None
|
||||
code = _COUNTRY_MAP.get(country.strip().upper()) or _COUNTRY_MAP.get(country.strip())
|
||||
return code if code in SUPPORTED_COUNTRIES else None
|
||||
@@ -0,0 +1,98 @@
|
||||
"""SDS 面单 OCR enrichment:对已发货的 SDS 订单,用面单 PDF URL 调 OCR 服务回填标签字段。
|
||||
|
||||
对应 sds-label-ocr。独立于同步 flow,每 4 小时跑一批。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from prefect import flow, task
|
||||
|
||||
from api.ocr import OcrClient, country_to_ocr_code
|
||||
from flows.shared import SyncDB, build_stats_rows, emit_sync_summary
|
||||
|
||||
OCR_CONCURRENCY = 5
|
||||
SHIPPED_STATUSES = ["已完成", "部分发货", "备货中", "待确认"]
|
||||
|
||||
_OCR_FIELD_MAP = [
|
||||
("layout", "ocr_layout"), ("courier", "ocr_courier"), ("tracking", "ocr_tracking"),
|
||||
("marker", "ocr_marker"), ("s_name", "ocr_s_name"), ("s_street", "ocr_s_street"),
|
||||
("s_city", "ocr_s_city"), ("s_state", "ocr_s_state"), ("s_zip", "ocr_s_zip"),
|
||||
("r_name", "ocr_r_name"), ("r_street", "ocr_r_street"), ("r_city", "ocr_r_city"),
|
||||
("r_state", "ocr_r_state"), ("r_zip", "ocr_r_zip"),
|
||||
]
|
||||
|
||||
|
||||
def _process_one(db: SyncDB, ocr: OcrClient, order_no: str, created_at, pdf_url: str, dest_country: str | None) -> str:
|
||||
country_code = country_to_ocr_code(dest_country)
|
||||
if not country_code:
|
||||
db.update_ocr_fields(order_no, created_at, {"ocr_layout": "UNSUPPORTED"})
|
||||
return "unsupported"
|
||||
try:
|
||||
r = ocr.recognize_url(pdf_url, country_code)
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[OCR] {order_no} 请求失败: {str(err)[:100]}")
|
||||
db.update_ocr_fields(order_no, created_at, {"ocr_layout": "ERROR"})
|
||||
return "error"
|
||||
if not r.ok:
|
||||
print(f"[OCR] {order_no} 识别失败: {(r.error or '')[:80]}")
|
||||
db.update_ocr_fields(order_no, created_at, {"ocr_layout": "ERROR"})
|
||||
return "error"
|
||||
fields = {col: getattr(r, attr) or "" for attr, col in _OCR_FIELD_MAP}
|
||||
db.update_ocr_fields(order_no, created_at, fields)
|
||||
return "success"
|
||||
|
||||
|
||||
@task
|
||||
def process_batch(pending: list) -> dict[str, int]:
|
||||
db = SyncDB()
|
||||
ocr = OcrClient()
|
||||
stats = {"success": 0, "error": 0, "unsupported": 0}
|
||||
try:
|
||||
done = 0
|
||||
total = len(pending)
|
||||
with ThreadPoolExecutor(max_workers=min(OCR_CONCURRENCY, total)) as pool:
|
||||
futures = {
|
||||
pool.submit(_process_one, db, ocr, no, ca, url, dc): no
|
||||
for no, ca, url, dc in pending
|
||||
}
|
||||
for fut in as_completed(futures):
|
||||
result = fut.result()
|
||||
stats[result] += 1
|
||||
done += 1
|
||||
if done % 50 == 0 or done == total:
|
||||
print(f"[OCR] 进度: {done}/{total} 成功 {stats['success']} / 失败 {stats['error']} / 不支持 {stats['unsupported']}")
|
||||
stats["processed"] = total
|
||||
return stats
|
||||
finally:
|
||||
ocr.close()
|
||||
|
||||
|
||||
@flow(name="sds-label-ocr", log_prints=True)
|
||||
def sds_label_ocr(batch_size: int = 200) -> dict:
|
||||
db = SyncDB()
|
||||
before = db.count_ocr_status()
|
||||
print(f"OCR 状态(开始): {before}")
|
||||
|
||||
pending = db.get_ocr_pending(batch_size)
|
||||
print(f"待 OCR: {len(pending)} 单 (batch_size={batch_size})")
|
||||
if not pending:
|
||||
emit_sync_summary("SDS 面单 OCR", build_stats_rows("SDS", {}, [
|
||||
("待处理", 0), ("累计已完成", before.get("done", 0)), ("累计失败", before.get("error", 0)),
|
||||
]))
|
||||
return {"processed": 0, **before}
|
||||
|
||||
stats = process_batch(pending)
|
||||
after = db.count_ocr_status()
|
||||
print(f"完成: {stats} 累计: {after}")
|
||||
emit_sync_summary("SDS 面单 OCR", build_stats_rows("SDS", {}, [
|
||||
("本批处理", stats["processed"]), ("本批成功", stats["success"]),
|
||||
("本批失败", stats["error"]), ("本批不支持", stats["unsupported"]),
|
||||
("累计已完成", after.get("done", 0)), ("累计待处理", after.get("pending", 0)),
|
||||
]))
|
||||
return {**stats, **after}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sds_label_ocr.serve(name="sds-label-ocr", cron="0 */4 * * *")
|
||||
@@ -98,6 +98,59 @@ class SyncDB:
|
||||
rows = cur.fetchall()
|
||||
return [(r[0], r[1]) for r in rows]
|
||||
|
||||
# ---------- OCR 面单 enrichment ----------
|
||||
|
||||
def get_ocr_pending(self, limit: int = 200) -> list[tuple[str, datetime, str, str | None]]:
|
||||
"""查待 OCR 的 SDS 订单:(orderNo, createdAt, labelPdfUrl, destinationCountry)。
|
||||
|
||||
条件:SDS、有 labelPdfUrl、ocr_layout 未处理(Null)、已发货/已完成。
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'''SELECT "orderNo","createdAt","labelPdfUrl","destinationCountry"
|
||||
FROM "Orders"
|
||||
WHERE "systemName"=%s AND "labelPdfUrl" IS NOT NULL
|
||||
AND "ocr_layout" IS NULL
|
||||
AND "systemStatus"=ANY(%s)
|
||||
ORDER BY "orderedAt" DESC LIMIT %s''',
|
||||
("SDS", ["已完成", "部分发货", "备货中", "待确认"], limit),
|
||||
)
|
||||
return [(r[0], r[1], r[2], r[3]) for r in cur.fetchall()]
|
||||
|
||||
def update_ocr_fields(
|
||||
self, order_no: str, created_at: datetime, fields: dict[str, str | None]
|
||||
) -> None:
|
||||
"""更新单个订单的 ocr_* 字段。fields 为 {ocr_xxx: value} 字典。"""
|
||||
if not fields:
|
||||
return
|
||||
assignments = ", ".join(f'"{k}"=%s' for k in fields)
|
||||
params = list(fields.values()) + [order_no, created_at]
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f'''UPDATE "Orders" SET {assignments}
|
||||
WHERE "orderNo"=%s AND "createdAt"=%s''',
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def count_ocr_status(self) -> dict[str, int]:
|
||||
"""统计 OCR 处理状态(done/error/unsupported/pending)。"""
|
||||
with self._conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
'''SELECT
|
||||
COUNT(*) FILTER (WHERE "ocr_layout" IS NOT NULL AND "ocr_layout" NOT IN ('ERROR','UNSUPPORTED')) AS done,
|
||||
COUNT(*) FILTER (WHERE "ocr_layout"='ERROR') AS error,
|
||||
COUNT(*) FILTER (WHERE "ocr_layout"='UNSUPPORTED') AS unsupported,
|
||||
COUNT(*) FILTER (WHERE "ocr_layout" IS NULL AND "labelPdfUrl" IS NOT NULL) AS pending
|
||||
FROM "Orders" WHERE "systemName"=%s''',
|
||||
("SDS",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return {"done": row[0], "error": row[1], "unsupported": row[2], "pending": row[3]} if row else {}
|
||||
|
||||
# ---------- 主 upsert ----------
|
||||
|
||||
def sync_orders_and_goods(
|
||||
|
||||
@@ -80,3 +80,10 @@ deployments:
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
- name: sds-label-ocr
|
||||
entrypoint: flows/sds/label_ocr.py:sds_label_ocr
|
||||
schedules:
|
||||
- cron: 0 */4 * * *
|
||||
timezone: Asia/Shanghai
|
||||
work_pool:
|
||||
name: inkreach-pool
|
||||
|
||||
Reference in New Issue
Block a user