落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+13 -4
View File
@@ -47,17 +47,24 @@ class LedgerClient:
def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient":
"""从环境组装客户端。"""
cfg = settings or get_settings()
token = (
getattr(cfg, "biz_api_token", None)
or getattr(cfg, "agent_callback_token", None)
or getattr(cfg, "inquiry_agent_callback_token", None)
or ""
)
return cls(
base_url=(cfg.biz_api_base_url or "").rstrip("/"),
timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10),
service_token=(getattr(cfg, "biz_api_token", None) or ""),
service_token=str(token),
)
def _http(self) -> httpx.Client:
if self._client is None:
headers = {"Accept": "application/json"}
if self.service_token:
headers["X-Access-Token"] = self.service_token
# 回调令牌不是 Jeecg 登录 JWT。写入 X-Access-Token 会被 JwtFilter 验签失败并 401。
headers["X-Agent-Token"] = self.service_token
self._client = httpx.Client(
base_url=self.base_url,
timeout=self.timeout_seconds,
@@ -123,7 +130,9 @@ class LedgerClient:
"""
headers: dict[str, str] = {}
if idempotency_key:
headers["X-Idempotency-Key"] = idempotency_key
from agent.schema.fingerprint import ascii_idempotency_key
headers["X-Idempotency-Key"] = ascii_idempotency_key(idempotency_key)
try:
resp = self._http().request(
method.upper(),
@@ -145,7 +154,7 @@ class LedgerClient:
path,
)
return LedgerResponse(ok=ok, status_code=resp.status_code, data=data)
except httpx.HTTPError as exc:
except (httpx.HTTPError, UnicodeEncodeError, ValueError) as exc:
logger.warning("ledger.request 异常 path=%s err=%s", path, exc)
return LedgerResponse(ok=False, status_code=0, error=str(exc))
+184
View File
@@ -0,0 +1,184 @@
"""
主账 HTTP 账本:建单 / 查价 / 报价 / 六态只打 8180。
本文件职责:把 MemoryLedger 同名方法接到 /inquiry/agent/**;查价走真 TMS(主账出站)。
禁止:直连 TMS / MySQL;把 Jeecg 包装失败当成有价。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.ledger import LedgerClient
logger = logging.getLogger(__name__)
def _unwrap(data: Any) -> dict[str, Any]:
"""解开 Jeecg Result:{success, result} → result。"""
if not isinstance(data, dict):
return {}
if "result" in data and isinstance(data.get("result"), dict):
inner = data["result"]
if data.get("success") is False:
return {"ok": False, "error": str(data.get("message") or "ledger_error"), **inner}
return inner
return data
class HttpLedger:
"""
生产主账客户端。
超时随 LedgerClient;查价在主账线程打 TMS,本类只转发分类。
"""
def __init__(self, client: LedgerClient | None = None) -> None:
self._client = client or LedgerClient.from_settings()
def _post(self, path: str, body: dict[str, Any], *, idem: str = "") -> dict[str, Any]:
resp = self._client.request_json("POST", path, json_body=body, idempotency_key=idem)
if not resp.ok:
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, resp.error)
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
return _unwrap(resp.data)
def find_similar_open(
self,
*,
facts: dict[str, str],
business_line: str,
exclude_work_order_no: str = "",
) -> dict[str, Any]:
"""全库相同需求检索;exclude_work_order_no 排除刚建的本单。"""
out = self._post(
"/inquiry/agent/ticket/findSimilar",
{
"salesWecomId": "",
"businessLine": business_line,
"facts": facts,
"excludeWorkOrderNo": exclude_work_order_no,
},
)
if "found" not in out:
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
return out
def create_ticket(
self,
*,
sender_id: str,
business_line: str,
facts: dict[str, str],
idempotency_key: str = "",
) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/create",
{
"salesWecomId": sender_id,
"businessLine": business_line,
"facts": facts,
"idempotencyKey": idempotency_key,
},
idem=idempotency_key,
)
if not out.get("ok") and not out.get("work_order_no"):
return {"ok": False, "created": False, "work_order_no": "", "status": "", "first_or_same": "first"}
out.setdefault("ok", True)
if out.get("similar_work_order_no"):
out.setdefault("first_or_same", "same")
else:
out.setdefault("first_or_same", "first")
return out
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
"""读主账工单快照,供续办回到当时节点。"""
out = self._post(
"/inquiry/agent/ticket/get",
{"work_order_no": work_order_no, "salesWecomId": sender_id},
)
if not out.get("found") and not out.get("ok"):
return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no}
return out
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/tms/query",
{"work_order_no": work_order_no, "facts": facts},
idem=f"tms:{work_order_no}",
)
if "classification" not in out and not out.get("ok"):
return {
"ok": False,
"classification": "TMS_TECH_FAILURE",
"has_price": False,
"quote": {},
}
out.setdefault("quote", {})
return out
def upsert_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
# 有价时主账 query 已写快照并迁「已报价」;此处只做幂等确认,避免二次假写入。
_ = (work_order_no, quote, to_status)
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
def render_quote(
self,
*,
work_order_no: str,
business_line: str,
facts: dict[str, Any],
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
) -> dict[str, Any]:
"""
打主账按后台报价模板填 Excel。
失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。
"""
from agent.quote_templates.match import FAIL_MESSAGE
# 填表+回文件可能超过默认 10s,单独放宽,仍禁止无限等。
prev_timeout = self._client.timeout_seconds
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
self._client.close()
try:
out = self._post(
"/inquiry/agent/quote/render",
{
"workOrderNo": work_order_no,
"businessLine": business_line,
"facts": facts,
"quote": quote or {},
"salesWecomId": sales_wecom_id,
},
idem=f"quote-render:{work_order_no}:{format}",
)
finally:
self._client.timeout_seconds = prev_timeout
self._client.close()
if not out.get("ok"):
return {
"ok": False,
"error": out.get("error") or "NO_TEMPLATE",
"message": out.get("message") or FAIL_MESSAGE,
}
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/transition",
{"workOrderNo": work_order_no, "toStatus": to_status},
)
if not out.get("ok") and out.get("status"):
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
+277
View File
@@ -0,0 +1,277 @@
"""
主账内存假账本(单测 / 无 8180 时)。
本文件职责:实现建单(每次新开)、相似检索、TMS fixture、报价写入、六态流转,语义对齐 Java 主账。
禁止:冒充已打真实 TMS;禁止被生产 Worker 当默认 Owner(生产必须 HTTP 主账)。
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from zoneinfo import ZoneInfo
from agent.schema.fingerprint import inquiry_fingerprint
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
@dataclass
class MemoryTicket:
"""内存工单。"""
work_order_no: str
sales_wecom_id: str
business_line: str
facts: dict[str, str]
fingerprint: str
status: str = "询价中"
quote: dict[str, Any] = field(default_factory=dict)
quote_version: int = 0
class MemoryLedger:
"""
线程安全内存主账。
TMS fixture:facts.tms_force=1002 或目的港含「无价」→ 无价;否则有价。
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._tickets: dict[str, MemoryTicket] = {}
self._seq = 0
# 单测默认同业务有一张默认模板,避免「生成 Excel」误走失败话术
self._templates: list[dict[str, Any]] = [
{
"templateId": "mem-default-air",
"name": "空运默认",
"bizType": "空运",
"keywords": [],
"isDefault": True,
}
]
self.last_render: dict[str, Any] = {}
def _next_no(self) -> str:
self._seq += 1
day = datetime.now(_SHANGHAI).strftime("%Y%m%d")
return f"WO{day}{self._seq:04d}"
def find_similar_open(
self,
*,
facts: dict[str, str],
business_line: str,
exclude_work_order_no: str = "",
) -> dict[str, Any]:
"""
全库相同需求检索:需求指纹一模一样才算,不限六态。
多条只回最新一条;exclude_work_order_no 排除刚建的本单。
只供卡片展示,不复用当前工单号。
"""
fp = inquiry_fingerprint(facts, business_line=business_line)
skip = (exclude_work_order_no or "").strip()
latest: MemoryTicket | None = None
with self._lock:
for t in self._tickets.values():
if t.fingerprint != fp:
continue
if skip and t.work_order_no == skip:
continue
latest = t
if latest is None:
return {"found": False, "first_or_same": "first", "work_order_no": "", "status": ""}
return {
"found": True,
"first_or_same": "same",
"work_order_no": latest.work_order_no,
"status": latest.status,
}
def create_ticket(
self,
*,
sender_id: str,
business_line: str,
facts: dict[str, str],
idempotency_key: str = "",
) -> dict[str, Any]:
_ = idempotency_key
# 出询价确认必须新开一单,相同需求不复用未关闭工单
fp = inquiry_fingerprint(facts, business_line=business_line)
with self._lock:
no = self._next_no()
ticket = MemoryTicket(
work_order_no=no,
sales_wecom_id=sender_id,
business_line=business_line,
facts=dict(facts),
fingerprint=fp,
)
self._tickets[no] = ticket
similar = self.find_similar_open(
facts=facts, business_line=business_line, exclude_work_order_no=no
)
logger.info(
"memory_ledger.create %s first_or_same=%s similar=%s",
no,
similar.get("first_or_same"),
similar.get("work_order_no"),
)
return {
"ok": True,
"created": True,
"first_or_same": similar.get("first_or_same") or "first",
"found": bool(similar.get("found")),
"similar_work_order_no": similar.get("work_order_no") or "",
"work_order_no": no,
"status": "询价中",
}
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
dest = str(facts.get("目的港") or "")
force = str(facts.get("tms_force") or "")
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
return {
"ok": True,
"classification": "TMS_NO_QUOTE_1002",
"has_price": False,
"quote": {},
}
if force == "tech":
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
quote = {
"freight": "USD 1,200",
"other_fee": "USD 80",
"total": "USD 1,280",
"eta": "3-5 天",
"valid_until": "2026-09-20",
"source": "TMS",
"source_label": "TMS 标准报价",
"fee_lines": [
{"name": "空运费", "amount": "USD 1,200"},
{"name": "其他附加费", "amount": "USD 80"},
],
}
return {
"ok": True,
"classification": "HAS_PRICE",
"has_price": True,
"quote": quote,
}
def upsert_quote(
self,
*,
work_order_no: str,
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.quote_version += 1
ticket.quote = dict(quote)
if to_status:
ticket.status = to_status
return {
"ok": True,
"quote_version": ticket.quote_version,
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
return {"ok": True, "status": ticket.status}
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
def set_quote_templates(self, templates: list[dict[str, Any]]) -> None:
"""单测注入后台模板列表;空列表=未维护关键词也未维护默认。"""
with self._lock:
self._templates = list(templates)
def render_quote(
self,
*,
work_order_no: str,
business_line: str,
facts: dict[str, Any],
quote: Optional[dict[str, Any]] = None,
format: str = "xlsx",
sales_wecom_id: str = "",
) -> dict[str, Any]:
"""
按询价需求匹配内存模板。不填真实 xlsx,只回假字节供流程/单测。
"""
from agent.quote_templates.match import FAIL_MESSAGE, pick_quote_template
_ = sales_wecom_id
with self._lock:
templates = list(self._templates)
picked = pick_quote_template(
templates=templates,
business_line=business_line,
facts=facts,
quote=quote,
)
if picked is None:
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE}
self.last_render = out
return out
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
out = {
"ok": True,
"matchType": picked.match_type,
"templateId": picked.template_id,
"templateName": picked.name,
"fileName": f"{work_order_no}_quote.{ext}",
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
}
self.last_render = out
return out
def get_for_agent(self, *, work_order_no: str, sender_id: str) -> dict[str, Any]:
"""
智能体续办读取:工单是否存在、是否本人、字段/报价/推断停点。
"""
no = (work_order_no or "").strip()
ticket = self.get_ticket(work_order_no=no)
if ticket is None:
return {"ok": False, "found": False, "owned": False, "work_order_no": no}
owned = ticket.sales_wecom_id == sender_id
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
wait_phase = "done"
elif status == "已报价" and quote:
wait_phase = "wait_adopt"
else:
wait_phase = "tms_miss"
return {
"ok": True,
"found": True,
"owned": owned,
"work_order_no": ticket.work_order_no,
"status": status,
"business_line": ticket.business_line,
"facts": dict(ticket.facts),
"quote": quote,
"wait_phase": wait_phase,
}