落地私聊空运询价与TMS报价回填,按后台模板出单。
查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user