Files
inquiry_robot/inquiry-agent/agent/ledger/http_ledger.py
T

603 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
主账 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]:
"""
覆盖本单最新 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,
}
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