diff --git a/inquiry-agent/agent/channel/h5/air_route_page.py b/inquiry-agent/agent/channel/h5/air_route_page.py index 0ae3416..c35fe83 100644 --- a/inquiry-agent/agent/channel/h5/air_route_page.py +++ b/inquiry-agent/agent/channel/h5/air_route_page.py @@ -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( "已选定", - "

已选定

请回企微查看价格明细。

", + "

已选定

正在返回企微,请查看价格明细。

", + 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 ( "" "" f"{html.escape(title)}" f"" - f"{body}" + f"{body}{closer}" ) diff --git a/inquiry-agent/agent/channel/h5/land_route_page.py b/inquiry-agent/agent/channel/h5/land_route_page.py index 26b6bd4..d0ba0cd 100644 --- a/inquiry-agent/agent/channel/h5/land_route_page.py +++ b/inquiry-agent/agent/channel/h5/land_route_page.py @@ -56,10 +56,11 @@ def render_land_route_page(payload: dict[str, Any]) -> str: def render_land_route_ok() -> str: - """选定成功。""" + """选定成功:短暂展示后关企微 H5。""" return _wrap( "已选定", - "

已选定

请回企微查看价格明细。

", + "

已选定

正在返回企微,请查看价格明细。

", + auto_close=True, ) diff --git a/inquiry-agent/agent/channel/h5/upload_page.py b/inquiry-agent/agent/channel/h5/upload_page.py index cee6508..0f328b2 100644 --- a/inquiry-agent/agent/channel/h5/upload_page.py +++ b/inquiry-agent/agent/channel/h5/upload_page.py @@ -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 = '

请回企微查看识别结果。

' if tone == "ok" else "" + extra = '

正在返回企微,请查看识别结果。

' if tone == "ok" else "" + closer = wecom_auto_close_script() if tone == "ok" else "" return f""" @@ -210,6 +212,7 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str: {extra} + {closer} """ diff --git a/inquiry-agent/agent/channel/h5/wecom_close.py b/inquiry-agent/agent/channel/h5/wecom_close.py new file mode 100644 index 0000000..73faaf5 --- /dev/null +++ b/inquiry-agent/agent/channel/h5/wecom_close.py @@ -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""" +""" diff --git a/inquiry-agent/agent/handlers/air_group_collab.py b/inquiry-agent/agent/handlers/air_group_collab.py index c00cfca..bfabac3 100644 --- a/inquiry-agent/agent/handlers/air_group_collab.py +++ b/inquiry-agent/agent/handlers/air_group_collab.py @@ -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, diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py index b102f51..dababef 100644 --- a/inquiry-agent/agent/llm/extract_text.py +++ b/inquiry-agent/agent/llm/extract_text.py @@ -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[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)" + r"\s*单价\s*" + r"(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + r"(?P\d+(?:\.\d+)?)" + r"(?:\s*[,,、]\s*数量\s*(?P\d+(?:\.\d+)?))?" + r"(?:\s*[,,、]\s*金额\s*" + r"(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s*)?" + r"(?P\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}], } diff --git a/inquiry-agent/agent/llm/mode_extract_adjust.py b/inquiry-agent/agent/llm/mode_extract_adjust.py index 5685f5c..fa438df 100644 --- a/inquiry-agent/agent/llm/mode_extract_adjust.py +++ b/inquiry-agent/agent/llm/mode_extract_adjust.py @@ -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。" diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py index ff27880..9873af1 100644 --- a/inquiry-agent/agent/policy/inquiry_copy.py +++ b/inquiry-agent/agent/policy/inquiry_copy.py @@ -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", diff --git a/inquiry-agent/agent/policy/multi_segments.py b/inquiry-agent/agent/policy/multi_segments.py index 33300a0..774c484 100644 --- a/inquiry-agent/agent/policy/multi_segments.py +++ b/inquiry-agent/agent/policy/multi_segments.py @@ -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): diff --git a/inquiry-agent/agent/policy/quote_adjust_ops.py b/inquiry-agent/agent/policy/quote_adjust_ops.py index a450035..bb9d9d3 100644 --- a/inquiry-agent/agent/policy/quote_adjust_ops.py +++ b/inquiry-agent/agent/policy/quote_adjust_ops.py @@ -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" diff --git a/inquiry-agent/agent/routing/air_group_intent.py b/inquiry-agent/agent/routing/air_group_intent.py index a617404..294f9ac 100644 --- a/inquiry-agent/agent/routing/air_group_intent.py +++ b/inquiry-agent/agent/routing/air_group_intent.py @@ -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( diff --git a/inquiry-agent/agent/routing/group_intent.py b/inquiry-agent/agent/routing/group_intent.py index af52c8e..7f943b0 100644 --- a/inquiry-agent/agent/routing/group_intent.py +++ b/inquiry-agent/agent/routing/group_intent.py @@ -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: diff --git a/inquiry-agent/agent/schema/quote_adjust.py b/inquiry-agent/agent/schema/quote_adjust.py index 9ec043c..ee84b77 100644 --- a/inquiry-agent/agent/schema/quote_adjust.py +++ b/inquiry-agent/agent/schema/quote_adjust.py @@ -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: diff --git a/inquiry-agent/tests/test_air_group_collab.py b/inquiry-agent/tests/test_air_group_collab.py index 2bea562..63ba233 100644 --- a/inquiry-agent/tests/test_air_group_collab.py +++ b/inquiry-agent/tests/test_air_group_collab.py @@ -210,8 +210,8 @@ 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) @@ -266,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: @@ -312,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 diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py index bb70326..f3013be 100644 --- a/inquiry-agent/tests/test_air_route_options.py +++ b/inquiry-agent/tests/test_air_route_options.py @@ -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() diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py index 5854afe..684999d 100644 --- a/inquiry-agent/tests/test_air_text_inquiry.py +++ b/inquiry-agent/tests/test_air_text_inquiry.py @@ -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"]} @@ -1124,7 +1124,7 @@ class AirTextInquiryTests(unittest.TestCase): self.assertIn("询价工单 WO202608210017", text) self.assertIn("报价单已发送,请跟进成交状态", text) self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text) - self.assertIn("总销售价:CNY 722.5", 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", }, ) diff --git a/inquiry-agent/tests/test_attachment_upload_h5.py b/inquiry-agent/tests/test_attachment_upload_h5.py index 5d9d13c..e1a9f0f 100644 --- a/inquiry-agent/tests/test_attachment_upload_h5.py +++ b/inquiry-agent/tests/test_attachment_upload_h5.py @@ -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() diff --git a/inquiry-agent/tests/test_attachment_wave.py b/inquiry-agent/tests/test_attachment_wave.py index a095ac6..3913680 100644 --- a/inquiry-agent/tests/test_attachment_wave.py +++ b/inquiry-agent/tests/test_attachment_wave.py @@ -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 diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py index 724c9c6..28cbdf8 100644 --- a/inquiry-agent/tests/test_deal_outcome.py +++ b/inquiry-agent/tests/test_deal_outcome.py @@ -150,6 +150,10 @@ class TestDealOutcome(unittest.TestCase): 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("未成交原因客户不想要了"), "客户不想要了") diff --git a/inquiry-agent/tests/test_land_route_options.py b/inquiry-agent/tests/test_land_route_options.py index 1c12853..9da2053 100644 --- a/inquiry-agent/tests/test_land_route_options.py +++ b/inquiry-agent/tests/test_land_route_options.py @@ -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( @@ -342,12 +342,12 @@ class LandRouteOptionsTests(unittest.TestCase): 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) @@ -653,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() diff --git a/inquiry-agent/tests/test_multi_segments.py b/inquiry-agent/tests/test_multi_segments.py index bff41f3..fb066ae 100644 --- a/inquiry-agent/tests/test_multi_segments.py +++ b/inquiry-agent/tests/test_multi_segments.py @@ -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 = ( diff --git a/inquiry-agent/tests/test_quote_adjust.py b/inquiry-agent/tests/test_quote_adjust.py index 942c2ee..6472eed 100644 --- a/inquiry-agent/tests/test_quote_adjust.py +++ b/inquiry-agent/tests/test_quote_adjust.py @@ -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 段。""" @@ -492,7 +573,7 @@ class QuoteAdjustTests(unittest.TestCase): 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__": diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py index 36c1312..b06c50a 100644 --- a/inquiry-agent/tests/test_sea_text_inquiry.py +++ b/inquiry-agent/tests/test_sea_text_inquiry.py @@ -478,7 +478,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 +515,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( @@ -1120,6 +1120,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 diff --git a/inquiry-backend/src/views/sys/login/useLogin.ts b/inquiry-backend/src/views/sys/login/useLogin.ts index 029abde..866c426 100644 --- a/inquiry-backend/src/views/sys/login/useLogin.ts +++ b/inquiry-backend/src/views/sys/login/useLogin.ts @@ -169,7 +169,8 @@ async function checkPhone(rule, value, callback) { * 判断是否是OAuth2APP环境 */ export function isOAuth2AppEnv() { - return /wxwork|dingtalk/i.test(navigator.userAgent); + // 后台只用账号密码。企微里打开卡片也不能走企业微信授权,否则会跳去授权地址并不断刷新。 + return false; } /**