""" 主账 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 get_ticket(self, *, work_order_no: str) -> dict[str, Any] | None: """用工单号读快照。给群入站在按房间反查时用。找不到返回 None。""" out = self.get_for_agent(work_order_no=work_order_no, sender_id="") if not out or (not out.get("found") and not out.get("work_order_no") and not out.get("ok")): return None if out.get("found") is False and out.get("ok") is False: return None 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, remark: str = "", lost_reason: str = "", next_followup_at: str = "", deal_notify_summary: str = "", ) -> dict[str, Any]: body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status} if remark: body["remark"] = remark if lost_reason: body["lostReason"] = lost_reason if next_followup_at: body["nextFollowupAt"] = next_followup_at if deal_notify_summary: body["dealNotifySummary"] = deal_notify_summary out = self._post("/inquiry/agent/ticket/transition", body) 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 patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]: """未成交后记下原因。不改六态。""" out = self._post( "/inquiry/agent/ticket/patchLostReason", {"workOrderNo": work_order_no, "lostReason": lost_reason}, ) return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"} def claim_due_negotiate(self) -> list[dict[str, Any]]: """认领到期协商再问,主账会清空再问时刻防重发。""" out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {}) rows = out.get("tickets") or out.get("result") or [] if isinstance(rows, list): return [dict(x) for x in rows if isinstance(x, dict)] return [] 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 match_land_staff(self, *, route_category: str) -> list[dict[str, Any]]: """主账按线路类别返回启用的陆运产品岗。""" out = self._post( "/inquiry/agent/staff/matchLand", {"routeCategory": route_category, "线路类别": route_category}, ) 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 list_staff_by_role(self, *, role_code: str) -> list[dict[str, Any]]: """按岗位列出启用员工。空运群 @ 航线。""" out = self._post("/inquiry/agent/staff/listByRole", {"roleCode": role_code}) 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 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, product_ids: 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 [], "productIds": product_ids 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 activate_air_group(self, *, chat_id: str, work_order_no: str) -> dict[str, Any]: """空运群激活/切换当前工单。一单一群。""" return self._post( "/inquiry/agent/ticket/activateAirGroup", {"chatId": chat_id, "workOrderNo": work_order_no}, idem=f"air-activate:{chat_id}:{work_order_no}", ) def lock_cabin( self, *, work_order_no: str, option_no: str, sender_id: str = "", operator_name: str = "", instruction_text: str = "", ) -> dict[str, Any]: return self._post( "/inquiry/agent/tms/lockCabin", { "workOrderNo": work_order_no, "optionNo": option_no, "operatorUserId": sender_id, "operatorName": operator_name, "instructionText": instruction_text, }, idem=f"air-lock:{work_order_no}:{option_no}", ) def release_cabin( self, *, work_order_no: str, lock_id: str, sender_id: str = "", operator_name: str = "", instruction_text: str = "", ) -> dict[str, Any]: return self._post( "/inquiry/agent/tms/releaseCabin", { "workOrderNo": work_order_no, "lockId": lock_id, "operatorUserId": sender_id, "operatorName": operator_name, "instructionText": instruction_text, }, idem=f"air-release:{work_order_no}:{lock_id}", ) 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 def end_collab_group(self, *, work_order_no: str) -> dict[str, Any]: """协同结束,这单不能再拉群。不改六态。""" return self._post( "/inquiry/agent/ticket/endCollabGroup", {"workOrderNo": work_order_no}, idem=f"end-collab:{work_order_no}", ) def attach_file( self, *, work_order_no: str, file_name: str, object_key: str = "", content_type: str = "", sender_id: str = "", ) -> dict[str, Any]: """群附件元数据写入主账。""" return self._post( "/inquiry/agent/ticket/attachFile", { "workOrderNo": work_order_no, "fileName": file_name, "objectKey": object_key or file_name, "contentType": content_type, "senderId": sender_id, }, ) def upsert_product_quote( self, *, work_order_no: str, quote: dict[str, Any], to_status: str = "已报价", ) -> dict[str, Any]: """产品报价写入主账并迁已报价。""" return self._post( "/inquiry/agent/ticket/upsertProductQuote", { "workOrderNo": work_order_no, "quote": quote, "toStatus": to_status, }, idem=f"product-quote:{work_order_no}:{sorted((quote or {}).keys())}", )