""" 主账 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]: """ 打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。 若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。 """ 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 # TMS 出站 + persistHit 常超过默认 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/tms/query", body, idem=f"tms:{work_order_no}", ) finally: self._client.timeout_seconds = prev_timeout self._client.close() 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]: """ 覆盖本单最新 TMS 快照。选定陆运线路后必须走这里,才能把该条成本写回工单。 空报价不打主账,避免把查价结果抹掉。 若接口暂不可用(热更丢映射常见 404)但查价时主账已落库为已报价, 则按成功续办,避免销售侧误报「写入报价失败」。 """ no = (work_order_no or "").strip() src = dict(quote or {}) if not no or not src: return {"ok": True, "quote_version": 1, "status": to_status or "已报价"} out = self._post( "/inquiry/agent/ticket/replaceTmsQuote", {"workOrderNo": no, "quote": src, "toStatus": to_status}, idem=f"tms-quote:{no}:{src.get('total') or ''}:{src.get('route_line') or ''}", ) if out.get("ok") is False: recovered = self._recover_upsert_if_already_quoted(work_order_no=no, failed=out) if recovered is not None: return recovered return out out.setdefault("ok", True) out.setdefault("status", to_status or "已报价") return out def _recover_upsert_if_already_quoted( self, *, work_order_no: str, failed: dict[str, Any], ) -> dict[str, Any] | None: """ replaceTmsQuote 失败时,读主账工单:已报价且已有 TMS 价则视为写入成功。 查价路径会先 persistHit,接口 404 时工单其实已经有价。 """ try: snap = self.get_for_agent(work_order_no=work_order_no, sender_id="") except Exception: # noqa: BLE001 — 兜底读失败仍按原失败返回 return None status = str(snap.get("status") or "") tms_hit = snap.get("tms_hit") in (1, "1", True, "true") sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip() if status == "已报价" and (tms_hit or sale): logger.warning( "upsert_quote 接口失败但工单已报价,按成功续办 wo=%s err=%s", work_order_no, failed.get("error") or failed, ) return { "ok": True, "status": "已报价", "recovered": True, "quote_version": snap.get("quote_version") or 1, } return None 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 from agent.schema.quote_adjust import stamp_prices_for_sheet # 出单前把当前费用写入线路表、海运分段、空运舱位。否则报价单仍是旧价。 quote = stamp_prices_for_sheet(dict(quote or {})) # 填表+回文件可能超过默认 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, } from agent.quote_templates.filename import filename_from_render # 主账旧版本仍回 WO_quote.xlsx 时,这里按模板名改成销售看到的名字。 out["fileName"] = filename_from_render(work_order_no, out) 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 update_facts(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]: """选定线路后写回起运港等询价事实。不改六态。""" out = self._post( "/inquiry/agent/ticket/updateFacts", {"workOrderNo": work_order_no, "facts": facts}, idem=f"update-facts:{work_order_no}:{sorted((facts or {}).items())}", ) return out if out.get("ok") else {"ok": False, "error": out.get("error") or "update_facts_failed"} def void_quote_files(self, *, work_order_no: str) -> dict[str, Any]: """采用后改选:作废旧报价单。不改六态。""" out = self._post( "/inquiry/agent/ticket/voidQuoteFiles", {"workOrderNo": work_order_no}, idem=f"void-quote:{work_order_no}", ) return out if out.get("ok") else {"ok": False, "error": out.get("error") or "void_quote_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 = "", airline: str = "", ) -> dict[str, Any]: body: dict[str, Any] = { "workOrderNo": work_order_no, "optionNo": option_no, "operatorUserId": sender_id, "operatorName": operator_name, "instructionText": instruction_text, } if (airline or "").strip(): body["airline"] = airline.strip().upper() return self._post( "/inquiry/agent/tms/lockCabin", body, 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 = "已报价", remark: str = "", kind: str = "", ) -> dict[str, Any]: """ 手工报价 / 销售调价写入主账新版本。 幂等键带合计和备注,避免连续调价被同一把钥匙吞掉。 """ src = dict(quote or {}) note = (remark or "").strip() body: dict[str, Any] = { "workOrderNo": work_order_no, "quote": src, "toStatus": to_status, } if note: body["remark"] = note if (kind or "").strip(): body["kind"] = kind.strip() sig = f"{src.get('total') or ''}|{note}|{kind or ''}|{len(src.get('fee_rows') or src.get('fee_lines') or [])}" return self._post( "/inquiry/agent/ticket/upsertProductQuote", body, idem=f"manual-quote:{work_order_no}:{sig}", ) def mark_system_exception( self, *, work_order_no: str, system_exception: str, system_exception_reason: str, step: str = "", payload: dict[str, Any] | None = None, ) -> dict[str, Any]: """写入工单系统异常两列。已关闭由主账拒绝。""" out = self._post( "/inquiry/agent/ticket/markSystemException", { "workOrderNo": work_order_no, "systemException": system_exception, "systemExceptionReason": system_exception_reason, "step": step, "payload": dict(payload or {}), }, ) if out.get("ok") is False: return out out.setdefault("ok", True) return out def clear_system_exception(self, *, work_order_no: str) -> dict[str, Any]: """重试成功后清两列。""" out = self._post( "/inquiry/agent/ticket/clearSystemException", {"workOrderNo": work_order_no}, ) if out.get("ok") is False: return out out.setdefault("ok", True) return out def list_exception_notify_users(self) -> list[dict[str, Any]]: """角色勾了系统异常消息通知、且账号已绑企微。""" out = self._post("/inquiry/agent/account/listExceptionNotify", {}) 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 start_event_wait(self, *, work_order_no: str, wait_kind: str) -> dict[str, Any]: out = self._post( "/inquiry/agent/ticket/startEventWait", {"workOrderNo": work_order_no, "waitKind": wait_kind}, ) if out.get("ok") is False: return out out.setdefault("ok", True) return out def clear_event_wait(self, *, work_order_no: str) -> dict[str, Any]: out = self._post( "/inquiry/agent/ticket/clearEventWait", {"workOrderNo": work_order_no}, ) if out.get("ok") is False: return out out.setdefault("ok", True) return out def list_event_waits(self) -> list[dict[str, Any]]: out = self._post("/inquiry/agent/ticket/listEventWaits", {}) rows = out.get("result") if isinstance(out.get("result"), list) else None if rows is None: rows = out.get("tickets") if isinstance(out.get("tickets"), list) else [] return [dict(x) for x in rows if isinstance(x, dict)] def record_event_exception(self, *, work_order_no: str, event_exception: str) -> dict[str, Any]: out = self._post( "/inquiry/agent/ticket/recordEventException", {"workOrderNo": work_order_no, "eventException": event_exception}, ) if out.get("ok") is False: return out out.setdefault("ok", True) return out def get_timeout_by_code(self, *, timeout_code: str) -> dict[str, Any]: out = self._post( "/inquiry/agent/responseTimeout/byCode", {"timeoutCode": timeout_code}, ) if "found" not in out: return {"found": False, "timeoutCode": timeout_code} return out