销售侧汇总统一叫费用合计,并认准空运费的金额。
企微跳转改为账号密码登录,报价卡上的费用按原金额改回;航线按单价数量金额报价时不再记成海运费。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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。"
|
||||
|
||||
@@ -856,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 ""),
|
||||
@@ -2282,7 +2282,7 @@ def deal_horizontal_rows(
|
||||
transport_mode: str = "",
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
|
||||
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
|
||||
|
||||
空运业务确认没有时效,这里不写。
|
||||
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
|
||||
@@ -2339,7 +2339,7 @@ def deal_wecom_payload(
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
|
||||
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
|
||||
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
字段:费用合计、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
@@ -2593,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,
|
||||
@@ -2615,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",
|
||||
@@ -2991,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",
|
||||
@@ -3090,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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user