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