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

638 lines
24 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:
# 500 时 Jeecg 仍可能带回 message(如六态非法);勿只留 http_500,
# 否则成交补「已报价」重试认不出「询价中」(WO023)。
data = resp.data if isinstance(resp.data, dict) else {}
msg = ""
if isinstance(data, dict):
msg = str(data.get("message") or "").strip()
unwrapped = _unwrap(data)
if unwrapped.get("error"):
msg = str(unwrapped.get("error") or msg)
err = msg or resp.error or f"http_{resp.status_code}"
logger.warning("http_ledger fail path=%s status=%s err=%s", path, resp.status_code, err)
return {"ok": False, "error": err}
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:
return {"ok": False, "error": "work_order_blank"}
# 空报价不打主账(避免抹掉已有查价),但禁止伪装成「已写入已报价」——
# 否则会话以为已报价、主账仍停询价中,成交三态会失败(WO022)。
if not src:
return {"ok": False, "error": "quote_blank", "skipped": True}
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 = "",
flight_no: 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()
if (flight_no or "").strip():
body["flightNo"] = flight_no.strip().upper()
return self._post(
"/inquiry/agent/tms/lockCabin",
body,
idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}",
)
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