落地私聊空运询价与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
+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"}