""" 主账内存假账本(单测 / 无 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 collab_chat_id: str = "" collab_facts: dict[str, str] = field(default_factory=dict) collab_member_ids: list[str] = field(default_factory=list) product_ids: list[str] = field(default_factory=list) product_names: list[str] = field(default_factory=list) collab_ended: bool = False attachments: list[dict[str, Any]] = field(default_factory=list) quote_file_ready: bool = False lost_reason: str = "" next_followup_at: str = "" deal_notify_summary: str = "" 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] = {} self._sea_staff: list[dict[str, Any]] = [] self._land_staff: list[dict[str, Any]] = [] self._staff_by_wecom: dict[str, dict[str, Any]] = {} self._staff_by_name: dict[str, 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], business_line: str = "", ) -> dict[str, Any]: _ = business_line 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, remark: str = "", lost_reason: str = "", next_followup_at: str = "", deal_notify_summary: str = "", ) -> dict[str, Any]: _ = remark 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 if to_status == "未成交": ticket.next_followup_at = "" if lost_reason: ticket.lost_reason = lost_reason elif to_status == "已成交": ticket.next_followup_at = "" if deal_notify_summary: ticket.deal_notify_summary = deal_notify_summary elif to_status == "协商中": ticket.next_followup_at = next_followup_at return {"ok": True, "status": ticket.status} def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]: reason = (lost_reason or "").strip() with self._lock: ticket = self._tickets.get(work_order_no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} if ticket.status != "未成交": return {"ok": False, "error": "not_lost", "status": ticket.status} if not reason: return {"ok": False, "error": "reason_blank"} ticket.lost_reason = reason return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason} def claim_due_negotiate(self) -> list[dict[str, Any]]: now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S") hits: list[dict[str, Any]] = [] with self._lock: for ticket in self._tickets.values(): due = (ticket.next_followup_at or "").strip() if ticket.status != "协商中" or not due or due > now: continue ticket.next_followup_at = "" hits.append( { "work_order_no": ticket.work_order_no, "sales_wecom_id": ticket.sales_wecom_id, "sales_name": "", "business_line": ticket.business_line, "collab_chat_id": ticket.collab_chat_id, "status": ticket.status, "facts": dict(ticket.facts), "quote": dict(ticket.quote), } ) return hits 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, "facts": dict(facts or {})} 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, "facts": dict(facts or {}), } with self._lock: ticket = self._tickets.get((work_order_no or "").strip()) if ticket is not None: ticket.quote_file_ready = True 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 == "未成交": wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done" elif status == "协商中": wait_phase = "wait_deal" elif status == "已报价" and quote: # 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡 if ticket.quote_file_ready or quote.get("quoteFileIssued"): wait_phase = "wait_deal" elif (ticket.business_line or "").upper() == "SEA": wait_phase = "wait_collab" else: 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, "collab_chat_id": ticket.collab_chat_id, "collab_facts": dict(ticket.collab_facts), "lost_reason": ticket.lost_reason, "lostReason": ticket.lost_reason, "deal_notify_summary": ticket.deal_notify_summary, "next_followup_at": ticket.next_followup_at, } def upsert_staff(self, row: dict[str, Any]) -> None: """单测注入任意岗位(含会话存档账号「询价机器人」)。""" data = dict(row) uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip() name = str(data.get("name") or "").strip() with self._lock: if uid: self._staff_by_wecom[uid] = data if name: self._staff_by_name[name] = data def set_sea_staff(self, rows: list[dict[str, Any]]) -> None: """单测注入海运产品岗。""" with self._lock: self._sea_staff = [dict(x) for x in rows] for row in self._sea_staff: uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() name = str(row.get("name") or "").strip() if uid: self._staff_by_wecom[uid] = dict(row) if name: self._staff_by_name[name] = dict(row) def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]: """ 岗位=产品·海运、启用:写了「海运·全部线路」必拉; 写了具体港口的,对得上本单起运港/目的港才拉。 """ origin = (origin or "").strip() dest = (destination or "").strip() hits: list[dict[str, Any]] = [] with self._lock: for row in self._sea_staff: if str(row.get("roleCode") or row.get("role_code") or "") != "sea": continue if str(row.get("status") or "active") != "active": continue routes = str(row.get("routes") or "") if "海运·全部线路" in routes or "海运全线" in routes: hits.append(dict(row)) continue if origin and origin in routes: hits.append(dict(row)) continue if dest and dest in routes: hits.append(dict(row)) return hits def set_land_staff(self, rows: list[dict[str, Any]]) -> None: """单测注入陆运产品岗。""" with self._lock: self._land_staff = [dict(x) for x in rows] for row in self._land_staff: uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip() name = str(row.get("name") or "").strip() if uid: self._staff_by_wecom[uid] = dict(row) if name: self._staff_by_name[name] = dict(row) def match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]: """ 岗位=产品·陆运、启用:负责线路整词含本单线路类别才拉。 不按斜杠切开「中港/中亚/中欧」。 """ want = (route_category or "").strip() hits: list[dict[str, Any]] = [] if not want: return hits with self._lock: for row in self._land_staff: if str(row.get("roleCode") or row.get("role_code") or "") != "land": continue if str(row.get("status") or "active") != "active": continue tokens = [ p.strip() for p in str(row.get("routes") or "").replace(",", ",").replace("、", ",").split(",") if p.strip() ] if want in tokens: hits.append(dict(row)) return hits def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: """按企微 userid 取员工展示名。""" with self._lock: return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {}) def list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]: """按岗位列出启用员工。空运群 @ 航线用。""" want = (role_code or "").strip().lower() hits: list[dict[str, Any]] = [] with self._lock: for row in self._staff_by_wecom.values(): code = str(row.get("roleCode") or row.get("role_code") or "").strip().lower() status = str(row.get("status") or "active").strip().lower() if code == want and status not in {"disabled", "0", "停用"}: hits.append(dict(row)) return hits def find_staff_by_name(self, *, name: str) -> dict[str, Any]: """按员工姓名取企微 userid。""" with self._lock: return dict(self._staff_by_name.get((name or "").strip()) or {}) def bind_collab_group( self, *, work_order_no: str, chat_id: str, member_ids: list[str], product_names: list[str] | None = None, product_ids: list[str] | None = None, ) -> 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.collab_chat_id = (chat_id or "").strip() ticket.collab_member_ids = [str(x) for x in member_ids] ticket.product_ids = [str(x) for x in (product_ids or [])] ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()] return {"ok": True, "chat_id": ticket.collab_chat_id} def end_collab_group(self, *, work_order_no: 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.collab_ended = True ticket.collab_facts["__ended"] = "1" return {"ok": True, "collab_ended": True} def attach_file( self, *, work_order_no: str, file_name: str, object_key: str = "", content_type: str = "", sender_id: 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"} row = { "file_name": file_name, "object_key": object_key or file_name, "content_type": content_type, "sender_id": sender_id, } ticket.attachments.append(row) return {"ok": True, "file_name": file_name} def upsert_product_quote( self, *, work_order_no: str, quote: dict[str, Any], to_status: str = "已报价", ) -> dict[str, Any]: """产品报价盖 TMS 价。""" return self.upsert_quote( work_order_no=work_order_no, quote=quote, to_status=to_status ) def patch_collab_facts( self, *, work_order_no: str, facts: dict[str, str], sender_id: str = "", ) -> dict[str, Any]: """合并协同字段。不改六态、不写报价版本。""" _ = sender_id with self._lock: ticket = self._tickets.get(work_order_no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} for key, raw in (facts or {}).items(): name = str(key or "").strip() if not name: continue val = str(raw or "").strip() if val: ticket.collab_facts[name] = val else: ticket.collab_facts.pop(name, None) return {"ok": True, "facts": dict(ticket.collab_facts)} def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None: """用工微群 chat_id 找回工单。""" raw = (chat_id or "").strip() if not raw: return None with self._lock: for ticket in self._tickets.values(): if ticket.collab_chat_id == raw: return ticket return None def activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]: """ 空运群当前工单:一单一群,未结案才能激活。 不改六态。 """ no = (work_order_no or "").strip() room = (chat_id or "").strip() with self._lock: ticket = self._tickets.get(no) if ticket is None: return {"ok": False, "error": "ticket_not_found"} if (ticket.business_line or "").upper() != "AIR": return {"ok": False, "error": "not_air", "status": ticket.status} if ticket.status in {"已成交", "未成交", "已关闭"}: return {"ok": False, "error": "terminal", "status": ticket.status} if ticket.collab_chat_id and ticket.collab_chat_id != room: return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id} for other in self._tickets.values(): if other.work_order_no != no and other.collab_chat_id == room: if (other.business_line or "").upper() == "AIR": other.collab_chat_id = "" ticket.collab_chat_id = room return { "ok": True, "work_order_no": no, "chat_id": room, "status": ticket.status, "business_line": ticket.business_line, } def lock_cabin( self, *, work_order_no: str, option_no: str, sender_id: str = "", operator_name: str = "", instruction_text: str = "", ) -> dict[str, Any]: """单测可注入 _cabin_lock_result;默认成功。""" _ = sender_id, operator_name, instruction_text forced = getattr(self, "_cabin_lock_result", None) if isinstance(forced, dict): return dict(forced) no = (work_order_no or "").strip() ticket = self.get_ticket(work_order_no=no) quote = dict(ticket.quote or {}) if ticket else {} return { "ok": True, "status": "LOCKED", "lockId": f"CA-{no}-LOCK", "quoteId": str(quote.get("quoteId") or f"QT-{no}"), "optionNo": option_no, "tmsRequestId": f"TMS-LOCK-{no}", } def release_cabin( self, *, work_order_no: str, lock_id: str, sender_id: str = "", operator_name: str = "", instruction_text: str = "", ) -> dict[str, Any]: _ = lock_id, sender_id, operator_name, instruction_text forced = getattr(self, "_cabin_release_result", None) if isinstance(forced, dict): return dict(forced) no = (work_order_no or "").strip() return { "ok": True, "status": "RELEASED", "releaseId": f"CA-{no}-RLSE", "tmsRequestId": f"TMS-RLSE-{no}", }