""" 主账内存假账本(单测 / 无 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, }