""" 主账 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], business_line: str = "", ) -> dict[str, Any]: body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts} line = (business_line or str((facts or {}).get("业务线") or "")).strip() if line: body["businessLine"] = line out = self._post( "/inquiry/agent/tms/query", body, 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"} def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]: """主账按线路返回启用的海运产品岗。""" out = self._post( "/inquiry/agent/staff/matchSea", {"origin": origin, "destination": destination}, ) rows = out.get("staff") or out.get("result") or [] if isinstance(rows, list): return [dict(x) for x in rows if isinstance(x, dict)] return [] def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]: out = self._post("/inquiry/agent/staff/byWecom", {"wecomId": wecom_id}) if out.get("name") or out.get("wecomId"): return out return {} def find_staff_by_name(self, *, name: str) -> dict[str, Any]: """按后台员工姓名取企微 userid。会话存档账号「询价机器人」走这里。""" out = self._post("/inquiry/agent/staff/byName", {"name": name}) if out.get("name") or out.get("wecomId"): return out return {} def bind_collab_group( self, *, work_order_no: str, chat_id: str, member_ids: list[str], product_names: list[str] | None = None, ) -> dict[str, Any]: out = self._post( "/inquiry/agent/ticket/bindCollabGroup", { "workOrderNo": work_order_no, "chatId": chat_id, "memberIds": member_ids, "productNames": product_names or [], }, idem=f"bind-group:{work_order_no}", ) out.setdefault("ok", bool(out.get("chat_id") or out.get("chatId"))) return out def patch_collab_facts( self, *, work_order_no: str, facts: dict[str, str], sender_id: str = "", ) -> dict[str, Any]: return self._post( "/inquiry/agent/ticket/patchCollabFacts", { "workOrderNo": work_order_no, "facts": facts, "senderId": sender_id, }, idem=f"collab-facts:{work_order_no}:{sorted(facts)}", ) def find_by_collab_chat(self, *, chat_id: str) -> dict[str, Any] | None: """用协同群 chat_id 找回工单。对不上返回 None。""" out = self._post("/inquiry/agent/ticket/byCollabChat", {"chatId": chat_id}) if not out.get("found") and not out.get("work_order_no"): return None return out