后台成本价、销售价、利润点按费用逐行展示,费用合计用报价官方总价。选完线路只关页面。报价单文件名带上模板名,空运起运地和转人工跟对应工单走。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -230,7 +230,7 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
企微内嵌 H5 成功后自动关页。
|
||||
|
||||
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
|
||||
手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样
|
||||
先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。
|
||||
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
|
||||
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
|
||||
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。
|
||||
失败、过期页不要挂这段。
|
||||
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
|
||||
"""
|
||||
@@ -46,30 +47,37 @@ def wecom_auto_close_script() -> str:
|
||||
}};
|
||||
}}
|
||||
|
||||
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) {{}}
|
||||
var closed = false;
|
||||
|
||||
function closeOnce(fn) {{
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {{ fn(); }} catch (e) {{}}
|
||||
}}
|
||||
|
||||
// 手机企微有 WeixinJSBridge:只关当前 H5。再调第二套关闭会把询价小助手整个退出,回到企业微信首页。
|
||||
function closeMobile() {{
|
||||
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
||||
}}
|
||||
|
||||
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
|
||||
function closeDesktop() {{
|
||||
closeOnce(function () {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
|
||||
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
|
||||
}});
|
||||
}}
|
||||
|
||||
function boot() {{
|
||||
if (typeof WeixinJSBridge !== "undefined") {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(closeMobile, {delay});
|
||||
return;
|
||||
}}
|
||||
document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
|
||||
var sawBridge = false;
|
||||
document.addEventListener("WeixinJSBridgeReady", function () {{
|
||||
sawBridge = true;
|
||||
setTimeout(closeMobile, {delay});
|
||||
}}, false);
|
||||
var ready = Promise.resolve();
|
||||
if (window.ww && ww.register && corpId) {{
|
||||
ready = ww.register({{
|
||||
@@ -81,9 +89,17 @@ def wecom_auto_close_script() -> str:
|
||||
}}).catch(function () {{ return null; }});
|
||||
}}
|
||||
ready.then(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(function () {{
|
||||
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
|
||||
if (!closed) closeMobile();
|
||||
return;
|
||||
}}
|
||||
closeDesktop();
|
||||
}}, {delay});
|
||||
}}).catch(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(function () {{
|
||||
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
|
||||
}}, {delay});
|
||||
}});
|
||||
}}
|
||||
|
||||
|
||||
@@ -245,6 +245,10 @@ class HttpLedger:
|
||||
"error": out.get("error") or "NO_TEMPLATE",
|
||||
"message": out.get("message") or FAIL_MESSAGE,
|
||||
}
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
# 主账旧版本仍回 WO_quote.xlsx 时,这里按模板名改成销售看到的名字。
|
||||
out["fileName"] = filename_from_render(work_order_no, out)
|
||||
out.setdefault("needsPdf", str(format).lower() == "pdf")
|
||||
return out
|
||||
|
||||
|
||||
@@ -425,13 +425,16 @@ class MemoryLedger:
|
||||
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE, "facts": dict(facts or {})}
|
||||
self.last_render = out
|
||||
return out
|
||||
ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
|
||||
from agent.quote_templates.filename import quote_sheet_filename
|
||||
|
||||
suffix = ".pdf" if str(format).lower() == "pdf" else ".xlsx"
|
||||
out = {
|
||||
"ok": True,
|
||||
"matchType": picked.match_type,
|
||||
"templateId": picked.template_id,
|
||||
"templateName": picked.name,
|
||||
"fileName": f"{work_order_no}_quote.{ext}",
|
||||
"fileName": quote_sheet_filename(work_order_no, picked.name, suffix=suffix)
|
||||
or f"{work_order_no}_quote{suffix}",
|
||||
"fileBase64": "UEsDBAoAAAAAA",
|
||||
"needsPdf": False,
|
||||
"facts": dict(facts or {}),
|
||||
|
||||
@@ -57,6 +57,7 @@ _OPTIONAL_INQUIRY_LABELS = (
|
||||
"提货地址(非必填)",
|
||||
"收货地址(非必填)",
|
||||
"贸易条款(非必填)",
|
||||
"起运地(非必填)",
|
||||
"起运港(非必填)",
|
||||
"货源地",
|
||||
"货好时间",
|
||||
@@ -129,6 +130,7 @@ _SHEET_CLEAN_KEYS = frozenset(
|
||||
"整柜或拼柜",
|
||||
"箱型箱量",
|
||||
"贸易条款",
|
||||
"货好时间",
|
||||
"运输分类",
|
||||
"报价日期",
|
||||
*QUOTE_DATE_SYNONYMS,
|
||||
@@ -136,7 +138,52 @@ _SHEET_CLEAN_KEYS = frozenset(
|
||||
"包装类型",
|
||||
}
|
||||
)
|
||||
# 报价单从这里往下是费用和条款,不是询价栏。切掉后再抽字段。
|
||||
_QUOTE_BODY_MARKS = (
|
||||
"报价明细",
|
||||
"报价条件",
|
||||
"预估费用",
|
||||
"空舱费",
|
||||
"托运方",
|
||||
"承运方",
|
||||
)
|
||||
# 表头常被拉成「体 积」「计 费 重」。只收这些词中间的空格,不跨行。
|
||||
_TIGHTEN_LABELS = tuple(
|
||||
sorted(
|
||||
{
|
||||
*_QUOTE_BODY_MARKS,
|
||||
"计费重",
|
||||
"时效要求",
|
||||
"派送地址",
|
||||
"提货地址",
|
||||
"收货地址",
|
||||
"货好时间",
|
||||
"贸易条款",
|
||||
"报价日期",
|
||||
"起运地",
|
||||
"起运港",
|
||||
"目的地",
|
||||
"目的港",
|
||||
"包装类型",
|
||||
"包装方式",
|
||||
"体积",
|
||||
"重量",
|
||||
"品名",
|
||||
"件数",
|
||||
"货源地",
|
||||
"运输方式",
|
||||
},
|
||||
key=len,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
_FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$")
|
||||
_SHEET_STOP = (
|
||||
"报价明细",
|
||||
"报价条件",
|
||||
"预估费用",
|
||||
"计费重",
|
||||
"时效要求",
|
||||
"要求运输时效",
|
||||
"Requested Transit",
|
||||
"费用汇总",
|
||||
@@ -291,6 +338,52 @@ def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
|
||||
return merged
|
||||
|
||||
|
||||
def prepare_sheet_oral(text: str) -> str:
|
||||
"""
|
||||
报价单只留询价栏。
|
||||
|
||||
费用明细、报价条件、落款整段丢掉。表头里被空格拉开的「体 积」收回成「体积」,
|
||||
否则上一栏会把后面的报价条件整段吞进去。计费重不是毛重,单独拿掉。
|
||||
副作用:无。
|
||||
"""
|
||||
body = _tighten_sheet_labels(text or "")
|
||||
cut = len(body)
|
||||
for mark in _QUOTE_BODY_MARKS:
|
||||
pos = body.find(mark)
|
||||
if 0 <= pos < cut:
|
||||
cut = pos
|
||||
body = body[:cut]
|
||||
body = re.sub(r"计费重\s*[::]?\s*[0-9.]+\s*(?:KG|KGS|kg|公斤)?", " ", body)
|
||||
return body.strip()
|
||||
|
||||
|
||||
def _tighten_sheet_labels(text: str) -> str:
|
||||
"""已知表头中间只允许空格。不跨行,避免把两行并成一个词。"""
|
||||
body = text or ""
|
||||
gap = r"[ \t\u3000]{0,6}"
|
||||
for label in _TIGHTEN_LABELS:
|
||||
if len(label) < 2:
|
||||
continue
|
||||
pat = re.compile(
|
||||
r"(?<![\u4e00-\u9fffA-Za-z0-9])" + gap.join(re.escape(ch) for ch in label)
|
||||
)
|
||||
body = pat.sub(label, body)
|
||||
return body
|
||||
|
||||
|
||||
def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]:
|
||||
"""起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。"""
|
||||
out = dict(facts or {})
|
||||
for key in ("起运港", "起运地", "目的港", "目的地"):
|
||||
val = str(out.get(key) or "").strip()
|
||||
if not val:
|
||||
continue
|
||||
bits = [p.strip(" *::\t") for p in re.split(r"[\n/、,,]+", val) if p.strip()]
|
||||
if bits and all(_FEE_NAME.match(p) for p in bits):
|
||||
out.pop(key, None)
|
||||
return out
|
||||
|
||||
|
||||
def extract_inquiry_snapshot(
|
||||
text: str,
|
||||
*,
|
||||
@@ -317,7 +410,7 @@ def extract_inquiry_snapshot(
|
||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _clip_sheet_inquiry_facts(facts)
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
subtype = str(facts.get("运输类型") or "").strip()
|
||||
return {
|
||||
"business_line": mode,
|
||||
@@ -325,6 +418,8 @@ def extract_inquiry_snapshot(
|
||||
"facts": facts,
|
||||
"source": "injected",
|
||||
}
|
||||
# 附件常是整张报价单。先丢掉费用明细和条款,再抽询价栏。
|
||||
text = prepare_sheet_oral(text)
|
||||
mode = detect_transport_mode(text)
|
||||
facts = parse_labeled_facts(text)
|
||||
source = "labeled_or_mode"
|
||||
@@ -361,7 +456,7 @@ def extract_inquiry_snapshot(
|
||||
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
|
||||
if str(facts.get(key) or "").strip():
|
||||
facts[key] = clip_inquiry_value(str(facts[key]))
|
||||
facts = _clip_sheet_inquiry_facts(facts)
|
||||
facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
|
||||
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
|
||||
if land_subtype and not str(facts.get("运输类型") or "").strip():
|
||||
facts["运输类型"] = land_subtype
|
||||
@@ -453,6 +548,10 @@ def clip_sheet_field_value(raw: str) -> str:
|
||||
parts = [p for p in parts if p and not _SHEET_HEADER_NOISE.search(p)]
|
||||
if not parts:
|
||||
return ""
|
||||
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
|
||||
token = parts[0].strip().strip("::")
|
||||
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
|
||||
return ""
|
||||
return parts[0].strip()
|
||||
|
||||
|
||||
|
||||
@@ -337,13 +337,134 @@ class AirTextInquiryFlow:
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return None
|
||||
|
||||
def _handoff_flows(self) -> list[Any]:
|
||||
"""陆运、海运、空运各查自己的工单索引。当前流程放前面,避免单测实例对不上全局。"""
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
flows: list[Any] = [self]
|
||||
for flow in (get_land_text_flow(), get_sea_text_flow(), get_air_text_flow()):
|
||||
if all(flow is not item for item in flows):
|
||||
flows.append(flow)
|
||||
return flows
|
||||
|
||||
def _handoff_owner_for_line(self, line: str):
|
||||
"""
|
||||
转人工入口跟工单运输方式走,不跟当前私聊里的另一张单走。
|
||||
|
||||
空运、海运、陆运各用自己的流程收书签。对不上就返回空,不能默认成空运。
|
||||
"""
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow, get_land_text_flow
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, get_sea_text_flow
|
||||
|
||||
code = (line or "").strip().upper()
|
||||
if code == "LAND":
|
||||
return self if isinstance(self, LandTextInquiryFlow) else get_land_text_flow()
|
||||
if code == "SEA":
|
||||
return (
|
||||
self
|
||||
if isinstance(self, SeaTextInquiryFlow)
|
||||
and not isinstance(self, LandTextInquiryFlow)
|
||||
else get_sea_text_flow()
|
||||
)
|
||||
if code == "AIR":
|
||||
return self if type(self) is AirTextInquiryFlow else get_air_text_flow()
|
||||
return None
|
||||
|
||||
def _offer_handoff_for_named_ticket(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
work_order_no: str,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
) -> str:
|
||||
"""
|
||||
按原话里的工单号转人工。
|
||||
|
||||
有没有协同群只决定拦不拦。话术只看这张单是空运、海运还是陆运。
|
||||
销售私聊里另一张已建群的单,不能拿来拒绝,也不能拿来决定话术。
|
||||
副作用:命中后把这张单收到对应运输方式的转人工入口;读主账。不建群。
|
||||
"""
|
||||
from agent.policy.handoff_human import is_closed_for_handoff
|
||||
|
||||
no = (work_order_no or "").strip().upper()
|
||||
hit = None
|
||||
hit_flow = None
|
||||
for flow in self._handoff_flows():
|
||||
found = flow.session_by_ticket(sender_id, no)
|
||||
if found is None:
|
||||
continue
|
||||
hit = found
|
||||
hit_flow = flow
|
||||
break
|
||||
getter = getattr((hit_flow or self)._ledger, "get_for_agent", None)
|
||||
view: dict[str, Any] = {}
|
||||
if callable(getter):
|
||||
try:
|
||||
view = getter(work_order_no=no, sender_id=sender_id) or {}
|
||||
except Exception:
|
||||
logger.exception("转人工读主账失败 wo=%s", no)
|
||||
view = {}
|
||||
if view.get("found") and view.get("owned") is False:
|
||||
reply("这张工单不属于当前账号,不能转人工。")
|
||||
return "ticket_forbidden"
|
||||
if hit is not None and view.get("found"):
|
||||
# 主账上的运输方式和群号盖过进程里的旧书签。
|
||||
hit.collab_chat_id = str(view.get("collab_chat_id") or "").strip()
|
||||
if str(view.get("status") or "").strip():
|
||||
hit.status = str(view.get("status") or "").strip()
|
||||
if str(view.get("business_line") or "").strip():
|
||||
hit.business_line = str(view.get("business_line") or "").strip().upper()
|
||||
if hit is None:
|
||||
if not callable(getter):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
return "ticket_missing"
|
||||
if not view.get("found"):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
return "ticket_missing"
|
||||
line = str(view.get("business_line") or "").strip().upper()
|
||||
owner = self._handoff_owner_for_line(line)
|
||||
if owner is None:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
return "need_mode"
|
||||
hit = owner._session_from_ledger(sender_id=sender_id, view=view)
|
||||
line = (hit.business_line or "").strip().upper()
|
||||
owner = self._handoff_owner_for_line(line)
|
||||
if owner is None:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
return "need_mode"
|
||||
logger.info(
|
||||
"handoff.named wo=%s line=%s phase=%s chat=%s",
|
||||
no,
|
||||
line or "-",
|
||||
hit.phase or "-",
|
||||
"yes" if (hit.collab_chat_id or "").strip() else "no",
|
||||
)
|
||||
if is_closed_for_handoff(hit.status):
|
||||
reply("该工单已结束,不能再转人工。")
|
||||
return "handoff_closed"
|
||||
if owner._block_if_handoff_committed(hit, reply):
|
||||
return "handoff_done"
|
||||
return owner._offer_handoff_now(
|
||||
sender_id=sender_id, reason="intent", reply=reply, text=text, sess=hit
|
||||
)
|
||||
|
||||
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
|
||||
"""
|
||||
路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。
|
||||
|
||||
原话里写了工单号时,只处理那一张。
|
||||
有没有协同群只决定拦不拦;空运、海运、陆运各走各的转人工。
|
||||
"""
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.policy.handoff_human import is_closed_for_handoff
|
||||
|
||||
named = copy.extract_work_order_no(text)
|
||||
if named:
|
||||
return self._offer_handoff_for_named_ticket(
|
||||
sender_id=sender_id, work_order_no=named, text=text, reply=reply
|
||||
)
|
||||
sess = self.session_of(sender_id)
|
||||
if sess and is_closed_for_handoff(sess.status):
|
||||
reply("该工单已结束,不能再转人工。")
|
||||
@@ -1572,7 +1693,15 @@ class AirTextInquiryFlow:
|
||||
if not result.get("ok"):
|
||||
reply(str(result.get("message") or copy.quote_template_missing()))
|
||||
return "wait_adopt"
|
||||
filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
# 私聊直接生成与群里同一套名字:工单号 + 模板名 + 后缀。PDF 用 .pdf,表格用 .xlsx。
|
||||
xlsx_name = filename_from_render(sess.work_order_no, result, suffix=".xlsx")
|
||||
filename = (
|
||||
filename_from_render(sess.work_order_no, result, suffix=".pdf")
|
||||
if kind == "PDF"
|
||||
else xlsx_name
|
||||
)
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||||
@@ -1582,20 +1711,18 @@ class AirTextInquiryFlow:
|
||||
result,
|
||||
work_order_no=sess.work_order_no,
|
||||
sender_id=sess.sender_id,
|
||||
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
|
||||
filename=filename,
|
||||
quote=dict(sess.quote or {}),
|
||||
facts=dict(sess.facts or {}),
|
||||
)
|
||||
if not queued:
|
||||
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
|
||||
if not fallback.lower().endswith(".xlsx"):
|
||||
fallback = f"{sess.work_order_no}_quote.xlsx"
|
||||
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
|
||||
file_extra = self._quote_file_payload(result, filename=xlsx_name)
|
||||
return self._emit_file_then_deal(
|
||||
sess,
|
||||
reply,
|
||||
kind="Excel",
|
||||
filename=fallback,
|
||||
filename=xlsx_name,
|
||||
file_extra=file_extra,
|
||||
)
|
||||
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
|
||||
|
||||
@@ -454,9 +454,7 @@ def _air_field_line(key: str, value: str, *, required: bool, example: str = "")
|
||||
|
||||
|
||||
def AIR_DISPLAY_LABEL(key: str) -> str:
|
||||
"""核对/补问对外名。起运港按产品口径写起运港。"""
|
||||
if key == "起运港":
|
||||
return "起运港"
|
||||
"""核对/补问对外名。空运起运港对外写起运地,内部键不变。"""
|
||||
return display_name(key, "AIR")
|
||||
|
||||
|
||||
@@ -464,10 +462,11 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
"""
|
||||
确定查价前的核对卡。没有工单号。
|
||||
|
||||
必填 7 项 + 6 个非必填都列出;空着的非必填留空行。
|
||||
起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
lines = ["运输方式(必填):空运"]
|
||||
lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False))
|
||||
for key, _label in AIR_REQUIRED_FIELDS:
|
||||
if key == "报价日期":
|
||||
val = _air_known_value(src, key) or (src.get("报价日期") or "").strip()
|
||||
@@ -475,6 +474,8 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
continue
|
||||
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
|
||||
for key, _label in AIR_OPTIONAL_FIELDS:
|
||||
if key == "起运港":
|
||||
continue
|
||||
lines.append(_air_field_line(key, _air_known_value(src, key), required=False))
|
||||
lines.append("")
|
||||
lines.append(AIR_CONFIRM_TAIL)
|
||||
@@ -523,14 +524,15 @@ def ask_clarify(
|
||||
lines = ["请确认并补充以下信息:"]
|
||||
if transport_mode:
|
||||
lines.append(f"运输方式:{transport_mode}")
|
||||
if not sea:
|
||||
origin = known(src, "起运港")
|
||||
if origin:
|
||||
lines.append(f"起运地:{origin}")
|
||||
for key, label, _example in rows:
|
||||
val = known(src, key)
|
||||
if val:
|
||||
lines.append(f"{label}:{val}")
|
||||
if not sea:
|
||||
origin = known(src, "起运港")
|
||||
if origin:
|
||||
lines.append(f"起运地:{origin}")
|
||||
for key, label, _example in _AIR_OPTIONAL_CLARIFY:
|
||||
if key == "起运港":
|
||||
continue
|
||||
@@ -545,6 +547,10 @@ def ask_clarify(
|
||||
any_missing = True
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, "运输方式", "空运"))
|
||||
if not sea and not known(src, "起运港"):
|
||||
any_missing = True
|
||||
seq += 1
|
||||
lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True))
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
@@ -564,13 +570,12 @@ def ask_clarify(
|
||||
optional_pending = [
|
||||
(key, label, example)
|
||||
for key, label, example in _AIR_OPTIONAL_CLARIFY
|
||||
if not known(src, key)
|
||||
if key != "起运港" and not known(src, key)
|
||||
]
|
||||
for key, label, example in optional_pending:
|
||||
any_missing = True
|
||||
seq += 1
|
||||
shown = "起运港" if key == "起运港" else label
|
||||
lines.append(_pending_field_block(seq, shown, example, optional=True))
|
||||
lines.append(_pending_field_block(seq, label, example, optional=True))
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -778,7 +778,10 @@ def _render_and_send_sheet(
|
||||
if not result.get("ok"):
|
||||
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
|
||||
return result
|
||||
filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
# 群里文件名:工单号 + 后台模板名 + .xlsx。调价后再发也是这个名字。
|
||||
filename = filename_from_render(work_order_no, result)
|
||||
sender = getattr(client, "send_group_file", None)
|
||||
if not callable(sender):
|
||||
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
|
||||
@@ -1039,6 +1042,8 @@ def _persist_quote_sheet(
|
||||
if sess is not None and getattr(sess, "collab_facts", None):
|
||||
collab.update(dict(sess.collab_facts or {}))
|
||||
renderer = getattr(flow.ledger, "render_quote", None)
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
filename = f"{no}_quote.xlsx"
|
||||
object_key = filename
|
||||
line = str(
|
||||
@@ -1062,8 +1067,8 @@ def _persist_quote_sheet(
|
||||
),
|
||||
) or {}
|
||||
if result.get("ok"):
|
||||
filename = str(result.get("fileName") or filename)
|
||||
object_key = str(result.get("objectKey") or result.get("fileName") or filename)
|
||||
filename = filename_from_render(no, result)
|
||||
object_key = str(result.get("objectKey") or filename)
|
||||
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
|
||||
|
||||
|
||||
|
||||
@@ -598,7 +598,14 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
if sess.business_line == "SEA" and sess.phase == "wait_collab":
|
||||
"""
|
||||
海运、陆运停在拉群问句时,继续工单必须把同一张卡拉回来。
|
||||
|
||||
空运父类的无价卡没有「拉产品进群」按钮。陆运以前被当成空运重放,
|
||||
销售继续工单就看不到这张卡。
|
||||
"""
|
||||
line = (sess.business_line or "").upper()
|
||||
if line in {"SEA", "LAND"} and sess.phase == "wait_collab":
|
||||
self._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
@@ -606,10 +613,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
business_line=sess.business_line or "SEA",
|
||||
business_line=line,
|
||||
)
|
||||
return "wait_collab"
|
||||
if sess.business_line == "SEA" and sess.phase == "tms_miss":
|
||||
if line in {"SEA", "LAND"} and sess.phase == "tms_miss":
|
||||
self._emit_tms_miss(reply, sess.work_order_no)
|
||||
return "tms_miss"
|
||||
return super()._replay_wait(sess, reply, source=source)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
系统生成报价单的对外文件名。
|
||||
|
||||
协同群发出、私聊直接生成、调价后再发,都用「工单号 + 后台模板名 + 后缀」。
|
||||
表格是 .xlsx,转成 PDF 再发出去是 .pdf。
|
||||
航线、产品自己上传的附件不走这里,原文件名存后台。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 模板名若自带这些后缀,发出去时去掉。中文名本身不动。
|
||||
_SUFFIXES = (".xlsx", ".xls", ".xlsm", ".pdf")
|
||||
# Windows / 企微文件名不允许的字符。其余(含中文)原样保留。
|
||||
_ILLEGAL = set('\\/:*?"<>|')
|
||||
|
||||
|
||||
def quote_sheet_filename(
|
||||
work_order_no: str,
|
||||
template_name: str,
|
||||
*,
|
||||
suffix: str = ".xlsx",
|
||||
) -> str:
|
||||
"""
|
||||
工单号紧挨后台模板名,再加文件后缀。没有模板名则返回空。
|
||||
|
||||
模板名里如果已经写了 .xlsx / .pdf,先去掉再按这次要发的格式重加,避免两个后缀。
|
||||
副作用:无。纯字符串,可在请求线程调用。
|
||||
"""
|
||||
no = str(work_order_no or "").strip()
|
||||
name = _plain_name(template_name)
|
||||
if not no or not name:
|
||||
return ""
|
||||
return f"{no}{name}{_suffix(suffix)}"
|
||||
|
||||
|
||||
def filename_from_render(
|
||||
work_order_no: str,
|
||||
result: dict[str, Any] | None,
|
||||
*,
|
||||
suffix: str = ".xlsx",
|
||||
) -> str:
|
||||
"""
|
||||
出单结果里的对外文件名。有模板名就用工单号拼接,并带上这次的后缀。
|
||||
|
||||
主账即使仍回 WO_quote.xlsx,只要带了 templateName,销售侧仍是「工单号+模板名.xlsx」。
|
||||
PDF 出站传入 suffix=".pdf"。
|
||||
"""
|
||||
src = result or {}
|
||||
ext = _suffix(suffix)
|
||||
shown = quote_sheet_filename(
|
||||
work_order_no, str(src.get("templateName") or ""), suffix=ext
|
||||
)
|
||||
if shown:
|
||||
return shown
|
||||
raw = _plain_name(str(src.get("fileName") or ""))
|
||||
if raw:
|
||||
return f"{raw}{ext}"
|
||||
no = str(work_order_no or "").strip()
|
||||
return f"{no}_quote{ext}" if no else f"quote{ext}"
|
||||
|
||||
|
||||
def _suffix(suffix: str) -> str:
|
||||
"""只接受已知表格/PDF 后缀,其余一律按 Excel。"""
|
||||
text = str(suffix or "").strip().lower()
|
||||
if not text.startswith("."):
|
||||
text = f".{text}" if text else ""
|
||||
if text in _SUFFIXES:
|
||||
return text
|
||||
return ".xlsx"
|
||||
|
||||
|
||||
def _plain_name(name: str) -> str:
|
||||
"""去掉已知后缀和非法字符。空则返回空。"""
|
||||
text = str(name or "").strip()
|
||||
lower = text.lower()
|
||||
for ext in _SUFFIXES:
|
||||
if lower.endswith(ext):
|
||||
text = text[: -len(ext)].rstrip(" .")
|
||||
break
|
||||
cleaned = "".join(ch for ch in text if ch not in _ILLEGAL and ord(ch) >= 32)
|
||||
return cleaned.strip()
|
||||
@@ -31,7 +31,7 @@ AIR_REQUIRED_FIELDS = (
|
||||
)
|
||||
# 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。
|
||||
AIR_OPTIONAL_FIELDS = (
|
||||
("起运港", "起运港"),
|
||||
("起运港", "起运地"),
|
||||
("货源地", "货源地"),
|
||||
("货好时间", "货好时间"),
|
||||
("提货地址", "提货地址"),
|
||||
@@ -40,7 +40,7 @@ AIR_OPTIONAL_FIELDS = (
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
|
||||
# 补问回显仍用「起运地」,与历史卡片一致。
|
||||
# 内部键仍是起运港(主账/TMS)。销售侧一律写起运地。
|
||||
AIR_DISPLAY["起运港"] = "起运地"
|
||||
|
||||
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
|
||||
@@ -235,6 +235,7 @@ _ALIASES = {
|
||||
"收货地址(非必填)": "收货地址",
|
||||
"贸易条款(非必填)": "贸易条款",
|
||||
"起运港(非必填)": "起运港",
|
||||
"起运地(非必填)": "起运港",
|
||||
}
|
||||
for _quote_alias in QUOTE_DATE_SYNONYMS:
|
||||
_ALIASES[_quote_alias] = "报价日期"
|
||||
@@ -327,7 +328,30 @@ def _quote_date_labeled_value(raw: str) -> str:
|
||||
best_val = hit.group(1).strip().strip(",,。;;、")
|
||||
if not best_val:
|
||||
return ""
|
||||
return _harvest_quote_date(best_val) or best_val
|
||||
return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val
|
||||
|
||||
|
||||
def _excel_serial_date(token: str) -> str:
|
||||
"""
|
||||
Excel 把日期存成序列号时(如 46307),收成 yyyy-MM-dd。
|
||||
|
||||
只认整段就是 5 位数字。件数、重量不走这里。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
text = (token or "").strip()
|
||||
if text.endswith(".0"):
|
||||
text = text[:-2]
|
||||
if not re.fullmatch(r"\d{5}", text):
|
||||
return ""
|
||||
serial = int(text)
|
||||
# 约 1982–2064。再外的 5 位数不当日期。
|
||||
if serial < 30000 or serial > 60000:
|
||||
return ""
|
||||
try:
|
||||
return (datetime(1899, 12, 30) + timedelta(days=serial)).date().isoformat()
|
||||
except OverflowError:
|
||||
return ""
|
||||
|
||||
|
||||
def _harvest_quote_date(raw: str) -> str:
|
||||
|
||||
@@ -113,12 +113,25 @@ class AirFieldContractTests(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
self.assertNotIn("工单号", text)
|
||||
self.assertIn("起运地(非必填):", text)
|
||||
self.assertNotIn("起运港(非必填)", text)
|
||||
self.assertLess(text.index("起运地(非必填):"), text.index("目的地(必填):克拉克"))
|
||||
self.assertIn("目的地(必填):克拉克", text)
|
||||
self.assertIn("起运港(非必填):", text)
|
||||
self.assertIn("货源地(非必填):", text)
|
||||
self.assertIn("贸易条款(非必填):", text)
|
||||
self.assertIn(copy.AIR_CONFIRM_TAIL, text)
|
||||
|
||||
def test_clarify_puts_origin_above_destination(self) -> None:
|
||||
text = copy.ask_clarify(
|
||||
facts={},
|
||||
missing_keys=["目的港", "品名"],
|
||||
transport_mode="空运",
|
||||
)
|
||||
self.assertIn("1.起运地(非必填):\n(如:南京)", text)
|
||||
self.assertIn("2.目的地(必填):\n(如:吉隆坡)", text)
|
||||
self.assertLess(text.index("起运地(非必填)"), text.index("目的地(必填)"))
|
||||
self.assertNotIn("起运港(非必填)", text)
|
||||
|
||||
def test_empty_origin_confirm_then_quote(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
@@ -331,6 +331,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertNotIn("window.close()", ok)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("请确认并补充以下信息:", text)
|
||||
self.assertIn("运输方式:空运", text)
|
||||
self.assertIn("起运地:珠海", text)
|
||||
self.assertLess(text.index("起运地:珠海"), text.index("目的地:克拉克"))
|
||||
self.assertIn("目的地:克拉克", text)
|
||||
self.assertIn("品名:茶叶", text)
|
||||
self.assertIn("件数:10件", text)
|
||||
|
||||
@@ -572,5 +572,58 @@ class SeaSheetHeaderClipTests(unittest.TestCase):
|
||||
self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15")
|
||||
|
||||
|
||||
# 测服 2026-10-05:空运报价单整表被抽进补问,费用和条款进了起运地、货好时间。
|
||||
AIR_QUOTE_SHEET = """
|
||||
运输方式:空运
|
||||
目的地:CRK
|
||||
品名:化妆品
|
||||
件数:11
|
||||
重量:1KG
|
||||
体 积: 100 CBM
|
||||
报价日期:46307
|
||||
货好时间:
|
||||
体 积: 100 CBM 时效要求:
|
||||
计 费 重: 10 KG
|
||||
派送地址:
|
||||
二、报价明细
|
||||
项目 费用名称 单位 币别 单价 数量 金额 备注
|
||||
空运段 空运费
|
||||
起运港 操作费
|
||||
文件费 如需多个分单另计
|
||||
*代理报关费 发生时收取
|
||||
*提货费 发生时收取
|
||||
其他 *其他杂费 发生时收取
|
||||
预估费用 0
|
||||
三、报价条件
|
||||
1、报价适用范围:一般货物(非危险品)
|
||||
贸易条款:DAP
|
||||
托运方:某某有限公司 承运方:深圳市友通达国际货运代理有限公司
|
||||
"""
|
||||
|
||||
|
||||
class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
|
||||
def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
|
||||
"""费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
|
||||
snap = extract_inquiry_snapshot(AIR_QUOTE_SHEET, allow_b=False)
|
||||
facts = snap["facts"]
|
||||
self.assertEqual(snap["business_line"], "AIR")
|
||||
self.assertEqual(facts.get("目的港"), "CRK")
|
||||
self.assertEqual(facts.get("品名"), "化妆品")
|
||||
self.assertIn("11", str(facts.get("件数") or ""))
|
||||
self.assertIn("1", str(facts.get("毛重") or ""))
|
||||
self.assertNotIn("10", str(facts.get("毛重") or ""))
|
||||
self.assertIn("100", str(facts.get("体积") or ""))
|
||||
self.assertEqual(facts.get("报价日期"), "2026-10-12")
|
||||
self.assertNotIn("起运港", facts)
|
||||
self.assertNotIn("货好时间", facts)
|
||||
self.assertNotIn("贸易条款", facts)
|
||||
blob = "\n".join(facts.values())
|
||||
self.assertNotIn("操作费", blob)
|
||||
self.assertNotIn("报价明细", blob)
|
||||
self.assertNotIn("报价条件", blob)
|
||||
self.assertNotIn("空舱", blob)
|
||||
self.assertNotIn("友通达", blob)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -345,5 +345,231 @@ class HandoffFlowOfferTests(unittest.TestCase):
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, bodies)
|
||||
|
||||
|
||||
class NamedTicketHandoffTests(unittest.TestCase):
|
||||
"""点名工单号转人工时,只看那一张单,不看私聊里另一张已建群的单。"""
|
||||
|
||||
def _flow(self):
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
|
||||
return LandTextInquiryFlow(MemoryLedger())
|
||||
|
||||
def test_deal_followup_not_blocked_by_other_group(self) -> None:
|
||||
flow = self._flow()
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="wait_deal",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040002",
|
||||
status="已报价",
|
||||
quote={"total": "CNY 800", "quoteFileIssued": True},
|
||||
)
|
||||
)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="collab_group",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040004",
|
||||
collab_chat_id="wr_other",
|
||||
status="询价中",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(
|
||||
sender_id="s1", text="WO202610040002转人工", reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertIn("运输方式:陆运", replies[-1])
|
||||
self.assertIn("是否拉产品人员进群协同", replies[-1])
|
||||
self.assertNotIn("原有空运群", replies[-1])
|
||||
self.assertNotEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB)
|
||||
named = flow.session_by_ticket("s1", "WO202610040002")
|
||||
self.assertEqual(named.phase, "handoff_offer")
|
||||
self.assertFalse((named.collab_chat_id or "").strip())
|
||||
|
||||
def test_named_ticket_with_group_still_blocked(self) -> None:
|
||||
flow = self._flow()
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="collab_group",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040002",
|
||||
collab_chat_id="wr_real",
|
||||
status="询价中",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(
|
||||
sender_id="s1", text="WO202610040002转人工", reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "handoff_already")
|
||||
self.assertEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB)
|
||||
|
||||
def test_ledger_deal_ticket_without_group_offers_card(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryTicket
|
||||
|
||||
flow = self._flow()
|
||||
no = "WO202610040012"
|
||||
flow.ledger._tickets[no] = MemoryTicket(
|
||||
work_order_no=no,
|
||||
sales_wecom_id="s1",
|
||||
business_line="LAND",
|
||||
facts={"运输方式": "陆运"},
|
||||
fingerprint="land-deal",
|
||||
status="已报价",
|
||||
quote={"total": "CNY 800", "quoteFileIssued": True},
|
||||
quote_file_ready=True,
|
||||
)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="collab_group",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040004",
|
||||
collab_chat_id="wr_other",
|
||||
status="询价中",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertIn("是否拉产品人员进群协同", "\n".join(replies))
|
||||
self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, replies)
|
||||
|
||||
def test_air_deal_ticket_ignores_other_land_group(self) -> None:
|
||||
"""空运成交跟进没有协同群时,不能被另一张陆运已拉群的单挡成「已拉产品进群」。"""
|
||||
from agent.ledger.memory_ledger import MemoryTicket
|
||||
|
||||
flow = self._flow()
|
||||
no = "WO202610040002"
|
||||
flow.ledger._tickets[no] = MemoryTicket(
|
||||
work_order_no=no,
|
||||
sales_wecom_id="s1",
|
||||
business_line="AIR",
|
||||
facts={"运输方式": "空运", "目的港": "CRK"},
|
||||
fingerprint="air-deal",
|
||||
status="已报价",
|
||||
quote={"total": "CNY 800", "quoteFileIssued": True},
|
||||
quote_file_ready=True,
|
||||
)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="collab_group",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040004",
|
||||
collab_chat_id="wr_other",
|
||||
status="询价中",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
|
||||
joined = "\n".join(replies)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertIn("原有空运群", joined)
|
||||
self.assertNotIn("是否拉产品人员进群协同", joined)
|
||||
self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, joined)
|
||||
|
||||
def test_ledger_mode_wins_over_stale_bookmark(self) -> None:
|
||||
"""主账是空运时,进程里标成陆运的旧书签也不能改成陆运拉群卡。"""
|
||||
from agent.ledger.memory_ledger import MemoryTicket
|
||||
|
||||
flow = self._flow()
|
||||
no = "WO202610040002"
|
||||
flow.ledger._tickets[no] = MemoryTicket(
|
||||
work_order_no=no,
|
||||
sales_wecom_id="s1",
|
||||
business_line="AIR",
|
||||
facts={},
|
||||
fingerprint="air-wins",
|
||||
status="已报价",
|
||||
quote={"total": "CNY 800", "quoteFileIssued": True},
|
||||
quote_file_ready=True,
|
||||
)
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="s1",
|
||||
thread_id="s1",
|
||||
phase="wait_deal",
|
||||
business_line="LAND",
|
||||
work_order_no=no,
|
||||
status="已报价",
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
|
||||
joined = "\n".join(replies)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertIn("原有空运群", joined)
|
||||
self.assertNotIn("运输方式:陆运", joined)
|
||||
|
||||
def test_sea_deal_ticket_uses_sea_card(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryTicket
|
||||
|
||||
flow = self._flow()
|
||||
no = "WO202610040013"
|
||||
flow.ledger._tickets[no] = MemoryTicket(
|
||||
work_order_no=no,
|
||||
sales_wecom_id="s1",
|
||||
business_line="SEA",
|
||||
facts={"运输方式": "海运"},
|
||||
fingerprint="sea-deal",
|
||||
status="已报价",
|
||||
quote={"total": "USD 100", "quoteFileIssued": True},
|
||||
quote_file_ready=True,
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
|
||||
joined = "\n".join(replies)
|
||||
self.assertEqual(phase, "handoff_offer")
|
||||
self.assertIn("运输方式:海运", joined)
|
||||
self.assertIn("是否拉产品人员进群协同", joined)
|
||||
self.assertNotIn("原有空运群", joined)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -711,5 +711,69 @@ class LandSkipAfterRestartTests(unittest.TestCase):
|
||||
self.assertIn(f"{wo}报价方案", joined)
|
||||
|
||||
|
||||
class LandContinuePullCardTests(unittest.TestCase):
|
||||
def test_tms_miss_resume_shows_pull_card(self) -> None:
|
||||
"""陆运没查到价时继续工单,要再出「是否拉产品进群」卡,不能出空运无价卡。"""
|
||||
flow = LandTextInquiryFlow(MemoryLedger())
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="land-miss",
|
||||
thread_id="land-miss",
|
||||
phase="tms_miss",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040004",
|
||||
status="询价中",
|
||||
allowed=(copy.BTN_PULL_COLLAB,),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
extras: list[dict] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
extras.append(dict(extra) if extra else {})
|
||||
return ""
|
||||
|
||||
phase = flow.resume_historical(
|
||||
sender_id="land-miss", work_order_no="WO202610040004", reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "tms_miss")
|
||||
joined = "\n".join(replies)
|
||||
self.assertIn("是否需要拉内部产品人员进群", joined)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
buttons = (extras[-1].get("template_card") or {}).get("button_list") or []
|
||||
self.assertTrue(any(copy.BTN_PULL_COLLAB == b.get("text") for b in buttons))
|
||||
|
||||
def test_priced_resume_shows_pull_card(self) -> None:
|
||||
"""陆运已有价、还在问要不要拉群时,继续工单要出带拉群按钮的价格卡。"""
|
||||
flow = LandTextInquiryFlow(MemoryLedger())
|
||||
flow._save(
|
||||
FlowSession(
|
||||
sender_id="land-hit",
|
||||
thread_id="land-hit",
|
||||
phase="wait_collab",
|
||||
business_line="LAND",
|
||||
work_order_no="WO202610040004",
|
||||
status="已报价",
|
||||
quote={"total": "CNY 800", "source_label": "TMS 标准报价"},
|
||||
allowed=(copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB),
|
||||
)
|
||||
)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
phase = flow.resume_historical(
|
||||
sender_id="land-hit", work_order_no="WO202610040004", reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(replies)
|
||||
self.assertIn("是否拉产品人员进群协同", joined)
|
||||
self.assertIn(copy.BTN_PULL_COLLAB, joined)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""系统报价单对外文件名:工单号接后台模板名,并带文件后缀。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.quote_templates.filename import filename_from_render, quote_sheet_filename
|
||||
|
||||
|
||||
class QuoteSheetFilenameTests(unittest.TestCase):
|
||||
def test_joins_work_order_and_backend_template_name(self) -> None:
|
||||
self.assertEqual(
|
||||
quote_sheet_filename("WO202609100001", "陆运国内运输拼车报价单"),
|
||||
"WO202609100001陆运国内运输拼车报价单.xlsx",
|
||||
)
|
||||
|
||||
def test_drops_suffix_on_template_name(self) -> None:
|
||||
self.assertEqual(
|
||||
quote_sheet_filename("WO202609100001", "海运报价单模板.xlsx"),
|
||||
"WO202609100001海运报价单模板.xlsx",
|
||||
)
|
||||
|
||||
def test_pdf_uses_pdf_suffix(self) -> None:
|
||||
self.assertEqual(
|
||||
quote_sheet_filename("WO202609100001", "默认空运", suffix=".pdf"),
|
||||
"WO202609100001默认空运.pdf",
|
||||
)
|
||||
|
||||
def test_render_prefers_template_name_over_old_file_name(self) -> None:
|
||||
shown = filename_from_render(
|
||||
"WO202609100001",
|
||||
{
|
||||
"templateName": "国内运输拼车报价单",
|
||||
"fileName": "WO202609100001_quote.xlsx",
|
||||
},
|
||||
)
|
||||
self.assertEqual(shown, "WO202609100001国内运输拼车报价单.xlsx")
|
||||
|
||||
def test_blank_template_keeps_xlsx_suffix(self) -> None:
|
||||
shown = filename_from_render(
|
||||
"WO202609100001",
|
||||
{"fileName": "WO202609100001_quote.xlsx"},
|
||||
)
|
||||
self.assertEqual(shown, "WO202609100001_quote.xlsx")
|
||||
@@ -2404,7 +2404,8 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(ticket.attachments)
|
||||
self.assertIn("quote", str(ticket.attachments[0].get("file_name") or "").lower())
|
||||
saved_name = str(ticket.attachments[0].get("file_name") or "")
|
||||
self.assertEqual(saved_name, f"{wo}海运默认.xlsx")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
|
||||
self.assertTrue(any("成交" in t for t in texts))
|
||||
|
||||
Reference in New Issue
Block a user