查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。 Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
9.2 KiB
Python
278 lines
9.2 KiB
Python
"""
|
||
主账内存假账本(单测 / 无 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,
|
||
}
|