Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5402f94d30 | ||
|
|
37278aa0db | ||
|
|
4dfd756bcf | ||
|
|
84207bb4e5 | ||
|
|
378bb892b3 |
@@ -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>"""
|
||||
@@ -13,6 +13,7 @@ from typing import Any, Optional
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_group_ops import (
|
||||
is_air_ticket,
|
||||
activate_ticket,
|
||||
apply_air_confirm_tms,
|
||||
apply_air_deal,
|
||||
@@ -135,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,
|
||||
@@ -167,7 +176,7 @@ def handle_air_group(
|
||||
return phase
|
||||
else:
|
||||
ticket = current_ticket(engine, chat_id)
|
||||
if ticket is None:
|
||||
if ticket is None or not is_air_ticket(ticket):
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
if is_handoff_ticket(ticket):
|
||||
@@ -210,7 +219,7 @@ def _handle_file(engine, message: InboundMessage) -> str:
|
||||
return phase
|
||||
else:
|
||||
ticket = current_ticket(engine, chat_id)
|
||||
if ticket is None:
|
||||
if ticket is None or not is_air_ticket(ticket):
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
if is_handoff_ticket(ticket):
|
||||
@@ -312,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(
|
||||
|
||||
@@ -21,6 +21,20 @@ logger = logging.getLogger(__name__)
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _memory_quote_can_lock(quote: dict[str, Any]) -> bool:
|
||||
"""单测锁舱:有 TMS 编号,或有人工总价/费用,才算有报价。不编造编号。"""
|
||||
src = dict(quote or {})
|
||||
label = str(src.get("source_label") or src.get("sourceLabel") or "")
|
||||
if "确认" in label and "TMS" in label.upper():
|
||||
return bool(str(src.get("quoteId") or "").strip()) and not str(src.get("quoteId") or "").upper().startswith("QT-")
|
||||
if str(src.get("total") or "").strip() not in {"", "-"}:
|
||||
return True
|
||||
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
|
||||
return True
|
||||
quote_id = str(src.get("quoteId") or "").strip()
|
||||
return bool(quote_id) and not quote_id.upper().startswith("QT-WO")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryTicket:
|
||||
"""内存工单。"""
|
||||
@@ -665,7 +679,10 @@ class MemoryLedger:
|
||||
ticket = self._tickets.get(work_order_no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "ticket_not_found"}
|
||||
ticket.collab_chat_id = (chat_id or "").strip()
|
||||
room = (chat_id or "").strip()
|
||||
# 一群同时只跟一张单:陆运/海运旧绑定也要清掉,否则群里补字段会写到旧单。
|
||||
self._unbind_other_collab_rooms(room=room, keep_work_order_no=work_order_no)
|
||||
ticket.collab_chat_id = room
|
||||
ticket.collab_member_ids = [str(x) for x in member_ids]
|
||||
ticket.product_ids = [str(x) for x in (product_ids or [])]
|
||||
ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()]
|
||||
@@ -743,6 +760,22 @@ class MemoryLedger:
|
||||
ticket.collab_facts.pop(name, None)
|
||||
return {"ok": True, "facts": dict(ticket.collab_facts)}
|
||||
|
||||
def _unbind_other_collab_rooms(self, *, room: str, keep_work_order_no: str) -> None:
|
||||
"""
|
||||
同一群只留 keep 这一张单。调用方必须已持 self._lock。
|
||||
|
||||
陆运/海运旧单也要摘掉。测服 WO202609230023 补协同写到了同群陆运 WO202609230007。
|
||||
"""
|
||||
room_id = (room or "").strip()
|
||||
keep = (keep_work_order_no or "").strip()
|
||||
if not room_id:
|
||||
return
|
||||
for other in self._tickets.values():
|
||||
if other.work_order_no == keep:
|
||||
continue
|
||||
if other.collab_chat_id == room_id:
|
||||
other.collab_chat_id = ""
|
||||
|
||||
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
|
||||
"""用工微群 chat_id 找回工单。"""
|
||||
raw = (chat_id or "").strip()
|
||||
@@ -772,10 +805,7 @@ class MemoryLedger:
|
||||
return {"ok": False, "error": "terminal", "status": ticket.status}
|
||||
if ticket.collab_chat_id and ticket.collab_chat_id != room:
|
||||
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
|
||||
for other in self._tickets.values():
|
||||
if other.work_order_no != no and other.collab_chat_id == room:
|
||||
if (other.business_line or "").upper() == "AIR":
|
||||
other.collab_chat_id = ""
|
||||
self._unbind_other_collab_rooms(room=room, keep_work_order_no=no)
|
||||
ticket.collab_chat_id = room
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -802,11 +832,20 @@ class MemoryLedger:
|
||||
no = (work_order_no or "").strip()
|
||||
ticket = self.get_ticket(work_order_no=no)
|
||||
quote = dict(ticket.quote or {}) if ticket else {}
|
||||
if not _memory_quote_can_lock(quote):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "no_quote",
|
||||
"failReason": "这张单还没有报价,不能锁舱",
|
||||
}
|
||||
quote_id = str(quote.get("quoteId") or "").strip()
|
||||
if quote_id.upper().startswith("QT-WO"):
|
||||
quote_id = ""
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "LOCKED",
|
||||
"lockId": f"CA-{no}-LOCK",
|
||||
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
|
||||
"quoteId": quote_id,
|
||||
"optionNo": option_no,
|
||||
"tmsRequestId": f"TMS-LOCK-{no}",
|
||||
}
|
||||
|
||||
@@ -11,27 +11,43 @@ import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
|
||||
_QUOTE_DATE_LABEL_RE = "|".join(
|
||||
re.escape(name)
|
||||
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
|
||||
)
|
||||
|
||||
_MODE_WORDS = (
|
||||
("空运", "AIR"),
|
||||
("海运", "SEA"),
|
||||
("陆运", "LAND"),
|
||||
)
|
||||
# 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。
|
||||
_MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$")
|
||||
_MODE_BY_INDEX = {
|
||||
"1": "LAND",
|
||||
"2": "SEA",
|
||||
"3": "AIR",
|
||||
"1": "LAND",
|
||||
"2": "SEA",
|
||||
"3": "AIR",
|
||||
}
|
||||
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。
|
||||
_LABEL_KEYS = (
|
||||
r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
|
||||
+ _QUOTE_DATE_LABEL_RE
|
||||
+ r"|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
|
||||
)
|
||||
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
|
||||
_LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)")
|
||||
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
|
||||
_OPTIONAL_INQUIRY_LABELS = (
|
||||
"通关口岸(非必填)",
|
||||
@@ -52,6 +68,48 @@ _OPTIONAL_INQUIRY_LABELS = (
|
||||
)
|
||||
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
|
||||
_NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S")
|
||||
# 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。
|
||||
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
|
||||
_CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*")
|
||||
_CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I)
|
||||
_CLARIFY_NAMES = frozenset(
|
||||
{
|
||||
"起运地",
|
||||
"起运港",
|
||||
"目的地",
|
||||
"目的港",
|
||||
"品名",
|
||||
"货物品名",
|
||||
"件数",
|
||||
"毛重",
|
||||
"体积(CBM)",
|
||||
"体积",
|
||||
"重量(KG)",
|
||||
"重量",
|
||||
"包装方式",
|
||||
"包装类型",
|
||||
"货量",
|
||||
"货物数量",
|
||||
"整柜或拼柜",
|
||||
"箱型箱量",
|
||||
"报价日期",
|
||||
*QUOTE_DATE_SYNONYMS,
|
||||
"运输分类",
|
||||
"运输方式",
|
||||
"运输类型",
|
||||
"线路类别",
|
||||
"车型/数量",
|
||||
"车型数量",
|
||||
"贸易条款",
|
||||
"货源地",
|
||||
"货好时间",
|
||||
"提货地址",
|
||||
"收货地址",
|
||||
"送货地址",
|
||||
"通关口岸",
|
||||
"始发站",
|
||||
}
|
||||
)
|
||||
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
|
||||
_SHEET_CLEAN_KEYS = frozenset(
|
||||
{
|
||||
@@ -62,7 +120,10 @@ _SHEET_CLEAN_KEYS = frozenset(
|
||||
"品名",
|
||||
"件数",
|
||||
"毛重",
|
||||
"体积(CBM)",
|
||||
"体积",
|
||||
"重量(KG)",
|
||||
"重量",
|
||||
"货量",
|
||||
"货物数量",
|
||||
"整柜或拼柜",
|
||||
@@ -70,6 +131,7 @@ _SHEET_CLEAN_KEYS = frozenset(
|
||||
"贸易条款",
|
||||
"运输分类",
|
||||
"报价日期",
|
||||
*QUOTE_DATE_SYNONYMS,
|
||||
"包装方式",
|
||||
"包装类型",
|
||||
}
|
||||
@@ -121,7 +183,9 @@ def detect_transport_mode(text: str) -> str:
|
||||
"""
|
||||
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
|
||||
|
||||
未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。
|
||||
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
|
||||
「10件」「1.件数」不是运输方式。
|
||||
"""
|
||||
raw = text or ""
|
||||
found = [code for word, code in _MODE_WORDS if word in raw]
|
||||
@@ -129,30 +193,79 @@ def detect_transport_mode(text: str) -> str:
|
||||
return found[0]
|
||||
if len(found) > 1:
|
||||
return ""
|
||||
hit = _MODE_INDEX.match(raw.strip())
|
||||
if hit:
|
||||
return _MODE_BY_INDEX.get(hit.group(1), "")
|
||||
if "飞" in raw:
|
||||
return "AIR"
|
||||
return ""
|
||||
|
||||
|
||||
def parse_clarify_paste_facts(text: str) -> dict[str, str]:
|
||||
"""
|
||||
认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。
|
||||
|
||||
「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。
|
||||
不猜港口,不改六态。
|
||||
"""
|
||||
facts: dict[str, str] = {}
|
||||
for line in (text or "").splitlines():
|
||||
raw = (line or "").strip()
|
||||
if not raw or _CLARIFY_EXAMPLE_LINE.match(raw):
|
||||
continue
|
||||
raw = _CLARIFY_INDEX.sub("", raw, count=1)
|
||||
if ":" not in raw and ":" not in raw:
|
||||
continue
|
||||
if ":" in raw:
|
||||
name, val = raw.split(":", 1)
|
||||
else:
|
||||
name, val = raw.split(":", 1)
|
||||
name = _CLARIFY_DECOR.sub("", name).strip()
|
||||
val = (val or "").strip()
|
||||
if not name or name not in _CLARIFY_NAMES:
|
||||
continue
|
||||
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
|
||||
continue
|
||||
facts[name] = clip_inquiry_value(val)
|
||||
return {key: val for key, val in facts.items() if val}
|
||||
|
||||
|
||||
def parse_labeled_facts(text: str) -> dict[str, str]:
|
||||
"""
|
||||
只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。
|
||||
|
||||
补问复制清单(1.件数(必填):10)优先。
|
||||
报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港),
|
||||
冒号对不上时再按表头切一刀,值只留格子。
|
||||
"""
|
||||
facts: dict[str, str] = {}
|
||||
raw = text or ""
|
||||
facts: dict[str, str] = parse_clarify_paste_facts(text)
|
||||
raw = "\n".join(
|
||||
line
|
||||
for line in (text or "").splitlines()
|
||||
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
|
||||
)
|
||||
for line in raw.splitlines():
|
||||
m = _LABEL_LINE.match(line.strip())
|
||||
if m:
|
||||
shown = line.strip()
|
||||
m = _LABEL_LINE.match(shown)
|
||||
if m and not str(facts.get(m.group(1)) or "").strip():
|
||||
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
|
||||
if not facts:
|
||||
for m in _LABEL_INLINE.finditer(raw):
|
||||
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
|
||||
from agent.schema.field_validate import normalize_facts
|
||||
|
||||
taken = set(normalize_facts(facts))
|
||||
for key, val in harvest_sheet_inquiry_cells(raw).items():
|
||||
if val and not str(facts.get(key) or "").strip():
|
||||
facts[key] = val
|
||||
if not val:
|
||||
continue
|
||||
mapped = next(iter(normalize_facts({key: val})), "")
|
||||
if mapped and mapped in taken:
|
||||
continue
|
||||
if str(facts.get(key) or "").strip():
|
||||
continue
|
||||
facts[key] = val
|
||||
if mapped:
|
||||
taken.add(mapped)
|
||||
return {key: val for key, val in facts.items() if val}
|
||||
|
||||
|
||||
@@ -287,6 +400,9 @@ def clip_inquiry_value(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
|
||||
if _CLARIFY_EXAMPLE_LINE.match(text):
|
||||
return ""
|
||||
hit = _NUMBERED_ITEM.search(text)
|
||||
if hit and hit.start() > 0:
|
||||
text = text[: hit.start()]
|
||||
@@ -384,7 +500,7 @@ def _label_is_token(raw: str, pos: int, label: str) -> bool:
|
||||
return False
|
||||
end = pos + len(label)
|
||||
nxt = raw[end : end + 6]
|
||||
if label in {"重量", "体积"} and nxt.startswith("("):
|
||||
if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -555,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.")
|
||||
|
||||
|
||||
@@ -582,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,群里发工单号不能当报价。
|
||||
@@ -591,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(),
|
||||
@@ -620,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)
|
||||
@@ -656,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。"
|
||||
|
||||
@@ -128,6 +128,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
|
||||
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
|
||||
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
|
||||
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
|
||||
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
|
||||
"货好时间、提货地址不要写成报价日期。"
|
||||
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
|
||||
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
|
||||
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
|
||||
|
||||
@@ -25,7 +25,24 @@ _PROMPT_CACHE: str | None = None
|
||||
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
|
||||
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
|
||||
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
|
||||
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
|
||||
(
|
||||
(
|
||||
"报价日期",
|
||||
"询价日期",
|
||||
"走货日期",
|
||||
"询价时间",
|
||||
"走货时间",
|
||||
"发货时间",
|
||||
"发货日期",
|
||||
"出货日期",
|
||||
"出货时间",
|
||||
"提货时间",
|
||||
"提货日期",
|
||||
"Inquiry Date",
|
||||
"Quote Date",
|
||||
),
|
||||
"quote_date",
|
||||
),
|
||||
(("有效期至", "有效期", "Validity"), "valid_until"),
|
||||
(("客户名称", "Customer Name", "Customer"), "customer_name"),
|
||||
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
|
||||
@@ -351,7 +368,20 @@ def merge_mapping_prefer_existing(
|
||||
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
|
||||
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
|
||||
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
|
||||
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
|
||||
"quote_date": (
|
||||
"报价日期",
|
||||
"询价日期",
|
||||
"走货日期",
|
||||
"询价时间",
|
||||
"走货时间",
|
||||
"发货时间",
|
||||
"发货日期",
|
||||
"出货日期",
|
||||
"出货时间",
|
||||
"提货时间",
|
||||
"提货日期",
|
||||
"Inquiry Date",
|
||||
),
|
||||
"pol": ("起运港", "POL"),
|
||||
"pod": ("目的港", "POD"),
|
||||
"commodity": ("品名", "货物品名", "Commodity"),
|
||||
|
||||
@@ -94,6 +94,13 @@ def operator_name(flow: Any, sender_id: str) -> str:
|
||||
return (sender_id or "").strip() or "航线"
|
||||
|
||||
|
||||
def is_air_ticket(ticket: object) -> bool:
|
||||
"""按群找回的工单必须是空运。陆运旧单不能当空运当前单补含电含磁。"""
|
||||
view = ticket_view(ticket)
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
|
||||
return not line or line == "AIR"
|
||||
|
||||
|
||||
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
|
||||
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
|
||||
|
||||
@@ -147,6 +154,8 @@ def activate_ticket(
|
||||
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
|
||||
if is_handoff_ticket(ticket):
|
||||
return None, "handoff_ignore"
|
||||
# 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。
|
||||
_seed_air_activate_collab(flow, ticket)
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
@@ -156,6 +165,54 @@ def activate_ticket(
|
||||
return ticket, "activated"
|
||||
|
||||
|
||||
def _collab_slot_empty(val: object) -> bool:
|
||||
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
|
||||
return str(val or "").strip() in {"", "-", "—", "-"}
|
||||
|
||||
|
||||
def _seed_air_activate_collab(flow: Any, ticket: object) -> dict[str, str]:
|
||||
"""
|
||||
空运群激活时预填协同,并记到主账。
|
||||
|
||||
私聊已经写过的含电/含磁先带上。
|
||||
货物品名正好是「普货」、该项还空着,默认否。
|
||||
群里已经记下的值不覆盖。只补空项。
|
||||
"""
|
||||
from agent.schema.field_validate import (
|
||||
AIR_COLLAB_FIELDS,
|
||||
air_general_cargo_collab_defaults,
|
||||
pick_collab_from_facts,
|
||||
)
|
||||
|
||||
view = ticket_view(ticket)
|
||||
no = ticket_no(ticket)
|
||||
facts = dict(view.get("facts") or {})
|
||||
# 主账有时品名在货物名称列,事实里没带「品名」。群摘要仍能看到普货。
|
||||
if not str(facts.get("品名") or facts.get("货物品名") or "").strip():
|
||||
cargo = str(view.get("goodsName") or view.get("goods_name") or "").strip()
|
||||
if cargo:
|
||||
facts["品名"] = cargo
|
||||
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
|
||||
for key, val in air_general_cargo_collab_defaults(facts).items():
|
||||
if _collab_slot_empty(seeded.get(key)):
|
||||
seeded[key] = val
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
to_write: dict[str, str] = {}
|
||||
for key, val in seeded.items():
|
||||
if val and _collab_slot_empty(collab.get(key)):
|
||||
collab[key] = val
|
||||
to_write[key] = val
|
||||
if to_write:
|
||||
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
|
||||
if callable(patcher):
|
||||
patcher(
|
||||
work_order_no=no,
|
||||
facts=to_write,
|
||||
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
|
||||
)
|
||||
return collab
|
||||
|
||||
|
||||
def send_air_brief_and_sheet(
|
||||
*,
|
||||
flow: Any,
|
||||
@@ -177,25 +234,7 @@ def send_air_brief_and_sheet(
|
||||
if room and not view.get("collab_chat_id"):
|
||||
view["collab_chat_id"] = room
|
||||
facts = dict(view.get("facts") or {})
|
||||
from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts
|
||||
|
||||
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
if seeded:
|
||||
changed = False
|
||||
for k, v in seeded.items():
|
||||
if v and not str(collab.get(k) or "").strip():
|
||||
collab[k] = v
|
||||
changed = True
|
||||
if changed:
|
||||
view["collab_facts"] = collab
|
||||
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
|
||||
if callable(patcher):
|
||||
patcher(
|
||||
work_order_no=no,
|
||||
facts=seeded,
|
||||
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
|
||||
)
|
||||
collab = _seed_air_activate_collab(flow, view)
|
||||
view["collab_facts"] = collab
|
||||
view_q = dict(view.get("quote") or {})
|
||||
override = dict(quote_override or {})
|
||||
@@ -358,6 +397,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
no = ticket_no(ticket)
|
||||
client = group_client_of(flow)
|
||||
if not is_air_ticket(ticket):
|
||||
send_group_text(client, chat_id, copy.air_need_work_order())
|
||||
return "need_work_order"
|
||||
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
if stopped:
|
||||
return stopped
|
||||
@@ -369,7 +411,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
|
||||
send_group_text(client, chat_id, copy.air_fields_not_recognized())
|
||||
return "fields_empty"
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
ticket2 = current_ticket(flow, chat_id) or ticket
|
||||
# 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。
|
||||
getter = getattr(flow.ledger, "get_ticket", None)
|
||||
reloaded = getter(work_order_no=no) if callable(getter) else None
|
||||
ticket2 = hydrate_ticket(flow, reloaded or ticket)
|
||||
view = ticket_view(ticket2)
|
||||
collab = dict(view.get("collab_facts") or {})
|
||||
collab.update(fields)
|
||||
@@ -564,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())
|
||||
@@ -571,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),
|
||||
@@ -583,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(
|
||||
@@ -625,10 +680,8 @@ def apply_air_lock(
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked and options:
|
||||
if not picked and len(options) == 1:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
if not picked:
|
||||
picked = "AIR-OPT-01"
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
@@ -657,6 +710,9 @@ def apply_air_lock(
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
if str(out.get("error") or "") == "no_quote":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
|
||||
return "no_quote"
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
|
||||
@@ -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
|
||||
@@ -817,7 +828,7 @@ class AirTextInquiryFlow:
|
||||
chat_fn=chat_fn,
|
||||
inbound_raw=inbound_raw,
|
||||
)
|
||||
reply("请补充运输方式:空运、海运或陆运。")
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
return "need_mode"
|
||||
|
||||
check = validate_required_fields(facts=facts, business_line="AIR")
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ _SALES_CODES = {
|
||||
}
|
||||
|
||||
_LAND_PRODUCT_CODES = {
|
||||
"普件(常用线路/常规车型)": "land_common",
|
||||
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
|
||||
|
||||
@@ -26,23 +26,22 @@ from agent.schema.field_validate import (
|
||||
)
|
||||
from agent.schema.land_options import option_prompt as _land_option_prompt
|
||||
from agent.schema.quote_adjust import total_price_label
|
||||
from agent.schema.sea_options import sea_class_prompt
|
||||
|
||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "茶叶",
|
||||
"件数": "10件",
|
||||
"毛重": "100KGS",
|
||||
"体积": "2CBM",
|
||||
"包装方式": "托盘",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘、散货",
|
||||
"报价日期": "2026-09-15",
|
||||
"货源地": "东莞",
|
||||
"货好时间": "随时可提",
|
||||
"提货地址": "东莞仓库",
|
||||
"收货地址": "马尼拉",
|
||||
"贸易条款": "FOB",
|
||||
"贸易条款": "DDP、DAP、CIF、CIP",
|
||||
}
|
||||
_AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
@@ -72,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 = "拉产品进群协同"
|
||||
@@ -114,7 +115,15 @@ _CONTINUE_HINT = (
|
||||
)
|
||||
|
||||
|
||||
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
|
||||
ASK_TRANSPORT = (
|
||||
"请回复数字或文字,告知我询价的运输方式:\n"
|
||||
"1.陆运\n"
|
||||
"2.海运\n"
|
||||
"3.空运"
|
||||
)
|
||||
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
|
||||
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
|
||||
_PENDING_UNITS = {}
|
||||
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
|
||||
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
|
||||
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
|
||||
@@ -179,10 +188,9 @@ ASK_LAND_SUBTYPE = _land_option_prompt(current={})
|
||||
|
||||
def land_sample_image_payload() -> dict[str, Any]:
|
||||
"""
|
||||
填写样例表图片出站。
|
||||
填写样例表图片载荷。编号清单上线后不再主动出站,保留给旧调用。
|
||||
|
||||
走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。
|
||||
禁止在回调线程上传。
|
||||
走企微 image,不走文件气泡。禁止在回调线程上传。
|
||||
"""
|
||||
from agent.schema.land_options import sample_image_path
|
||||
|
||||
@@ -195,7 +203,7 @@ def land_sample_image_payload() -> dict[str, Any]:
|
||||
|
||||
|
||||
def land_sample_image_fallback() -> str:
|
||||
"""图片失败时回文字样例,避免销售什么都看不到。"""
|
||||
"""旧图失败兜底。新补问不再发图,一般不会走到这里。"""
|
||||
from agent.schema.land_options import sample_fallback_text
|
||||
|
||||
return sample_fallback_text()
|
||||
@@ -278,14 +286,40 @@ def _land_optional_keys(land_type: str) -> tuple[str, ...]:
|
||||
return ()
|
||||
|
||||
|
||||
def _pending_field_block(
|
||||
index: int,
|
||||
label: str,
|
||||
example: str = "",
|
||||
*,
|
||||
optional: bool = False,
|
||||
unit: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
待补充一项:编号 + 字段名 + 必填/单位,示例另起一行。
|
||||
|
||||
销售侧如:
|
||||
1.件数(必填):
|
||||
(如:10)
|
||||
2.重量(KG)(必填):
|
||||
(如:100)
|
||||
复制回去时只认冒号后面的值;下一行「(如:…)」不算已填。
|
||||
"""
|
||||
mark = "(非必填)" if optional else "(必填)"
|
||||
extra = f"({unit})" if (unit or "").strip() else ""
|
||||
head = f"{index}.{label}{extra}{mark}:"
|
||||
shown = (example or "").strip()
|
||||
if shown:
|
||||
return f"{head}\n(如:{shown})"
|
||||
return head
|
||||
|
||||
|
||||
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
|
||||
"""销售侧一行:名称(必填/非必填):值或示例。"""
|
||||
"""已识别一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
|
||||
label = LAND_DISPLAY.get(key, key)
|
||||
mark = "必填" if required else "非必填"
|
||||
if value:
|
||||
return f"{label}({mark}):{value}"
|
||||
if example:
|
||||
return f"{label}({mark}):(如:{example})"
|
||||
_ = example
|
||||
return f"{label}({mark}):"
|
||||
|
||||
|
||||
@@ -350,11 +384,22 @@ def ask_land_fields(
|
||||
if not pending:
|
||||
return ""
|
||||
lines.append("")
|
||||
lines.append("待补充:")
|
||||
lines.append(CLARIFY_PASTE_HINT)
|
||||
seq = 0
|
||||
for key in pending:
|
||||
seq += 1
|
||||
required = key not in optional
|
||||
example = _LAND_EXAMPLES.get(key, "")
|
||||
lines.append(_land_field_line(key, "", required=required, example=example))
|
||||
label = LAND_DISPLAY.get(key, key)
|
||||
lines.append(
|
||||
_pending_field_block(
|
||||
seq,
|
||||
label,
|
||||
example,
|
||||
optional=not required,
|
||||
unit=_PENDING_UNITS.get(key, ""),
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -394,14 +439,13 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str:
|
||||
|
||||
|
||||
def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
|
||||
"""空运销售侧一行:名称(必填/非必填):值或示例。"""
|
||||
"""空运核对卡一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
|
||||
label = AIR_DISPLAY_LABEL(key)
|
||||
mark = "必填" if required else "非必填"
|
||||
text = (value or "").strip()
|
||||
if text:
|
||||
return f"{label}({mark}):{text}"
|
||||
if example:
|
||||
return f"{label}({mark}):(如:{example})"
|
||||
_ = example
|
||||
return f"{label}({mark}):"
|
||||
|
||||
|
||||
@@ -440,7 +484,7 @@ def ask_clarify(
|
||||
transport_mode: str = "空运",
|
||||
) -> str:
|
||||
"""
|
||||
缺项补问:先回显已识别,再列待补充并给示例。
|
||||
缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。
|
||||
|
||||
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
|
||||
空运非必填一并列出,没填进待补充,不挡查价。
|
||||
@@ -470,18 +514,28 @@ def ask_clarify(
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
lines.append("")
|
||||
lines.append("待补充:")
|
||||
lines.append(CLARIFY_PASTE_HINT)
|
||||
any_missing = False
|
||||
seq = 0
|
||||
if "运输方式" in missing_set:
|
||||
any_missing = True
|
||||
lines.append("运输方式:(如:空运)")
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, "运输方式", "空运"))
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
if sea and key == "运输分类":
|
||||
lines.append(sea_class_prompt())
|
||||
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
|
||||
else:
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
lines.append(
|
||||
_pending_field_block(
|
||||
seq,
|
||||
label,
|
||||
example,
|
||||
unit=_PENDING_UNITS.get(key, ""),
|
||||
)
|
||||
)
|
||||
if not sea:
|
||||
optional_pending = [
|
||||
(key, label, example)
|
||||
@@ -490,8 +544,9 @@ def ask_clarify(
|
||||
]
|
||||
for key, label, example in optional_pending:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
shown = "起运港" if key == "起运港" else label
|
||||
lines.append(f"{shown}(非必填):(如:{example})")
|
||||
lines.append(_pending_field_block(seq, shown, example, optional=True))
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
@@ -507,8 +562,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
|
||||
"毛重": "毛重",
|
||||
"重量": "毛重",
|
||||
"重量(KG)": "毛重",
|
||||
"重量(KG)": "毛重",
|
||||
"体积": "体积",
|
||||
"体积(CBM)": "体积",
|
||||
"体积(CBM)": "体积",
|
||||
"包装类型": "包装方式",
|
||||
"包装方式": "包装方式",
|
||||
"货物数量": "货量",
|
||||
@@ -610,8 +667,8 @@ def inquiry_field_lines(
|
||||
f"目的地:{dest}",
|
||||
f"品名:{cargo}",
|
||||
f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}",
|
||||
f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
|
||||
f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
|
||||
f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
|
||||
f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
|
||||
f"包装类型:{pack}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
@@ -640,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}:"):
|
||||
@@ -794,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 ""),
|
||||
@@ -1386,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)
|
||||
|
||||
|
||||
@@ -1430,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}",
|
||||
},
|
||||
@@ -1805,7 +1867,7 @@ def sea_group_brief(
|
||||
"""
|
||||
群里自动摘要:询价 + 协同 + TMS 报价 + 人工报价。
|
||||
|
||||
TMS 无价只写「TMS 暂无匹配报价」,产品报的价单独放人工报价,销售调价另起一段。
|
||||
TMS 无价写「TMS 暂无匹配报价」并提示用文字或 Excel 另报;产品报的价单独放人工报价,销售调价另起一段。
|
||||
禁止把调后价写进 TMS 段。陆运传入 transport_mode=陆运,始发站/货物字段跟核对卡同一套。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -1852,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("人工报价:")
|
||||
@@ -2205,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 = "报价单已发送,请跟进成交状态"
|
||||
|
||||
|
||||
@@ -2217,7 +2282,7 @@ def deal_horizontal_rows(
|
||||
transport_mode: str = "",
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
|
||||
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
|
||||
|
||||
空运业务确认没有时效,这里不写。
|
||||
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
|
||||
@@ -2274,7 +2339,7 @@ def deal_wecom_payload(
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
|
||||
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
|
||||
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
字段:费用合计、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
@@ -2338,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:
|
||||
@@ -2427,7 +2518,7 @@ def group_deal_followup(*, sales_name: str, work_order_no: str) -> str:
|
||||
f"@{name}\n"
|
||||
f"询价工单 {no}\n"
|
||||
"报价单已发送,请确认成交状态\n"
|
||||
"咨询成交状态:请输入成交 / 未成交 / 协商中(未成交需补充原因)"
|
||||
"咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中"
|
||||
)
|
||||
|
||||
|
||||
@@ -2435,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:
|
||||
@@ -2494,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,
|
||||
@@ -2516,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",
|
||||
@@ -2601,8 +2710,17 @@ def air_group_brief(
|
||||
lines.append("")
|
||||
lines.append("协同补充字段:")
|
||||
collab = dict(collab_facts or {})
|
||||
from agent.schema.field_validate import air_general_cargo_collab_defaults
|
||||
|
||||
# 品名是普货时,摘要里不能再把没写的含电/含磁显示成横杠。
|
||||
for key, val in air_general_cargo_collab_defaults(facts).items():
|
||||
current = (collab.get(key) or "").strip()
|
||||
if current in {"", "-", "—", "-"}:
|
||||
collab[key] = val
|
||||
for key in AIR_COLLAB_FIELDS:
|
||||
val = (collab.get(key) or "").strip() or "-"
|
||||
val = (collab.get(key) or "").strip()
|
||||
if val in {"", "-", "—", "-"}:
|
||||
val = "-"
|
||||
lines.append(f"{key}:{val}")
|
||||
tms, manual, adjust = _split_brief_quotes(
|
||||
quote=quote,
|
||||
@@ -2618,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("人工报价:")
|
||||
@@ -2692,8 +2811,13 @@ def air_fields_denied() -> str:
|
||||
|
||||
|
||||
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
|
||||
return air_lock_need_quote(work_order_no)
|
||||
|
||||
|
||||
def air_lock_need_quote(work_order_no: str) -> str:
|
||||
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
|
||||
return f"工单{no}还没有报价,不能锁舱。"
|
||||
|
||||
|
||||
def air_ask_lock_option(options: list) -> str:
|
||||
@@ -2738,7 +2862,7 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||
"""
|
||||
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
||||
没有报价也可以锁舱,禁止改写成「报价选项不存在」或「航班已起飞」。
|
||||
禁止改写成「报价选项不存在」或「航班已起飞」。
|
||||
flight_date / today 只为兼容旧调用,不再用来改写原文。
|
||||
"""
|
||||
_ = flight_date
|
||||
@@ -2882,10 +3006,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",
|
||||
@@ -2981,10 +3102,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",
|
||||
|
||||
@@ -24,7 +24,6 @@ from agent.schema.land_options import (
|
||||
harvest_land_options,
|
||||
looks_like_confirm,
|
||||
option_prompt,
|
||||
option_prompt_needs_sample,
|
||||
sample_pair_allowed,
|
||||
validate_land_options,
|
||||
)
|
||||
@@ -79,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)
|
||||
@@ -225,14 +227,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
message: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
选项补问:先发文字清单,需要样例时再发表格图。
|
||||
选项补问:只发复制填空清单。填写样例图不再另发。
|
||||
|
||||
图片失败由出站回退文字样例。不在本方法上传素材。
|
||||
不在本方法上传素材。
|
||||
"""
|
||||
body = (message or "").strip() or option_prompt(current=facts)
|
||||
reply(body)
|
||||
if option_prompt_needs_sample(current=facts):
|
||||
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
|
||||
|
||||
def _confirm_or_hold(
|
||||
self,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -22,7 +22,6 @@ from agent.llm.extract_text import parse_labeled_facts
|
||||
from agent.schema.land_options import (
|
||||
harvest_land_options,
|
||||
option_prompt,
|
||||
option_prompt_needs_sample,
|
||||
scrub_land_placeholder_ports,
|
||||
validate_land_options,
|
||||
)
|
||||
@@ -81,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):
|
||||
@@ -155,7 +162,7 @@ def refresh_segments(segments: list[Segment]) -> tuple[bool, list[Segment], str]
|
||||
按各段运输方式校验必填,并把默认报价日期写回。
|
||||
|
||||
返回:是否已齐、更新后的段、缺口文案。缺口一条消息说完,不套群摘要。
|
||||
待补充的举例与单聊相同,例如「品名:(如:茶叶)」。
|
||||
待补充的举例与单聊相同:字段名一行,下一行「(如:茶叶)」。
|
||||
"""
|
||||
updated: list[Segment] = []
|
||||
blocks: list[str] = []
|
||||
@@ -369,7 +376,10 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str:
|
||||
return ""
|
||||
missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
|
||||
if check.get("need_land_subtype"):
|
||||
return "待补充:\n运输类型(必填):(如:国内运输拼车)"
|
||||
return (
|
||||
copy.CLARIFY_PASTE_HINT
|
||||
+ "\n1.运输类型(必填):\n(如:国内运输拼车)"
|
||||
)
|
||||
if seg.mode == "LAND":
|
||||
if check.get("need_land_options"):
|
||||
# 和单聊一样先列出线路类别、运输分类,选完再问询价字段。
|
||||
|
||||
@@ -37,7 +37,6 @@ from agent.policy.multi_segments import (
|
||||
)
|
||||
from agent.schema.land_options import (
|
||||
looks_like_confirm,
|
||||
option_prompt_needs_sample,
|
||||
sample_pair_allowed,
|
||||
)
|
||||
|
||||
@@ -291,17 +290,11 @@ class MultiTextInquiryFlow:
|
||||
|
||||
def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None:
|
||||
"""
|
||||
补问正文。陆运还缺类型或线路时,再发一张和单聊相同的填写样例图。
|
||||
补问正文。陆运选项走复制清单,不再另发填写样例图。
|
||||
|
||||
副作用:出站文字,可能再出一张图片。只在 Worker 里调。
|
||||
副作用:出站文字。只在 Worker 里调。
|
||||
"""
|
||||
reply(gaps)
|
||||
for seg in segments:
|
||||
if seg.mode != "LAND":
|
||||
continue
|
||||
if option_prompt_needs_sample(current=seg.facts):
|
||||
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
|
||||
return
|
||||
|
||||
def _enrich_segments(
|
||||
self,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
════════════════════════════════════
|
||||
|
||||
(1)询价事实 facts(对话/工单,不是 TMS 价)
|
||||
起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。
|
||||
起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期/走货日期/发货日期/出货日期/提货日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。
|
||||
|
||||
(2)TMS 报价 data(经主账查询返回;出单前投影为 fillFacts / fee_rows / 分段费用)
|
||||
顶层共通字段(海陆空响应里都可能出现):
|
||||
@@ -42,7 +42,7 @@ data.chargeableWeightKg, missingFields, remark
|
||||
data.isMulti(空运/陆运:1=单段或单方案,2=多段/多方案三选一等,以业务约定为准)
|
||||
|
||||
系统补全:quote_no←工单号;sales_name←业务员;quote_date 空则当天。
|
||||
「询价日期」与「报价日期」→ 都映射 quote_date。
|
||||
「报价日期」「询价日期」「走货日期」「询价时间」「走货时间」「发货时间」「发货日期」「出货日期」「出货时间」「提货时间」「提货日期」→ 都映射 quote_date。货好时间、提货地址不要映射成 quote_date。
|
||||
|
||||
════════════════════════════════════
|
||||
【二】TMS 返回结构 → 填表投影(出单程序做;你的映射必须接得住)
|
||||
@@ -145,7 +145,7 @@ customer_name ← 客户名称、客户
|
||||
contact_name ← 联系人、承运联系人
|
||||
contact_phone ← 电话
|
||||
quote_no ← 报价单编号、报价编号、RFQ No
|
||||
quote_date ← 报价日期、询价日期、Inquiry Date
|
||||
quote_date ← 报价日期、询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期、Inquiry Date
|
||||
valid_until ← 有效期至、报价有效期、period(← TMS validUntil)
|
||||
currency ← 币别、币种(← TMS currency / settlementCurrency)
|
||||
sales_name ← 业务员
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.routing.group_intent import (
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_OTHER,
|
||||
INTENT_QUOTE,
|
||||
_exact_tms_choice,
|
||||
_hint_confirm_tms,
|
||||
_hint_deal_outcome,
|
||||
_hint_tms_choice,
|
||||
@@ -94,6 +95,14 @@ def classify_air_group_text(
|
||||
locked = _hint_lock_or_release(raw)
|
||||
if locked:
|
||||
return locked
|
||||
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
return exact
|
||||
# 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 +187,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(
|
||||
|
||||
@@ -134,6 +134,10 @@ def classify_group_text(
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return INTENT_OTHER
|
||||
# 问句让回 1 或 2。整句就是选项时先认,不能把「2」抽成价格。
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
return exact
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
@@ -155,9 +159,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:
|
||||
@@ -184,7 +188,20 @@ def classify_group_text(
|
||||
|
||||
|
||||
def _norm_intent_text(text: str) -> str:
|
||||
return (text or "").replace(" ", "").replace(" ", "").lower()
|
||||
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
|
||||
blob = (text or "").replace(" ", "").replace(" ", "").lower()
|
||||
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
|
||||
return blob.strip()
|
||||
|
||||
|
||||
def _exact_tms_choice(text: str) -> str:
|
||||
"""整句只是 1 / 2 / 第一个 / 第二个。这是在选题,不是报价金额。"""
|
||||
blob = _norm_intent_text(text)
|
||||
if blob in _KEEP_CHOICE:
|
||||
return INTENT_KEEP_TMS
|
||||
if blob in _DROP_CHOICE:
|
||||
return INTENT_DROP_TMS
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_confirm_tms(text: str) -> str:
|
||||
|
||||
@@ -24,8 +24,8 @@ AIR_REQUIRED_FIELDS = (
|
||||
("目的港", "目的地"),
|
||||
("品名", "品名"),
|
||||
("件数", "件数"),
|
||||
("毛重", "重量"),
|
||||
("体积", "体积"),
|
||||
("毛重", "重量(KG)"),
|
||||
("体积", "体积(CBM)"),
|
||||
("包装方式", "包装类型"),
|
||||
("报价日期", "报价日期"),
|
||||
)
|
||||
@@ -65,8 +65,8 @@ LAND_DISPLAY = {
|
||||
"起运港": "始发站",
|
||||
"目的港": "目的地",
|
||||
"品名": "货物品名",
|
||||
"毛重": "重量",
|
||||
"体积": "体积",
|
||||
"毛重": "重量(KG)",
|
||||
"体积": "体积(CBM)",
|
||||
"车型/数量": "车型/数量",
|
||||
"通关口岸": "通关口岸",
|
||||
"报价日期": "报价日期",
|
||||
@@ -178,6 +178,20 @@ _RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
|
||||
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
|
||||
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
|
||||
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
|
||||
# 销售口中的报价日期。内部键仍是报价日期。货好时间、提货地址不是这里。
|
||||
QUOTE_DATE_SYNONYMS = (
|
||||
"询价日期",
|
||||
"走货日期",
|
||||
"询价时间",
|
||||
"走货时间",
|
||||
"发货时间",
|
||||
"发货日期",
|
||||
"出货日期",
|
||||
"出货时间",
|
||||
"提货时间",
|
||||
"提货日期",
|
||||
)
|
||||
|
||||
# 报价日期:9月15日 / 2026-09-15;未写年用上海当年
|
||||
_RE_DATE_YMD = re.compile(
|
||||
r"(20\d{2})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*[日号]?"
|
||||
@@ -193,8 +207,10 @@ _ALIASES = {
|
||||
"始发站": "起运港",
|
||||
"货物品名": "品名",
|
||||
"重量(KG)": "毛重",
|
||||
"重量(KG)": "毛重",
|
||||
"重量": "毛重",
|
||||
"体积(CBM)": "体积",
|
||||
"体积(CBM)": "体积",
|
||||
"车型/数量": "车型/数量",
|
||||
"车型数量": "车型/数量",
|
||||
"数量": "数量",
|
||||
@@ -220,6 +236,8 @@ _ALIASES = {
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
}
|
||||
for _quote_alias in QUOTE_DATE_SYNONYMS:
|
||||
_ALIASES[_quote_alias] = "报价日期"
|
||||
|
||||
|
||||
def shanghai_today() -> str:
|
||||
@@ -239,6 +257,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
|
||||
for raw_key, raw_val in (facts or {}).items():
|
||||
key = str(raw_key).strip()
|
||||
key = re.sub(r"(必填)|(非必填)|\(必填\)|\(非必填\)", "", key).strip()
|
||||
key = re.sub(r"(KG)|(CBM)|\(KG\)|\(CBM\)", "", key, flags=re.I).strip()
|
||||
key = _ALIASES.get(key, key)
|
||||
if raw_val is None:
|
||||
continue
|
||||
@@ -285,6 +304,32 @@ def _harvest_packaging(raw: str) -> str:
|
||||
return last
|
||||
|
||||
|
||||
def _quote_date_labeled_value(raw: str) -> str:
|
||||
"""
|
||||
原话里「报价日期」或同义词后面的值。
|
||||
|
||||
询价日期、走货日期、发货时间、提货日期等都算报价日期。
|
||||
能写成 yyyy-MM-dd 就规范;「明天」这类留原文。
|
||||
没有这些标签则空串,交给句子里的裸日期。
|
||||
同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。
|
||||
"""
|
||||
text = raw or ""
|
||||
labels = ("报价日期",) + QUOTE_DATE_SYNONYMS
|
||||
best_at = -1
|
||||
best_val = ""
|
||||
for label in labels:
|
||||
pattern = re.compile(
|
||||
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}\s*[::]?\s*(\S+)"
|
||||
)
|
||||
for hit in pattern.finditer(text):
|
||||
if hit.start() >= best_at:
|
||||
best_at = hit.start()
|
||||
best_val = hit.group(1).strip().strip(",,。;;、")
|
||||
if not best_val:
|
||||
return ""
|
||||
return _harvest_quote_date(best_val) or best_val
|
||||
|
||||
|
||||
def _harvest_quote_date(raw: str) -> str:
|
||||
"""当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。"""
|
||||
from datetime import datetime
|
||||
@@ -304,15 +349,26 @@ def _harvest_quote_date(raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_RE_CLARIFY_EXAMPLE = re.compile(r"^\s*[((]如\s*[::]")
|
||||
|
||||
|
||||
def _without_clarify_examples(text: str) -> str:
|
||||
"""补问清单里的「(如:托盘、散货)」整行丢掉,避免示例被当成已填。"""
|
||||
return "\n".join(
|
||||
line for line in (text or "").splitlines() if not _RE_CLARIFY_EXAMPLE.match(line)
|
||||
)
|
||||
|
||||
|
||||
def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
从当前原话机读件数/重量/体积/包装/报价日期。
|
||||
|
||||
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
|
||||
补问复制清单的示例行不参与机读。
|
||||
副作用:无。不改主账。不猜港口。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
raw = text or ""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
hit = _RE_PIECES.search(raw)
|
||||
if hit:
|
||||
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
|
||||
@@ -334,9 +390,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
|
||||
pack = _harvest_packaging(raw)
|
||||
if pack:
|
||||
merged["包装方式"] = pack
|
||||
quote_date = _harvest_quote_date(raw)
|
||||
if quote_date:
|
||||
merged["报价日期"] = quote_date
|
||||
labeled_date = _quote_date_labeled_value(raw)
|
||||
if labeled_date:
|
||||
merged["报价日期"] = labeled_date
|
||||
else:
|
||||
quote_date = _harvest_quote_date(raw)
|
||||
if quote_date:
|
||||
merged["报价日期"] = quote_date
|
||||
from agent.schema.tms_air_query import harvest_named_air_route
|
||||
|
||||
return harvest_named_air_route(raw, merged)
|
||||
@@ -405,6 +465,25 @@ def pick_collab_from_facts(
|
||||
return out
|
||||
|
||||
|
||||
def air_general_cargo_collab_defaults(facts: dict[str, str] | None) -> dict[str, str]:
|
||||
"""
|
||||
空运货物品名正好是「普货」时,含电、含磁缺省为否。
|
||||
|
||||
只补事实里还没写的项。已经写了「是」或「否」的不在这里改。
|
||||
品名多一个字(如普货纸箱)不算普货,不默认。
|
||||
调用方仍须遵守:群里已记下的协同值优先,本函数只给空项。
|
||||
"""
|
||||
src = normalize_facts(facts)
|
||||
if (src.get("品名") or "").strip() != "普货":
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key in AIR_COLLAB_FIELDS:
|
||||
# 摘要占位「-」等于没写,不能挡住默认否。
|
||||
if (src.get(key) or "").strip() in {"", "-", "—", "-"}:
|
||||
out[key] = "否"
|
||||
return out
|
||||
|
||||
|
||||
def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
"""
|
||||
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
|
||||
@@ -437,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
|
||||
|
||||
@@ -483,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()
|
||||
@@ -510,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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
陆运选项规则:运输类型、线路类别、运输分类。
|
||||
|
||||
本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。
|
||||
本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。
|
||||
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
|
||||
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
|
||||
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +28,7 @@ ROUTES = (
|
||||
"东南亚",
|
||||
)
|
||||
LAND_CLASSES = (
|
||||
"普件(常用线路/常规车型)",
|
||||
"普件(东南亚、中港常用线路/常规车型)",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
|
||||
@@ -53,6 +55,21 @@ def scrub_land_placeholder_ports(facts: dict[str, str] | None) -> dict[str, str]
|
||||
out[str(key)] = val
|
||||
return out
|
||||
|
||||
# 复制清单里的线路编号:1.x=拼车 2.x=整车,编号比文字更短,必须先认。
|
||||
ROUTE_CODES = (
|
||||
("1.1", "国内运输拼车", "国内长途/零担"),
|
||||
("1.2", "国内运输拼车", "国内城配/拖车/打包"),
|
||||
("2.1", "国内运输整车", "国内长途/零担"),
|
||||
("2.2", "国内运输整车", "国内城配/拖车/打包"),
|
||||
("3.1", "跨境集拼", "中港/中亚/中欧"),
|
||||
("3.2", "跨境集拼", "东南亚"),
|
||||
("4.1", "跨境整车", "中港/中亚/中欧"),
|
||||
("4.2", "跨境整车", "东南亚"),
|
||||
("5.1", "中港整车", "中港/中亚/中欧"),
|
||||
("6.1", "中港零担/集拼", "中港/中亚/中欧"),
|
||||
)
|
||||
_ROUTE_CODE_MAP = {code: (typ, route) for code, typ, route in ROUTE_CODES}
|
||||
|
||||
# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。
|
||||
SAMPLE_PAIRS = (
|
||||
("国内运输拼车", "国内长途/零担"),
|
||||
@@ -173,10 +190,118 @@ def canonicalize_hk_load(raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_PASTE_LABEL = re.compile(
|
||||
r"(运输类型|线路类别|运输分类)(?:(必填))?[ \t]*[::][ \t]*([^\n]*)"
|
||||
)
|
||||
# 1.国内拼车 / 1.1国内长途 都是清单选项行,不能当销售已答。
|
||||
_MENU_LINE = re.compile(r"^\s*(?:\d+\.\d+|\d+\.)")
|
||||
_STEP_LINE = re.compile(r"^\s*Step[12]\s*:", re.I)
|
||||
_COPY_FENCE = re.compile(r"^\s*-{2,}请复制以下内容-{2,}\s*$")
|
||||
|
||||
|
||||
def _apply_route_code(merged: dict[str, str], raw: str, *, fill_type: bool) -> bool:
|
||||
"""编号 1.1~6.1 收成线路;类型还空着时按编号补上。"""
|
||||
token = (raw or "").strip()
|
||||
if not token:
|
||||
return False
|
||||
code = ""
|
||||
for key in sorted(_ROUTE_CODE_MAP, key=len, reverse=True):
|
||||
if token == key or token.startswith(key):
|
||||
code = key
|
||||
break
|
||||
if not code:
|
||||
return False
|
||||
typ, route = _ROUTE_CODE_MAP[code]
|
||||
merged["线路类别"] = route
|
||||
if fill_type and not str(merged.get("运输类型") or "").strip():
|
||||
merged["运输类型"] = typ
|
||||
return True
|
||||
|
||||
|
||||
def _decode_option_token(key: str, raw: str) -> str:
|
||||
"""冒号后的数字或文字收成标准项。认不到返回空串。"""
|
||||
token = (raw or "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
if key == "运输类型":
|
||||
if token.isdigit():
|
||||
idx = int(token)
|
||||
if 1 <= idx <= len(TRANSPORT_TYPES):
|
||||
return TRANSPORT_TYPES[idx - 1]
|
||||
return canonicalize_transport_type(token)
|
||||
if key == "线路类别":
|
||||
for code, _typ, route in ROUTE_CODES:
|
||||
if token == code or token.startswith(code):
|
||||
return route
|
||||
return canonicalize_route(token)
|
||||
if key == "运输分类":
|
||||
if token.isdigit():
|
||||
idx = int(token)
|
||||
if 1 <= idx <= len(LAND_CLASSES):
|
||||
return LAND_CLASSES[idx - 1]
|
||||
return canonicalize_land_class(token)
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_land_option_labels(text: str) -> dict[str, str]:
|
||||
"""复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。"""
|
||||
found: dict[str, str] = {}
|
||||
inferred_type = ""
|
||||
for match in _PASTE_LABEL.finditer(text or ""):
|
||||
key = match.group(1)
|
||||
token = (match.group(2) or "").strip()
|
||||
if not token:
|
||||
continue
|
||||
if key == "线路类别":
|
||||
hit = ""
|
||||
for code, typ, route in ROUTE_CODES:
|
||||
if token == code or token.startswith(code):
|
||||
found["线路类别"] = route
|
||||
inferred_type = typ
|
||||
hit = code
|
||||
break
|
||||
if hit:
|
||||
continue
|
||||
decoded = _decode_option_token(key, token)
|
||||
if decoded:
|
||||
found[key] = decoded
|
||||
if inferred_type and not found.get("运输类型"):
|
||||
found["运输类型"] = inferred_type
|
||||
return found
|
||||
|
||||
|
||||
def _strip_land_option_prompt(text: str) -> str:
|
||||
"""
|
||||
丢掉补问清单的 Step 和编号选项行,避免未填清单被收成已选。
|
||||
|
||||
整句只有一个编号(如 1.1)要留下,那是销售在答题。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
compact = raw.replace(" ", "")
|
||||
if compact in _ROUTE_CODE_MAP or compact.isdigit():
|
||||
return raw
|
||||
kept: list[str] = []
|
||||
for line in raw.splitlines():
|
||||
if _STEP_LINE.match(line):
|
||||
continue
|
||||
if _COPY_FENCE.match(line):
|
||||
continue
|
||||
if _PASTE_LABEL.match(line):
|
||||
continue
|
||||
if _MENU_LINE.match(line):
|
||||
continue
|
||||
kept.append(line)
|
||||
return "\n".join(kept).strip()
|
||||
|
||||
|
||||
def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
从一句原话收取陆运选项,合并到已有值上。
|
||||
|
||||
先收复制清单冒号后的数字/编号/文字,再收口语。
|
||||
未填的补问清单本身不能当成已选。
|
||||
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
|
||||
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
|
||||
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
|
||||
@@ -187,7 +312,28 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
|
||||
if str(v).strip()
|
||||
}
|
||||
_keep_canonical_options(merged)
|
||||
blob = text or ""
|
||||
labeled = _parse_land_option_labels(text or "")
|
||||
for key in ("运输类型", "线路类别", "运输分类"):
|
||||
if labeled.get(key):
|
||||
merged[key] = labeled[key]
|
||||
|
||||
blob = _strip_land_option_prompt(text or "")
|
||||
compact = blob.replace(" ", "")
|
||||
if compact in _ROUTE_CODE_MAP:
|
||||
_apply_route_code(merged, compact, fill_type=not bool(merged.get("运输类型")))
|
||||
merged.pop("中港装载方式", None)
|
||||
return merged
|
||||
if compact.isdigit() and len(compact) == 1:
|
||||
idx = int(compact)
|
||||
missing_type = not merged.get("运输类型")
|
||||
missing_class = not merged.get("运输分类")
|
||||
if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES):
|
||||
merged["运输类型"] = TRANSPORT_TYPES[idx - 1]
|
||||
elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES):
|
||||
merged["运输分类"] = LAND_CLASSES[idx - 1]
|
||||
merged.pop("中港装载方式", None)
|
||||
return merged
|
||||
|
||||
found_class = canonicalize_land_class(blob)
|
||||
remainder = blob.replace(found_class, " ") if found_class else blob
|
||||
found_type = canonicalize_transport_type(remainder)
|
||||
@@ -259,19 +405,46 @@ def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
_OPTION_PASTE_HEAD = (
|
||||
"Step1: 请复制以下内容,粘贴到输入框;",
|
||||
"Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:",
|
||||
"---请复制以下内容---",
|
||||
)
|
||||
|
||||
|
||||
def _type_prompt_lines() -> list[str]:
|
||||
lines = ["运输类型(必填):"]
|
||||
for i, name in enumerate(TRANSPORT_TYPES, start=1):
|
||||
lines.append(f"{i}.{name}")
|
||||
return lines
|
||||
|
||||
|
||||
def _route_prompt_lines() -> list[str]:
|
||||
lines = ["线路类别(必填):"]
|
||||
for code, _typ, route in ROUTE_CODES:
|
||||
lines.append(f"{code}{route}")
|
||||
return lines
|
||||
|
||||
|
||||
def _class_prompt_lines() -> list[str]:
|
||||
lines = ["运输分类(必填):可选择回复,"]
|
||||
lines = ["运输分类(必填):"]
|
||||
for i, name in enumerate(LAND_CLASSES, start=1):
|
||||
lines.append(f"{i}.{name}")
|
||||
return lines
|
||||
|
||||
|
||||
# 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。
|
||||
SAMPLE_IMAGE_DISABLED = True
|
||||
|
||||
|
||||
def option_prompt_needs_sample(*, current: dict[str, str]) -> bool:
|
||||
"""运输类型或线路类别缺任一,就要发填写样例图。只缺分类不发。"""
|
||||
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "")
|
||||
r = canonicalize_route(src.get("线路类别") or "")
|
||||
return t not in TRANSPORT_TYPES or r not in ROUTES
|
||||
"""
|
||||
填写样例图已停发。编号清单已经覆盖类型+线路搭配,不再另出图。
|
||||
|
||||
保留函数名避免旧调用处报缺符号;恒为假。current 不使用。
|
||||
"""
|
||||
_ = current
|
||||
return False
|
||||
|
||||
|
||||
def sample_image_path() -> Path:
|
||||
@@ -298,10 +471,10 @@ def _sample_table() -> str:
|
||||
|
||||
def option_prompt(*, current: dict[str, str]) -> str:
|
||||
"""
|
||||
按已识别项生成补问清单。
|
||||
按已识别项生成复制填空清单。
|
||||
|
||||
缺哪项列哪项。运输类型或线路类别缺任一则提示见图,样例图另发出站。只缺分类不附图。
|
||||
不修改 current。
|
||||
缺哪项列哪项。销售把数字或文字填在字段冒号后面。
|
||||
填写样例图不再另发。不修改 current。
|
||||
"""
|
||||
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
|
||||
t = canonicalize_transport_type(src.get("运输类型") or "")
|
||||
@@ -310,34 +483,21 @@ def option_prompt(*, current: dict[str, str]) -> str:
|
||||
need_type = t not in TRANSPORT_TYPES
|
||||
need_route = r not in ROUTES
|
||||
need_class = c not in LAND_CLASSES
|
||||
lines: list[str] = []
|
||||
|
||||
if need_type and need_route and need_class:
|
||||
lines.append("请一次选择:运输类型、线路类别、运输分类")
|
||||
lines.append("")
|
||||
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
|
||||
lines.append("")
|
||||
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
|
||||
lines.append("")
|
||||
lines.extend(_class_prompt_lines())
|
||||
lines.append("")
|
||||
lines.append("填写样例见图。")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
if not (need_type or need_route or need_class):
|
||||
return ""
|
||||
chunks: list[list[str]] = []
|
||||
if need_type:
|
||||
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
|
||||
chunks.append(_type_prompt_lines())
|
||||
if need_route:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
|
||||
chunks.append(_route_prompt_lines())
|
||||
if need_class:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.extend(_class_prompt_lines())
|
||||
if need_type or need_route:
|
||||
lines.append("")
|
||||
lines.append("填写样例见图。")
|
||||
return "\n".join(lines).strip()
|
||||
chunks.append(_class_prompt_lines())
|
||||
body: list[str] = list(_OPTION_PASTE_HEAD)
|
||||
for i, block in enumerate(chunks):
|
||||
if i:
|
||||
body.append("")
|
||||
body.extend(block)
|
||||
return "\n".join(body).strip()
|
||||
|
||||
|
||||
def looks_like_confirm(text: str) -> bool:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,8 +8,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
SEA_CLASSES = ("Port to Port", "其他")
|
||||
|
||||
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
|
||||
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
|
||||
|
||||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||||
_ALIASES = (
|
||||
("port to port", "Port to Port"),
|
||||
@@ -29,9 +34,16 @@ def canonicalize_sea_class(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
# 企微常把空格发成不换行空格,Port to Port 对不上普通空格就会整段丢掉。
|
||||
folded = re.sub(r"\s+", " ", text).strip()
|
||||
lowered = folded.lower()
|
||||
squashed = re.sub(r"\s+", "", lowered)
|
||||
if "port to port" in lowered or "porttoport" in squashed:
|
||||
return "Port to Port"
|
||||
for needle, canon in _ALIASES:
|
||||
if needle in lowered or needle in text:
|
||||
if needle == "port to port":
|
||||
continue
|
||||
if needle in lowered or needle in text or needle in squashed:
|
||||
return canon
|
||||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||||
if stripped in SEA_CLASSES:
|
||||
@@ -39,15 +51,23 @@ def canonicalize_sea_class(raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _without_clarify_examples(text: str) -> str:
|
||||
"""丢掉补问举例行。销售只在冒号后作答,「(如:…)」整行不参与判断。"""
|
||||
return "\n".join(
|
||||
line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line)
|
||||
)
|
||||
|
||||
|
||||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
当前句能认则覆盖;认不出则保留已有标准值。
|
||||
|
||||
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
|
||||
非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
举例行「(如:Port to Port、其他)」先丢掉,避免示例里的 Port to Port 盖掉冒号后的「其它」。
|
||||
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
"""
|
||||
merged = dict(current or {})
|
||||
found = canonicalize_sea_class(text or "")
|
||||
found = canonicalize_sea_class(_without_clarify_examples(text or ""))
|
||||
if found:
|
||||
merged["运输分类"] = found
|
||||
return merged
|
||||
|
||||
@@ -183,10 +183,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
missing_display.append("件数")
|
||||
if not weight:
|
||||
missing.append("毛重")
|
||||
missing_display.append("重量")
|
||||
missing_display.append("重量(KG)")
|
||||
if not volume:
|
||||
missing.append("体积")
|
||||
missing_display.append("体积")
|
||||
missing_display.append("体积(CBM)")
|
||||
if not pack_raw:
|
||||
missing.append("包装方式")
|
||||
missing_display.append("包装类型")
|
||||
|
||||
@@ -17,7 +17,10 @@ os.environ.setdefault("YTD_ENV", "test")
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.field_validate import (
|
||||
air_general_cargo_collab_defaults,
|
||||
validate_required_fields,
|
||||
)
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
|
||||
@@ -164,6 +167,23 @@ class AirFieldContractTests(unittest.TestCase):
|
||||
self.assertFalse(out["ok"])
|
||||
self.assertIn("目的港", out["missing"])
|
||||
|
||||
def test_general_cargo_defaults_battery_and_magnet(self) -> None:
|
||||
"""品名正好是普货,含电含磁还空着才默认否。"""
|
||||
self.assertEqual(
|
||||
air_general_cargo_collab_defaults({"品名": "普货"}),
|
||||
{"是否含电": "否", "是否含磁": "否"},
|
||||
)
|
||||
self.assertEqual(
|
||||
air_general_cargo_collab_defaults({"货物品名": "普货"}),
|
||||
{"是否含电": "否", "是否含磁": "否"},
|
||||
)
|
||||
self.assertEqual(
|
||||
air_general_cargo_collab_defaults({"品名": "普货", "是否含电": "是"}),
|
||||
{"是否含磁": "否"},
|
||||
)
|
||||
self.assertEqual(air_general_cargo_collab_defaults({"品名": "配件"}), {})
|
||||
self.assertEqual(air_general_cargo_collab_defaults({"品名": "普货纸箱"}), {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -167,7 +167,24 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("起运地:ZUH", text)
|
||||
self.assertIn("\n\n协同补充字段:\n是否含电:-", text)
|
||||
self.assertIn("是否含磁:-", text)
|
||||
general = copy.air_group_brief(
|
||||
work_order_no="WO202609230017",
|
||||
facts={
|
||||
"起运地": "上海",
|
||||
"目的地": "洛杉矶",
|
||||
"品名": "普货",
|
||||
},
|
||||
quote=None,
|
||||
collab_facts={},
|
||||
has_price=False,
|
||||
)
|
||||
self.assertIn("是否含电:否", general)
|
||||
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)
|
||||
|
||||
@@ -193,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:
|
||||
"""
|
||||
@@ -245,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:
|
||||
@@ -291,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
|
||||
@@ -402,6 +460,78 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("是否含磁:是", joined)
|
||||
self.assertIn("是否含电:-", joined)
|
||||
|
||||
def test_activate_general_cargo_defaults_battery_and_magnet(self) -> None:
|
||||
"""品名是普货:群内激活后含电、含磁都是否,报价单也带上。"""
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={
|
||||
"起运地": "上海",
|
||||
"目的地": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"件数": "3",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装类型": "纸箱",
|
||||
"报价日期": "2026-09-16",
|
||||
},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"},
|
||||
to_status="已报价",
|
||||
)
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("是否含电"), "否")
|
||||
self.assertEqual(ticket.collab_facts.get("是否含磁"), "否")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("是否含电:否", joined)
|
||||
self.assertIn("是否含磁:否", joined)
|
||||
fill = dict(getattr(self.ledger, "last_render", {}).get("facts") or {})
|
||||
self.assertEqual(fill.get("是否含电") or fill.get("has_battery"), "否")
|
||||
self.assertEqual(fill.get("是否含磁") or fill.get("has_magnet"), "否")
|
||||
|
||||
def test_activate_general_cargo_keeps_explicit_battery(self) -> None:
|
||||
"""普货只补空着的项;私聊已经说含电,激活不能改成否。"""
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={
|
||||
"起运地": "上海",
|
||||
"目的地": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"件数": "3",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装类型": "纸箱",
|
||||
"报价日期": "2026-09-16",
|
||||
"是否含电": "是",
|
||||
},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={"total": "USD 800", "source": "TMS", "quoteId": "QT-AIR-1"},
|
||||
to_status="已报价",
|
||||
)
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="gc2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.collab_facts.get("是否含电"), "是")
|
||||
self.assertEqual(ticket.collab_facts.get("是否含磁"), "否")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("是否含电:是", joined)
|
||||
self.assertIn("是否含磁:否", joined)
|
||||
|
||||
def test_second_group_occupied(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
@@ -460,6 +590,57 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertLess(texts[-1], files[-1])
|
||||
self.assertEqual(len(files), 2)
|
||||
|
||||
def test_collab_after_activate_does_not_use_old_land_ticket(self) -> None:
|
||||
"""
|
||||
测服 WO202609230023:空运进原群激活后补含电含磁,
|
||||
不能写到同群更早的陆运单 WO202609230007,摘要也不能变成空字段。
|
||||
"""
|
||||
land = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="LAND",
|
||||
facts={
|
||||
"运输类型": "国内运输整车",
|
||||
"始发站": "深圳",
|
||||
"目的站": "上海",
|
||||
"品名": "配件",
|
||||
},
|
||||
)["work_order_no"]
|
||||
self.ledger.bind_collab_group(
|
||||
work_order_no=land,
|
||||
chat_id="wr_air_1",
|
||||
member_ids=["sales1"],
|
||||
)
|
||||
air = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=air, chat="wr_air_1", mid="mix0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(self.ledger.find_by_collab_chat(chat_id="wr_air_1").work_order_no, air)
|
||||
self.assertEqual(
|
||||
str(self.ledger.get_ticket(work_order_no=land).collab_chat_id or ""),
|
||||
"",
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="含电 不含磁", chat="wr_air_1", mid="mix1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="collab_fields",
|
||||
)
|
||||
self.assertEqual(phase, "fields_saved")
|
||||
air_ticket = self.ledger.get_ticket(work_order_no=air)
|
||||
land_ticket = self.ledger.get_ticket(work_order_no=land)
|
||||
self.assertEqual(air_ticket.collab_facts.get("是否含电"), "是")
|
||||
self.assertEqual(air_ticket.collab_facts.get("是否含磁"), "否")
|
||||
self.assertFalse(land_ticket.collab_facts.get("是否含电"))
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单号:{air}", joined)
|
||||
self.assertNotIn(f"工单号:{land}", joined)
|
||||
self.assertIn("起运地:上海", joined)
|
||||
self.assertIn("重量(KG):100", joined)
|
||||
self.assertNotIn("起运地:-", joined)
|
||||
|
||||
def test_sales_quote_rejected(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
@@ -514,6 +695,33 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
|
||||
def test_reply_2_means_drop_tms_not_price(self) -> None:
|
||||
"""问 1/2 之后,航线回 @询价小助手 2 是选不用 TMS,不能把价格改成 2。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="d0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="d1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
injected_quote={"total": "USD 900", "currency": "USD", "source_label": "航线报价"},
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="@询价小助手 2", chat="wr_air_1", mid="d2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 900")
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线报价")
|
||||
self.assertNotEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
|
||||
|
||||
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
handle_air_group(
|
||||
@@ -635,10 +843,10 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
|
||||
self.assertEqual(phase, "no_quote")
|
||||
self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts()))
|
||||
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
||||
wo = self._create_air(
|
||||
@@ -1052,7 +1260,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertNotIn("与工单不匹配", joined)
|
||||
|
||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||
"""没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。"""
|
||||
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -96,11 +96,13 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("目的地:克拉克", text)
|
||||
self.assertIn("品名:茶叶", text)
|
||||
self.assertIn("件数:10件", text)
|
||||
self.assertIn("待补充:", text)
|
||||
self.assertIn("重量:(如:100KGS)", text)
|
||||
self.assertIn("体积:(如:2CBM)", text)
|
||||
self.assertIn("包装类型:(如:托盘)", text)
|
||||
self.assertIn("货源地(非必填)", text)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||
self.assertNotIn("待补充:", text)
|
||||
self.assertIn("1.重量(KG)(必填):\n(如:100)", text)
|
||||
self.assertIn("2.体积(CBM)(必填):\n(如:2)", text)
|
||||
self.assertIn("3.包装类型(必填):\n(如:托盘、散货)", text)
|
||||
self.assertNotIn("重量:(如:", text)
|
||||
self.assertIn("货源地(非必填):\n(如:东莞)", text)
|
||||
|
||||
def test_inquiry_card_matches_sales_format(self) -> None:
|
||||
text = copy.inquiry_card(
|
||||
@@ -128,8 +130,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
"目的地:克拉克",
|
||||
"品名:茶叶",
|
||||
"件数:10件",
|
||||
"重量:1",
|
||||
"体积:8",
|
||||
"重量(KG):1",
|
||||
"体积(CBM):8",
|
||||
"包装类型:托盘",
|
||||
"报价日期:2026-09-12",
|
||||
"工单已创建,正在查询TMS报价。",
|
||||
@@ -199,7 +201,33 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
injected_mode="",
|
||||
)
|
||||
self.assertEqual(phase, "need_mode")
|
||||
self.assertIn("运输方式", self.replies[-1])
|
||||
self.assertIn(copy.ASK_TRANSPORT, self.replies[-1])
|
||||
self.assertIn("1.陆运", self.replies[-1])
|
||||
self.assertIn("2.海运", self.replies[-1])
|
||||
self.assertIn("3.空运", self.replies[-1])
|
||||
|
||||
def test_missing_mode_accepts_number_or_word(self) -> None:
|
||||
"""未定运输方式时,回 3 或「空运」都走空运。"""
|
||||
self.flow.on_text(
|
||||
sender_id="u_mode_num",
|
||||
text="帮我看看价格",
|
||||
reply=self._reply,
|
||||
injected_facts={},
|
||||
injected_mode="",
|
||||
)
|
||||
phase = self.flow.on_text(sender_id="u_mode_num", text="3", reply=self._reply)
|
||||
self.assertNotEqual(phase, "need_mode")
|
||||
self.assertEqual(self.flow.session_of("u_mode_num").business_line, "AIR")
|
||||
self.flow.on_text(
|
||||
sender_id="u_mode_word",
|
||||
text="帮我看看价格",
|
||||
reply=self._reply,
|
||||
injected_facts={},
|
||||
injected_mode="",
|
||||
)
|
||||
phase = self.flow.on_text(sender_id="u_mode_word", text="空运", reply=self._reply)
|
||||
self.assertNotEqual(phase, "need_mode")
|
||||
self.assertEqual(self.flow.session_of("u_mode_word").business_line, "AIR")
|
||||
|
||||
def test_pack_bulk_cargo_accepted_as_user_said(self) -> None:
|
||||
facts = dict(COMPLETE_AIR)
|
||||
@@ -251,13 +279,48 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("请确认并补充以下信息", last)
|
||||
self.assertIn("运输方式:空运", last)
|
||||
self.assertIn("起运地:上海", last)
|
||||
self.assertIn("待补充:", last)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, last)
|
||||
self.assertIn("目的地", last)
|
||||
self.assertIn("件数", last)
|
||||
self.assertIn("如:", last)
|
||||
self.assertIn("报价日期:", last)
|
||||
self.assertNotIn("报价日期:(如", last)
|
||||
|
||||
def test_clarify_paste_reply_fills_missing_required(self) -> None:
|
||||
"""销售复制清单只填冒号后的值,空着的包装类型继续补问。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="paste1",
|
||||
text="空运询价",
|
||||
reply=self._reply,
|
||||
injected_facts={
|
||||
"起运港": "南京",
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "茶叶",
|
||||
"报价日期": "2026-09-17",
|
||||
},
|
||||
injected_mode="AIR",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
phase = self.flow.on_text(
|
||||
sender_id="paste1",
|
||||
text=(
|
||||
"1.件数(必填):10\n"
|
||||
"(如:10)\n"
|
||||
"2.重量(KG)(必填):10\n"
|
||||
"(如:100)\n"
|
||||
"3.体积(CBM)(必填):10\n"
|
||||
"(如:2)\n"
|
||||
"4.包装类型(必填):\n"
|
||||
"(如:托盘、散货)"
|
||||
),
|
||||
reply=self._reply,
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
last = self.replies[-1]
|
||||
self.assertIn("件数:10件", last)
|
||||
self.assertIn("包装类型(必填)", last)
|
||||
self.assertNotIn("包装类型:托盘、散货", last)
|
||||
|
||||
def test_complete_air_hit_then_skip_adopt_excel_deal(self) -> None:
|
||||
phase = self._on_text(
|
||||
sender_id="u1",
|
||||
@@ -272,14 +335,14 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("运输方式:空运", joined)
|
||||
self.assertIn("起运地:上海", joined)
|
||||
self.assertIn("目的地:洛杉矶", joined)
|
||||
self.assertIn("重量:", joined)
|
||||
self.assertIn("重量(KG):", joined)
|
||||
self.assertIn("正在查询TMS报价", joined)
|
||||
self.assertNotIn("【询价卡片】", joined)
|
||||
self.assertNotIn("[test]", joined)
|
||||
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)
|
||||
@@ -315,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(
|
||||
@@ -345,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)
|
||||
@@ -400,8 +463,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("起运地:南京", joined)
|
||||
self.assertIn("目的地:吉隆坡", joined)
|
||||
self.assertIn("重量:", joined)
|
||||
self.assertIn("体积:", joined)
|
||||
self.assertIn("重量(KG):", joined)
|
||||
self.assertIn("体积(CBM):", joined)
|
||||
self.assertIn("包装类型:散货", joined)
|
||||
self.assertIn("报价日期:2026-09-16", joined)
|
||||
self.assertNotIn("重量(KG)", joined)
|
||||
@@ -810,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(
|
||||
@@ -825,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)
|
||||
@@ -865,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(
|
||||
@@ -902,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
|
||||
@@ -916,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(
|
||||
@@ -935,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:
|
||||
@@ -985,7 +1048,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("【WO202609140001报价方案】", text)
|
||||
self.assertIn("工单号:WO202609140001", text)
|
||||
self.assertIn("起运地:上海", text)
|
||||
self.assertIn("体积:2.5", text)
|
||||
self.assertIn("体积(CBM):2.5", text)
|
||||
self.assertIn("空运费:USD 1,200", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertIn("报价有效期:2026-09-20", text)
|
||||
@@ -1009,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"]}
|
||||
@@ -1060,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)
|
||||
@@ -1081,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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -62,6 +62,18 @@ class TestTimeoutCode(unittest.TestCase):
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
|
||||
"land_hk_express",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE,
|
||||
business_line="LAND",
|
||||
transport_class="普件(常用线路/常规车型)",
|
||||
),
|
||||
"land_common",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="常用线路"),
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
|
||||
"",
|
||||
|
||||
@@ -15,7 +15,12 @@ if ROOT not in sys.path:
|
||||
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot, parse_labeled_facts
|
||||
from agent.llm.extract_text import (
|
||||
detect_transport_mode,
|
||||
extract_inquiry_snapshot,
|
||||
parse_labeled_facts,
|
||||
)
|
||||
from agent.schema.field_validate import normalize_facts
|
||||
from agent.llm.mode_extract_fields import invoke_shell, parse_extract_payload
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
@@ -118,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
|
||||
@@ -260,7 +289,7 @@ class OralExtractTests(unittest.TestCase):
|
||||
work_order_no="WO1",
|
||||
facts={**facts, "起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶", "件数": "10"},
|
||||
)
|
||||
self.assertIn("重量:1.5KG", shown)
|
||||
self.assertIn("重量(KG):1.5KG", shown)
|
||||
self.assertNotIn("1.5KGS", shown)
|
||||
|
||||
def test_oral_units_fill_weight_volume_without_b(self) -> None:
|
||||
@@ -326,6 +355,36 @@ class OralExtractTests(unittest.TestCase):
|
||||
self.assertIn("目的地:洛杉矶", joined)
|
||||
self.assertNotIn("【询价卡片】", joined)
|
||||
|
||||
def test_mode_index_maps_land_sea_air(self) -> None:
|
||||
self.assertEqual(detect_transport_mode("1"), "LAND")
|
||||
self.assertEqual(detect_transport_mode("2"), "SEA")
|
||||
self.assertEqual(detect_transport_mode("3"), "AIR")
|
||||
self.assertEqual(detect_transport_mode("1.陆运"), "LAND")
|
||||
self.assertEqual(detect_transport_mode("空运"), "AIR")
|
||||
self.assertEqual(detect_transport_mode("10件"), "")
|
||||
self.assertEqual(detect_transport_mode("1.件数(必填):10"), "")
|
||||
|
||||
|
||||
class ClarifyPasteTests(unittest.TestCase):
|
||||
def test_paste_numbered_required_lines_and_skip_examples(self) -> None:
|
||||
"""复制补问清单:只收冒号后的值,示例行和空项都不算填了。"""
|
||||
text = """
|
||||
1.件数(必填):10
|
||||
(如:10)
|
||||
2.重量(KG)(必填):10
|
||||
(如:100)
|
||||
3.体积(CBM)(必填):10
|
||||
(如:2)
|
||||
4.包装类型(必填):
|
||||
(如:托盘、散货)
|
||||
"""
|
||||
facts = normalize_facts(parse_labeled_facts(text))
|
||||
self.assertEqual(facts.get("件数"), "10")
|
||||
self.assertEqual(facts.get("毛重"), "10")
|
||||
self.assertEqual(facts.get("体积"), "10")
|
||||
self.assertNotIn("包装方式", facts)
|
||||
self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
|
||||
|
||||
|
||||
class ImageOptionalClipTests(unittest.TestCase):
|
||||
def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
|
||||
@@ -361,7 +420,7 @@ class ImageOptionalClipTests(unittest.TestCase):
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertNotIn("希望送到", card)
|
||||
self.assertNotIn("4.", card)
|
||||
self.assertNotIn("4. 希望送到", card)
|
||||
self.assertIn("收货地址", card)
|
||||
|
||||
|
||||
@@ -435,6 +494,32 @@ class SeaSheetHeaderClipTests(unittest.TestCase):
|
||||
self.assertNotIn("送货地址", facts.get("整柜或拼柜") or "")
|
||||
self.assertNotIn("抵达", facts.get("箱型箱量") or "")
|
||||
|
||||
def test_quote_date_synonyms_for_sea_land_air(self) -> None:
|
||||
"""询价/走货/发货/出货/提货的日期或时间,海运陆运空运都记成报价日期。"""
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS, normalize_facts
|
||||
|
||||
modes = (("海运", "SEA"), ("陆运", "LAND"), ("空运", "AIR"))
|
||||
for label in QUOTE_DATE_SYNONYMS:
|
||||
self.assertEqual(normalize_facts({label: "2026-09-23"})["报价日期"], "2026-09-23")
|
||||
for word, mode in modes:
|
||||
snap = extract_inquiry_snapshot(f"{word}\n{label}:2026-09-23", allow_b=False)
|
||||
self.assertEqual(snap["business_line"], mode, label)
|
||||
self.assertEqual(snap["facts"].get("报价日期"), "2026-09-23", label)
|
||||
self.assertNotIn(label, snap["facts"])
|
||||
|
||||
def test_quote_date_synonym_does_not_steal_ready_or_pickup(self) -> None:
|
||||
"""货好时间、提货地址仍是原字段;同义词日期不被前面的日期抢走。"""
|
||||
snap = extract_inquiry_snapshot(
|
||||
"海运\n货好时间:9月20日\n提货地址:上海仓库\n询价日期:2026-09-15",
|
||||
allow_b=False,
|
||||
)
|
||||
facts = snap["facts"]
|
||||
self.assertEqual(facts.get("报价日期"), "2026-09-15")
|
||||
self.assertEqual(facts.get("货好时间"), "9月20日")
|
||||
self.assertEqual(facts.get("提货地址"), "上海仓库")
|
||||
oral = extract_inquiry_snapshot("空运 发货日期9月15日", allow_b=False)
|
||||
self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -121,9 +121,10 @@ class LandCopyTest(unittest.TestCase):
|
||||
land_subtype="跨境集拼",
|
||||
)
|
||||
self.assertIn("始发站(必填):广州", text)
|
||||
self.assertIn("待补充:", text)
|
||||
self.assertIn("体积(必填)", text)
|
||||
self.assertIn("通关口岸(非必填)", text)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
||||
self.assertIn("2.通关口岸(非必填):\n(如:皇岗)", text)
|
||||
self.assertNotIn("体积(必填):(如:", text)
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, text)
|
||||
|
||||
def test_ask_land_fields_domestic_has_no_customs(self):
|
||||
@@ -138,7 +139,7 @@ class LandCopyTest(unittest.TestCase):
|
||||
missing_keys=["体积"],
|
||||
land_subtype="国内运输拼车",
|
||||
)
|
||||
self.assertIn("体积(必填)", text)
|
||||
self.assertIn("1.体积(CBM)(必填):\n(如:100CBM)", text)
|
||||
self.assertNotIn("通关口岸", text)
|
||||
|
||||
def test_ask_land_fields_filled_customs_not_pending(self):
|
||||
@@ -154,7 +155,7 @@ class LandCopyTest(unittest.TestCase):
|
||||
land_subtype="中港整车",
|
||||
)
|
||||
self.assertIn("通关口岸(非必填):皇岗", text)
|
||||
pending = text.split("待补充:", 1)[1]
|
||||
pending = text.split(copy.CLARIFY_PASTE_HINT, 1)[1]
|
||||
self.assertNotIn("通关口岸", pending)
|
||||
|
||||
def test_inquiry_card_lists_land_fields_and_wo(self):
|
||||
@@ -205,7 +206,7 @@ class LandCopyTest(unittest.TestCase):
|
||||
self.assertIn("运输类型:国内运输拼车", text)
|
||||
self.assertIn("始发站:泉州市", text)
|
||||
self.assertIn("货物品名:通信机柜及线缆配件", text)
|
||||
self.assertIn("体积:10CBM", text)
|
||||
self.assertIn("体积(CBM):10CBM", text)
|
||||
self.assertNotIn("整柜或拼柜", text)
|
||||
self.assertNotIn("箱型箱量", text)
|
||||
self.assertNotIn("件数:", text)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
陆运选项单测:三项各自必填、不联动;中港拆进运输类型;样例表跟类型/线路缺项走。
|
||||
陆运选项单测:三项各自必填、不联动;中港拆进运输类型;补问只发复制清单,不再发填写样例图。
|
||||
|
||||
不连企微、主账、TMS。
|
||||
"""
|
||||
@@ -108,48 +108,50 @@ class LandOptionsTest(unittest.TestCase):
|
||||
|
||||
def test_full_prompt_when_empty(self):
|
||||
text = option_prompt(current={})
|
||||
self.assertIn("请一次选择:运输类型、线路类别、运输分类", text)
|
||||
self.assertIn("运输类型(必填):可选择回复,", text)
|
||||
self.assertIn("中港整车", text)
|
||||
self.assertIn("中港零担/集拼", text)
|
||||
self.assertIn("线路类别(必填):可选择回复,", text)
|
||||
self.assertIn("国内长途/零担", text)
|
||||
self.assertIn("1.普件(东南亚、中港常用线路/常规车型)", text)
|
||||
self.assertIn("填写样例见图", text)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:", text)
|
||||
self.assertIn("---请复制以下内容---\n运输类型(必填):", text)
|
||||
self.assertIn("运输类型(必填):\n1.国内运输拼车", text)
|
||||
self.assertIn("6.中港零担/集拼", text)
|
||||
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
|
||||
self.assertIn("6.1中港/中亚/中欧", text)
|
||||
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
|
||||
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
|
||||
self.assertIn("5.急件", text)
|
||||
self.assertNotIn("请一次选择:运输类型", text)
|
||||
self.assertNotIn("可选择回复", text)
|
||||
self.assertNotIn("填写样例见图", text)
|
||||
self.assertNotIn("国内运输整车 + 国内城配/拖车/打包", text)
|
||||
self.assertNotIn("┌", text)
|
||||
self.assertNotIn("| 国内运输整车 |", text)
|
||||
self.assertNotIn("仅供参考", text)
|
||||
self.assertNotIn("中港装载方式", text)
|
||||
self.assertNotIn("跟运输类型联动", text)
|
||||
|
||||
def test_copy_alias_lists_new_types(self):
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
text = copy.ASK_LAND_SUBTYPE
|
||||
self.assertIn("中港整车", text)
|
||||
self.assertIn("填写样例", text)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---", text)
|
||||
self.assertNotIn("请补充陆运类型:国内运输拼车", text)
|
||||
|
||||
def test_sample_table_when_route_missing(self):
|
||||
text = option_prompt(current={"运输类型": "国内运输拼车"})
|
||||
self.assertIn("线路类别(必填)", text)
|
||||
self.assertIn("填写样例见图", text)
|
||||
self.assertTrue(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---\n线路类别(必填):", text)
|
||||
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
|
||||
self.assertIn("运输分类(必填):\n1.普件", text)
|
||||
self.assertFalse(option_prompt_needs_sample(current={"运输类型": "国内运输拼车"}))
|
||||
self.assertFalse(option_prompt_needs_sample(current={}))
|
||||
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
|
||||
self.assertNotIn("请一次选择:运输类型", text)
|
||||
self.assertNotIn("运输类型(必填)", text)
|
||||
|
||||
def test_sample_pairs_go_to_image_not_text(self):
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
text = option_prompt(current={})
|
||||
self.assertIn("填写样例见图", text)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---", text)
|
||||
self.assertNotIn("填写样例见图", text)
|
||||
self.assertNotIn("填写样例", text)
|
||||
self.assertNotIn("国内运输拼车 + 国内长途/零担", text)
|
||||
extra = copy.land_sample_image_payload()
|
||||
self.assertEqual(extra.get("msgtype"), "image")
|
||||
self.assertTrue(os.path.isfile(str(extra.get("filepath") or "")))
|
||||
self.assertGreater(os.path.getsize(str(extra["filepath"])), 1000)
|
||||
|
||||
def test_no_sample_when_only_class_missing(self):
|
||||
text = option_prompt(
|
||||
@@ -179,6 +181,43 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertNotIn("目的港", out)
|
||||
self.assertEqual(out.get("品名"), "衣服")
|
||||
|
||||
def test_paste_numbers_and_codes(self):
|
||||
"""复制清单后在冒号后填数字/编号,要收到标准项。"""
|
||||
pasted = (
|
||||
"运输类型(必填):2\n"
|
||||
"线路类别(必填):2.1\n"
|
||||
"运输分类(必填):5"
|
||||
)
|
||||
got = harvest_land_options(pasted)
|
||||
self.assertEqual(got.get("运输类型"), "国内运输整车")
|
||||
self.assertEqual(got.get("线路类别"), "国内长途/零担")
|
||||
self.assertEqual(got.get("运输分类"), "急件")
|
||||
|
||||
def test_common_route_class_only_full_text_or_number(self):
|
||||
"""新项只认编号 1 或全文;短话和逗号写法不收。"""
|
||||
full = "普件(常用线路/常规车型)"
|
||||
self.assertEqual(harvest_land_options(full).get("运输分类"), full)
|
||||
numbered = harvest_land_options(
|
||||
"1",
|
||||
current={"运输类型": "国内运输拼车", "线路类别": "国内长途/零担"},
|
||||
)
|
||||
self.assertEqual(numbered.get("运输分类"), full)
|
||||
self.assertFalse(harvest_land_options("常用线路").get("运输分类"))
|
||||
self.assertFalse(harvest_land_options("常规车型").get("运输分类"))
|
||||
self.assertFalse(harvest_land_options("普件,常用线路/常规车型").get("运输分类"))
|
||||
|
||||
def test_route_code_infers_type_when_empty(self):
|
||||
got = harvest_land_options("1.1")
|
||||
self.assertEqual(got.get("运输类型"), "国内运输拼车")
|
||||
self.assertEqual(got.get("线路类别"), "国内长途/零担")
|
||||
|
||||
def test_unfilled_prompt_does_not_eat_menu(self):
|
||||
"""原样粘贴未填的清单,不能把选项名当成已选。"""
|
||||
got = harvest_land_options(option_prompt(current={}))
|
||||
self.assertFalse(got.get("运输类型"))
|
||||
self.assertFalse(got.get("线路类别"))
|
||||
self.assertFalse(got.get("运输分类"))
|
||||
|
||||
def test_confirm_intent(self):
|
||||
self.assertTrue(looks_like_confirm("确认"))
|
||||
self.assertTrue(looks_like_confirm("可以查了"))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -73,11 +73,10 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertIn("线路类别", text)
|
||||
self.assertIn("运输分类", text)
|
||||
self.assertIn("中港整车", text)
|
||||
self.assertIn("填写样例见图", text)
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", text)
|
||||
self.assertIn("---请复制以下内容---", text)
|
||||
self.assertNotIn("请补充陆运类型:", text)
|
||||
img = [x for x in self.extras if x.get("msgtype") == "image"]
|
||||
self.assertEqual(len(img), 1)
|
||||
self.assertTrue(os.path.isfile(str(img[0].get("filepath") or "")))
|
||||
self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras))
|
||||
|
||||
def test_known_type_does_not_reask_type(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
@@ -90,8 +89,9 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
self.assertEqual(phase, "need_land_options")
|
||||
self.assertNotIn("请选择运输类型", self._joined())
|
||||
self.assertIn("国内长途/零担", self._joined())
|
||||
self.assertIn("填写样例见图", self._joined())
|
||||
self.assertTrue(any(x.get("msgtype") == "image" for x in self.extras))
|
||||
self.assertIn("Step1: 请复制以下内容,粘贴到输入框;", self._joined())
|
||||
self.assertIn("---请复制以下内容---", self._joined())
|
||||
self.assertFalse(any(x.get("msgtype") == "image" for x in self.extras))
|
||||
|
||||
def test_unlinked_route_still_keeps_type(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
@@ -247,7 +247,7 @@ class LandTextInquiryFrontTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertNotIn(copy.LAND_CONFIRM_TAIL, self._joined())
|
||||
self.assertIn("体积(必填)", self._joined())
|
||||
self.assertIn("体积(CBM)(必填)", self._joined())
|
||||
self.assertNotIn("通关口岸", self._joined())
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -11,6 +11,7 @@ if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.multi_segments import (
|
||||
Segment,
|
||||
classify_multi_text,
|
||||
@@ -70,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 = (
|
||||
@@ -130,10 +150,11 @@ class SplitTests(unittest.TestCase):
|
||||
ready, _, text = refresh_segments(outcome.segments)
|
||||
self.assertFalse(ready)
|
||||
self.assertIn("请确认并补充以下信息:", text)
|
||||
self.assertIn("待补充:", text)
|
||||
self.assertIn("品名:(如:茶叶)", text)
|
||||
self.assertIn("货源地(非必填):(如:东莞)", text)
|
||||
self.assertIn("贸易条款(非必填):(如:FOB)", text)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, text)
|
||||
self.assertIn("品名(必填):\n(如:茶叶)", text)
|
||||
self.assertIn("货源地(非必填):\n(如:东莞)", text)
|
||||
self.assertIn("贸易条款(非必填):\n(如:DDP、DAP、CIF、CIP)", text)
|
||||
self.assertNotIn("品名:(如:茶叶)", text)
|
||||
|
||||
def test_port_to_port_fills_sea_class(self) -> None:
|
||||
"""海运只缺运输分类时,回复 Port to Port 要写进该段,和单聊一样。"""
|
||||
@@ -226,10 +247,10 @@ class SplitTests(unittest.TestCase):
|
||||
]
|
||||
ready, updated, text = refresh_segments(segs)
|
||||
self.assertFalse(ready)
|
||||
self.assertIn("线路类别(必填):可选择回复", text)
|
||||
self.assertIn("国内长途/零担", text)
|
||||
self.assertIn("线路类别(必填):", text)
|
||||
self.assertIn("1.1国内长途/零担", text)
|
||||
self.assertIn("国内城配/拖车/打包", text)
|
||||
self.assertIn("运输分类(必填):可选择回复", text)
|
||||
self.assertIn("运输分类(必填):", text)
|
||||
self.assertIn("1.普件", text)
|
||||
self.assertNotIn("始发站", text)
|
||||
self.assertNotIn("请回复「确定」创建工单", text)
|
||||
@@ -301,9 +322,9 @@ class FlowTests(unittest.TestCase):
|
||||
return "ok"
|
||||
|
||||
def last_prompt(self) -> str:
|
||||
"""样例图兜底会后发,取带段号或可选择回复的那条补问。"""
|
||||
"""取带段号或复制填空清单的那条补问。"""
|
||||
for text in reversed(self.replies):
|
||||
if "段1" in text or "可选择回复" in text or "请回复「确定」" in text or "待补充" in text:
|
||||
if "段1" in text or "Step1:" in text or "请回复「确定」" in text or "待补充" in text:
|
||||
return text
|
||||
return self.replies[-1] if self.replies else ""
|
||||
|
||||
@@ -313,8 +334,8 @@ class FlowTests(unittest.TestCase):
|
||||
card = self.replies[-1]
|
||||
self.assertIn("请确认并补充以下信息:", card)
|
||||
self.assertIn("段2:空运", card)
|
||||
self.assertIn("待补充:", card)
|
||||
self.assertIn("包装类型:(如:", card)
|
||||
self.assertIn(copy.CLARIFY_PASTE_HINT, card)
|
||||
self.assertIn("包装类型(必填):\n(如:", card)
|
||||
self.assertNotIn("TMS", card)
|
||||
|
||||
def test_clarify_reply_gets_a_followup(self) -> None:
|
||||
@@ -377,7 +398,7 @@ class FlowTests(unittest.TestCase):
|
||||
)
|
||||
card = self.last_prompt()
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("线路类别(必填):可选择回复", card)
|
||||
self.assertIn("线路类别(必填):", card)
|
||||
self.assertIn("纽约港", card)
|
||||
self.assertIn("巴生西港", card)
|
||||
self.assertIn("机器设备", card)
|
||||
@@ -393,8 +414,8 @@ class FlowTests(unittest.TestCase):
|
||||
phase = self.flow.on_text(sender_id="sales", text=start, reply=self.reply)
|
||||
self.assertEqual(phase, "clarify")
|
||||
first = self.last_prompt()
|
||||
self.assertIn("线路类别(必填):可选择回复", first)
|
||||
self.assertIn("运输分类(必填):可选择回复", first)
|
||||
self.assertIn("线路类别(必填):", first)
|
||||
self.assertIn("运输分类(必填):", first)
|
||||
self.assertNotIn("始发站", first)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="sales",
|
||||
@@ -411,7 +432,7 @@ class FlowTests(unittest.TestCase):
|
||||
)
|
||||
card = self.last_prompt()
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("线路类别(必填):可选择回复", card)
|
||||
self.assertIn("线路类别(必填):", card)
|
||||
self.assertNotIn("始发站", card)
|
||||
self.assertNotIn("请回复「确定」创建工单", card)
|
||||
|
||||
@@ -466,8 +487,8 @@ class FlowTests(unittest.TestCase):
|
||||
phase = self.flow.on_text(sender_id="sales", text=text, reply=self.reply)
|
||||
self.assertEqual(phase, "clarify")
|
||||
card = self.last_prompt()
|
||||
self.assertIn("线路类别(必填):可选择回复", card)
|
||||
self.assertIn("运输分类(必填):可选择回复", card)
|
||||
self.assertIn("线路类别(必填):", card)
|
||||
self.assertIn("运输分类(必填):", card)
|
||||
self.assertNotIn("始发站", card)
|
||||
self.assertNotIn("请回复「确定」创建工单", card)
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -26,6 +26,9 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
|
||||
glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR"
|
||||
self.assertEqual(canonicalize_sea_class(glued), "Port to Port")
|
||||
self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port")
|
||||
|
||||
def test_other_alias(self):
|
||||
self.assertEqual(canonicalize_sea_class("其他"), "其他")
|
||||
@@ -42,6 +45,14 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
|
||||
def test_clarify_example_does_not_override_filled_other(self):
|
||||
"""举例行里的 Port to Port 不能盖掉冒号后的其它/其他。"""
|
||||
pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)"
|
||||
got = harvest_sea_class(pasted, {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {})
|
||||
self.assertEqual(other["运输分类"], "其他")
|
||||
|
||||
def test_door_to_door_not_kept_as_trade_terms(self):
|
||||
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
|
||||
@@ -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 = {
|
||||
"起运港": "上海",
|
||||
@@ -167,7 +184,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("运输方式:海运", text)
|
||||
self.assertIn("起运港:上海", text)
|
||||
self.assertIn("货物数量:20吨", text)
|
||||
self.assertIn("箱型箱量:(如", text)
|
||||
self.assertIn("1.箱型箱量(必填):\n(如:1x40HQ)", text)
|
||||
self.assertNotIn("箱型箱量:(如", text)
|
||||
self.assertNotIn("件数", text)
|
||||
self.assertNotIn("重量", text)
|
||||
self.assertNotIn("报价日期:(如", text)
|
||||
@@ -256,8 +274,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
missing_keys=["运输分类"],
|
||||
transport_mode="海运",
|
||||
)
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertIn("1.运输分类(必填):\n(如:Port to Port、其他)", text)
|
||||
self.assertIn("贸易条款:FOB", text)
|
||||
|
||||
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
|
||||
@@ -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()
|
||||
|
||||
+21
-3
@@ -814,8 +814,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
|
||||
out.put("pieces", first(src, "件数", "pieces"));
|
||||
// 销售常写「重量」,合同键是毛重;两边都认
|
||||
out.put("weight_kg", first(src, "毛重", "重量", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm", "volume"));
|
||||
out.put("weight_kg", first(src, "毛重", "重量", "重量(KG)", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "体积(CBM)", "volume_cbm", "volume"));
|
||||
// 去掉单位后缀,避免模板「KG/CBM」列旁再写一遍单位
|
||||
String w = out.getString("weight_kg");
|
||||
if (StringUtils.isNotBlank(w)) {
|
||||
@@ -825,7 +825,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (StringUtils.isNotBlank(vol)) {
|
||||
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
|
||||
}
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
// 询价/走货/发货/出货/提货的日期或时间,都是报价日期
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期", "走货日期", "询价时间",
|
||||
"走货时间", "发货时间", "发货日期", "出货日期", "出货时间", "提货时间", "提货日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("运输分类", first(src, "运输分类"));
|
||||
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
|
||||
@@ -833,6 +835,16 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
String oil = first(src, "是否含油", "has_oil");
|
||||
String elec = first(src, "是否含电", "has_battery");
|
||||
String mag = first(src, "是否含磁", "has_magnet");
|
||||
// 空运品名正好是普货:报价单含电、含磁空着或只写横杠时默认否。海运陆运不套这条。
|
||||
String cargoName = StringUtils.trimToEmpty(first(src, "品名", "货物品名", "commodity"));
|
||||
if (looksAirInquiry(src, q, businessLine) && "普货".equals(cargoName)) {
|
||||
if (collabBlank(elec)) {
|
||||
elec = "否";
|
||||
}
|
||||
if (collabBlank(mag)) {
|
||||
mag = "否";
|
||||
}
|
||||
}
|
||||
String oem = first(src, "oil_elec_mag", "是否含油,电,磁");
|
||||
if (StringUtils.isBlank(oem) && (StringUtils.isNotBlank(oil) || StringUtils.isNotBlank(elec) || StringUtils.isNotBlank(mag))) {
|
||||
oem = "油:" + (StringUtils.isNotBlank(oil) ? oil : "-")
|
||||
@@ -1059,6 +1071,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 协同项没写,或只写了横杠。横杠是空占位,不能当成已经填过。 */
|
||||
private static boolean collabBlank(String raw) {
|
||||
String val = StringUtils.trimToEmpty(raw);
|
||||
return val.isEmpty() || "-".equals(val) || "—".equals(val) || "-".equals(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运销售面没有时效:出单不把 eta / transit_* 填进模板。
|
||||
* 业务线 AIR / 运输方式含空运 / 报价带 airOptions 都算空运。
|
||||
|
||||
+3
-2
@@ -41,7 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
// 陆运产品 5 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件(东南亚、中港常用线路/常规车型)");
|
||||
@@ -58,7 +58,8 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
|
||||
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
|
||||
// 陆运产品运输分类 5 项(编码勿再按线路拆 ltl/city/sea)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000020", "land_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
|
||||
+88
-19
@@ -822,11 +822,11 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("ok", true);
|
||||
out.put("status", status);
|
||||
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
String salesWecomId = StringUtils.defaultString(ticket.getSalesWecomId());
|
||||
out.put("salesWecomId", salesWecomId);
|
||||
out.put("sales_wecom_id", salesWecomId);
|
||||
String ticketSalesWecomId = StringUtils.defaultString(ticket.getSalesWecomId());
|
||||
out.put("salesWecomId", ticketSalesWecomId);
|
||||
out.put("sales_wecom_id", ticketSalesWecomId);
|
||||
String salesName = InquiryTicketDisplaySupport.resolveSalesDisplayName(
|
||||
ticket.getSalesName(), salesWecomId, resolveStaffDisplayName(salesWecomId));
|
||||
ticket.getSalesName(), ticketSalesWecomId, resolveStaffDisplayName(ticketSalesWecomId));
|
||||
out.put("sales_name", salesName);
|
||||
out.put("salesName", salesName);
|
||||
out.put("facts", facts);
|
||||
@@ -983,7 +983,9 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* 记下海运协同群 chat_id 与成员。不改六态、不写报价版本。
|
||||
* 记下协同群 chat_id 与成员。不改六态、不写报价版本。
|
||||
* <p>
|
||||
* 同一群只留这一张单,陆运/海运/空运旧绑定一并摘掉。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1000,6 +1002,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setCollabChatId(chatId);
|
||||
ticket.setCollabMemberIds(memberIdsJson(body));
|
||||
ticket.setUpdateTime(new Date());
|
||||
unbindOtherTicketsInRoom(chatId, no);
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "bind_collab_group", "agent", chatId);
|
||||
out.put("ok", true);
|
||||
@@ -1064,6 +1067,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|
||||
/**
|
||||
* 按协同群找回工单。群里补字段用。只读。
|
||||
* <p>
|
||||
* 同群若仍有多张单,取最近更新的一张,避免 LIMIT 1 无排序拿到最早的陆运旧单。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> findByCollabChat(String chatId) {
|
||||
@@ -1075,6 +1080,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
InquiryTicket ticket = getOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getCollabChatId, id)
|
||||
.orderByDesc(InquiryTicket::getUpdateTime)
|
||||
.last("LIMIT 1"), false);
|
||||
if (ticket == null) {
|
||||
return out;
|
||||
@@ -1241,7 +1247,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运群当前工单:清掉同群其它空运单绑定,一单一群。不改六态。
|
||||
* 空运群当前工单:清掉同群其它工单绑定,一单一群。不改六态。
|
||||
* <p>
|
||||
* 陆运/海运旧单也要摘掉。测服 WO202609230023 激活后补协同,
|
||||
* 按群找回仍拿到同群陆运 WO202609230007,摘要字段全空。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1260,20 +1269,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
return out;
|
||||
}
|
||||
List<InquiryTicket> others = list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getCollabChatId, room)
|
||||
.ne(InquiryTicket::getWorkOrderNo, no));
|
||||
if (others != null) {
|
||||
for (InquiryTicket other : others) {
|
||||
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(other.getBusinessLine()))) {
|
||||
continue;
|
||||
}
|
||||
other.setCollabChatId("");
|
||||
other.setUpdateTime(new Date());
|
||||
updateById(other);
|
||||
}
|
||||
}
|
||||
unbindOtherTicketsInRoom(room, no);
|
||||
ticket.setCollabChatId(room);
|
||||
// 品名是普货:激活时就把含电、含磁写成否,群摘要不再显示横杠。
|
||||
seedAirGeneralCargoCollab(ticket);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "activate_air_group", "agent", room);
|
||||
@@ -1288,9 +1287,79 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("facts", parseFactsJson(ticket.getFactsJson()));
|
||||
out.put("quote", latestQuotePayload(no));
|
||||
out.put("collab_chat_id", room);
|
||||
out.put("collab_facts", parseFactsJson(ticket.getCollabFactsJson()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一协同群只留 keep 这一张单。陆运、海运、空运旧绑定都清。
|
||||
*/
|
||||
private void unbindOtherTicketsInRoom(String room, String keepWorkOrderNo) {
|
||||
String chat = StringUtils.trimToEmpty(room);
|
||||
String keep = StringUtils.trimToEmpty(keepWorkOrderNo);
|
||||
if (StringUtils.isBlank(chat)) {
|
||||
return;
|
||||
}
|
||||
List<InquiryTicket> others = list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getCollabChatId, chat)
|
||||
.ne(InquiryTicket::getWorkOrderNo, keep));
|
||||
if (others == null) {
|
||||
return;
|
||||
}
|
||||
Date now = new Date();
|
||||
for (InquiryTicket other : others) {
|
||||
other.setCollabChatId("");
|
||||
other.setUpdateTime(now);
|
||||
updateById(other);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运品名正好是「普货」时,含电、含磁还空着就写成否。
|
||||
* <p>
|
||||
* 已经写了「是」或「否」的不改。摘要里的横杠当成没写。
|
||||
* 只改传入工单上的协同 JSON,由调用方落库。海运、陆运不进这里。
|
||||
*/
|
||||
private void seedAirGeneralCargoCollab(InquiryTicket ticket) {
|
||||
if (ticket == null || !"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
|
||||
return;
|
||||
}
|
||||
String name = StringUtils.trimToEmpty(ticket.getGoodsName());
|
||||
if (!"普货".equals(name)) {
|
||||
Map<String, String> facts = parseFactsJson(ticket.getFactsJson());
|
||||
name = StringUtils.trimToEmpty(facts.get("品名"));
|
||||
if (!"普货".equals(name)) {
|
||||
name = StringUtils.trimToEmpty(facts.get("货物品名"));
|
||||
}
|
||||
}
|
||||
if (!"普货".equals(name)) {
|
||||
return;
|
||||
}
|
||||
JSONObject collab = new JSONObject(true);
|
||||
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getCollabFactsJson());
|
||||
if (parsed != null) {
|
||||
collab.putAll(parsed);
|
||||
}
|
||||
}
|
||||
boolean changed = false;
|
||||
for (String key : new String[]{"是否含电", "是否含磁"}) {
|
||||
if (collabBlank(collab.getString(key))) {
|
||||
collab.put(key, "否");
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
ticket.setCollabFactsJson(JSON.toJSONString(collab));
|
||||
}
|
||||
}
|
||||
|
||||
/** 协同项没写,或只写了横杠。 */
|
||||
private static boolean collabBlank(String raw) {
|
||||
String val = StringUtils.trimToEmpty(raw);
|
||||
return val.isEmpty() || "-".equals(val) || "—".equals(val) || "-".equals(val);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseFactsJson(String raw) {
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
|
||||
+8
-8
@@ -45,20 +45,20 @@ public final class InquiryTicketDisplaySupport {
|
||||
List<String> quantities = new ArrayList<>();
|
||||
if ("AIR".equals(businessLine) || containsChinese(lineLabel, "空运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "毛重", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("SEA".equals(businessLine) || containsChinese(lineLabel, "海运")) {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
} else if ("LAND".equals(businessLine) || containsChinese(lineLabel, "陆运")) {
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
} else {
|
||||
addLabel(quantities, "件数", ticket.getPieces());
|
||||
addUnit(quantities, "重量", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积", ticket.getVolumeCbm(), "CBM");
|
||||
addUnit(quantities, "重量(KG)", ticket.getWeightKg(), "kg");
|
||||
addUnit(quantities, "体积(CBM)", ticket.getVolumeCbm(), "CBM");
|
||||
}
|
||||
addLine(lines, join(quantities, " · "));
|
||||
|
||||
|
||||
+14
-2
@@ -41,6 +41,16 @@ public final class QuoteTemplateExcelSupport {
|
||||
static {
|
||||
FILL_BY_LABEL.put("询价日期", "quote_date");
|
||||
FILL_BY_LABEL.put("报价日期", "quote_date");
|
||||
// 销售口中的报价日期。货好时间、提货地址不在这里。
|
||||
FILL_BY_LABEL.put("走货日期", "quote_date");
|
||||
FILL_BY_LABEL.put("询价时间", "quote_date");
|
||||
FILL_BY_LABEL.put("走货时间", "quote_date");
|
||||
FILL_BY_LABEL.put("发货时间", "quote_date");
|
||||
FILL_BY_LABEL.put("发货日期", "quote_date");
|
||||
FILL_BY_LABEL.put("出货日期", "quote_date");
|
||||
FILL_BY_LABEL.put("出货时间", "quote_date");
|
||||
FILL_BY_LABEL.put("提货时间", "quote_date");
|
||||
FILL_BY_LABEL.put("提货日期", "quote_date");
|
||||
FILL_BY_LABEL.put("品名", "commodity");
|
||||
FILL_BY_LABEL.put("货物数量", "quantity");
|
||||
FILL_BY_LABEL.put("整柜或拼柜", "fcl_or_lcl");
|
||||
@@ -70,7 +80,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
FILL_BY_LABEL.put("目的地", "destination");
|
||||
FILL_BY_LABEL.put("件数", "pieces");
|
||||
FILL_BY_LABEL.put("重量", "weight_kg");
|
||||
FILL_BY_LABEL.put("重量(KG)", "weight_kg");
|
||||
FILL_BY_LABEL.put("体积", "volume_cbm");
|
||||
FILL_BY_LABEL.put("体积(CBM)", "volume_cbm");
|
||||
FILL_BY_LABEL.put("始发地", "origin");
|
||||
FILL_BY_LABEL.put("始发站", "origin");
|
||||
FILL_BY_LABEL.put("目的地", "destination");
|
||||
@@ -530,8 +542,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
m.put("trade_terms", "贸易条款");
|
||||
m.put("cargo_ready_date", "货好时间");
|
||||
m.put("pieces", "件数");
|
||||
m.put("weight_kg", "重量");
|
||||
m.put("volume_cbm", "体积");
|
||||
m.put("weight_kg", "重量(KG)");
|
||||
m.put("volume_cbm", "体积(CBM)");
|
||||
m.put("total_amount", "报价合计");
|
||||
return m;
|
||||
}
|
||||
|
||||
+322
-44
@@ -26,10 +26,9 @@ import java.util.Map;
|
||||
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
|
||||
* <p>
|
||||
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
||||
* 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
|
||||
* 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
|
||||
* 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
|
||||
* 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
|
||||
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
|
||||
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
|
||||
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班和费用不拦。
|
||||
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
||||
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
||||
*/
|
||||
@@ -67,15 +66,34 @@ public class InquiryTmsCabinService {
|
||||
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
|
||||
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
|
||||
}
|
||||
Map<String, Object> quote = latestQuote(no);
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
|
||||
List<InquiryQuote> rows = quoteRows(no);
|
||||
if (rows.isEmpty()) {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> quote = payloadOf(rows.get(0));
|
||||
boolean manual = isManualQuote(quote);
|
||||
if (!manual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
}
|
||||
String quoteId = manual ? realTmsQuoteId(rows, no) : resolveQuoteId(quote, no);
|
||||
if (!manual && StringUtils.isBlank(quoteId)) {
|
||||
quoteId = realTmsQuoteId(rows, no);
|
||||
}
|
||||
if (!manual && StringUtils.isBlank(quoteId)) {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> option = resolveOption(quote, optionNo);
|
||||
if (option == null) {
|
||||
option = new LinkedHashMap<>();
|
||||
}
|
||||
String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
|
||||
option.put("optionNo", resolvedOption);
|
||||
if (manual && StringUtils.isBlank(text(option, "optionNo", "option_no"))) {
|
||||
// 手工价往往只有金额。舱位、航班仍用本单那条 TMS 报价,不另编编号。
|
||||
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, optionNo);
|
||||
if (!tmsOption.isEmpty()) {
|
||||
option = tmsOption;
|
||||
}
|
||||
}
|
||||
String resolvedOption = text(option, "optionNo", "option_no");
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放锁舱");
|
||||
}
|
||||
@@ -83,15 +101,20 @@ public class InquiryTmsCabinService {
|
||||
req.put("workOrderNo", no);
|
||||
req.put("quoteId", quoteId);
|
||||
req.put("optionNo", resolvedOption);
|
||||
req.put("flightDate", quoteDate(ticket));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
|
||||
req.put("flightDate", firstNonBlank(text(option, "flightDate"), quoteDate(ticket)));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
|
||||
fact(ticket, "originCode"),
|
||||
text(option, "origin"), ticket.getOrigin(),
|
||||
fact(ticket, "起运地", "起运港")));
|
||||
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
|
||||
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"),
|
||||
fact(ticket, "destinationCode"),
|
||||
text(option, "destination"), ticket.getDestination(),
|
||||
fact(ticket, "目的地", "目的港")));
|
||||
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
|
||||
text(option, "airlineName"),
|
||||
text(quote, "airlineCode", "airline", "airlineName"),
|
||||
fact(ticket, "航司", "航司代码")));
|
||||
fact(ticket, "航司", "航司代码"),
|
||||
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
|
||||
req.put("weightKg", ticket.getWeightKg());
|
||||
req.put("volumeCbm", ticket.getVolumeCbm());
|
||||
req.put("pieceQty", ticket.getPieces());
|
||||
@@ -101,6 +124,15 @@ public class InquiryTmsCabinService {
|
||||
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
||||
req.put("operatorName", text(body, "operatorName", "operator_name"));
|
||||
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
||||
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
|
||||
req.put("requestedUntil", text(body, "requestedUntil"));
|
||||
}
|
||||
if (manual) {
|
||||
fillManualLock(req, quote, option);
|
||||
} else {
|
||||
// 库里仍是 source=TMS。TMS 锁舱要求纯系统报价带 source=SYSTEM,只写在出站正文。
|
||||
req.put("source", "SYSTEM");
|
||||
}
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
}
|
||||
|
||||
@@ -310,8 +342,267 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
|
||||
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
|
||||
* 人工报价出站:source 只写在 TMS 正文,不回写报价表。
|
||||
* 费用数组用报价里已有的;没有就不放。不用总价编一条空运费。
|
||||
*/
|
||||
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
|
||||
req.put("source", "MANUAL");
|
||||
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
|
||||
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
|
||||
req.put("validUntil", until);
|
||||
}
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
req.put("transferAirport", transfer);
|
||||
}
|
||||
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
|
||||
if (StringUtils.isNotBlank(flightNo)) {
|
||||
req.put("flightNo", flightNo);
|
||||
}
|
||||
String currency = firstNonBlank(text(quote, "currency", "currencyCode"), text(option, "currency"));
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
req.put("currency", currency);
|
||||
}
|
||||
String total = firstNonBlank(text(quote, "totalSalePrice", "salePrice", "total"), text(option, "salePrice"));
|
||||
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
|
||||
req.put("totalSalePrice", total);
|
||||
}
|
||||
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
|
||||
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
|
||||
splitFeeRows(quote, departure, fees);
|
||||
if (!departure.isEmpty()) {
|
||||
req.put("departureCharges", toLockFees(departure, true));
|
||||
}
|
||||
if (!fees.isEmpty()) {
|
||||
req.put("feeItems", toLockFees(fees, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
|
||||
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
|
||||
*/
|
||||
static boolean isManualQuote(Map<String, Object> quote) {
|
||||
String label = text(quote, "source_label", "sourceLabel");
|
||||
if (label.contains("确认") && label.toUpperCase().contains("TMS")) {
|
||||
return false;
|
||||
}
|
||||
String src = text(quote, "source").toUpperCase();
|
||||
if ("SALES_ADJUST".equals(src) || label.contains("销售调价")) {
|
||||
return true;
|
||||
}
|
||||
if ("PRODUCT".equals(src) || "MANUAL".equals(src)) {
|
||||
return true;
|
||||
}
|
||||
return label.contains("航线报价") || label.contains("航线附件")
|
||||
|| label.contains("在TMS上改") || label.contains("手工");
|
||||
}
|
||||
|
||||
/**
|
||||
* 本单更早报价里的真实 TMS 编号。自编 QT-工单号不算。
|
||||
*/
|
||||
/**
|
||||
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
|
||||
*/
|
||||
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
|
||||
if (rows == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
Map<String, Object> payload = payloadOf(row);
|
||||
if (StringUtils.isBlank(resolveQuoteId(payload, workOrderNo))) {
|
||||
continue;
|
||||
}
|
||||
if (isManualQuote(payload)) {
|
||||
continue;
|
||||
}
|
||||
List<Map<String, Object>> options = airOptions(payload);
|
||||
if (options.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(optionNo)) {
|
||||
for (Map<String, Object> item : options) {
|
||||
if (optionNo.equals(text(item, "optionNo", "option_no"))) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (options.size() == 1) {
|
||||
return options.get(0);
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
|
||||
if (rows == null) {
|
||||
return "";
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
String id = resolveQuoteId(payloadOf(row), workOrderNo);
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private List<InquiryQuote> quoteRows(String workOrderNo) {
|
||||
if (StringUtils.isBlank(workOrderNo)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 8"));
|
||||
return rows == null ? new ArrayList<>() : rows;
|
||||
}
|
||||
|
||||
private static Map<String, Object> payloadOf(InquiryQuote row) {
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
if (row == null) {
|
||||
return quote;
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getPayloadJson())) {
|
||||
JSONObject parsed = JSON.parseObject(row.getPayloadJson());
|
||||
if (parsed != null) {
|
||||
quote.putAll(parsed);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getSource()) && StringUtils.isBlank(text(quote, "source"))) {
|
||||
quote.put("source", row.getSource());
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getSourceLabel()) && StringUtils.isBlank(text(quote, "source_label", "sourceLabel"))) {
|
||||
quote.put("source_label", row.getSourceLabel());
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> feeRows(Map<String, Object> quote, Map<String, Object> option,
|
||||
String... keys) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
collectFeeRows(out, option, keys);
|
||||
if (out.isEmpty()) {
|
||||
collectFeeRows(out, quote, keys);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void collectFeeRows(List<Map<String, Object>> out, Map<String, Object> src, String... keys) {
|
||||
if (src == null || keys == null) {
|
||||
return;
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object raw = src.get(key);
|
||||
if (raw instanceof String && String.valueOf(raw).trim().startsWith("[")) {
|
||||
try {
|
||||
raw = JSON.parseArray(String.valueOf(raw));
|
||||
} catch (Exception ignore) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
if (raw instanceof JSONArray) {
|
||||
raw = ((JSONArray) raw).toJavaList(Object.class);
|
||||
}
|
||||
if (!(raw instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object item : (List<?>) raw) {
|
||||
if (item instanceof Map && StringUtils.isNotBlank(feeName((Map<String, Object>) item))) {
|
||||
out.add((Map<String, Object>) item);
|
||||
}
|
||||
}
|
||||
if (!out.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价只存了 fee_rows 时,本地费进 departureCharges,其余进 feeItems。已分开的不再搬。
|
||||
*/
|
||||
private static void splitFeeRows(Map<String, Object> quote, List<Map<String, Object>> departure,
|
||||
List<Map<String, Object>> fees) {
|
||||
if (!departure.isEmpty() || !fees.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, Object> row : feeRows(quote, null, "fee_rows", "fee_lines")) {
|
||||
String name = feeName(row);
|
||||
if ("运输费".equals(name) || "提送货费".equals(name) || "人工费".equals(name)) {
|
||||
departure.add(row);
|
||||
} else {
|
||||
fees.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String feeName(Map<String, Object> row) {
|
||||
return text(row, "chargeName", "feeName", "name", "label");
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行出站改成 TMS 字段:feeName/chargeName、unitPrice、数量和金额用数字。
|
||||
* 库里的 name、unit_price 不原样送出,避免 TMS 报参数格式错误。
|
||||
*/
|
||||
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
String name = feeName(row);
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
item.put(departure ? "chargeName" : "feeName", name);
|
||||
}
|
||||
String code = text(row, "feeCode", "fee_code");
|
||||
if (!departure && StringUtils.isNotBlank(code)) {
|
||||
item.put("feeCode", code);
|
||||
}
|
||||
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
|
||||
putFeeNumber(item, "quantity", text(row, "quantity"));
|
||||
putFeeNumber(item, "amount", text(row, "amount"));
|
||||
String currency = text(row, "currency");
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
item.put("currency", currency);
|
||||
}
|
||||
String unit = text(row, "unit", "单位");
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
item.put("unit", unit);
|
||||
}
|
||||
if (!item.isEmpty()) {
|
||||
out.add(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
|
||||
return;
|
||||
}
|
||||
String text = raw.trim().replace(",", "");
|
||||
int space = text.lastIndexOf(' ');
|
||||
if (space >= 0) {
|
||||
text = text.substring(space + 1).trim();
|
||||
}
|
||||
try {
|
||||
item.put(key, new java.math.BigDecimal(text));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 库里的真实 TMS 报价编号。自编 QT-工单号不算,缺了也不补占位。
|
||||
*/
|
||||
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
|
||||
String id = text(quote, "quoteId", "quote_id");
|
||||
@@ -326,33 +617,21 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
|
||||
* 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
|
||||
* 报价没给航司时,用航班号前两位字母。TH739 → TH。
|
||||
* 不是字母开头就不猜,避免把纯数字航班编成航司。
|
||||
*/
|
||||
static String lockQuoteId(String resolved, String workOrderNo) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
static String airlineFromFlight(String flightNo) {
|
||||
String raw = StringUtils.trimToEmpty(flightNo).toUpperCase();
|
||||
if (raw.length() >= 2
|
||||
&& Character.isLetter(raw.charAt(0))
|
||||
&& Character.isLetter(raw.charAt(1))) {
|
||||
return raw.substring(0, 2);
|
||||
}
|
||||
String wo = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (wo.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "QT-" + wo;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
|
||||
* 有选项用选项;没有补 AIR-OPT-01。
|
||||
*/
|
||||
static String lockOptionNo(String resolved) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
}
|
||||
return "AIR-OPT-01";
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
|
||||
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
|
||||
*/
|
||||
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
@@ -361,17 +640,16 @@ public class InquiryTmsCabinService {
|
||||
if (picked != null) {
|
||||
return picked;
|
||||
}
|
||||
if (options.size() == 1) {
|
||||
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
|
||||
return options.get(0);
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", lockOptionNo(optionNo));
|
||||
String airline = text(quote, "airlineCode", "airline", "airlineName");
|
||||
if (StringUtils.isNotBlank(airline)) {
|
||||
row.put("airlineCode", airline);
|
||||
if (StringUtils.isBlank(optionNo)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", optionNo.trim());
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -459,7 +737,7 @@ public class InquiryTmsCabinService {
|
||||
|
||||
/**
|
||||
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
|
||||
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
|
||||
* 禁止按航班日改写成「起飞」,禁止把 2006 改写成另一句业务话。
|
||||
*/
|
||||
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
|
||||
return cabinFailReason(code, tms, flightDate, "");
|
||||
|
||||
+56
-3
@@ -4,6 +4,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -12,8 +13,9 @@ import java.util.UUID;
|
||||
* <p>
|
||||
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
|
||||
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
|
||||
* quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。
|
||||
* mapper 仍跳过空字符串,避免把 null 写成 JSON。
|
||||
* 只有 TMS 报价:沿用原字段,不带 source、不带费用数组。
|
||||
* 人工报价:调用方放入 source=MANUAL 和已有的费用;空字符串、空数组不写出。
|
||||
* 不在这里编造 quoteId / optionNo。
|
||||
*/
|
||||
public final class TmsCabinRequestMapper {
|
||||
|
||||
@@ -45,6 +47,14 @@ public final class TmsCabinRequestMapper {
|
||||
put(body, "operatorName", internal.get("operatorName"));
|
||||
body.put("operatorRole", "AIRLINE");
|
||||
put(body, "instructionText", internal.get("instructionText"));
|
||||
put(body, "source", internal.get("source"));
|
||||
put(body, "validUntil", internal.get("validUntil"));
|
||||
put(body, "transferAirport", internal.get("transferAirport"));
|
||||
put(body, "flightNo", internal.get("flightNo"));
|
||||
put(body, "currency", internal.get("currency"));
|
||||
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
|
||||
putList(body, "departureCharges", internal.get("departureCharges"));
|
||||
putList(body, "feeItems", internal.get("feeItems"));
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -77,10 +87,53 @@ public final class TmsCabinRequestMapper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用数组有行才写入。空数组不传,避免 TMS 把「没有明细」当成一条空费用。
|
||||
*/
|
||||
private static void putList(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof List && !((List<?>) value).isEmpty()) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal asDecimal(Object value) {
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
if (value == null || StringUtils.isBlank(String.valueOf(value))) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim().replace(",", "");
|
||||
int space = text.lastIndexOf(' ');
|
||||
if (space >= 0) {
|
||||
text = text.substring(space + 1).trim();
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 100.000 去掉尾零会变成 1E+2,TMS 锁舱当参数格式错误。
|
||||
* 重量和体积类型是 decimal,整数也写成 100.0,避免被收成没有小数点的整数。
|
||||
*/
|
||||
static BigDecimal plainDecimal(BigDecimal value) {
|
||||
BigDecimal stripped = value.stripTrailingZeros();
|
||||
if (stripped.scale() < 1) {
|
||||
return stripped.setScale(1);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
private static void putNumber(Map<String, Object> target, String key, Number value) {
|
||||
if (value != null) {
|
||||
if (value instanceof BigDecimal) {
|
||||
target.put(key, ((BigDecimal) value).stripTrailingZeros());
|
||||
target.put(key, plainDecimal((BigDecimal) value));
|
||||
} else {
|
||||
target.put(key, value);
|
||||
}
|
||||
|
||||
+4
-3
@@ -122,7 +122,8 @@ public class TmsGatewayClient {
|
||||
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
|
||||
}
|
||||
assertCircuitAvailable();
|
||||
String json = JSON.toJSONString(request);
|
||||
String json = JSON.toJSONString(request, com.alibaba.fastjson.serializer.SerializerFeature.WriteBigDecimalAsPlain);
|
||||
log.info("tms.cabin req path={} json={}", path, json);
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = java.util.UUID.randomUUID().toString();
|
||||
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
|
||||
@@ -167,10 +168,10 @@ public class TmsGatewayClient {
|
||||
consecutiveFailures.set(0);
|
||||
body.put("formal", true);
|
||||
body.put("source", "TMS");
|
||||
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={}",
|
||||
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={} resp={}",
|
||||
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
|
||||
body.get("code"), status,
|
||||
System.currentTimeMillis() - started, attempt, response.bodyLen);
|
||||
System.currentTimeMillis() - started, attempt, response.bodyLen, response.body);
|
||||
return body;
|
||||
} catch (SocketTimeoutException ex) {
|
||||
log.warn("tms.cabin timeout path={} attempt={}", path, attempt);
|
||||
|
||||
+13
-2
@@ -96,8 +96,8 @@
|
||||
{ "code": "trade_terms", "label": "贸易条款", "requiredHint": false },
|
||||
{ "code": "cargo_ready_date", "label": "货好时间", "requiredHint": false },
|
||||
{ "code": "pieces", "label": "件数", "requiredHint": false },
|
||||
{ "code": "weight_kg", "label": "重量", "requiredHint": false },
|
||||
{ "code": "volume_cbm", "label": "体积", "requiredHint": false },
|
||||
{ "code": "weight_kg", "label": "重量(KG)", "requiredHint": false },
|
||||
{ "code": "volume_cbm", "label": "体积(CBM)", "requiredHint": false },
|
||||
{ "code": "chargeable_weight_kg", "label": "计费重", "requiredHint": false },
|
||||
{ "code": "delivery_address", "label": "派送地址", "requiredHint": false },
|
||||
{ "code": "pickup_address", "label": "提货地址", "requiredHint": false },
|
||||
@@ -131,6 +131,15 @@
|
||||
"报价编号": "quote_no",
|
||||
"报价日期": "quote_date",
|
||||
"询价日期": "quote_date",
|
||||
"走货日期": "quote_date",
|
||||
"询价时间": "quote_date",
|
||||
"走货时间": "quote_date",
|
||||
"发货时间": "quote_date",
|
||||
"发货日期": "quote_date",
|
||||
"出货日期": "quote_date",
|
||||
"出货时间": "quote_date",
|
||||
"提货时间": "quote_date",
|
||||
"提货日期": "quote_date",
|
||||
"有效期至": "valid_until",
|
||||
"报价有效期": "valid_until",
|
||||
"币别": "currency",
|
||||
@@ -152,8 +161,10 @@
|
||||
"件数": "pieces",
|
||||
"件 数": "pieces",
|
||||
"重量": "weight_kg",
|
||||
"重量(KG)": "weight_kg",
|
||||
"重 量": "weight_kg",
|
||||
"体积": "volume_cbm",
|
||||
"体积(CBM)": "volume_cbm",
|
||||
"体 积": "volume_cbm",
|
||||
"费用总计": "total_amount",
|
||||
"报价合计": "total_amount",
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
-- 陆运产品 5 项;常用线路见 migration_inq_response_timeout_land_common_v1.sql
|
||||
-- ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
|
||||
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- 陆运产品运输分类增加「普件(常用线路/常规车型)」,超时小时 0=不监测。
|
||||
-- 排在原 land_hk_common 之前;不改销售行,不改原 4 项文案和小时数。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `inq_response_timeout` (
|
||||
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
|
||||
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
|
||||
)
|
||||
SELECT
|
||||
'rt100000000000000000000000000020',
|
||||
'land_common',
|
||||
'LAND',
|
||||
'product',
|
||||
'',
|
||||
CONVERT(UNHEX('e699aee4bbb628e5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
|
||||
0,
|
||||
IFNULL((
|
||||
SELECT `sort_no` FROM (
|
||||
SELECT `sort_no` FROM `inq_response_timeout` WHERE `timeout_code`='land_hk_common' LIMIT 1
|
||||
) AS `hk`
|
||||
), 7) - 1,
|
||||
'system',
|
||||
NOW(),
|
||||
'system',
|
||||
NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='land_common'
|
||||
) AS `existed`
|
||||
);
|
||||
+2
-2
@@ -30,12 +30,12 @@ public class InquiryTicketDisplaySupportTest {
|
||||
|
||||
List<String> lines = InquiryTicketDisplaySupport.requirementSummaryLines(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", lines.get(0));
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM", lines.get(1));
|
||||
assertEquals("件数 10 · 重量(KG) 1kg · 体积(CBM) 8CBM", lines.get(1));
|
||||
assertEquals("品名 茶叶", lines.get(2));
|
||||
|
||||
InquiryTicketDisplaySupport.applyRequirementSummary(ticket);
|
||||
assertEquals("空运 · ZUH → CRK", ticket.getDemandTitle());
|
||||
assertEquals("件数 10 · 毛重 1kg · 体积 8CBM\n品名 茶叶", ticket.getDemandDetail());
|
||||
assertEquals("件数 10 · 重量(KG) 1kg · 体积(CBM) 8CBM\n品名 茶叶", ticket.getDemandDetail());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+16
@@ -112,6 +112,22 @@ public class QuoteTemplateFillTest {
|
||||
assertTrue(StringUtils.isBlank(out.getString("时效")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsAirGeneralCargoDefaultsBatteryAndMagnet() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("品名", "普货");
|
||||
facts.put("运输方式", "空运");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609230017", facts, new HashMap<>(), "WuJiLin", "AIR");
|
||||
assertEquals("否", out.getString("是否含电"));
|
||||
assertEquals("否", out.getString("是否含磁"));
|
||||
facts.put("是否含电", "是");
|
||||
JSONObject kept = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609230017", facts, new HashMap<>(), "WuJiLin", "AIR");
|
||||
assertEquals("是", kept.getString("是否含电"));
|
||||
assertEquals("否", kept.getString("是否含磁"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception {
|
||||
Path src = Files.createTempFile("quote-fee-src", ".xlsx");
|
||||
|
||||
+25
-12
@@ -48,28 +48,41 @@ public class InquiryTmsCabinServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveOptionWithoutTmsUsesAirOpt01() {
|
||||
public void resolveOptionWithoutTmsStaysBlank() {
|
||||
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
|
||||
assertEquals("AIR-OPT-01", option.get("optionNo"));
|
||||
assertNull(option.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() {
|
||||
assertEquals("QT-WO202609210003",
|
||||
InquiryTmsCabinService.lockQuoteId("", "WO202609210003"));
|
||||
public void manualQuoteKeepsCallerOptionText() {
|
||||
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "AIR-OPT01");
|
||||
assertEquals("AIR-OPT01", option.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdKeepsRealAirId() {
|
||||
assertEquals("AIR-2094721692181016578-2094721692654972929",
|
||||
InquiryTmsCabinService.lockQuoteId(
|
||||
"AIR-2094721692181016578-2094721692654972929",
|
||||
"WO202609160015"));
|
||||
public void airlineFromFlightUsesFirstTwoLetters() {
|
||||
assertEquals("TH", InquiryTmsCabinService.airlineFromFlight("TH739"));
|
||||
assertEquals("CZ", InquiryTmsCabinService.airlineFromFlight("cz327"));
|
||||
assertEquals("", InquiryTmsCabinService.airlineFromFlight("739"));
|
||||
assertEquals("", InquiryTmsCabinService.airlineFromFlight(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockOptionNoWithoutTmsUsesAirOpt01() {
|
||||
assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo(""));
|
||||
public void confirmTmsIsNotManual() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("source", "PRODUCT");
|
||||
quote.put("source_label", "航线确认TMS");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(quote));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void salesAdjustAndProductAreManual() {
|
||||
Map<String, Object> adjust = new HashMap<>();
|
||||
adjust.put("source", "SALES_ADJUST");
|
||||
Map<String, Object> edited = new HashMap<>();
|
||||
edited.put("source_label", "航线在TMS上改");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(adjust));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(edited));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+22
-9
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -53,15 +54,27 @@ public class TmsCabinRequestMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockKeepsNoQuotePlaceholders() {
|
||||
Map<String, Object> filled = new LinkedHashMap<>();
|
||||
filled.put("workOrderNo", "WO202609210003");
|
||||
filled.put("quoteId", "QT-WO202609210003");
|
||||
filled.put("optionNo", "AIR-OPT-01");
|
||||
filled.put("flightDate", "2026-09-20");
|
||||
Map<String, Object> sent = TmsCabinRequestMapper.lock(filled);
|
||||
assertEquals("QT-WO202609210003", sent.get("quoteId"));
|
||||
assertEquals("AIR-OPT-01", sent.get("optionNo"));
|
||||
public void weightWithTrailingZerosStaysPlain() {
|
||||
assertEquals("100.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("100.000")).toString());
|
||||
assertEquals("2.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("2.0000")).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualLockOmitsEmptyFeesAndKeepsSource() {
|
||||
Map<String, Object> internal = new LinkedHashMap<>();
|
||||
internal.put("workOrderNo", "WO202609220001");
|
||||
internal.put("source", "MANUAL");
|
||||
internal.put("quoteId", "AIR-1");
|
||||
internal.put("totalSalePrice", "14809.6");
|
||||
internal.put("currency", "CNY");
|
||||
internal.put("departureCharges", java.util.Collections.emptyList());
|
||||
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
|
||||
assertEquals("MANUAL", body.get("source"));
|
||||
assertEquals("AIR-1", body.get("quoteId"));
|
||||
assertEquals(new java.math.BigDecimal("14809.6"), body.get("totalSalePrice"));
|
||||
assertEquals(null, body.get("departureCharges"));
|
||||
assertEquals(null, body.get("feeItems"));
|
||||
assertEquals(null, body.get("flightNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
|
||||
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
@@ -24,8 +24,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
trade_terms: "贸易条款",
|
||||
cargo_ready_date: "货好时间",
|
||||
pieces: "件数",
|
||||
weight_kg: "重量",
|
||||
volume_cbm: "体积",
|
||||
weight_kg: "重量(KG)",
|
||||
volume_cbm: "体积(CBM)",
|
||||
total_amount: "报价合计",
|
||||
chargeable_weight_kg: "计费重",
|
||||
delivery_address: "送货地址",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 询价工单列表:对接主账 /inquiry/workOrder。
|
||||
* 六态关单/重试必须调主账;禁止本地改 status 冒充成功。
|
||||
@@ -198,20 +198,20 @@ function requirementSummaryLines(t: any): string[] {
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`毛重 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量 ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积 ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
|
||||
@@ -708,8 +708,8 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>起运 / 目的</label><span>{{ routeText(current) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>件数</label><span>{{ current.pieces }}</span></div>
|
||||
<div class="ticket-detail-item"><label>毛重</label><span>{{ current.weightKg }}</span></div>
|
||||
<div class="ticket-detail-item"><label>体积</label><span>{{ current.volumeCbm }}</span></div>
|
||||
<div class="ticket-detail-item"><label>重量(KG)</label><span>{{ current.weightKg }}</span></div>
|
||||
<div class="ticket-detail-item"><label>体积(CBM)</label><span>{{ current.volumeCbm }}</span></div>
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,8 @@ async function checkPhone(rule, value, callback) {
|
||||
* 判断是否是OAuth2APP环境
|
||||
*/
|
||||
export function isOAuth2AppEnv() {
|
||||
return /wxwork|dingtalk/i.test(navigator.userAgent);
|
||||
// 后台只用账号密码。企微里打开卡片也不能走企业微信授权,否则会跳去授权地址并不断刷新。
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user