526 lines
19 KiB
Python
526 lines
19 KiB
Python
"""
|
||
主账 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 = "已报价",
|
||
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
|