Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
235ecd8c83 | ||
|
|
0a3c74e3a1 |
@@ -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:
|
||||
"""海运有价:价格明细 + 拉群问句 + 两个按钮。空运卡片函数保持不动。"""
|
||||
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,
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(sea_collab_ask())
|
||||
lines.append(BTN_PULL_COLLAB)
|
||||
lines.append(BTN_SKIP_COLLAB)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sea_tms_hit_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> dict[str, Any]:
|
||||
"""海运有价企微卡:价格明细 + 问句 + 拉群 / 跳过协同。"""
|
||||
_ = first_or_same, history_work_order_no, status
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
meta = "\n".join(
|
||||
[
|
||||
f"时效:{tms_eta_display(src)}",
|
||||
f"报价有效期:{tms_valid_until_display(src)}",
|
||||
"",
|
||||
sea_collab_ask(),
|
||||
]
|
||||
)
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"template_card": {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": source, "desc_color": 0},
|
||||
"main_title": {"title": f"{work_order_no}价格明细"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_safe_text(src),
|
||||
},
|
||||
"sub_title_text": meta,
|
||||
"task_id": card_task_id("quote", work_order_no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_PULL_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}",
|
||||
},
|
||||
{
|
||||
"text": BTN_SKIP_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sea_tms_miss(*, work_order_no: str) -> str:
|
||||
"""海运无价文字兜底:工单号、暂无匹配、询价中、拉群问句。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return "\n".join(
|
||||
[
|
||||
f"询价工单 {no}",
|
||||
"暂无匹配报价",
|
||||
"工单状态:询价中",
|
||||
sea_miss_ask(),
|
||||
BTN_PULL_COLLAB,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
海运无价一张卡:工单号、暂无匹配、询价中、拉群问句、一个按钮。
|
||||
|
||||
禁止再写「请手动拉人到原有群」。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
card: dict[str, Any] = {
|
||||
"card_type": "button_interaction",
|
||||
"source": {"desc": "TMS查价结果", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": f"询价工单 {no}",
|
||||
"desc": "暂无匹配报价",
|
||||
},
|
||||
"sub_title_text": "\n".join(
|
||||
[
|
||||
"需求已解析并创建工单。",
|
||||
sea_miss_ask(),
|
||||
]
|
||||
),
|
||||
"horizontal_content_list": [
|
||||
{"keyname": "工单状态", "value": "询价中"},
|
||||
{"keyname": "查价结果", "value": "暂无匹配报价"},
|
||||
],
|
||||
"task_id": card_task_id("miss", no),
|
||||
"button_list": [
|
||||
{
|
||||
"text": BTN_PULL_COLLAB,
|
||||
"style": 1,
|
||||
"key": f"{BTN_PULL_COLLAB_KEY}:{no}",
|
||||
}
|
||||
],
|
||||
}
|
||||
url = (action_url or "").strip()
|
||||
if url:
|
||||
card["card_action"] = {"type": 1, "url": url}
|
||||
return {"msgtype": "template_card", "template_card": card}
|
||||
|
||||
|
||||
def _role_label(name: str, role: str) -> str:
|
||||
"""姓名前加岗位,避免「销售甲、乙」被读成两人都是销售。已带岗位前缀的不再重复加。"""
|
||||
text = (name or "").strip()
|
||||
if not text:
|
||||
return role
|
||||
if text.startswith(role):
|
||||
return text
|
||||
return f"{role}{text}"
|
||||
|
||||
|
||||
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
|
||||
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = _role_label(sales_name, "销售")
|
||||
products = "、".join(
|
||||
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip()
|
||||
) or "产品"
|
||||
return (
|
||||
f"协同群已创建,群名与工单号一致:{no}。"
|
||||
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
|
||||
)
|
||||
|
||||
|
||||
def sea_group_exists(*, work_order_no: str) -> str:
|
||||
"""同一工单已有群,不再新建。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。"
|
||||
|
||||
|
||||
def sea_group_no_staff() -> str:
|
||||
"""没有可拉的产品人员,不建空群。"""
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
|
||||
|
||||
|
||||
def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
"""建群失败,按钮保持可点。"""
|
||||
extra = (reason or "").strip()
|
||||
if extra:
|
||||
return f"建群失败:{extra}。请稍后重试。"
|
||||
return "建群失败,请稍后重试。"
|
||||
|
||||
|
||||
def sea_group_brief(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any] | None,
|
||||
collab_facts: dict[str, str] | None,
|
||||
sales_name: str,
|
||||
product_names: list[str],
|
||||
has_price: bool,
|
||||
) -> str:
|
||||
"""
|
||||
群里自动摘要:@销售 @产品 + 工单号 + 询价字段 + 协同字段 + TMS 报价。
|
||||
|
||||
协同没填写 -。无价写明暂无匹配报价。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = (sales_name or "").strip() or "销售"
|
||||
products = [n.strip() for n in (product_names or []) if (n or "").strip()]
|
||||
at_bits = [f"@{sales}"] + [f"@{n}" for n in products]
|
||||
lines = [" ".join(at_bits), f"工单号:{no}", "询价字段:"]
|
||||
lines.extend(
|
||||
inquiry_field_lines(work_order_no=no, facts=facts, transport_mode="海运")[1:]
|
||||
)
|
||||
lines.append("协同补充字段:")
|
||||
collab = dict(collab_facts or {})
|
||||
for key in SEA_COLLAB_FIELDS:
|
||||
val = (collab.get(key) or "").strip() or "-"
|
||||
lines.append(f"{key}:{val}")
|
||||
lines.append("TMS报价:")
|
||||
if has_price and quote:
|
||||
lines.extend(quote_detail_lines(quote))
|
||||
else:
|
||||
lines.append("TMS 暂无匹配报价 / 未查到合适报价")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def scheme_card(
|
||||
*,
|
||||
work_order_no: str,
|
||||
status: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
报价方案文本兜底(模板卡发失败时仍给销售看)。
|
||||
|
||||
@@ -692,7 +1019,9 @@ def scheme_card(*, work_order_no: str, status: str, facts: dict[str, str], quote
|
||||
# 方案卡不展示询价「报价日期」,改展示 TMS 报价有效期
|
||||
body = "\n".join(
|
||||
line
|
||||
for line in inquiry_field_lines(work_order_no=work_order_no, facts=facts)
|
||||
for line in inquiry_field_lines(
|
||||
work_order_no=work_order_no, facts=facts, transport_mode=transport_mode
|
||||
)
|
||||
if not line.startswith("报价日期:")
|
||||
)
|
||||
eta = tms_eta_display(src)
|
||||
@@ -718,16 +1047,31 @@ def _clip(raw: str, limit: int) -> str:
|
||||
return text if len(text) <= limit else text[: limit - 1] + "…"
|
||||
|
||||
|
||||
def scheme_transport_mode(business_line: str = "", fallback: str = "空运") -> str:
|
||||
"""会话业务线 → 方案卡运输方式。海运不得再写成空运。"""
|
||||
raw = (business_line or "").strip()
|
||||
line = raw.upper()
|
||||
if line == "SEA" or raw == "海" or "海运" in raw:
|
||||
return "海运"
|
||||
if line == "LAND" or raw == "陆" or "陆运" in raw:
|
||||
return "陆运"
|
||||
if line == "AIR" or raw == "空" or "空运" in raw:
|
||||
return "空运"
|
||||
return (fallback or "空运").strip() or "空运"
|
||||
|
||||
|
||||
def scheme_horizontal_rows(
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
transport_mode: str = "空运",
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
卡片字段:一行一项,每格独立字数,不会像副标题整段截成 8CB…。
|
||||
|
||||
企微 horizontal 最多 6 条。运输方式写进航线行,避免丢字段。
|
||||
海运用货物数量/整柜或拼柜/箱型箱量,禁止套空运件数毛重体积包装。
|
||||
不用 sub_title 堆字段(它会排到列表前面,且超长必截断)。
|
||||
"""
|
||||
src = facts or {}
|
||||
@@ -735,6 +1079,19 @@ def scheme_horizontal_rows(
|
||||
origin = (src.get("起运港") or "-").strip() or "-"
|
||||
dest = (src.get("目的港") or "-").strip() or "-"
|
||||
cargo = (src.get("品名") or "-").strip() or "-"
|
||||
mode = (transport_mode or "空运").strip() or "空运"
|
||||
if mode == "海运":
|
||||
qty = (src.get("货量") or src.get("货物数量") or "-").strip() or "-"
|
||||
load = (src.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (src.get("箱型箱量") or "-").strip() or "-"
|
||||
return [
|
||||
{"keyname": "工单号", "value": _clip(work_order_no, 26)},
|
||||
{"keyname": "航线", "value": _clip(f"海运 {origin}→{dest}", 26)},
|
||||
{"keyname": "货物", "value": _clip(f"{cargo};{qty}", 26)},
|
||||
{"keyname": "箱型", "value": _clip(f"{load};{box}", 26)},
|
||||
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
|
||||
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
|
||||
]
|
||||
pieces = _with_unit(str(src.get("件数") or ""), "件")
|
||||
weight = _with_unit(str(src.get("毛重") or ""), "KGS")
|
||||
volume = _with_unit(str(src.get("体积") or ""), "CBM")
|
||||
@@ -755,11 +1112,13 @@ def scheme_wecom_payload(
|
||||
facts: dict[str, str],
|
||||
quote: dict[str, Any],
|
||||
status: str = "已报价",
|
||||
transport_mode: str = "空运",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
一张报价方案卡:标题带工单号、引用条、字段、三个短按钮。
|
||||
|
||||
按钮文案必须短:生成Excel / 生成PDF / 不采用。长文案横排会被裁。
|
||||
海运/空运字段分开,禁止海运卡再出件数毛重体积。
|
||||
"""
|
||||
_ = status
|
||||
src = dict(quote or {})
|
||||
@@ -772,10 +1131,17 @@ def scheme_wecom_payload(
|
||||
"main_title": {"title": f"{work_order_no}报价方案"},
|
||||
"quote_area": {
|
||||
"type": 0,
|
||||
"quote_text": quote_area_fee_text(src),
|
||||
"quote_text": (
|
||||
quote_area_safe_text(src)
|
||||
if (transport_mode or "").strip() == "海运"
|
||||
else quote_area_fee_text(src)
|
||||
),
|
||||
},
|
||||
"horizontal_content_list": scheme_horizontal_rows(
|
||||
work_order_no=work_order_no, facts=facts, quote=src
|
||||
work_order_no=work_order_no,
|
||||
facts=facts,
|
||||
quote=src,
|
||||
transport_mode=transport_mode,
|
||||
),
|
||||
"task_id": card_task_id("scheme", work_order_no),
|
||||
"button_list": [
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"""
|
||||
海运文字询价确定性流程。
|
||||
|
||||
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import (
|
||||
AirTextInquiryFlow,
|
||||
FlowSession,
|
||||
ReplyFn,
|
||||
build_ledger,
|
||||
)
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.tms_sea_query import assemble_sea_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 会话存档账号:每个协同群必拉。私聊「已邀请」不写这些人。
|
||||
# 姓名与后台员工管理一致;userid 以员工表/环境变量为准,源码不写死。
|
||||
ARCHIVE_SEAT_NAMES = ("询价机器人",)
|
||||
|
||||
|
||||
class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
"""
|
||||
海运文字询价 Owner。
|
||||
|
||||
复用空运后半段(跳过协同 / 出方案卡 / Excel PDF / 成交),只改查价前与卡片、拉群。
|
||||
"""
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
|
||||
super().__init__(ledger=ledger)
|
||||
self._group_client = group_client
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
injected_facts: Optional[dict[str, Any]] = None,
|
||||
injected_mode: str = "",
|
||||
b_result: Optional[dict[str, Any]] = None,
|
||||
allow_b: bool = False,
|
||||
chat_fn: Optional[Any] = None,
|
||||
inbound_raw: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
处理一句海运私聊文字。返回阶段名供单测断言。
|
||||
|
||||
先认按钮,再抽字段。运输方式必须是 SEA 才建单。
|
||||
"""
|
||||
if not (sender_id or "").strip():
|
||||
return "reject_no_sender"
|
||||
sess = self.session_for_action(sender_id, inbound_raw, text=text)
|
||||
btn = self.match_button(text, sess)
|
||||
if btn:
|
||||
return self.on_button(
|
||||
sender_id=sender_id,
|
||||
action=btn,
|
||||
reply=reply,
|
||||
card_meta=inbound_raw,
|
||||
)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
injected_facts=injected_facts,
|
||||
injected_mode=injected_mode,
|
||||
b_result=b_result,
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
merged = dict(sess.facts)
|
||||
merged.update(facts)
|
||||
facts = harvest_oral_measures(text, merged)
|
||||
mode = mode or (sess.business_line or "").upper()
|
||||
if not mode:
|
||||
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
|
||||
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="need_mode",
|
||||
business_line="",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
)
|
||||
)
|
||||
return "need_mode"
|
||||
|
||||
if mode != "SEA":
|
||||
if mode == "LAND":
|
||||
reply(copy.ASK_LAND_SUBTYPE)
|
||||
return "need_land_subtype"
|
||||
# 空运交给空运 Owner,避免海运流程半做空运
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
return get_air_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,
|
||||
)
|
||||
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
facts = dict(check["facts"])
|
||||
tms_ready = assemble_sea_query(facts)
|
||||
facts = dict(tms_ready["facts"])
|
||||
if not check["ok"] or not tms_ready["ok"]:
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
reply(
|
||||
copy.ask_clarify(
|
||||
facts=facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="海运",
|
||||
)
|
||||
)
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
)
|
||||
)
|
||||
return "clarify"
|
||||
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
facts=facts,
|
||||
text=text,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
def _create_and_quote(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
"""建海运工单、先出确认卡、再查 TMS。"""
|
||||
import uuid
|
||||
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="quoting",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
)
|
||||
)
|
||||
created = self._ledger.create_ticket(
|
||||
sender_id=sender_id,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
idempotency_key=f"create:{sender_id}:{uuid.uuid4().hex}",
|
||||
)
|
||||
no = str(created.get("work_order_no") or "").strip()
|
||||
if not no:
|
||||
reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。")
|
||||
return "ledger_fail"
|
||||
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
|
||||
out_id = reply(
|
||||
copy.inquiry_card(
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
first_or_same=first,
|
||||
transport_mode="海运",
|
||||
)
|
||||
) or ""
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="SEA")
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
work_order_no=no,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
immutable_text=text,
|
||||
wait_version=1,
|
||||
)
|
||||
if not tms.get("ok"):
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
if not tms.get("has_price"):
|
||||
self._emit_tms_miss(reply, no)
|
||||
sess.phase = "tms_miss"
|
||||
sess.status = "询价中"
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||||
self._save(sess)
|
||||
return "tms_miss"
|
||||
|
||||
quote = tms["quote"]
|
||||
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
reply("主账写入报价失败,不能当作已报价。")
|
||||
sess.phase = "ledger_fail"
|
||||
self._save(sess)
|
||||
return "ledger_fail"
|
||||
sess.quote = quote
|
||||
sess.status = "已报价"
|
||||
sess.phase = "wait_collab"
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
first_or_same=first,
|
||||
history_work_order_no=history_wo,
|
||||
status="已报价",
|
||||
)
|
||||
return "wait_collab"
|
||||
|
||||
def _resolve_similar(
|
||||
self,
|
||||
created: dict[str, Any],
|
||||
*,
|
||||
facts: dict[str, str],
|
||||
current_no: str,
|
||||
) -> tuple[str, str]:
|
||||
history = str(created.get("similar_work_order_no") or "").strip()
|
||||
first = str(created.get("first_or_same") or "first")
|
||||
if first == "same" and history and history != current_no:
|
||||
return "same", history
|
||||
finder = getattr(self._ledger, "find_similar_open", None)
|
||||
if not callable(finder):
|
||||
return "first", ""
|
||||
try:
|
||||
similar = finder(
|
||||
facts=facts, business_line="SEA", exclude_work_order_no=current_no
|
||||
) or {}
|
||||
except TypeError:
|
||||
similar = finder(facts=facts, business_line="SEA") or {}
|
||||
except Exception:
|
||||
logger.warning("find_similar 失败,按首次询价展示 current=%s", current_no)
|
||||
return "first", ""
|
||||
history = str(similar.get("work_order_no") or "").strip()
|
||||
if similar.get("found") and history and history != current_no:
|
||||
return "same", history
|
||||
return "first", ""
|
||||
|
||||
def _emit_tms_miss(self, reply: ReplyFn, work_order_no: str) -> None:
|
||||
"""海运无价:一张带拉群按钮的卡,不再发手动拉人群句。"""
|
||||
from agent.config import get_settings
|
||||
|
||||
reply(
|
||||
copy.sea_tms_miss(work_order_no=work_order_no),
|
||||
copy.sea_tms_miss_wecom_payload(
|
||||
work_order_no=work_order_no,
|
||||
action_url=(get_settings().public_base_url or "").strip(),
|
||||
),
|
||||
)
|
||||
|
||||
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
|
||||
sess = super()._session_from_ledger(sender_id=sender_id, view=view)
|
||||
if sess.business_line == "SEA" and sess.phase == "wait_collab":
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
if sess.business_line == "SEA" and sess.phase == "tms_miss":
|
||||
sess.allowed = (copy.BTN_PULL_COLLAB,)
|
||||
sess.collab_chat_id = str(view.get("collab_chat_id") or "")
|
||||
sess.collab_facts = dict(view.get("collab_facts") or {})
|
||||
return sess
|
||||
|
||||
def _emit_sea_hit(
|
||||
self,
|
||||
reply: ReplyFn,
|
||||
*,
|
||||
work_order_no: str,
|
||||
quote: dict[str, Any],
|
||||
first_or_same: str = "first",
|
||||
history_work_order_no: str = "",
|
||||
status: str = "已报价",
|
||||
) -> None:
|
||||
"""
|
||||
先发完整价格明细文本,再发带按钮的卡。
|
||||
|
||||
企微只发卡时,引用条超长会被截断;文本不受限。
|
||||
"""
|
||||
reply(
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
reply(
|
||||
copy.sea_tms_hit_card(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
copy.sea_tms_hit_wecom_payload(
|
||||
work_order_no=work_order_no,
|
||||
quote=quote,
|
||||
first_or_same=first_or_same,
|
||||
history_work_order_no=history_work_order_no,
|
||||
status=status,
|
||||
),
|
||||
)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
if sess.business_line == "SEA" and sess.phase == "wait_collab":
|
||||
self._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
quote=sess.quote,
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
)
|
||||
return "wait_collab"
|
||||
if sess.business_line == "SEA" and sess.phase == "tms_miss":
|
||||
self._emit_tms_miss(reply, sess.work_order_no)
|
||||
return "tms_miss"
|
||||
return super()._replay_wait(sess, reply, source=source)
|
||||
|
||||
def on_button(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
action: str,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
sess = self.session_for_action(sender_id, card_meta)
|
||||
if sess and action == copy.BTN_PULL_COLLAB and action in sess.allowed:
|
||||
return self._pull_collab_group(sess, reply, card_meta=card_meta)
|
||||
return super().on_button(
|
||||
sender_id=sender_id, action=action, reply=reply, card_meta=card_meta
|
||||
)
|
||||
|
||||
def _pull_collab_group(
|
||||
self,
|
||||
sess: FlowSession,
|
||||
reply: ReplyFn,
|
||||
card_meta: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
点「拉产品进群协同」:按线路拉产品人,新建群,私聊回执,按钮置灰。
|
||||
|
||||
没人可拉 / 建群失败:说明原因,按钮保持可点。已有群:不新建,置灰。
|
||||
"""
|
||||
no = (sess.work_order_no or "").strip()
|
||||
existing = (sess.collab_chat_id or "").strip()
|
||||
if not existing:
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
ticket = getter(work_order_no=no) if callable(getter) else None
|
||||
existing = str(getattr(ticket, "collab_chat_id", "") or "")
|
||||
if existing:
|
||||
sess.collab_chat_id = existing
|
||||
sess.allowed = ()
|
||||
sess.phase = "collab_group"
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
self._ensure_archive_seats_in_group(existing)
|
||||
reply(copy.sea_group_exists(work_order_no=no))
|
||||
return "collab_group"
|
||||
|
||||
staff = self._match_sea_staff(sess)
|
||||
if not staff:
|
||||
reply(copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
userids = [sess.sender_id]
|
||||
product_names: list[str] = []
|
||||
product_ids: list[str] = []
|
||||
for row in staff:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
name = str(row.get("name") or "").strip() or uid
|
||||
if uid and uid not in userids:
|
||||
userids.append(uid)
|
||||
product_ids.append(uid)
|
||||
product_names.append(name)
|
||||
if not product_ids:
|
||||
reply(copy.sea_group_no_staff())
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
# 销售 + 产品之后,必须再拉会话存档账号;没有也不挡建群。
|
||||
for uid in self._archive_seat_userids():
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
|
||||
client = self._group_client or _default_group_client()
|
||||
created = client.create_group(name=no, userids=userids) or {}
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
sales_name = self._sales_display_name(sess.sender_id)
|
||||
binder = getattr(self._ledger, "bind_collab_group", None)
|
||||
if callable(binder):
|
||||
binder(
|
||||
work_order_no=no,
|
||||
chat_id=chat_id,
|
||||
member_ids=userids,
|
||||
product_names=product_names,
|
||||
)
|
||||
|
||||
brief = copy.sea_group_brief(
|
||||
work_order_no=no,
|
||||
facts=sess.facts,
|
||||
quote=sess.quote,
|
||||
collab_facts=sess.collab_facts,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
has_price=bool(sess.quote and sess.status == "已报价"),
|
||||
)
|
||||
client.send_group(chat_id=chat_id, content=brief)
|
||||
|
||||
sess.collab_chat_id = chat_id
|
||||
sess.invited_userids = tuple(userids)
|
||||
sess.invited_sales_name = sales_name
|
||||
sess.invited_product_names = tuple(product_names)
|
||||
sess.phase = "collab_group"
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
reply(
|
||||
copy.sea_group_created(
|
||||
work_order_no=no,
|
||||
sales_name=sales_name,
|
||||
product_names=product_names,
|
||||
)
|
||||
)
|
||||
return "collab_group"
|
||||
|
||||
def _match_sea_staff(self, sess: FlowSession) -> list[dict[str, Any]]:
|
||||
"""主账按起运/目的匹配启用的海运产品岗。"""
|
||||
matcher = getattr(self._ledger, "match_sea_staff", None)
|
||||
if not callable(matcher):
|
||||
return []
|
||||
try:
|
||||
rows = matcher(
|
||||
origin=str((sess.facts or {}).get("起运港") or ""),
|
||||
destination=str((sess.facts or {}).get("目的港") or ""),
|
||||
) or []
|
||||
except Exception:
|
||||
logger.exception("匹配海运产品人员失败 wo=%s", sess.work_order_no)
|
||||
return []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
|
||||
def _archive_seat_userids(self) -> list[str]:
|
||||
"""
|
||||
会话存档账号 userid 列表。
|
||||
|
||||
先读环境变量 WECOM_ARCHIVE_SEAT_USER_IDS,再按姓名「询价机器人」查员工管理。
|
||||
查不到只记日志,不把建群判失败。
|
||||
"""
|
||||
ids: list[str] = []
|
||||
from agent.config import get_settings
|
||||
|
||||
seat = str(getattr(get_settings(), "wecom_archive_seat_user_ids", "") or "").strip()
|
||||
for part in seat.replace(";", ",").split(","):
|
||||
uid = part.strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
finder = getattr(self._ledger, "find_staff_by_name", None)
|
||||
if callable(finder):
|
||||
for name in ARCHIVE_SEAT_NAMES:
|
||||
try:
|
||||
row = finder(name=name) or {}
|
||||
except Exception:
|
||||
logger.exception("查会话存档账号失败 name=%s", name)
|
||||
continue
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if uid and uid not in ids:
|
||||
ids.append(uid)
|
||||
if not ids:
|
||||
logger.warning("协同群缺少会话存档账号:员工管理未找到「询价机器人」且未配置 WECOM_ARCHIVE_SEAT_USER_IDS")
|
||||
return ids
|
||||
|
||||
def _ensure_archive_seats_in_group(self, chat_id: str) -> None:
|
||||
"""已有群补拉会话存档账号;失败只记日志,不改销售侧已有群提示。"""
|
||||
seats = self._archive_seat_userids()
|
||||
if not chat_id or not seats:
|
||||
return
|
||||
client = self._group_client or _default_group_client()
|
||||
adder = getattr(client, "add_group_members", None)
|
||||
if not callable(adder):
|
||||
return
|
||||
try:
|
||||
adder(chat_id=chat_id, userids=seats)
|
||||
except Exception:
|
||||
logger.exception("已有协同群补拉会话存档失败 chat=%s", chat_id)
|
||||
|
||||
def _sales_display_name(self, sender_id: str) -> str:
|
||||
"""私聊「已邀请」用的销售名;没有展示名就用 userid。"""
|
||||
finder = getattr(self._ledger, "get_staff_by_wecom_id", None)
|
||||
if callable(finder):
|
||||
try:
|
||||
row = finder(wecom_id=sender_id) or {}
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
logger.warning("查销售姓名失败 sender=%s", sender_id)
|
||||
return sender_id
|
||||
|
||||
def apply_collab_facts(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
facts: dict[str, str],
|
||||
sender_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
群里静默写入协同字段。不改六态、不回复、不出报价版本。
|
||||
|
||||
只合并非空协同键。
|
||||
"""
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
cleaned = {
|
||||
k: str(v).strip()
|
||||
for k, v in (facts or {}).items()
|
||||
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
|
||||
}
|
||||
if not no or not cleaned:
|
||||
return {"ok": False, "updated": []}
|
||||
with self._lock:
|
||||
sess = self._by_ticket.get(no)
|
||||
if sess:
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(cleaned)
|
||||
sess.collab_facts = merged
|
||||
self._save(sess)
|
||||
writer = getattr(self._ledger, "patch_collab_facts", None)
|
||||
if callable(writer):
|
||||
writer(work_order_no=no, facts=cleaned, sender_id=sender_id)
|
||||
logger.info("sea.collab_fields wo=%s keys=%s by=%s", no, sorted(cleaned), sender_id)
|
||||
return {"ok": True, "updated": sorted(cleaned)}
|
||||
|
||||
|
||||
def _default_group_client() -> Any:
|
||||
"""生产走企微应用建群;未配置时返回会失败的空客户端。"""
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
return WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
secret=settings.wecom_secret,
|
||||
agent_id=int(settings.wecom_agent_id or "1000010"),
|
||||
)
|
||||
|
||||
|
||||
_FLOW: Optional[SeaTextInquiryFlow] = None
|
||||
_FLOW_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def get_sea_text_flow() -> SeaTextInquiryFlow:
|
||||
"""进程内一份海运流程;测试可 new 独立实例。"""
|
||||
global _FLOW
|
||||
with _FLOW_GUARD:
|
||||
if _FLOW is None:
|
||||
_FLOW = SeaTextInquiryFlow()
|
||||
return _FLOW
|
||||
|
||||
|
||||
def reset_sea_text_flow_for_test() -> SeaTextInquiryFlow:
|
||||
"""单测重置全局海运实例(强制内存账本)。"""
|
||||
global _FLOW
|
||||
with _FLOW_GUARD:
|
||||
_FLOW = SeaTextInquiryFlow(MemoryLedger())
|
||||
return _FLOW
|
||||
@@ -34,7 +34,6 @@ class TemplateEntry:
|
||||
version_id: str = ""
|
||||
mapping_json: str = ""
|
||||
biz_type: str = ""
|
||||
category: str = ""
|
||||
keywords: tuple[str, ...] = ()
|
||||
is_default: bool = False
|
||||
|
||||
@@ -94,7 +93,6 @@ def entries_from_ledger_response(resp: LedgerResponse) -> list[TemplateEntry]:
|
||||
version_id=str(it.get("versionId") or ""),
|
||||
mapping_json=str(it.get("mappingJson") or ""),
|
||||
biz_type=str(it.get("bizType") or ""),
|
||||
category=str(it.get("category") or ""),
|
||||
keywords=tuple(str(x) for x in (it.get("keywords") or []) if str(x).strip()),
|
||||
is_default=bool(it.get("isDefault")),
|
||||
)
|
||||
@@ -155,7 +153,6 @@ def registry_as_dicts() -> list[dict[str, Any]]:
|
||||
"template_id": e.template_id,
|
||||
"version_no": e.version_no,
|
||||
"biz_type": e.biz_type,
|
||||
"category": e.category,
|
||||
}
|
||||
for e in list_template_registry()
|
||||
]
|
||||
|
||||
@@ -41,10 +41,18 @@ def normalize_biz(raw: str) -> str:
|
||||
|
||||
|
||||
def _as_keywords(raw: Any) -> list[str]:
|
||||
"""摊开关键词。列表里若整段是 [海运,整柜] 也要拆开,与 Java asKeywords 一致。"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return [str(x).strip() for x in raw if str(x).strip()]
|
||||
out: list[str] = []
|
||||
for x in raw:
|
||||
s = str(x).strip()
|
||||
if s.startswith("["):
|
||||
out.extend(_as_keywords(s))
|
||||
elif s:
|
||||
out.append(s)
|
||||
return out
|
||||
text = str(raw).strip()
|
||||
if not text or text.lower() == "null":
|
||||
return []
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Callable, Optional
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
from agent.handlers.group_collab import handle_group_collab
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry, handle_text_other
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
@@ -134,6 +135,11 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
返回:实际调用的 intent 名,或 unknown_intent / skipped。
|
||||
副作用:仅 handler 内产生;本函数不做业务。
|
||||
"""
|
||||
if (message.chat_type or "") == "group":
|
||||
phase = handle_group_collab(message)
|
||||
logger.info("dispatch group_collab phase=%s chat=%s", phase, message.chat_id)
|
||||
return "group_collab"
|
||||
|
||||
if not message.has_sender() and decision.intent != "reject_no_sender":
|
||||
logger.warning("dispatch 拒绝:无 sender_id")
|
||||
HANDLER_REGISTRY["reject_no_sender"](message)
|
||||
|
||||
@@ -32,6 +32,21 @@ AIR_REQUIRED_FIELDS = (
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
|
||||
# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。
|
||||
SEA_REQUIRED_FIELDS = (
|
||||
("起运港", "起运港"),
|
||||
("目的港", "目的港"),
|
||||
("品名", "品名"),
|
||||
("货量", "货物数量"),
|
||||
("整柜或拼柜", "整柜或拼柜"),
|
||||
("箱型箱量", "箱型箱量"),
|
||||
("报价日期", "报价日期"),
|
||||
)
|
||||
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。
|
||||
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
|
||||
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
|
||||
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
|
||||
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)\b", re.I)
|
||||
@@ -63,6 +78,8 @@ _ALIASES = {
|
||||
"重量": "毛重",
|
||||
"体积(CBM)": "体积",
|
||||
"车型/数量": "车型数量",
|
||||
"HS": "HS编码",
|
||||
"HS CODE": "HS编码",
|
||||
}
|
||||
|
||||
|
||||
@@ -161,6 +178,20 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
|
||||
return harvest_named_air_route(raw, merged)
|
||||
|
||||
|
||||
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
|
||||
"""
|
||||
海运货物数量投影:原话「15件」常被抽成空运「件数」。
|
||||
|
||||
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
|
||||
pieces = (merged.get("件数") or "").strip()
|
||||
if not qty and pieces:
|
||||
merged["货量"] = pieces
|
||||
return merged
|
||||
|
||||
|
||||
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
|
||||
"""销售未提供报价日期时填当天;已有则不覆盖。"""
|
||||
merged = dict(facts)
|
||||
@@ -192,6 +223,8 @@ def display_name(internal_key: str, business_line: str = "AIR") -> str:
|
||||
line = (business_line or "").upper()
|
||||
if line == "AIR":
|
||||
return AIR_DISPLAY.get(internal_key, internal_key)
|
||||
if line == "SEA":
|
||||
return SEA_DISPLAY.get(internal_key, internal_key)
|
||||
return internal_key
|
||||
|
||||
|
||||
@@ -212,6 +245,8 @@ def validate_required_fields(
|
||||
contract = require_formal_contract("inquiry-required-fields-v1")
|
||||
normalized = apply_quote_date_default(normalize_facts(facts))
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
if line not in {"SEA", "AIR", "LAND"}:
|
||||
return {
|
||||
"ok": False,
|
||||
|
||||
@@ -33,7 +33,7 @@ def inquiry_fingerprint(facts: dict[str, Any], *, business_line: str) -> str:
|
||||
空运箱型不在空运必填里;海运箱型箱量若有则纳入。
|
||||
"""
|
||||
line = (business_line or "").strip().upper()
|
||||
keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "报价日期"]
|
||||
keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "整柜或拼柜", "报价日期"]
|
||||
parts = [line]
|
||||
for key in keys:
|
||||
parts.append(str(facts.get(key) or "").strip())
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
海运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
|
||||
|
||||
本文件职责:询价卡片出现前,确认海运七项能拼出查价正文。
|
||||
港口只带名称,不做港口字典对应;箱型箱量保持原文字段,containerItems 只透传模型投影。
|
||||
禁止:本地拆箱型;因对不上港口字典跳过 TMS;改六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.field_validate import apply_quote_date_default
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_type(raw: str) -> str:
|
||||
"""整柜/拼柜原文透传;出站可带 FCL/LCL 提示,不拒绝口语。"""
|
||||
text = (raw or "").strip()
|
||||
return text
|
||||
|
||||
|
||||
def parse_container_items(facts: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
只收下 DeepSeek B 已投影的 containerItems。
|
||||
|
||||
本地不得从「箱型箱量」字符串拆 type/quantity。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
raw = src.get("containerItems")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in raw:
|
||||
if isinstance(row, dict):
|
||||
items.append(dict(row))
|
||||
return items
|
||||
|
||||
|
||||
def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
把已采集海运字段拼成 TMS 查价参数。
|
||||
|
||||
不齐则 ok=False,missing 给销售补问;不建单、不出卡片。
|
||||
齐了也不因没有港口代码拦网。报价日期未说则填当天。
|
||||
"""
|
||||
src = apply_quote_date_default(
|
||||
{str(k): str(v) for k, v in dict(facts or {}).items() if v is not None}
|
||||
)
|
||||
origin = str(src.get("起运港") or "").strip()
|
||||
dest = str(src.get("目的港") or "").strip()
|
||||
cargo = str(src.get("品名") or "").strip()
|
||||
qty = str(src.get("货量") or src.get("货物数量") or "").strip()
|
||||
load = _load_type(str(src.get("整柜或拼柜") or ""))
|
||||
box = str(src.get("箱型箱量") or "").strip()
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
|
||||
missing: list[str] = []
|
||||
missing_display: list[str] = []
|
||||
pairs = (
|
||||
("起运港", "起运港", origin),
|
||||
("目的港", "目的港", dest),
|
||||
("品名", "品名", cargo),
|
||||
("货量", "货物数量", qty),
|
||||
("整柜或拼柜", "整柜或拼柜", load),
|
||||
("箱型箱量", "箱型箱量", box),
|
||||
("报价日期", "报价日期", quote_date),
|
||||
)
|
||||
for key, label, val in pairs:
|
||||
if not val:
|
||||
missing.append(key)
|
||||
missing_display.append(label)
|
||||
|
||||
ok = not missing
|
||||
out_facts = dict(src)
|
||||
if origin:
|
||||
out_facts["起运港"] = origin
|
||||
if dest:
|
||||
out_facts["目的港"] = dest
|
||||
if cargo:
|
||||
out_facts["品名"] = cargo
|
||||
if qty:
|
||||
out_facts["货量"] = qty
|
||||
if load:
|
||||
out_facts["整柜或拼柜"] = load
|
||||
if box:
|
||||
out_facts["箱型箱量"] = box
|
||||
if quote_date:
|
||||
out_facts["报价日期"] = quote_date
|
||||
items = parse_container_items(src)
|
||||
if items:
|
||||
out_facts["containerItems"] = items
|
||||
|
||||
logger.info(
|
||||
"tms.sea.assemble ok=%s origin=%s dest=%s load=%s missing=%s",
|
||||
ok,
|
||||
origin or "-",
|
||||
dest or "-",
|
||||
load or "-",
|
||||
missing,
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"missing": missing,
|
||||
"missing_display": missing_display,
|
||||
"facts": out_facts,
|
||||
}
|
||||
@@ -45,6 +45,9 @@ COMPLETE_AIR = {
|
||||
|
||||
class AirTextInquiryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.runtime import shutdown_redis_runtime
|
||||
|
||||
shutdown_redis_runtime()
|
||||
self.flow = AirTextInquiryFlow(MemoryLedger())
|
||||
self.replies: list[str] = []
|
||||
self.extras: list[dict] = []
|
||||
|
||||
@@ -41,6 +41,11 @@ COMPLETE_AIR = {
|
||||
|
||||
|
||||
class ContinueTicketTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.runtime import shutdown_redis_runtime
|
||||
|
||||
shutdown_redis_runtime()
|
||||
|
||||
def test_model_contract_accepts_continue_intent(self) -> None:
|
||||
digest = "a" * 64
|
||||
parsed = parse_ordered_actions_arguments(
|
||||
@@ -182,6 +187,36 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
self.assertTrue(any("报价方案" in x for x in replies))
|
||||
|
||||
def test_resume_after_quote_file_is_deal(self) -> None:
|
||||
ledger = MemoryLedger()
|
||||
created = ledger.create_ticket(
|
||||
sender_id="u6", business_line="AIR", facts=COMPLETE_AIR
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
|
||||
to_status="已报价",
|
||||
)
|
||||
ledger.render_quote(
|
||||
work_order_no=wo,
|
||||
business_line="AIR",
|
||||
facts=COMPLETE_AIR,
|
||||
quote={"total": "USD 1"},
|
||||
)
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
phase = flow.resume_historical(
|
||||
sender_id="u6", work_order_no=wo, reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
self.assertTrue(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
||||
|
||||
def test_resume_missing_wo_asks(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
@@ -53,6 +53,24 @@ class QuoteTemplateMatchTests(unittest.TestCase):
|
||||
self.assertIsNone(hit)
|
||||
self.assertIn("IT运维", FAIL_MESSAGE)
|
||||
|
||||
def test_broken_bracket_keyword_still_hits_sea(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
{
|
||||
"templateId": "s1",
|
||||
"name": "海运报价单模板",
|
||||
"bizType": "海运",
|
||||
"keywords": ["[海运,整柜,FCL]"],
|
||||
"isDefault": False,
|
||||
},
|
||||
],
|
||||
business_line="SEA",
|
||||
facts={"整柜或拼柜": "整柜", "目的港": "巴生西港"},
|
||||
)
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.match_type, "keyword")
|
||||
self.assertEqual(hit.name, "海运报价单模板")
|
||||
|
||||
def test_sea_ignored_for_air(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
"""
|
||||
海运文字询价主路径单测(不连企微 / 真实 TMS / 8180)。
|
||||
|
||||
覆盖:海运补问、询价确认卡、有价/无价卡、跳过协同、拉群、群里静默补协同字段。
|
||||
空运话术不得出现在海运路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.group_collab import handle_group_collab
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.tms_sea_query import assemble_sea_query
|
||||
|
||||
|
||||
COMPLETE_SEA = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
|
||||
|
||||
class FakeGroupClient:
|
||||
"""单测假企微建群:不打外网。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.created: list[dict] = []
|
||||
self.sent: list[dict] = []
|
||||
self.fail = False
|
||||
self.next_chat_id = "wr_sea_1"
|
||||
|
||||
def create_group(self, *, name: str, userids: list[str]) -> dict:
|
||||
if self.fail:
|
||||
return {"ok": False, "error": "wecom_create_fail", "chat_id": ""}
|
||||
self.created.append({"name": name, "userids": list(userids)})
|
||||
return {"ok": True, "chat_id": self.next_chat_id, "error": ""}
|
||||
|
||||
def add_group_members(self, *, chat_id: str, userids: list[str]) -> dict:
|
||||
self.created.append({"name": "", "userids": list(userids), "chat_id": chat_id, "add": True})
|
||||
return {"ok": True}
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str) -> dict:
|
||||
self.sent.append({"chat_id": chat_id, "content": content})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class SeaTextInquiryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "mem-default-sea",
|
||||
"name": "海运默认",
|
||||
"bizType": "海运",
|
||||
"keywords": [],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
self.extras: list[dict] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def test_sea_pieces_fill_cargo_qty(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"件数": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertTrue(check["ok"])
|
||||
self.assertEqual(check["facts"]["货量"], "15件")
|
||||
self.assertNotIn("货量", check["missing"])
|
||||
|
||||
def test_sea_missing_container_only_asks_container(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "茶叶",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
self.assertEqual(check["missing"], ["箱型箱量"])
|
||||
self.assertIn("箱型箱量", check["missing_display"])
|
||||
self.assertNotIn("件数", check["missing"])
|
||||
self.assertNotIn("毛重", check["missing"])
|
||||
text = copy.ask_clarify(
|
||||
facts=check["facts"],
|
||||
missing_keys=check["missing"],
|
||||
transport_mode="海运",
|
||||
)
|
||||
self.assertIn("运输方式:海运", text)
|
||||
self.assertIn("起运港:上海", text)
|
||||
self.assertIn("货物数量:20吨", text)
|
||||
self.assertIn("箱型箱量:(如", text)
|
||||
self.assertNotIn("件数", text)
|
||||
self.assertNotIn("重量", text)
|
||||
self.assertNotIn("报价日期:(如", text)
|
||||
self.assertIn("报价日期:", text)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运完整",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("运输方式:海运", joined)
|
||||
self.assertIn("起运港:上海", joined)
|
||||
self.assertIn("目的港:洛杉矶", joined)
|
||||
self.assertIn("货物数量:20吨", joined)
|
||||
self.assertIn("整柜或拼柜:整柜", joined)
|
||||
self.assertIn("箱型箱量:1x40HQ", joined)
|
||||
self.assertIn("正在查询TMS报价", joined)
|
||||
self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0])
|
||||
self.assertNotIn("本轮先开通空运", joined)
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertEqual(sess.business_line, "SEA")
|
||||
self.assertEqual(sess.status, "已报价")
|
||||
|
||||
def test_handler_routes_sea_not_air_deferred(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
|
||||
store = MemoryMessageStore()
|
||||
msg = InboundMessage(
|
||||
sender_id="s2",
|
||||
message_id="m-sea-1",
|
||||
content="海运询价",
|
||||
)
|
||||
phase = handle_text_inquiry(
|
||||
msg,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
flow=self.flow,
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertNotEqual(phase, "sea_deferred")
|
||||
|
||||
|
||||
class SeaTmsCardTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
self.extras: list[dict] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def test_assemble_sea_query_does_not_need_air_measures(self) -> None:
|
||||
ready = assemble_sea_query(COMPLETE_SEA)
|
||||
self.assertTrue(ready["ok"])
|
||||
self.assertEqual(ready["missing"], [])
|
||||
self.assertNotIn("件数", ready.get("missing") or [])
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
"""海运多项费用:完整明细必须在文本里,卡片引用条不得半截。"""
|
||||
long_quote = {
|
||||
"total": "2287.65",
|
||||
"currency": "CNY",
|
||||
"eta": "1天",
|
||||
"valid_until": "2026-09-30",
|
||||
"source_label": "TMS 标准报价",
|
||||
"fee_lines": [
|
||||
{"name": "海运费", "amount": "100.00", "currency": "USD"},
|
||||
{"name": "燃油附加费", "amount": "15.00", "currency": "USD"},
|
||||
{"name": "操作费", "amount": "680.00", "currency": "CNY"},
|
||||
{"name": "文件费", "amount": "450.00", "currency": "CNY"},
|
||||
{"name": "封条费(SEAI)", "amount": "50.00", "currency": "CNY"},
|
||||
{"name": "设备费", "amount": "50.00", "currency": "CNY"},
|
||||
{"name": "报关费", "amount": "250.00", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609150029", quote=long_quote
|
||||
)
|
||||
self.assertIn("海运费:USD 100.00", text)
|
||||
self.assertIn("报关费:CNY 250.00", text)
|
||||
self.assertIn("总销售价合计:CNY 2287.65", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote)
|
||||
self.assertIn("总销售价合计:CNY 2287.65", card_text)
|
||||
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
|
||||
self.assertLessEqual(len(card_text), 80)
|
||||
payload = copy.sea_tms_hit_wecom_payload(
|
||||
work_order_no="WO202609150029", quote=long_quote
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["template_card"]["quote_area"]["quote_text"], card_text
|
||||
)
|
||||
|
||||
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("价格明细", joined)
|
||||
self.assertIn("是否拉产品人员进群协同", joined)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
self.assertNotIn("请手动拉相关人员", joined)
|
||||
self.assertNotIn("若需要咨询内部航线人员", joined)
|
||||
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
|
||||
self.assertTrue(cards)
|
||||
buttons = [b["text"] for b in cards[-1]["template_card"]["button_list"]]
|
||||
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB])
|
||||
|
||||
def test_sea_miss_one_card_with_pull_button(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "tms_miss")
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertEqual(sess.status, "询价中")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("暂无匹配报价", joined)
|
||||
self.assertIn("询价中", joined)
|
||||
self.assertIn("未查到合适报价", joined)
|
||||
self.assertIn("是否需要拉内部产品人员进群", joined)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
self.assertNotIn("请手动拉相关人员和智能体到原有群", joined)
|
||||
cards = [x for x in self.extras if x.get("msgtype") == "template_card"]
|
||||
self.assertTrue(cards)
|
||||
buttons = [b["text"] for b in cards[-1]["template_card"].get("button_list") or []]
|
||||
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB])
|
||||
|
||||
def test_sea_tech_fail_is_not_miss(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["tms_force"] = "tech"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运技术失败",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "tms_tech")
|
||||
self.assertIn("TMS 查询失败", "\n".join(self.replies))
|
||||
self.assertNotIn(copy.BTN_PULL_COLLAB, "\n".join(self.replies))
|
||||
|
||||
|
||||
class SeaSkipAndPullTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
os.environ["WECOM_ARCHIVE_SEAT_USER_IDS"] = ""
|
||||
from agent.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
self.ledger = MemoryLedger()
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "mem-default-sea",
|
||||
"name": "海运默认",
|
||||
"bizType": "海运",
|
||||
"keywords": [],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
self.extras: list[dict] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> str:
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
return ""
|
||||
|
||||
def _quote_first(self, sender: str = "s1") -> None:
|
||||
self.flow.on_text(
|
||||
sender_id=sender,
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
|
||||
def test_skip_collab_reuses_air_scheme(self) -> None:
|
||||
self._quote_first()
|
||||
phase = self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_SKIP_COLLAB,
|
||||
reply=self._reply,
|
||||
card_meta={"response_code": "rc-sea-1"},
|
||||
)
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
self.assertIn("已跳过内部协同", "\n".join(self.replies))
|
||||
scheme = "\n".join(x for x in self.replies if "报价方案" in x)
|
||||
self.assertIn("运输方式:海运", scheme)
|
||||
self.assertIn("货物数量:", scheme)
|
||||
self.assertIn("整柜或拼柜:", scheme)
|
||||
self.assertIn("箱型箱量:", scheme)
|
||||
self.assertNotIn("件数:", scheme)
|
||||
self.assertNotIn("重量:", scheme)
|
||||
self.assertNotIn("体积:", scheme)
|
||||
self.assertNotIn("包装类型:", scheme)
|
||||
cards = [
|
||||
x.get("template_card") or {}
|
||||
for x in self.extras
|
||||
if (x.get("template_card") or {}).get("main_title", {}).get("title", "").endswith("报价方案")
|
||||
]
|
||||
self.assertTrue(cards)
|
||||
rows = {r["keyname"]: r["value"] for r in cards[0].get("horizontal_content_list") or []}
|
||||
self.assertIn("海运", rows.get("航线", ""))
|
||||
self.assertIn("箱型", rows)
|
||||
self.assertNotIn("包装", rows)
|
||||
again = self.flow.on_button(
|
||||
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
|
||||
)
|
||||
self.assertEqual(again, "card_discard")
|
||||
|
||||
def test_pull_group_success_greys_buttons(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "张产品",
|
||||
"wecomId": "prod1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self._quote_first()
|
||||
wo = self.flow.session_of("s1").work_order_no
|
||||
phase = self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
card_meta={"response_code": "rc-pull-1"},
|
||||
)
|
||||
self.assertEqual(phase, "collab_group")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("协同群已创建", joined)
|
||||
self.assertIn(wo, joined)
|
||||
self.assertIn("销售", joined)
|
||||
self.assertIn("产品张产品", joined)
|
||||
self.assertEqual(self.groups.created[0]["name"], wo)
|
||||
self.assertIn("prod1", self.groups.created[0]["userids"])
|
||||
self.assertIn("s1", self.groups.created[0]["userids"])
|
||||
self.assertNotIn("AI001", self.groups.created[0]["userids"])
|
||||
self.assertTrue(self.groups.sent)
|
||||
group_text = self.groups.sent[0]["content"]
|
||||
self.assertIn(wo, group_text)
|
||||
self.assertIn("起运港", group_text)
|
||||
self.assertIn("贸易条款", group_text)
|
||||
self.assertIn("TMS", group_text)
|
||||
sess = self.flow.session_of("s1")
|
||||
self.assertNotIn(copy.BTN_PULL_COLLAB, sess.allowed)
|
||||
self.assertNotIn(copy.BTN_SKIP_COLLAB, sess.allowed)
|
||||
again = self.flow.on_button(
|
||||
sender_id="s1", action=copy.BTN_SKIP_COLLAB, reply=self._reply
|
||||
)
|
||||
self.assertEqual(again, "card_discard")
|
||||
|
||||
def test_pull_group_includes_archive_bot(self) -> None:
|
||||
self.ledger.upsert_staff(
|
||||
{
|
||||
"name": "询价机器人",
|
||||
"wecomId": "AI001",
|
||||
"roleCode": "sales",
|
||||
"status": "active",
|
||||
}
|
||||
)
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "张产品",
|
||||
"wecomId": "prod1",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运全线",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self._quote_first()
|
||||
self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
userids = self.groups.created[0]["userids"]
|
||||
self.assertIn("AI001", userids)
|
||||
self.assertIn("s1", userids)
|
||||
self.assertIn("prod1", userids)
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("产品张产品", joined)
|
||||
self.assertNotIn("产品询价机器人", joined)
|
||||
self.assertNotIn("销售询价机器人", joined)
|
||||
|
||||
def test_pull_no_staff_keeps_button(self) -> None:
|
||||
self.ledger.set_sea_staff([])
|
||||
self._quote_first()
|
||||
phase = self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertIn("没有可拉的产品人员", "\n".join(self.replies))
|
||||
self.assertEqual(self.groups.created, [])
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, self.flow.session_of("s1").allowed)
|
||||
|
||||
def test_pull_already_has_group_no_second(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "李产品",
|
||||
"wecomId": "prod2",
|
||||
"roleCode": "sea",
|
||||
"routes": "上海",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self._quote_first()
|
||||
self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
n = len(self.groups.created)
|
||||
self.flow.session_of("s1").allowed = (copy.BTN_PULL_COLLAB,)
|
||||
phase = self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertEqual(phase, "collab_group")
|
||||
self.assertEqual(len(self.groups.created), n)
|
||||
self.assertIn("群已在", "\n".join(self.replies))
|
||||
|
||||
def test_miss_group_message_writes_no_quote(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "王产品",
|
||||
"wecomId": "prod3",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="s1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertTrue(self.groups.sent)
|
||||
self.assertIn("暂无匹配报价", self.groups.sent[0]["content"])
|
||||
|
||||
|
||||
class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
self.groups = FakeGroupClient()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger, group_client=self.groups)
|
||||
self.replies: list[str] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
|
||||
def test_group_text_writes_incoterm_silently(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "赵产品",
|
||||
"wecomId": "prod4",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
wo = self.flow.session_of("sales1").work_order_no
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
chat_id = ticket.collab_chat_id
|
||||
n = len(self.replies)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g1",
|
||||
content="贸易条款:FOB",
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "collab_field_saved")
|
||||
self.assertEqual(len(self.replies), n)
|
||||
self.assertEqual(ticket.collab_facts.get("贸易条款"), "FOB")
|
||||
|
||||
def test_group_price_chat_is_ignored(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"name": "赵产品",
|
||||
"wecomId": "prod4",
|
||||
"roleCode": "sea",
|
||||
"routes": "海运·全部线路",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.flow.on_text(
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="sales1",
|
||||
action=copy.BTN_PULL_COLLAB,
|
||||
reply=self._reply,
|
||||
)
|
||||
wo = self.flow.session_of("sales1").work_order_no
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g2",
|
||||
content="报价 1200 美元可以做",
|
||||
chat_id=ticket.collab_chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "group_ignore")
|
||||
self.assertFalse(ticket.quote_version > 1 and "1200" in str(ticket.quote))
|
||||
|
||||
|
||||
class SeaContinueTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.runtime import shutdown_redis_runtime
|
||||
|
||||
shutdown_redis_runtime()
|
||||
self.ledger = MemoryLedger()
|
||||
self.ledger.set_quote_templates(
|
||||
[
|
||||
{
|
||||
"templateId": "mem-default-sea",
|
||||
"name": "海运默认",
|
||||
"bizType": "海运",
|
||||
"keywords": [],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
)
|
||||
self.flow = SeaTextInquiryFlow(self.ledger)
|
||||
self.replies: list[str] = []
|
||||
self.extras: list[dict] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> str:
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
return ""
|
||||
|
||||
def test_continue_after_skip_replays_sea_scheme_not_air(self) -> None:
|
||||
"""已跳过协同后再续办:必须海运方案卡,不能套空运件数包装。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.handlers.continue_thread import handle_continue_thread
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
self.flow.on_text(
|
||||
sender_id="c1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="c1",
|
||||
action=copy.BTN_SKIP_COLLAB,
|
||||
reply=self._reply,
|
||||
card_meta={"response_code": "rc-cont-1"},
|
||||
)
|
||||
wo = self.flow.session_of("c1").work_order_no
|
||||
store = MemoryMessageStore()
|
||||
msg = InboundMessage(
|
||||
sender_id="c1",
|
||||
message_id="m-cont-sea",
|
||||
content=f"继续工单{wo}",
|
||||
)
|
||||
air_trap = AirTextInquiryFlow(MemoryLedger())
|
||||
with patch(
|
||||
"agent.handlers.continue_thread.get_sea_text_flow", return_value=self.flow
|
||||
), patch(
|
||||
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
|
||||
):
|
||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
cards = [
|
||||
(item.payload or {}).get("template_card") or {}
|
||||
for item in store._outbox.values()
|
||||
]
|
||||
titles = [
|
||||
(c.get("main_title") or {}).get("title") or "" for c in cards if c
|
||||
]
|
||||
self.assertTrue(any(t == f"{wo}报价方案" for t in titles))
|
||||
rows = {}
|
||||
for card in cards:
|
||||
if (card.get("main_title") or {}).get("title") == f"{wo}报价方案":
|
||||
rows = {
|
||||
r["keyname"]: r["value"]
|
||||
for r in card.get("horizontal_content_list") or []
|
||||
}
|
||||
self.assertIn("海运", rows.get("航线", ""))
|
||||
self.assertIn("箱型", rows)
|
||||
self.assertNotIn("包装", rows)
|
||||
|
||||
def test_continue_from_ledger_uses_sea_collab_card(self) -> None:
|
||||
"""进程无书签:主账海运已报价应回收海运协同卡,不是空运卡。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.handlers.continue_thread import handle_continue_thread
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="c2", business_line="SEA", facts=COMPLETE_SEA
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={
|
||||
"total": "USD 1,200",
|
||||
"eta": "18天",
|
||||
"valid_until": "2026-09-20",
|
||||
"source_label": "TMS 标准报价",
|
||||
},
|
||||
to_status="已报价",
|
||||
)
|
||||
empty = SeaTextInquiryFlow(self.ledger)
|
||||
store = MemoryMessageStore()
|
||||
msg = InboundMessage(
|
||||
sender_id="c2",
|
||||
message_id="m-cont-ledger",
|
||||
content=f"继续工单{wo}",
|
||||
)
|
||||
air_trap = AirTextInquiryFlow(MemoryLedger())
|
||||
with patch(
|
||||
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
|
||||
), patch(
|
||||
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
|
||||
):
|
||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
sess = empty.session_by_ticket("c2", wo)
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertEqual(sess.business_line, "SEA")
|
||||
joined = "\n".join(item.content for item in store._outbox.values())
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
self.assertIn("是否拉产品人员进群协同", joined)
|
||||
self.assertNotIn("包装方式", joined)
|
||||
|
||||
def test_continue_after_quote_file_returns_deal(self) -> None:
|
||||
"""报价单已发出后再续办:必须成交跟进,不能退回生成报价单。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.handlers.continue_thread import handle_continue_thread
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
self.flow.on_text(
|
||||
sender_id="c3",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.flow.on_button(
|
||||
sender_id="c3",
|
||||
action=copy.BTN_SKIP_COLLAB,
|
||||
reply=self._reply,
|
||||
card_meta={"response_code": "rc-deal-1"},
|
||||
)
|
||||
wo = self.flow.session_of("c3").work_order_no
|
||||
self.flow.on_button(
|
||||
sender_id="c3",
|
||||
action=copy.BTN_ADOPT_EXCEL,
|
||||
reply=self._reply,
|
||||
card_meta={"event_key": f"adopt_excel:{wo}", "response_code": "rc-xl"},
|
||||
)
|
||||
self.assertEqual(self.flow.session_of("c3").phase, "wait_deal")
|
||||
empty = SeaTextInquiryFlow(self.ledger)
|
||||
store = MemoryMessageStore()
|
||||
msg = InboundMessage(
|
||||
sender_id="c3",
|
||||
message_id="m-cont-deal",
|
||||
content=f"继续工单{wo}",
|
||||
)
|
||||
air_trap = AirTextInquiryFlow(MemoryLedger())
|
||||
with patch(
|
||||
"agent.handlers.continue_thread.get_sea_text_flow", return_value=empty
|
||||
), patch(
|
||||
"agent.handlers.continue_thread.get_air_text_flow", return_value=air_trap
|
||||
):
|
||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
joined = "\n".join(item.content for item in store._outbox.values())
|
||||
self.assertTrue(any(x in joined for x in (copy.BTN_DEAL, "成交")))
|
||||
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
|
||||
|
||||
class SeaGroupCopyTests(unittest.TestCase):
|
||||
def test_scheme_card_uses_sea_fields(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
quote = {"total": "USD 1200", "eta": "18天", "valid_until": "2026-09-20"}
|
||||
text = copy.scheme_card(
|
||||
work_order_no="WO202609150021",
|
||||
status="已报价",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
transport_mode="海运",
|
||||
)
|
||||
self.assertIn("运输方式:海运", text)
|
||||
self.assertIn("货物数量:15件", text)
|
||||
self.assertIn("箱型箱量:1x40HQ", text)
|
||||
self.assertNotIn("件数:", text)
|
||||
self.assertNotIn("包装类型:", text)
|
||||
rows = {
|
||||
x["keyname"]: x["value"]
|
||||
for x in copy.scheme_horizontal_rows(
|
||||
work_order_no="WO202609150021",
|
||||
facts=facts,
|
||||
quote=quote,
|
||||
transport_mode="海运",
|
||||
)
|
||||
}
|
||||
self.assertIn("海运 上海→洛杉矶", rows["航线"])
|
||||
self.assertIn("15件", rows["货物"])
|
||||
self.assertIn("1x40HQ", rows["箱型"])
|
||||
self.assertNotIn("包装", rows)
|
||||
|
||||
def test_invited_names_carry_role(self) -> None:
|
||||
text = copy.sea_group_created(
|
||||
work_order_no="WO202609150019",
|
||||
sales_name="吴季林",
|
||||
product_names=["罗小华"],
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"协同群已创建,群名与工单号一致:WO202609150019。"
|
||||
"已邀请:销售吴季林、产品罗小华。请在群聊中继续确认报价。",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+73
-1
@@ -34,6 +34,9 @@ public class InquiryAgentController {
|
||||
@Autowired
|
||||
private IInquiryQuoteTemplateService quoteTemplateService;
|
||||
|
||||
@Autowired
|
||||
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
|
||||
|
||||
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
|
||||
private String agentToken;
|
||||
|
||||
@@ -122,7 +125,12 @@ public class InquiryAgentController {
|
||||
Map<String, Object> facts = asMap(body == null ? null : body.get("facts"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> quote = asMap(body == null ? null : body.get("quote"));
|
||||
return Result.OK(quoteTemplateService.renderForAgent(workOrderNo, businessLine, facts, quote, sales));
|
||||
Map<String, Object> rendered = quoteTemplateService.renderForAgent(
|
||||
workOrderNo, businessLine, facts, quote, sales);
|
||||
if (rendered != null && Boolean.TRUE.equals(rendered.get("ok"))) {
|
||||
workOrderService.markQuoteFileIssued(workOrderNo);
|
||||
}
|
||||
return Result.OK(rendered);
|
||||
}
|
||||
|
||||
@ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)")
|
||||
@@ -135,6 +143,70 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.queryTms(body));
|
||||
}
|
||||
|
||||
@ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运")
|
||||
@PostMapping("/staff/matchSea")
|
||||
public Result<Map<String, Object>> matchSea(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
String origin = firstStr(body, "origin", "起运港");
|
||||
String dest = firstStr(body, "destination", "目的港");
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("staff", staffRoleService.matchSeaStaff(origin, dest));
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("按企微 userid 取员工展示名")
|
||||
@PostMapping("/staff/byWecom")
|
||||
public Result<Map<String, Object>> staffByWecom(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(staffRoleService.findDisplayByWecomId(firstStr(body, "wecomId", "wecom_id")));
|
||||
}
|
||||
|
||||
@ApiOperation("按员工姓名取企微 userid(会话存档询价机器人)")
|
||||
@PostMapping("/staff/byName")
|
||||
public Result<Map<String, Object>> staffByName(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
|
||||
}
|
||||
|
||||
@ApiOperation("记下海运协同群(不改六态)")
|
||||
@PostMapping("/ticket/bindCollabGroup")
|
||||
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.bindCollabGroup(body));
|
||||
}
|
||||
|
||||
@ApiOperation("合并协同字段(不改六态、不写报价版本)")
|
||||
@PostMapping("/ticket/patchCollabFacts")
|
||||
public Result<Map<String, Object>> patchCollabFacts(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.patchCollabFacts(body));
|
||||
}
|
||||
|
||||
@ApiOperation("按协同群 chat_id 找回工单(只读)")
|
||||
@PostMapping("/ticket/byCollabChat")
|
||||
public Result<Map<String, Object>> byCollabChat(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.findByCollabChat(firstStr(body, "chatId", "chat_id")));
|
||||
}
|
||||
|
||||
private boolean authed(HttpServletRequest http) {
|
||||
String got = http.getHeader("X-Agent-Token");
|
||||
if (StringUtils.isBlank(got)) {
|
||||
|
||||
+3
-8
@@ -50,11 +50,10 @@ public class InquiryQuoteTemplateController {
|
||||
@GetMapping("/list")
|
||||
public Result<IPage<Map<String, Object>>> list(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String bizType,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(defaultValue = "20") Integer pageSize) {
|
||||
return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, category, status, pageNo, pageSize));
|
||||
return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, status, pageNo, pageSize));
|
||||
}
|
||||
|
||||
@AutoLog(value = "报价模板-上传版本")
|
||||
@@ -64,13 +63,12 @@ public class InquiryQuoteTemplateController {
|
||||
public Result<Map<String, Object>> upload(@RequestParam(required = false) String templateId,
|
||||
@RequestParam(required = false) String name,
|
||||
@RequestParam(required = false) String bizType,
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String keywordsJson,
|
||||
@RequestParam(required = false, defaultValue = "auto") String mode,
|
||||
@RequestParam(required = false, defaultValue = "false") Boolean isDefault,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
Map<String, Object> row = quoteTemplateService.uploadVersion(
|
||||
templateId, name, bizType, category, keywordsJson, mode,
|
||||
templateId, name, bizType, keywordsJson, mode,
|
||||
Boolean.TRUE.equals(isDefault), file, currentUser());
|
||||
return Result.OK(row);
|
||||
}
|
||||
@@ -136,7 +134,7 @@ public class InquiryQuoteTemplateController {
|
||||
return Result.OK(quoteTemplateService.getById(id));
|
||||
}
|
||||
|
||||
@ApiOperation("编辑逻辑头(名称/分类/关键词/模式,不换文件)")
|
||||
@ApiOperation("编辑逻辑头(名称/业务/关键词/模式,不换文件)")
|
||||
@RequiresPermissions("inquiry:template:edit")
|
||||
@PutMapping("/editMeta")
|
||||
public Result<String> editMeta(@RequestBody InquiryQuoteTemplate body) {
|
||||
@@ -153,9 +151,6 @@ public class InquiryQuoteTemplateController {
|
||||
if (StringUtils.isNotBlank(body.getBizType())) {
|
||||
db.setBizType(body.getBizType());
|
||||
}
|
||||
if (StringUtils.isNotBlank(body.getCategory())) {
|
||||
db.setCategory(body.getCategory());
|
||||
}
|
||||
if (StringUtils.isNotBlank(body.getKeywordsJson())) {
|
||||
db.setKeywordsJson(body.getKeywordsJson());
|
||||
}
|
||||
|
||||
+2
-4
@@ -17,7 +17,8 @@ import java.util.Date;
|
||||
/**
|
||||
* 报价模板逻辑头,对应 inq_quote_template。
|
||||
* <p>
|
||||
* 职责:业务身份(海陆空/分类/关键词/默认/启停/auto|manual)。
|
||||
* 职责:业务身份(海陆空/关键词/默认/启停/auto|manual)。
|
||||
* 分类字段已下线:匹配只靠 bizType + keywords + isDefault。
|
||||
* 版式字节与映射在版本表;历史报价应绑 templateId + versionNo。
|
||||
*/
|
||||
@Data
|
||||
@@ -38,9 +39,6 @@ public class InquiryQuoteTemplate implements Serializable {
|
||||
@ApiModelProperty("海运/空运/陆运")
|
||||
private String bizType;
|
||||
|
||||
@ApiModelProperty("分类")
|
||||
private String category;
|
||||
|
||||
@ApiModelProperty("关键词 JSON 数组字符串")
|
||||
private String keywordsJson;
|
||||
|
||||
|
||||
+21
@@ -118,6 +118,27 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("业务版本")
|
||||
private Integer version;
|
||||
|
||||
@ApiModelProperty("海运货量原文")
|
||||
private String cargoQty;
|
||||
|
||||
@ApiModelProperty("整柜或拼柜")
|
||||
private String fclLcl;
|
||||
|
||||
@ApiModelProperty("箱型箱量原文")
|
||||
private String containerSpec;
|
||||
|
||||
@ApiModelProperty("询价字段快照 JSON")
|
||||
private String factsJson;
|
||||
|
||||
@ApiModelProperty("协同字段快照 JSON")
|
||||
private String collabFactsJson;
|
||||
|
||||
@ApiModelProperty("协同群 chat_id")
|
||||
private String collabChatId;
|
||||
|
||||
@ApiModelProperty("协同群成员 userid JSON")
|
||||
private String collabMemberIds;
|
||||
|
||||
private String createBy;
|
||||
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
+2
-2
@@ -15,13 +15,13 @@ import java.util.Map;
|
||||
*/
|
||||
public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTemplate> {
|
||||
|
||||
IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String category, String status,
|
||||
IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String status,
|
||||
int pageNo, int pageSize);
|
||||
|
||||
/**
|
||||
* 新建逻辑模板并写入第一版;或对已有模板追加新版本。
|
||||
*/
|
||||
Map<String, Object> uploadVersion(String templateId, String name, String bizType, String category,
|
||||
Map<String, Object> uploadVersion(String templateId, String name, String bizType,
|
||||
String keywordsJson, String mode, boolean isDefault,
|
||||
MultipartFile file, String operator);
|
||||
|
||||
|
||||
+15
@@ -30,4 +30,19 @@ public interface IInquiryStaffRoleService extends IService<InquiryStaffRole> {
|
||||
* @return 同步计数
|
||||
*/
|
||||
StaffWecomSyncResult syncFromWeCom(String operator);
|
||||
|
||||
/**
|
||||
* 海运拉群:启用的产品·海运,按起运/目的匹配线路。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> matchSeaStaff(String origin, String destination);
|
||||
|
||||
/**
|
||||
* 按企微 userid 取展示名。
|
||||
*/
|
||||
java.util.Map<String, Object> findDisplayByWecomId(String wecomId);
|
||||
|
||||
/**
|
||||
* 按员工姓名取启用账号(会话存档「询价机器人」用)。
|
||||
*/
|
||||
java.util.Map<String, Object> findDisplayByName(String name);
|
||||
}
|
||||
|
||||
+21
@@ -68,4 +68,25 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 必须校验 salesWecomId 与工单发起人一致。
|
||||
*/
|
||||
Map<String, Object> getForAgent(String workOrderNo, String salesWecomId);
|
||||
|
||||
/**
|
||||
* 报价文件已发给销售:写入报价载荷 quoteFileIssued,续办据此回到成交跟进。
|
||||
* 不改六态(仍是已报价,直到点成交/未成交/协商中)。
|
||||
*/
|
||||
void markQuoteFileIssued(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 记下海运协同群。不改六态。
|
||||
*/
|
||||
Map<String, Object> bindCollabGroup(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 合并协同字段。不改六态、不写报价版本。
|
||||
*/
|
||||
Map<String, Object> patchCollabFacts(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 用协同群 chat_id 找回工单。只读。
|
||||
*/
|
||||
Map<String, Object> findByCollabChat(String chatId);
|
||||
}
|
||||
|
||||
+15
-25
@@ -57,20 +57,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
private String contractPath;
|
||||
|
||||
@Override
|
||||
public IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String category, String status,
|
||||
public IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String status,
|
||||
int pageNo, int pageSize) {
|
||||
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
|
||||
if (StringUtils.isNotBlank(bizType)) {
|
||||
qw.eq("biz_type", bizType);
|
||||
}
|
||||
if (StringUtils.isNotBlank(category)) {
|
||||
qw.eq("category", category);
|
||||
}
|
||||
if (StringUtils.isNotBlank(status)) {
|
||||
qw.eq("status", status);
|
||||
}
|
||||
if (StringUtils.isNotBlank(keyword)) {
|
||||
qw.and(w -> w.like("name", keyword).or().like("category", keyword).or().like("keywords_json", keyword));
|
||||
qw.and(w -> w.like("name", keyword).or().like("keywords_json", keyword));
|
||||
}
|
||||
qw.orderByDesc("update_time").orderByDesc("create_time");
|
||||
IPage<InquiryQuoteTemplate> page = page(new Page<>(pageNo, pageSize), qw);
|
||||
@@ -88,7 +85,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
m.put("id", t.getId());
|
||||
m.put("name", t.getName());
|
||||
m.put("bizType", t.getBizType());
|
||||
m.put("category", t.getCategory());
|
||||
m.put("keywords", parseKeywords(t.getKeywordsJson()));
|
||||
m.put("mode", t.getMode());
|
||||
m.put("status", t.getStatus());
|
||||
@@ -122,7 +118,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> uploadVersion(String templateId, String name, String bizType, String category,
|
||||
public Map<String, Object> uploadVersion(String templateId, String name, String bizType,
|
||||
String keywordsJson, String mode, boolean isDefault,
|
||||
MultipartFile file, String operator) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
@@ -138,7 +134,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
template = new InquiryQuoteTemplate();
|
||||
template.setName(StringUtils.defaultIfBlank(name, stripExt(original)));
|
||||
template.setBizType(StringUtils.defaultIfBlank(bizType, "海运"));
|
||||
template.setCategory(StringUtils.defaultIfBlank(category, defaultCategory(template.getBizType())));
|
||||
template.setKeywordsJson(StringUtils.defaultIfBlank(keywordsJson, "[]"));
|
||||
template.setMode(StringUtils.defaultIfBlank(mode, "auto"));
|
||||
template.setStatus("disabled");
|
||||
@@ -159,9 +154,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (StringUtils.isNotBlank(bizType)) {
|
||||
template.setBizType(bizType);
|
||||
}
|
||||
if (StringUtils.isNotBlank(category)) {
|
||||
template.setCategory(category);
|
||||
}
|
||||
if (StringUtils.isNotBlank(keywordsJson)) {
|
||||
template.setKeywordsJson(keywordsJson);
|
||||
}
|
||||
@@ -445,7 +437,6 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
m.put("templateId", t.getId());
|
||||
m.put("name", t.getName());
|
||||
m.put("bizType", t.getBizType());
|
||||
m.put("category", t.getCategory());
|
||||
m.put("keywords", parseKeywords(t.getKeywordsJson()));
|
||||
m.put("isDefault", t.getIsDefault() != null && t.getIsDefault() == 1);
|
||||
m.put("versionNo", ver.getVersionNo());
|
||||
@@ -661,15 +652,24 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读库 keywords_json。合法 JSON 数组优先;非法(如 {@code [海运,整柜,FCL]})
|
||||
* 或数组里套了一整段方括号,都交给 {@link QuoteTemplateMatcher#asKeywords} 摊开。
|
||||
* 禁止把整段原文当成一个关键词,否则海运有模板也对不上。
|
||||
*/
|
||||
private static List<String> parseKeywords(String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return JSON.parseArray(json, String.class);
|
||||
} catch (Exception e) {
|
||||
return Collections.singletonList(json);
|
||||
List<String> parsed = JSON.parseArray(json, String.class);
|
||||
if (parsed != null && !parsed.isEmpty()) {
|
||||
return QuoteTemplateMatcher.asKeywords(parsed);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 非法 JSON:按方括号/逗号拆
|
||||
}
|
||||
return QuoteTemplateMatcher.asKeywords(json);
|
||||
}
|
||||
|
||||
private static List<String> extractUnmapped(String mappingJson) {
|
||||
@@ -707,14 +707,4 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
int i = name.lastIndexOf('.');
|
||||
return i > 0 ? name.substring(0, i) : name;
|
||||
}
|
||||
|
||||
private static String defaultCategory(String bizType) {
|
||||
if ("空运".equals(bizType)) {
|
||||
return "空运到港报价单模板";
|
||||
}
|
||||
if ("陆运".equals(bizType)) {
|
||||
return "国内拼车";
|
||||
}
|
||||
return "海运报价单模板";
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -132,4 +132,49 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运拉群:库里启用的产品·海运,再按起运/目的过滤线路。
|
||||
*/
|
||||
@Override
|
||||
public List<Map<String, Object>> matchSeaStaff(String origin, String destination) {
|
||||
List<InquiryStaffRole> rows = list(new QueryWrapper<InquiryStaffRole>()
|
||||
.eq("role_code", "sea")
|
||||
.eq("status", "active"));
|
||||
return org.jeecg.modules.inquiry.staff.SeaStaffMatcher.match(rows, origin, destination);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findDisplayByWecomId(String wecomId) {
|
||||
InquiryStaffRole row = findByWecomId(wecomId);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (row == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("name", row.getName());
|
||||
out.put("wecomId", row.getWecomId());
|
||||
out.put("roleCode", row.getRoleCode());
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> findDisplayByName(String name) {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (StringUtils.isBlank(name)) {
|
||||
return out;
|
||||
}
|
||||
InquiryStaffRole row = getOne(new QueryWrapper<InquiryStaffRole>()
|
||||
.eq("name", name.trim())
|
||||
.eq("status", "active")
|
||||
.isNotNull("wecom_id")
|
||||
.ne("wecom_id", "")
|
||||
.last("LIMIT 1"), false);
|
||||
if (row == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("name", row.getName());
|
||||
out.put("wecomId", row.getWecomId());
|
||||
out.put("roleCode", row.getRoleCode());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
+260
-1
@@ -301,6 +301,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
|
||||
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
|
||||
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
|
||||
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
|
||||
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
|
||||
ticket.setContainerSpec(StringUtils.trimToNull(facts.get("箱型箱量")));
|
||||
ticket.setFactsJson(JSON.toJSONString(facts));
|
||||
ticket.setDemandTitle(ticket.getOrigin() + " → " + ticket.getDestination());
|
||||
ticket.setDemandDetail("FP:" + fingerprint(req));
|
||||
ticket.setStatus(TicketStatuses.INQUIRING);
|
||||
@@ -422,8 +426,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
StringUtils.trimToEmpty(facts.get("目的港")),
|
||||
StringUtils.trimToEmpty(facts.get("品名")),
|
||||
StringUtils.trimToEmpty(facts.get("件数")),
|
||||
StringUtils.trimToEmpty(firstFact(facts, "货量", "货物数量")),
|
||||
StringUtils.trimToEmpty(firstFact(facts, "重量", "毛重")),
|
||||
StringUtils.trimToEmpty(facts.get("体积")),
|
||||
StringUtils.trimToEmpty(facts.get("箱型箱量")),
|
||||
StringUtils.trimToEmpty(facts.get("整柜或拼柜")),
|
||||
StringUtils.trimToEmpty(facts.get("报价日期")));
|
||||
}
|
||||
|
||||
@@ -528,6 +535,25 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (ticket.getQuoteDate() != null) {
|
||||
facts.put("报价日期", new SimpleDateFormat("yyyy-MM-dd").format(ticket.getQuoteDate()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getCargoQty())) {
|
||||
facts.put("货量", ticket.getCargoQty());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFclLcl())) {
|
||||
facts.put("整柜或拼柜", ticket.getFclLcl());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getContainerSpec())) {
|
||||
facts.put("箱型箱量", ticket.getContainerSpec());
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (StringUtils.isBlank(facts.get(key)) && stored.get(key) != null) {
|
||||
facts.put(key, String.valueOf(stored.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no)
|
||||
@@ -545,7 +571,14 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
|
||||
waitPhase = "done";
|
||||
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
|
||||
waitPhase = "wait_adopt";
|
||||
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
|
||||
if (quoteFileAlreadyIssued(no, quote)) {
|
||||
waitPhase = "wait_deal";
|
||||
} else if ("SEA".equalsIgnoreCase(ticket.getBusinessLine())) {
|
||||
waitPhase = "wait_collab";
|
||||
} else {
|
||||
waitPhase = "wait_adopt";
|
||||
}
|
||||
} else {
|
||||
waitPhase = "tms_miss";
|
||||
}
|
||||
@@ -555,9 +588,235 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("facts", facts);
|
||||
out.put("quote", quote);
|
||||
out.put("wait_phase", waitPhase);
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (parsed != null) {
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
collabFacts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价文件已发给销售。只打载荷标记,不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markQuoteFileIssued(String workOrderNo) {
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (StringUtils.isBlank(no)) {
|
||||
return;
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, no)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (quotes == null || quotes.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
InquiryQuote row = quotes.get(0);
|
||||
JSONObject payload = StringUtils.isBlank(row.getPayloadJson())
|
||||
? new JSONObject(true)
|
||||
: JSON.parseObject(row.getPayloadJson());
|
||||
if (payload == null) {
|
||||
payload = new JSONObject(true);
|
||||
}
|
||||
payload.put("quoteFileIssued", true);
|
||||
row.setPayloadJson(payload.toJSONString());
|
||||
row.setUpdateTime(new Date());
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 续办停点:文件已发出或已有附件,就不再回到出方案/协同。
|
||||
*/
|
||||
private boolean quoteFileAlreadyIssued(String workOrderNo, Map<String, Object> quote) {
|
||||
Object flag = quote == null ? null : quote.get("quoteFileIssued");
|
||||
if (Boolean.TRUE.equals(flag) || "true".equalsIgnoreCase(String.valueOf(flag))) {
|
||||
return true;
|
||||
}
|
||||
Long n = attachmentMapper.selectCount(new LambdaQueryWrapper<InquiryAttachment>()
|
||||
.eq(InquiryAttachment::getWorkOrderNo, workOrderNo));
|
||||
return n != null && n > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下海运协同群 chat_id 与成员。不改六态、不写报价版本。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> bindCollabGroup(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
String chatId = text(body, "chatId", "chat_id");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (ticket == null || StringUtils.isBlank(chatId)) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_or_chat_missing");
|
||||
return out;
|
||||
}
|
||||
ticket.setCollabChatId(chatId);
|
||||
ticket.setCollabMemberIds(memberIdsJson(body));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "bind_collab_group", "agent", chatId);
|
||||
out.put("ok", true);
|
||||
out.put("chat_id", chatId);
|
||||
out.put("work_order_no", no);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并协同六字段。只写非空键;不改六态。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> patchCollabFacts(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
Map<String, String> merged = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject old = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (old != null) {
|
||||
for (String key : old.keySet()) {
|
||||
if (old.get(key) != null) {
|
||||
merged.put(key, String.valueOf(old.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Object raw = body == null ? null : body.get("facts");
|
||||
if (raw instanceof Map) {
|
||||
for (Map.Entry<?, ?> e : ((Map<?, ?>) raw).entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
String val = String.valueOf(e.getValue()).trim();
|
||||
if (!val.isEmpty()) {
|
||||
merged.put(String.valueOf(e.getKey()), val);
|
||||
}
|
||||
}
|
||||
}
|
||||
ticket.setCollabFactsJson(JSON.toJSONString(merged));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("facts", merged);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按协同群找回工单。群里补字段用。只读。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> findByCollabChat(String chatId) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String id = StringUtils.trimToEmpty(chatId);
|
||||
out.put("found", false);
|
||||
if (StringUtils.isBlank(id)) {
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getCollabChatId, id)
|
||||
.last("LIMIT 1"), false);
|
||||
if (ticket == null) {
|
||||
return out;
|
||||
}
|
||||
out.put("found", true);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||
out.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
out.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
out.put("memberIds", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
Map<String, String> collabFacts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (parsed != null) {
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
collabFacts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String memberIdsJson(Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
return "[]";
|
||||
}
|
||||
Object raw = body.get("memberIds");
|
||||
if (raw == null) {
|
||||
raw = body.get("member_ids");
|
||||
}
|
||||
if (raw instanceof java.util.Collection) {
|
||||
return JSON.toJSONString(raw);
|
||||
}
|
||||
if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
|
||||
return ((String) raw).trim();
|
||||
}
|
||||
return "[]";
|
||||
}
|
||||
|
||||
private static List<String> parseMemberIds(String raw) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return ids;
|
||||
}
|
||||
try {
|
||||
com.alibaba.fastjson.JSONArray arr = JSON.parseArray(raw);
|
||||
if (arr != null) {
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
String uid = StringUtils.trimToEmpty(arr.getString(i));
|
||||
if (!uid.isEmpty()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
} catch (Exception ignore) {
|
||||
// 兼容逗号分隔旧值
|
||||
}
|
||||
for (String part : raw.split("[,;]")) {
|
||||
String uid = StringUtils.trimToEmpty(part);
|
||||
if (!uid.isEmpty()) {
|
||||
ids.add(uid);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static String text(Map<String, Object> body, String... keys) {
|
||||
if (body == null || keys == null) {
|
||||
return "";
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object val = body.get(key);
|
||||
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
|
||||
return String.valueOf(val).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private InquiryTicket findByWorkOrderNo(String workOrderNo) {
|
||||
return getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package org.jeecg.modules.inquiry.staff;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 海运拉群人员匹配:只看员工管理里岗位=产品·海运且启用的人。
|
||||
* <p>
|
||||
* 「海运·全部线路 / 海运全线」必拉;写了具体港口的,对得上本单起运港或目的港才拉。
|
||||
* 禁止:按销售 userid 伪造名单;不查企微通讯录。
|
||||
*/
|
||||
public final class SeaStaffMatcher {
|
||||
|
||||
private SeaStaffMatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rows 员工岗位行
|
||||
* @param origin 本单起运港
|
||||
* @param destination 本单目的港
|
||||
* @return 给智能体的 {name, wecomId, routes}
|
||||
*/
|
||||
public static List<Map<String, Object>> match(List<InquiryStaffRole> rows, String origin, String destination) {
|
||||
List<Map<String, Object>> hits = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return hits;
|
||||
}
|
||||
String from = StringUtils.trimToEmpty(origin);
|
||||
String to = StringUtils.trimToEmpty(destination);
|
||||
for (InquiryStaffRole row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
if (!"sea".equalsIgnoreCase(StringUtils.trimToEmpty(row.getRoleCode()))) {
|
||||
continue;
|
||||
}
|
||||
if (!"active".equalsIgnoreCase(StringUtils.defaultIfBlank(row.getStatus(), "active"))) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isBlank(row.getWecomId())) {
|
||||
continue;
|
||||
}
|
||||
String routes = StringUtils.defaultString(row.getRoutes());
|
||||
if (routes.contains("海运·全部线路") || routes.contains("海运全线")
|
||||
|| (StringUtils.isNotBlank(from) && routes.contains(from))
|
||||
|| (StringUtils.isNotBlank(to) && routes.contains(to))) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("name", StringUtils.defaultString(row.getName()));
|
||||
item.put("wecomId", row.getWecomId().trim());
|
||||
item.put("roleCode", "sea");
|
||||
item.put("routes", routes);
|
||||
item.put("status", "active");
|
||||
hits.add(item);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -130,6 +130,10 @@ public final class QuoteTemplateMatcher {
|
||||
|
||||
/**
|
||||
* 模板 keywordsJson / 列表字段转关键词;空串不参与匹配。
|
||||
* <p>
|
||||
* 后台/seed 偶发把未加引号的 {@code [海运,整柜,FCL]} 整段塞进列表一项,
|
||||
* 或 JSON 解析失败后把原文当成单个词。这两种都要摊开,否则「海运」对不上、
|
||||
* 又没设默认模板时,销售会看到固定失败话术。
|
||||
*/
|
||||
public static List<String> asKeywords(Object raw) {
|
||||
List<String> out = new ArrayList<>();
|
||||
@@ -139,7 +143,9 @@ public final class QuoteTemplateMatcher {
|
||||
if (raw instanceof Collection) {
|
||||
for (Object it : (Collection<?>) raw) {
|
||||
String s = it == null ? "" : String.valueOf(it).trim();
|
||||
if (StringUtils.isNotBlank(s)) {
|
||||
if (s.startsWith("[")) {
|
||||
out.addAll(asKeywords(s));
|
||||
} else if (StringUtils.isNotBlank(s)) {
|
||||
out.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
+133
-10
@@ -1,6 +1,8 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
@@ -25,8 +27,9 @@ import java.util.TimeZone;
|
||||
/**
|
||||
* 智能体查价门面:询价必填齐了就必须打真 TMS,再按 TMS 回包分类。
|
||||
* <p>
|
||||
* 职责:销售侧起运/目的原文、品名、件数、重量、体积、包装、报价日期、工单号齐了就出站。
|
||||
* 职责:询价必填齐了就出站。空运看件数/重量/体积/包装;海运看货量/整柜或拼柜/箱型箱量,不拦空运量尺。
|
||||
* 机场三字码能投影就带上;对不上也必须出站,禁止本地判「暂无报价」。
|
||||
* 业务线以工单为准,禁止海运请求被默认成 AIR。
|
||||
* 分类:1 且有价 → 报价;TMS 已回 1002 / 无 quoteFound / 1003 → 暂无报价;
|
||||
* HTTP 超时/熔断/5xx → 技术失败。
|
||||
* 禁止:因 originCode 对不上就 skip;fixture 假价;LOCK/RELEASE;Python 直连 TMS。
|
||||
@@ -53,8 +56,18 @@ public class InquiryTmsQueryService {
|
||||
*/
|
||||
public Map<String, Object> queryFromAgent(Map<String, Object> body) {
|
||||
Map<String, Object> facts = extractFacts(body);
|
||||
String workOrderNo = text(first(body.get("work_order_no"), facts.get("work_order_no")));
|
||||
String line = text(first(facts.get("业务线"), facts.get("businessLine"), "AIR")).toUpperCase();
|
||||
String workOrderNo = text(first(
|
||||
body == null ? null : body.get("work_order_no"),
|
||||
body == null ? null : body.get("workOrderNo"),
|
||||
facts.get("work_order_no")));
|
||||
InquiryTicket ticket = findTicket(workOrderNo);
|
||||
if (ticket != null) {
|
||||
mergeTicketFacts(facts, ticket);
|
||||
}
|
||||
String line = resolveTransportLine(
|
||||
body,
|
||||
facts,
|
||||
ticket == null ? "" : ticket.getBusinessLine());
|
||||
|
||||
if (!properties.queryConfigured() || !gateway.isConfigured()) {
|
||||
return tech("TMS 未配置,拒绝假价");
|
||||
@@ -68,12 +81,23 @@ public class InquiryTmsQueryService {
|
||||
segment.put("origin", origin);
|
||||
segment.put("destination", destination);
|
||||
segment.put("cargoName", text(facts.get("品名")));
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
|
||||
if ("SEA".equals(line)) {
|
||||
// 海运不拦空运件数/重量/体积/包装;港口只带名称,没有港口字典也不跳过。
|
||||
segment.put("cargoQty", first(facts.get("货量"), facts.get("货物数量")));
|
||||
segment.put("loadType", facts.get("整柜或拼柜"));
|
||||
segment.put("containerSpec", facts.get("箱型箱量"));
|
||||
Object items = facts.get("containerItems");
|
||||
if (items != null) {
|
||||
segment.put("containerItems", items);
|
||||
}
|
||||
} else {
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
fillPackageType(segment, text(first(facts.get("packageType"), facts.get("包装方式"), facts.get("包装类型"))));
|
||||
}
|
||||
|
||||
String quoteDate = TmsV2RequestMapper.normalizeQuoteDate(facts.get("报价日期"));
|
||||
if (quoteDate == null) {
|
||||
@@ -82,7 +106,9 @@ public class InquiryTmsQueryService {
|
||||
quoteDate = fmt.format(new Date());
|
||||
}
|
||||
|
||||
List<String> missing = missingAirQuery(workOrderNo, quoteDate, segment);
|
||||
List<String> missing = "SEA".equals(line)
|
||||
? missingSeaQuery(workOrderNo, quoteDate, segment, facts)
|
||||
: missingAirQuery(workOrderNo, quoteDate, segment);
|
||||
if (!missing.isEmpty()) {
|
||||
log.warn("tms.query skip incomplete wo={} missing={}", workOrderNo, missing);
|
||||
return incomplete(missing);
|
||||
@@ -362,6 +388,40 @@ public class InquiryTmsQueryService {
|
||||
* 出站前只拦销售侧必填真缺。originCode / destinationCode 不是拦网:
|
||||
* 对不上机场也要查,TMS 自己回无价或 1003。
|
||||
*/
|
||||
/**
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
|
||||
* 不要求机场三字码、件数、重量、体积、包装。
|
||||
*/
|
||||
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment, Map<String, Object> facts) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
missing.add("workOrderNo");
|
||||
}
|
||||
if (quoteDate == null || quoteDate.isEmpty()) {
|
||||
missing.add("quoteDate");
|
||||
}
|
||||
for (String key : new String[] {"origin", "destination", "cargoName"}) {
|
||||
if (segment.get(key) == null || "".equals(segment.get(key))) {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
if (blank(facts.get("货量")) && blank(facts.get("货物数量"))) {
|
||||
missing.add("货量");
|
||||
}
|
||||
if (blank(facts.get("整柜或拼柜"))) {
|
||||
missing.add("整柜或拼柜");
|
||||
}
|
||||
if (blank(facts.get("箱型箱量"))) {
|
||||
missing.add("箱型箱量");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private static boolean blank(Object raw) {
|
||||
return raw == null || String.valueOf(raw).trim().isEmpty();
|
||||
}
|
||||
|
||||
private static List<String> missingAirQuery(String workOrderNo, String quoteDate, Map<String, Object> segment) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (workOrderNo == null || !workOrderNo.matches("WO\\d{12}")) {
|
||||
@@ -439,6 +499,69 @@ public class InquiryTmsQueryService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务线:请求体 / 字段快照 / 工单,禁止无业务线时把海运当成空运拦网。
|
||||
*/
|
||||
static String resolveTransportLine(Map<String, Object> body, Map<String, Object> facts, String ticketLine) {
|
||||
String line = text(first(
|
||||
body == null ? null : body.get("businessLine"),
|
||||
body == null ? null : body.get("business_line"),
|
||||
facts == null ? null : facts.get("业务线"),
|
||||
facts == null ? null : facts.get("businessLine"),
|
||||
facts == null ? null : facts.get("business_line"),
|
||||
ticketLine
|
||||
)).toUpperCase();
|
||||
if ("SEA".equals(line) || "AIR".equals(line) || "LAND".equals(line)) {
|
||||
return line;
|
||||
}
|
||||
return "AIR";
|
||||
}
|
||||
|
||||
private InquiryTicket findTicket(String workOrderNo) {
|
||||
if (StringUtils.isBlank(workOrderNo) || workOrderService == null) {
|
||||
return null;
|
||||
}
|
||||
return workOrderService.getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
.last("LIMIT 1"), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单已落库的海运事实补进查价 facts,避免智能体漏传货量/箱型。
|
||||
*/
|
||||
static void mergeTicketFacts(Map<String, Object> facts, InquiryTicket ticket) {
|
||||
if (facts == null || ticket == null) {
|
||||
return;
|
||||
}
|
||||
putIfBlank(facts, "起运港", ticket.getOrigin());
|
||||
putIfBlank(facts, "目的港", ticket.getDestination());
|
||||
putIfBlank(facts, "品名", ticket.getGoodsName());
|
||||
putIfBlank(facts, "货量", ticket.getCargoQty());
|
||||
putIfBlank(facts, "整柜或拼柜", ticket.getFclLcl());
|
||||
putIfBlank(facts, "箱型箱量", ticket.getContainerSpec());
|
||||
if (ticket.getQuoteDate() != null && blank(facts.get("报价日期"))) {
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd");
|
||||
fmt.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
facts.put("报价日期", fmt.format(ticket.getQuoteDate()));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
|
||||
if (stored != null) {
|
||||
for (String key : stored.keySet()) {
|
||||
if (blank(facts.get(key)) && stored.get(key) != null) {
|
||||
facts.put(key, stored.get(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void putIfBlank(Map<String, Object> facts, String key, String val) {
|
||||
if (blank(facts.get(key)) && StringUtils.isNotBlank(val)) {
|
||||
facts.put(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> extractFacts(Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 海运出站前把销售口语整理成 TMS V2 字段。
|
||||
* <p>
|
||||
* 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1),
|
||||
* loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。
|
||||
* 禁止:把口语原样扔给 TMS;在这里猜港口。
|
||||
*/
|
||||
public final class SeaTmsFieldNormalizer {
|
||||
|
||||
/** 1x40HQ / 2*40高柜 */
|
||||
private static final Pattern QTY_X_TYPE = Pattern.compile(
|
||||
"(?i)(\\d+)\\s*[xX×*]\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜|柜)?");
|
||||
|
||||
/** 40HQ*2 */
|
||||
private static final Pattern TYPE_X_QTY = Pattern.compile(
|
||||
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)?\\s*[xX×*]\\s*(\\d+)");
|
||||
|
||||
/** 2个40尺高柜 */
|
||||
private static final Pattern CN_GE = Pattern.compile(
|
||||
"(?i)(\\d+)\\s*个\\s*(20|40|45)\\s*['\"′尺]?(HQ|GP|高柜|平柜|柜)?");
|
||||
|
||||
/** 单独写 40HQ / 40高柜,箱量按 1 */
|
||||
private static final Pattern BARE_TYPE = Pattern.compile(
|
||||
"(?i)(20|40|45)\\s*['\"′尺]?(HQ|GP|NOR|RH|HC|高柜|平柜)");
|
||||
|
||||
private SeaTmsFieldNormalizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。
|
||||
*/
|
||||
public static String loadType(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String upper = text.toUpperCase(Locale.ROOT);
|
||||
if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) {
|
||||
return "FCL";
|
||||
}
|
||||
if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) {
|
||||
return "LCL";
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物数量:15件 / 20吨 → 15 / 20。带单位会 1001。
|
||||
*/
|
||||
public static BigDecimal cargoQty(Object raw) {
|
||||
return TmsV2RequestMapper.asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「1x40HQ」「2个40尺高柜」「1*20GP+1*40HQ」拆成 TMS 箱型列表。
|
||||
*
|
||||
* @return 每项含 containerType、containerQty;拆不出则空列表,不编造。
|
||||
*/
|
||||
public static List<Map<String, Object>> parseContainers(Object raw) {
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
if (raw == null) {
|
||||
return items;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
collect(items, QTY_X_TYPE.matcher(text), 1, 2, 3);
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
Matcher typeFirst = TYPE_X_QTY.matcher(text);
|
||||
while (typeFirst.find()) {
|
||||
addItem(items, typeFirst.group(1), typeFirst.group(2), typeFirst.group(3));
|
||||
}
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
collect(items, CN_GE.matcher(text), 1, 2, 3);
|
||||
if (!items.isEmpty()) {
|
||||
return items;
|
||||
}
|
||||
Matcher bare = BARE_TYPE.matcher(text);
|
||||
if (bare.find()) {
|
||||
addItem(items, bare.group(1), bare.group(2), "1");
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void collect(List<Map<String, Object>> items, Matcher matcher,
|
||||
int qtyGroup, int sizeGroup, int suffixGroup) {
|
||||
while (matcher.find()) {
|
||||
addItem(items, matcher.group(sizeGroup), matcher.group(suffixGroup), matcher.group(qtyGroup));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addItem(List<Map<String, Object>> items, String size, String suffix, String qty) {
|
||||
String type = toTmsType(size, suffix);
|
||||
Integer count = TmsV2RequestMapper.asInteger(qty);
|
||||
if (type == null || count == null || count < 1) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("containerType", type);
|
||||
row.put("containerQty", count);
|
||||
items.add(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。
|
||||
*/
|
||||
static String toTmsType(String size, String suffix) {
|
||||
if (size == null) {
|
||||
return null;
|
||||
}
|
||||
String foot = size.trim();
|
||||
String tag = suffix == null ? "" : suffix.trim().toUpperCase(Locale.ROOT);
|
||||
if (tag.contains("高") || "HQ".equals(tag) || "HC".equals(tag)) {
|
||||
return foot + "HQ";
|
||||
}
|
||||
if (tag.contains("NOR")) {
|
||||
return foot + "NOR";
|
||||
}
|
||||
if (tag.contains("RH")) {
|
||||
return foot + "RH";
|
||||
}
|
||||
if (tag.contains("平") || "GP".equals(tag) || "柜".equals(suffix) || tag.isEmpty()) {
|
||||
return foot + "GP";
|
||||
}
|
||||
return foot + tag;
|
||||
}
|
||||
}
|
||||
+35
@@ -15,6 +15,7 @@ import java.util.regex.Pattern;
|
||||
* 对照《友通达询价智能体TMS系统对接开发接口文档V1.0.1》5.2 / 2.1 / 6.3:
|
||||
* quantity 必须是 JSON 整数;weightKg / volumeCbm 必须是 JSON 小数且大于 0;
|
||||
* 带「KGS」「CBM」的字符串会触发业务码 1001(参数格式错误)。
|
||||
* 海运同样:整柜/1x40HQ/15件 必须先整理成 FCL + 40HQ + 1 + 数字货量,否则 TMS 回 1001。
|
||||
* 报价日期:文档漏了该请求字段,实际 TMS 使用 quoteDate,格式 yyyy-MM-dd(如 2026-09-07)。
|
||||
* 禁止:inquiryTime=此刻(会查成无匹配);在这里做字典模糊匹配或猜测机场代码。
|
||||
*/
|
||||
@@ -76,9 +77,43 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
|
||||
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
|
||||
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
|
||||
applySeaFields(source, target);
|
||||
return compact(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把海运口语字段写成 TMS 查询字段。空运段没有这些键则不写。
|
||||
*/
|
||||
private static void applySeaFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
Object cargoQty = first(source, "cargoQty", "货量");
|
||||
Object loadRaw = first(source, "loadType", "整柜或拼柜");
|
||||
Object spec = first(source, "containerSpec", "箱型箱量", "containerTypeQty");
|
||||
if (cargoQty == null && loadRaw == null && spec == null
|
||||
&& first(source, "containerType") == null
|
||||
&& first(source, "containerItems") == null) {
|
||||
return;
|
||||
}
|
||||
put(target, "cargoQty", SeaTmsFieldNormalizer.cargoQty(cargoQty));
|
||||
put(target, "loadType", SeaTmsFieldNormalizer.loadType(loadRaw));
|
||||
Object readyItems = first(source, "containerItems");
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers(spec);
|
||||
if (readyItems instanceof List && !((List<?>) readyItems).isEmpty()) {
|
||||
put(target, "containerItems", readyItems);
|
||||
} else if (items.size() > 1) {
|
||||
put(target, "containerItems", items);
|
||||
}
|
||||
Object type = first(source, "containerType");
|
||||
Object qty = first(source, "containerQty");
|
||||
if (type == null && !items.isEmpty()) {
|
||||
type = items.get(0).get("containerType");
|
||||
qty = items.get(0).get("containerQty");
|
||||
}
|
||||
put(target, "containerType", type);
|
||||
put(target, "containerQty", asInteger(qty));
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价日期规范成 yyyy-MM-dd。解析不了则不传,避免 1001。
|
||||
*/
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package org.jeecg.modules.inquiry.staff;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 海运拉群人员匹配:不连库、不打企微。
|
||||
* <p>
|
||||
* 「海运·全部线路」必拉;写了具体港口的,对得上本单起运/目的才拉;停用不拉。
|
||||
*/
|
||||
public class SeaStaffMatcherTest {
|
||||
|
||||
@Test
|
||||
public void allRoutesMustPull() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p1", "张产品", "海运·全部线路");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "洛杉矶");
|
||||
assertEquals(1, hits.size());
|
||||
assertEquals("p1", hits.get(0).get("wecomId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void specificPortPullsWhenMatch() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p2", "李产品", "上海,宁波");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "汉堡");
|
||||
assertEquals(1, hits.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void specificPortSkipsWhenMismatch() {
|
||||
InquiryStaffRole row = staff("sea", "active", "p3", "王产品", "深圳");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(row), "上海", "洛杉矶");
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledOrWrongRoleSkipped() {
|
||||
InquiryStaffRole disabled = staff("sea", "disabled", "p4", "停用", "海运·全部线路");
|
||||
InquiryStaffRole air = staff("air", "active", "p5", "空运岗", "海运·全部线路");
|
||||
List<Map<String, Object>> hits = SeaStaffMatcher.match(
|
||||
Arrays.asList(disabled, air), "上海", "洛杉矶");
|
||||
assertTrue(hits.isEmpty());
|
||||
}
|
||||
|
||||
private static InquiryStaffRole staff(String role, String status, String wecomId,
|
||||
String name, String routes) {
|
||||
InquiryStaffRole row = new InquiryStaffRole();
|
||||
row.setRoleCode(role);
|
||||
row.setStatus(status);
|
||||
row.setWecomId(wecomId);
|
||||
row.setName(name);
|
||||
row.setRoutes(routes);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
+31
@@ -51,6 +51,37 @@ public class QuoteTemplateMatcherTest {
|
||||
assertNull(QuoteTemplateMatcher.pick(list, "AIR", facts, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void brokenBracketKeywordStillHitsSea() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
Map<String, Object> sea = new HashMap<>();
|
||||
sea.put("name", "海运报价单模板");
|
||||
sea.put("bizType", "海运");
|
||||
sea.put("isDefault", false);
|
||||
List<String> broken = new ArrayList<>();
|
||||
broken.add("[海运,整柜,FCL]");
|
||||
sea.put("keywords", broken);
|
||||
list.add(sea);
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("整柜或拼柜", "整柜");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("keyword", hit.get("matchType"));
|
||||
assertEquals("海运报价单模板", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFallsBackToDefaultWhenKeywordMisses() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("海运默认", "海运", true, "洛杉矶"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("目的港", "纽约港");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "SEA", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("default", hit.get("matchType"));
|
||||
assertEquals("海运默认", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaTemplateIgnoredForAir() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 海运查价身份:不打真实 TMS。
|
||||
* <p>
|
||||
* 工单已是 SEA 时,即使请求没带业务线,也不能默认成 AIR。
|
||||
*/
|
||||
public class InquiryTmsQueryServiceTest {
|
||||
|
||||
@Test
|
||||
public void ticketSeaWinsWhenBodyOmitsLine() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("work_order_no", "WO202609150001");
|
||||
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "SEA");
|
||||
assertEquals("SEA", line);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bodyBusinessLineWins() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("businessLine", "SEA");
|
||||
String line = InquiryTmsQueryService.resolveTransportLine(body, new HashMap<>(), "AIR");
|
||||
assertEquals("SEA", line);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeTicketFillsSeaFacts() {
|
||||
InquiryTicket ticket = new InquiryTicket();
|
||||
ticket.setOrigin("上海");
|
||||
ticket.setDestination("洛杉矶");
|
||||
ticket.setGoodsName("普货");
|
||||
ticket.setCargoQty("20吨");
|
||||
ticket.setFclLcl("整柜");
|
||||
ticket.setContainerSpec("1x40HQ");
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
InquiryTmsQueryService.mergeTicketFacts(facts, ticket);
|
||||
assertEquals("上海", facts.get("起运港"));
|
||||
assertEquals("20吨", facts.get("货量"));
|
||||
assertEquals("整柜", facts.get("整柜或拼柜"));
|
||||
assertEquals("1x40HQ", facts.get("箱型箱量"));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 海运口语 → TMS 字段。不连 TMS。
|
||||
*/
|
||||
public class SeaTmsFieldNormalizerTest {
|
||||
|
||||
@Test
|
||||
public void loadTypeFclLcl() {
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cargoQtyStripsUnit() {
|
||||
assertEquals(new BigDecimal("15"), SeaTmsFieldNormalizer.cargoQty("15件"));
|
||||
assertEquals(new BigDecimal("20"), SeaTmsFieldNormalizer.cargoQty("20吨"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseOneByFortyHq() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x40HQ");
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("40HQ", items.get(0).get("containerType"));
|
||||
assertEquals(1, items.get(0).get("containerQty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseChineseHighCube() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("2个40尺高柜");
|
||||
assertEquals(1, items.size());
|
||||
assertEquals("40HQ", items.get(0).get("containerType"));
|
||||
assertEquals(2, items.get(0).get("containerQty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseTwoTypes() {
|
||||
List<Map<String, Object>> items = SeaTmsFieldNormalizer.parseContainers("1x20GP+1x40HQ");
|
||||
assertEquals(2, items.size());
|
||||
assertEquals("20GP", items.get(0).get("containerType"));
|
||||
assertEquals("40HQ", items.get(1).get("containerType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownTextNotInvented() {
|
||||
assertTrue(SeaTmsFieldNormalizer.parseContainers("随便写").isEmpty());
|
||||
}
|
||||
}
|
||||
+47
@@ -58,4 +58,51 @@ public class TmsV2RequestMapperTest {
|
||||
assertEquals(new java.math.BigDecimal("2"), segs.get(0).get("volumeCbm"));
|
||||
assertFalse(String.valueOf(segs.get(0).get("weightKg")).contains("KGS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaFieldsPassThroughWithoutAirMeasures() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海");
|
||||
seg.put("destination", "洛杉矶");
|
||||
seg.put("cargoName", "普货");
|
||||
seg.put("cargoQty", "20吨");
|
||||
seg.put("loadType", "整柜");
|
||||
List<Map<String, Object>> items = Collections.singletonList(
|
||||
Collections.singletonMap("type", "40HQ"));
|
||||
seg.put("containerItems", items);
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals(items, segs.get(0).get("containerItems"));
|
||||
assertFalse(segs.get(0).containsKey("quantity"));
|
||||
assertFalse(segs.get(0).containsKey("weightKg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oralSeaSpecBecomesTmsContainerFields() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海港");
|
||||
seg.put("destination", "巴生西港");
|
||||
seg.put("cargoName", "机器设备");
|
||||
seg.put("cargoQty", "15件");
|
||||
seg.put("loadType", "整柜");
|
||||
seg.put("containerSpec", "1x40HQ");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("40HQ", segs.get(0).get("containerType"));
|
||||
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# 是否启用mock(生产必须 false,否则会走本地 Mock 像旧骨架)
|
||||
VITE_USE_MOCK = false
|
||||
|
||||
# 网站标题(浏览器标签后缀;缺省会显示「页面名 undefined」)
|
||||
VITE_GLOB_APP_TITLE = 友通达询价系统
|
||||
|
||||
# 简称,用于生产 _app.config.js 变量名;勿空格/数字开头
|
||||
VITE_GLOB_APP_SHORT_NAME = YtdInquiryAdmin
|
||||
|
||||
# 发布路径
|
||||
VITE_PUBLIC_PATH = /
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function useTitle() {
|
||||
}
|
||||
|
||||
const tTitle = t(route?.meta?.title as string);
|
||||
pageTitle.value = tTitle ? ` ${tTitle} - ${title} ` : `${title}`;
|
||||
// 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」
|
||||
const appTitle = title || '友通达询价系统';
|
||||
pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* placeholderCount 语义改为 mappingPointCount(映射点数)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 列表行类型。分类字段已从运营页移除(筛选项/列/表单均不展示);
|
||||
* 主账库仍可存 category,由服务端按业务默认,前端不再读写。
|
||||
*/
|
||||
export type TemplateRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
bizType: "空运" | "海运" | "陆运";
|
||||
category: string;
|
||||
fileLabel: string;
|
||||
fileName: string;
|
||||
format: "excel" | "pdf";
|
||||
@@ -28,34 +31,6 @@ export type TemplateRow = {
|
||||
objectKey?: string;
|
||||
};
|
||||
|
||||
/** 业务 → 分类(与 contracts/quote-template-mapping-v1.json 一致) */
|
||||
export const CATEGORY_BY_BIZ: Record<TemplateRow["bizType"], string[]> = {
|
||||
海运: ["海运报价单模板"],
|
||||
空运: ["空运到港报价单模板", "空运到门报价单模板"],
|
||||
陆运: [
|
||||
"双清或中越",
|
||||
"国内整车",
|
||||
"国内拼车",
|
||||
"跨境整车",
|
||||
"跨境集拼",
|
||||
"中港",
|
||||
"凭祥保税仓&普仓仓储",
|
||||
],
|
||||
};
|
||||
|
||||
export const ALL_CATEGORIES = [
|
||||
"空运到港报价单模板",
|
||||
"空运到门报价单模板",
|
||||
"海运报价单模板",
|
||||
"双清或中越",
|
||||
"国内整车",
|
||||
"国内拼车",
|
||||
"跨境整车",
|
||||
"跨境集拼",
|
||||
"中港",
|
||||
"凭祥保税仓&普仓仓储",
|
||||
];
|
||||
|
||||
/** 将主账 list 记录规范为页面行 */
|
||||
export function mapApiRow(raw: any): TemplateRow {
|
||||
const created =
|
||||
@@ -68,7 +43,6 @@ export function mapApiRow(raw: any): TemplateRow {
|
||||
id: String(raw.id || ""),
|
||||
name: raw.name || "",
|
||||
bizType: (raw.bizType || "海运") as TemplateRow["bizType"],
|
||||
category: raw.category || "",
|
||||
fileLabel: raw.fileLabel || raw.name || "",
|
||||
fileName: raw.fileName || "",
|
||||
format: raw.format === "pdf" ? "pdf" : "excel",
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import {
|
||||
ALL_CATEGORIES,
|
||||
CATEGORY_BY_BIZ,
|
||||
mapApiRow,
|
||||
type TemplateRow,
|
||||
} from "/@/views/inquiry/mock/templates";
|
||||
import { mapApiRow, type TemplateRow } from "/@/views/inquiry/mock/templates";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import {
|
||||
disableTemplate,
|
||||
@@ -47,7 +42,6 @@ const rows = ref<TemplateRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const keyword = ref("");
|
||||
const bizFilter = ref("");
|
||||
const categoryFilter = ref("");
|
||||
const statusFilter = ref("");
|
||||
|
||||
const formOpen = ref(false);
|
||||
@@ -59,10 +53,10 @@ const previewVm = ref<PreviewViewModel>(emptyPreviewViewModel());
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const pendingFile = ref<File | null>(null);
|
||||
|
||||
/** 运营表单不含「分类」:主账上传时按业务自行补默认分类 */
|
||||
const form = reactive({
|
||||
name: "",
|
||||
bizType: "海运" as TemplateRow["bizType"],
|
||||
category: "",
|
||||
mode: "auto" as "auto" | "manual",
|
||||
keywordItems: [""] as string[],
|
||||
isDefault: false,
|
||||
@@ -98,10 +92,6 @@ const previewTotalText = computed(() => {
|
||||
return `${vm.currency || "USD"} ${amt}`;
|
||||
});
|
||||
|
||||
function defaultCategoryForBiz(biz: TemplateRow["bizType"]) {
|
||||
return (CATEGORY_BY_BIZ[biz] || [])[0] || "";
|
||||
}
|
||||
|
||||
const filtered = computed(() => rows.value);
|
||||
|
||||
function bizTag(biz: string) {
|
||||
@@ -110,13 +100,18 @@ function bizTag(biz: string) {
|
||||
return "tag-blue";
|
||||
}
|
||||
|
||||
/** 列表展示用:关键词用顿号拼接;空则显示占位 */
|
||||
function formatKeywords(keywords: string[] | undefined) {
|
||||
if (!keywords?.length) return "";
|
||||
return keywords.map((k) => String(k || "").trim()).filter(Boolean).join("、");
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getTemplateList({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
bizType: bizFilter.value || undefined,
|
||||
category: categoryFilter.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
@@ -139,7 +134,6 @@ function resetForm() {
|
||||
Object.assign(form, {
|
||||
name: "",
|
||||
bizType: "海运" as TemplateRow["bizType"],
|
||||
category: defaultCategoryForBiz("海运"),
|
||||
mode: "auto" as const,
|
||||
keywordItems: [""],
|
||||
isDefault: false,
|
||||
@@ -165,7 +159,6 @@ function openEdit(row: TemplateRow) {
|
||||
Object.assign(form, {
|
||||
name: row.name,
|
||||
bizType: row.bizType,
|
||||
category: row.category || defaultCategoryForBiz(row.bizType),
|
||||
mode: row.mode || "auto",
|
||||
keywordItems: row.keywords.length ? [...row.keywords] : [""],
|
||||
isDefault: Boolean(row.isDefault),
|
||||
@@ -318,8 +311,7 @@ async function submitForm() {
|
||||
message.warning("请至少填写一个关键词");
|
||||
return;
|
||||
}
|
||||
// 分类/模式不对运营展示:分类按业务默认,模式新建默认 auto,编辑保留原值
|
||||
const category = defaultCategoryForBiz(form.bizType);
|
||||
// 模式不对运营展示:新建默认 auto,编辑保留原值;分类交主账按业务补默认
|
||||
const mode = editing.value?.mode || "auto";
|
||||
|
||||
try {
|
||||
@@ -328,7 +320,6 @@ async function submitForm() {
|
||||
if (editing.value?.id) fd.append("templateId", editing.value.id);
|
||||
fd.append("name", form.name.trim());
|
||||
fd.append("bizType", form.bizType);
|
||||
fd.append("category", category);
|
||||
fd.append("keywordsJson", JSON.stringify(keywords));
|
||||
fd.append("mode", mode);
|
||||
fd.append("isDefault", String(form.isDefault));
|
||||
@@ -340,7 +331,6 @@ async function submitForm() {
|
||||
id: editing.value.id,
|
||||
name: form.name.trim(),
|
||||
bizType: form.bizType,
|
||||
category,
|
||||
keywordsJson: JSON.stringify(keywords),
|
||||
mode,
|
||||
isDefault: form.isDefault ? 1 : 0,
|
||||
@@ -398,10 +388,6 @@ async function downloadPreviewOrOriginal() {
|
||||
<option value="海运">海运</option>
|
||||
<option value="陆运">陆运</option>
|
||||
</select>
|
||||
<select v-model="categoryFilter" @change="loadList">
|
||||
<option value="">全部分类</option>
|
||||
<option v-for="c in ALL_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="statusFilter" @change="loadList">
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">启用</option>
|
||||
@@ -415,8 +401,7 @@ async function downloadPreviewOrOriginal() {
|
||||
<tr>
|
||||
<th>模板名称</th>
|
||||
<th>业务</th>
|
||||
<th>分类</th>
|
||||
<th>模板文件</th>
|
||||
<th>关键词</th>
|
||||
<th>解析状态</th>
|
||||
<th>状态</th>
|
||||
<th>总使用次数</th>
|
||||
@@ -434,8 +419,9 @@ async function downloadPreviewOrOriginal() {
|
||||
<td>
|
||||
<span class="tag" :class="bizTag(row.bizType)">{{ row.bizType }}</span>
|
||||
</td>
|
||||
<td class="qt-col-muted">{{ row.category }}</td>
|
||||
<td class="qt-col-file">{{ row.fileLabel }}</td>
|
||||
<td class="qt-col-keywords" :title="formatKeywords(row.keywords)">
|
||||
{{ formatKeywords(row.keywords) || "—" }}
|
||||
</td>
|
||||
<td class="qt-col-muted">{{ row.parseStatus }}</td>
|
||||
<td class="qt-col-muted">{{ row.status === "enabled" ? "启用" : "禁用" }}</td>
|
||||
<td class="qt-col-count">{{ row.useCount }} 次</td>
|
||||
@@ -732,10 +718,14 @@ async function downloadPreviewOrOriginal() {
|
||||
font-size: 11px;
|
||||
color: #334155;
|
||||
}
|
||||
.qt-col-file {
|
||||
.qt-col-keywords {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qt-col-count {
|
||||
font-size: 11px;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
职责:
|
||||
1) 扫描仓库「海陆空报价单模板」目录(忽略 ~$ 临时文件与嵌套重复)
|
||||
2) 按文件名判定 bizType / category / mode(auto|manual) / 关键词
|
||||
2) 按文件名判定 bizType / mode(auto|manual) / 关键词
|
||||
3) 用 openpyxl 做标签粗映射,写出 seed 清单 JSON(供人工核对)
|
||||
4) 若提供 --upload-url 与 token,则逐个 POST 到主账 /inquiry/quoteTemplate/upload
|
||||
|
||||
@@ -32,7 +32,7 @@ OUT_JSON = ROOT / "deploy" / "seed" / "quote_templates_seed_manifest_v1.json"
|
||||
|
||||
|
||||
def classify(name: str) -> dict[str, Any]:
|
||||
"""按文件名归类业务/分类/模式。"""
|
||||
"""按文件名归类业务/模式/关键词(分类字段已下线)。"""
|
||||
n = name
|
||||
mode = "auto"
|
||||
if any(k in n for k in ("人工报价", "双清", "保税仓", "仓储")):
|
||||
@@ -41,82 +41,69 @@ def classify(name: str) -> dict[str, Any]:
|
||||
if "海运" in n:
|
||||
return {
|
||||
"bizType": "海运",
|
||||
"category": "海运报价单模板",
|
||||
"keywords": ["海运", "整柜", "FCL"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "空运到门" in n:
|
||||
return {
|
||||
"bizType": "空运",
|
||||
"category": "空运到门报价单模板",
|
||||
"keywords": ["空运", "到门"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "空运" in n:
|
||||
return {
|
||||
"bizType": "空运",
|
||||
"category": "空运到港报价单模板",
|
||||
"keywords": ["空运", "到港"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "中港" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "中港",
|
||||
"keywords": ["中港", "陆运"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "拼车" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "国内拼车",
|
||||
"keywords": ["拼车", "零担"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "国内" in n and "整车" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "国内整车",
|
||||
"keywords": ["整车", "国内"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "跨境整车" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "跨境整车",
|
||||
"keywords": ["跨境", "整车"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "跨境集拼" in n or "中越" in n or "集拼" in n:
|
||||
cat = "双清或中越" if ("中越" in n or "双清" in n) else "跨境集拼"
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": cat,
|
||||
"keywords": ["集拼", "跨境"] if "集拼" in n else ["中越"],
|
||||
"mode": mode,
|
||||
}
|
||||
if "凭祥" in n or "仓储" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "凭祥保税仓&普仓仓储",
|
||||
"keywords": ["仓储", "凭祥"],
|
||||
"mode": "manual",
|
||||
}
|
||||
if "双清" in n:
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "双清或中越",
|
||||
"keywords": ["双清"],
|
||||
"mode": "manual",
|
||||
}
|
||||
return {
|
||||
"bizType": "陆运",
|
||||
"category": "国内拼车",
|
||||
"keywords": [Path(name).stem[:20]],
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def unique_xlsx_files() -> list[Path]:
|
||||
files = [p for p in TEMPLATE_DIR.rglob("*.xlsx") if not p.name.startswith("~$")]
|
||||
by_name: dict[str, Path] = {}
|
||||
@@ -181,7 +168,6 @@ def build_manifest() -> list[dict[str, Any]]:
|
||||
"fileName": path.name,
|
||||
"relativePath": str(path.relative_to(ROOT)).replace("\\", "/"),
|
||||
"bizType": meta["bizType"],
|
||||
"category": meta["category"],
|
||||
"keywords": meta["keywords"],
|
||||
"mode": meta["mode"],
|
||||
"suggestedMappingPointCount": mapped.get("mappingPointCount", 0),
|
||||
@@ -201,7 +187,6 @@ def upload_one(base_url: str, token: str, item: dict[str, Any]) -> dict[str, Any
|
||||
fields = {
|
||||
"name": Path(item["fileName"]).stem,
|
||||
"bizType": item["bizType"],
|
||||
"category": item["category"],
|
||||
"keywordsJson": json.dumps(item["keywords"], ensure_ascii=False),
|
||||
"mode": item["mode"],
|
||||
"isDefault": "false",
|
||||
@@ -259,7 +244,7 @@ def main(argv: list[str]) -> int:
|
||||
print(f"wrote {OUT_JSON} count={len(items)}")
|
||||
for it in items:
|
||||
print(
|
||||
f" [{it['mode']}] {it['bizType']}/{it['category']} "
|
||||
f" [{it['mode']}] {it['bizType']} "
|
||||
f"map~{it['suggestedMappingPointCount']} {it['fileName']}"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user