Compare commits

...
Author SHA1 Message Date
jillion886andCursor 37278aa0db 目的地只留地名,货物数量不再用箱型,未成交原因去掉艾特。
口语里逗号后面的品名和包装不再粘在目的地上;件数被抽成箱型时改用件数;群里艾特机器人不记进未成交原因。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:17:23 +08:00
jillion886andCursor 4dfd756bcf 销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 11:15:33 +08:00
jillion886andCursor 84207bb4e5 重启后仍能跳过协同出方案,未成交可同一句带原因;海陆有价卡改为直接生成报价单。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:45:28 +08:00
36 changed files with 1310 additions and 190 deletions
@@ -11,6 +11,7 @@ from __future__ import annotations
import html
from typing import Any
from agent.channel.h5.wecom_close import wecom_auto_close_script
from agent.schema.air_route_options import (
option_fee_lines,
option_total,
@@ -226,10 +227,11 @@ def render_air_route_page(payload: dict[str, Any]) -> str:
def render_air_route_ok() -> str:
"""选定成功。"""
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
auto_close=True,
)
@@ -272,12 +274,13 @@ def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], fact
)
def _wrap(title: str, body: str) -> str:
"""整页壳:标题、viewport、共用样式。"""
def _wrap(title: str, body: str, *, auto_close: bool = False) -> str:
"""整页壳:标题、viewport、共用样式。成功页可挂企微关窗脚本。"""
closer = wecom_auto_close_script() if auto_close else ""
return (
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
f"<title>{html.escape(title)}</title>"
f"<style>{_PAGE_CSS}</style></head><body>"
f"{body}</body></html>"
f"{body}{closer}</body></html>"
)
@@ -56,10 +56,11 @@ def render_land_route_page(payload: dict[str, Any]) -> str:
def render_land_route_ok() -> str:
"""选定成功。"""
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
auto_close=True,
)
@@ -10,6 +10,7 @@ from __future__ import annotations
from html import escape
import json
from agent.channel.h5.wecom_close import wecom_auto_close_script
from agent.policy import inquiry_copy as copy
# 三页共用:浅底、白卡片、企微蓝主按钮。禁止在页面里写 userid。
@@ -188,7 +189,8 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
"""成功 / 拒绝 / 过期共用卡片壳,避免三页三种简陋白底。"""
icons = {"ok": _ICON_OK, "warn": _ICON_WARN, "expired": _ICON_EXPIRED}
icon = icons.get(tone, _ICON_WARN)
extra = '<p class="back">请回企微查看识别结果。</p>' if tone == "ok" else ""
extra = '<p class="back">正在返回企微,请查看识别结果。</p>' if tone == "ok" else ""
closer = wecom_auto_close_script() if tone == "ok" else ""
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
@@ -210,6 +212,7 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
{extra}
</div>
</div>
{closer}
</body>
</html>
"""
@@ -0,0 +1,96 @@
"""
企微内嵌 H5 成功后自动关页。
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样
先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。
失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
"""
from __future__ import annotations
from html import escape
from agent.config import get_settings
# 签名完成后再稍停,让销售看见「已成功」。
_CLOSE_DELAY_MS = 400
def wecom_auto_close_script() -> str:
"""
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
"""
settings = get_settings()
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
agent = escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True)
delay = _CLOSE_DELAY_MS
return f"""<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
<script>
(function () {{
var corpId = "{corp}";
var agentId = Number("{agent}") || 0;
function sign(kind) {{
return function (url) {{
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
+ "&url=" + encodeURIComponent(url))
.then(function (r) {{
if (!r.ok) throw new Error("sign");
return r.json();
}});
}};
}}
function nativeClose() {{
try {{
if (typeof WeixinJSBridge !== "undefined") {{
WeixinJSBridge.call("closeWindow");
}}
}} catch (e) {{}}
try {{
if (window.ww && typeof ww.closeWindow === "function") {{
ww.closeWindow();
}}
}} catch (e) {{}}
try {{
if (window.wx && typeof wx.closeWindow === "function") {{
wx.closeWindow();
}}
}} catch (e) {{}}
try {{ window.close(); }} catch (e) {{}}
}}
function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{
setTimeout(nativeClose, {delay});
}}
document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
corpId: corpId,
agentId: agentId,
jsApiList: ["closeWindow"],
getConfigSignature: sign("corp"),
getAgentConfigSignature: sign("agent")
}}).catch(function () {{ return null; }});
}}
ready.then(function () {{
setTimeout(nativeClose, {delay});
}}).catch(function () {{
setTimeout(nativeClose, {delay});
}});
}}
if (document.readyState === "loading") {{
document.addEventListener("DOMContentLoaded", boot);
}} else {{
boot();
}}
}})();
</script>"""
@@ -136,6 +136,14 @@ def handle_air_group(
if handed:
return handed
intent = classify_air_group_text(text, injected=injected_intent)
# 注入/模型若仍判成激活,同一句里的成交跟进不能丢掉。
if wo and intent == INTENT_ACTIVATE:
from agent.routing.group_intent import _hint_deal_outcome
hinted = _hint_deal_outcome(text)
if hinted:
logger.info("air_group 工单号+成交跟进同一句,按 %s 办 wo=%s", hinted, wo)
intent = hinted
logger.info(
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
chat_id,
@@ -313,6 +321,7 @@ def _handle_bound(
sender_id=message.sender_id,
outcome=outcome,
chat_id=chat_id,
text=message.content or "",
)
if intent == INTENT_CONFIRM_TMS:
return apply_air_confirm_tms(
@@ -211,6 +211,7 @@ def handle_group_inbound(
sender_id=message.sender_id,
outcome=outcome,
sess=sess,
text=message.content or "",
)
if intent == INTENT_CONFIRM_TMS:
return apply_confirm_tms_quote(
+44 -10
View File
@@ -671,6 +671,18 @@ _RE_FEE_CHANGE = re.compile(
re.I,
)
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
_RE_FEE_PRICED = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
r"\s*单价\s*"
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<unit>\d+(?:\.\d+)?)"
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
r"(?:\s*[,,、]\s*金额\s*"
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
r"(?P<amount>\d+(?:\.\d+)?))?",
re.I,
)
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
@@ -698,7 +710,7 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
seen: set[str] = set()
def _add(name: str, amount: str, ccy: str) -> None:
def _add(name: str, amount: str, ccy: str, **extra: str) -> None:
if not name or not amount or name.lower() in _SKIP_FEE_NAMES:
return
# 「WO202609160001」会被 [A-Za-z]{2,10}+数字 抽成费用名 WO,群里发工单号不能当报价。
@@ -707,14 +719,27 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
if name in seen:
return
seen.add(name)
rows.append(
{
"name": name,
"amount": amount,
"currency": _currency_of(raw, ccy),
}
)
row = {
"name": name,
"amount": amount,
"currency": _currency_of(raw, ccy),
}
for key, val in extra.items():
if val:
row[key] = val
rows.append(row)
for hit in _RE_FEE_PRICED.finditer(raw):
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
_add(
(hit.group("name") or "").strip(),
_ascii_amount(amount),
ccy,
unit_price=_ascii_amount(hit.group("unit") or ""),
quantity=_ascii_amount(hit.group("qty") or ""),
)
for hit in _RE_FEE_LINE.finditer(raw):
_add(
(hit.group("name") or "").strip(),
@@ -736,6 +761,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
认不出价格返回空 dict,调用方回「没有识别到报价」。
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
"""
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
out = dict(injected)
@@ -772,11 +798,19 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
if not amount:
return {}
ccy = _currency_of(raw)
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
named = re.search(
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
raw,
)
fee_name = (named.group(1) if named else "") or "海运费"
if fee_name in {"费用"}:
fee_name = "海运费"
return {
"total": amount,
"currency": ccy,
"source_label": "产品报价",
"raw_text": raw,
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
"fee_lines": [{"name": "海运费", "amount": amount, "currency": ccy}],
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
}
@@ -114,7 +114,7 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str
"必须调用 extract_quote_adjust。不要对销售说话,不要改价。"
"口令不写死:上升、上浮、往上抬、加一成、涨一点、改到某金额、在现有报价基础上调整都算调价。"
"没点名费用、只说报价:is_adjust=true,wants_total=false,items 为空。"
"只动总销售价/总成本价/合计/总价/汇总价:wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
"只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
"kind 只能 percent 或 target;direction 只能 up/down/set。"
"value 必须来自原话数字;一成=10。禁止编造费用名和金额。"
"不是调价(问候、新询价、谢谢)时 is_adjust=false。"
+13 -3
View File
@@ -609,6 +609,7 @@ def apply_air_deal(
sender_id: str,
outcome: str,
chat_id: str,
text: str = "",
) -> str:
if not is_sales(ticket, sender_id):
send_group_text(group_client_of(flow), chat_id, copy.air_sales_only_deal())
@@ -616,10 +617,11 @@ def apply_air_deal(
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket)
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=ticket_no(ticket),
@@ -628,16 +630,24 @@ def apply_air_deal(
quote=dict(view.get("quote") or {}),
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
lost_reason=inline,
)
if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
send_group_text(
group_client_of(flow),
chat_id,
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
copy.deal_ack(
outcome,
ticket_no(ticket),
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
),
)
return str(result.get("phase") or "done")
return phase
def apply_air_lock(
+52 -5
View File
@@ -560,7 +560,15 @@ class AirTextInquiryFlow:
if phase == "wait_collab":
allowed = (copy.BTN_SKIP_COLLAB,)
elif phase == "wait_adopt":
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
# skip_collab_on_wait_adopt:主账把空运/陆运「已报价未出文件」收成方案卡,
# 但手机上协同卡还在。进程重启后点「不需要协同」必须还能出方案,不能静默丢掉。
# 报价文件已发出时主账会改 wait_deal,这里不会走到。
allowed = (
copy.BTN_SKIP_COLLAB,
copy.BTN_ADOPT_EXCEL,
copy.BTN_ADOPT_PDF,
copy.BTN_REJECT,
)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
@@ -707,6 +715,9 @@ class AirTextInquiryFlow:
reply=reply,
card_meta=inbound_raw,
)
oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw)
if oral_lost:
return oral_lost
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
if (
sess
@@ -1612,17 +1623,44 @@ class AirTextInquiryFlow:
logger.exception("报价 PDF 入队失败 wo=%s", work_order_no)
return False
def try_deal_oral(
self,
sess: Optional[FlowSession],
text: str,
reply: ReplyFn,
card_meta: Optional[dict[str, Any]] = None,
) -> str:
"""
成交跟进阶段口播未成交;同一句可带原因。
点卡仍走 match_button。返回阶段名;不是未成交口播则空串。
"""
from agent.policy.deal_outcome import looks_like_lost_outcome
if sess is None or not (text or "").strip():
return ""
allowed = sess.allowed or ()
if copy.BTN_LOST not in allowed and sess.phase not in {"wait_deal", "wait_file"}:
return ""
if not looks_like_lost_outcome(text):
return ""
if sess.phase == "wait_file":
self._promote_wait_deal(sess)
return self._close(sess, reply, "未成交", card_meta=card_meta, source_text=text)
def _close(
self,
sess: FlowSession,
reply: ReplyFn,
status: str,
card_meta: Optional[dict[str, Any]] = None,
source_text: str = "",
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
"""点成交/未成交/协商中:先整卡置灰,再入主账。未成交同一句带原因则一次记完。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
inline = harvest_inline_lost_reason(source_text) if status == "未成交" else ""
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
@@ -1631,6 +1669,7 @@ class AirTextInquiryFlow:
quote=sess.quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
lost_reason=inline,
)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
@@ -1644,7 +1683,15 @@ class AirTextInquiryFlow:
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
ack_reason = inline if phase == "done" and status == "未成交" else ""
reply(
copy.deal_ack(
status,
sess.work_order_no,
str(result.get("next_followup_at") or ""),
lost_reason=ack_reason,
)
)
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
@@ -1662,7 +1709,7 @@ class AirTextInquiryFlow:
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.lost_reason_ack(text))
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+75 -4
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import logging
import re
from typing import Any, Callable, Optional
from agent.config import get_settings
@@ -18,6 +19,65 @@ logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
# 成交跟进里先认未成交,避免被「成交」误伤。
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"})
_KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"})
# 群里 @询价小助手 是用来叫醒机器人的,不是未成交原因。
_MENTION = re.compile(r"@[^\s,,。.;;::、@]+")
def strip_bot_mentions(text: str) -> str:
"""去掉 @某人。原因前后都可能带,去掉后不留空标点。"""
cleaned = _MENTION.sub("", text or "")
return cleaned.strip(",,。.;;::、 ")
def harvest_inline_lost_reason(text: str) -> str:
"""
从「未成交,原因客户不想要了」这类一句里抽出原因。
只写未成交、问句尾巴(吗/呢)不当原因。原因不明整句保留。
调用:成交跟进私聊/群聊;不在回调线程跑。
"""
raw = (text or "").strip()
if not raw:
return ""
compact = raw.replace(" ", "")
marker = ""
at = -1
for token in _LOST_MARKERS:
hit = compact.find(token)
if hit < 0:
continue
if at < 0 or hit < at:
at = hit
marker = token
if at < 0 or not marker:
return ""
rest = compact[at + len(marker) :].strip()
rest = rest.lstrip(",,。.;;::、-/—")
if rest in _KEEP_REASON_AS_IS:
return rest
for label in ("未成交原因是", "未成交原因", "原因是", "理由是", "原因", "理由"):
if rest.startswith(label):
rest = rest[len(label) :].lstrip(",,。.;;::、-/— ")
break
if rest in _QUESTION_TAILS:
return ""
rest = strip_bot_mentions(rest)
if rest in _QUESTION_TAILS:
return ""
return rest
def looks_like_lost_outcome(text: str) -> bool:
"""成交跟进阶段:这句话是在说未成交。"""
blob = (text or "").replace(" ", "")
if not blob:
return False
return any(token in blob for token in _LOST_MARKERS)
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
@@ -65,12 +125,13 @@ def finish_deal_outcome(
sales_name: str = "",
enqueue: Optional[EnqueueFn] = None,
notify_ready: Optional[bool] = None,
lost_reason: str = "",
) -> dict[str, Any]:
"""
先入主账,再按结果推人或排再问。
返回 ok / phase / notify_summary。phase:
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
已成交 done;未成交无原因 wait_lost_reason;未成交已带原因 done;协商中 wait_deal。
"""
no = (work_order_no or "").strip()
status = (outcome or "").strip()
@@ -135,7 +196,15 @@ def finish_deal_outcome(
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
if status == "未成交":
inline_reason = (lost_reason or "").strip()
if status == "未成交" and inline_reason:
captured = capture_lost_reason(ledger=ledger, work_order_no=no, reason=inline_reason)
if captured.get("ok"):
phase = "done"
else:
phase = "wait_lost_reason"
start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON)
elif status == "未成交":
phase = "wait_lost_reason"
start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON)
elif status == "协商中":
@@ -154,8 +223,8 @@ def finish_deal_outcome(
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。"""
text = (reason or "").strip()
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
text = strip_bot_mentions(reason or "")
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
@@ -163,6 +232,8 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
out = patcher(work_order_no=no, lost_reason=text) or {"ok": False}
if isinstance(out, dict):
out.setdefault("lost_reason", text)
if out.get("ok"):
from agent.policy.event_exception import clear_wait
+76 -27
View File
@@ -71,6 +71,8 @@ _SEA_CLARIFY = tuple(
# 文字按钮(销售点卡或原样回复均可)
BTN_SKIP_COLLAB = "不需要协同,直接生成报价单"
BTN_SKIP_COLLAB_OLD = "不需要协同,直接生成基础报价单"
# 海运/陆运已有 TMS 报价时价格明细卡用短文案;空运仍用上面长文案。点卡 EventKey 不变。
BTN_SKIP_COLLAB_TMS = "直接生成报价单"
BTN_SKIP_COLLAB_KEY = "skip_collab"
BTN_SKIP_COLLAB_DONE = "已提交"
BTN_PULL_COLLAB = "拉产品进群协同"
@@ -695,7 +697,12 @@ def inquiry_card(
def canonical_button(raw: str) -> str:
"""把销售点卡 / 原文 / 企微 EventKey 收成标准按钮文案;对不上返回空。"""
text = (raw or "").strip().strip("【】")
if text in {BTN_SKIP_COLLAB, BTN_SKIP_COLLAB_OLD, BTN_SKIP_COLLAB_KEY}:
if text in {
BTN_SKIP_COLLAB,
BTN_SKIP_COLLAB_OLD,
BTN_SKIP_COLLAB_TMS,
BTN_SKIP_COLLAB_KEY,
}:
return BTN_SKIP_COLLAB
# 新卡 EventKey 带工单号:skip_collab:WO…,避免点旧卡落到最新单
if text.startswith(f"{BTN_SKIP_COLLAB_KEY}:"):
@@ -849,7 +856,7 @@ def _total_label(
business_line: str = "",
transport_mode: str = "",
) -> str:
"""空运总销售价;海运/陆运看这版报价是成本还是销售调价。"""
"""销售侧汇总行。企微卡片、文字明细、成交通知都叫费用合计。"""
src = dict(quote or {})
return total_price_label(
business_line or str(src.get("business_line") or ""),
@@ -1441,7 +1448,7 @@ def sea_tms_hit_card(
lines.append("")
lines.append(sea_collab_ask())
lines.append(BTN_PULL_COLLAB)
lines.append(BTN_SKIP_COLLAB)
lines.append(BTN_SKIP_COLLAB_TMS)
return "\n".join(lines)
@@ -1485,7 +1492,7 @@ def sea_tms_hit_wecom_payload(
"key": f"{BTN_PULL_COLLAB_KEY}:{work_order_no}",
},
{
"text": BTN_SKIP_COLLAB,
"text": BTN_SKIP_COLLAB_TMS,
"style": 1,
"key": f"{BTN_SKIP_COLLAB_KEY}:{work_order_no}",
},
@@ -1860,7 +1867,7 @@ def sea_group_brief(
"""
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,销售调价另起一段。
TMS 无价写「TMS 暂无匹配报价」并提示用文字或 Excel 另报;产品报的价单独放人工报价,销售调价另起一段。
禁止把调后价写进 TMS 段。陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。
"""
no = (work_order_no or "").strip()
@@ -1907,9 +1914,10 @@ def sea_group_brief(
lines.extend(ln for ln in detail if ln.strip() not in {source, title, "TMS标准报价"})
else:
lines.extend(quote_detail_lines(tms, transport_mode=mode))
lines.append(group_tms_confirm_hint())
lines.append(group_tms_confirm_hint(business_line="LAND" if mode == "陆运" else "SEA"))
else:
lines.append("TMS 暂无匹配报价")
lines.append(group_no_tms_quote_hint(business_line="LAND" if mode == "陆运" else "SEA"))
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
@@ -2260,7 +2268,9 @@ def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
DEAL_HINT = "咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。"
# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。
DEAL_LOST_REASON_BEFORE_NEGOTIATE = True
DEAL_HINT = "咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。"
DEAL_SUBTITLE = "报价单已发送,请跟进成交状态"
@@ -2272,7 +2282,7 @@ def deal_horizontal_rows(
transport_mode: str = "",
) -> list[dict[str, str]]:
"""
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
空运业务确认没有时效,这里不写。
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
@@ -2329,7 +2339,7 @@ def deal_wecom_payload(
成交跟进卡:对齐现网 button_interaction。
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
字段:费用合计、报价有效期;海运/陆运再加时效。空运不写时效。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
@@ -2393,9 +2403,35 @@ def group_only_product_can_quote() -> str:
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
def group_tms_confirm_hint() -> str:
"""报价有效期下方:有 TMS 价时告诉产品还可以确认沿用。"""
return "有TMS报价时可回复“确认报价”沿用该价;也可直接回复协同报价或上传报价附件。"
_GROUP_QUOTE_TAIL = (
"亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
)
def group_tms_confirm_hint(*, business_line: str = "") -> str:
"""
有 TMS 价时写在报价有效期下方:谁可以确认沿用,以及怎么另报。
空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。
"""
line = (business_line or "").strip().upper()
who = "航线人员" if line == "AIR" else "产品人员"
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
"""
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
空运喊航线人员,海运/陆运喊产品人员。附件只当存档,不读进报价。
"""
line = (business_line or "").strip().upper()
who = "航线人员" if line == "AIR" else "产品人员"
return (
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
)
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
@@ -2482,7 +2518,7 @@ def group_deal_followup(*, sales_name: str, work_order_no: str) -> str:
f"@{name}\n"
f"询价工单 {no}\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
"咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中"
)
@@ -2490,11 +2526,19 @@ def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
def deal_ack(
outcome: str,
work_order_no: str,
next_followup_at: str = "",
lost_reason: str = "",
) -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
reason = (lost_reason or "").strip()
if reason:
return f"已记录为「未成交」。{lost_reason_ack(reason)}"
return "已记录为「未成交」。请补充未成交原因。"
when = _followup_month_day(next_followup_at)
if when:
@@ -2549,6 +2593,19 @@ def deal_notify_card(
return "\n".join(lines)
def site_card_url() -> str:
"""
整张卡片点击后打开的后台地址。
事件异常、系统异常、成交通知都走这里,点开是同一个网站。
不用企业微信授权页,也不要用 work.weixin.qq.com。
"""
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
return base or "https://ai.ytd-scm.com"
def deal_notify_wecom_payload(
*,
work_order_no: str,
@@ -2571,10 +2628,7 @@ def deal_notify_wecom_payload(
quote=dict(quote or {}),
transport_mode=mode,
)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
@@ -2682,9 +2736,10 @@ def air_group_brief(
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
lines.extend(quote_detail_lines(tms, business_line="AIR"))
lines.append(group_tms_confirm_hint())
lines.append(group_tms_confirm_hint(business_line="AIR"))
else:
lines.append("TMS 暂无匹配报价")
lines.append(group_no_tms_quote_hint(business_line="AIR"))
if manual and (quote_fee_lines(manual) or str(manual.get("total") or "").strip() not in {"", "-"}):
lines.append("")
lines.append("人工报价:")
@@ -2946,10 +3001,7 @@ def system_exception_alert_payload(
{"keyname": "影响范围", "value": system_exception_impact(step)},
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
]
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
@@ -3045,10 +3097,7 @@ def event_exception_alert_payload(
no = (work_order_no or "").strip() or "-"
title = (card_title or "").strip() or "事件响应异常"
hour_text = format_timeout_hours(hours)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
action_url = site_card_url()
return {
"msgtype": "template_card",
"wecom_app": "notify",
@@ -78,6 +78,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
reply=reply,
card_meta=inbound_raw,
)
oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw)
if oral_lost:
return oral_lost
if sess and sess.phase == "tms_tech" and (sess.work_order_no or "").strip():
if looks_like_confirm(text):
logger.info("land.tms_retry wo=%s sender=%s", sess.work_order_no, sender_id)
@@ -515,7 +515,13 @@ def _maybe_deal(flow: Any, ticket: object, sender_id: str, text: str) -> str:
"还有运输段没有报价单,全部报完后再确认成交。",
)
return "deal_wait_segments"
return apply_group_deal(flow=flow, ticket=ticket, sender_id=sender_id, outcome=outcome)
return apply_group_deal(
flow=flow,
ticket=ticket,
sender_id=sender_id,
outcome=outcome,
text=text,
)
def _write_segments(flow: Any, work_order_no: str, segments: list[Segment]) -> None:
+12 -4
View File
@@ -80,17 +80,25 @@ def split_segments(text: str) -> SplitOutcome:
"""
按运输方式出现的先后切段。
「段1:海运」和同一句里的「陆运+海运」都认。同一方式出现两次则 same_mode,不建单。
已经标了两段以上时,只按「段N:」切,表里重复的「海运/陆运」不当新段。
「段1:海运」和同一句里的「陆运+海运」都认。
写了「段N:」就只按段标切:一份海运报价单文件名会先标「段1:海运」,
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
每段正文只收「字段名:值」,不猜没写标签的港口。
"""
raw = text or ""
explicit = list(_EXPLICIT_SEG.finditer(raw))
hits = explicit if len(explicit) >= 2 else list(_MODE_RE.finditer(raw))
# explicit_only_segment_split:有段标就只认段标。一份附件常被标「段1:海运」,表里还会再写一次海运。
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
if len(hits) < 2:
return SplitOutcome()
modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits]
if len(modes) != len(set(modes)):
unique = set(modes)
if len(modes) != len(unique):
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
if len(explicit) < 2 and len(unique) == 1:
return SplitOutcome()
return SplitOutcome(same_mode=True)
segments: list[Segment] = []
for i, hit in enumerate(hits):
@@ -12,6 +12,7 @@ from typing import Any, Optional
from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
adjustable_rows,
apply_adjust,
extract_adjust,
private_session_can_adjust,
@@ -151,7 +152,7 @@ def try_private_adjust(
"""
if not session_quoted(sess):
return None
names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))]
names = [str(r.get("name") or "") for r in adjustable_rows(getattr(sess, "quote", None))]
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
if not extract.is_adjust:
return None
@@ -262,7 +263,7 @@ def apply_group_adjust(
send_group_text(client, room, copy.adjust_only_sales())
return "adjust_not_sales"
current = _current_quote(view, sess)
names = [str(r.get("name") or "") for r in quote_fee_rows(current)]
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
if not extract.is_adjust:
return "not_adjust"
+11 -3
View File
@@ -1106,6 +1106,7 @@ def apply_group_deal(
sender_id: str,
outcome: str,
sess: Any = None,
text: str = "",
) -> str:
"""
群里文字确认成交/未成交/协商中。口令不写死,意图在 group_intent。
@@ -1128,8 +1129,9 @@ def apply_group_deal(
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.deal_outcome import finish_deal_outcome, harvest_inline_lost_reason
inline = harvest_inline_lost_reason(text) if outcome == "未成交" else ""
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=no,
@@ -1138,6 +1140,7 @@ def apply_group_deal(
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
lost_reason=inline,
)
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
@@ -1151,7 +1154,12 @@ def apply_group_deal(
else:
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
ack_reason = inline if phase == "done" and outcome == "未成交" else ""
send_group_text(
client,
chat_id,
copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason),
)
return phase
@@ -1196,7 +1204,7 @@ def capture_group_lost_reason(
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.lost_reason_ack(text))
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
return "done"
@@ -89,6 +89,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply,
card_meta=inbound_raw,
)
oral_lost = self.try_deal_oral(sess, text, reply, inbound_raw)
if oral_lost:
return oral_lost
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
if (
sess
@@ -389,6 +392,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
sess = super()._session_from_ledger(sender_id=sender_id, view=view)
if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_collab":
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
if sess.business_line in {"SEA", "LAND"} and sess.phase == "wait_adopt":
# 陆运/海运未出文件时主账也可能收成方案卡;手机上拉群/跳过协同仍有效。
keep = tuple(sess.allowed or ())
head = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
sess.allowed = tuple(dict.fromkeys(head + keep))
if sess.business_line in {"SEA", "LAND"} and sess.phase == "tms_miss":
sess.allowed = (copy.BTN_PULL_COLLAB,)
sess.collab_chat_id = str(view.get("collab_chat_id") or "")
@@ -94,6 +94,10 @@ def classify_air_group_text(
locked = _hint_lock_or_release(raw)
if locked:
return locked
# wo_plus_deal_not_activate:工单号+成交跟进同一句先关单,不能只当激活。
closed = _hint_deal_outcome(raw)
if copy.extract_work_order_no(raw) and closed:
return closed
# 只发工单号先当激活,不要先打模型;WO + 12 位数字也会被费用行误认成金额。
if copy.extract_work_order_no(raw) and _only_work_order(raw):
return INTENT_ACTIVATE
@@ -178,6 +182,7 @@ def _classify_with_model(text: str, settings) -> str:
"deal_negotiate=销售要继续协商;"
"other=闲聊。"
"一句话里既有锁舱又有工单号,回 air_lock。"
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
)
data = client.chat(
+3 -3
View File
@@ -155,9 +155,9 @@ def classify_group_text(
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_tms, raw[:40])
return hinted_tms
hinted_deal = _hint_deal_outcome(raw)
# 销售说成交时,模型不能把群拆了。
if hinted_deal and got == INTENT_DISSOLVE:
logger.warning("群意图模型把成交判成解散,改回 %s text=%s", hinted_deal, raw[:40])
# 销售说成交时,模型不能把群拆了,也不能把「工单号+未成交」判成闲聊。
if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
return hinted_deal
return got
except Exception:
+55 -4
View File
@@ -516,16 +516,35 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
return out
# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。
_CONTAINER_SPEC = re.compile(
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
)
def looks_like_container_spec(text: str) -> bool:
"""这串是箱型箱量,不是件数或吨数。20件、20吨不算。"""
return bool(_CONTAINER_SPEC.search(text or ""))
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影:原话「15件」常被抽成空运「件数」。
海运货物数量投影。
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。
货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。
箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。
"""
merged = dict(facts or {})
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
pieces = (merged.get("件数") or "").strip()
if not qty and pieces:
if looks_like_container_spec(qty):
if not (merged.get("箱型箱量") or "").strip():
merged["箱型箱量"] = qty
merged.pop("货量", None)
merged.pop("货物数量", None)
qty = ""
if not qty and pieces and not looks_like_container_spec(pieces):
merged["货量"] = pieces
return merged
@@ -562,6 +581,38 @@ def _pricing_required_keys(
return []
_PLACE_KEYS = ("起运港", "目的港")
_PACK_IN_PLACE = ("散货", "纸箱", "木箱", "托盘", "卡板", "木托")
_PRICE_TAIL = re.compile(r"(的价格|的报价|价格|报价)$")
def peel_trailing_place_clauses(facts: dict[str, str] | None) -> dict[str, str]:
"""
起运地/目的地只留逗号前的地名。
口语「目的地吉隆坡,普货,散货的价格」会被抽成一整串目的地。
逗号后面的品名、包装、价格不是地名,拆回去;已有品名/包装不覆盖。
"""
merged = dict(facts or {})
for key in _PLACE_KEYS:
raw = (merged.get(key) or "").strip()
parts = [p.strip() for p in re.split(r"[,,、]", raw) if p.strip()]
if len(parts) <= 1:
continue
place = parts[0]
for part in parts[1:]:
part = _PRICE_TAIL.sub("", part).strip()
if not part or part in {"价格", "报价", "查价"}:
continue
if part in _PACK_IN_PLACE and not (merged.get("包装方式") or "").strip():
merged["包装方式"] = part
continue
if not (merged.get("品名") or "").strip() and not looks_like_container_spec(part):
merged["品名"] = part
merged[key] = place
return merged
def display_name(internal_key: str, business_line: str = "AIR") -> str:
"""补问时给销售看的名字。"""
line = (business_line or "").upper()
@@ -589,7 +640,7 @@ def validate_required_fields(
"""
_ = require_formal
contract = require_formal_contract("inquiry-required-fields-v1")
normalized = apply_quote_date_default(normalize_facts(facts))
normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts)))
line = (business_line or "").strip().upper()
if line == "SEA":
normalized = project_sea_cargo_qty(normalized)
+308 -41
View File
@@ -28,7 +28,7 @@ _TARGET = re.compile(
)
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
_TOTAL_WORDS = ("合计", "总价", "汇总价", "总销售价", "总成本价")
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
_QUOTE_WORD = "报价"
@@ -90,12 +90,17 @@ def _fee_key(name: str) -> str:
return raw.strip()
_TOTAL_ALIASES = frozenset({"费用合计", "总销售价", "总成本价", "汇总价", "总价"})
def fee_name_same(left: str, right: str) -> bool:
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。"""
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。汇总行的旧称呼也算同一行。"""
a = _fee_key(left)
b = _fee_key(right)
if not a or not b:
return False
if a in _TOTAL_ALIASES and b in _TOTAL_ALIASES:
return True
if a == b:
return True
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
@@ -126,6 +131,240 @@ def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]:
return hits
# 海运卡按三段展示。小计是卡上的一行,不在扁平 fee_lines 里。
_SEA_BUCKETS = (
("polCostItems", "始发港费用小计"),
("oceanCostItems", "海运费小计"),
("podCostItems", "目的港费用小计"),
)
def _bucket_items(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
"""读海运分段。主账有时给列表,有时给 JSON 字符串。"""
raw = quote.get(key)
if raw in (None, ""):
raw = quote.get("fee_rows_" + key)
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return []
if not isinstance(raw, list):
return []
return [dict(row) for row in raw if isinstance(row, dict)]
def _item_fee_name(item: dict[str, Any]) -> str:
return str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
def _same_money(left: Any, right: Any) -> bool:
"""两条金额是否同一笔,用来把同名费用配回原来那一行。"""
a, _ = parse_money(left)
b, _ = parse_money(right)
if a is None or b is None:
return False
return abs(a - b) < 0.0001
def _plain_amount(amount: str) -> str:
"""写回分段时去掉币种前缀,避免小计被展示成 CNY CNY。"""
text = str(amount or "").strip()
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
if text.upper().startswith(prefix):
return text[len(prefix) :].strip()
return text
def _store_bucket(quote: dict[str, Any], key: str, items: list[dict[str, Any]]) -> None:
"""分段和 fee_rows_ 键一起改,卡和模板读的是这两处。"""
if key in quote or quote.get(key) not in (None, ""):
quote[key] = items
alt = "fee_rows_" + key
if alt in quote or quote.get(alt) not in (None, ""):
quote[alt] = json.dumps(items, ensure_ascii=False)
def adjustable_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
调价能对上的行:扁平明细,加上海运分段里的费目和小计。
小计只用来对「始发港费用小计」这类话,不写进 fee_lines。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
for row in rows:
row["_origin"] = str(row.get("amount") or "")
header = str(src.get("currency") or src.get("currencyCode") or "").strip()
for key, sub_label in _SEA_BUCKETS:
used: set[int] = set()
for item in _bucket_items(src, key):
name = _item_fee_name(item)
amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), amount)
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": item.get("currency") or header,
"_bucket": key,
"_origin": amount,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_bucket"] = key
used.add(idx)
subtotal = str(src.get("fee_total_" + key) or "").strip()
if subtotal and subtotal != "-":
rows.append(
{
"name": sub_label,
"amount": subtotal,
"currency": header,
"_subtotal_key": "fee_total_" + key,
"_bucket": key,
}
)
_attach_land_legs(src, rows)
total = str(src.get("total") or "").strip()
if total and total != "-":
rows.append(
{
"name": total_price_label(
str(src.get("business_line") or ""),
quote=src,
),
"amount": total,
"currency": header,
"_is_total": True,
}
)
return rows
def _attach_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""陆运卡按段展示。同名费用每段一行,改价要写回路段成本。"""
from agent.schema.land_route_options import land_leg_fee_blocks
used: set[int] = set()
for index, (_title, fees, _eta) in enumerate(land_leg_fee_blocks(quote)):
for fee in fees:
name = str(fee.get("name") or "").strip()
amount = str(fee.get("amount") or "").strip()
if not name or not amount:
continue
idx = next(
(
i
for i, row in enumerate(rows)
if i not in used
and not row.get("_subtotal_key")
and not row.get("_is_total")
and fee_name_same(name, str(row.get("name") or ""))
and str(row.get("amount") or "") == amount
),
-1,
)
if idx < 0:
rows.append(
{
"name": name,
"amount": amount,
"currency": fee.get("currency") or quote.get("currency") or "",
"_land_leg": index,
}
)
used.add(len(rows) - 1)
else:
rows[idx]["_land_leg"] = index
used.add(idx)
def _write_adjusted_buckets(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""把改过的费目和小计写回海运分段,价格卡才看得到。"""
for key, _label in _SEA_BUCKETS:
items = _bucket_items(quote, key)
changed = False
used: set[int] = set()
for item in items:
name = _item_fee_name(item)
origin = str(item.get("amount") or item.get("value") or item.get("salePrice") or "")
hit_i = next(
(
i
for i, row in enumerate(rows)
if i not in used
and row.get("_bucket") == key
and not row.get("_subtotal_key")
and fee_name_same(name, str(row.get("name") or ""))
and _same_money(row.get("_origin"), origin)
),
-1,
)
if hit_i < 0:
continue
used.add(hit_i)
plain = _plain_amount(str(rows[hit_i].get("amount") or ""))
if not plain:
continue
item["amount"] = plain
if item.get("name"):
item["name"] = name
for field_name in ("salePrice", "value", "unit_price"):
if item.get(field_name) not in (None, ""):
item[field_name] = plain
changed = True
if changed:
_store_bucket(quote, key, items)
for row in rows:
sub_key = str(row.get("_subtotal_key") or "")
if sub_key:
quote[sub_key] = _plain_amount(str(row.get("amount") or ""))
_write_land_legs(quote, rows)
def _write_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
"""路段成本改掉,下次出卡才不会又按旧成本显示。"""
from agent.schema.land_route_options import _selected_legs
legs = _selected_legs(quote)
if not legs:
return
for row in rows:
index = row.get("_land_leg")
if index is None or not isinstance(index, int) or index >= len(legs):
continue
plain = _plain_amount(str(row.get("amount") or ""))
if not plain:
continue
leg = legs[index]
leg["costPrice"] = plain
leg["cost_price"] = plain
if leg.get("salePrice") not in (None, ""):
leg["salePrice"] = plain
selected = quote.get("selected_land_route")
if isinstance(selected, dict):
selected["legs"] = legs
quote["selected_land_route"] = selected
if quote.get("landSegments") or quote.get("land_segments"):
quote["landSegments"] = legs
def quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""抽出费用行。TMS 常把 fee_lines 存成 JSON 字符串。"""
src = dict(quote or {})
@@ -377,21 +616,13 @@ def total_price_label(
quote: dict[str, Any] | None = None,
) -> str:
"""
销售可见总价名称。
销售在企微看到的汇总行名称,一律叫费用合计。
空运一律总销售价。海运/陆运:TMS、产品报价是总成本价,销售调价后才是总销售价。
禁止写「合计」。Excel/PDF 栏目名不走这里。
不区分空运/海运/陆运,也不区分 TMS 还是销售调价。
后台成本价、销售价不走这里。Excel/PDF 报价单模板也不走这里。
"""
src = dict(quote or {})
raw = f"{business_line or ''} {transport_mode or ''} {src.get('business_line') or ''}"
key = raw.upper()
if key.strip() in {"AIR"} or "AIR" in key or "空运" in raw:
return "总销售价"
source = str(src.get("source") or "").strip().upper()
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
if source == "SALES_ADJUST" or "销售调价" in label:
return "总销售价"
return "总成本价"
_ = business_line, transport_mode, quote
return "费用合计"
def _mark_sales_adjust(quote: dict[str, Any]) -> None:
@@ -545,10 +776,10 @@ def apply_adjust(
明细只有一项且币种与总价一致:改总价或改明细都一起调。
多项同币种改合计:只改合计数字。多币种:只改点名的那一格。
副作用:无。原 quote 不被修改。
副作用:无。原 quote 不被修改。海运小计按卡上的「始发港费用小计」改。
"""
src = dict(quote or {})
rows = [dict(x) for x in quote_fee_rows(src)]
rows = adjustable_rows(src)
if not extract.is_adjust:
return ApplyResult(ok=False, code="not_adjust", quote=src)
header = str(src.get("currency") or "").strip()
@@ -567,7 +798,12 @@ def apply_adjust(
currency_hint = bool(multi)
total_amt, _ = parse_money(src.get("total"))
new_total = total_amt
single_same_ccy = _single_line_same_currency(rows, total_ccy or header) is not None
single_same_ccy = _single_line_same_currency(
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
total_ccy or header,
) is not None
subtotal_only: dict[str, float] = {}
touched_buckets: set[str] = set()
for item in extract.items:
spoken = (item.fee_name or "").strip()
@@ -581,34 +817,65 @@ def apply_adjust(
if not hits:
unmatched.append(spoken)
continue
idx = next(i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0])
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
indexes = [i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]]
if not indexes:
unmatched.append(spoken)
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
any_applied = False
for idx in indexes:
row = dict(rows[idx])
old_amt, prefix = parse_money(row.get("amount"))
if old_amt is None:
continue
line_ccy = row_currency(row, header) or prefix
if item.kind == "target":
new_amt = float(item.value)
how = f"改到 {item.value:g}"
elif item.direction == "down":
new_amt = old_amt * (1 - float(item.value) / 100.0)
how = f"下调 {item.value:g}%"
else:
new_total = new_total + (new_amt - old_amt)
new_amt = old_amt * (1 + float(item.value) / 100.0)
how = f"上调 {item.value:g}%"
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
rows[idx] = row
if not any_applied:
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
any_applied = True
bucket = str(row.get("_bucket") or "")
if row.get("_is_total"):
subtotal_only["__total__"] = new_amt
continue
if row.get("_subtotal_key"):
subtotal_only[bucket] = new_amt - old_amt
continue
if bucket:
touched_buckets.add(bucket)
if (not multi) and new_total is not None:
if single_same_ccy:
new_total = new_amt
else:
new_total = new_total + (new_amt - old_amt)
if not any_applied:
unmatched.append(spoken)
if (not multi) and new_total is not None:
if "__total__" in subtotal_only:
new_total = subtotal_only.pop("__total__")
for bucket, delta in subtotal_only.items():
if bucket not in touched_buckets:
new_total = new_total + delta
out = dict(src)
out["fee_rows"] = rows
out["fee_lines"] = rows
visible = []
for row in rows:
if row.get("_subtotal_key") or row.get("_is_total"):
continue
clean = {k: v for k, v in row.items() if not str(k).startswith("_")}
visible.append(clean)
out["fee_rows"] = visible
out["fee_lines"] = visible
_write_adjusted_buckets(out, rows)
if applied and (not multi) and new_total is not None and new_total != total_amt:
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
if not applied:
+50 -6
View File
@@ -182,6 +182,9 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("是否含磁:否", general)
self.assertNotIn("是否含电:-", general)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("航线人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("产品人员可通过", text)
self.assertNotIn("当前工单:", text)
self.assertNotIn("贸易条款", text)
@@ -207,10 +210,14 @@ class AirGroupCollabTests(unittest.TestCase):
tms_quote=tms,
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("总销售价:CNY 183370.0", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertIn("费用合计:CNY 183370.0", tms_block)
self.assertNotIn("时效", tms_block)
self.assertIn("报价有效期:2026-09-30", tms_block)
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("请产品人员回复", text)
def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None:
"""
@@ -259,8 +266,8 @@ class AirGroupCollabTests(unittest.TestCase):
self.assertIn("运输费:CNY 11.0", tms_block)
self.assertIn("提送货费:CNY 6.0", tms_block)
self.assertIn("人工费:CNY 123.0", tms_block)
self.assertIn("总销售价:CNY 183370", tms_block)
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("费用合计:CNY 183370", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
self.assertNotIn("空运费", tms_block)
def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None:
@@ -305,8 +312,45 @@ class AirGroupCollabTests(unittest.TestCase):
tms_block = text.split("TMS报价:", 1)[1]
self.assertNotIn("空运费", tms_block)
self.assertNotIn("运输费", tms_block)
self.assertIn("总销售价:CNY 200040", tms_block)
self.assertEqual(tms_block.count("总销售价:"), 1)
self.assertIn("费用合计:CNY 200040", tms_block)
self.assertEqual(tms_block.count("费用合计:"), 1)
def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None:
"""WO@助手 未成交,原因… 必须当关单,不能只激活。"""
from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST
text = "WO202609220014@询价小助手 未成交,原因客户不想送货了"
self.assertEqual(classify_air_group_text(text, allow_network=False), INTENT_LOST)
from unittest.mock import patch
with patch(
"agent.routing.air_group_intent._classify_with_model",
return_value=INTENT_ACTIVATE,
):
self.assertEqual(
classify_air_group_text(text, allow_network=True),
INTENT_LOST,
)
def test_activate_lost_and_reason_same_message(self) -> None:
wo = self._create_air()
phase = handle_air_group(
self._msg(
sender="sales1",
text=f"{wo}@询价小助手 未成交,原因客户不想送货了",
chat="wr_air_1",
mid="lost-inline-wo",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "客户不想送货了")
joined = "\n".join(self._texts())
self.assertIn("客户不想送货了", joined)
self.assertNotIn("请补充未成交原因", joined)
def test_work_order_only_is_activate_not_quote(self) -> None:
from agent.routing.air_group_intent import INTENT_ACTIVATE
+12 -2
View File
@@ -218,7 +218,7 @@ class AirRouteOptionsTests(unittest.TestCase):
business_line="AIR",
)
self.assertNotIn("空运费", detail)
self.assertIn("总销售价", detail)
self.assertIn("费用合计", detail)
self.assertIn("200040", detail)
def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None:
@@ -281,7 +281,7 @@ class AirRouteOptionsTests(unittest.TestCase):
"NKG-KUL",
"增点费:CNY 100.0",
"拆板费:USD 100.0",
"总销售价:CNY 5001.0",
"费用合计:CNY 5001.0",
"报价有效期:2026-09-30",
]
),
@@ -323,6 +323,16 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertIn("/air-routes/tok-abc", url)
self.assertNotIn("userid", url)
def test_ok_page_auto_closes_wecom_h5(self) -> None:
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_ok
ok = render_air_route_ok()
dead = expired_air_route_page()
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", dead)
if __name__ == "__main__":
unittest.main()
+14 -14
View File
@@ -342,7 +342,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("已报价", self.flow.session_of("u1").status)
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
self.assertIn("价格明细", joined)
self.assertIn("总销售价", joined)
self.assertIn("费用合计", joined)
self.assertNotIn("时效:", joined)
self.assertIn("报价有效期:", joined)
self.assertNotIn("相同询价内容检索", joined)
@@ -378,7 +378,7 @@ class AirTextInquiryTests(unittest.TestCase):
card = scheme_cards[0]["template_card"]
self.assertEqual(
card["quote_area"]["quote_text"],
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertEqual(len(card["button_list"]), 3)
self.assertEqual(
@@ -408,7 +408,7 @@ class AirTextInquiryTests(unittest.TestCase):
follow = file_extras[-1].get("followup_card") or {}
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
self.assertIn("总销售价", deal_rows)
self.assertIn("费用合计", deal_rows)
self.assertNotIn("时效", deal_rows)
self.assertNotIn("历史报价", deal_rows)
self.assertNotIn("工单状态", deal_rows)
@@ -873,7 +873,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("title", card["quote_area"])
self.assertEqual(
quote_text,
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertNotIn("时效:", quote_text)
numeric = copy.tms_hit_wecom_payload(
@@ -888,7 +888,7 @@ class AirTextInquiryTests(unittest.TestCase):
)["template_card"]["quote_area"]["quote_text"]
self.assertEqual(
numeric,
"总销售价:CNY 14669.60\n共1项费用,详见上一条明细",
"费用合计:CNY 14669.60\n共1项费用,详见上一条明细",
)
self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
@@ -928,11 +928,11 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("WO202609220005价格明细", text)
self.assertIn("操作费:CNY 1220.00", text)
self.assertIn("空运费:USD 1250.00", text)
self.assertIn("总销售价:CNY 142998.53", text)
self.assertIn("费用合计:CNY 142998.53", text)
self.assertNotIn("时效", text)
self.assertIn("报价有效期:2026-09-30", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="AIR")
self.assertIn("总销售价:CNY 142998.53", card_text)
self.assertIn("费用合计:CNY 142998.53", card_text)
self.assertIn("详见上一条明细", card_text)
self.assertNotIn("操作费:CNY 1220.00", card_text)
payload = copy.tms_hit_wecom_payload(
@@ -965,7 +965,7 @@ class AirTextInquiryTests(unittest.TestCase):
card_text = copy.quote_area_safe_text(quote, business_line="AIR")
self.assertEqual(
card_text,
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
)
payload = copy.tms_hit_wecom_payload(
work_order_no="WO202609220006", quote=quote
@@ -979,7 +979,7 @@ class AirTextInquiryTests(unittest.TestCase):
)
self.assertIn("ZUH-CAN-CRK", detail)
self.assertIn("运输费:CNY 11.0", detail)
self.assertIn("总销售价:CNY 183370", detail)
self.assertIn("费用合计:CNY 183370", detail)
rows = {
x["keyname"]: x["value"]
for x in copy.scheme_horizontal_rows(
@@ -998,7 +998,7 @@ class AirTextInquiryTests(unittest.TestCase):
)
self.assertEqual(
scheme["template_card"]["quote_area"]["quote_text"],
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
)
def test_air_hit_sends_detail_text_before_card(self) -> None:
@@ -1072,7 +1072,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertNotIn("title", card["quote_area"])
self.assertEqual(
quote_text,
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
)
self.assertNotIn("sub_title_text", card)
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
@@ -1123,8 +1123,8 @@ class AirTextInquiryTests(unittest.TestCase):
text = copy.deal_card(work_order_no="WO202608210017", quote=quote, business_line="AIR")
self.assertIn("询价工单 WO202608210017", text)
self.assertIn("报价单已发送,请跟进成交状态", text)
self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text)
self.assertIn("总销售价:CNY 722.5", text)
self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text)
self.assertIn("费用合计:CNY 722.5", text)
self.assertNotIn("时效", text)
self.assertNotIn("- / -", text)
self.assertIn("报价有效期:2026-08-24", text)
@@ -1144,7 +1144,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertEqual(
rows,
{
"总销售价": "CNY 722.5",
"费用合计": "CNY 722.5",
"报价有效期": "2026-08-24",
},
)
@@ -105,6 +105,11 @@ class AttachmentUploadH5Tests(unittest.TestCase):
self.assertIn('class="card"', ok)
self.assertIn('class="card"', dead)
self.assertIn("过期", dead)
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", page)
self.assertNotIn("closeWindow", dead)
def test_post_png_rejected(self) -> None:
token = self._open_card()
@@ -90,6 +90,22 @@ class AttachWaveTests(unittest.TestCase):
self.assertIn("纽约港", oral)
self.assertEqual(classify_multi_text(oral), "multi")
def test_one_sea_quote_filename_is_single(self) -> None:
"""只传一份海运报价单:文件名带海运,表里再写运输方式,不能报同一方式两段。"""
from agent.policy.multi_segments import classify_multi_text
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
self.book.add_files("s1", ["WO202608250012_海运报价单.xlsx"], now=2.0)
self.book.complete_material(
"s1",
0,
text="运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n海运询价",
)
oral = self.book.compose_oral("s1")
self.assertIn("段1:海运", oral)
self.assertIn("运输方式:海运", oral)
self.assertEqual(classify_multi_text(oral), "single")
def test_ltl_filename_counts_as_land_segment(self) -> None:
"""零担询价表文件名没有「陆运」二字,也要和海运表切成两段。"""
from agent.policy.multi_segments import classify_multi_text
@@ -269,6 +269,54 @@ class ContinueTicketTests(unittest.TestCase):
self.assertEqual(decision.intent, "continue_thread")
_ = dispatch_inbound
def test_skip_collab_after_restart_from_ledger(self) -> None:
"""
测服 WO202609230031:进程重启后主账把空运已报价收成 wait_adopt,
点「不需要协同,直接生成报价单」不能再静默丢掉。
"""
ledger = MemoryLedger()
live = AirTextInquiryFlow(ledger)
sink: list[str] = []
def reply(text: str, extra=None) -> None:
sink.append(text)
_ = extra
created = ledger.create_ticket(
sender_id="u-skip-restart", business_line="AIR", facts=COMPLETE_AIR
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={
"total": "USD 1,280",
"eta": "3-5 天",
"valid_until": "2026-09-20",
"source_label": "TMS 标准报价",
},
to_status="已报价",
)
view = ledger.get_for_agent(work_order_no=wo, sender_id="u-skip-restart")
self.assertEqual(view.get("wait_phase"), "wait_adopt")
restarted = AirTextInquiryFlow(ledger)
phase = restarted.on_button(
sender_id="u-skip-restart",
action=copy.BTN_SKIP_COLLAB,
reply=reply,
card_meta={
"event_key": f"skip_collab:{wo}",
"task_id": f"price-{wo}-deadbeef",
"response_code": "rc-skip-restart",
},
)
self.assertEqual(phase, "wait_adopt")
joined = "\n".join(sink)
self.assertIn("已跳过内部协同", joined)
self.assertIn(f"{wo}报价方案", joined)
self.assertEqual(restarted.session_of("u-skip-restart").phase, "wait_adopt")
self.assertNotIn(copy.BTN_SKIP_COLLAB, restarted.session_of("u-skip-restart").allowed)
def test_adopt_excel_after_restart_stays_on_card_ticket(self) -> None:
"""点 0040 方案卡不能落到刚续办的 0037:进程无书签时按卡上工单号从主账收回。"""
ledger = MemoryLedger()
+68 -1
View File
@@ -9,7 +9,11 @@ import unittest
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
from agent.policy.deal_outcome import (
capture_lost_reason,
finish_deal_outcome,
harvest_inline_lost_reason,
)
from agent.policy.sea_text_flow import SeaTextInquiryFlow
@@ -144,6 +148,69 @@ class TestDealOutcome(unittest.TestCase):
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_harvest_inline_lost_reason(self) -> None:
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
self.assertEqual(
harvest_inline_lost_reason("WO202609220014@询价小助手 未成交,原因客户不想送货了"),
"客户不想送货了",
)
self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了")
self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高")
self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了")
self.assertEqual(
harvest_inline_lost_reason("WO202609240010未成交,原因客户不需要了@询价小助手"),
"客户不需要了",
)
self.assertEqual(harvest_inline_lost_reason("未成交"), "")
self.assertEqual(harvest_inline_lost_reason("未成交吗"), "")
self.assertEqual(harvest_inline_lost_reason("成交"), "")
def test_lost_with_inline_reason_finishes(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="未成交",
facts={},
quote={},
business_line="SEA",
lost_reason="客户不想要了",
)
self.assertEqual(out.get("phase"), "done")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "客户不想要了")
self.assertEqual(ticket.event_wait_kind, "")
self.assertEqual(
copy.deal_ack("未成交", self.wo, lost_reason="客户不想要了"),
"已记录为「未成交」。已记下未成交原因:客户不想要了。",
)
def test_private_lost_and_reason_same_message(self) -> None:
flow = AirTextInquiryFlow(self.ledger)
flow._save(
FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_text(
sender_id="sales1",
text="未成交,原因客户不想要了",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(
self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客户不想要了"
)
joined = "\n".join(replies)
self.assertIn("客户不想要了", joined)
self.assertNotIn("请补充未成交原因", joined)
def test_negotiate_ack_names_followup_day(self) -> None:
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
self.assertEqual(
+24
View File
@@ -123,6 +123,30 @@ class OralExtractTests(unittest.TestCase):
self.assertEqual(bulk["facts"]["包装方式"], "散货")
self.assertNotIn("包装方式", bulk["missing"])
def test_place_stops_before_cargo_and_price(self) -> None:
"""目的地只留地名。后面的品名、包装、的价格不能粘在地名上。"""
from agent.schema.field_validate import validate_required_fields
check = validate_required_fields(
facts={
"目的港": "吉隆坡,普货,散货的价格",
"品名": "普货",
"包装方式": "散货",
"报价日期": "2026-09-29",
},
business_line="AIR",
)
self.assertEqual(check["facts"]["目的港"], "吉隆坡")
self.assertEqual(check["facts"]["品名"], "普货")
self.assertEqual(check["facts"]["包装方式"], "散货")
bare = validate_required_fields(
facts={"目的港": "洛杉矶,茶叶,纸箱的价格"},
business_line="AIR",
)
self.assertEqual(bare["facts"]["目的港"], "洛杉矶")
self.assertEqual(bare["facts"]["品名"], "茶叶")
self.assertEqual(bare["facts"]["包装方式"], "纸箱")
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
from agent.schema.field_validate import harvest_oral_measures
from agent.schema.tms_air_query import assemble_air_query
+20 -8
View File
@@ -239,9 +239,9 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertGreater(eta1, fee1)
self.assertLess(eta1, od2)
self.assertGreater(eta2, fee2)
self.assertIn("总成本价:CNY 17100", text)
valid = text.find("报价有效期:", text.find("总成本价:"))
self.assertGreater(valid, text.find("总成本价:"))
self.assertIn("费用合计:CNY 17100", text)
valid = text.find("报价有效期:", text.find("费用合计:"))
self.assertGreater(valid, text.find("费用合计:"))
def test_land_detail_and_group_use_selected_route_valid_until(self) -> None:
"""选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。"""
@@ -312,9 +312,9 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertIn("运输费:CNY 7500.00", detail)
self.assertIn("广西壮族自治区 → 胡志明", detail)
self.assertIn("运输费:CNY 12500.00", detail)
self.assertIn("总成本价:CNY 20000", detail)
self.assertIn("费用合计:CNY 20000", detail)
self.assertIn("报价有效期:2026-10-31", detail)
self.assertGreater(detail.find("报价有效期:"), detail.find("总成本价:"))
self.assertGreater(detail.find("报价有效期:"), detail.find("费用合计:"))
self.assertNotIn("9600", detail)
self.assertNotIn("时效:N+3 / N+3", detail)
brief = copy.sea_group_brief(
@@ -338,18 +338,20 @@ class LandRouteOptionsTests(unittest.TestCase):
tms_quote=raw,
transport_mode="陆运",
)
tms_block = brief.split("TMS报价:", 1)[1].split("有TMS报价时", 1)[0]
tms_block = brief.split("TMS报价:", 1)[1].split("请产品人员回复", 1)[0]
self.assertIn("上海-广西壮族自治区-胡志明", tms_block)
self.assertIn("上海 → 广西壮族自治区", tms_block)
self.assertIn("运输费:CNY 12500.00", tms_block)
self.assertIn("总成本价:CNY 20000", tms_block)
self.assertIn("费用合计:CNY 20000", tms_block)
self.assertIn("报价有效期:2026-10-31", tms_block)
self.assertNotIn("TMS标准报价", tms_block)
self.assertNotIn("价格明细", tms_block)
self.assertNotIn("9600", tms_block)
self.assertNotIn("总成本价:-", tms_block)
self.assertNotIn("费用合计:-", tms_block)
self.assertNotIn("时效:-", tms_block)
self.assertNotIn("时效:N+3 / N+3", tms_block)
self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", brief)
self.assertNotIn("请航线人员回复", brief)
def test_h5_lists_segment_costs(self) -> None:
from agent.channel.h5.land_route_page import render_land_route_page
@@ -651,6 +653,16 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertEqual(seqs, {1})
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
def test_ok_page_auto_closes_wecom_h5(self) -> None:
from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_ok
ok = render_land_route_ok()
dead = expired_land_route_page()
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
self.assertNotIn("closeWindow", dead)
if __name__ == "__main__":
unittest.main()
@@ -659,5 +659,57 @@ class LandLostReasonTests(unittest.TestCase):
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
class LandSkipAfterRestartTests(unittest.TestCase):
def test_skip_collab_after_restart_from_ledger(self) -> None:
"""
陆运已报价主账也会收成 wait_adopt。进程无书签时点「不需要协同」仍须出方案卡。
"""
ledger = MemoryLedger()
facts = {
"起运港": "广州",
"目的港": "深圳",
"品名": "衣服",
"体积": "100CBM",
"毛重": "50kg",
"运输类型": "国内运输拼车",
"线路类别": "国内长途/零担",
"运输分类": "急件",
}
created = ledger.create_ticket(
sender_id="u-land-skip", business_line="LAND", facts=facts
)
wo = created["work_order_no"]
ledger.upsert_quote(
work_order_no=wo,
quote={"total": "CNY 800", "source_label": "TMS 标准报价"},
to_status="已报价",
)
view = ledger.get_for_agent(work_order_no=wo, sender_id="u-land-skip")
self.assertEqual(view.get("wait_phase"), "wait_adopt")
restarted = LandTextInquiryFlow(ledger)
replies: list[str] = []
def reply(text: str, extra=None) -> str:
replies.append(text)
_ = extra
return ""
phase = restarted.on_button(
sender_id="u-land-skip",
action=copy.BTN_SKIP_COLLAB,
reply=reply,
card_meta={
"event_key": f"skip_collab:{wo}",
"task_id": f"price-{wo}-land",
"response_code": "rc-land-skip",
},
)
self.assertEqual(phase, "wait_adopt")
joined = "\n".join(replies)
self.assertIn("已跳过内部协同", joined)
self.assertIn(f"{wo}报价方案", joined)
if __name__ == "__main__":
unittest.main()
@@ -71,6 +71,25 @@ class SplitTests(unittest.TestCase):
def test_same_mode_twice_is_rejected(self) -> None:
self.assertEqual(classify_multi_text("段1:海运\n起运港:A\n段2:海运\n目的港:B"), "same_mode")
def test_one_sea_quote_sheet_is_single(self) -> None:
"""测服 WO202608250012 海运报价单:文件名标段1,表里再写运输方式,仍是一段。"""
oral = (
"段1:海运\n"
"运输方式:海运\n"
"起运港:纽约港\n"
"目的港:巴生西港\n"
"品名:机器设备\n"
"海运询价"
)
self.assertEqual(classify_multi_text(oral), "single")
def test_unlabeled_repeated_sea_is_single(self) -> None:
"""报价单原文没有段标、海运出现两次,不能拆开再发。"""
self.assertEqual(
classify_multi_text("运输方式:海运\n起运港:A\n备注:海运询价"),
"single",
)
def test_explicit_markers_ignore_mode_words_in_body(self) -> None:
"""文件名先标了段,表里再写「运输方式:海运」不能当成又一段海运。"""
oral = (
+116 -34
View File
@@ -15,6 +15,8 @@ if ROOT not in sys.path:
from agent.policy import inquiry_copy as copy
from agent.schema.quote_adjust import (
AdjustExtract,
AdjustItem,
apply_adjust,
effective_quote,
extract_adjust,
@@ -164,6 +166,85 @@ class QuoteAdjustTests(unittest.TestCase):
self.assertFalse(got.ok)
self.assertEqual(got.code, "none_matched")
def test_sea_subtotal_and_bucket_lines(self) -> None:
src = {
"currency": "CNY",
"total": "2000",
"fee_lines": [
{"name": "操作费", "amount": "1000", "currency": "CNY"},
{"name": "箱单费", "amount": "200", "currency": "CNY"},
],
"polCostItems": [
{"name": "操作费", "amount": "1000", "currency": "CNY"},
{"name": "箱单费", "amount": "200", "currency": "CNY"},
],
"fee_total_polCostItems": "1200",
}
ext = AdjustExtract(
is_adjust=True,
items=[
AdjustItem("操作费", "percent", "up", 10),
AdjustItem("箱单费", "percent", "up", 5),
AdjustItem("始发港费用小计", "percent", "up", 12),
],
)
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
self.assertEqual(got.unmatched, [])
names = {row["name"]: row["amount"] for row in got.quote["polCostItems"]}
self.assertEqual(names["操作费"], "1100.00")
self.assertEqual(names["箱单费"], "210.00")
self.assertEqual(got.quote["fee_total_polCostItems"], "1344.00")
def test_same_fee_name_keeps_its_own_amount(self) -> None:
src = {
"currency": "USD",
"total": "500",
"fee_lines": [
{"name": "文件费", "amount": "450", "currency": "USD"},
{"name": "文件费", "amount": "50", "currency": "USD"},
],
"polCostItems": [
{"name": "文件费", "amount": "450", "currency": "USD"},
{"name": "文件费", "amount": "50", "currency": "USD"},
],
}
ext = AdjustExtract(is_adjust=True, items=[AdjustItem("文件费", "percent", "up", 10)])
got = apply_adjust(src, ext, business_line="SEA")
self.assertTrue(got.ok)
amounts = [row["amount"] for row in got.quote["polCostItems"]]
self.assertEqual(amounts, ["495.00", "55.00"])
def test_land_same_fee_name_each_leg_and_total(self) -> None:
src = {
"business_line": "LAND",
"currency": "CNY",
"total": "10000",
"fee_lines": [
{"name": "运输费", "amount": "7000", "currency": "CNY"},
{"name": "运输费", "amount": "3000", "currency": "CNY"},
],
"landSegments": [
{"origin": "上海", "destination": "谅山", "costPrice": "7000", "segmentSequence": 1, "routeLegSequence": 1},
{"origin": "谅山", "destination": "河内", "costPrice": "3000", "segmentSequence": 1, "routeLegSequence": 2},
],
}
ext = AdjustExtract(
is_adjust=True,
items=[
AdjustItem("运输费", "percent", "up", 10),
AdjustItem("总成本价", "percent", "up", 10),
],
)
got = apply_adjust(src, ext, business_line="LAND")
self.assertTrue(got.ok)
self.assertEqual(got.unmatched, [])
amounts = sorted(row["amount"] for row in got.quote["fee_lines"])
self.assertEqual(amounts, ["3300.00", "7700.00"])
costs = [leg["costPrice"] for leg in got.quote["landSegments"]]
self.assertEqual(costs, ["7700.00", "3300.00"])
self.assertEqual(got.quote["total"], "11000.00")
def test_overlay_manual_wins(self) -> None:
tms = _quote(("运输费", "1000", "CNY"), ("卸货费", "200", "CNY"), total="1200")
manual = _quote(("卸货费", "300", "CNY"), ("配送费", "20", "CNY"), total="320")
@@ -219,17 +300,18 @@ class QuoteAdjustTests(unittest.TestCase):
)
self.assertAlmostEqual(second.profit_point or 0, 30.0)
def test_total_label_cost_until_sales_adjust(self) -> None:
self.assertEqual(total_price_label("AIR"), "总销售价")
self.assertEqual(total_price_label("SEA"), "总成本价")
self.assertEqual(total_price_label("LAND"), "总成本价")
self.assertEqual(total_price_label("", transport_mode="空运"), "总销售价")
self.assertEqual(total_price_label("", transport_mode="陆运"), "总成本价")
def test_total_label_is_fee_total_for_every_line(self) -> None:
"""销售侧汇总一律叫费用合计,空运/海运/陆运、TMS/调价都一样。"""
self.assertEqual(total_price_label("AIR"), "费用合计")
self.assertEqual(total_price_label("SEA"), "费用合计")
self.assertEqual(total_price_label("LAND"), "费用合计")
self.assertEqual(total_price_label("", transport_mode="空运"), "费用合计")
self.assertEqual(total_price_label("", transport_mode="陆运"), "费用合计")
tms = {"source": "TMS", "source_label": "TMS标准报价"}
self.assertEqual(total_price_label("LAND", quote=tms), "总成本价")
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "总成本价")
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "总销售价")
self.assertEqual(total_price_label("AIR", quote=tms), "总销售价")
self.assertEqual(total_price_label("LAND", quote=tms), "费用合计")
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "费用合计")
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "费用合计")
self.assertEqual(total_price_label("AIR", quote=tms), "费用合计")
def test_tms_land_card_uses_cost_not_sale(self) -> None:
q = _quote(("运输费", "121.00", "CNY"), total="121.00")
@@ -239,7 +321,7 @@ class QuoteAdjustTests(unittest.TestCase):
work_order_no="WO202609200005", quote=q, business_line="LAND"
)
self.assertEqual(detail["template_card"]["source"]["desc"], "TMS标准报价")
self.assertIn("总成本价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertNotIn("总销售价", detail["template_card"]["quote_area"]["quote_text"])
scheme = copy.scheme_wecom_payload(
work_order_no="WO202609200005",
@@ -248,9 +330,9 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
self.assertEqual(scheme["template_card"]["source"]["desc"], "TMS标准报价")
self.assertIn("总成本价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
deal = copy.deal_horizontal_rows(quote=q, business_line="LAND")
self.assertEqual(deal[0]["keyname"], "总成本价")
self.assertEqual(deal[0]["keyname"], "费用合计")
def test_adjust_confirm_uses_sale_total_name(self) -> None:
src = _quote(("运输费", "110", "CNY"), total="110")
@@ -261,24 +343,22 @@ class QuoteAdjustTests(unittest.TestCase):
)
self.assertTrue(land.ok)
names = [x.get("name") for x in land.applied]
self.assertIn("总销售价", names)
self.assertNotIn("合计", names)
self.assertIn("费用合计", names)
self.assertNotIn("总销售价", names)
self.assertNotIn("总成本价", names)
text = copy.adjust_confirm(land.applied)
self.assertIn("总销售价在当前报价基础上", text)
self.assertNotIn("合计在当前报价基础上", text)
self.assertIn("费用合计在当前报价基础上", text)
body = copy.adjust_group_body(land.quote, business_line="LAND")
self.assertIn("总销售价:", body)
self.assertNotIn("合计:", body)
self.assertIn("费用合计:", body)
air = apply_adjust(
src,
extract_adjust("总销售价改为100CNY", fee_names=["运输费"], allow_model=False),
business_line="AIR",
)
self.assertIn("总销售价", [x.get("name") for x in air.applied])
self.assertNotIn("合计", [x.get("name") for x in air.applied])
self.assertIn("费用合计", [x.get("name") for x in air.applied])
note = copy.adjust_ledger_remark(land.applied)
self.assertTrue(note.startswith("销售调价:"))
self.assertIn("总销售价", note)
self.assertIn("费用合计", note)
def test_quote_copy_uses_line_and_source_total_name(self) -> None:
sea = {
@@ -293,16 +373,17 @@ class QuoteAdjustTests(unittest.TestCase):
"source": "TMS",
"fee_rows": [{"name": "空运费", "amount": "700", "currency": "CNY"}],
}
self.assertIn("总成本价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("总销售价:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertIn("费用合计:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("费用合计:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertNotIn("总销售价合计", copy.quote_area_fee_text(air, business_line="AIR"))
self.assertNotIn("总销售价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
self.assertNotIn("总销售价", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertNotIn("总成本价", copy.quote_area_fee_text(sea, business_line="SEA"))
self.assertIn("费用合计:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
rows = copy.deal_horizontal_rows(quote=air, business_line="AIR")
self.assertEqual(rows[0]["keyname"], "总销售价")
self.assertEqual(rows[0]["keyname"], "费用合计")
self.assertNotIn("时效", [x["keyname"] for x in rows])
land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND")
self.assertEqual(land_rows[0]["keyname"], "总成本价")
self.assertEqual(land_rows[0]["keyname"], "费用合计")
self.assertIn("时效", [x["keyname"] for x in land_rows])
def test_adjust_cards_use_sales_adjust_not_tms(self) -> None:
@@ -314,7 +395,7 @@ class QuoteAdjustTests(unittest.TestCase):
work_order_no="WO202609200004", quote=q, business_line="LAND"
)
self.assertEqual(detail["template_card"]["source"]["desc"], "销售调价")
self.assertIn("总销售价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
self.assertNotIn("总成本价", detail["template_card"]["quote_area"]["quote_text"])
scheme = copy.scheme_wecom_payload(
work_order_no="WO202609200004",
@@ -323,7 +404,7 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
self.assertEqual(scheme["template_card"]["source"]["desc"], "销售调价")
self.assertIn("总销售价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
def test_private_eligibility(self) -> None:
self.assertEqual(private_session_can_adjust("已报价", True, ""), "")
@@ -469,10 +550,10 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
tms_block = text.split("TMS报价:", 1)[1]
self.assertIn("总成本价:CNY 110.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("费用合计:CNY 110.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("销售调价:", text)
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertNotIn("总销售价:CNY 100.00", tms_block.split("销售调价:", 1)[0])
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertNotIn("费用合计:CNY 100.00", tms_block.split("销售调价:", 1)[0])
def test_group_brief_adjust_alone_does_not_become_tms(self) -> None:
"""没单独传入 TMS 时,调后价也不能写进 TMS 段。"""
@@ -490,8 +571,9 @@ class QuoteAdjustTests(unittest.TestCase):
transport_mode="陆运",
)
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("销售调价:", text)
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
if __name__ == "__main__":
+67 -8
View File
@@ -143,6 +143,23 @@ class SeaTextInquiryTests(unittest.TestCase):
self.assertEqual(check["facts"]["货量"], "15件")
self.assertNotIn("货量", check["missing"])
def test_container_shaped_cargo_qty_uses_pieces(self) -> None:
"""货量被抽成箱型时,改用件数。20件、15件同一条规则。"""
facts = {
"起运港": "广州港",
"目的港": "巴生港",
"品名": "机器设备",
"货量": "40HQ*1",
"件数": "20件",
"整柜或拼柜": "整柜",
"箱型箱量": "40HQ*1",
"贸易条款": "DOOR-DOOR",
"运输分类": "Port to Port",
}
check = validate_required_fields(facts=facts, business_line="SEA")
self.assertEqual(check["facts"]["货量"], "20件")
self.assertEqual(check["facts"]["箱型箱量"], "40HQ*1")
def test_sea_missing_container_only_asks_container(self) -> None:
facts = {
"起运港": "上海",
@@ -478,7 +495,7 @@ class SeaTmsCardTests(unittest.TestCase):
self.assertIn("海运费:USD 575", text)
self.assertIn("海运费小计:CNY 4038", text)
self.assertIn("目的港费用小计:CNY 470", text)
self.assertIn("总成本价:CNY 6952.77", text)
self.assertIn("费用合计:CNY 6952.77", text)
self.assertNotIn("时效", text)
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
@@ -515,14 +532,14 @@ class SeaTmsCardTests(unittest.TestCase):
"封条费(SEAI):CNY 50.00",
"设备费:CNY 50.00",
"报关费:CNY 250.00",
"总成本价:CNY 2287.65",
"费用合计:CNY 2287.65",
]
),
)
self.assertNotIn("时效", text)
self.assertNotIn("报价有效期", text)
card_text = copy.quote_area_safe_text(long_quote, business_line="SEA")
self.assertIn("总成本价:CNY 2287.65", card_text)
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(
@@ -579,13 +596,15 @@ class SeaTmsCardTests(unittest.TestCase):
self.assertIn("价格明细", joined)
self.assertIn("是否拉产品人员进群协同", joined)
self.assertIn(copy.BTN_PULL_COLLAB, joined)
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
self.assertIn(copy.BTN_SKIP_COLLAB_TMS, joined)
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
self.assertEqual(copy.canonical_button(copy.BTN_SKIP_COLLAB_TMS), copy.BTN_SKIP_COLLAB)
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])
self.assertEqual(buttons, [copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB_TMS])
def test_sea_miss_one_card_with_pull_button(self) -> None:
facts = dict(COMPLETE_SEA)
@@ -1118,6 +1137,21 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
from agent.llm.extract_text import extract_product_quote
quote = extract_product_quote(
"空运段,空运费 单价CNY99.00,数量100 ,金额CNY199.00@询价小助手"
)
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
self.assertIn("空运费", rows)
self.assertNotIn("海运费", rows)
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
self.assertEqual(str(quote.get("total") or ""), "199.0")
def test_fee_line_text_is_a_product_quote(self) -> None:
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
from agent.llm.extract_text import extract_product_quote
@@ -1374,6 +1408,9 @@ class SeaGroupCopyTests(unittest.TestCase):
self.assertNotIn("贸易条款", collab_block)
self.assertNotIn("HS编码", text)
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("航线人员可通过", text)
self.assertNotIn("未查到合适报价", text)
self.assertNotIn("确认报价", text)
@@ -1388,8 +1425,10 @@ class SeaGroupCopyTests(unittest.TestCase):
has_price=True,
)
self.assertIn("报价有效期:2026-09-30", text)
self.assertIn("有TMS报价时可回复“确认报价”沿用该价", text)
self.assertIn("也可直接回复协同报价或上传报价附件", text)
self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", text)
self.assertIn("亦可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统", text)
self.assertNotIn("请航线人员回复", text)
self.assertNotIn("人工报价:", text)
def test_group_brief_manual_quote_stays_off_tms(self) -> None:
@@ -1419,6 +1458,7 @@ class SeaGroupCopyTests(unittest.TestCase):
},
)
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
self.assertIn("人工报价:\n海运费:USD 100.00\n燃油附加费:USD 15.00", text)
self.assertNotIn("确认报价", text)
self.assertNotIn("总销售价合计:USD 100.00", text)
@@ -1795,7 +1835,7 @@ class SeaGroupOpsTests(unittest.TestCase):
"@吴季林\n"
"询价工单 WO202609160001\n"
"报价单已发送,请确认成交状态\n"
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
"咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中"
),
)
wo, chat = self._open_group()
@@ -1903,6 +1943,25 @@ class SeaGroupOpsTests(unittest.TestCase):
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
def test_sales_lost_and_reason_same_message(self) -> None:
"""群里一句同时说未成交和原因,不再追问。"""
wo, chat = self._open_group()
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-inline-1",
content="未成交,原因客户不想要了",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "客户不想要了")
def test_sales_can_close_deal_by_text(self) -> None:
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
wo, chat = self._open_group()
@@ -169,7 +169,8 @@ async function checkPhone(rule, value, callback) {
* 判断是否是OAuth2APP环境
*/
export function isOAuth2AppEnv() {
return /wxwork|dingtalk/i.test(navigator.userAgent);
// 后台只用账号密码。企微里打开卡片也不能走企业微信授权,否则会跳去授权地址并不断刷新。
return false;
}
/**