Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a3c74e3a1 | ||
|
|
58b3c2f3e6 |
@@ -22,6 +22,8 @@ WECOM_CALLBACK_TOKEN=
|
||||
WECOM_ENCODING_AES_KEY=
|
||||
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
|
||||
WECOM_OPS_AGENT_ID=1000014
|
||||
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
|
||||
WECOM_ARCHIVE_SEAT_USER_IDS=
|
||||
DEV_INJECT_ENABLED=true
|
||||
|
||||
# 本机端口(切流前并存:勿占用现网 8080/8810/8813)
|
||||
|
||||
@@ -218,6 +218,101 @@ class WeComAppClient:
|
||||
)
|
||||
return media_id, WeComSendResult(ok=True, errcode=0, raw=data)
|
||||
|
||||
def create_group(self, *, name: str, userids: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
创建企微应用群(appchat/create),群名=工单号。
|
||||
|
||||
成员须含销售、产品、会话存档账号(询价机器人);应用自己会进群。失败回 ok=False,不抛给回调。
|
||||
"""
|
||||
users = [str(x).strip() for x in userids if str(x).strip()]
|
||||
if not name or len(users) < 2:
|
||||
return {"ok": False, "error": "name_or_users", "chat_id": ""}
|
||||
try:
|
||||
token = self._get_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("建群取 token 失败")
|
||||
return {"ok": False, "error": str(exc), "chat_id": ""}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/create"
|
||||
body = {"name": name, "userlist": users}
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("企微建群网络失败")
|
||||
return {"ok": False, "error": str(exc), "chat_id": ""}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
chat_id = str(data.get("chatid") or data.get("chat_id") or "")
|
||||
if errcode != 0 or not chat_id:
|
||||
logger.warning("企微建群失败 errcode=%s", errcode)
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(data.get("errmsg") or f"errcode_{errcode}"),
|
||||
"chat_id": "",
|
||||
}
|
||||
logger.info("企微群已创建 name=%s chat=%s", name, chat_id)
|
||||
return {"ok": True, "chat_id": chat_id, "error": ""}
|
||||
|
||||
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict[str, Any]:
|
||||
"""
|
||||
往已有应用群补人(appchat/update add_user_list)。
|
||||
|
||||
用于已建群补拉会话存档账号。已在群里的人企微会忽略。不抛给回调。
|
||||
"""
|
||||
users = [str(x).strip() for x in userids if str(x).strip()]
|
||||
if not chat_id or not users:
|
||||
return {"ok": False, "error": "chat_or_users"}
|
||||
try:
|
||||
token = self._get_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("补拉群成员取 token 失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/update"
|
||||
body = {"chatid": chat_id, "add_user_list": users}
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("企微补拉群成员网络失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if errcode != 0:
|
||||
logger.warning("企微补拉群成员失败 errcode=%s", errcode)
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
logger.info("企微群已补人 chat=%s", chat_id)
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str) -> dict[str, Any]:
|
||||
"""
|
||||
向应用群发文本。必须走 appchat/send,不能当私聊 message/send。
|
||||
|
||||
失败回 ok=False,由调用方决定是否重试;不抛给回调线程。
|
||||
"""
|
||||
if not chat_id or not content:
|
||||
return {"ok": False, "error": "chat_or_content"}
|
||||
if not self._corp_id or not self._secret:
|
||||
return {"ok": False, "error": "WECOM_CORP_ID/SECRET 未配置"}
|
||||
try:
|
||||
token = self._get_token()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("群消息取 token 失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
url = f"{self._api_base}/cgi-bin/appchat/send"
|
||||
body = {"chatid": chat_id, "msgtype": "text", "text": {"content": content}}
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(url, params={"access_token": token}, json=body)
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("企微群消息网络失败")
|
||||
return {"ok": False, "error": str(exc)}
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if errcode != 0:
|
||||
logger.warning("企微群消息失败 errcode=%s", errcode)
|
||||
return {"ok": False, "error": str(data.get("errmsg") or f"errcode_{errcode}")}
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
def send_text(self, *, touser: str, content: str) -> WeComSendResult:
|
||||
"""
|
||||
向指定企微 userid 发送文本应用消息。
|
||||
|
||||
@@ -60,6 +60,10 @@ class Settings(BaseSettings):
|
||||
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
|
||||
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
|
||||
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
|
||||
wecom_archive_seat_user_ids: str = Field(
|
||||
default="",
|
||||
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
|
||||
)
|
||||
wecom_callback_token: str = Field(default="", alias="WECOM_CALLBACK_TOKEN")
|
||||
wecom_encoding_aes_key: str = Field(default="", alias="WECOM_ENCODING_AES_KEY")
|
||||
wecom_callback_reply_text: str = Field(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Handler 包:一类意图 / 一类卡片一个模块。
|
||||
|
||||
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
|
||||
当前:文字询价空运闭环 + echo 联调残留。
|
||||
当前:文字询价空运/海运闭环 + 海运群协同字段 + echo 联调残留。
|
||||
"""
|
||||
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
意图:询价卡片按钮(复制工单号 / 跳过协同 / 采用 / 不采用 / 成交)。
|
||||
意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。
|
||||
|
||||
本文件职责:校验当前会话允许的动作后交给流程执行。
|
||||
禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。
|
||||
@@ -13,10 +13,20 @@ from typing import Any
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pick_card_flow(sender_id: str, payload: dict | None):
|
||||
"""点卡:海运书签能找回工单就走海运,否则空运。"""
|
||||
sea = get_sea_text_flow()
|
||||
sess = sea.session_for_action(sender_id, payload)
|
||||
if sess and (sess.business_line or "").upper() == "SEA":
|
||||
return sea
|
||||
return get_air_text_flow()
|
||||
|
||||
|
||||
def handle_card_action(
|
||||
*,
|
||||
sender_id: str,
|
||||
@@ -36,7 +46,7 @@ def handle_card_action(
|
||||
_ = thread_id
|
||||
if not (sender_id or "").strip():
|
||||
return "reject_no_sender"
|
||||
engine = flow or get_air_text_flow()
|
||||
engine = flow or _pick_card_flow(sender_id, payload)
|
||||
sess = engine.session_for_action(sender_id, payload)
|
||||
if sess and wait_version and sess.wait_version != wait_version:
|
||||
logger.info("card waitVersion 不匹配 expect=%s got=%s", sess.wait_version, wait_version)
|
||||
|
||||
@@ -14,10 +14,38 @@ from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pick_continue_flow(*, sender_id: str, work_order_no: str, flow=None):
|
||||
"""
|
||||
续办按工单选空运/海运流程。
|
||||
|
||||
海运书签或主账业务线是 SEA 必须走海运 Owner。
|
||||
一律走空运会把已报价海运重放成空运协同卡(件数/毛重/包装)。
|
||||
单测传入 flow 时不改选路,保持原断言。
|
||||
"""
|
||||
if flow is not None:
|
||||
return flow
|
||||
sea = get_sea_text_flow()
|
||||
no = (work_order_no or "").strip()
|
||||
hit = sea.session_by_ticket(sender_id, no) if no else None
|
||||
if hit and (hit.business_line or "").upper() == "SEA":
|
||||
return sea
|
||||
getter = getattr(sea.ledger, "get_for_agent", None)
|
||||
if callable(getter) and no:
|
||||
try:
|
||||
view = getter(work_order_no=no, sender_id=sender_id) or {}
|
||||
except Exception:
|
||||
logger.warning("continue_thread 读主账业务线失败 wo=%s", no)
|
||||
view = {}
|
||||
if str(view.get("business_line") or "").upper() == "SEA":
|
||||
return sea
|
||||
return get_air_text_flow()
|
||||
|
||||
|
||||
def handle_continue_thread(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
@@ -34,7 +62,10 @@ def handle_continue_thread(
|
||||
logger.warning("continue_thread 拒绝:无 sender_id")
|
||||
return "reject_no_sender"
|
||||
msg_store = store or get_message_store()
|
||||
engine = flow or get_air_text_flow()
|
||||
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
|
||||
engine = _pick_continue_flow(
|
||||
sender_id=message.sender_id, work_order_no=wo, flow=flow
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra: dict | None = None) -> str:
|
||||
@@ -48,7 +79,6 @@ def handle_continue_thread(
|
||||
logger.info("continue_thread 出站 ok=%s info=%s", ok, info)
|
||||
return info if ok else ""
|
||||
|
||||
wo = (thread_id or "").strip() or copy.extract_work_order_no(message.content or "")
|
||||
phase = engine.resume_historical(
|
||||
sender_id=message.sender_id,
|
||||
work_order_no=wo,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
意图:海运协同群里补协同字段。
|
||||
|
||||
本文件职责:群聊文字 → 只抽协同六字段 → 静默写主账。
|
||||
禁止:回「已记下」;把闲聊/报价金额写成报价版本;改工单六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.llm.extract_text import extract_collab_fields
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_group_collab(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
flow=None,
|
||||
ledger=None,
|
||||
) -> str:
|
||||
"""
|
||||
处理一条群聊文字。
|
||||
|
||||
必须能对上已绑定工单,且发言人是被拉进来的销售或产品。
|
||||
识别到协同字段才写库;否则忽略。不回复。
|
||||
"""
|
||||
if (message.chat_type or "") != "group":
|
||||
return "group_ignore"
|
||||
if not message.has_sender():
|
||||
return "reject_no_sender"
|
||||
engine = flow or get_sea_text_flow()
|
||||
book = ledger or engine.ledger
|
||||
chat_id = (message.chat_id or "").strip()
|
||||
if not chat_id:
|
||||
return "group_ignore"
|
||||
|
||||
ticket = _find_ticket(book, chat_id)
|
||||
if ticket is None:
|
||||
return "group_ignore"
|
||||
if not _sender_allowed(ticket, message.sender_id):
|
||||
logger.info("group_collab 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
|
||||
return "group_ignore"
|
||||
|
||||
facts = extract_collab_fields(message.content or "")
|
||||
if not facts:
|
||||
return "group_ignore"
|
||||
|
||||
if isinstance(ticket, dict):
|
||||
wo = str(ticket.get("work_order_no") or "")
|
||||
else:
|
||||
wo = str(getattr(ticket, "work_order_no", "") or "")
|
||||
if not wo:
|
||||
return "group_ignore"
|
||||
|
||||
engine.apply_collab_facts(
|
||||
work_order_no=wo,
|
||||
facts=facts,
|
||||
sender_id=message.sender_id,
|
||||
)
|
||||
logger.info(
|
||||
"group_collab 写入 wo=%s keys=%s sender=%s",
|
||||
wo,
|
||||
sorted(k for k in facts if k in SEA_COLLAB_FIELDS),
|
||||
message.sender_id,
|
||||
)
|
||||
return "collab_field_saved"
|
||||
|
||||
|
||||
def _find_ticket(book: object, chat_id: str):
|
||||
finder = getattr(book, "find_by_collab_chat", None)
|
||||
if callable(finder):
|
||||
return finder(chat_id=chat_id)
|
||||
return None
|
||||
|
||||
|
||||
def _sender_allowed(ticket: object, sender_id: str) -> bool:
|
||||
uid = (sender_id or "").strip()
|
||||
if not uid:
|
||||
return False
|
||||
if isinstance(ticket, dict):
|
||||
sales = str(ticket.get("sales_wecom_id") or ticket.get("salesWecomId") or "")
|
||||
members = ticket.get("collab_member_ids") or ticket.get("memberIds") or []
|
||||
else:
|
||||
sales = str(getattr(ticket, "sales_wecom_id", "") or "")
|
||||
members = list(getattr(ticket, "collab_member_ids", []) or [])
|
||||
if uid == sales:
|
||||
return True
|
||||
return uid in {str(x) for x in members}
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
意图:普通文字询价(空运闭环)。
|
||||
意图:普通文字询价(空运 / 海运闭环)。
|
||||
|
||||
本文件职责:入站文字 → 流程编排;回复只写入 outbox。
|
||||
禁止:在本文件抽字段语义(交给 extract);禁止直连 TMS;禁止回调线程外的额外阻塞。
|
||||
@@ -13,11 +13,29 @@ from typing import Any, Optional
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.config import get_settings
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = ""):
|
||||
"""
|
||||
按当前书签或本句运输方式选空运/海运流程。
|
||||
|
||||
已有海运书签优先;新句写了海运走海运;其余仍走空运(含补问运输方式)。
|
||||
"""
|
||||
sea = get_sea_text_flow()
|
||||
sea_sess = sea.session_of(sender_id)
|
||||
if sea_sess and (sea_sess.business_line or "").upper() == "SEA":
|
||||
return sea
|
||||
mode = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
if mode == "SEA":
|
||||
return sea
|
||||
return get_air_text_flow()
|
||||
|
||||
|
||||
def handle_text_inquiry(
|
||||
message: InboundMessage,
|
||||
*,
|
||||
@@ -39,7 +57,11 @@ def handle_text_inquiry(
|
||||
return "reject_no_sender"
|
||||
settings = get_settings()
|
||||
msg_store = store or get_message_store()
|
||||
engine = flow or get_air_text_flow()
|
||||
engine = flow or _pick_text_flow(
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
injected_mode=injected_mode,
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra: dict | None = None) -> str:
|
||||
@@ -75,12 +97,13 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
|
||||
闲聊/其它:若有待点按钮则当按钮;否则回首次单聊话术。
|
||||
"""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
if not message.has_sender():
|
||||
return "reject_no_sender"
|
||||
msg_store = store or get_message_store()
|
||||
engine = get_air_text_flow()
|
||||
engine = _pick_text_flow(
|
||||
sender_id=message.sender_id, text=message.content or ""
|
||||
)
|
||||
sess = engine.session_for_action(
|
||||
message.sender_id, message.raw, text=message.content or ""
|
||||
)
|
||||
|
||||
@@ -102,10 +102,20 @@ class HttpLedger:
|
||||
return {"ok": False, "found": False, "owned": False, "work_order_no": work_order_no}
|
||||
return out
|
||||
|
||||
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
|
||||
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",
|
||||
{"work_order_no": work_order_no, "facts": facts},
|
||||
body,
|
||||
idem=f"tms:{work_order_no}",
|
||||
)
|
||||
if "classification" not in out and not out.get("ok"):
|
||||
@@ -182,3 +192,72 @@ class HttpLedger:
|
||||
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 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 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 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,
|
||||
) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/bindCollabGroup",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"chatId": chat_id,
|
||||
"memberIds": member_ids,
|
||||
"productNames": product_names 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 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
|
||||
|
||||
@@ -33,6 +33,10 @@ class MemoryTicket:
|
||||
status: str = "询价中"
|
||||
quote: dict[str, Any] = field(default_factory=dict)
|
||||
quote_version: int = 0
|
||||
collab_chat_id: str = ""
|
||||
collab_facts: dict[str, str] = field(default_factory=dict)
|
||||
collab_member_ids: list[str] = field(default_factory=list)
|
||||
quote_file_ready: bool = False
|
||||
|
||||
|
||||
class MemoryLedger:
|
||||
@@ -57,6 +61,9 @@ class MemoryLedger:
|
||||
}
|
||||
]
|
||||
self.last_render: dict[str, Any] = {}
|
||||
self._sea_staff: list[dict[str, Any]] = []
|
||||
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
|
||||
self._staff_by_name: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def _next_no(self) -> str:
|
||||
self._seq += 1
|
||||
@@ -135,7 +142,14 @@ class MemoryLedger:
|
||||
"status": "询价中",
|
||||
}
|
||||
|
||||
def query_tms(self, *, work_order_no: str, facts: dict[str, str]) -> dict[str, Any]:
|
||||
def query_tms(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
_ = business_line
|
||||
dest = str(facts.get("目的港") or "")
|
||||
force = str(facts.get("tms_force") or "")
|
||||
if force == "1002" or force == "1003" or "无价" in dest or "歧义" in dest:
|
||||
@@ -243,6 +257,10 @@ class MemoryLedger:
|
||||
"fileBase64": "UEsDBAoAAAAAA",
|
||||
"needsPdf": False,
|
||||
}
|
||||
with self._lock:
|
||||
ticket = self._tickets.get((work_order_no or "").strip())
|
||||
if ticket is not None:
|
||||
ticket.quote_file_ready = True
|
||||
self.last_render = out
|
||||
return out
|
||||
|
||||
@@ -261,7 +279,13 @@ class MemoryLedger:
|
||||
if status in {"已成交", "未成交", "协商中", "已关闭"}:
|
||||
wait_phase = "done"
|
||||
elif status == "已报价" and quote:
|
||||
wait_phase = "wait_adopt"
|
||||
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
|
||||
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
|
||||
wait_phase = "wait_deal"
|
||||
elif (ticket.business_line or "").upper() == "SEA":
|
||||
wait_phase = "wait_collab"
|
||||
else:
|
||||
wait_phase = "wait_adopt"
|
||||
else:
|
||||
wait_phase = "tms_miss"
|
||||
return {
|
||||
@@ -274,4 +298,111 @@ class MemoryLedger:
|
||||
"facts": dict(ticket.facts),
|
||||
"quote": quote,
|
||||
"wait_phase": wait_phase,
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collab_facts": dict(ticket.collab_facts),
|
||||
}
|
||||
|
||||
def upsert_staff(self, row: dict[str, Any]) -> None:
|
||||
"""单测注入任意岗位(含会话存档账号「询价机器人」)。"""
|
||||
data = dict(row)
|
||||
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
|
||||
name = str(data.get("name") or "").strip()
|
||||
with self._lock:
|
||||
if uid:
|
||||
self._staff_by_wecom[uid] = data
|
||||
if name:
|
||||
self._staff_by_name[name] = data
|
||||
|
||||
def set_sea_staff(self, rows: list[dict[str, Any]]) -> None:
|
||||
"""单测注入海运产品岗。"""
|
||||
with self._lock:
|
||||
self._sea_staff = [dict(x) for x in rows]
|
||||
for row in self._sea_staff:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip()
|
||||
if uid:
|
||||
self._staff_by_wecom[uid] = dict(row)
|
||||
if name:
|
||||
self._staff_by_name[name] = dict(row)
|
||||
|
||||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
岗位=产品·海运、启用:写了「海运·全部线路」必拉;
|
||||
写了具体港口的,对得上本单起运港/目的港才拉。
|
||||
"""
|
||||
origin = (origin or "").strip()
|
||||
dest = (destination or "").strip()
|
||||
hits: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for row in self._sea_staff:
|
||||
if str(row.get("roleCode") or row.get("role_code") or "") != "sea":
|
||||
continue
|
||||
if str(row.get("status") or "active") != "active":
|
||||
continue
|
||||
routes = str(row.get("routes") or "")
|
||||
if "海运·全部线路" in routes or "海运全线" in routes:
|
||||
hits.append(dict(row))
|
||||
continue
|
||||
if origin and origin in routes:
|
||||
hits.append(dict(row))
|
||||
continue
|
||||
if dest and dest in routes:
|
||||
hits.append(dict(row))
|
||||
return hits
|
||||
|
||||
def get_staff_by_wecom_id(self, *, wecom_id: str) -> dict[str, Any]:
|
||||
"""按企微 userid 取员工展示名。"""
|
||||
with self._lock:
|
||||
return dict(self._staff_by_wecom.get((wecom_id or "").strip()) or {})
|
||||
|
||||
def find_staff_by_name(self, *, name: str) -> dict[str, Any]:
|
||||
"""按员工姓名取企微 userid。"""
|
||||
with self._lock:
|
||||
return dict(self._staff_by_name.get((name or "").strip()) or {})
|
||||
|
||||
def bind_collab_group(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
chat_id: str,
|
||||
member_ids: list[str],
|
||||
product_names: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""记下协同群,不改六态。"""
|
||||
_ = product_names
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.collab_chat_id = (chat_id or "").strip()
|
||||
ticket.collab_member_ids = [str(x) for x in member_ids]
|
||||
return {"ok": True, "chat_id": ticket.collab_chat_id}
|
||||
|
||||
def patch_collab_facts(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
sender_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""合并协同字段。不改六态、不写报价版本。"""
|
||||
_ = sender_id
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.collab_facts.update(
|
||||
{k: v for k, v in (facts or {}).items() if str(v or "").strip()}
|
||||
)
|
||||
return {"ok": True, "facts": dict(ticket.collab_facts)}
|
||||
|
||||
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
|
||||
"""用工微群 chat_id 找回工单。"""
|
||||
raw = (chat_id or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
with self._lock:
|
||||
for ticket in self._tickets.values():
|
||||
if ticket.collab_chat_id == raw:
|
||||
return ticket
|
||||
return None
|
||||
|
||||
@@ -22,11 +22,13 @@ _MODE_WORDS = (
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(.+)$"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|整柜或拼柜|箱型箱量|报价日期|车型数量)\s*[::]\s*(\S+)"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||||
r"贸易条款|货好时间|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
|
||||
)
|
||||
|
||||
|
||||
@@ -114,3 +116,20 @@ def extract_inquiry_snapshot(
|
||||
"facts": facts,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def extract_collab_fields(text: str) -> dict[str, str]:
|
||||
"""
|
||||
群里协同字段:只收带标签的非空值。
|
||||
|
||||
闲聊、报价金额、附件本轮不进这里。HS 折成 HS编码。
|
||||
"""
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS, normalize_facts
|
||||
|
||||
labeled = normalize_facts(parse_labeled_facts(text))
|
||||
out: dict[str, str] = {}
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = (labeled.get(key) or "").strip()
|
||||
if val:
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
@@ -121,8 +121,9 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
||||
"你是询价字段抽取器。只根据销售原话抽取字段,调用 extract_inquiry_fields。"
|
||||
"未出现的字段必须空串,禁止编造港口、重量、日期。"
|
||||
"空运件数/毛重/体积/包装方式;海运货量与箱型箱量;陆运按二级类型。"
|
||||
"海运时 15件/20吨/3箱 必须写入货量,不要只写到件数把货量留空。"
|
||||
"原话已带单位必须抽取,不要因为没写「毛重:」就留空:"
|
||||
"10件/3pcs→件数;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
|
||||
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
|
||||
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
|
||||
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
|
||||
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
||||
|
||||
@@ -45,11 +45,16 @@ class FlowSession:
|
||||
status: str = ""
|
||||
allowed: tuple[str, ...] = ()
|
||||
immutable_text: str = ""
|
||||
collab_chat_id: str = ""
|
||||
collab_facts: dict[str, str] = field(default_factory=dict)
|
||||
invited_userids: tuple[str, ...] = ()
|
||||
invited_sales_name: str = ""
|
||||
invited_product_names: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AirTextInquiryFlow:
|
||||
"""
|
||||
空运文字询价 Owner(本轮产品范围);海运/陆运先补问运输方式或陆运二级。
|
||||
空运文字询价 Owner。海运走 SeaTextInquiryFlow,不要再往本文件堆海运。陆运仍补问二级。
|
||||
|
||||
同一 sender 新开询价会覆盖「当前书签」(旧单留在主账)。
|
||||
价格卡按工单号另存一份当时会话:点哪张卡就生成哪张卡当时的报价单。
|
||||
@@ -69,6 +74,25 @@ class AirTextInquiryFlow:
|
||||
with self._lock:
|
||||
return self._by_sender.get(sender_id)
|
||||
|
||||
def session_by_ticket(self, sender_id: str, work_order_no: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
只查进程内该书签,不读主账。
|
||||
|
||||
续办选空运/海运流程用:海运已跳过协同时必须找回 wait_adopt,
|
||||
不能因为主账把已报价海运一律收成 wait_collab 就套空运协同卡。
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not (sender_id or "").strip() or not no:
|
||||
return None
|
||||
with self._lock:
|
||||
hit = self._by_ticket.get(no)
|
||||
if hit and hit.sender_id == sender_id:
|
||||
return hit
|
||||
for key, sess in self._by_ticket.items():
|
||||
if (key or "").upper() == no and sess.sender_id == sender_id:
|
||||
return sess
|
||||
return None
|
||||
|
||||
def session_for_action(
|
||||
self,
|
||||
sender_id: str,
|
||||
@@ -152,6 +176,7 @@ class AirTextInquiryFlow:
|
||||
else:
|
||||
keep = None
|
||||
if keep:
|
||||
self._align_resume_phase(keep)
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
@@ -165,6 +190,7 @@ class AirTextInquiryFlow:
|
||||
reply("这张工单不属于当前账号,不能继续。")
|
||||
return "ticket_forbidden"
|
||||
sess = self._session_from_ledger(sender_id=sender_id, view=view)
|
||||
self._align_resume_phase(sess)
|
||||
self._save(sess)
|
||||
return self._replay_wait(sess, reply, source="ledger")
|
||||
|
||||
@@ -204,6 +230,20 @@ class AirTextInquiryFlow:
|
||||
return "wait_adopt"
|
||||
return "tms_miss"
|
||||
|
||||
def _align_resume_phase(self, sess: FlowSession) -> None:
|
||||
"""
|
||||
报价文件已经发给销售,续办必须回到成交跟进。
|
||||
|
||||
主账六态仍是「已报价」,不能据此重放到协同卡或生成报价单。
|
||||
Redis 就绪键由出文件时写入;主账 wait_phase=wait_deal 也认。
|
||||
"""
|
||||
if sess.phase not in {"wait_collab", "wait_adopt", "wait_file"}:
|
||||
return
|
||||
issued = bool((sess.quote or {}).get("quoteFileIssued"))
|
||||
if issued or self._quote_file_already_out(sess.work_order_no):
|
||||
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
"""按书签重出当时节点,不改六态。"""
|
||||
logger.info(
|
||||
@@ -213,18 +253,21 @@ class AirTextInquiryFlow:
|
||||
source,
|
||||
)
|
||||
if sess.phase == "wait_adopt":
|
||||
mode = copy.scheme_transport_mode(sess.business_line)
|
||||
reply(
|
||||
copy.scheme_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
transport_mode=mode,
|
||||
),
|
||||
copy.scheme_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
status=sess.status or "已报价",
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
return "wait_adopt"
|
||||
@@ -349,9 +392,23 @@ class AirTextInquiryFlow:
|
||||
if mode == "LAND":
|
||||
reply(copy.ASK_LAND_SUBTYPE)
|
||||
return "need_land_subtype"
|
||||
# 海运本轮只提示先走空运闭环(不半做海运查价)
|
||||
reply("本轮先开通空运文字询价。请按空运描述需求,或补充为「空运」。")
|
||||
return "sea_deferred"
|
||||
if mode == "SEA":
|
||||
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
return get_sea_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
reply=reply,
|
||||
injected_facts=facts,
|
||||
injected_mode=mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
inbound_raw=inbound_raw,
|
||||
)
|
||||
reply("请补充运输方式:空运、海运或陆运。")
|
||||
return "need_mode"
|
||||
|
||||
check = validate_required_fields(facts=facts, business_line="AIR")
|
||||
facts = dict(check["facts"])
|
||||
@@ -640,18 +697,21 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
mode = copy.scheme_transport_mode(sess.business_line)
|
||||
reply(
|
||||
copy.scheme_card(
|
||||
work_order_no=sess.work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
transport_mode=mode,
|
||||
),
|
||||
copy.scheme_wecom_payload(
|
||||
work_order_no=sess.work_order_no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
status=sess.status or "已报价",
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
return "wait_adopt"
|
||||
@@ -763,6 +823,9 @@ class AirTextInquiryFlow:
|
||||
file_extra,
|
||||
)
|
||||
self._wait_outbound_sent(str(out_id or ""), work_order_no=sess.work_order_no, what="报价文件")
|
||||
from agent.redis_coord.quote_file import mark_quote_file_ready
|
||||
|
||||
mark_quote_file_ready(sess.work_order_no)
|
||||
sess.phase = "wait_deal"
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
|
||||
@@ -13,7 +13,7 @@ import time
|
||||
import uuid
|
||||
from typing import Any, Iterable
|
||||
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS
|
||||
from agent.schema.field_validate import AIR_REQUIRED_FIELDS, SEA_COLLAB_FIELDS, SEA_REQUIRED_FIELDS
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -30,11 +30,27 @@ _AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_SEA_CLARIFY = tuple(
|
||||
(key, label, _SEA_EXAMPLES[key]) for key, label in SEA_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 文字按钮(销售点卡或原样回复均可)
|
||||
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
|
||||
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
|
||||
BTN_SKIP_COLLAB_KEY = "skip_collab"
|
||||
BTN_SKIP_COLLAB_DONE = "已提交"
|
||||
BTN_PULL_COLLAB = "拉产品进群协同"
|
||||
BTN_PULL_COLLAB_KEY = "pull_collab"
|
||||
BTN_ADOPT_EXCEL = "采用,生成Excel报价单"
|
||||
BTN_ADOPT_PDF = "采用,生成PDF报价单"
|
||||
BTN_REJECT = "不采用"
|
||||
@@ -104,6 +120,14 @@ def _air_known_value(facts: dict[str, str], key: str) -> str:
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def _sea_known_value(facts: dict[str, str], key: str) -> str:
|
||||
"""海运已识别字段的销售侧展示值。"""
|
||||
src = facts or {}
|
||||
if key == "货量":
|
||||
return (src.get("货量") or src.get("货物数量") or "").strip()
|
||||
return (src.get(key) or "").strip()
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -119,11 +143,14 @@ def ask_clarify(
|
||||
skip = {"报价日期"}
|
||||
missing = [k for k in missing_keys if k and k not in skip]
|
||||
missing_set = set(missing)
|
||||
sea = (transport_mode or "").strip() == "海运"
|
||||
rows = _SEA_CLARIFY if sea else _AIR_CLARIFY
|
||||
known = _sea_known_value if sea else _air_known_value
|
||||
lines = ["请确认并补充以下信息:"]
|
||||
if transport_mode:
|
||||
lines.append(f"运输方式:{transport_mode}")
|
||||
for key, label, _example in _AIR_CLARIFY:
|
||||
val = _air_known_value(src, key)
|
||||
for key, label, _example in rows:
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
@@ -132,7 +159,7 @@ def ask_clarify(
|
||||
if "运输方式" in missing_set:
|
||||
any_missing = True
|
||||
lines.append("运输方式:(如:空运)")
|
||||
for key, label, example in _AIR_CLARIFY:
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
@@ -155,6 +182,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
|
||||
"体积(CBM)": "体积",
|
||||
"包装类型": "包装方式",
|
||||
"包装方式": "包装方式",
|
||||
"货物数量": "货量",
|
||||
"货量": "货量",
|
||||
"整柜或拼柜": "整柜或拼柜",
|
||||
"箱型箱量": "箱型箱量",
|
||||
}
|
||||
keys: list[str] = []
|
||||
for name in display_names:
|
||||
@@ -207,6 +238,21 @@ def inquiry_field_lines(
|
||||
cargo = (facts.get("品名") or "-").strip() or "-"
|
||||
pack = (facts.get("包装方式") or facts.get("包装类型") or "-").strip() or "-"
|
||||
quote_date = (facts.get("报价日期") or "-").strip() or "-"
|
||||
if (transport_mode or "").strip() == "海运":
|
||||
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
|
||||
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (facts.get("箱型箱量") or "-").strip() or "-"
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
f"起运港:{origin}",
|
||||
f"目的港:{dest}",
|
||||
f"品名:{cargo}",
|
||||
f"货物数量:{qty}",
|
||||
f"整柜或拼柜:{load}",
|
||||
f"箱型箱量:{box}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
@@ -221,14 +267,22 @@ def inquiry_field_lines(
|
||||
]
|
||||
|
||||
|
||||
def inquiry_card(*, work_order_no: str, facts: dict[str, str], first_or_same: str = "first") -> str:
|
||||
def inquiry_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
first_or_same: str = "first",
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
建单后的询价确认。
|
||||
|
||||
无标题、无 [test]。first_or_same 保留兼容,不再对销售展示。
|
||||
"""
|
||||
_ = first_or_same
|
||||
lines = inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
lines = inquiry_field_lines(
|
||||
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
|
||||
)
|
||||
lines.append("工单已创建,正在查询TMS报价。")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -241,6 +295,10 @@ def canonical_button(raw: str) -> str:
|
||||
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
|
||||
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
|
||||
return BTN_SKIP_COLLAB
|
||||
if text in {BTN_PULL_COLLAB, BTN_PULL_COLLAB_KEY} or text.startswith(
|
||||
f"{BTN_PULL_COLLAB_KEY}:"
|
||||
):
|
||||
return BTN_PULL_COLLAB
|
||||
if text in {BTN_ADOPT_EXCEL, BTN_ADOPT_EXCEL_CARD, BTN_ADOPT_EXCEL_KEY} or text.startswith(
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:"
|
||||
):
|
||||
@@ -328,6 +386,7 @@ def _work_order_in_token(raw: str) -> str:
|
||||
return ""
|
||||
for prefix in (
|
||||
f"{BTN_SKIP_COLLAB_KEY}:",
|
||||
f"{BTN_PULL_COLLAB_KEY}:",
|
||||
f"{BTN_ADOPT_EXCEL_KEY}:",
|
||||
f"{BTN_ADOPT_PDF_KEY}:",
|
||||
f"{BTN_REJECT_KEY}:",
|
||||
@@ -416,6 +475,51 @@ def quote_area_fee_text(quote: dict[str, Any] | None) -> str:
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
|
||||
_QUOTE_AREA_SAFE_CHARS = 80
|
||||
|
||||
|
||||
def quote_area_safe_text(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
卡片引用条:短报价原样;超长只留合计,避免半截费用名。
|
||||
|
||||
完整明细由 sea_quote_detail_text 另发一条文本。空运卡仍用 quote_area_fee_text。
|
||||
"""
|
||||
full = quote_area_fee_text(quote)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
n = len(quote_fee_lines(src))
|
||||
if n:
|
||||
return f"总销售价合计:{total}\n共{n}项费用,详见上一条明细"
|
||||
return f"总销售价合计:{total}"
|
||||
|
||||
|
||||
def sea_quote_detail_text(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||
"""海运完整价格明细文本。不受卡片引用条字数限制。"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
lines = [source, f"{work_order_no}价格明细"]
|
||||
lines.extend(
|
||||
quote_detail_lines(
|
||||
src,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def collab_hint(work_order_no: str) -> str:
|
||||
"""价格卡协同说明:无「内部协同」前缀,紧挨生成报价单按钮上方。"""
|
||||
return (
|
||||
@@ -681,7 +785,230 @@ def skip_collab_ack() -> str:
|
||||
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
|
||||
|
||||
|
||||
def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote: dict[str, Any]) -> str:
|
||||
def sea_collab_ask() -> str:
|
||||
"""海运有价卡协同问句。"""
|
||||
return "是否拉产品人员进群协同?"
|
||||
|
||||
|
||||
def sea_miss_ask() -> str:
|
||||
"""海运无价卡协同问句。"""
|
||||
return "未查到合适报价,是否需要拉内部产品人员进群,进行手工协同报价?"
|
||||
|
||||
|
||||
def sea_tms_hit_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> str:
|
||||