Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b070a9b7a7 | ||
|
|
ae818f26b1 | ||
|
|
b15f64f419 | ||
|
|
2e8918a5c9 | ||
|
|
2aab38da99 | ||
|
|
5e2a3e29aa | ||
|
|
baa4d81be9 | ||
|
|
6141978a9e | ||
|
|
43e633f359 |
@@ -4,7 +4,8 @@
|
||||
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
|
||||
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
|
||||
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
|
||||
电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。
|
||||
电脑企微 5.x 内置页经常加载不了 JS-SDK,也不会打签名接口。
|
||||
这时 ww.closeWindow 不会执行。电脑改用 WeixinJSBridge.invoke,再补 window.close,只关当前页。
|
||||
失败、过期页不要挂这段。
|
||||
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
|
||||
"""
|
||||
@@ -24,7 +25,9 @@ def wecom_auto_close_script() -> str:
|
||||
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
|
||||
|
||||
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
|
||||
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
|
||||
为何这样写:21:27 的上传日志里,成功页已经返回,但没有 jsapi 签名请求。
|
||||
电脑企微没跑起 ww.register,只调 ww.closeWindow 等于没关。
|
||||
电脑改为 invoke + window.close,不依赖签名先成功。手机仍只关一次。
|
||||
"""
|
||||
settings = get_settings()
|
||||
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
|
||||
@@ -49,6 +52,14 @@ def wecom_auto_close_script() -> str:
|
||||
|
||||
var closed = false;
|
||||
|
||||
function isPhone() {{
|
||||
var ua = navigator.userAgent || "";
|
||||
if (/wxwork/i.test(ua) && /Windows|Macintosh/i.test(ua) && !/Android|iPhone|iPad|iPod/i.test(ua)) {{
|
||||
return false;
|
||||
}}
|
||||
return /Android|iPhone|iPad|iPod/i.test(ua);
|
||||
}}
|
||||
|
||||
function closeOnce(fn) {{
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
@@ -60,46 +71,47 @@ def wecom_auto_close_script() -> str:
|
||||
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
||||
}}
|
||||
|
||||
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
|
||||
// 电脑企微:call 关不掉,JS-SDK 又经常没装上。invoke 关内置页,window.close 关独立窗口。
|
||||
function closeDesktop() {{
|
||||
closeOnce(function () {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
|
||||
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
|
||||
}});
|
||||
try {{
|
||||
if (typeof WeixinJSBridge !== "undefined" && typeof WeixinJSBridge.invoke === "function") {{
|
||||
WeixinJSBridge.invoke("closeWindow", {{}}, function () {{}});
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") {{
|
||||
var ret = ww.closeWindow();
|
||||
if (ret && ret.catch) ret.catch(function () {{}});
|
||||
}} else if (window.wx && typeof wx.closeWindow === "function") {{
|
||||
wx.closeWindow();
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{ window.close(); }} catch (e) {{}}
|
||||
}}
|
||||
|
||||
function boot() {{
|
||||
if (typeof WeixinJSBridge !== "undefined") {{
|
||||
setTimeout(closeMobile, {delay});
|
||||
if (isPhone()) {{
|
||||
if (typeof WeixinJSBridge !== "undefined") {{
|
||||
setTimeout(closeMobile, {delay});
|
||||
return;
|
||||
}}
|
||||
document.addEventListener("WeixinJSBridgeReady", function () {{
|
||||
setTimeout(closeMobile, {delay});
|
||||
}}, false);
|
||||
return;
|
||||
}}
|
||||
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({{
|
||||
corpId: corpId,
|
||||
agentId: agentId,
|
||||
jsApiList: ["closeWindow"],
|
||||
getConfigSignature: sign("corp"),
|
||||
getAgentConfigSignature: sign("agent")
|
||||
}}).catch(function () {{ return null; }});
|
||||
}}
|
||||
ready.then(function () {{
|
||||
setTimeout(function () {{
|
||||
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
|
||||
if (!closed) closeMobile();
|
||||
return;
|
||||
}}
|
||||
closeDesktop();
|
||||
}}, {delay});
|
||||
setTimeout(closeDesktop, {delay});
|
||||
if (!(window.ww && ww.register && corpId)) return;
|
||||
ww.register({{
|
||||
corpId: corpId,
|
||||
agentId: agentId,
|
||||
jsApiList: ["closeWindow"],
|
||||
getConfigSignature: sign("corp"),
|
||||
getAgentConfigSignature: sign("agent")
|
||||
}}).then(function () {{
|
||||
setTimeout(closeDesktop, {delay});
|
||||
}}).catch(function () {{
|
||||
setTimeout(function () {{
|
||||
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
|
||||
}}, {delay});
|
||||
setTimeout(closeDesktop, {delay});
|
||||
}});
|
||||
}}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
意图:空运协同群入站(BOT @ 之后的文字或附件)。
|
||||
|
||||
本文件职责:激活/切单后交给 air_group_ops。海运应用群不进这里。
|
||||
禁止:存档文字当业务;锁舱用当前工单去猜;套 sea_group_ops 建群。
|
||||
多段单已在本群激活后,没带工单号的后续消息跟这张多段单,不再要求重发工单号。
|
||||
禁止:存档文字当业务;纯空运锁舱用当前工单去猜;套 sea_group_ops 建群。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +22,8 @@ from agent.policy.air_group_ops import (
|
||||
apply_air_file,
|
||||
apply_air_lock,
|
||||
apply_air_quote,
|
||||
lock_params_from_text,
|
||||
text_has_lock_supplement,
|
||||
apply_air_release,
|
||||
apply_air_tms_choice,
|
||||
current_ticket,
|
||||
@@ -56,6 +59,54 @@ from agent.routing.air_group_intent import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _follow_activated_multi(
|
||||
engine,
|
||||
*,
|
||||
chat_id: str,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
injected_quote: Optional[dict[str, Any]],
|
||||
named_wo: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
多段单激活后,后续消息跟这张单,不必再带工单号。
|
||||
|
||||
句中写了工单号就认那张;没写就认本群已经绑上的多段单。
|
||||
纯空运、陆运旧单返回 None,仍走空运自己的当前单。
|
||||
副作用:可能发群消息、写入分段报价。在入站线程调用。
|
||||
"""
|
||||
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
|
||||
from agent.policy.sea_group_ops import ticket_no
|
||||
|
||||
ticket = None
|
||||
named = (named_wo or "").strip()
|
||||
if named:
|
||||
getter = getattr(engine.ledger, "get_ticket", None)
|
||||
raw = getter(work_order_no=named) if callable(getter) else None
|
||||
if raw is not None and ticket_is_multi(raw):
|
||||
ticket = raw
|
||||
else:
|
||||
bound = current_ticket(engine, chat_id)
|
||||
if bound is not None and ticket_is_multi(bound):
|
||||
ticket = bound
|
||||
if ticket is None:
|
||||
return None
|
||||
logger.info(
|
||||
"air_group 多段跟单 wo=%s chat=%s named=%s",
|
||||
ticket_no(ticket),
|
||||
chat_id,
|
||||
"1" if named else "0",
|
||||
)
|
||||
return handle_multi_inbound(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
chat_id=chat_id,
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
|
||||
|
||||
def _attach_bot_client(engine, message: InboundMessage) -> None:
|
||||
"""
|
||||
每次入站换一次 BOT 出站客户端。
|
||||
@@ -116,20 +167,18 @@ def handle_air_group(
|
||||
return "duplicate_inbound"
|
||||
mark_air_text(chat_id=chat_id, sender_id=message.sender_id, text=text)
|
||||
wo = copy.extract_work_order_no(text)
|
||||
# 句中有多段工单号,或本群已经激活过多段单:跟这张单,不必每句再带工单号。
|
||||
followed = _follow_activated_multi(
|
||||
engine,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=text,
|
||||
injected_quote=injected_quote,
|
||||
named_wo=wo,
|
||||
)
|
||||
if followed is not None:
|
||||
return followed
|
||||
if wo:
|
||||
from agent.policy.multi_group_ops import handle_multi_inbound, ticket_is_multi
|
||||
|
||||
getter = getattr(engine.ledger, "get_ticket", None)
|
||||
raw_ticket = getter(work_order_no=wo) if callable(getter) else None
|
||||
if raw_ticket is not None and ticket_is_multi(raw_ticket):
|
||||
return handle_multi_inbound(
|
||||
flow=engine,
|
||||
ticket=raw_ticket,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=text,
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
from agent.policy.handoff_human import try_commit_air_group_handoff
|
||||
|
||||
handed = try_commit_air_group_handoff(engine, chat_id=chat_id, work_order_no=wo)
|
||||
@@ -182,10 +231,18 @@ def handle_air_group(
|
||||
if is_handoff_ticket(ticket):
|
||||
logger.info("air_group 转人工不再跟 chat=%s", chat_id)
|
||||
return "handoff_ignore"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
from agent.policy.system_exception import (
|
||||
cabin_failure_pause,
|
||||
reply_paused_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
|
||||
# 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。
|
||||
if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent):
|
||||
# 查价失败的暂停仍要拦住。锁舱/释放失败已经告诉过原因,不能挡住后面的报价和成交。
|
||||
if (
|
||||
ticket_is_paused(ticket)
|
||||
and not cabin_failure_pause(ticket)
|
||||
and not _deal_ignores_tms_pause(ticket, intent)
|
||||
):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
|
||||
@@ -212,6 +269,17 @@ def _handle_file(engine, message: InboundMessage) -> str:
|
||||
wo = copy.extract_work_order_no(
|
||||
" ".join([message.content or "", filename])
|
||||
)
|
||||
# 多段已激活的附件也跟当前单,不能再要求先发工单号。
|
||||
followed = _follow_activated_multi(
|
||||
engine,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=" ".join(part for part in ((message.content or "").strip(), filename) if part),
|
||||
injected_quote=None,
|
||||
named_wo=wo,
|
||||
)
|
||||
if followed is not None:
|
||||
return followed
|
||||
if wo:
|
||||
ticket, phase = activate_ticket(
|
||||
flow=engine, chat_id=chat_id, work_order_no=wo, speak=False
|
||||
@@ -252,6 +320,29 @@ def _handle_cabin(
|
||||
_ = injected_quote
|
||||
chat_id = message.chat_id
|
||||
if not work_order_no:
|
||||
# 本群已经在锁这张单时,补舱位不用再报工单号。
|
||||
bound = current_ticket(engine, chat_id)
|
||||
if bound is not None and is_air_ticket(bound):
|
||||
ticket = bound
|
||||
if intent == INTENT_RELEASE:
|
||||
return apply_air_release(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
option_no, airline, flight_no = lock_params_from_text(message.content or "")
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=option_no,
|
||||
airline=airline,
|
||||
flight_no=flight_no,
|
||||
)
|
||||
send_group_text(group_client_of(engine), chat_id, copy.air_lock_need_work_order())
|
||||
return "cabin_need_work_order"
|
||||
ticket, phase = resolve_cabin_ticket(
|
||||
@@ -270,15 +361,17 @@ def _handle_cabin(
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
|
||||
# 「锁 AIR-OPT01 CZ 航班CZ327」要带上舱位、航司、航班号。缺了先补,不能直接锁。
|
||||
option_no, airline, flight_no = lock_params_from_text(message.content or "")
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=_option_from_text(message.content or ""),
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
option_no=option_no,
|
||||
airline=airline,
|
||||
flight_no=flight_no,
|
||||
)
|
||||
|
||||
|
||||
@@ -315,19 +408,20 @@ def _handle_bound(
|
||||
if lost:
|
||||
return lost
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if intent == INTENT_PICK_OPTION or (
|
||||
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
|
||||
):
|
||||
option = _option_from_text(message.content or "")
|
||||
if option:
|
||||
pending_lock = str(facts.get("__pending_air_lock") or "") == "1"
|
||||
# 等补舱位时,报价/选线路等意图让路:话里带了舱位、航司或航班号就继续锁。
|
||||
if intent == INTENT_PICK_OPTION or pending_lock or text_has_lock_supplement(message.content or ""):
|
||||
option_no, airline, flight_no = lock_params_from_text(message.content or "")
|
||||
if option_no or airline or flight_no:
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=option,
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
option_no=option_no,
|
||||
airline=airline,
|
||||
flight_no=flight_no,
|
||||
)
|
||||
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
|
||||
outcome = {
|
||||
@@ -363,6 +457,18 @@ def _handle_bound(
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
from agent.policy.sea_group_ops import is_sales
|
||||
from agent.routing.group_intent import resolve_price_intent
|
||||
|
||||
# 航线写出「空运费改为900」是报价。模型若判成调价,这里按说话人改回来。
|
||||
priced = resolve_price_intent(
|
||||
intent,
|
||||
message.content or "",
|
||||
sales=is_sales(ticket, message.sender_id),
|
||||
)
|
||||
if priced != intent:
|
||||
logger.info("air_group 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
|
||||
intent = priced
|
||||
if intent == INTENT_ADJUST:
|
||||
from agent.policy.quote_adjust_ops import apply_group_adjust
|
||||
|
||||
@@ -390,26 +496,3 @@ def _handle_bound(
|
||||
return "group_ignore"
|
||||
|
||||
|
||||
def _option_from_text(text: str) -> str:
|
||||
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
|
||||
import re
|
||||
|
||||
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
|
||||
if not m:
|
||||
return ""
|
||||
raw = m.group(0)
|
||||
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
|
||||
if raw.startswith("AIROPT"):
|
||||
return "AIR-OPT" + raw[6:]
|
||||
return raw
|
||||
|
||||
|
||||
def _airline_from_text(text: str) -> str:
|
||||
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
|
||||
import re
|
||||
|
||||
blob = (text or "").upper()
|
||||
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
@@ -103,7 +103,7 @@ def handle_group_inbound(
|
||||
or ""
|
||||
).upper()
|
||||
if line == "MULTI":
|
||||
from agent.policy.multi_group_ops import handle_multi_inbound, sender_mode
|
||||
from agent.policy.multi_group_ops import bot_sender_when_air_multi, handle_multi_inbound, sender_mode
|
||||
|
||||
if not (
|
||||
_sender_allowed(ticket, message.sender_id)
|
||||
@@ -111,22 +111,29 @@ def handle_group_inbound(
|
||||
):
|
||||
logger.info("group_inbound 多段忽略非成员 sender=%s", message.sender_id)
|
||||
return "group_ignore"
|
||||
return handle_multi_inbound(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
# 会话存档听到的报价也走这里。含空运时群是机器人建的,必须改走桥才能发得出去。
|
||||
with bot_sender_when_air_multi(ticket):
|
||||
return handle_multi_inbound(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
chat_id=chat_id,
|
||||
sender_id=message.sender_id,
|
||||
text=message.content or "",
|
||||
injected_quote=injected_quote,
|
||||
)
|
||||
ticket = hydrate_ticket(engine, ticket)
|
||||
if not _sender_allowed(ticket, message.sender_id):
|
||||
logger.info("group_inbound 忽略非成员 sender=%s chat=%s", message.sender_id, chat_id)
|
||||
return "group_ignore"
|
||||
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
from agent.policy.system_exception import (
|
||||
cabin_failure_pause,
|
||||
reply_paused_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
# 锁舱失败不挡海运/陆运群里的后续报价。查价失败的暂停仍要停。
|
||||
if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
|
||||
@@ -236,6 +243,18 @@ def handle_group_inbound(
|
||||
sender_id=message.sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
from agent.policy.sea_group_ops import is_sales
|
||||
from agent.routing.group_intent import resolve_price_intent
|
||||
|
||||
# 产品写出「报关费改为300」是报价。不是这张单的销售时,不能回调价话术。
|
||||
priced = resolve_price_intent(
|
||||
intent,
|
||||
message.content or "",
|
||||
sales=is_sales(ticket, message.sender_id),
|
||||
)
|
||||
if priced != intent:
|
||||
logger.info("group_inbound 报价/调价改判 %s -> %s chat=%s", intent, priced, chat_id)
|
||||
intent = priced
|
||||
if intent == INTENT_ADJUST:
|
||||
from agent.policy.quote_adjust_ops import apply_group_adjust
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ def handle_text_inquiry(
|
||||
return info if ok else ""
|
||||
|
||||
from agent.policy.system_exception import (
|
||||
cabin_failure_pause,
|
||||
extract_work_order_nos,
|
||||
reply_paused_ticket,
|
||||
ticket_is_paused,
|
||||
@@ -420,7 +421,7 @@ def handle_text_inquiry(
|
||||
getter = getattr(ledger, "get_for_agent", None)
|
||||
if callable(getter):
|
||||
ticket = getter(work_order_no=no, sender_id=message.sender_id)
|
||||
if ticket_is_paused(ticket):
|
||||
if ticket_is_paused(ticket) and not cabin_failure_pause(ticket):
|
||||
reply_paused_ticket(ticket=ticket, reply=reply)
|
||||
logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id)
|
||||
return "system_exception_paused"
|
||||
|
||||
@@ -18,6 +18,11 @@ from agent.policy.event_exception import (
|
||||
card_title,
|
||||
conversation_of,
|
||||
exception_type,
|
||||
WAIT_ADOPT,
|
||||
WAIT_AIR_QUOTE,
|
||||
WAIT_DEAL,
|
||||
WAIT_LOST_REASON,
|
||||
mentions_for_role,
|
||||
pick_app_targets,
|
||||
pick_mentions,
|
||||
timeout_code,
|
||||
@@ -25,6 +30,9 @@ from agent.policy.event_exception import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。
|
||||
_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
|
||||
|
||||
SendGroupFn = Callable[..., bool]
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
||||
@@ -115,6 +123,35 @@ def notify_app_flag(override: Optional[bool]) -> bool:
|
||||
return notify_app_ready()
|
||||
|
||||
|
||||
def _skip_sales_timeout(
|
||||
*,
|
||||
status: str,
|
||||
wait_kind: str,
|
||||
next_followup_at: str,
|
||||
lost_reason: str,
|
||||
now: datetime,
|
||||
) -> str:
|
||||
"""
|
||||
成交跟进相关等待在终态/顺延窗内不应再告警。
|
||||
|
||||
返回跳过原因;空串表示继续按小时判定。
|
||||
协商中:只要还排着再问时刻(next_followup_at > now),就不发销售超时;
|
||||
再问认领后会清空该时刻并重新 start deal,那时才重新计时。
|
||||
"""
|
||||
st = (status or "").strip()
|
||||
_ = wait_kind
|
||||
if st == "未成交" and (lost_reason or "").strip():
|
||||
return "lost_reason_recorded"
|
||||
if st == "协商中":
|
||||
raw = (next_followup_at or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
due = _parse_started(raw)
|
||||
if due is None or due > now:
|
||||
return "negotiate_followup_pending"
|
||||
return ""
|
||||
|
||||
|
||||
def _handle_one(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
@@ -131,6 +168,14 @@ def _handle_one(
|
||||
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
||||
if not no or not kind:
|
||||
return False
|
||||
status = str(row.get("status") or "").strip()
|
||||
followup = str(row.get("next_followup_at") or row.get("nextFollowupAt") or "").strip()
|
||||
lost_reason = str(row.get("lost_reason") or row.get("lostReason") or "").strip()
|
||||
# 产品口径:协商中顺延再问期间不算「销售无响应」;未成交已写原因不再催。
|
||||
# 即使 clear_wait 失败留下陈旧 deal 等待,这里也要挡住(WO202610060024 / WO202610070034)。
|
||||
skip_reason = _skip_sales_timeout(
|
||||
status=status, wait_kind=kind, next_followup_at=followup, lost_reason=lost_reason, now=now
|
||||
)
|
||||
line = str(row.get("business_line") or row.get("businessLine") or "").strip()
|
||||
facts = dict(row.get("facts") or {})
|
||||
klass = str(facts.get("运输分类") or facts.get("transportType") or "").strip()
|
||||
@@ -146,22 +191,23 @@ def _handle_one(
|
||||
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
||||
if started is None:
|
||||
return False
|
||||
if skip_reason:
|
||||
logger.info("事件异常跳过 wo=%s kind=%s reason=%s", no, kind, skip_reason)
|
||||
return False
|
||||
if workday_elapsed_hours(started, now) < hours:
|
||||
return False
|
||||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
||||
sales = next((dict(w) for w in watchers if str(w.get("roleCode") or "").lower() in {"sales", ""}), None)
|
||||
if sales is None:
|
||||
sales = {
|
||||
"wecomId": str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip(),
|
||||
"name": str(row.get("sales_name") or row.get("salesName") or "销售").strip() or "销售",
|
||||
"roleCode": "sales",
|
||||
"notifyEventException": 0,
|
||||
}
|
||||
sales = _ticket_sales(row, watchers)
|
||||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
||||
# 空运群往往只绑了群号,成员列表是空的,群里就找不到航线岗,话术会落成「相关人员」。
|
||||
# 只补航线超时。销售超时不能改去 @ 航线,只 @ 本单销售。
|
||||
if kind == WAIT_AIR_QUOTE and not people:
|
||||
people = _airline_staff_mentions(book)
|
||||
names = [str(p.get("name") or "").strip() for p in people if str(p.get("name") or "").strip()]
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
where = conversation_of(wait_kind=kind, collab_chat_id=chat_id)
|
||||
mention = where == "group"
|
||||
# 群里用 @。销售超时即使发到私聊,也写成 @销售姓名,不写「您」或「相关人员」。
|
||||
mention = where == "group" or kind in _SALES_WAIT_KINDS
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type=exception_type(kind),
|
||||
work_order_no=no,
|
||||
@@ -208,6 +254,59 @@ def _handle_one(
|
||||
return True
|
||||
|
||||
|
||||
def _ticket_sales(row: dict[str, Any], watchers: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""
|
||||
这张工单的销售。销售超时只 @ 这个人。
|
||||
|
||||
先按工单上的销售企微对上关注人,对不上再用岗位是销售的那条。
|
||||
姓名用工单上的销售名,避免点成「销售」两个字或点到航线。
|
||||
"""
|
||||
sid = str(row.get("sales_wecom_id") or row.get("salesWecomId") or "").strip()
|
||||
sname = str(row.get("sales_name") or row.get("salesName") or "").strip()
|
||||
matched: dict[str, Any] | None = None
|
||||
for raw in watchers:
|
||||
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||||
if sid and uid == sid:
|
||||
matched = dict(raw)
|
||||
break
|
||||
if matched is None and role == "sales":
|
||||
matched = dict(raw)
|
||||
if matched is None:
|
||||
matched = {
|
||||
"wecomId": sid,
|
||||
"roleCode": "sales",
|
||||
"notifyEventException": 0,
|
||||
}
|
||||
name = str(matched.get("name") or "").strip()
|
||||
if sname and (not name or name in {sid, "销售"}):
|
||||
matched["name"] = sname
|
||||
elif not name:
|
||||
matched["name"] = sname or "销售"
|
||||
if sid and not str(matched.get("wecomId") or "").strip():
|
||||
matched["wecomId"] = sid
|
||||
matched.setdefault("roleCode", "sales")
|
||||
return matched
|
||||
|
||||
|
||||
def _airline_staff_mentions(book: Any) -> list[dict[str, Any]]:
|
||||
"""
|
||||
主账按岗位列出启用的航线同事。
|
||||
|
||||
只在群成员里没有航线岗时调用。主账失败或没有这个接口时返回空,话术仍用「相关人员」。
|
||||
不写工单。可与巡检线程并发,每次只读这一单的员工名单。
|
||||
"""
|
||||
lister = getattr(book, "list_staff_by_role", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
try:
|
||||
rows = list(lister(role_code="air") or [])
|
||||
except Exception:
|
||||
logger.exception("事件异常列航线岗失败")
|
||||
return []
|
||||
return mentions_for_role(rows, role_code="air")
|
||||
|
||||
|
||||
def _send_app_cards(
|
||||
*,
|
||||
people: list[dict[str, Any]],
|
||||
|
||||
@@ -439,6 +439,7 @@ class HttpLedger:
|
||||
airline: str = "",
|
||||
flight_no: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""锁舱出站。航班号和航司一并带上,主账缺了这两项不能当锁舱失败。"""
|
||||
body: dict[str, Any] = {
|
||||
"workOrderNo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
@@ -453,7 +454,7 @@ class HttpLedger:
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
body,
|
||||
idem=f"air-lock:{work_order_no}:{option_no}",
|
||||
idem=f"air-lock:{work_order_no}:{option_no}:{(airline or '').strip()}:{(flight_no or '').strip()}",
|
||||
)
|
||||
|
||||
def release_cabin(
|
||||
|
||||
@@ -344,12 +344,23 @@ class MemoryLedger:
|
||||
ticket.next_followup_at = ""
|
||||
if lost_reason:
|
||||
ticket.lost_reason = lost_reason
|
||||
# 成交等待结束;若仍缺原因由智能体再 start lost_reason
|
||||
ticket.event_wait_kind = ""
|
||||
ticket.event_wait_started_at = ""
|
||||
ticket.event_wait_alerted = 0
|
||||
elif to_status == "已成交":
|
||||
ticket.next_followup_at = ""
|
||||
if deal_notify_summary:
|
||||
ticket.deal_notify_summary = deal_notify_summary
|
||||
ticket.event_wait_kind = ""
|
||||
ticket.event_wait_started_at = ""
|
||||
ticket.event_wait_alerted = 0
|
||||
elif to_status == "协商中":
|
||||
ticket.next_followup_at = next_followup_at
|
||||
# 顺延再问期间不盯成交超时;到期再问会重新 start deal
|
||||
ticket.event_wait_kind = ""
|
||||
ticket.event_wait_started_at = ""
|
||||
ticket.event_wait_alerted = 0
|
||||
return {"ok": True, "status": ticket.status}
|
||||
|
||||
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
|
||||
@@ -363,6 +374,9 @@ class MemoryLedger:
|
||||
if not reason:
|
||||
return {"ok": False, "error": "reason_blank"}
|
||||
ticket.lost_reason = reason
|
||||
ticket.event_wait_kind = ""
|
||||
ticket.event_wait_started_at = ""
|
||||
ticket.event_wait_alerted = 0
|
||||
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
@@ -834,7 +848,7 @@ class MemoryLedger:
|
||||
纯人工且视图无 TMS 舱位时,缺 option/航司/航班 → lock_incomplete,
|
||||
对齐主账「现拉仍缺才回」的口径(内存账不会真查 TMS)。
|
||||
"""
|
||||
_ = sender_id, operator_name, instruction_text
|
||||
_ = sender_id, operator_name, instruction_text, airline, flight_no
|
||||
forced = getattr(self, "_cabin_lock_result", None)
|
||||
if isinstance(forced, dict):
|
||||
return dict(forced)
|
||||
@@ -940,6 +954,11 @@ class MemoryLedger:
|
||||
continue
|
||||
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
||||
continue
|
||||
# 未成交已写原因、协商中仍排着再问:不进巡检列表
|
||||
if ticket.status == "未成交" and (ticket.lost_reason or "").strip():
|
||||
continue
|
||||
if ticket.status == "协商中" and (ticket.next_followup_at or "").strip():
|
||||
continue
|
||||
watchers: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
sales_id = (ticket.sales_wecom_id or "").strip()
|
||||
@@ -989,6 +1008,10 @@ class MemoryLedger:
|
||||
"collab_member_ids": list(ticket.collab_member_ids),
|
||||
"facts": dict(ticket.facts),
|
||||
"eventException": ticket.event_exception,
|
||||
"lost_reason": ticket.lost_reason,
|
||||
"lostReason": ticket.lost_reason,
|
||||
"next_followup_at": ticket.next_followup_at,
|
||||
"nextFollowupAt": ticket.next_followup_at,
|
||||
"watchers": watchers,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -477,8 +477,14 @@ def harvest_collab_labels(text: str) -> dict[str, str]:
|
||||
|
||||
「客户名称张三 包装方式编织袋 货值 10万」切成三项。
|
||||
只说「纸箱」没有标签则不收。值取到下一个标签之前。
|
||||
「(如:随时可提)」整行丢掉,避免补问示例被当成已填。
|
||||
"""
|
||||
return _harvest_labels(text, _COLLAB_LABELS)
|
||||
kept = "\n".join(
|
||||
line
|
||||
for line in (text or "").splitlines()
|
||||
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
|
||||
)
|
||||
return _harvest_labels(kept, _COLLAB_LABELS)
|
||||
|
||||
|
||||
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
|
||||
@@ -508,7 +514,11 @@ def clip_inquiry_value(raw: str) -> str:
|
||||
if hit and hit.start() > 0:
|
||||
text = text[: hit.start()]
|
||||
text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text)
|
||||
return text.strip()
|
||||
text = text.strip()
|
||||
# 复制补问清单时空着的下一行是「4.提货地址」。切到下一栏后只剩编号,不能当成货好时间。
|
||||
if re.fullmatch(r"\d+[\.、.]", text):
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def clip_sheet_field_value(raw: str) -> str:
|
||||
@@ -549,9 +559,12 @@ def clip_sheet_field_value(raw: str) -> str:
|
||||
if not parts:
|
||||
return ""
|
||||
# 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
|
||||
# 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。
|
||||
token = parts[0].strip().strip("::")
|
||||
if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
|
||||
return ""
|
||||
if re.fullmatch(r"\d+[\.、.]", token):
|
||||
return ""
|
||||
return parts[0].strip()
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
||||
@@ -44,6 +45,9 @@ AIRLINE_ROLE = "air"
|
||||
|
||||
_PENDING_QUOTE = "__pending_air_quote"
|
||||
_PENDING_LOCK = "__pending_air_lock"
|
||||
_PENDING_LOCK_OPTION = "__pending_lock_option"
|
||||
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
|
||||
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
|
||||
_LOCK_ID = "__air_lock_id"
|
||||
_LOCK_OPTION = "__air_lock_option"
|
||||
_LOCK_QUOTE = "__air_lock_quote"
|
||||
@@ -117,7 +121,7 @@ def activate_ticket(
|
||||
把本群当前工单切到指定空运未结案单。
|
||||
|
||||
返回 (工单, phase)。失败时工单为 None。
|
||||
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
|
||||
已经在成交跟进,或航线/产品价已经落账时,再发工单号只回到成交跟进,不再发工单摘要和模板。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -175,23 +179,45 @@ def activate_ticket(
|
||||
|
||||
def _air_already_quoted_for_deal(ticket: object) -> bool:
|
||||
"""
|
||||
航线报价已经落账,再激活应回成交跟进。
|
||||
再激活应回成交跟进,而不是重发工单摘要和空运模板。
|
||||
|
||||
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。
|
||||
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。
|
||||
报价单已经发给销售(quoteFileIssued)或主账停在 wait_deal 时,
|
||||
即使报价来源仍是 TMS 标准价,也已经在成交跟进。
|
||||
协商中、航线/产品价、销售调价同样只回成交跟进。
|
||||
报价单还没出、也没在等成交时,仍发模板给航线填。
|
||||
"""
|
||||
status = ticket_status(ticket)
|
||||
if status == "协商中":
|
||||
return True
|
||||
view = ticket_view(ticket)
|
||||
phase = str(view.get("wait_phase") or view.get("waitPhase") or "").strip()
|
||||
if phase == "wait_deal":
|
||||
return True
|
||||
quote = dict(view.get("quote") or {})
|
||||
if _quote_file_issued(quote):
|
||||
return True
|
||||
if status != "已报价":
|
||||
return False
|
||||
quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
label = str(quote.get("source_label") or quote.get("sourceLabel") or "")
|
||||
if "航线" in label or "产品" in label:
|
||||
return True
|
||||
return copy.quote_is_sales_adjust(quote)
|
||||
|
||||
|
||||
def _quote_file_issued(quote: dict[str, Any]) -> bool:
|
||||
"""报价单已发给销售,且没有被作废。作废后重新激活仍要发模板。"""
|
||||
if _flag_on(quote.get("quoteFileVoided") or quote.get("quote_file_voided")):
|
||||
return False
|
||||
return _flag_on(quote.get("quoteFileIssued") or quote.get("quote_file_issued"))
|
||||
|
||||
|
||||
def _flag_on(val: object) -> bool:
|
||||
"""主账布尔可能是 true / \"true\" / 1。"""
|
||||
if isinstance(val, str):
|
||||
return val.strip().lower() in {"1", "true", "yes"}
|
||||
return bool(val)
|
||||
|
||||
|
||||
def _collab_slot_empty(val: object) -> bool:
|
||||
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
|
||||
return str(val or "").strip() in {"", "-", "—", "-"}
|
||||
@@ -842,80 +868,45 @@ def apply_air_lock(
|
||||
instruction: str,
|
||||
option_no: str = "",
|
||||
airline: str = "",
|
||||
flight_no: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
空运群锁舱。
|
||||
航线锁舱。
|
||||
|
||||
副作用:调主账 lockCabin(HMAC 出站 TMS);成功写协同 facts、推成交跟进。
|
||||
舱位/航司/航班交给主账现拉 TMS 补全;仅计费重全空时群里补问。
|
||||
主账仍缺则回 lock_incomplete,禁止空壳出站。
|
||||
舱位编号、航司、航班号没齐时只请补全,不调 TMS 锁舱,不标系统异常。
|
||||
三项齐了(话里写的优先,报价里已有的可以补上)才打主账锁舱。
|
||||
上次锁舱失败标过暂停的,航线再锁仍要打 TMS。
|
||||
"""
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
# 锁舱是航线主动再试:即使工单因上次失败标了系统异常,也要再打 TMS,
|
||||
# 不能只回「TMS接口异常,IT运维排查中」。
|
||||
no = ticket_no(ticket)
|
||||
name = operator_name(flow, sender_id)
|
||||
view = ticket_view(ticket)
|
||||
view_quote = dict(view.get("quote") or {})
|
||||
view_quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
air_quote = dict((view_quote.get("segment_quotes") or {}).get("AIR") or {})
|
||||
quote = air_quote or view_quote
|
||||
# 最新价常是人工覆盖:舱位/航司/航班要从本单 TMS 原价自动补,不能逼航线手打。
|
||||
tms_quote = copy.resolve_tms_quote(None, view)
|
||||
if not tms_quote:
|
||||
tms_quote = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
|
||||
quote_options = _air_options(quote)
|
||||
tms_options = _air_options(tms_quote)
|
||||
options = quote_options or tms_options
|
||||
quote_id = _quote_id(quote) or _quote_id(tms_quote)
|
||||
picked = (option_no or "").strip()
|
||||
if not picked and len(options) > 1:
|
||||
facts = dict(view.get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked and len(options) == 1:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
# 没有任何可锁报价时先拒绝,不要把「无报价」误说成缺舱位。
|
||||
if not _quote_ready_for_lock(quote) and not _quote_ready_for_lock(tms_quote):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
|
||||
return "no_quote"
|
||||
airline_code = (airline or "").strip().upper()
|
||||
if not airline_code:
|
||||
airline_code = _airline_from_quote_or_option(quote, quote_options, picked)
|
||||
if not airline_code:
|
||||
airline_code = _airline_from_quote_or_option(tms_quote, tms_options, picked)
|
||||
chargeable = _chargeable_weight_kg(quote, quote_options, picked, ticket)
|
||||
if not chargeable:
|
||||
chargeable = _chargeable_weight_kg(tms_quote, tms_options, picked, ticket)
|
||||
flight_no = _flight_no_from_quote_or_option(quote, quote_options, picked, instruction)
|
||||
if not flight_no:
|
||||
flight_no = _flight_no_from_quote_or_option(tms_quote, tms_options, picked, instruction)
|
||||
# 舱位/航司/航班交给主账:库里无 TMS 时会现拉补全;仍缺才回 lock_incomplete。
|
||||
# 这里只拦计费重全空(工单毛重也没有)——否则会逼航线手打航班。
|
||||
need_chargeable = not chargeable
|
||||
if need_chargeable:
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_ask_manual_lock_fields(
|
||||
need_option=False,
|
||||
need_airline=False,
|
||||
need_chargeable=True,
|
||||
need_flight=False,
|
||||
options=options,
|
||||
),
|
||||
quote_id = _quote_id(quote)
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
picked, air_code, flight = _resolve_lock_params(
|
||||
quote,
|
||||
facts,
|
||||
option_no=option_no,
|
||||
airline=airline,
|
||||
flight_no=flight_no,
|
||||
instruction=instruction,
|
||||
)
|
||||
missing = _missing_lock_labels(picked, air_code, flight)
|
||||
if missing and _quote_ready_for_lock(quote):
|
||||
_remember_partial_lock(
|
||||
flow,
|
||||
work_order_no=no,
|
||||
sender_id=sender_id,
|
||||
option_no=picked,
|
||||
airline=air_code,
|
||||
flight_no=flight,
|
||||
)
|
||||
return "wait_lock_fields"
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(missing))
|
||||
return "wait_lock_params"
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
@@ -932,14 +923,6 @@ def apply_air_lock(
|
||||
confirm_system_exception,
|
||||
)
|
||||
|
||||
def _lock_terminal(x: object) -> bool:
|
||||
"""成功,或业务上不必再重试(缺舱位/无报价)。"""
|
||||
row = x if isinstance(x, dict) else {}
|
||||
if bool(row.get("ok")):
|
||||
return True
|
||||
err = str(row.get("error") or "")
|
||||
return err in {"lock_incomplete", "no_quote"}
|
||||
|
||||
out = call_with_retries(
|
||||
lambda: locker(
|
||||
work_order_no=no,
|
||||
@@ -947,30 +930,38 @@ def apply_air_lock(
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
airline=airline_code,
|
||||
flight_no=flight_no,
|
||||
airline=air_code,
|
||||
flight_no=flight,
|
||||
)
|
||||
or {},
|
||||
is_ok=_lock_terminal,
|
||||
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"
|
||||
if str(out.get("error") or "") == "lock_incomplete":
|
||||
# 主账现拉仍无舱位:业务补问,不算系统异常,不惊动 IT。
|
||||
why = str(out.get("failReason") or out.get("error") or "锁舱参数不完整")
|
||||
facts = dict(ticket_view(ticket).get("collab_facts") or {})
|
||||
if str(facts.get(_PENDING_LOCK) or "") != "1":
|
||||
flow.ledger.patch_collab_facts(
|
||||
work_order_no=no, facts={_PENDING_LOCK: "1"}, sender_id=sender_id
|
||||
)
|
||||
# 主账在出站前发现三项不齐:只请补,不当 TMS 故障,不通知 IT。
|
||||
reason_text = str(out.get("failReason") or "")
|
||||
if str(out.get("error") or "") == "lock_params_incomplete" or "锁舱参数不完整" in reason_text:
|
||||
labels = missing or _labels_from_incomplete_reason(reason_text)
|
||||
_remember_partial_lock(
|
||||
flow,
|
||||
work_order_no=no,
|
||||
sender_id=sender_id,
|
||||
option_no=picked,
|
||||
airline=air_code,
|
||||
flight_no=flight,
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_params(labels))
|
||||
return "wait_lock_params"
|
||||
# 多段是产品限制,不是 TMS 故障。只回原因,不标系统异常、不暂停工单。
|
||||
if str(out.get("error") or "") == "multi_no_lock":
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_incomplete(work_order_no=no, operator=name, reason=why),
|
||||
copy.multi_air_lock_unsupported(work_order_no=no, operator=name),
|
||||
)
|
||||
return "lock_incomplete"
|
||||
return "multi_lock_unsupported"
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
@@ -993,7 +984,8 @@ def apply_air_lock(
|
||||
conversation_target=chat_id,
|
||||
extra={
|
||||
"option_no": picked,
|
||||
"airline": airline_code,
|
||||
"airline": air_code,
|
||||
"flight_no": flight,
|
||||
"instruction": instruction,
|
||||
"sender_id": sender_id,
|
||||
"operator_name": name,
|
||||
@@ -1013,6 +1005,9 @@ def apply_air_lock(
|
||||
_LOCK_QUOTE: quote_no,
|
||||
_LOCK_EXEC: exec_no,
|
||||
_PENDING_LOCK: "",
|
||||
_PENDING_LOCK_OPTION: "",
|
||||
_PENDING_LOCK_AIRLINE: "",
|
||||
_PENDING_LOCK_FLIGHT: "",
|
||||
},
|
||||
sender_id=sender_id,
|
||||
)
|
||||
@@ -1036,7 +1031,6 @@ def apply_air_lock(
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
return "lock_ok"
|
||||
|
||||
|
||||
def _release_should_push_deal(ticket: object) -> bool:
|
||||
"""
|
||||
放舱成功后要不要再推成交跟进。
|
||||
@@ -1259,6 +1253,339 @@ def _is_product_quote(quote: dict[str, Any]) -> bool:
|
||||
return "航线" in src or "产品" in src
|
||||
|
||||
|
||||
_OPTION_RE = re.compile(r"AIR-?OPT-?\d+")
|
||||
_AIRLINE_AFTER_OPTION_RE = re.compile(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])")
|
||||
_AIRLINE_LABELED_CODE_RE = re.compile(
|
||||
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}|[A-Z]\d|\d[A-Z])(?![A-Z0-9])"
|
||||
)
|
||||
_AIRLINE_LABELED_NAME_RE = re.compile(
|
||||
r"航司(?:代码|二字码)?\s*[::]?\s*(?:是|为)?\s*([\u4e00-\u9fffA-Za-z0-9]{2,12})"
|
||||
)
|
||||
_FLIGHT_LABELED_RE = re.compile(
|
||||
r"(?:航班号|航班)\s*[::]?\s*(?:是|为)?\s*([A-Z]{2}\s*\d{2,4}|[A-Z]\d\s*\d{2,4}|\d[A-Z]\s*\d{2,4})"
|
||||
)
|
||||
_FLIGHT_TRAIL_RE = re.compile(
|
||||
r"AIR-?OPT-?\d+\s+(?:[A-Z]{2}\s+)?([A-Z]{2}\s*\d{2,4})"
|
||||
)
|
||||
_FLIGHT_LOOSE_RE = re.compile(r"(?<![A-Z0-9])(?!WO)([A-Z]{2}\d{2,4})(?!\d)")
|
||||
_AIRLINE_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])$")
|
||||
_FLIGHT_CODE_RE = re.compile(r"^(?:[A-Z]{2}|[A-Z]\d|\d[A-Z])\d{2,4}$")
|
||||
# 口语航司名归一成二字码。认不出的原样留下,交给 TMS,不因此再追问。
|
||||
_AIRLINE_ALIASES = (
|
||||
("中国南方航空", "CZ"),
|
||||
("南方航空", "CZ"),
|
||||
("南航", "CZ"),
|
||||
("中国国际航空", "CA"),
|
||||
("国际航空", "CA"),
|
||||
("国航", "CA"),
|
||||
("中国东方航空", "MU"),
|
||||
("东方航空", "MU"),
|
||||
("东航", "MU"),
|
||||
("海南航空", "HU"),
|
||||
("海航", "HU"),
|
||||
("厦门航空", "MF"),
|
||||
("厦航", "MF"),
|
||||
("深圳航空", "ZH"),
|
||||
("深航", "ZH"),
|
||||
("四川航空", "3U"),
|
||||
("川航", "3U"),
|
||||
("山东航空", "SC"),
|
||||
("山航", "SC"),
|
||||
("春秋航空", "9C"),
|
||||
("吉祥航空", "HO"),
|
||||
("新加坡航空", "SQ"),
|
||||
("新航", "SQ"),
|
||||
("泰国航空", "TG"),
|
||||
("泰航", "TG"),
|
||||
("国泰航空", "CX"),
|
||||
("国泰", "CX"),
|
||||
("卡塔尔航空", "QR"),
|
||||
("顺丰航空", "O3"),
|
||||
)
|
||||
|
||||
|
||||
def lock_params_from_text(text: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
从航线原话抽出舱位编号、航司、航班号。
|
||||
|
||||
不要求「AIR-OPT01 CZ 航班CZ327」这一种写法。
|
||||
带「舱位 / 航司 / 航班」的说法、换顺序、航司用中文名,都认。
|
||||
本地对不上、话里又在补这三项时,再问模型。没说的不猜。
|
||||
"""
|
||||
option, airline, flight = _lock_params_local(text)
|
||||
if option and airline and flight:
|
||||
return option, airline, flight
|
||||
if not _should_ask_lock_model(text, option, airline, flight):
|
||||
return option, airline, flight
|
||||
got_opt, got_air, got_flight = _lock_params_from_model(text)
|
||||
return (
|
||||
option or got_opt,
|
||||
airline or _normalize_airline(got_air),
|
||||
flight or _normalize_flight(got_flight),
|
||||
)
|
||||
|
||||
|
||||
def text_has_lock_supplement(text: str) -> bool:
|
||||
"""这句话是在补锁舱三项,不是报价或闲聊。凑齐两项,或点名了舱位/航司/航班,都算。"""
|
||||
option, airline, flight = _lock_params_local(text)
|
||||
filled = sum(1 for item in (option, airline, flight) if item)
|
||||
if filled >= 2:
|
||||
return True
|
||||
raw = text or ""
|
||||
if filled < 1 or not any(word in raw for word in ("舱位", "航司", "航班")):
|
||||
return False
|
||||
from agent.routing.group_intent import text_is_staff_fee_quote
|
||||
|
||||
return not text_is_staff_fee_quote(raw)
|
||||
|
||||
|
||||
def _lock_params_local(text: str) -> tuple[str, str, str]:
|
||||
"""按原话里的编号、标签和常见航司名收三项。不调模型。"""
|
||||
raw = text or ""
|
||||
blob = raw.upper()
|
||||
option = _option_from_blob(blob)
|
||||
flight = _flight_from_blob(blob)
|
||||
airline = _airline_from_text(raw, blob, option=option, flight=flight)
|
||||
return option, airline, flight
|
||||
|
||||
|
||||
def _option_from_blob(blob: str) -> str:
|
||||
found = _OPTION_RE.search(blob or "")
|
||||
if not found:
|
||||
return ""
|
||||
raw = found.group(0)
|
||||
if raw.startswith("AIROPT"):
|
||||
return "AIR-OPT" + raw[6:]
|
||||
return raw
|
||||
|
||||
|
||||
def _flight_from_blob(blob: str) -> str:
|
||||
labeled = _FLIGHT_LABELED_RE.search(blob or "")
|
||||
if labeled:
|
||||
return _normalize_flight(labeled.group(1))
|
||||
trailed = _FLIGHT_TRAIL_RE.search(blob or "")
|
||||
if trailed:
|
||||
return _normalize_flight(trailed.group(1))
|
||||
loose = _FLIGHT_LOOSE_RE.search(blob or "")
|
||||
if loose:
|
||||
return _normalize_flight(loose.group(1))
|
||||
return ""
|
||||
|
||||
|
||||
def _airline_from_text(raw: str, blob: str, *, option: str, flight: str) -> str:
|
||||
"""航司可以是二字码,也可以是「航司南航」这种说法。"""
|
||||
labeled = _AIRLINE_LABELED_CODE_RE.search(blob or "")
|
||||
if labeled:
|
||||
return labeled.group(1)
|
||||
named = _AIRLINE_LABELED_NAME_RE.search(raw or "")
|
||||
if named:
|
||||
token = named.group(1).strip()
|
||||
if token.upper() not in {"AIR", "OPT"} and not token.upper().startswith("AIR-OPT"):
|
||||
return _normalize_airline(token)
|
||||
after = _AIRLINE_AFTER_OPTION_RE.search(blob or "")
|
||||
if after:
|
||||
return after.group(1)
|
||||
# 没写「航司」二字时,只有这句话已经带了舱位或航班,才从航司名里认,避免报价句子被误伤。
|
||||
if option or flight:
|
||||
found = _alias_in_text(raw)
|
||||
if found:
|
||||
return found
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_flight(value: str) -> str:
|
||||
compact = re.sub(r"\s+", "", (value or "").upper())
|
||||
return compact if _FLIGHT_CODE_RE.match(compact) else ""
|
||||
|
||||
|
||||
def _normalize_airline(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
upper = re.sub(r"\s+", "", text.upper())
|
||||
if _FLIGHT_CODE_RE.match(upper):
|
||||
return ""
|
||||
if _AIRLINE_CODE_RE.match(upper):
|
||||
return upper
|
||||
alias = _alias_in_text(text)
|
||||
if alias:
|
||||
return alias
|
||||
if re.fullmatch(r"[\u4e00-\u9fffA-Za-z0-9]{2,12}", text) and not text.upper().startswith("AIR"):
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _alias_in_text(text: str) -> str:
|
||||
raw = text or ""
|
||||
for name, code in _AIRLINE_ALIASES:
|
||||
if name in raw:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _airline_ready(airline: str) -> bool:
|
||||
text = (airline or "").strip()
|
||||
if _AIRLINE_CODE_RE.match(text.upper()):
|
||||
return True
|
||||
return bool(_normalize_airline(text))
|
||||
|
||||
|
||||
def _should_ask_lock_model(text: str, option: str, airline: str, flight: str) -> bool:
|
||||
"""三项已经齐,或这句话根本没在说舱位,就不要再打模型。"""
|
||||
if option and airline and flight:
|
||||
return False
|
||||
raw = text or ""
|
||||
if any(word in raw for word in ("舱位", "航司", "航班")):
|
||||
return True
|
||||
blob = raw.upper()
|
||||
return "AIR-OPT" in blob or "AIROPT" in blob
|
||||
|
||||
|
||||
def _lock_params_from_model(text: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
模型只抽舱位编号、航司、航班号。
|
||||
|
||||
网络未开或调用失败时当没抽到,群里继续按缺项追问。不在请求里猜。
|
||||
"""
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
settings = get_settings()
|
||||
if not (
|
||||
getattr(settings, "llm_allow_network", False)
|
||||
and getattr(settings, "llm_data_usage_confirmed", False)
|
||||
):
|
||||
return "", "", ""
|
||||
client = LlmHttpClient.from_settings(settings)
|
||||
data = client.chat(
|
||||
mode=LlmMode.EXTRACT_FIELDS,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"从航线这句话抽出锁舱三项。只回复一行 JSON,不要解释。"
|
||||
'键为 optionNo、airline、flightNo。没有的填空字符串。'
|
||||
"optionNo 是舱位编号,如 AIR-OPT01;airline 是航司二字码或航司名;"
|
||||
"flightNo 是航班号,如 CZ327。不要编造原文没有的值。"
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": (text or "")[:500]},
|
||||
],
|
||||
)
|
||||
content = str((data or {}).get("content") or "")
|
||||
start = content.find("{")
|
||||
end = content.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return "", "", ""
|
||||
payload = json.loads(content[start : end + 1])
|
||||
if not isinstance(payload, dict):
|
||||
return "", "", ""
|
||||
option = _option_from_blob(str(payload.get("optionNo") or "").upper())
|
||||
airline = _normalize_airline(str(payload.get("airline") or ""))
|
||||
flight = _normalize_flight(str(payload.get("flightNo") or ""))
|
||||
return option, airline, flight
|
||||
except Exception:
|
||||
logger.exception("锁舱三项模型抽取失败")
|
||||
return "", "", ""
|
||||
|
||||
|
||||
def _resolve_lock_params(
|
||||
quote: dict[str, Any],
|
||||
facts: dict[str, Any],
|
||||
*,
|
||||
option_no: str,
|
||||
airline: str,
|
||||
flight_no: str,
|
||||
instruction: str,
|
||||
) -> tuple[str, str, str]:
|
||||
"""
|
||||
拼出锁舱三项。
|
||||
|
||||
这一句里写的优先;没写的用上一句暂存的;再没有才用报价上已有的舱位。
|
||||
航司写成南航、东方航空这类名字时归一成二字码;归一不了也算已经补充,不再追问。
|
||||
"""
|
||||
said_opt, said_air, said_flight = lock_params_from_text(instruction)
|
||||
option = (option_no or said_opt or str(facts.get(_PENDING_LOCK_OPTION) or "")).strip()
|
||||
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
|
||||
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
|
||||
chosen = _option_row(quote, option)
|
||||
if chosen is None and not option:
|
||||
options = _air_options(quote)
|
||||
if len(options) == 1:
|
||||
chosen = options[0]
|
||||
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
|
||||
if not _airline_ready(air) and chosen is not None:
|
||||
air = _normalize_airline(
|
||||
str(chosen.get("airlineCode") or chosen.get("airline") or "")
|
||||
)
|
||||
if not flight and chosen is not None:
|
||||
flight = _normalize_flight(str(chosen.get("flightNo") or chosen.get("flight_no") or ""))
|
||||
if not flight:
|
||||
flight = _normalize_flight(str(quote.get("flightNo") or quote.get("flight_no") or ""))
|
||||
if not _airline_ready(air):
|
||||
air = ""
|
||||
if not air and len(flight) >= 2 and flight[:2].isalpha():
|
||||
air = flight[:2]
|
||||
return option, air, flight
|
||||
|
||||
|
||||
def _option_row(quote: dict[str, Any], option_no: str) -> Optional[dict[str, Any]]:
|
||||
"""按舱位编号找报价里的那一条;对不上返回空。"""
|
||||
want = (option_no or "").strip().upper()
|
||||
if not want:
|
||||
return None
|
||||
for row in _air_options(quote):
|
||||
no = str(row.get("optionNo") or row.get("option_no") or "").strip().upper()
|
||||
if no == want:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _missing_lock_labels(option_no: str, airline: str, flight_no: str) -> list[str]:
|
||||
"""缺的中文名,顺序固定:舱位编号、航司、航班号。"""
|
||||
missing: list[str] = []
|
||||
if not (option_no or "").strip():
|
||||
missing.append("舱位编号")
|
||||
if not _airline_ready(airline):
|
||||
missing.append("航司")
|
||||
if not _FLIGHT_CODE_RE.match((flight_no or "").strip().upper()):
|
||||
missing.append("航班号")
|
||||
return missing
|
||||
|
||||
|
||||
def _labels_from_incomplete_reason(reason: str) -> list[str]:
|
||||
"""主账回了「缺少:…」时,按中文名收回缺项,丢掉英文字段名。"""
|
||||
found = [label for label in ("舱位编号", "航司", "航班号") if label in (reason or "")]
|
||||
return found or ["舱位编号", "航司", "航班号"]
|
||||
|
||||
|
||||
def _remember_partial_lock(
|
||||
flow: Any,
|
||||
*,
|
||||
work_order_no: str,
|
||||
sender_id: str,
|
||||
option_no: str,
|
||||
airline: str,
|
||||
flight_no: str,
|
||||
) -> None:
|
||||
"""先记下已经说清的项,下一句补齐后接着锁,不用重说。"""
|
||||
patcher = getattr(flow.ledger, "patch_collab_facts", None)
|
||||
if not callable(patcher):
|
||||
return
|
||||
patcher(
|
||||
work_order_no=work_order_no,
|
||||
facts={
|
||||
_PENDING_LOCK: "1",
|
||||
_PENDING_LOCK_OPTION: option_no,
|
||||
_PENDING_LOCK_AIRLINE: airline,
|
||||
_PENDING_LOCK_FLIGHT: flight_no,
|
||||
},
|
||||
sender_id=sender_id,
|
||||
)
|
||||
|
||||
|
||||
def _quote_id(quote: dict[str, Any]) -> str:
|
||||
return str(quote.get("quoteId") or quote.get("quote_id") or "").strip()
|
||||
|
||||
|
||||
@@ -882,8 +882,11 @@ class AirTextInquiryFlow:
|
||||
reply(copy.quote_generating())
|
||||
return "wait_file"
|
||||
if sess.phase == "wait_deal":
|
||||
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
|
||||
# 原话写了已成交/未成交/协商中时,由 _close_spoken_deal 先入账,不会走到这里。
|
||||
# 成交卡原来挂在报价单那条消息上,聊天一上滑就找不到。
|
||||
# 销售再说继续工单、又没写成交结果时,再发一次成交/未成交/协商中。
|
||||
# 原话已经写了结果时,_close_spoken_deal 会先入账,不会走到这里。
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
self._emit_deal(reply, sess)
|
||||
return "wait_deal"
|
||||
if sess.phase == "wait_lost_reason":
|
||||
reply(copy.deal_ack("未成交", sess.work_order_no))
|
||||
@@ -1260,13 +1263,39 @@ class AirTextInquiryFlow:
|
||||
|
||||
quote = tms["quote"]
|
||||
from agent.schema.air_route_options import (
|
||||
choose_stated_origin_option,
|
||||
needs_route_pick,
|
||||
options_matching_origin,
|
||||
parse_air_options,
|
||||
project_selected_quote,
|
||||
)
|
||||
|
||||
options = parse_air_options(quote)
|
||||
if options and not needs_route_pick(quote):
|
||||
stated_origin = str(
|
||||
(sess.facts or {}).get("起运港") or (sess.facts or {}).get("起运地") or ""
|
||||
).strip()
|
||||
# 写了起运地:只认这个起运地的线路,直接报价,不让销售再选。
|
||||
# 没写起运地:TMS 按目的地回多条时,才进选线页。
|
||||
if stated_origin:
|
||||
matched = options_matching_origin(options, stated_origin)
|
||||
if options and not matched:
|
||||
logger.info(
|
||||
"air 起运地对不上回包线路 origin=%s wo=%s options=%s",
|
||||
stated_origin,
|
||||
no,
|
||||
len(options),
|
||||
)
|
||||
self._emit_tms_miss(reply, no)
|
||||
sess.phase = "tms_miss"
|
||||
sess.status = "询价中"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
return "tms_miss"
|
||||
if matched:
|
||||
quote = project_selected_quote(
|
||||
quote, choose_stated_origin_option(matched, quote)
|
||||
)
|
||||
elif options and not needs_route_pick(quote):
|
||||
quote = project_selected_quote(quote, options[0])
|
||||
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
@@ -1278,7 +1307,7 @@ class AirTextInquiryFlow:
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
sess.tms_quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
if needs_route_pick(quote):
|
||||
if not stated_origin and needs_route_pick(quote):
|
||||
return self._offer_air_routes(
|
||||
sess=sess,
|
||||
work_order_no=no,
|
||||
|
||||
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
|
||||
date(2026, 9, 25),
|
||||
date(2026, 9, 26),
|
||||
date(2026, 9, 27),
|
||||
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
|
||||
date(2026, 10, 1),
|
||||
date(2026, 10, 2),
|
||||
date(2026, 10, 3),
|
||||
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
|
||||
date(2026, 10, 5),
|
||||
date(2026, 10, 6),
|
||||
date(2026, 10, 7),
|
||||
date(2026, 10, 8),
|
||||
# 2027 元旦预留
|
||||
date(2027, 1, 1),
|
||||
date(2027, 1, 2),
|
||||
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
|
||||
date(2026, 2, 14),
|
||||
date(2026, 2, 28),
|
||||
date(2026, 5, 9),
|
||||
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
|
||||
date(2026, 9, 20),
|
||||
date(2026, 10, 10),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ def pick_mentions(
|
||||
"""
|
||||
本单这次应回而未回、需要被点名的人。
|
||||
|
||||
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。
|
||||
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。
|
||||
不要询价机器人、不要群里其他人。
|
||||
"""
|
||||
kind = (wait_kind or "").strip()
|
||||
@@ -206,6 +206,48 @@ def pick_mentions(
|
||||
return out
|
||||
|
||||
|
||||
def mentions_for_role(
|
||||
rows: list[dict[str, Any]] | None,
|
||||
*,
|
||||
role_code: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
从员工行里取出启用、有企微、有姓名的指定岗位。
|
||||
|
||||
空运群激活只记群号,协同成员经常是空的。超时巡检在群成员里找不到航线岗时,
|
||||
用本函数点名员工管理里的航线·空运,和进群摘要 @ 的是同一批人。
|
||||
不发消息、不写主账。
|
||||
"""
|
||||
want = (role_code or "").strip().lower()
|
||||
if not want:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in rows or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||||
if role != want:
|
||||
continue
|
||||
status = str(raw.get("status") or "active").strip().lower()
|
||||
if status in {"disabled", "0", "停用", "inactive"}:
|
||||
continue
|
||||
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||
name = str(raw.get("name") or "").strip()
|
||||
if not uid or not name or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
out.append(
|
||||
{
|
||||
"wecomId": uid,
|
||||
"name": name,
|
||||
"roleCode": want,
|
||||
"notifyEventException": raw.get("notifyEventException", 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
@@ -2400,8 +2400,14 @@ def adjust_ineligible(reason: str) -> str:
|
||||
return "当前工单还没有可调的报价,不能按这套调价。"
|
||||
|
||||
|
||||
def adjust_only_sales() -> str:
|
||||
return "只有这张单的销售能调价。航线/产品请继续报价,价格未改。"
|
||||
def adjust_only_sales(*, air_room: bool = False) -> str:
|
||||
"""
|
||||
群里不是这张单的销售在发调价。价格不改。
|
||||
|
||||
空运群称呼航线;海运、陆运称呼产品。两句用句号分开,避免连成一句。
|
||||
"""
|
||||
who = "航线" if air_room else "产品"
|
||||
return f"只有这张单的销售能调价。{who}请继续报价,价格未改。"
|
||||
|
||||
|
||||
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
|
||||
@@ -2778,9 +2784,10 @@ def _followup_month_day(raw: str) -> str:
|
||||
|
||||
def lost_reason_ack(reason: str = "", work_order_no: str = "") -> str:
|
||||
"""
|
||||
收下未成交原因后回显。后台 lost.reason_ack。
|
||||
收下未成交原因后的回执。后台 lost.reason_ack。
|
||||
|
||||
测服文案含 {{ticket_no}} / {{lost_reason}};缺 ticket_no 会原样打出占位符。
|
||||
后台模板里的 {{lost_reason}}、{{ticket_no}} 都在这里填。
|
||||
不传工单号时占位留空串,避免群里再露出 {{ticket_no}}。
|
||||
"""
|
||||
text = (reason or "").strip()
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -2932,7 +2939,8 @@ def air_group_brief(
|
||||
空运群激活摘要:与海运进群同一套结构,字段用空运。
|
||||
|
||||
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
|
||||
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。
|
||||
TMS 段跟单聊同一套线路分项,总销售价只一行。
|
||||
已有运输费等分项时,不再叠一行「空运费=总价」;只有空运费这一项时要写出来。
|
||||
不写「当前工单/询价要点/现在等到哪一步」。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -3059,6 +3067,23 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
|
||||
return air_lock_need_quote(work_order_no)
|
||||
|
||||
|
||||
def air_lock_need_params(missing=None) -> str:
|
||||
"""
|
||||
舱位编号、航司、航班号没齐时,只请航线补全。
|
||||
|
||||
这时还没锁舱,不能说 TMS 锁舱失败,也不能通知 IT。
|
||||
缺哪项就点哪项;三项都缺时是产品指定的那一句。
|
||||
"""
|
||||
labels = [str(x).strip() for x in (missing or []) if str(x).strip()]
|
||||
if not labels:
|
||||
labels = ["舱位编号", "航司", "航班号"]
|
||||
joined = "、".join(labels)
|
||||
return (
|
||||
f"锁舱参数不完整,缺少:{joined}。"
|
||||
"请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。"
|
||||
)
|
||||
|
||||
|
||||
def air_lock_need_quote(work_order_no: str) -> str:
|
||||
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -3155,6 +3180,32 @@ def air_lock_ok(
|
||||
)
|
||||
|
||||
|
||||
# 多段里有空运段时,航线锁舱的失败原因。产品暂不支持,不当 TMS 故障。
|
||||
MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱"
|
||||
# 后台锁舱失败模板末尾常有「已通知IT…请稍等」。多段这条要整句拿掉。
|
||||
_IT_NOTICE_LINE = re.compile(r"已通知\s*IT[^\n。]*。?")
|
||||
|
||||
|
||||
def multi_air_lock_unsupported(*, work_order_no: str, operator: str) -> str:
|
||||
"""
|
||||
多段含空运、航线锁舱时的群回复。
|
||||
|
||||
抬头、操作人、失败原因仍走锁舱失败模板,只去掉通知 IT 那句。
|
||||
不打主账、不标系统异常。
|
||||
"""
|
||||
text = air_lock_fail(
|
||||
work_order_no=work_order_no,
|
||||
operator=operator,
|
||||
reason=MULTI_LOCK_UNSUPPORTED,
|
||||
)
|
||||
kept: list[str] = []
|
||||
for line in text.splitlines():
|
||||
cleaned = _IT_NOTICE_LINE.sub("", line).strip()
|
||||
if cleaned:
|
||||
kept.append(cleaned)
|
||||
return "\n".join(kept).strip()
|
||||
|
||||
|
||||
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
"""空运锁舱失败(接口/系统类)。后台 inquiring.air_lock_fail。"""
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
@@ -3359,7 +3410,7 @@ def system_exception_alert_payload(
|
||||
"""
|
||||
系统异常通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
|
||||
五行:异常类型、接口/服务、报错原因、影响范围、发生时间。
|
||||
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "无"
|
||||
@@ -3368,7 +3419,7 @@ def system_exception_alert_payload(
|
||||
rows = [
|
||||
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
|
||||
{"keyname": "接口/服务", "value": (service or "").strip() or "-"},
|
||||
{"keyname": "错误码", "value": (error_code or "").strip() or "-"},
|
||||
{"keyname": "报错原因", "value": (error_code or "").strip() or "-"},
|
||||
{"keyname": "影响范围", "value": system_exception_impact(step)},
|
||||
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
|
||||
]
|
||||
@@ -3409,7 +3460,7 @@ def system_exception_alert_text(
|
||||
f"关联工单 {no}",
|
||||
f"异常类型:{(exception_type or '').strip() or '-'}",
|
||||
f"接口/服务:{(service or '').strip() or '-'}",
|
||||
f"错误码:{(error_code or '').strip() or '-'}",
|
||||
f"报错原因:{(error_code or '').strip() or '-'}",
|
||||
f"影响范围:{system_exception_impact(step)}",
|
||||
f"发生时间:{(occurred_at or '').strip() or '-'}",
|
||||
]
|
||||
|
||||
@@ -11,7 +11,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy import inquiry_copy as copy
|
||||
@@ -20,6 +21,7 @@ from agent.policy.sea_group_ops import (
|
||||
apply_group_deal,
|
||||
group_client_of,
|
||||
is_sales,
|
||||
override_group_client,
|
||||
send_filled_sheet,
|
||||
send_group_text,
|
||||
ticket_no,
|
||||
@@ -33,6 +35,24 @@ _ROLE_MODE = {"sea": "SEA", "land": "LAND", "air": "AIR"}
|
||||
SheetFn = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bot_sender_when_air_multi(ticket: object) -> Iterator[None]:
|
||||
"""
|
||||
含空运的多段用的是机器人原来的群。应用接口发不进这个群,报价和模板都会没反应。
|
||||
|
||||
这一次出站改走机器人桥,发完还原。没有空运段的多段仍用应用自己建的群,这里不动。
|
||||
只影响当前线程,不改流程上缓存的客户端。
|
||||
"""
|
||||
if not includes_air(load_segments(ticket)):
|
||||
yield
|
||||
return
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
logger.info("multi.outbound 含空运走机器人桥 wo=%s", ticket_no(ticket))
|
||||
with override_group_client(AibotReplyClient()):
|
||||
yield
|
||||
|
||||
|
||||
def ticket_is_multi(ticket: object) -> bool:
|
||||
"""主账业务线是多段联运。"""
|
||||
view = ticket_view(ticket)
|
||||
@@ -257,8 +277,17 @@ def _save_segment_quote(
|
||||
|
||||
|
||||
def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, intent: str) -> str:
|
||||
"""空运段的锁舱、释放走现有空运规则,不挡住其他段报价。"""
|
||||
from agent.policy.air_group_ops import apply_air_lock, apply_air_release
|
||||
"""
|
||||
多段含空运时的锁舱、释放。
|
||||
|
||||
锁舱暂不支持:只回复失败原因,不打 TMS,也不标系统异常。
|
||||
释放仍走空运规则。调用方已确认本单含空运段。群消息在 Worker 线程发出。
|
||||
"""
|
||||
from agent.policy.air_group_ops import (
|
||||
apply_air_release,
|
||||
is_airline,
|
||||
operator_name,
|
||||
)
|
||||
|
||||
if intent == "release":
|
||||
return apply_air_release(
|
||||
@@ -268,13 +297,20 @@ def _cabin(flow: Any, ticket: object, chat_id: str, sender_id: str, text: str, i
|
||||
chat_id=chat_id,
|
||||
instruction=text,
|
||||
)
|
||||
return apply_air_lock(
|
||||
flow=flow,
|
||||
ticket=ticket,
|
||||
sender_id=sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=text,
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
no = ticket_no(ticket)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.multi_air_lock_unsupported(
|
||||
work_order_no=no,
|
||||
operator=operator_name(flow, sender_id),
|
||||
),
|
||||
)
|
||||
logger.info("multi.lock unsupported wo=%s sender=%s", no, sender_id)
|
||||
return "multi_lock_unsupported"
|
||||
|
||||
|
||||
def _cabin_intent(text: str) -> str:
|
||||
|
||||
@@ -260,7 +260,14 @@ def apply_group_adjust(
|
||||
send_group_text(client, room, copy.adjust_ineligible("no_quote"))
|
||||
return "adjust_blocked"
|
||||
if not is_sales(ticket, sender_id):
|
||||
send_group_text(client, room, copy.adjust_only_sales())
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").upper()
|
||||
air_room = line == "AIR"
|
||||
if line == "MULTI":
|
||||
from agent.policy.multi_group_ops import load_segments
|
||||
from agent.policy.multi_segments import includes_air
|
||||
|
||||
air_room = includes_air(load_segments(ticket))
|
||||
send_group_text(client, room, copy.adjust_only_sales(air_room=air_room))
|
||||
return "adjust_not_sales"
|
||||
current = _current_quote(view, sess)
|
||||
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
|
||||
|
||||
@@ -9,7 +9,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Iterator, Optional
|
||||
|
||||
from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy.quote_overlay import overlay_labeled_facts
|
||||
@@ -244,14 +246,40 @@ def filename_is_excel_pdf(name: str) -> bool:
|
||||
return any(raw.endswith(ext) for ext in EXCEL_PDF)
|
||||
|
||||
|
||||
# 这一次群出站临时改用的客户端。只在当前线程有效,不改流程单例上的企微客户端。
|
||||
_outbound_client: ContextVar[Any] = ContextVar("inquiry_group_outbound_client", default=None)
|
||||
|
||||
|
||||
def group_client_of(flow: Any) -> Any:
|
||||
"""正式路径没有注入客户端时,走流程自己的默认企微客户端。"""
|
||||
"""
|
||||
取这一次群出站用的客户端。
|
||||
|
||||
有临时指定时用指定的(含空运的多段要走机器人桥)。
|
||||
没有时走流程自己的客户端;正式路径默认是应用发群。
|
||||
"""
|
||||
override = _outbound_client.get()
|
||||
if override is not None:
|
||||
return override
|
||||
getter = getattr(flow, "group_client", None)
|
||||
if callable(getter):
|
||||
return getter()
|
||||
return getattr(flow, "_group_client", None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def override_group_client(client: Any) -> Iterator[None]:
|
||||
"""
|
||||
这一次群出站改用指定客户端,用完还原。
|
||||
|
||||
只影响当前线程。不改流程单例,海运自己建的群不会被别的工单抢走客户端。
|
||||
"""
|
||||
token = _outbound_client.set(client)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_outbound_client.reset(token)
|
||||
|
||||
|
||||
def _archive_seat_ids(flow: Any) -> set[str]:
|
||||
"""会话存档账号(测服 AI001 / 询价机器人)不能出现在群 @ 里。"""
|
||||
seats: set[str] = {"AI001", "询价机器人"}
|
||||
@@ -1317,10 +1345,7 @@ def capture_group_lost_reason(
|
||||
send_group_text(
|
||||
client,
|
||||
chat_id,
|
||||
copy.lost_reason_ack(
|
||||
str(result.get("lost_reason") or text),
|
||||
work_order_no=no,
|
||||
),
|
||||
copy.lost_reason_ack(str(result.get("lost_reason") or text), work_order_no=no),
|
||||
)
|
||||
return "done"
|
||||
|
||||
|
||||
@@ -95,6 +95,35 @@ def ticket_is_paused(ticket: Any) -> bool:
|
||||
return bool(ticket_exception_type(ticket))
|
||||
|
||||
|
||||
def ticket_pause_step(ticket: Any) -> str:
|
||||
"""暂停是哪一步留下的。查价、锁舱、释放各算各的。"""
|
||||
if ticket is None:
|
||||
return ""
|
||||
if isinstance(ticket, dict):
|
||||
step = str(
|
||||
ticket.get("system_exception_step") or ticket.get("systemExceptionStep") or ""
|
||||
).strip()
|
||||
payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {}
|
||||
else:
|
||||
step = str(getattr(ticket, "system_exception_step", "") or "").strip()
|
||||
payload = getattr(ticket, "system_exception_payload", {}) or {}
|
||||
if step:
|
||||
return step
|
||||
if isinstance(payload, dict):
|
||||
return str(payload.get("step") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def cabin_failure_pause(ticket: Any) -> bool:
|
||||
"""
|
||||
锁舱或释放失败留下的暂停。
|
||||
|
||||
群里已经回过失败原因,并通知了 IT。之后的手工报价、补字段、成交不能再被
|
||||
「TMS接口异常」挡住。查价失败的暂停不在这里,那种仍要停。
|
||||
"""
|
||||
return ticket_pause_step(ticket) in {STEP_TMS_LOCK, STEP_TMS_RELEASE, STEP_TMS_CABIN}
|
||||
|
||||
|
||||
def extract_work_order_nos(text: str) -> list[str]:
|
||||
"""从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。"""
|
||||
import re
|
||||
|
||||
@@ -26,6 +26,8 @@ from agent.routing.group_intent import (
|
||||
_hint_confirm_tms,
|
||||
_hint_deal_outcome,
|
||||
_hint_tms_choice,
|
||||
_prefer_staff_quote,
|
||||
text_is_staff_fee_quote,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -95,6 +97,11 @@ def classify_air_group_text(
|
||||
locked = _hint_lock_or_release(raw)
|
||||
if locked:
|
||||
return locked
|
||||
# 补舱位不限写法。凑齐舱位编号、航司、航班号就当选舱,不要先当成报价。
|
||||
from agent.policy.air_group_ops import text_has_lock_supplement
|
||||
|
||||
if text_has_lock_supplement(raw):
|
||||
return INTENT_PICK_OPTION
|
||||
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
@@ -118,12 +125,15 @@ def classify_air_group_text(
|
||||
if use_net and getattr(settings, "llm_data_usage_confirmed", False):
|
||||
got = _classify_with_model(raw, settings)
|
||||
if got in _VALID:
|
||||
return got
|
||||
return _prefer_staff_quote(got, raw)
|
||||
except Exception:
|
||||
logger.exception("空运群意图模型失败,回退规则")
|
||||
from agent.llm.extract_text import extract_air_collab_fields, extract_product_quote
|
||||
from agent.schema.quote_adjust import extract_adjust
|
||||
|
||||
# 费用金额先当航线报价。「空运费改为900」不是销售调价。
|
||||
if text_is_staff_fee_quote(raw):
|
||||
return INTENT_QUOTE
|
||||
if extract_adjust(raw).is_adjust:
|
||||
return INTENT_ADJUST
|
||||
if extract_product_quote(raw):
|
||||
@@ -183,10 +193,10 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
"你是空运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"air_lock=航线要锁舱;air_release=航线要释放舱位;"
|
||||
"air_activate=只是指定/切换工单号;"
|
||||
"air_pick_option=在选舱位选项编号;"
|
||||
"air_pick_option=在补充或选择舱位编号、航司、航班号,写法不限;"
|
||||
"collab_fields=在补是否含电/是否含磁;"
|
||||
"quote_adjust=销售在调已有费用,口令不写死(上升、往上抬、加一成、改到某金额都算),不是新产品报价;"
|
||||
"product_quote=航线在报价格或费用(含具体金额);"
|
||||
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
|
||||
"product_quote=航线在报某一项费用的金额。写出「空运费9900」「空运费改为900USD」是报价,不是调价;"
|
||||
"quote_confirm_tms=确认沿用当前TMS报价;"
|
||||
"quote_keep_tms=要在已有TMS报价上改;"
|
||||
"quote_drop_tms=不要TMS、只用刚报的价;"
|
||||
@@ -195,7 +205,7 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
"other=闲聊。"
|
||||
"一句话里既有锁舱又有工单号,回 air_lock。"
|
||||
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
|
||||
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
|
||||
"有具体费用金额就是 product_quote。只有比例或只改总价才是 quote_adjust。"
|
||||
)
|
||||
data = client.chat(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
|
||||
@@ -117,6 +117,63 @@ _DROP_HINTS = (
|
||||
)
|
||||
|
||||
|
||||
def text_is_staff_fee_quote(text: str) -> bool:
|
||||
"""
|
||||
航线/产品在报某一项费用的金额。
|
||||
|
||||
「空运费 9900」「空运费改为900USD」「报关费改为300CNY」都算手工报价。
|
||||
比例调价和只改总价/总销售价不算,那两句仍留给销售调价。
|
||||
句里没有费用名、只剩一个数字,也不算,避免把选项编号当成报价。
|
||||
不打模型。分类和入站改判都会用。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
from agent.schema.quote_adjust import text_has_relative_adjust, text_has_total_only_adjust
|
||||
|
||||
if text_has_relative_adjust(raw) or text_has_total_only_adjust(raw):
|
||||
return False
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(raw)
|
||||
rows = [row for row in (quote.get("fee_rows") or []) if isinstance(row, dict)]
|
||||
if not rows:
|
||||
return False
|
||||
# 「费用合计」里的「费」不能单独把改总价认成报价;上面已经排除只改总价。
|
||||
if "费" in raw:
|
||||
return True
|
||||
names = [str(row.get("name") or "").strip() for row in rows]
|
||||
return any(name and name != "海运费" for name in names)
|
||||
|
||||
|
||||
def resolve_price_intent(intent: str, text: str, *, sales: bool) -> str:
|
||||
"""
|
||||
按说话人把「报价」和「调价」分开。
|
||||
|
||||
航线/产品写出费用金额:按报价办,不能回「只有销售能调价」。
|
||||
销售在做比例、改总价,或点名把某项费用改到某个金额:仍按调价办。
|
||||
不打模型。群入站分发前调用,避免手工报价被调价话术拦住。
|
||||
"""
|
||||
if intent not in {INTENT_ADJUST, INTENT_QUOTE}:
|
||||
return intent
|
||||
from agent.schema.quote_adjust import extract_adjust
|
||||
|
||||
# 这里只用本地规则。正式调价抽取仍在调价处理里,必要时再问模型。
|
||||
if sales and extract_adjust(text, allow_model=False).is_adjust:
|
||||
return INTENT_ADJUST
|
||||
if text_is_staff_fee_quote(text):
|
||||
return INTENT_QUOTE
|
||||
return intent
|
||||
|
||||
|
||||
def _prefer_staff_quote(intent: str, text: str) -> str:
|
||||
"""模型或本地规则把费用金额判成调价时,改回报价。比例和改总价不动。"""
|
||||
if intent == INTENT_ADJUST and text_is_staff_fee_quote(text):
|
||||
logger.info("判成调价,但这是费用报价,改回报价 text=%s", (text or "")[:40])
|
||||
return INTENT_QUOTE
|
||||
return intent
|
||||
|
||||
|
||||
def classify_group_text(
|
||||
text: str,
|
||||
*,
|
||||
@@ -163,12 +220,15 @@ def classify_group_text(
|
||||
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
|
||||
return _prefer_staff_quote(got, raw)
|
||||
except Exception:
|
||||
logger.exception("群意图模型失败,回退标签字段")
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.schema.quote_adjust import extract_adjust
|
||||
|
||||
# 费用金额先当报价。「改为900」是航线/产品常用写法,不能先被调价规则抢走。
|
||||
if text_is_staff_fee_quote(raw):
|
||||
return INTENT_QUOTE
|
||||
if extract_adjust(raw).is_adjust:
|
||||
return INTENT_ADJUST
|
||||
if extract_product_quote(raw):
|
||||
@@ -249,8 +309,8 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
system = (
|
||||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"collab_fields=在补货好时间/商品海关编码/含油含电含磁;"
|
||||
"quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;"
|
||||
"product_quote=产品在报价格或费用(含具体金额);"
|
||||
"quote_adjust=销售在按比例改已有费用(上调、上浮、加10%、减5%、加一成),或只改总价/总销售价;"
|
||||
"product_quote=产品在报某一项费用的金额。写出「运费1000」「报关费改为300CNY」是报价,不是调价;"
|
||||
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
|
||||
"quote_keep_tms=要在已有TMS报价上改,没有报新金额;"
|
||||
"quote_drop_tms=不要TMS、只用产品刚报的价,没有报新金额;"
|
||||
@@ -260,7 +320,8 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
"dissolve=要把这个协同群解散;"
|
||||
"other=闲聊、问候、对询价港口的随口更正(不当改单)。"
|
||||
"一句话里既补字段又报价,回 product_quote。"
|
||||
"有金额就是 product_quote,不要判成 quote_keep_tms。"
|
||||
"有具体费用金额就是 product_quote,不要判成 quote_adjust 或 quote_keep_tms。"
|
||||
"只有比例或只改总价才是 quote_adjust。"
|
||||
)
|
||||
data = client.chat(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
|
||||
@@ -45,10 +45,55 @@ def parse_air_options(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
|
||||
"""多于一条线路才进 H5 选。"""
|
||||
"""没写起运地、且多于一条线路,才进 H5 选。有起运地时由调用方先按起运地收口。"""
|
||||
return len(parse_air_options(quote)) > 1
|
||||
|
||||
|
||||
def options_matching_origin(
|
||||
options: list[dict[str, Any]],
|
||||
origin_text: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
销售已经写了起运地时,只留起运机场对得上的线路。
|
||||
|
||||
杭州对 HGH,珠海对 ZUH。回包里的南京/深圳/郑州不能拿来给这张单选。
|
||||
对不上返回空列表。不改入参。
|
||||
"""
|
||||
text = (origin_text or "").strip()
|
||||
if not text:
|
||||
return list(options)
|
||||
code = resolve_airport_code(text).upper()
|
||||
raw = text.upper()
|
||||
matched: list[dict[str, Any]] = []
|
||||
for opt in options:
|
||||
origin = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
|
||||
if not origin:
|
||||
continue
|
||||
if code and origin == code:
|
||||
matched.append(opt)
|
||||
elif origin == raw:
|
||||
matched.append(opt)
|
||||
return matched
|
||||
|
||||
|
||||
def choose_stated_origin_option(
|
||||
options: list[dict[str, Any]],
|
||||
quote: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
起运地已指定时直接用一条,不再让销售选线。
|
||||
|
||||
只有一条就用它。多条时优先和报价总价相同的那条,否则用第一条。
|
||||
"""
|
||||
if len(options) == 1:
|
||||
return options[0]
|
||||
total = (quote or {}).get("total")
|
||||
for opt in options:
|
||||
if _same_amount(opt.get("salePrice"), total):
|
||||
return opt
|
||||
return options[0]
|
||||
|
||||
|
||||
def _place_name(code: str, fallback: str = "") -> str:
|
||||
"""三字码能对上就用中文,否则用原文或 fallback。"""
|
||||
raw = (code or "").strip()
|
||||
@@ -331,14 +376,14 @@ def _same_amount(left: Any, right: Any) -> bool:
|
||||
|
||||
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。
|
||||
已经有其它费用分项时,去掉「空运费=总价」这一行兜底。
|
||||
|
||||
真有空运费且金额不等于总价(例如另有附加费)则保留。
|
||||
舱位上有运输费、提送货费时,主账快照常再塞一行空运费,金额就是总价,不能和分项叠在一起。
|
||||
只有空运费这一项时必须留下:TMS 经常只回这一项,金额自然等于总价,群里仍要写出费用名。
|
||||
不写库。群摘要投影之后调用。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
total = src.get("total")
|
||||
if _same_amount(src.get("freight"), total):
|
||||
src["freight"] = "-"
|
||||
raw = src.get("fee_lines") or src.get("fee_rows")
|
||||
parsed: Any = raw
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
@@ -348,17 +393,26 @@ def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]
|
||||
parsed = None
|
||||
if not isinstance(parsed, list):
|
||||
return src
|
||||
kept: list[Any] = []
|
||||
others: list[Any] = []
|
||||
alias: list[Any] = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
kept.append(item)
|
||||
others.append(item)
|
||||
continue
|
||||
name = str(item.get("name") or item.get("label") or "").strip()
|
||||
name = str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
|
||||
amount = item.get("amount") or item.get("value")
|
||||
if name == "空运费" and _same_amount(amount, total):
|
||||
alias.append(item)
|
||||
continue
|
||||
kept.append(item)
|
||||
src["fee_lines"] = kept
|
||||
others.append(item)
|
||||
if others:
|
||||
# 分项已经在,空运费=总价是重复兜底。运费字段同值也不再补一行。
|
||||
src["fee_lines"] = others
|
||||
if _same_amount(src.get("freight"), total):
|
||||
src["freight"] = "-"
|
||||
return src
|
||||
# 明细就是这一条空运费,群摘要要显示费用名,不能只剩费用合计。
|
||||
src["fee_lines"] = alias
|
||||
return src
|
||||
|
||||
|
||||
|
||||
@@ -520,9 +520,10 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
|
||||
|
||||
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
|
||||
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
|
||||
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
|
||||
"""
|
||||
raw = text or ""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
out: dict[str, str] = {}
|
||||
yes_no = (
|
||||
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
|
||||
|
||||
@@ -493,6 +493,40 @@ def effective_quote(tms: dict[str, Any] | None, overlay: dict[str, Any] | None)
|
||||
return out
|
||||
|
||||
|
||||
def text_has_relative_adjust(text: str) -> bool:
|
||||
"""
|
||||
这句话是在按比例改价:加10%、减5%、上调、上浮、加一成。
|
||||
|
||||
这种是销售调价,不是航线/产品报一个新金额。不打模型。
|
||||
"""
|
||||
raw = text or ""
|
||||
if _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw):
|
||||
return True
|
||||
blob = raw.replace(" ", "").replace(" ", "")
|
||||
return any(phrase in blob for phrase in ("加一成", "减一成", "往上抬", "往下压", "上浮", "下浮"))
|
||||
|
||||
|
||||
def text_has_total_only_adjust(text: str) -> bool:
|
||||
"""
|
||||
只动总价/总销售价/费用合计,没有点名某一项费用。
|
||||
|
||||
「总销售价改为180」仍是销售调价。点了「空运费」「报关费」则不是。不打模型。
|
||||
"""
|
||||
raw = text or ""
|
||||
if not (_TARGET.search(raw) or _PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw)):
|
||||
return False
|
||||
if not any(word in raw for word in _TOTAL_WORDS) and "报价" not in raw:
|
||||
return False
|
||||
real: list[str] = []
|
||||
for name in _FEE_TOKEN.findall(raw):
|
||||
if name in {"费用", "费用合计"}:
|
||||
continue
|
||||
if any(word in name for word in ("合计", "总价", "汇总")):
|
||||
continue
|
||||
real.append(name)
|
||||
return not real
|
||||
|
||||
|
||||
def extract_adjust(
|
||||
text: str,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,9 @@ _AIRPORTS = {
|
||||
"南京": "NKG",
|
||||
"吉隆坡": "KUL",
|
||||
"北京": "PEK",
|
||||
"杭州": "HGH",
|
||||
"萧山": "HGH",
|
||||
"杭州萧山": "HGH",
|
||||
}
|
||||
|
||||
# 历史字典:只给对照,空运出站不再编成 1/2/3。
|
||||
@@ -199,7 +202,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
包装只看销售是否说了,散货等原文原样保留。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
origin = str(src.get("起运港") or "").strip()
|
||||
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
|
||||
dest = str(src.get("目的港") or "").strip()
|
||||
cargo = str(src.get("品名") or "").strip()
|
||||
pieces = _first_number(str(src.get("件数") or ""))
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
||||
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
||||
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
|
||||
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。
|
||||
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
|
||||
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
|
||||
禁止:把运输分类放进出站;直连 TMS;改六态。
|
||||
线程:纯函数。
|
||||
"""
|
||||
@@ -29,8 +29,8 @@ _TYPE_ENUM = {
|
||||
"国内运输整车": "DOMESTIC_FTL",
|
||||
"跨境集拼": "CROSS_BORDER_LTL",
|
||||
"跨境整车": "CROSS_BORDER_FTL",
|
||||
"中港整车": "CHINA_HK",
|
||||
"中港零担/集拼": "CHINA_HK",
|
||||
"中港整车": "中港",
|
||||
"中港零担/集拼": "中港",
|
||||
}
|
||||
_ROUTE_ENUM = {
|
||||
"国内长途/零担": "DOMESTIC_LONG_LTL",
|
||||
@@ -45,7 +45,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
|
||||
|
||||
|
||||
def tms_land_type_code(raw: str) -> str:
|
||||
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。"""
|
||||
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
|
||||
text = (raw or "").strip()
|
||||
if text in _TYPE_ENUM:
|
||||
return _TYPE_ENUM[text]
|
||||
|
||||
@@ -160,7 +160,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
"source_label": "TMS 标准报价",
|
||||
"quoteId": "QT-AIR-1",
|
||||
"airOptions": [
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航"}
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "airlineName": "南航", "flightNo": "CZ327"}
|
||||
],
|
||||
}
|
||||
if quote:
|
||||
@@ -203,6 +203,67 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(classify_air_group_text("帮我锁舱 WO202609160001", allow_network=False), INTENT_LOCK)
|
||||
self.assertEqual(classify_air_group_text("WO202609160001释放舱位", allow_network=False), INTENT_RELEASE)
|
||||
|
||||
def test_airline_fee_wording_is_quote_not_adjust(self) -> None:
|
||||
"""航线写「空运费改为900」是手工报价,不能先判成调价。比例和改总价仍是调价。"""
|
||||
from agent.routing.air_group_intent import INTENT_ADJUST, INTENT_QUOTE
|
||||
|
||||
self.assertEqual(
|
||||
classify_air_group_text("空运费改为900USD", allow_network=False),
|
||||
INTENT_QUOTE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text("报关费改为300CNY", allow_network=False),
|
||||
INTENT_QUOTE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text("空运费 9900CNY", allow_network=False),
|
||||
INTENT_QUOTE,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text("空运费加10%", allow_network=False),
|
||||
INTENT_ADJUST,
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_air_group_text("总销售价改为180", allow_network=False),
|
||||
INTENT_ADJUST,
|
||||
)
|
||||
|
||||
def test_airline_rewritten_fee_does_not_say_only_sales(self) -> None:
|
||||
"""已有报价后,航线再写费用金额,不能提示只有销售能调价。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="fee0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_1", mid="fee1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertNotEqual(phase, "adjust_not_sales")
|
||||
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
|
||||
self.assertIn(phase, {"wait_tms_choice", "quoted"})
|
||||
|
||||
def test_sales_percent_still_adjusts(self) -> None:
|
||||
"""销售说加百分之几,仍是调价,不会被改成航线报价。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="adj0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="空运费加10%", chat="wr_air_1", mid="adj1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertNotEqual(phase, "not_airline")
|
||||
self.assertNotIn("只有这张单的销售能调价", "\n".join(self._texts()))
|
||||
self.assertIn(phase, {"group_adjusted", "adjust_need_fee", "adjust_none", "adjust_need_lines"})
|
||||
|
||||
def test_air_group_brief_matches_sea_sections(self) -> None:
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202609160009",
|
||||
@@ -373,6 +434,51 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("费用合计:CNY 200040", tms_block)
|
||||
self.assertEqual(tms_block.count("费用合计:"), 1)
|
||||
|
||||
def test_air_group_brief_keeps_sole_air_freight_line(self) -> None:
|
||||
"""TMS 只有空运费、金额等于总价时,群摘要必须写出这一行,不能只剩费用合计。"""
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"total": "1833.7",
|
||||
"freight": "1833.70",
|
||||
"currency": "CNY",
|
||||
"valid_until": "2026-10-21",
|
||||
"fee_lines": '[{"name":"空运费","amount":"CNY 1833.70"}]',
|
||||
"feeItems": [
|
||||
{
|
||||
"feeName": "空运费",
|
||||
"feeCode": "AIR_FREIGHT",
|
||||
"amount": 1833.70,
|
||||
"unitPrice": 11.00,
|
||||
"quantity": 166.7,
|
||||
"currency": "CNY",
|
||||
}
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "CGO",
|
||||
"destinationAirportCode": "KUL",
|
||||
"salePrice": 1833.70,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202610080020",
|
||||
facts={"起运地": "CGO", "目的地": "吉隆坡", "originCode": "CGO"},
|
||||
quote=tms,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=tms,
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertIn("空运费:CNY 1833.70", tms_block)
|
||||
self.assertLess(tms_block.find("空运费:"), tms_block.find("费用合计:"))
|
||||
self.assertIn("费用合计:CNY 1833.7", tms_block)
|
||||
self.assertEqual(tms_block.count("费用合计:"), 1)
|
||||
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
|
||||
@@ -636,6 +742,45 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "need_work_order")
|
||||
|
||||
def test_activated_multi_quote_follows_without_work_order(self) -> None:
|
||||
"""多段激活后,报价不必再带工单号,也不要回「请先 @我」。"""
|
||||
import json
|
||||
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="MULTI",
|
||||
facts={
|
||||
"segments_json": json.dumps(
|
||||
[
|
||||
{"index": 1, "mode": "LAND", "facts": {"起运港": "北京", "目的港": "上海"}},
|
||||
{"index": 2, "mode": "AIR", "facts": {"起运港": "上海", "目的港": "北宁"}},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
self.ledger.bind_collab_group(
|
||||
work_order_no=wo,
|
||||
chat_id="wr_multi",
|
||||
member_ids=["sales1", "air1"],
|
||||
product_ids=["air1"],
|
||||
product_names=["HangXian"],
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费 100USD", chat="wr_multi", mid="mq1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertEqual(phase, "segment_quoted")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertNotIn("请先 @我", joined)
|
||||
self.assertNotIn("并发送要跟的工单号", joined)
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
air_quote = (ticket.quote or {}).get("segment_quotes", {}).get("AIR") or {}
|
||||
self.assertTrue(air_quote.get("total") or air_quote.get("fee_rows"))
|
||||
|
||||
def test_sales_can_fill_battery(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
@@ -944,35 +1089,146 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_incomplete")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("舱位编号", joined)
|
||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_with_option_airline_can_lock(self) -> None:
|
||||
"""人工价补齐舱位+航司+航班后可锁;计费重用工单毛重兜底。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "100",
|
||||
"source": "manual",
|
||||
"source_label": "产品报价",
|
||||
"currency": "CNY",
|
||||
},
|
||||
status="已报价",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lock_params")
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="air1",
|
||||
text=f"{wo}锁舱,锁AIR-OPT01 CZ 航班CZ327",
|
||||
chat="wr_air_lock_manual2",
|
||||
text="AIR-OPT01 CZ 航班CZ327",
|
||||
chat="wr_air_lock_manual",
|
||||
mid="lm2",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_pick_option",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_lock_missing_params_asks_and_does_not_call_tms(self) -> None:
|
||||
"""缺舱位、航司、航班号时只请补,不锁舱、不报 TMS 失败、不通知 IT。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "CNY 1800",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS 标准报价",
|
||||
"quoteId": "QT-AIR-23",
|
||||
}
|
||||
)
|
||||
calls: list[dict] = []
|
||||
orig = self.ledger.lock_cabin
|
||||
|
||||
def spy(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return orig(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = spy # type: ignore
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_ask", mid="ask1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lock_params")
|
||||
self.assertEqual(calls, [])
|
||||
asked = self._texts()[-1]
|
||||
self.assertEqual(
|
||||
asked,
|
||||
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
|
||||
)
|
||||
self.assertNotIn("已通知", asked)
|
||||
self.assertNotIn("锁舱失败", asked)
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="air1",
|
||||
text="AIR-OPT01 CZ 航班CZ327",
|
||||
chat="wr_air_lock_ask",
|
||||
mid="ask2",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_pick_option",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
|
||||
self.assertEqual(calls[0].get("airline"), "CZ")
|
||||
self.assertEqual(calls[0].get("flight_no"), "CZ327")
|
||||
|
||||
def test_lock_accepts_free_wording_not_only_sample_format(self) -> None:
|
||||
"""补舱位不限格式。点名舱位、航司、航班号,换顺序、用航司中文名,也要锁。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "CNY 1800",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS 标准报价",
|
||||
"quoteId": "QT-AIR-24",
|
||||
}
|
||||
)
|
||||
calls: list[dict] = []
|
||||
orig = self.ledger.lock_cabin
|
||||
|
||||
def spy(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return orig(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = spy # type: ignore
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_free", mid="free1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lock_params")
|
||||
self.assertEqual(calls, [])
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="air1",
|
||||
text="航班号:CZ327,航司南航,舱位编号是AIR-OPT01",
|
||||
chat="wr_air_lock_free",
|
||||
mid="free2",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertEqual(calls[0].get("option_no"), "AIR-OPT01")
|
||||
self.assertEqual(calls[0].get("airline"), "CZ")
|
||||
self.assertEqual(calls[0].get("flight_no"), "CZ327")
|
||||
|
||||
def test_lock_sentence_with_labeled_params_locks_immediately(self) -> None:
|
||||
"""同一句里已经说齐三项,直接锁,不再要求按示例格式重说。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "CNY 1800",
|
||||
"source": "manual",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
)
|
||||
calls: list[dict] = []
|
||||
orig = self.ledger.lock_cabin
|
||||
|
||||
def spy(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return orig(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = spy # type: ignore
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="air1",
|
||||
text=f"{wo}锁舱,舱位 AIR-OPT02,东方航空,航班号 MU5123",
|
||||
chat="wr_air_lock_once",
|
||||
mid="once1",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].get("option_no"), "AIR-OPT02")
|
||||
self.assertEqual(calls[0].get("airline"), "MU")
|
||||
self.assertEqual(calls[0].get("flight_no"), "MU5123")
|
||||
self.assertFalse(any("锁舱参数不完整" in t for t in self._texts()))
|
||||
|
||||
def test_lock_success_and_release_after_deal_blocked(self) -> None:
|
||||
wo = self._create_air()
|
||||
@@ -1060,8 +1316,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
"source": "TMS",
|
||||
"quoteId": "QT-AIR-2",
|
||||
"airOptions": [
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ"},
|
||||
{"optionNo": "AIR-OPT-02", "airlineCode": "MU"},
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"},
|
||||
{"optionNo": "AIR-OPT-02", "airlineCode": "MU", "flightNo": "MU5123"},
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -1071,7 +1327,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lock_option")
|
||||
self.assertEqual(phase, "wait_lock_params")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="AIR-OPT-02", chat="wr_air_1", mid="m2"),
|
||||
flow=self.flow,
|
||||
@@ -1166,6 +1422,31 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("请确认成交状态", texts)
|
||||
self.assertFalse(any(x.get("kind") == "file" for x in added))
|
||||
|
||||
def test_reactivate_quoted_tms_file_is_deal_not_sheet(self) -> None:
|
||||
"""TMS 价的报价单已经发出、停在成交跟进:再发工单号只回成交跟进。"""
|
||||
wo = self._create_air(
|
||||
quote={
|
||||
"total": "CNY 3500.70",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS 标准报价",
|
||||
"quoteId": "QT-AIR-FILE",
|
||||
"quoteFileIssued": True,
|
||||
"airOptions": [
|
||||
{"optionNo": "AIR-OPT-01", "airlineCode": "CZ", "flightNo": "CZ327"}
|
||||
],
|
||||
}
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="react-deal"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated_deal")
|
||||
texts = "\n".join(self._texts())
|
||||
self.assertIn("请确认成交状态", texts)
|
||||
self.assertNotIn("询价字段:", texts)
|
||||
self.assertFalse(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
|
||||
def test_image_rejected(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
@@ -1396,6 +1677,38 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertNotIn("报价选项不存在", joined)
|
||||
self.assertNotIn("与工单不匹配", joined)
|
||||
|
||||
def test_lock_fail_does_not_block_airline_quote(self) -> None:
|
||||
"""锁舱失败后,航线再报手工价必须收下,不能回 TMS 接口异常。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_lock_quote", mid="lq0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
"error": "lock_fail",
|
||||
"failReason": "该航班日期不存在可锁定的配载记录",
|
||||
}
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text=f"{wo}锁舱", chat="wr_air_lock_quote", mid="lq1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "system_exception")
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费改为900USD", chat="wr_air_lock_quote", mid="lq2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
)
|
||||
self.assertNotEqual(phase, "system_exception_paused")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertNotIn("TMS接口异常", joined)
|
||||
self.assertIn(phase, {"wait_tms_choice", "quoted"})
|
||||
|
||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
||||
wo = self._create_air()
|
||||
|
||||
@@ -106,6 +106,82 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertEqual(sess.facts.get("起运港"), "深圳")
|
||||
self.assertIn("SZX-CRK", "\n".join(replies[-2:]))
|
||||
|
||||
def test_hangzhou_query_uses_airport_and_skips_other_cities(self) -> None:
|
||||
"""写了杭州就不能按目的地把南京、深圳、郑州拿来选。"""
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
ready = assemble_air_query({"起运港": "杭州", "目的港": "吉隆坡"})
|
||||
self.assertEqual(ready["facts"].get("originCode"), "HGH")
|
||||
self.assertEqual(ready["facts"].get("destinationCode"), "KUL")
|
||||
|
||||
class Ledger(MemoryLedger):
|
||||
def query_tms(self, **kwargs):
|
||||
out = super().query_tms(**kwargs)
|
||||
quote = out["quote"]
|
||||
quote["total"] = "1750.35"
|
||||
quote["currency"] = "CNY"
|
||||
quote["airOptions"] = [
|
||||
{"originAirportCode": "NKG", "destinationAirportCode": "KUL", "salePrice": 1750.35, "currency": "CNY"},
|
||||
{"originAirportCode": "SZX", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
|
||||
{"originAirportCode": "CGO", "destinationAirportCode": "KUL", "salePrice": 1833.7, "currency": "CNY"},
|
||||
]
|
||||
return out
|
||||
|
||||
flow = AirTextInquiryFlow(Ledger())
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
facts = {
|
||||
"起运港": "杭州",
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "化妆品",
|
||||
"件数": "12",
|
||||
"毛重": "21",
|
||||
"体积": "1",
|
||||
"包装方式": "散货",
|
||||
}
|
||||
self.assertEqual(
|
||||
flow.on_text(sender_id="u_hgh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR"),
|
||||
"wait_confirm",
|
||||
)
|
||||
phase = flow.on_text(sender_id="u_hgh", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "tms_miss")
|
||||
self.assertFalse(any("条线路" in x for x in replies))
|
||||
self.assertEqual(flow.session_of("u_hgh").facts.get("originCode"), "HGH")
|
||||
|
||||
def test_stated_origin_quotes_directly(self) -> None:
|
||||
"""起运地已经是珠海时,即使 TMS 还带回别的城市,也不再让销售选线。"""
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
facts = {
|
||||
"起运港": "珠海",
|
||||
"目的港": "克拉克",
|
||||
"品名": "茶叶",
|
||||
"件数": "10",
|
||||
"毛重": "100",
|
||||
"体积": "2",
|
||||
"包装方式": "托盘",
|
||||
"tms_multi": "1",
|
||||
}
|
||||
flow.on_text(sender_id="u_zuh", text="空运", reply=reply, injected_facts=facts, injected_mode="AIR")
|
||||
phase = flow.on_text(sender_id="u_zuh", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertFalse(any("条线路" in x for x in replies))
|
||||
self.assertEqual(flow.session_of("u_zuh").facts.get("起运港"), "珠海")
|
||||
|
||||
def test_reselect_after_adopt_voids_old_file(self) -> None:
|
||||
"""采用后再改选:旧报价单作废,回到方案卡重新采用。"""
|
||||
ledger = MemoryLedger()
|
||||
@@ -331,7 +407,10 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertNotIn("window.close()", ok)
|
||||
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
|
||||
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
|
||||
self.assertNotIn("window.close()", mobile)
|
||||
self.assertIn("window.close()", desktop)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
|
||||
|
||||
@@ -900,6 +900,10 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("引用历史工单号", sub)
|
||||
self.assertNotIn("工单状态", sub)
|
||||
self.assertTrue(sub.endswith(copy.collab_hint("WO202609140001")))
|
||||
self.assertIn(
|
||||
"请手动将相关方拉到空运原有协同群,并在空运协同群中发送“ WO202609140001@询价小助手”,谢谢。",
|
||||
sub,
|
||||
)
|
||||
self.assertNotIn("内部协同", json.dumps(card, ensure_ascii=False))
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
@@ -1555,7 +1559,8 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
source="test",
|
||||
)
|
||||
self.assertEqual(replay, "wait_deal")
|
||||
self.assertNotIn("咨询成交状态", "\n".join(again))
|
||||
self.assertIn("咨询成交状态", "\n".join(again))
|
||||
self.assertNotIn("请稍候", "\n".join(again))
|
||||
self.assertEqual(http.session_of("u-pdf-x").phase, "wait_deal")
|
||||
|
||||
def test_deal_click_works_while_http_still_wait_file(self) -> None:
|
||||
|
||||
@@ -108,6 +108,12 @@ class AttachmentUploadH5Tests(unittest.TestCase):
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertIn("function isPhone()", ok)
|
||||
self.assertIn("WeixinJSBridge.invoke", ok)
|
||||
mobile = ok.split("function closeMobile()")[1].split("function closeDesktop()")[0]
|
||||
desktop = ok.split("function closeDesktop()")[1].split("function boot()")[0]
|
||||
self.assertNotIn("window.close()", mobile)
|
||||
self.assertIn("window.close()", desktop)
|
||||
self.assertNotIn("closeWindow", page)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
sender_id="u6", work_order_no=wo, reply=reply
|
||||
)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
self.assertFalse(any("成交" in x or copy.BTN_DEAL in x for x in replies))
|
||||
self.assertTrue(any("【成交】【未成交】【协商中】" in x for x in replies))
|
||||
|
||||
def test_resume_spoken_deal_closes(self) -> None:
|
||||
"""报价文件已出后,原话写工单号加已成交,要入账并回复,不能没反应。"""
|
||||
|
||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
||||
from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
air_route_keys,
|
||||
is_workday,
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
shift_workdays,
|
||||
@@ -20,33 +21,42 @@ from agent.policy.deal_followup import (
|
||||
|
||||
class TestWorkdays(unittest.TestCase):
|
||||
def test_skip_weekend(self) -> None:
|
||||
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
|
||||
# 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。
|
||||
self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18))
|
||||
self.assertEqual(shift_workdays(date(2026, 11, 13), 4), date(2026, 11, 19))
|
||||
|
||||
def test_sept20_makeup_pulls_followup_forward(self) -> None:
|
||||
# 2026-09-18 周五。9 月 20 日周日调休上班,第四个工作日是 23 日,不是 24 日。
|
||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 23))
|
||||
|
||||
def test_skip_national_day(self) -> None:
|
||||
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
|
||||
# 2026-09-30 周三。10 月 1–7 日放假,8 日上班,10 日周六调休。
|
||||
# 节后第 1 个工作日 8 日,第 4 个是 12 日周一。
|
||||
fourth = shift_workdays(date(2026, 9, 30), 4)
|
||||
self.assertGreaterEqual(fourth, date(2026, 10, 9))
|
||||
self.assertNotEqual(fourth.weekday(), 5)
|
||||
self.assertNotEqual(fourth.weekday(), 6)
|
||||
self.assertEqual(fourth, date(2026, 10, 12))
|
||||
|
||||
def test_oct8_works_and_sept20_is_makeup(self) -> None:
|
||||
# 国办发明电〔2025〕7号:10 月 8 日正常上班,9 月 20 日周日调休上班。
|
||||
self.assertTrue(is_workday(date(2026, 10, 8)))
|
||||
self.assertTrue(is_workday(date(2026, 9, 20)))
|
||||
self.assertFalse(is_workday(date(2026, 10, 7)))
|
||||
|
||||
def test_next_followup_is_9am(self) -> None:
|
||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||
self.assertEqual(when.hour, 9)
|
||||
self.assertEqual(when.minute, 0)
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 24))
|
||||
self.assertEqual(when.date(), date(2026, 9, 23))
|
||||
|
||||
def test_skip_weekend_keep_weekday_night(self) -> None:
|
||||
start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五
|
||||
now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一
|
||||
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
|
||||
now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一
|
||||
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
|
||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
|
||||
|
||||
def test_rest_day_start_counts_from_next_workday(self) -> None:
|
||||
start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六
|
||||
now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一
|
||||
start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六
|
||||
now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一
|
||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
|
||||
|
||||
def test_fractional_quarter_hour(self) -> None:
|
||||
|
||||
@@ -193,11 +193,11 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
|
||||
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
|
||||
|
||||
def test_lost_reason_ack_fills_ticket_no_from_bot_reply(self) -> None:
|
||||
"""后台文案带 {{ticket_no}} 时必须填实值,禁止原样打出占位符。"""
|
||||
from agent.policy import bot_reply
|
||||
def test_lost_reason_ack_fills_ticket_no(self) -> None:
|
||||
"""后台模板带工单号时,必须换成真实单号,不能把占位符原样发出去。"""
|
||||
from agent.policy.bot_reply import override_catalog
|
||||
|
||||
bot_reply.override_catalog(
|
||||
override_catalog(
|
||||
{
|
||||
"lost.reason_ack": (
|
||||
"已将未成交原因“{{lost_reason}}”记录在后台,"
|
||||
@@ -206,12 +206,12 @@ class TestDealOutcome(unittest.TestCase):
|
||||
}
|
||||
)
|
||||
try:
|
||||
text = copy.lost_reason_ack("换时间", work_order_no="WO202610070018")
|
||||
self.assertIn("WO202610070018", text)
|
||||
self.assertNotIn("{{ticket_no}}", text)
|
||||
self.assertIn("换时间", text)
|
||||
self.assertEqual(
|
||||
copy.lost_reason_ack("客户不需要了", work_order_no="WO202609100001"),
|
||||
"已将未成交原因“客户不需要了”记录在后台,工单WO202609100001已关闭,智能体不再跟进该工单,请悉知。",
|
||||
)
|
||||
finally:
|
||||
bot_reply.override_catalog(None)
|
||||
override_catalog(None)
|
||||
|
||||
def test_harvest_inline_lost_reason(self) -> None:
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
|
||||
|
||||
@@ -20,6 +20,7 @@ from agent.policy.event_exception import (
|
||||
WAIT_PRODUCT_QUOTE,
|
||||
conversation_of,
|
||||
format_timeout_hours,
|
||||
mentions_for_role,
|
||||
pick_app_targets,
|
||||
pick_mentions,
|
||||
timeout_code,
|
||||
@@ -221,6 +222,9 @@ class TestPoll(unittest.TestCase):
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
|
||||
# 钉在周五,避免周日跑单测时工作日折算为 0
|
||||
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||
|
||||
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||
spec = importlib.util.spec_from_file_location("ee_poll", path)
|
||||
self.assertIsNotNone(spec)
|
||||
@@ -247,9 +251,7 @@ class TestPoll(unittest.TestCase):
|
||||
t = book.get_ticket(work_order_no=wo)
|
||||
t.collab_chat_id = "wr1"
|
||||
t.collab_member_ids = ["p1"]
|
||||
t.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=3)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
t.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
book._timeouts["sea_product_other"] = 0.25
|
||||
groups: list[dict] = []
|
||||
|
||||
@@ -257,7 +259,6 @@ class TestPoll(unittest.TestCase):
|
||||
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
||||
return True
|
||||
|
||||
now = datetime.now(SHANGHAI)
|
||||
n = poll_event_exceptions(
|
||||
ledger=book, now=now, send_group=send_group, notify_ready=False
|
||||
)
|
||||
@@ -270,6 +271,349 @@ class TestPoll(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
|
||||
def test_air_group_names_airline_when_members_missing(self) -> None:
|
||||
"""空运群没记下成员时,超时要 @ 航线姓名,不能写成相关人员。"""
|
||||
import importlib.util
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
|
||||
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||
spec = importlib.util.spec_from_file_location("ee_poll_air", path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
book = MemoryLedger()
|
||||
book.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"wecomId": "sales1",
|
||||
"name": "罗小华",
|
||||
"roleCode": "sales",
|
||||
"status": "active",
|
||||
},
|
||||
{
|
||||
"wecomId": "air1",
|
||||
"name": "吴季林",
|
||||
"roleCode": "air",
|
||||
"status": "active",
|
||||
},
|
||||
]
|
||||
)
|
||||
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"起运港": "南京"})
|
||||
wo = created["work_order_no"]
|
||||
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
ticket.collab_chat_id = "wr-air"
|
||||
ticket.collab_member_ids = []
|
||||
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
book._timeouts["air_route"] = 0.25
|
||||
groups: list[dict] = []
|
||||
|
||||
def send_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||||
groups.append({"chat_id": chat_id, "content": content, "line": business_line})
|
||||
return True
|
||||
|
||||
n = mod.poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=now,
|
||||
send_group=send_group,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(
|
||||
groups[0]["content"],
|
||||
f"航线响应超时\n关联询价工单 {wo}。@吴季林已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
self.assertNotIn("相关人员", groups[0]["content"])
|
||||
|
||||
def test_sales_timeout_mentions_sales_not_airline(self) -> None:
|
||||
"""销售超时只 @ 本单销售。私聊也带 @,不点航线,不写相关人员。"""
|
||||
import importlib.util
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
|
||||
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||
spec = importlib.util.spec_from_file_location("ee_poll_sales", path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
book = MemoryLedger()
|
||||
book.set_sea_staff(
|
||||
[
|
||||
{"wecomId": "sales1", "name": "罗小华", "roleCode": "sales", "status": "active"},
|
||||
{"wecomId": "air1", "name": "吴季林", "roleCode": "air", "status": "active"},
|
||||
]
|
||||
)
|
||||
now = datetime(2026, 10, 9, 18, 0, 0, tzinfo=SHANGHAI)
|
||||
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
|
||||
wo = created["work_order_no"]
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="adopt")
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
ticket.event_wait_started_at = (now - timedelta(hours=3)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
book._timeouts["air_sales"] = 0.25
|
||||
private: list[dict] = []
|
||||
|
||||
def enqueue(**kwargs):
|
||||
private.append(kwargs)
|
||||
return True, ""
|
||||
|
||||
def send_group(**kwargs) -> bool:
|
||||
raise AssertionError("销售采用超时不应发到群")
|
||||
|
||||
n = mod.poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=now,
|
||||
enqueue=enqueue,
|
||||
send_group=send_group,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(len(private), 1)
|
||||
self.assertEqual(
|
||||
private[0]["content"],
|
||||
f"销售响应超时\n关联询价工单 {wo}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
self.assertNotIn("吴季林", private[0]["content"])
|
||||
self.assertNotIn("相关人员", private[0]["content"])
|
||||
|
||||
created_deal = book.create_ticket(sender_id="sales1", business_line="AIR", facts={"目的港": "胡志明"})
|
||||
deal_no = created_deal["work_order_no"]
|
||||
book.start_event_wait(work_order_no=deal_no, wait_kind="deal")
|
||||
deal = book.get_ticket(work_order_no=deal_no)
|
||||
deal.collab_chat_id = "wr-air"
|
||||
deal.event_wait_started_at = ticket.event_wait_started_at
|
||||
groups: list[dict] = []
|
||||
|
||||
def send_deal_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||||
groups.append({"chat_id": chat_id, "content": content})
|
||||
return True
|
||||
|
||||
n_deal = mod.poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=now,
|
||||
enqueue=enqueue,
|
||||
send_group=send_deal_group,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n_deal, 1)
|
||||
self.assertEqual(
|
||||
groups[0]["content"],
|
||||
f"销售响应超时\n关联询价工单 {deal_no}。@罗小华已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
self.assertNotIn("吴季林", groups[0]["content"])
|
||||
|
||||
|
||||
class TestMentionsForRole(unittest.TestCase):
|
||||
def test_keeps_active_named_role_only(self) -> None:
|
||||
rows = mentions_for_role(
|
||||
[
|
||||
{"wecomId": "a1", "name": "吴季林", "roleCode": "air", "status": "active"},
|
||||
{"wecomId": "s1", "name": "罗小华", "roleCode": "sales", "status": "active"},
|
||||
{"wecomId": "a2", "name": "停用航线", "roleCode": "air", "status": "disabled"},
|
||||
{"wecomId": "a3", "name": "", "roleCode": "air", "status": "active"},
|
||||
],
|
||||
role_code="air",
|
||||
)
|
||||
self.assertEqual([row["name"] for row in rows], ["吴季林"])
|
||||
|
||||
|
||||
class TestDealOutcomeStopsTimeout(unittest.TestCase):
|
||||
"""对应现网截图:协商中顺延 / 未成交已写原因后不应再发销售响应超时。"""
|
||||
|
||||
def _poll_mod(self):
|
||||
import importlib.util
|
||||
|
||||
path = os.path.join(ROOT, "agent", "jobs", "event_exception_poll.py")
|
||||
spec = importlib.util.spec_from_file_location("ee_poll_deal_stop", path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
def _book_with_deal_wait(self):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
|
||||
book = MemoryLedger()
|
||||
book.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"wecomId": "sales1",
|
||||
"name": "罗小华",
|
||||
"roleCode": "sales",
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
created = book.create_ticket(sender_id="sales1", business_line="AIR", facts={})
|
||||
wo = created["work_order_no"]
|
||||
book.transition(work_order_no=wo, to_status="已报价")
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="deal")
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
ticket.collab_chat_id = "wr-deal"
|
||||
ticket.event_wait_started_at = (datetime.now(SHANGHAI) - timedelta(hours=72)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
book._timeouts["air_sales"] = 48
|
||||
return book, wo
|
||||
|
||||
def test_negotiate_clears_wait_no_timeout(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
book, wo = self._book_with_deal_wait()
|
||||
result = finish_deal_outcome(
|
||||
ledger=book,
|
||||
work_order_no=wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={"total": "100"},
|
||||
business_line="AIR",
|
||||
sales_name="罗小华",
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertTrue(result.get("ok"))
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
groups: list[dict] = []
|
||||
|
||||
n = self._poll_mod().poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=datetime.now(SHANGHAI),
|
||||
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(groups, [])
|
||||
|
||||
def test_lost_with_reason_clears_wait_no_timeout(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
book, wo = self._book_with_deal_wait()
|
||||
result = finish_deal_outcome(
|
||||
ledger=book,
|
||||
work_order_no=wo,
|
||||
outcome="未成交",
|
||||
facts={},
|
||||
quote={"total": "100"},
|
||||
business_line="AIR",
|
||||
sales_name="罗小华",
|
||||
lost_reason="客户不要了",
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertTrue(result.get("ok"))
|
||||
self.assertEqual(result.get("phase"), "done")
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "未成交")
|
||||
self.assertEqual(ticket.lost_reason, "客户不要了")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
groups: list[dict] = []
|
||||
|
||||
n = self._poll_mod().poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=datetime.now(SHANGHAI),
|
||||
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(groups, [])
|
||||
|
||||
def test_stale_deal_wait_during_negotiate_is_skipped(self) -> None:
|
||||
"""截图回归:协商中已顺延,即使库里仍留着陈旧 deal 等待,也不再发超时。"""
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
book, wo = self._book_with_deal_wait()
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
started = ticket.event_wait_started_at
|
||||
result = finish_deal_outcome(
|
||||
ledger=book,
|
||||
work_order_no=wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={"total": "100"},
|
||||
business_line="AIR",
|
||||
sales_name="罗小华",
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertTrue(result.get("ok"))
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertTrue((ticket.next_followup_at or "").strip())
|
||||
# 人为留下陈旧等待(模拟 clear/迁态未清干净),巡检必须跳过
|
||||
ticket.event_wait_kind = "deal"
|
||||
ticket.event_wait_started_at = started
|
||||
ticket.event_wait_alerted = 0
|
||||
groups: list[dict] = []
|
||||
|
||||
n = self._poll_mod().poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=datetime.now(SHANGHAI),
|
||||
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(groups, [])
|
||||
|
||||
def test_stale_deal_wait_after_lost_reason_is_skipped(self) -> None:
|
||||
"""截图回归:未成交原因已记,陈旧 deal 等待不再触发销售超时。"""
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.policy.deal_outcome import finish_deal_outcome
|
||||
|
||||
book, wo = self._book_with_deal_wait()
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
started = ticket.event_wait_started_at
|
||||
result = finish_deal_outcome(
|
||||
ledger=book,
|
||||
work_order_no=wo,
|
||||
outcome="未成交",
|
||||
facts={},
|
||||
quote={"total": "100"},
|
||||
business_line="AIR",
|
||||
sales_name="罗小华",
|
||||
lost_reason="客户不要了",
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertTrue(result.get("ok"))
|
||||
ticket = book.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.lost_reason, "客户不要了")
|
||||
ticket.event_wait_kind = "deal"
|
||||
ticket.event_wait_started_at = started
|
||||
ticket.event_wait_alerted = 0
|
||||
groups: list[dict] = []
|
||||
|
||||
n = self._poll_mod().poll_event_exceptions(
|
||||
ledger=book,
|
||||
now=datetime.now(SHANGHAI),
|
||||
send_group=lambda **kwargs: groups.append(kwargs) or True,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertEqual(n, 0)
|
||||
self.assertEqual(groups, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -436,6 +436,29 @@ class ClarifyPasteTests(unittest.TestCase):
|
||||
self.assertNotIn("包装方式", facts)
|
||||
self.assertNotEqual(facts.get("包装方式"), "托盘、散货")
|
||||
|
||||
def test_blank_cargo_ready_does_not_take_next_item_number(self) -> None:
|
||||
"""货好时间空着时,下一行「4.提货地址」的编号不能写进货好时间。示例行也不算填了。"""
|
||||
text = """
|
||||
1.包装类型(必填):托盘
|
||||
(如:托盘、散货)
|
||||
2.货源地(非必填):
|
||||
(如:东莞)
|
||||
3.货好时间(非必填):
|
||||
(如:随时可提)
|
||||
4.提货地址(非必填):
|
||||
(如:东莞仓库)
|
||||
5.收货地址(非必填):
|
||||
(如:马尼拉)
|
||||
6.贸易条款(非必填):
|
||||
(如:DDP、DAP、CIF、CIP)
|
||||
"""
|
||||
facts = extract_inquiry_snapshot(text, allow_b=False)["facts"]
|
||||
self.assertEqual(facts.get("包装方式") or facts.get("包装类型"), "托盘")
|
||||
self.assertNotIn("货好时间", facts)
|
||||
self.assertNotIn("提货地址", facts)
|
||||
self.assertNotIn("货源地", facts)
|
||||
self.assertNotIn("贸易条款", facts)
|
||||
|
||||
|
||||
class ImageOptionalClipTests(unittest.TestCase):
|
||||
def test_numbered_extra_lines_do_not_enter_fields(self) -> None:
|
||||
|
||||
@@ -317,6 +317,31 @@ class LandGroupCollabTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
|
||||
def test_product_fee_change_is_quote_not_adjust(self) -> None:
|
||||
"""产品写「报关费改为300」是手工报价。已有报价后也不能被拦成只有销售能调价。"""
|
||||
from agent.routing.group_intent import classify_group_text
|
||||
|
||||
self.assertEqual(classify_group_text("报关费改为300CNY", allow_network=False), INTENT_QUOTE)
|
||||
self.assertEqual(classify_group_text("运费加 10%", allow_network=False), INTENT_ADJUST)
|
||||
wo, _ = self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||||
handle_group_collab(
|
||||
self._msg(sender="p_dom", text="运费 1000", mid="q_fee0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent=INTENT_QUOTE,
|
||||
injected_quote={"total": "1000", "source_label": "产品报价"},
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_group_collab(
|
||||
self._msg(sender="p_dom", text="报关费改为300CNY", mid="q_fee1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertNotEqual(phase, "adjust_not_sales")
|
||||
self.assertNotIn("只有这张单的销售能调价", self._group_texts())
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertTrue(ticket.quote)
|
||||
|
||||
def test_product_cannot_adjust(self) -> None:
|
||||
self._open(option_text="国内运输拼车 国内长途/零担 急件")
|
||||
self.groups.sent.clear()
|
||||
@@ -327,7 +352,13 @@ class LandGroupCollabTests(unittest.TestCase):
|
||||
injected_intent=INTENT_ADJUST,
|
||||
)
|
||||
self.assertEqual(phase, "adjust_not_sales")
|
||||
self.assertIn("只有这张单的销售能调价", self._group_texts())
|
||||
self.assertIn("只有这张单的销售能调价。产品请继续报价,价格未改。", self._group_texts())
|
||||
|
||||
def test_air_room_adjust_names_airline(self) -> None:
|
||||
self.assertEqual(
|
||||
copy.adjust_only_sales(air_room=True),
|
||||
"只有这张单的销售能调价。航线请继续报价,价格未改。",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -11,6 +11,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.group_collab import handle_group_inbound
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.multi_group_ops import deal_text, handle_multi_inbound
|
||||
from agent.policy.multi_segments import Segment
|
||||
@@ -387,7 +391,8 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertEqual(len(self.client.files), 2)
|
||||
self.assertTrue(self.ledger.last_render.get("ok"))
|
||||
|
||||
def test_lock_on_air_segment_does_not_wait_for_other_quotes(self) -> None:
|
||||
def test_lock_on_air_segment_says_multi_unsupported(self) -> None:
|
||||
"""多段含空运时航线锁舱,原因写成暂不支持,不再说不是空运单。"""
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales",
|
||||
business_line="MULTI",
|
||||
@@ -412,9 +417,128 @@ class QuoteTests(unittest.TestCase):
|
||||
text=f"{no}锁舱",
|
||||
sheet_fn=self.sheet,
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertEqual(phase, "multi_lock_unsupported")
|
||||
joined = "\n".join(self.client.texts)
|
||||
self.assertIn(f"工单{no} 空运锁舱失败。", joined)
|
||||
self.assertIn("操作人:航线甲", joined)
|
||||
self.assertIn("锁舱失败的原因:多段询价,暂时不支持锁舱", joined)
|
||||
self.assertNotIn("不是空运单,不能锁舱", joined)
|
||||
self.assertNotIn("已通知IT", joined)
|
||||
self.assertNotIn("请稍等", joined)
|
||||
self.assertFalse(any("其他段报完" in text for text in self.client.texts))
|
||||
|
||||
|
||||
class _Bot:
|
||||
"""替掉真实桥,记下这一次群里实际会发出去的字。"""
|
||||
|
||||
made: list["_Bot"] = []
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
_ = args, kwargs
|
||||
self.texts: list[str] = []
|
||||
_Bot.made.append(self)
|
||||
|
||||
def send_group(self, *, chat_id: str, content: str, mention_userids: list[str] | None = None) -> dict:
|
||||
_ = chat_id, mention_userids
|
||||
self.texts.append(content)
|
||||
return {"ok": True}
|
||||
|
||||
def send_group_file(self, **kwargs: object) -> dict:
|
||||
_ = kwargs
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class ArchiveSenderTests(unittest.TestCase):
|
||||
"""会话存档听到的报价:含空运走机器人,海运陆运仍走应用自己建的群。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
_Bot.made = []
|
||||
self.ledger = MemoryLedger()
|
||||
self.app = _Client()
|
||||
self.flow = _Flow(self.ledger, self.app)
|
||||
|
||||
def _message(self, chat_id: str, sender_id: str, text: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
sender_id=sender_id,
|
||||
message_id="m1",
|
||||
content=text,
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
def test_air_multi_quote_goes_through_bot(self) -> None:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales",
|
||||
business_line="MULTI",
|
||||
facts={
|
||||
"segments_json": json.dumps(
|
||||
[
|
||||
{"index": 1, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
|
||||
{"index": 2, "mode": "AIR", "facts": {"目的港": "北宁"}},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
|
||||
self.ledger.upsert_staff({"wecomId": "air1", "name": "航线甲", "roleCode": "air", "status": "active"})
|
||||
no = created["work_order_no"]
|
||||
self.ledger.bind_collab_group(
|
||||
work_order_no=no,
|
||||
chat_id="bot-room",
|
||||
member_ids=["sales", "air1"],
|
||||
product_names=["航线甲"],
|
||||
product_ids=["air1"],
|
||||
)
|
||||
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
|
||||
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
|
||||
phase = handle_group_inbound(
|
||||
self._message("bot-room", "air1", "空运费 9900CNY"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "segment_quoted")
|
||||
self.assertFalse(self.app.texts)
|
||||
self.assertTrue(_Bot.made)
|
||||
self.assertTrue(any("报价单已发出" in text for text in _Bot.made[-1].texts))
|
||||
|
||||
def test_sea_land_quote_stays_on_app_client(self) -> None:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales",
|
||||
business_line="MULTI",
|
||||
facts={
|
||||
"segments_json": json.dumps(
|
||||
[
|
||||
{"index": 1, "mode": "SEA", "facts": {"起运港": "广州港", "目的港": "巴生港"}},
|
||||
{"index": 2, "mode": "LAND", "facts": {"线路类别": "国内长途/零担"}},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
self.ledger.set_sea_staff(
|
||||
[{"wecomId": "sea1", "name": "海产", "roleCode": "sea", "status": "active", "routes": "海运·全部线路"}]
|
||||
)
|
||||
self.ledger.upsert_staff({"wecomId": "sales", "name": "销售甲", "roleCode": "sales", "status": "active"})
|
||||
no = created["work_order_no"]
|
||||
self.ledger.bind_collab_group(
|
||||
work_order_no=no,
|
||||
chat_id="app-room",
|
||||
member_ids=["sales", "sea1"],
|
||||
product_names=["海产"],
|
||||
product_ids=["sea1"],
|
||||
)
|
||||
with patch("agent.policy.multi_group_ops.send_filled_sheet", return_value={"ok": True}):
|
||||
with patch("agent.channel.aibot.reply.AibotReplyClient", _Bot):
|
||||
phase = handle_group_inbound(
|
||||
self._message("app-room", "sea1", "海运费 100CNY"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "segment_quoted")
|
||||
self.assertFalse(_Bot.made)
|
||||
self.assertTrue(any("报价单已发出" in text for text in self.app.texts))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1602,7 +1602,7 @@ class SeaContinueTests(unittest.TestCase):
|
||||
phase = handle_continue_thread(msg, thread_id=wo, store=store)
|
||||
self.assertEqual(phase, "wait_deal")
|
||||
joined = "\n".join(item.content for item in store._outbox.values())
|
||||
self.assertFalse(any(x in joined for x in (copy.BTN_DEAL, "成交")))
|
||||
self.assertIn("【成交】【未成交】【协商中】", joined)
|
||||
self.assertNotIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
|
||||
|
||||
|
||||
@@ -379,6 +379,9 @@ class TestConfirmSystemException(unittest.TestCase):
|
||||
)
|
||||
card = queued[0]["payload"]["template_card"]
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["报错原因"], "LOCK_FAIL")
|
||||
self.assertNotIn("错误码", values)
|
||||
self.assertIn("报错原因:LOCK_FAIL", queued[0]["content"])
|
||||
self.assertEqual(values["影响范围"], "锁舱失败,工单状态保持不变")
|
||||
self.assertEqual(card["main_title"]["title"], "系统异常通知")
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class TmsLandQueryTest(unittest.TestCase):
|
||||
got = assemble_land_query(facts)
|
||||
self.assertTrue(got["ok"])
|
||||
out = got["outbound"]
|
||||
self.assertEqual(out["landTransportType"], "CHINA_HK")
|
||||
self.assertEqual(out["landTransportType"], "中港")
|
||||
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
|
||||
self.assertNotIn("landRouteCategory", out)
|
||||
self.assertEqual(out["vehicleType"], "9米6")
|
||||
@@ -52,6 +52,7 @@ class TmsLandQueryTest(unittest.TestCase):
|
||||
got = assemble_land_query(facts)
|
||||
self.assertTrue(got["ok"])
|
||||
out = got["outbound"]
|
||||
self.assertEqual(out["landTransportType"], "中港")
|
||||
self.assertNotIn("vehicleType", out)
|
||||
self.assertNotIn("quantity", out)
|
||||
|
||||
|
||||
+3
-87
@@ -2,8 +2,6 @@ package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
@@ -14,6 +12,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -95,7 +94,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, from)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
applyDashboardSelfOnly(qw);
|
||||
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
|
||||
|
||||
long total = tickets.size();
|
||||
@@ -133,7 +132,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
applyDashboardSelfOnly(weekQw);
|
||||
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
@@ -667,87 +666,4 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return "sales";
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号勾选「只看自己数据」时收紧看板工单范围。
|
||||
* <p>
|
||||
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
|
||||
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
|
||||
* 超管(username=admin 或角色 admin)忽略开关。
|
||||
*/
|
||||
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
|
||||
LoginUser login = currentLoginUser();
|
||||
if (login == null) {
|
||||
return;
|
||||
}
|
||||
if (isAdministrator(login)) {
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
|
||||
return;
|
||||
}
|
||||
String username = StringUtils.trimToEmpty(login.getUsername());
|
||||
String wecom = dashboardWecomId(login);
|
||||
qw.and(w -> {
|
||||
boolean any = false;
|
||||
if (StringUtils.isNotBlank(wecom)) {
|
||||
w.eq(InquiryTicket::getSalesWecomId, wecom);
|
||||
any = true;
|
||||
}
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
if (any) {
|
||||
w.or();
|
||||
}
|
||||
w.eq(InquiryTicket::getCreateBy, username);
|
||||
any = true;
|
||||
}
|
||||
if (!any) {
|
||||
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
|
||||
w.apply("1 = 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static LoginUser currentLoginUser() {
|
||||
try {
|
||||
Object p = SecurityUtils.getSubject().getPrincipal();
|
||||
if (p instanceof LoginUser) {
|
||||
return (LoginUser) p;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 无登录上下文时不做过滤
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
|
||||
private static boolean isAdministrator(LoginUser login) {
|
||||
if (login == null) {
|
||||
return false;
|
||||
}
|
||||
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return SecurityUtils.getSubject().hasRole("admin")
|
||||
|| SecurityUtils.getSubject().hasRole("管理员");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板「只看自己」用的企微工号。
|
||||
* 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。
|
||||
*/
|
||||
private static String dashboardWecomId(LoginUser login) {
|
||||
if (login == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
|
||||
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-5
@@ -1148,16 +1148,39 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
// 首段时效若 facts 尚无,用腿上 transit
|
||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||
JSONObject firstSeg = landSegs.getJSONObject(0);
|
||||
if (firstSeg != null) {
|
||||
String t = firstNonBlankLocal(firstSeg.getString("transit_time"), firstSeg.getString("transitDays"));
|
||||
// 首段时效/燃油/税率抬到顶层,供侧栏与标签回填(WO006 燃油列不能空着有数却写不上)
|
||||
for (int i = 0; i < landSegs.size(); i++) {
|
||||
JSONObject seg = landSegs.getJSONObject(i);
|
||||
if (seg == null) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isBlank(out.getString("transit_time")) || "-".equals(out.getString("transit_time"))) {
|
||||
String t = firstNonBlankLocal(seg.getString("transit_time"), seg.getString("transitDays"));
|
||||
if (StringUtils.isNotBlank(t)) {
|
||||
out.put("transit_time", t);
|
||||
out.put("transit_days", t);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(out.getString("fuel_surcharge_rate"))) {
|
||||
String fuel = firstNonBlankLocal(seg.getString("fuel_surcharge_rate"),
|
||||
seg.getString("fuelSurchargeRate"));
|
||||
if (StringUtils.isNotBlank(fuel)) {
|
||||
out.put("fuel_surcharge_rate", fuel);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(out.getString("tax_rate"))) {
|
||||
String tax = firstNonBlankLocal(seg.getString("tax_rate"), seg.getString("taxRate"),
|
||||
seg.getString("rate"));
|
||||
if (StringUtils.isNotBlank(tax)) {
|
||||
out.put("tax_rate", tax);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(out.getString("currency"))) {
|
||||
String ccy = StringUtils.trimToEmpty(seg.getString("currency"));
|
||||
if (StringUtils.isNotBlank(ccy)) {
|
||||
out.put("currency", ccy);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (looksLandInquiry(src)) {
|
||||
// 无 landSegments 时:用询价 OD + fee_lines 分项拼一行,尽量填「提货费/运输费/送货费」列
|
||||
|
||||
+55
-5
@@ -26,6 +26,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.support.AgentSystemExceptionRetryClient;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryDataScopeSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryQuoteDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
@@ -122,6 +123,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (StringUtils.isNotBlank(dateTo)) {
|
||||
qw.le(InquiryTicket::getCreateTime, dateTo + " 23:59:59");
|
||||
}
|
||||
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
|
||||
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
@@ -283,24 +286,36 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
|
||||
ticket.setCloseReason(req.getRemark());
|
||||
}
|
||||
boolean clearEventWait = false;
|
||||
if (TicketStatuses.LOST.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getLostReason())) {
|
||||
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
|
||||
}
|
||||
// 成交等待结束;缺原因时由智能体再 start lost_reason,避免陈旧 deal 继续催
|
||||
clearEventWait = true;
|
||||
} else if (TicketStatuses.DEAL.equals(to)) {
|
||||
ticket.setNextFollowupAt(null);
|
||||
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
|
||||
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
|
||||
}
|
||||
clearEventWait = true;
|
||||
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
|
||||
Date follow = parseDateTime(req.getNextFollowupAt());
|
||||
ticket.setNextFollowupAt(follow);
|
||||
// 顺延再问期间不盯销售超时;到期再问会重新 start deal
|
||||
clearEventWait = true;
|
||||
} else if (TicketStatuses.CLOSED.equals(to)) {
|
||||
clearEventWait = true;
|
||||
}
|
||||
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
|
||||
ticket.setUpdateBy(operator);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
// updateById 默认不写 null:等待三列必须在落库后再强制清空
|
||||
if (clearEventWait) {
|
||||
clearEventWaitColumns(ticket);
|
||||
}
|
||||
String remark = StringUtils.defaultIfBlank(req.getRemark(),
|
||||
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
|
||||
appendLog(ticket, from, to, "transition", operator, remark);
|
||||
@@ -1494,6 +1509,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
// 原因已入账:强制清空等待三列(updateById 写不进 null)
|
||||
clearEventWaitColumns(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
@@ -1637,11 +1654,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
ticket.setEventWaitKind(null);
|
||||
ticket.setEventWaitStartedAt(null);
|
||||
ticket.setEventWaitAlerted(0);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
clearEventWaitColumns(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||
return out;
|
||||
@@ -1661,10 +1674,21 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return out;
|
||||
}
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
Date now = new Date();
|
||||
for (InquiryTicket ticket : rows) {
|
||||
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
// 未成交已写原因、协商中仍排着再问:不进巡检(挡住陈旧等待)
|
||||
if (TicketStatuses.LOST.equals(ticket.getStatus())
|
||||
&& StringUtils.isNotBlank(ticket.getLostReason())) {
|
||||
continue;
|
||||
}
|
||||
if (TicketStatuses.NEGOTIATING.equals(ticket.getStatus())
|
||||
&& ticket.getNextFollowupAt() != null
|
||||
&& ticket.getNextFollowupAt().after(now)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("workOrderNo", ticket.getWorkOrderNo());
|
||||
one.put("work_order_no", ticket.getWorkOrderNo());
|
||||
@@ -1688,6 +1712,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
one.put("collabMemberIds", members);
|
||||
one.put("collab_member_ids", members);
|
||||
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
||||
one.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
one.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
||||
@@ -1710,6 +1736,30 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空事件异常当前等待三列。不改六态、不改事件异常摘要。
|
||||
* <p>
|
||||
* 必须用 UpdateWrapper 强制写 null:MyBatis-Plus updateById 默认跳过 null 字段,
|
||||
* 否则协商中/未成交后库里仍残留 deal 等待,巡检会继续催(WO202610060024 / WO202610070034)。
|
||||
*/
|
||||
private void clearEventWaitColumns(InquiryTicket ticket) {
|
||||
if (ticket == null || StringUtils.isBlank(ticket.getId())) {
|
||||
return;
|
||||
}
|
||||
Date now = new Date();
|
||||
lambdaUpdate()
|
||||
.eq(InquiryTicket::getId, ticket.getId())
|
||||
.set(InquiryTicket::getEventWaitKind, null)
|
||||
.set(InquiryTicket::getEventWaitStartedAt, null)
|
||||
.set(InquiryTicket::getEventWaitAlerted, 0)
|
||||
.set(InquiryTicket::getUpdateTime, now)
|
||||
.update();
|
||||
ticket.setEventWaitKind(null);
|
||||
ticket.setEventWaitStartedAt(null);
|
||||
ticket.setEventWaitAlerted(0);
|
||||
ticket.setUpdateTime(now);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
/**
|
||||
* 后台数据范围:账号「只看自己数据」时收紧工单查询。
|
||||
* <p>
|
||||
* 口径:非超管且 {@code dashboardSelfOnly=true} →
|
||||
* {@code salesWecomId = 工号} 或 {@code createBy = 登录名}。
|
||||
* 看板与工单列表共用,避免只看板生效、列表漏看全站。
|
||||
* 线程:只读登录态,无全局锁。
|
||||
*/
|
||||
public final class InquiryDataScopeSupport {
|
||||
|
||||
private InquiryDataScopeSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前登录账号把「只看自己」条件叠到工单查询上。
|
||||
*
|
||||
* @param qw 工单查询包装器;null 时直接返回
|
||||
*/
|
||||
public static void applySelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
|
||||
if (qw == null) {
|
||||
return;
|
||||
}
|
||||
LoginUser login = currentLoginUser();
|
||||
if (login == null) {
|
||||
return;
|
||||
}
|
||||
if (isAdministrator(login)) {
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
|
||||
return;
|
||||
}
|
||||
String username = StringUtils.trimToEmpty(login.getUsername());
|
||||
String wecom = wecomId(login);
|
||||
qw.and(w -> {
|
||||
boolean any = false;
|
||||
if (StringUtils.isNotBlank(wecom)) {
|
||||
w.eq(InquiryTicket::getSalesWecomId, wecom);
|
||||
any = true;
|
||||
}
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
if (any) {
|
||||
w.or();
|
||||
}
|
||||
w.eq(InquiryTicket::getCreateBy, username);
|
||||
any = true;
|
||||
}
|
||||
if (!any) {
|
||||
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
|
||||
w.apply("1 = 0");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static LoginUser currentLoginUser() {
|
||||
try {
|
||||
Object p = SecurityUtils.getSubject().getPrincipal();
|
||||
if (p instanceof LoginUser) {
|
||||
return (LoginUser) p;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 无登录上下文
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
|
||||
private static boolean isAdministrator(LoginUser login) {
|
||||
if (login == null) {
|
||||
return false;
|
||||
}
|
||||
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return SecurityUtils.getSubject().hasRole("admin")
|
||||
|| SecurityUtils.getSubject().hasRole("管理员");
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微工号。反射读 getWorkNo,兼容测服旧 LoginUser 无该方法的包。
|
||||
*/
|
||||
public static String wecomId(LoginUser login) {
|
||||
if (login == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
|
||||
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
-339
@@ -31,92 +31,6 @@ import java.util.*;
|
||||
@Slf4j
|
||||
public final class QuoteTemplateExcelSupport {
|
||||
|
||||
// #region agent log
|
||||
/** 调试:出单底色。会话 446444=陆运整车顶栏;保留旧路径兼容。 */
|
||||
private static void agentDbg(String hypothesisId, String location, String message, String dataJson) {
|
||||
try {
|
||||
String line = "{\"sessionId\":\"446444\",\"runId\":\"post-fix\",\"hypothesisId\":\""
|
||||
+ hypothesisId + "\",\"location\":\"" + location
|
||||
+ "\",\"message\":\"" + String.valueOf(message).replace("\\", "\\\\").replace("\"", "'")
|
||||
+ "\",\"data\":" + (dataJson == null || dataJson.isEmpty() ? "{}" : dataJson)
|
||||
+ ",\"timestamp\":" + System.currentTimeMillis() + "}\n";
|
||||
byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
|
||||
// 主账进程账号写不了开发机目录;优先落到 inquiry-new/logs。
|
||||
String[] paths = {
|
||||
"E:/wwwroot/inquiry-new/logs/debug-446444.log",
|
||||
"C:/Users/ytd_extdev_0903/incoming/debug-446444.log",
|
||||
System.getProperty("java.io.tmpdir") + "/debug-446444.log",
|
||||
"d:/own/友通达询价智能体(新)/debug-446444.log"
|
||||
};
|
||||
for (String p : paths) {
|
||||
try {
|
||||
Path path = Paths.get(p);
|
||||
if (path.getParent() != null && !Files.exists(path.getParent())) {
|
||||
continue;
|
||||
}
|
||||
Files.write(path, bytes, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
} catch (Exception ignoredOne) {
|
||||
// 换下一路径
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 调试日志失败不影响出单
|
||||
}
|
||||
}
|
||||
|
||||
/** 采样顶栏/运价表头填充色,验证陆运整车蓝底是否被改写。 */
|
||||
private static void debugLandBannerColors(Workbook wb, String phase, String sourceHint) {
|
||||
try {
|
||||
if (wb == null || wb.getNumberOfSheets() <= 0) {
|
||||
return;
|
||||
}
|
||||
Sheet sheet = wb.getSheetAt(0);
|
||||
if (sheet == null) {
|
||||
return;
|
||||
}
|
||||
String name = String.valueOf(sheet.getSheetName());
|
||||
boolean landish = name.contains("整车") || name.contains("拼车") || name.contains("陆运")
|
||||
|| String.valueOf(sourceHint).contains("整车") || String.valueOf(sourceHint).contains("拼车");
|
||||
if (!landish && sheet.getLastRowNum() < 2) {
|
||||
return;
|
||||
}
|
||||
boolean[] keep = markLandHeaderRows(sheet);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("{\"phase\":\"").append(phase).append("\",\"sheet\":\"")
|
||||
.append(name.replace("\"", "'")).append("\",\"src\":\"")
|
||||
.append(String.valueOf(sourceHint).replace("\\", "/").replace("\"", "'"))
|
||||
.append("\",\"keep0to3\":[");
|
||||
for (int r = 0; r <= 3 && r < keep.length; r++) {
|
||||
if (r > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(keep[r]);
|
||||
}
|
||||
sb.append("],\"cells\":[");
|
||||
int[][] pts = {{0, 1}, {0, 2}, {1, 1}, {1, 2}, {7, 1}, {7, 8}, {8, 3}};
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
boolean first = true;
|
||||
for (int[] pt : pts) {
|
||||
Row row = sheet.getRow(pt[0]);
|
||||
Cell cell = row == null ? null : row.getCell(pt[1]);
|
||||
String text = cell == null ? "" : StringUtils.trimToEmpty(fmt.formatCellValue(cell));
|
||||
byte[] rgb = readFillRgb(cell);
|
||||
String hex = rgb == null ? "" : String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff);
|
||||
if (!first) {
|
||||
sb.append(',');
|
||||
}
|
||||
first = false;
|
||||
sb.append("{\"r\":").append(pt[0]).append(",\"c\":").append(pt[1])
|
||||
.append(",\"t\":\"").append(text.replace("\"", "'").replace("\\", "/"))
|
||||
.append("\",\"fill\":\"").append(hex).append("\"}");
|
||||
}
|
||||
sb.append("]}");
|
||||
agentDbg("H1", "land.banner." + phase, "land banner fill sample", sb.toString());
|
||||
} catch (Exception ignored) {
|
||||
// 采样失败不影响出单
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
private static final Set<String> YELLOW_RGB = new HashSet<>(Arrays.asList(
|
||||
"FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99",
|
||||
@@ -675,13 +589,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
JSONObject fieldMeta = mapping.getJSONObject("fieldMeta");
|
||||
JSONObject fields = mapping.getJSONObject("fields");
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "after_open", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
LandTemplateLook landLook = snapshotLandTemplateLook(wb);
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "after_snapshot", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
if (fields != null) {
|
||||
// 陆运拼车等模板常把「报价日期」与「有效期」误绑同一格(如 H5「报价日期:」)。
|
||||
// 同格时只写出报价日期,避免 TMS valid_until 盖掉日期标签含义。
|
||||
@@ -722,14 +630,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (afterColon) {
|
||||
writeCellAfterColon(wb, cellRef, String.valueOf(val));
|
||||
} else {
|
||||
// #region agent log
|
||||
if ("pol".equals(code) || "起运港".equals(code)
|
||||
|| String.valueOf(val).contains("广州") || String.valueOf(val).contains("Guangzhou")) {
|
||||
agentDbg("H2", "fillWorkbook.fields", "mapping field write",
|
||||
"{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef
|
||||
+ "\",\"val\":\"" + String.valueOf(val).replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
// 映射若误指费用名称列:跳过,避免无 TMS 时把起运港写入费用名称
|
||||
if (isPortFactCode(code)) {
|
||||
ParsedRef pref = ParsedRef.parse(cellRef);
|
||||
@@ -738,10 +638,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
sh = wb.getSheetAt(0);
|
||||
}
|
||||
if (sh != null && isFeeNameColumnRef(sh, cellRef)) {
|
||||
// #region agent log
|
||||
agentDbg("H2", "fillWorkbook.fields.blockFeeName", "blocked mapping pol into fee name",
|
||||
"{\"code\":\"" + code + "\",\"cellRef\":\"" + cellRef + "\"}");
|
||||
// #endregion
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -775,17 +671,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
// B3:J3 是原控件锚点合并区,禁止往里写任何单元格字。
|
||||
clearSyntheticSeaTransportClassCell(wb);
|
||||
writeSeaOtherClassFallbackLabel(wb, facts);
|
||||
// #region agent log
|
||||
agentDbg("T1", "fillWorkbook.facts", "sea subtotal facts present",
|
||||
"{\"fee_total_pol\":\"" + String.valueOf(facts.getString("fee_total_polCostItems")).replace("\"", "'")
|
||||
+ "\",\"fee_total_ocean\":\"" + String.valueOf(facts.getString("fee_total_oceanCostItems")).replace("\"", "'")
|
||||
+ "\",\"fee_total_pod\":\"" + String.valueOf(facts.getString("fee_total_podCostItems")).replace("\"", "'")
|
||||
+ "\",\"total_amount\":\"" + String.valueOf(facts.getString("total_amount")).replace("\"", "'")
|
||||
+ "\",\"currency\":\"" + String.valueOf(facts.getString("currency")).replace("\"", "'")
|
||||
+ "\",\"sea_class\":\"" + String.valueOf(firstNonBlank(
|
||||
facts.getString("运输分类"), facts.getString("transport_class"),
|
||||
facts.getString("sea_class"))).replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
// 产品口径:TMS 已返回汇总金额 → Excel 汇总/合计格去掉公式,改写 TMS 数。
|
||||
// 海运三段汇总/小计:fee_total_*;整单:total_amount(预估费用/费用总计等)。
|
||||
writeSeaSectionSubtotals(wb, facts, yellow);
|
||||
@@ -793,25 +678,13 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 无费用明细时:清掉误写入费用名称列的起运港/目的港(WO202609280006)
|
||||
clearPortSpillInFeeNameColumn(wb, facts);
|
||||
// 跨境集拼:列头必须和空白模板一样(蓝底白字)。POI 写回后币种/燃油会变成大红字。
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "before_restore", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
restoreLandTemplateLook(wb, landLook);
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "after_restore", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
// 列头盖回去时,数据行若被误当成表头,目的地会再次变成币种那套红字。
|
||||
restyleLandCityValues(wb);
|
||||
// 拼车「元/CBM」整车车型格:跟「始发站」同一浅蓝,禁止盖成顶栏那种深蓝。
|
||||
alignLandNestedHeaderFill(wb);
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "after_align", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||
applyQuotePrintLayout(wb);
|
||||
// #region agent log
|
||||
debugLandBannerColors(wb, "after_print", String.valueOf(sourceXlsx));
|
||||
// #endregion
|
||||
Files.createDirectories(targetXlsx.getParent());
|
||||
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
|
||||
wb.write(out);
|
||||
@@ -858,26 +731,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
} catch (Exception ignoredDst) {
|
||||
// 仅调试
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("H7", "land.theme.after_write", "restored land theme fills; strip mixed rgb",
|
||||
"{\"fixed\":" + themeFixed
|
||||
+ ",\"srcFg\":\"" + String.valueOf(srcFg).replace("\"", "'")
|
||||
+ "\",\"dstTheme\":\"" + String.valueOf(dstTheme).replace("\"", "'")
|
||||
+ "\",\"dstCollapsed\":" + dstCollapsed
|
||||
+ ",\"dstMixed\":" + dstMixed
|
||||
+ ",\"fill2\":\"" + fill2Sample
|
||||
+ "\",\"src\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'")
|
||||
+ "\",\"dst\":\"" + String.valueOf(targetXlsx).replace("\\", "/").replace("\"", "'")
|
||||
+ "\"}");
|
||||
// #endregion
|
||||
}
|
||||
}
|
||||
// 只对海运模板做控件包/骨架盖回。空运到港等模板禁止走 WO011,否则标题变成「国际海运询价单」。
|
||||
if (!seaTemplate) {
|
||||
// #region agent log
|
||||
agentDbg("H_SEA", "fillWorkbook.skipSeaScaffold", "non-sea template skip WO011 scaffold",
|
||||
"{\"path\":\"" + String.valueOf(sourceXlsx).replace("\\", "/").replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
return;
|
||||
}
|
||||
// 按运输分类盖回 WPS 可显示的控件包:
|
||||
@@ -1017,17 +874,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
usedVml = true;
|
||||
}
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("R2", "readSeaDrawingParts", "preserved template drawings",
|
||||
"{\"count\":" + out.size()
|
||||
+ ",\"srcDrawBytes\":" + (srcDraw == null ? 0 : srcDraw.length)
|
||||
+ ",\"srcVmlBytes\":" + (srcVml == null ? 0 : srcVml.length)
|
||||
+ ",\"usedWo011Draw\":" + usedDraw
|
||||
+ ",\"usedWo011Vml\":" + usedVml
|
||||
+ ",\"finalDrawBytes\":" + (out.get("xl/drawings/drawing1.xml") == null
|
||||
? 0 : out.get("xl/drawings/drawing1.xml").length)
|
||||
+ ",\"mode\":\"wo011-draw+vml+styles\"}");
|
||||
// #endregion
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1337,10 +1183,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String raw = facts.getString(mk[2]);
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
||||
// #region agent log
|
||||
agentDbg("T2", "writeSeaSectionSubtotals.skip", "no subtotal fact",
|
||||
"{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"key\":\"" + mk[2] + "\"}");
|
||||
// #endregion
|
||||
continue;
|
||||
}
|
||||
String ccy = headerCcy;
|
||||
@@ -1360,12 +1202,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 有 TMS 汇总:必须去掉公式再写数字
|
||||
writeAmountCell(sheet, r, amtCol, amt, yellow);
|
||||
clearOtherSummaryFormulasOnRow(row, amtCol, ccyCol);
|
||||
// #region agent log
|
||||
agentDbg("T2", "writeSeaSectionSubtotals.write", "wrote section subtotal",
|
||||
"{\"row\":" + r + ",\"marker\":\"" + mk[0] + "\",\"ccy\":\"" + ccy
|
||||
+ "\",\"amt\":\"" + amt.replace("\"", "'")
|
||||
+ "\",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol + "}");
|
||||
// #endregion
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1443,11 +1279,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
|| text.contains("其它") || text.contains("其他");
|
||||
if (synthetic) {
|
||||
target.setCellValue("");
|
||||
// #region agent log
|
||||
agentDbg("H_B3", "clearSyntheticSeaTransportClassCell", "cleared radio-anchor cell text",
|
||||
"{\"ref\":\"" + new CellReference(target).formatAsString()
|
||||
+ "\",\"was\":\"" + text.replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1545,24 +1376,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
tmp = null;
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("R1", "syncSeaTransportClassFormControls", "patched form control pack by transport class",
|
||||
"{\"cls\":\"" + String.valueOf(cls).replace("\"", "'")
|
||||
+ "\",\"wantOther\":" + wantOther
|
||||
+ ",\"useOtherChecked2\":" + useOtherChecked2
|
||||
+ ",\"touchedCtrl\":" + touchedCtrl + ",\"touchedVml\":" + touchedVml
|
||||
+ ",\"restoredDrawing\":" + restoredDrawing
|
||||
+ ",\"restoredStyles\":" + restoredStyles
|
||||
+ ",\"stylesBytes\":" + (stdStyles == null ? 0 : stdStyles.length)
|
||||
+ ",\"drawBytes\":" + (pickDraw == null ? 0 : pickDraw.length)
|
||||
+ ",\"vmlBytes\":" + (pickVml == null ? 0 : pickVml.length)
|
||||
+ ",\"mode\":\"" + mode + "\"}");
|
||||
// #endregion
|
||||
} catch (Exception e) {
|
||||
// #region agent log
|
||||
agentDbg("R1", "syncSeaTransportClassFormControls.err", "patch failed",
|
||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
log.warn("海运运输分类 Form Control 勾选同步失败 path={}", targetXlsx, e);
|
||||
} finally {
|
||||
if (tmp != null) {
|
||||
@@ -1620,13 +1434,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
boolean finished = p.waitFor(120, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
p.destroyForcibly();
|
||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.timeout", "excel com timeout", "{}");
|
||||
return false;
|
||||
}
|
||||
String out = new String(outBytes, StandardCharsets.UTF_8);
|
||||
if (!out.contains("SWITCH_OK")) {
|
||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.fail", "excel com failed",
|
||||
"{\"out\":\"" + out.replace("\"", "'").replace("\n", " ").replace("\r", "") + "\"}");
|
||||
return false;
|
||||
}
|
||||
// 校验 zip:次钮 Checked
|
||||
@@ -1656,14 +1467,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
&& !bodies.get(0).contains("<x:Checked>")
|
||||
&& bodies.get(1).contains("<x:Checked>")
|
||||
&& c2.contains("checked=\"Checked\"");
|
||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel", "excel switched other radio",
|
||||
"{\"ok\":" + ok + ",\"vmlChecked2\":" + (bodies.size() > 1 && bodies.get(1).contains("<x:Checked>"))
|
||||
+ ",\"ctrl2\":" + c2.contains("checked=\"Checked\"") + "}");
|
||||
return ok;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
agentDbg("R1", "switchSeaTransportClassToOtherViaExcel.err", "excel com exception",
|
||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
||||
log.warn("海运「其它」Excel 切勾失败 path={}", targetXlsx, e);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -1695,10 +1501,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (!"其他".equals(cls)) {
|
||||
return;
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("H_B3", "writeSeaOtherClassFallbackLabel", "skip B3 write keep radio anchors empty",
|
||||
"{\"cls\":\"其他\",\"reason\":\"B3 is radio anchor; scaffold restore keeps radios\"}");
|
||||
// #endregion
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1721,11 +1523,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
byte[] scaffold = loadWo011SeaSheet1();
|
||||
String scaffoldSrc = "wo011-bundled";
|
||||
if (scaffold == null || scaffold.length < 1000) {
|
||||
// #region agent log
|
||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.skip", "no scaffold sheet1",
|
||||
"{\"scaffoldBytes\":" + (scaffold == null ? 0 : scaffold.length)
|
||||
+ ",\"scaffoldSrc\":\"" + scaffoldSrc + "\"}");
|
||||
// #endregion
|
||||
return false;
|
||||
}
|
||||
Path tmp = null;
|
||||
@@ -1756,19 +1553,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
// WO035 证据:错误正则把自闭合 <c .../> 的 / 吃进属性,一次匹配跨越多行,
|
||||
// sheet 从 17KB 缩成 7KB、只剩 2 行。合并结果过瘦则放弃,保留 POI 原 sheet。
|
||||
if (merged.sheetXml.length() < scaffold.length / 2 || rowCount < 20) {
|
||||
// #region agent log
|
||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "merged sheet too thin, keep POI sheet",
|
||||
"{\"scaffoldBytes\":" + scaffold.length
|
||||
+ ",\"mergedSheetBytes\":" + merged.sheetXml.length()
|
||||
+ ",\"rowCount\":" + rowCount
|
||||
+ ",\"poiCells\":" + poiValues.size() + "}");
|
||||
// #endregion
|
||||
return false;
|
||||
}
|
||||
// WO011 骨架合并后若仍带本地 mc xmlns,则放弃(WO036)
|
||||
if (merged.sheetXml.contains("AlternateContent xmlns:mc=")) {
|
||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.abort", "scaffold still has local mc xmlns",
|
||||
"{\"scaffoldSrc\":\"" + scaffoldSrc + "\"}");
|
||||
return false;
|
||||
}
|
||||
tmp = Files.createTempFile(targetXlsx.getParent(), "sea-sheet-", ".xlsx");
|
||||
@@ -1802,23 +1590,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
tmp = null;
|
||||
// #region agent log
|
||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold", "restored template sheet1 scaffold",
|
||||
"{\"scaffoldSrc\":\"" + scaffoldSrc + "\""
|
||||
+ ",\"scaffoldBytes\":" + scaffold.length
|
||||
+ ",\"poiCells\":" + poiValues.size()
|
||||
+ ",\"mergedSheetBytes\":" + merged.sheetXml.length()
|
||||
+ ",\"sstCount\":" + merged.sstCount
|
||||
+ ",\"patchedCells\":" + merged.patchedCells
|
||||
+ ",\"rowCount\":" + rowCount
|
||||
+ ",\"acHasLocalMc\":" + merged.sheetXml.contains("AlternateContent xmlns:mc=") + "}");
|
||||
// #endregion
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// #region agent log
|
||||
agentDbg("H_SHEET", "restoreSeaSheet1Scaffold.err", "scaffold restore failed",
|
||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
log.warn("海运 sheet1 骨架还原失败 path={}", targetXlsx, e);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -2171,11 +1944,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
target = row.createCell(1);
|
||||
}
|
||||
target.setCellValue(label);
|
||||
// #region agent log
|
||||
agentDbg("R3", "writeSeaTransportClassVisibleLabel", "wrote visible radio label for WPS",
|
||||
"{\"ref\":\"" + new CellReference(target).formatAsString()
|
||||
+ "\",\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}");
|
||||
// #endregion
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2194,10 +1962,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
facts.getString("sea_class"));
|
||||
String cls = canonicalizeSeaTransportClass(raw);
|
||||
if (StringUtils.isBlank(cls)) {
|
||||
// #region agent log
|
||||
agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.skip", "no transport class for fallback",
|
||||
"{}");
|
||||
// #endregion
|
||||
return;
|
||||
}
|
||||
boolean port = "Port to Port".equalsIgnoreCase(cls);
|
||||
@@ -2258,15 +2022,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
Files.move(tmp, targetXlsx, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||
tmp = null;
|
||||
// #region agent log
|
||||
agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile", "B3 visible radio text for other or scaffold-fail",
|
||||
"{\"cls\":\"" + cls.replace("\"", "'") + "\",\"port\":" + port + "}");
|
||||
// #endregion
|
||||
} catch (Exception e) {
|
||||
// #region agent log
|
||||
agentDbg("R3", "writeSeaTransportClassVisibleLabelToFile.err", "fallback failed",
|
||||
"{\"err\":\"" + String.valueOf(e.getMessage()).replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
log.warn("海运运输分类明文兜底失败 path={}", targetXlsx, e);
|
||||
} finally {
|
||||
if (tmp != null) {
|
||||
@@ -2823,7 +2579,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
continue;
|
||||
}
|
||||
// 中港等模板:dataStartRow 常误指「4.2米/3T」车型表头行,下移到真正数据行
|
||||
int mappedStart = rowIdx0;
|
||||
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
|
||||
String kind = landRouteSectionKind(sheet, region);
|
||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
|
||||
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
|
||||
continue;
|
||||
@@ -2908,8 +2666,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
header0 = dataRow - 1;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
int from = Math.max(0, header0 - 1);
|
||||
int to = header0 + 1;
|
||||
// 上传版跨境整车:时效/燃油在表头行,币种常错开一行(O7),多扫两行才能对上。
|
||||
int from = Math.max(0, header0 - 2);
|
||||
int to = header0 + 2;
|
||||
for (int r = from; r <= to; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
@@ -2922,6 +2681,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
continue;
|
||||
}
|
||||
String val = landMatrixSideValue(text, rowObj, facts);
|
||||
if (text.contains("燃油") || "币种".equals(text) || "币别".equals(text) || text.contains("时效")) {
|
||||
}
|
||||
if (StringUtils.isBlank(val) || "-".equals(val)) {
|
||||
continue;
|
||||
}
|
||||
@@ -3013,10 +2774,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
short lastCol = row.getLastCellNum();
|
||||
for (int c = 0; c < lastCol; c++) {
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||
if (text.contains("国内段")) {
|
||||
if (isLandDomesticSectionTitle(text)) {
|
||||
domestic = true;
|
||||
}
|
||||
if (text.contains("境外段") || text.contains("国外段")) {
|
||||
if (isLandOverseasSectionTitle(text)) {
|
||||
overseas = true;
|
||||
}
|
||||
}
|
||||
@@ -3025,7 +2786,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」这类分区标题。
|
||||
* 国内段 / 境外段。认 mapping 标题,再认表上「一、国内段报价」「二、境外报价」这类分区标题。
|
||||
* 上传版标题是「境外报价」不一定带「段」字。
|
||||
*/
|
||||
static String landRouteSectionKind(Sheet sheet, JSONObject region) {
|
||||
String title = "";
|
||||
@@ -3033,10 +2795,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
title = firstNonBlank(region.getString("title"), region.getString("id"), region.getString("name"));
|
||||
}
|
||||
String compact = StringUtils.trimToEmpty(title).replaceAll("\\s+", "");
|
||||
if (compact.contains("境外") || compact.contains("国外")) {
|
||||
if (isLandOverseasSectionTitle(compact)) {
|
||||
return "overseas";
|
||||
}
|
||||
if (compact.contains("国内")) {
|
||||
if (isLandDomesticSectionTitle(compact)) {
|
||||
return "domestic";
|
||||
}
|
||||
if (sheet == null || region == null) {
|
||||
@@ -3055,10 +2817,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
short lastCol = row.getLastCellNum();
|
||||
for (int c = 0; c < lastCol; c++) {
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||
if (text.contains("境外段") || text.contains("国外段")) {
|
||||
if (isLandOverseasSectionTitle(text)) {
|
||||
return "overseas";
|
||||
}
|
||||
if (text.contains("国内段")) {
|
||||
if (isLandDomesticSectionTitle(text)) {
|
||||
return "domestic";
|
||||
}
|
||||
}
|
||||
@@ -3079,10 +2841,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
short lastCol = row.getLastCellNum();
|
||||
for (int c = 0; c < lastCol; c++) {
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(row.getCell(c))).replaceAll("\\s+", "");
|
||||
if (text.contains("境外段") || text.contains("国外段")) {
|
||||
if (isLandOverseasSectionTitle(text)) {
|
||||
return "overseas";
|
||||
}
|
||||
if (text.contains("国内段")) {
|
||||
if (isLandDomesticSectionTitle(text)) {
|
||||
return "domestic";
|
||||
}
|
||||
}
|
||||
@@ -3090,6 +2852,29 @@ public final class QuoteTemplateExcelSupport {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 分区标题:国内段报价 / 一、国内段报价。 */
|
||||
static boolean isLandDomesticSectionTitle(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.replaceAll("\\s+", "");
|
||||
return t.contains("国内段") || t.contains("国内报价");
|
||||
}
|
||||
|
||||
/**
|
||||
* 分区标题:境外段报价 / 二、境外报价(上传版常不带「段」)。
|
||||
* 不用光秃秃的「境外」两字,避免误伤备注里的「境外」字样。
|
||||
*/
|
||||
static boolean isLandOverseasSectionTitle(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.replaceAll("\\s+", "");
|
||||
return t.contains("境外段") || t.contains("国外段") || t.contains("境外报价")
|
||||
|| t.contains("国外报价") || (t.contains("境外") && t.contains("报价"))
|
||||
|| (t.contains("国外") && t.contains("报价"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
|
||||
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
|
||||
@@ -3206,6 +2991,26 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isNotBlank(included)) {
|
||||
out.put("tax_included", included);
|
||||
}
|
||||
// 跨境分段只拆 OD,车型/运价必须原样带上;否则国内段 3T 列对不上 vehicleType,4000 写不进去。
|
||||
String vehicleType = firstNonBlank(row.getString("vehicleType"), row.getString("vehicle_type"));
|
||||
if (StringUtils.isNotBlank(vehicleType)) {
|
||||
out.put("vehicleType", vehicleType);
|
||||
out.put("vehicle_type", vehicleType);
|
||||
}
|
||||
String sale = firstNonBlank(row.getString("salePrice"), row.getString("costPrice"), row.getString("amount"));
|
||||
if (StringUtils.isNotBlank(sale)) {
|
||||
if (StringUtils.isNotBlank(row.getString("salePrice"))) {
|
||||
out.put("salePrice", row.getString("salePrice"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getString("costPrice"))) {
|
||||
out.put("costPrice", row.getString("costPrice"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getString("amount"))) {
|
||||
out.put("amount", row.getString("amount"));
|
||||
} else {
|
||||
out.put("amount", sale);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -3216,7 +3021,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
String t = text.replaceAll("\\s+", "");
|
||||
String[] marks = {
|
||||
"吉隆坡", "胡志明", "河内", "海防", "岘港", "谅山", "同登", "老街", "芒街",
|
||||
"曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光",
|
||||
"春疆", "曼谷", "清迈", "金边", "西哈努克", "新加坡", "雅加达", "万象", "仰光",
|
||||
"马来", "越南", "泰国", "柬埔寨", "老挝", "缅甸", "印尼", "菲律宾",
|
||||
"Hồ", "ChiMinh", "Kuala", "Lumpur", "Hanoi", "Bangkok", "Saigon", "Phnom"
|
||||
};
|
||||
@@ -3245,9 +3050,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
return rowIdx0;
|
||||
}
|
||||
int cur = rowIdx0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)
|
||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)) {
|
||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)
|
||||
|| rowLooksLikeVehicleDimensionRow(sheet, cur)
|
||||
|| rowLooksLikeLandUnitSubHeader(sheet, cur)) {
|
||||
cur++;
|
||||
continue;
|
||||
}
|
||||
@@ -3257,7 +3064,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (header0 >= 0 && cur == header0) {
|
||||
cur = header0 + 1;
|
||||
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)
|
||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)) {
|
||||
|| rowLooksLikeDeliveryPriceHeader(sheet, cur)
|
||||
|| rowLooksLikeVehicleDimensionRow(sheet, cur)
|
||||
|| rowLooksLikeLandUnitSubHeader(sheet, cur)) {
|
||||
cur++;
|
||||
}
|
||||
}
|
||||
@@ -3330,11 +3139,16 @@ public final class QuoteTemplateExcelSupport {
|
||||
Cell mcell = mrow == null ? null : mrow.getCell(merge[1]);
|
||||
String exist = readCellText(mcell);
|
||||
boolean originOrDest = isLandOriginHeaderText(exist) || "目的地".equals(StringUtils.trimToEmpty(exist).replaceAll("\\s+", ""));
|
||||
boolean dimCell = looksLikeVehicleDimensionText(exist);
|
||||
boolean matrixRow = rowLooksLikeLandRouteMatrixHeader(sheet, rowIdx)
|
||||
|| rowLooksLikeVehicleHeader(sheet, rowIdx)
|
||||
|| rowLooksLikeDeliveryPriceHeader(sheet, rowIdx)
|
||||
|| rowLooksLikeVehicleDimensionRow(sheet, rowIdx)
|
||||
|| rowLooksLikeLandUnitSubHeader(sheet, rowIdx)
|
||||
|| rowLooksLikeLandRouteMatrixHeader(sheet, merge[0])
|
||||
|| rowLooksLikeVehicleHeader(sheet, merge[0]);
|
||||
|| rowLooksLikeVehicleHeader(sheet, merge[0])
|
||||
|| rowLooksLikeVehicleDimensionRow(sheet, merge[0])
|
||||
|| dimCell;
|
||||
// 拼车是上下标签栏(始发站、目的地、元/CBM),不能整列往下挪,否则城市会盖掉单价标签。
|
||||
if (!matrixRow && !originOrDest) {
|
||||
return new int[]{rowIdx, col};
|
||||
@@ -3969,13 +3783,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
col = origin[1];
|
||||
}
|
||||
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
|
||||
// #region agent log
|
||||
if (StringUtils.isNotBlank(value) && (value.contains("广州") || "B".equalsIgnoreCase(colLetter.trim()))) {
|
||||
agentDbg("H3", "writeFeeCol", "fee col write",
|
||||
"{\"ref\":\"" + ref + "\",\"col\":\"" + colLetter
|
||||
+ "\",\"val\":\"" + value.replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
String headerPeek = columnHeaderText(sheet, rowIdx, col);
|
||||
boolean trackOdFuel = isCityRouteColumn(sheet, rowIdx, col, headerPeek)
|
||||
|| looksLikeCurrencyCode(value)
|
||||
|| (headerPeek != null && (headerPeek.contains("燃油") || headerPeek.contains("币种")
|
||||
|| headerPeek.contains("起始") || headerPeek.contains("始发") || headerPeek.contains("目的")));
|
||||
if (yellow != null && yellow.contains(ref)) {
|
||||
return;
|
||||
}
|
||||
@@ -4016,6 +3828,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (looksLikeCurrencyCode(value) && columnRejectsCurrency(header)) {
|
||||
return;
|
||||
}
|
||||
// 起始地/目的地只写城市。fee_rows 误指运价行时,「运输费」不能盖掉深圳(WO202610080006)。
|
||||
if (isCityRouteColumn(sheet, rowIdx, col, header) && looksLikeFeeNameNotPlace(value)) {
|
||||
return;
|
||||
}
|
||||
writeNumericOrText(cell, value);
|
||||
// 始发地/目的地是城市。模板空格常套用「币种/燃油」的红字+顶对齐,
|
||||
// 长地名一换行就会画进蓝表头,看起来像列头被改了颜色。
|
||||
@@ -4061,6 +3877,26 @@ public final class QuoteTemplateExcelSupport {
|
||||
|| "HKD".equals(t) || "VND".equals(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用名,不是城市。写进「起始地/目的地」会把深圳盖成运输费。
|
||||
* 只拦常见运价费目,不拦「春疆货场」这类地名。
|
||||
*/
|
||||
static boolean looksLikeFeeNameNotPlace(String value) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return false;
|
||||
}
|
||||
String t = value.trim().replaceAll("\\s+", "");
|
||||
if ("运输费".equals(t) || "提货费".equals(t) || "送货费".equals(t) || "派送费".equals(t)
|
||||
|| "陆运费".equals(t) || "干线费".equals(t) || "空运费".equals(t) || "海运费".equals(t)
|
||||
|| "运费".equals(t) || "燃油附加费".equals(t) || "操作费".equals(t) || "文件费".equals(t)
|
||||
|| "提送货费".equals(t) || "人工费".equals(t)) {
|
||||
return true;
|
||||
}
|
||||
// 短费目名(…费)且不像货场/口岸地名
|
||||
return t.length() <= 8 && t.endsWith("费") && !t.contains("场") && !t.contains("港")
|
||||
&& !t.contains("站") && !t.contains("仓");
|
||||
}
|
||||
|
||||
/**
|
||||
* 这些列头不是币种:写 CNY 会把覆盖区域的城市、或燃油附加费率的空格盖掉。
|
||||
* 表头是「币种 / 币别」,或上面没有列头时,仍允许写币种。
|
||||
@@ -4661,7 +4497,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
continue;
|
||||
}
|
||||
byte[] sample = sampleLandMatrixHeaderFill(sheet, r);
|
||||
if (sample == null || looksYellowRgb(sample)) {
|
||||
if (sample == null || looksYellowRgb(sample) || looksLikeWhiteOrEmptyFill(sample)) {
|
||||
continue;
|
||||
}
|
||||
Row row = sheet.getRow(r);
|
||||
@@ -4684,7 +4520,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上找运价表头「始发站/始发地/序号」的浅蓝底。
|
||||
* 向上找运价表头「始发站/始发地/序号」的灰/浅蓝底。
|
||||
* 跨境整车境外段模板里「起始地」偶发白底(theme0);若拿它刷整行,会把同行「币种」灰底盖白。
|
||||
*/
|
||||
static byte[] sampleLandMatrixHeaderFill(Sheet sheet, int fromRow) {
|
||||
if (sheet == null) {
|
||||
@@ -4702,6 +4539,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
byte[] preferred = null;
|
||||
byte[] fallback = null;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
@@ -4709,17 +4547,23 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", "");
|
||||
byte[] rgb = readFillRgb(cell);
|
||||
if (rgb == null || looksYellowRgb(rgb)) {
|
||||
if (rgb == null || looksYellowRgb(rgb) || looksLikeWhiteOrEmptyFill(rgb)) {
|
||||
continue;
|
||||
}
|
||||
if (isLandOriginHeaderText(text) || "序号".equals(text) || "目的地".equals(text)
|
||||
|| "提货费".equals(text) || "送货费".equals(text)) {
|
||||
return rgb;
|
||||
|| "提货费".equals(text) || "送货费".equals(text)
|
||||
|| "币种".equals(text) || "币别".equals(text) || "时效".equals(text)
|
||||
|| text.contains("燃油")) {
|
||||
preferred = rgb;
|
||||
break;
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = rgb;
|
||||
}
|
||||
}
|
||||
if (preferred != null) {
|
||||
return preferred;
|
||||
}
|
||||
if (fallback != null) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -4737,6 +4581,24 @@ public final class QuoteTemplateExcelSupport {
|
||||
return r >= 200 && g >= 180 && b <= 140;
|
||||
}
|
||||
|
||||
/** 近白底:不能当表头采样色,否则会把灰底「币种」刷白。 */
|
||||
static boolean looksLikeWhiteOrEmptyFill(byte[] rgb) {
|
||||
if (rgb == null || rgb.length < 3) {
|
||||
return true;
|
||||
}
|
||||
int r = rgb[0] & 0xff;
|
||||
int g = rgb[1] & 0xff;
|
||||
int b = rgb[2] & 0xff;
|
||||
return r >= 245 && g >= 245 && b >= 245;
|
||||
}
|
||||
|
||||
static String rgbHex(byte[] rgb) {
|
||||
if (rgb == null || rgb.length < 3) {
|
||||
return "";
|
||||
}
|
||||
return String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff);
|
||||
}
|
||||
|
||||
static void applyFillRgb(Cell cell, byte[] fillRgb) {
|
||||
if (cell == null || fillRgb == null || fillRgb.length < 3) {
|
||||
return;
|
||||
@@ -4790,6 +4652,57 @@ public final class QuoteTemplateExcelSupport {
|
||||
return units >= 1 && !originDest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境整车车型尺寸副表头:如 4.1*2.1*2.3M / 12.8*2.32*2.7M。
|
||||
* 运价必须写在下一行,不能盖掉尺寸说明(WO125 曾把 4000 写进 D8)。
|
||||
*/
|
||||
static boolean rowLooksLikeVehicleDimensionRow(Sheet sheet, int rowIdx) {
|
||||
if (sheet == null || rowIdx < 0) {
|
||||
return false;
|
||||
}
|
||||
Row row = sheet.getRow(rowIdx);
|
||||
if (row == null) {
|
||||
return false;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
int hits = 0;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
|
||||
if (looksLikeVehicleDimensionText(text)) {
|
||||
hits++;
|
||||
if (hits >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 单个车型尺寸格:长*宽*高M 或 长x宽x高M。 */
|
||||
static boolean looksLikeVehicleDimensionText(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.replaceAll("\\s+", "").toUpperCase(Locale.ROOT);
|
||||
if (t.length() < 5 || t.length() > 40) {
|
||||
return false;
|
||||
}
|
||||
int stars = 0;
|
||||
for (int i = 0; i < t.length(); i++) {
|
||||
char ch = t.charAt(i);
|
||||
if (ch == '*' || ch == 'X' || ch == '×') {
|
||||
stars++;
|
||||
}
|
||||
}
|
||||
if (stars < 2) {
|
||||
return false;
|
||||
}
|
||||
return t.matches("^[0-9.]+[*X×][0-9.]+[*X×][0-9.]+M?$");
|
||||
}
|
||||
|
||||
/** 「项目一:零担报价 / 整车报价」「项目二:其它费用」。 */
|
||||
static boolean rowLooksLikeLandSectionTitle(Sheet sheet, int rowIdx) {
|
||||
if (sheet == null || rowIdx < 0) {
|
||||
@@ -5232,11 +5145,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
clearEstimateExtraCell(row, cell);
|
||||
}
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("T3", "overwriteEstimateTotal.sea", "wrote total at fee columns",
|
||||
"{\"row\":" + r + ",\"ccyCol\":" + ccyCol + ",\"amtCol\":" + amtCol
|
||||
+ ",\"ccy\":\"" + ccy + "\",\"amt\":\"" + norm.replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
continue;
|
||||
}
|
||||
// 空运:优先写到费目表「金额」列(常为 G),避免写到标签旁 B 而 SUM 仍在 G
|
||||
@@ -5304,11 +5212,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
Cell extra = row.getCell(dest[1]);
|
||||
clearEstimateExtraCell(row, extra);
|
||||
}
|
||||
// #region agent log
|
||||
agentDbg("T3", "overwriteEstimateTotal.air", "wrote tms total over SUM",
|
||||
"{\"row\":" + r + ",\"col\":" + targetCol
|
||||
+ ",\"amt\":\"" + norm.replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5480,13 +5383,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String name = StringUtils.trimToEmpty(rowObj.getString("name"));
|
||||
String amount = StringUtils.trimToEmpty(rowObj.getString("amount"));
|
||||
// #region agent log
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
agentDbg("H3", "writeFeeAmountsByName", "fee row candidate",
|
||||
"{\"name\":\"" + name.replace("\"", "'") + "\",\"amount\":\""
|
||||
+ amount.replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
if (StringUtils.isBlank(name) || StringUtils.isBlank(amount) || "-".equals(amount)) {
|
||||
continue;
|
||||
}
|
||||
@@ -6061,14 +5957,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
private static void writeCell(Workbook wb, String cellRef, String value) {
|
||||
// #region agent log
|
||||
if (StringUtils.isNotBlank(value) && (value.contains("广州") || value.contains("Guangzhou")
|
||||
|| "广州".equals(value.trim()))) {
|
||||
agentDbg("H5", "writeCell", "cell write pol-like",
|
||||
"{\"cellRef\":\"" + String.valueOf(cellRef).replace("\"", "'")
|
||||
+ "\",\"val\":\"" + value.replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
ParsedRef ref = ParsedRef.parse(cellRef);
|
||||
Sheet sheet = wb.getSheet(ref.sheet);
|
||||
if (sheet == null && wb.getNumberOfSheets() > 0) {
|
||||
@@ -6100,6 +5988,15 @@ public final class QuoteTemplateExcelSupport {
|
||||
|| rowLooksLikeLandUnitSubHeader(sheet, dest[0])) {
|
||||
return;
|
||||
}
|
||||
// mapping.fields 误指燃油/时效列时:CNY 不能经 writeCell 落进去(与 writeFeeCol 同规则)。
|
||||
String header = columnHeaderText(sheet, dest[0], dest[1]);
|
||||
if (looksLikeCurrencyCode(text) && columnRejectsCurrency(header)) {
|
||||
return;
|
||||
}
|
||||
// 起始地列禁止被费用名污染(与 writeFeeCol 同规则)。
|
||||
if (isCityRouteColumn(sheet, dest[0], dest[1], header) && looksLikeFeeNameNotPlace(text)) {
|
||||
return;
|
||||
}
|
||||
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
||||
if (colon >= 0) {
|
||||
String prefix = existing.substring(0, colon + 1);
|
||||
@@ -6474,19 +6371,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 费用明细数据行:「起运港/目的港」是项目列值,不是询价标签;
|
||||
// 否则会把 pol 写进右侧「费用名称」(WO202609230026:广州写入 B18)。
|
||||
if (!feeHeader && isFeeDetailDataRow(sheet, r)) {
|
||||
// #region agent log
|
||||
Row peek = sheet.getRow(r);
|
||||
String peek0 = peek == null ? "" : StringUtils.trimToEmpty(
|
||||
new DataFormatter().formatCellValue(peek.getCell(0)));
|
||||
String peek1 = peek == null ? "" : StringUtils.trimToEmpty(
|
||||
new DataFormatter().formatCellValue(peek.getCell(1)));
|
||||
if (peek0.contains("起运港") || peek1.contains("起运港")
|
||||
|| peek0.contains("空运段") || peek1.contains("空运段")) {
|
||||
agentDbg("H1", "writeFactsByLabel.skipFeeDetail", "skipped fee detail row",
|
||||
"{\"row\":" + r + ",\"c0\":\"" + peek0.replace("\"", "'")
|
||||
+ "\",\"c1\":\"" + peek1.replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
continue;
|
||||
}
|
||||
short lastCol = row.getLastCellNum();
|
||||
@@ -6524,11 +6408,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 运价矩阵的起始地/目的地按国内段、境外段拆开写,禁止同一对城市填进两张表。
|
||||
if (("origin".equals(code) || "destination".equals(code))
|
||||
&& rowLooksLikeLandRouteMatrixHeader(sheet, r)) {
|
||||
String kind = landRouteSectionKindNearRow(sheet, r);
|
||||
if ("domestic".equals(kind) && "destination".equals(code) && looksOverseasPlace(val)) {
|
||||
// 有 TMS 线路行时由 writeRouteRegions 分区写 OD;标签回填会用短地名盖掉「广东省-深圳市」。
|
||||
if (!parseFeeRows(facts.get("route_rows")).isEmpty()
|
||||
|| !parseFeeRows(facts.get("landSegments")).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if ("overseas".equals(kind) && "origin".equals(code) && looksDomesticPlace(val)) {
|
||||
String kind = landRouteSectionKindNearRow(sheet, r);
|
||||
boolean skipDomDest = "domestic".equals(kind) && "destination".equals(code)
|
||||
&& looksOverseasPlace(val);
|
||||
boolean skipOsOrigin = "overseas".equals(kind) && "origin".equals(code)
|
||||
&& looksDomesticPlace(val);
|
||||
if (skipDomDest || skipOsOrigin) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -6538,28 +6428,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
// 硬防护:pol/pod 等港口字段禁止写入费用「名称」列(无 TMS 时曾把「广州」写进费用名称)
|
||||
if (isPortFactCode(code) && isFeeNameColumnRef(sheet, fillRef)) {
|
||||
// #region agent log
|
||||
agentDbg("H1", "writeFactsByLabel.blockFeeName", "blocked pol/pod into fee name col",
|
||||
"{\"row\":" + r + ",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef
|
||||
+ "\",\"text\":\"" + text.replace("\"", "'")
|
||||
+ "\",\"val\":\"" + val.replace("\"", "'") + "\"}");
|
||||
// #endregion
|
||||
continue;
|
||||
}
|
||||
// #region agent log
|
||||
if ("pol".equals(code) || text.contains("起运港")
|
||||
|| "fcl_or_lcl".equals(code) || text.contains("整柜")
|
||||
|| "运输分类".equals(code) || text.contains("送货地址")) {
|
||||
boolean feeDetail = isFeeDetailDataRow(sheet, r);
|
||||
agentDbg("S1", "writeFactsByLabel.sea", "label matched sea-sensitive",
|
||||
"{\"sheet\":\"" + sheetName + "\",\"row\":" + r + ",\"col\":" + c
|
||||
+ ",\"text\":\"" + text.replace("\"", "'")
|
||||
+ "\",\"code\":\"" + code + "\",\"fillRef\":\"" + fillRef
|
||||
+ "\",\"feeHeader\":" + feeHeader
|
||||
+ ",\"feeDetailDataRow\":" + feeDetail
|
||||
+ ",\"val\":\"" + val.replace("\"", "'") + "\"}");
|
||||
}
|
||||
// #endregion
|
||||
boolean labelAfterColon = textContainsColon(text)
|
||||
&& ("quote_no".equals(code) || "quote_date".equals(code) || "valid_until".equals(code));
|
||||
// 编号/日期类:标签同格冒号后允许写黄区;其它字段仍跳过固定区
|
||||
@@ -6667,12 +6537,6 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #region agent log
|
||||
if (cleared > 0) {
|
||||
agentDbg("H1", "clearPortSpillInFeeNameColumn", "cleared port spill in fee names",
|
||||
"{\"cleared\":" + cleared + "}");
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+95
-2
@@ -51,6 +51,24 @@ public class InquiryTmsCabinService {
|
||||
@Lazy
|
||||
private InquiryTmsQueryService queryService;
|
||||
|
||||
/** 多段询价锁舱的产品口径。智能体群回复与主账拒绝用同一句。 */
|
||||
public static final String MULTI_LOCK_UNSUPPORTED = "多段询价,暂时不支持锁舱";
|
||||
|
||||
/**
|
||||
* 还没到 TMS 之前的拒绝原因。空运单返回空,表示可以继续锁。
|
||||
* 多段整单不是 AIR,但航线会按空运段来锁,单独说明暂不支持。
|
||||
*/
|
||||
static String lockRejectReason(String businessLine) {
|
||||
String line = StringUtils.trimToEmpty(businessLine);
|
||||
if ("MULTI".equalsIgnoreCase(line)) {
|
||||
return MULTI_LOCK_UNSUPPORTED;
|
||||
}
|
||||
if (!"AIR".equalsIgnoreCase(line)) {
|
||||
return "不是空运单,不能锁舱";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体锁舱入口。
|
||||
*/
|
||||
@@ -61,8 +79,11 @@ public class InquiryTmsCabinService {
|
||||
if (ticket == null) {
|
||||
return fail("ticket_not_found", "找不到工单");
|
||||
}
|
||||
if (!"AIR".equalsIgnoreCase(StringUtils.trimToEmpty(ticket.getBusinessLine()))) {
|
||||
return fail("not_air", "不是空运单,不能锁舱");
|
||||
// 多段整单业务线是 MULTI。含空运段时航线仍会来锁舱,不能再说「不是空运单」。
|
||||
String lockReject = lockRejectReason(ticket.getBusinessLine());
|
||||
if (StringUtils.isNotBlank(lockReject)) {
|
||||
String code = MULTI_LOCK_UNSUPPORTED.equals(lockReject) ? "multi_no_lock" : "not_air";
|
||||
return fail(code, lockReject);
|
||||
}
|
||||
if (TicketStatuses.DEAL.equals(ticket.getStatus())
|
||||
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
|
||||
@@ -233,6 +254,26 @@ public class InquiryTmsCabinService {
|
||||
req.put("requestedUntil", text(body, "requestedUntil"));
|
||||
}
|
||||
}
|
||||
// 航线口令里的航班号盖过报价。三项没齐就地拦住,不去调 TMS 锁舱。
|
||||
String flightFromCaller = firstNonBlank(
|
||||
text(body, "flightNo", "flight_no"),
|
||||
flightNoFromInstruction(text(body, "instructionText", "instruction_text")));
|
||||
if (StringUtils.isNotBlank(flightFromCaller)) {
|
||||
req.put("flightNo", flightFromCaller);
|
||||
}
|
||||
if (StringUtils.isBlank(text(req, "airline"))) {
|
||||
String fromFlight = airlineFromFlight(text(req, "flightNo"));
|
||||
if (StringUtils.isNotBlank(fromFlight)) {
|
||||
req.put("airline", fromFlight);
|
||||
}
|
||||
}
|
||||
String incomplete = missingLockParamReason(
|
||||
text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
|
||||
if (StringUtils.isNotBlank(incomplete)) {
|
||||
log.info("tms.lock skip incomplete wo={} optionNo={} airline={} flightNo={}",
|
||||
no, text(req, "optionNo"), text(req, "airline"), text(req, "flightNo"));
|
||||
return fail("lock_params_incomplete", incomplete);
|
||||
}
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
}
|
||||
|
||||
@@ -475,6 +516,14 @@ public class InquiryTmsCabinService {
|
||||
req.remove("quoteId");
|
||||
}
|
||||
fillLockQuoteExtras(req, quote, option);
|
||||
if (StringUtils.isBlank(text(req, "validUntil"))) {
|
||||
// 航线手工价经常不写有效期。TMS 人工锁舱必填 validUntil,
|
||||
// 用工单报价日(调用前已放进 flightDate)的当天结束,不再空着出站。
|
||||
String iso = normalizeValidUntilIso(text(req, "flightDate"));
|
||||
if (StringUtils.isNotBlank(iso)) {
|
||||
req.put("validUntil", iso);
|
||||
}
|
||||
}
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
@@ -1185,6 +1234,50 @@ public class InquiryTmsCabinService {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱三项没齐时的群回复。不叫锁舱失败,不提 TMS 查价结果。
|
||||
* 三项都缺时是:锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。
|
||||
*/
|
||||
static String missingLockParamReason(String optionNo, String airline, String flightNo) {
|
||||
java.util.List<String> missing = new ArrayList<>();
|
||||
if (StringUtils.isBlank(optionNo)) {
|
||||
missing.add("舱位编号");
|
||||
}
|
||||
if (StringUtils.isBlank(airline)) {
|
||||
missing.add("航司");
|
||||
}
|
||||
if (StringUtils.isBlank(flightNo)) {
|
||||
missing.add("航班号");
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "锁舱参数不完整,缺少:" + String.join("、", missing)
|
||||
+ "。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「AIR-OPT01 CZ 航班CZ327」抽出航班号。没有就不猜。
|
||||
*/
|
||||
static String flightNoFromInstruction(String instruction) {
|
||||
if (StringUtils.isBlank(instruction)) {
|
||||
return "";
|
||||
}
|
||||
java.util.regex.Matcher named = java.util.regex.Pattern
|
||||
.compile("(?i)(?:航班号|航班)\\s*[::]?\\s*(?:是|为)?\\s*([A-Za-z]{2}\\s*\\d{2,4})")
|
||||
.matcher(instruction);
|
||||
if (named.find()) {
|
||||
return named.group(1).toUpperCase();
|
||||
}
|
||||
java.util.regex.Matcher trailed = java.util.regex.Pattern
|
||||
.compile("(?i)AIR-?OPT-?\\d+\\s+(?:[A-Za-z]{2}\\s+)?([A-Za-z]{2}\\d{2,4})")
|
||||
.matcher(instruction);
|
||||
if (trailed.find()) {
|
||||
return trailed.group(1).toUpperCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
|
||||
*/
|
||||
|
||||
+87
@@ -380,8 +380,13 @@ public class InquiryTmsQueryService {
|
||||
quote.put("landSegments", json);
|
||||
// 同步一份 route_rows,字段名对齐填表(transit_time / salePrice 等)
|
||||
List<Map<String, String>> rows = normalizeLandSegmentRows(raw);
|
||||
// 腿上缺燃油时,从同段 feeItems 补(TMS 有时只把 fuelSurchargeRate 挂在费用行上)
|
||||
enrichLandFuelTaxFromFeeItems(rows, data == null ? null : data.get("feeItems"),
|
||||
data == null ? null : data.get("fee_items"));
|
||||
if (!rows.isEmpty()) {
|
||||
quote.put("route_rows", JSON.toJSONString(rows));
|
||||
// 回写补全后的腿,避免后续只读 landSegments 时又丢燃油
|
||||
quote.put("landSegments", JSON.toJSONString(rows));
|
||||
}
|
||||
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
||||
String feeLines = quote.get("fee_lines");
|
||||
@@ -1019,6 +1024,88 @@ public class InquiryTmsQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运腿缺燃油附加费率/税率时,按同段 feeItems 补。
|
||||
* <p>
|
||||
* 为何:TMS 偶发只在费用行带 fuelSurchargeRate,腿上为 null;出单侧栏就会空着。
|
||||
* 不编造;feeItems 也没有则保持空。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static void enrichLandFuelTaxFromFeeItems(List<Map<String, String>> rows, Object... feeSources) {
|
||||
if (rows == null || rows.isEmpty() || feeSources == null) {
|
||||
return;
|
||||
}
|
||||
List<Map<?, ?>> fees = new ArrayList<>();
|
||||
for (Object src : feeSources) {
|
||||
if (src == null) {
|
||||
continue;
|
||||
}
|
||||
Object parsed = src;
|
||||
if (src instanceof String) {
|
||||
try {
|
||||
parsed = JSON.parse(String.valueOf(src));
|
||||
} catch (Exception ex) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (parsed instanceof List) {
|
||||
for (Object one : (List<?>) parsed) {
|
||||
if (one instanceof Map) {
|
||||
fees.add((Map<?, ?>) one);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fees.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, String> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
boolean needFuel = StringUtils.isBlank(row.get("fuel_surcharge_rate"))
|
||||
&& StringUtils.isBlank(row.get("fuelSurchargeRate"));
|
||||
boolean needTax = StringUtils.isBlank(row.get("tax_rate"))
|
||||
&& StringUtils.isBlank(row.get("taxRate"));
|
||||
if (!needFuel && !needTax) {
|
||||
continue;
|
||||
}
|
||||
String seg = firstText(row.get("segmentSequence"), "1");
|
||||
String leg = firstText(row.get("routeLegSequence"), "");
|
||||
for (Map<?, ?> fee : fees) {
|
||||
String feeSeg = firstText(fee.get("segmentSequence"), fee.get("segment_sequence"), "1");
|
||||
String feeLeg = firstText(fee.get("routeLegSequence"), fee.get("route_leg_sequence"), "");
|
||||
if (!seg.equals(feeSeg)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(leg) && StringUtils.isNotBlank(feeLeg) && !leg.equals(feeLeg)) {
|
||||
continue;
|
||||
}
|
||||
if (needFuel) {
|
||||
String fuel = formatLandPercentRate(firstText(
|
||||
fee.get("fuelSurchargeRate"), fee.get("fuel_surcharge_rate")));
|
||||
if (StringUtils.isNotBlank(fuel)) {
|
||||
row.put("fuel_surcharge_rate", fuel);
|
||||
row.put("fuelSurchargeRate", fuel);
|
||||
needFuel = false;
|
||||
}
|
||||
}
|
||||
if (needTax) {
|
||||
// 费用行 rate 是税率;禁止拿无关字段冒充
|
||||
String tax = formatLandTaxRate(firstText(fee.get("rate"), fee.get("tax_rate"), fee.get("taxRate")));
|
||||
if (StringUtils.isNotBlank(tax)) {
|
||||
row.put("tax_rate", tax);
|
||||
row.put("taxRate", tax);
|
||||
needTax = false;
|
||||
}
|
||||
}
|
||||
if (!needFuel && !needTax) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 燃油附加费率展示:数字带 %;已带 % 保留。空值不编造。
|
||||
*/
|
||||
|
||||
+8
-2
@@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms;
|
||||
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
||||
* <p>
|
||||
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
||||
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
|
||||
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
||||
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
||||
*/
|
||||
@@ -14,10 +15,15 @@ public final class TmsLandEnumMapper {
|
||||
|
||||
/**
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return trim 后原文;空则 ""
|
||||
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
|
||||
*/
|
||||
public static String transportType(String raw) {
|
||||
return trimOrEmpty(raw);
|
||||
String text = trimOrEmpty(raw);
|
||||
if ("中港整车".equals(text) || "中港零担/集拼".equals(text)) {
|
||||
// zhonggang: 两类细类查价都只传「中港」
|
||||
return "中港";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ import java.util.regex.Pattern;
|
||||
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
|
||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
||||
* 中港整车不带车型/数量。通关口岸有值才放。
|
||||
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
|
||||
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
||||
*/
|
||||
public final class TmsLandSegmentMapper {
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
|
||||
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。
|
||||
*/
|
||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
{"source_name": "北京首都", "source_code": "PEK"},
|
||||
{"source_name": "北京大兴", "source_code": "PKX"},
|
||||
{"source_name": "珠海", "source_code": "ZUH"},
|
||||
{"source_name": "克拉克", "source_code": "CRK"}
|
||||
{"source_name": "克拉克", "source_code": "CRK"},
|
||||
{"source_name": "南京", "source_code": "NKG"},
|
||||
{"source_name": "吉隆坡", "source_code": "KUL"},
|
||||
{"source_name": "北京", "source_code": "PEK"},
|
||||
{"source_name": "杭州", "source_code": "HGH"},
|
||||
{"source_name": "萧山", "source_code": "HGH"},
|
||||
{"source_name": "杭州萧山", "source_code": "HGH"}
|
||||
],
|
||||
"air_packaging_type": [
|
||||
{"source_name": "全部", "source_code": "1"},
|
||||
|
||||
+251
@@ -2,10 +2,13 @@ package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.poi.ss.usermodel.FillPatternType;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
|
||||
import org.apache.poi.xssf.usermodel.XSSFColor;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -1736,6 +1739,127 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-price-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-price-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
|
||||
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
|
||||
head1.createCell(1).setCellValue("起始地");
|
||||
head1.createCell(2).setCellValue("目的地");
|
||||
head1.createCell(3).setCellValue("3T");
|
||||
head1.createCell(4).setCellValue("5T");
|
||||
org.apache.poi.ss.usermodel.Row dim1 = row(sheet, 8);
|
||||
dim1.createCell(3).setCellValue("4.1*2.1*2.3M");
|
||||
dim1.createCell(4).setCellValue("6.5*2.4*2.5M");
|
||||
row(sheet, 24).createCell(0).setCellValue("二、境外报价");
|
||||
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
|
||||
head2.createCell(1).setCellValue("起始地");
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(4).setCellValue("2.5T");
|
||||
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
|
||||
dim2.createCell(3).setCellValue("2.8x1.5x1.6M");
|
||||
dim2.createCell(4).setCellValue("3.50x1.6x1.6M");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject inland = new JSONObject(true);
|
||||
inland.put("title", "国内段报价");
|
||||
inland.put("headerRow", 8);
|
||||
inland.put("dataStartRow", 8);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
JSONObject rates1 = new JSONObject(true);
|
||||
rates1.put("3T", "D");
|
||||
rates1.put("5T", "E");
|
||||
cols1.put("vehicleRates", rates1);
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外报价");
|
||||
overseas.put("headerRow", 27);
|
||||
overseas.put("dataStartRow", 27);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
JSONObject rates2 = new JSONObject(true);
|
||||
rates2.put("1.25T", "D");
|
||||
rates2.put("2.5T", "E");
|
||||
cols2.put("vehicleRates", rates2);
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "深圳");
|
||||
facts.put("destination", "春疆货场");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("route_rows",
|
||||
"[{\"origin\":\"广东省-深圳市\",\"destination\":\"谅山-春疆货场\","
|
||||
+ "\"vehicleType\":\"3T\",\"amount\":\"4000.00\",\"currency\":\"CNY\"}]");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
|
||||
assertEquals("广东省-深圳市", read(sheet, 9, 1));
|
||||
assertEquals("", read(sheet, 9, 2));
|
||||
assertEquals("4000.00", read(sheet, 9, 3));
|
||||
assertEquals("", read(sheet, 9, 4));
|
||||
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
|
||||
assertEquals("", read(sheet, 28, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
|
||||
assertEquals("", read(sheet, 28, 3));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 境外段「起始地」若是白底,对齐表头时不能拿它去刷「币种」灰底。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlAlignHeaderSkipsWhiteOriginFill() throws Exception {
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
Row head = sheet.createRow(26);
|
||||
head.createCell(1).setCellValue("起始地");
|
||||
head.createCell(2).setCellValue("目的地");
|
||||
head.createCell(3).setCellValue("1.25T");
|
||||
head.createCell(4).setCellValue("2.5T");
|
||||
head.createCell(14).setCellValue("币种");
|
||||
XSSFCellStyle white = (XSSFCellStyle) wb.createCellStyle();
|
||||
white.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF}, null));
|
||||
white.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
XSSFCellStyle grey = (XSSFCellStyle) wb.createCellStyle();
|
||||
grey.setFillForegroundColor(new XSSFColor(new byte[]{(byte) 0xCF, (byte) 0xCF, (byte) 0xCF}, null));
|
||||
grey.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
head.getCell(1).setCellStyle(white);
|
||||
head.getCell(2).setCellStyle(white);
|
||||
head.getCell(3).setCellStyle(grey);
|
||||
head.getCell(4).setCellStyle(grey);
|
||||
head.getCell(14).setCellStyle(grey);
|
||||
byte[] sample = QuoteTemplateExcelSupport.sampleLandMatrixHeaderFill(sheet, 26);
|
||||
assertFalse(QuoteTemplateExcelSupport.looksLikeWhiteOrEmptyFill(sample));
|
||||
assertEquals("CFCFCF", String.format("%02X%02X%02X",
|
||||
sample[0] & 0xff, sample[1] & 0xff, sample[2] & 0xff));
|
||||
QuoteTemplateExcelSupport.alignLandNestedHeaderFill(wb);
|
||||
byte[] currencyFill = QuoteTemplateExcelSupport.readFillRgb(head.getCell(14));
|
||||
assertEquals("CFCFCF", String.format("%02X%02X%02X",
|
||||
currencyFill[0] & 0xff, currencyFill[1] & 0xff, currencyFill[2] & 0xff));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
|
||||
*/
|
||||
@@ -1819,6 +1943,133 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。
|
||||
* 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsOriginCurrencyFuelOnStaggeredHeaders() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-wo006-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-wo006-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车报价模板");
|
||||
row(sheet, 4).createCell(0).setCellValue("一、国内段报价");
|
||||
org.apache.poi.ss.usermodel.Row band1 = row(sheet, 5);
|
||||
band1.createCell(0).setCellValue("国内段报价");
|
||||
band1.createCell(1).setCellValue("路线");
|
||||
band1.createCell(3).setCellValue("车型");
|
||||
band1.createCell(13).setCellValue("时效");
|
||||
band1.createCell(15).setCellValue("燃油附加费率");
|
||||
band1.createCell(16).setCellValue("税率");
|
||||
band1.createCell(17).setCellValue("含税合计");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 13, 13));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 15, 15));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 16, 16));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 7, 17, 17));
|
||||
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 6);
|
||||
head1.createCell(1).setCellValue("起始地");
|
||||
head1.createCell(2).setCellValue("目的地");
|
||||
head1.createCell(3).setCellValue("3T");
|
||||
head1.createCell(4).setCellValue("5T");
|
||||
// 币种只印在第 7 行 O 列,和时效/燃油不在同一行(上传版真实布局)
|
||||
head1.createCell(14).setCellValue("币种");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 1, 1));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(6, 7, 2, 2));
|
||||
row(sheet, 7).createCell(3).setCellValue("4.1*2.1*2.3M");
|
||||
row(sheet, 23).createCell(0).setCellValue("二、境外报价");
|
||||
org.apache.poi.ss.usermodel.Row band2 = row(sheet, 24);
|
||||
band2.createCell(0).setCellValue("境外报价");
|
||||
band2.createCell(13).setCellValue("时效");
|
||||
band2.createCell(15).setCellValue("燃油附加费率");
|
||||
band2.createCell(16).setCellValue("税率");
|
||||
band2.createCell(17).setCellValue("含税合计");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 13, 13));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 15, 15));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 16, 16));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(24, 26, 17, 17));
|
||||
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 25);
|
||||
head2.createCell(1).setCellValue("起始地");
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(14).setCellValue("币种");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 1, 1));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(25, 26, 2, 2));
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车报价模板");
|
||||
// 映射误把 currency 指到燃油列 P9:writeCell 路径也必须拒绝
|
||||
JSONObject fields = new JSONObject(true);
|
||||
fields.put("currency", "跨境整车报价模板!P9");
|
||||
mapping.put("fields", fields);
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject inland = new JSONObject(true);
|
||||
inland.put("title", "国内段报价");
|
||||
// headerRow 只指到带「时效/燃油」的第 6 行;币种在下一行,侧栏扫描必须仍能找到
|
||||
inland.put("headerRow", 6);
|
||||
inland.put("dataStartRow", 9);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外报价");
|
||||
overseas.put("headerRow", 25);
|
||||
overseas.put("dataStartRow", 28);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
// fee_rows 在 routeRegions 之后写:误指运价行时不能把「运输费」盖掉起始地
|
||||
JSONObject feeRows = new JSONObject(true);
|
||||
feeRows.put("anchor", "跨境整车报价模板!B9");
|
||||
JSONObject feeCols = new JSONObject(true);
|
||||
feeCols.put("name", "B");
|
||||
feeCols.put("amount", "D");
|
||||
feeRows.put("columns", feeCols);
|
||||
mapping.put("fee_rows", feeRows);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "深圳");
|
||||
facts.put("destination", "春疆货场");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("transit_days", "N+1");
|
||||
facts.put("fuel_surcharge_rate", "8%");
|
||||
facts.put("tax_rate", "0%");
|
||||
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"origin\":\"深圳\",\"destination\":\"春疆货场\",\"currency\":\"CNY\","
|
||||
+ "\"transit_time\":\"N+1\",\"fuel_surcharge_rate\":\"8%\",\"tax_rate\":\"0%\","
|
||||
+ "\"amount\":\"1200\"}]"));
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"name\":\"运输费\",\"amount\":\"1200\",\"currency\":\"CNY\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("起始地", read(sheet, 6, 1));
|
||||
// 国内段只写境内起点;春疆货场是境外终点,不能被「运输费」盖掉起点
|
||||
assertEquals("深圳", read(sheet, 8, 1));
|
||||
assertEquals("", read(sheet, 8, 2));
|
||||
assertEquals("N+1", read(sheet, 8, 13));
|
||||
assertEquals("CNY", read(sheet, 8, 14));
|
||||
assertEquals("8%", read(sheet, 8, 15));
|
||||
assertEquals("0%", read(sheet, 8, 16));
|
||||
assertFalse("CNY".equalsIgnoreCase(read(sheet, 8, 15)));
|
||||
assertFalse("运输费".equals(read(sheet, 8, 1)));
|
||||
assertEquals("币种", read(sheet, 6, 14));
|
||||
assertEquals("燃油附加费率", read(sheet, 5, 15));
|
||||
// 境外段只写境外终点
|
||||
assertEquals("", read(sheet, 27, 1));
|
||||
assertEquals("春疆货场", read(sheet, 27, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
|
||||
*/
|
||||
|
||||
+37
@@ -15,6 +15,15 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
*/
|
||||
public class InquiryTmsCabinServiceTest {
|
||||
|
||||
@Test
|
||||
public void multiInquiryCannotLockCabin() {
|
||||
assertEquals(
|
||||
"多段询价,暂时不支持锁舱",
|
||||
InquiryTmsCabinService.lockRejectReason("MULTI"));
|
||||
assertEquals("不是空运单,不能锁舱", InquiryTmsCabinService.lockRejectReason("SEA"));
|
||||
assertEquals("", InquiryTmsCabinService.lockRejectReason("AIR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pickNamedOption() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
@@ -186,6 +195,34 @@ public class InquiryTmsCabinServiceTest {
|
||||
assertEquals("空运费", fees.get(0).get("feeName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualLockFillsValidUntilFromFlightDateWhenQuoteHasNone() {
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("flightDate", "2026-10-11");
|
||||
InquiryTmsCabinService.fillManualLock(req, new HashMap<>(), new HashMap<>(), new HashMap<>(), false);
|
||||
assertEquals("2026-10-11T23:59:59+08:00", req.get("validUntil"));
|
||||
assertEquals("2026-10-11T23:59:59+08:00", req.get("requestedUntil"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingLockParamsAsksWithoutCallingTmsWording() {
|
||||
assertEquals(
|
||||
"锁舱参数不完整,缺少:舱位编号、航司、航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
|
||||
InquiryTmsCabinService.missingLockParamReason("", "", ""));
|
||||
assertEquals(
|
||||
"锁舱参数不完整,缺少:航班号。请补充后再锁,例如:AIR-OPT01,南航,航班CZ327。",
|
||||
InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", ""));
|
||||
assertEquals("", InquiryTmsCabinService.missingLockParamReason("AIR-OPT01", "CZ", "CZ327"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flightNoFromInstructionReadsSampleFormat() {
|
||||
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction(
|
||||
"AIR-OPT01 CZ 航班CZ327"));
|
||||
assertEquals("CZ327", InquiryTmsCabinService.flightNoFromInstruction("AIR-OPT01 CZ CZ327"));
|
||||
assertEquals("", InquiryTmsCabinService.flightNoFromInstruction("WO202610080023锁舱"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airlineFromInstructionReadsCodeAfterOption() {
|
||||
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
|
||||
|
||||
+7
@@ -32,4 +32,11 @@ public class TmsExactProjectorTest {
|
||||
assertTrue(hit.ok());
|
||||
assertEquals("SHA", hit.code);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hangzhouMapsToXiaoshan() {
|
||||
TmsExactProjector.Projection hit = projector.resolveAirport("杭州");
|
||||
assertTrue(hit.ok());
|
||||
assertEquals("HGH", hit.code);
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest {
|
||||
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkTypesCollapseToZhonggang() {
|
||||
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
|
||||
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyEnglishCodesPassThrough() {
|
||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
facts.put("体积", "99");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("中港零担/集拼", seg.get("landTransportType"));
|
||||
assertEquals("中港", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("目的港", "香港");
|
||||
facts.put("品名", "衣服");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("中港整车", seg.get("landTransportType"));
|
||||
assertEquals("中港", seg.get("landTransportType"));
|
||||
assertNull(seg.get("vehicleType"));
|
||||
assertNull(seg.get("quantity"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
|
||||
+33
@@ -220,6 +220,39 @@ public class TmsQuoteCurrencyTest {
|
||||
assertEquals("", InquiryTmsQueryService.formatLandPercentRate(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 腿上燃油为空时,从同段 feeItems.fuelSurchargeRate 补到 route_rows,供出单侧栏写入。
|
||||
*/
|
||||
@Test
|
||||
public void toAgentQuoteFillsLandFuelFromFeeItemsWhenSegmentMissing() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("segmentSequence", 1);
|
||||
seg.put("routeLegSequence", 1);
|
||||
seg.put("origin", "深圳");
|
||||
seg.put("destination", "春疆货场");
|
||||
seg.put("costPrice", "1200");
|
||||
seg.put("currency", "CNY");
|
||||
Map<String, Object> fee = new HashMap<>();
|
||||
fee.put("segmentSequence", 1);
|
||||
fee.put("routeLegSequence", 1);
|
||||
fee.put("feeName", "运输费");
|
||||
fee.put("amount", 1200);
|
||||
fee.put("currency", "CNY");
|
||||
fee.put("fuelSurchargeRate", 8);
|
||||
fee.put("rate", 0);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("currency", "CNY");
|
||||
data.put("landSegments", Collections.singletonList(seg));
|
||||
data.put("feeItems", Collections.singletonList(fee));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertTrue(quote.get("route_rows").contains("\"fuel_surcharge_rate\":\"8%\""));
|
||||
assertTrue(quote.get("route_rows").contains("\"tax_rate\":\"0%\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteKeepsSeaBucketTotals() {
|
||||
Map<String, Object> pol = new HashMap<>();
|
||||
|
||||
+6
-6
@@ -65,14 +65,14 @@ public class SysPermissionServiceImpl extends ServiceImpl<SysPermissionMapper, S
|
||||
* 为何加护栏:询价测服库菜单已是 Vue3,且没有 {@code sys_permission_v3};盲目切换会弄挂后台菜单。
|
||||
* </p>
|
||||
*/
|
||||
/**
|
||||
* 询价新库永久拒绝切表:即便存在 sys_permission_v3 也不自动 rename,
|
||||
* 避免非管理员误点前端「Vue2 菜单」弹窗后弄挂 inquiry_robot 侧栏。
|
||||
* 真要升级菜单表须走人工迁移脚本,禁止在线一键切换。
|
||||
*/
|
||||
@Override
|
||||
public void switchVue3Menu() {
|
||||
Long vue3TableCnt = sysPermissionMapper.countVue3MenuTable();
|
||||
if (vue3TableCnt == null || vue3TableCnt.longValue() <= 0L) {
|
||||
throw new JeecgBootException("当前库没有 sys_permission_v3,菜单已是 Vue3 或不可自动切换,已拒绝执行");
|
||||
}
|
||||
sysPermissionMapper.backupVue2Menu();
|
||||
sysPermissionMapper.changeVue3Menu();
|
||||
throw new JeecgBootException("已禁用在线切换 Vue3 菜单表;inquiry_robot 菜单按迁移脚本维护,禁止 rename sys_permission");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -24,10 +24,12 @@ export const getMenuList = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换成vue3菜单
|
||||
* 切换成 vue3 菜单 —— 询价库永久禁用。
|
||||
* <p>
|
||||
* Jeecg 原逻辑会把 sys_permission rename 成 v2 再切 v3 表;测服 inquiry_robot
|
||||
* 菜单已是 Vue3 且常无 sys_permission_v3,执行会弄挂侧栏。前端不再发切表请求。
|
||||
*/
|
||||
export const switchVue3Menu = () => {
|
||||
return new Promise((resolve) => {
|
||||
defHttp.get({ url: Api.SwitchVue3Menu });
|
||||
});
|
||||
console.warn('[ytd] switchVue3Menu disabled: inquiry_robot menus are already Vue3');
|
||||
return Promise.resolve({ success: false, message: 'switchVue3Menu disabled' });
|
||||
};
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
import { useRoute, useRouter, RouterView } from 'vue-router';
|
||||
import { message, ConfigProvider } from 'ant-design-vue';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { usePermission } from '/@/hooks/web/usePermission';
|
||||
import ProtoIcon from '/@/views/inquiry/components/ProtoIcon.vue';
|
||||
import AdminModal from '/@/views/inquiry/components/AdminModal.vue';
|
||||
import { canSeeInquiryPage } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||
import '/@/views/inquiry/styles/admin-prototype.css';
|
||||
import '/@/views/inquiry/styles/admin-fidelity.css';
|
||||
import '/@/views/inquiry/styles/dashboard-prototype.css';
|
||||
@@ -74,12 +76,26 @@
|
||||
components: { RouterView, ProtoIcon, AdminModal, ConfigProvider },
|
||||
setup() {
|
||||
const userStore = useUserStore();
|
||||
const { hasPermission } = usePermission();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userMenuOpen = ref(false);
|
||||
const profileOpen = ref(false);
|
||||
const passwordOpen = ref(false);
|
||||
|
||||
/**
|
||||
* 侧栏按查看权限码过滤;无权限的分组整组隐藏。
|
||||
* 不再渲染写死全量 navGroups,避免「没权限仍看见菜单」。
|
||||
*/
|
||||
const visibleNavGroups = computed(() =>
|
||||
navGroups
|
||||
.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((item) => canSeeInquiryPage(item.key, hasPermission)),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0),
|
||||
);
|
||||
|
||||
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
||||
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
||||
|
||||
@@ -167,11 +183,15 @@
|
||||
if (!t.closest('.admin-user-menu')) closeUserMenu();
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick));
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocClick);
|
||||
});
|
||||
|
||||
return {
|
||||
navGroups,
|
||||
navGroups: visibleNavGroups,
|
||||
themeToken,
|
||||
route,
|
||||
pageTitle,
|
||||
|
||||
@@ -21,11 +21,12 @@ import { getMenuList } from '/@/api/sys/menu';
|
||||
import { getPermCode } from '/@/api/sys/user';
|
||||
|
||||
import { PageEnum } from '/@/enums/pageEnum';
|
||||
import { INQUIRY_PAGE_VIEW_PERMS } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||
|
||||
/**
|
||||
* 询价原型侧栏路径是写死的;Jeecg BACK 模式只注册后台菜单里的路由。
|
||||
* 菜单种子缺失/组件路径不对时,点侧栏会进 404(本机与线上相同)。
|
||||
* 这里按侧栏清单补挂手写页,不依赖种子是否已导入。
|
||||
* 询价手写页:纠正菜单种子里的组件路径/标题。
|
||||
* 禁止把未授权页强行补进路由——否则 backMenuList 会膨胀成全量侧栏,无权限也看得见。
|
||||
* 仅当「后台菜单已有该路径」或「用户已有对应查看权限码」时才补挂缺失叶子。
|
||||
*/
|
||||
type InquiryLeaf = {
|
||||
path: string;
|
||||
@@ -78,16 +79,65 @@ function findParent(list: AppRouteRecordRaw[] = [], parentPath: string): AppRout
|
||||
return null;
|
||||
}
|
||||
|
||||
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
||||
const groups: Record<string, { title: string; redirect: string; leaves: InquiryLeaf[] }> = {
|
||||
/**
|
||||
* 永久禁止 Jeecg「Vue2 菜单表」切表弹窗。
|
||||
* <p>
|
||||
* 检测条件:顶层既没有 component===layouts/default/index,图标也不含冒号。
|
||||
* 非管理员若只分到缺标记的询价菜单,会被误判;点确认会 rename sys_permission,测服无 v3 表会弄挂侧栏。
|
||||
* 本函数恒为 false,禁止再恢复弹窗逻辑。
|
||||
*/
|
||||
function shouldPromptVue2MenuSwitch(_routeList: AppRouteRecordRaw[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给菜单补齐 Vue3 标记,避免未来误恢复 Jeecg 检测时再次弹窗。
|
||||
* 副作用:就地改 component / meta.icon;不改业务权限。
|
||||
*/
|
||||
function normalizeVue3MenuMarkers(routeList: AppRouteRecordRaw[]): AppRouteRecordRaw[] {
|
||||
for (const item of routeList || []) {
|
||||
const comp = String((item as any).component || '');
|
||||
// Jeecg 检测认 layouts/default/index;LAYOUT 也会被 transform 成询价壳
|
||||
if (!comp || comp.toUpperCase() === 'LAYOUT') {
|
||||
(item as any).component = 'layouts/default/index';
|
||||
}
|
||||
item.meta = item.meta || ({} as any);
|
||||
const icon = String((item.meta as any).icon || '');
|
||||
if (!icon) {
|
||||
(item.meta as any).icon = 'ant-design:menu-outlined';
|
||||
} else if (!icon.includes(':')) {
|
||||
(item.meta as any).icon = `ant-design:${icon}`;
|
||||
}
|
||||
}
|
||||
return routeList;
|
||||
}
|
||||
|
||||
function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[], permCodes: Array<string | number> = []) {
|
||||
const codeSet = new Set((permCodes || []).map(String));
|
||||
const canAddLeaf = (leaf: InquiryLeaf) => {
|
||||
// 后台菜单已下发该路径:只纠正组件,不算越权补挂
|
||||
if (findRouteByPath(routeList, leaf.path)) {
|
||||
return true;
|
||||
}
|
||||
const need = INQUIRY_PAGE_VIEW_PERMS[leaf.path];
|
||||
// 无权限码表时不补挂(宁可少菜单,也不把未授权页塞进侧栏)
|
||||
if (!need) {
|
||||
return false;
|
||||
}
|
||||
return codeSet.has(need);
|
||||
};
|
||||
|
||||
const groups: Record<string, { title: string; redirect: string; icon: string; leaves: InquiryLeaf[] }> = {
|
||||
'/inquiry': {
|
||||
title: '询价',
|
||||
redirect: '/inquiry/dashboard',
|
||||
icon: 'ant-design:robot-outlined',
|
||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry/') && !l.path.startsWith('/inquiry-sys')),
|
||||
},
|
||||
'/inquiry-sys': {
|
||||
title: '询价系统配置',
|
||||
redirect: '/inquiry-sys/system-setting',
|
||||
icon: 'ant-design:setting-outlined',
|
||||
leaves: INQUIRY_LEAVES.filter((l) => l.path.startsWith('/inquiry-sys/')),
|
||||
},
|
||||
};
|
||||
@@ -105,6 +155,10 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
||||
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
|
||||
continue;
|
||||
}
|
||||
// 角色未授权且无对应查看权限:禁止补挂(根因:曾把 8 个询价页全塞进 backMenuList)
|
||||
if (!canAddLeaf(leaf)) {
|
||||
continue;
|
||||
}
|
||||
const node: AppRouteRecordRaw = {
|
||||
path: leaf.path,
|
||||
name: leaf.name,
|
||||
@@ -119,12 +173,23 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
||||
parent = {
|
||||
path: parentPath,
|
||||
name: parentPath.replace(/\//g, '-').replace(/^-/, '') || 'inquiry-root',
|
||||
component: 'LAYOUT' as any,
|
||||
// 必须用 layouts/default/index + 带冒号图标,满足 Jeecg Vue3 菜单检测(实际仍映射到询价壳)
|
||||
component: 'layouts/default/index' as any,
|
||||
redirect: group.redirect,
|
||||
meta: { title: group.title },
|
||||
meta: { title: group.title, icon: group.icon },
|
||||
children: [],
|
||||
};
|
||||
routeList.push(parent);
|
||||
} else {
|
||||
// 已有父级也补标记,防止非管理员菜单缺图标被误判 Vue2
|
||||
const pComp = String((parent as any).component || '');
|
||||
if (!pComp || pComp.toUpperCase() === 'LAYOUT') {
|
||||
(parent as any).component = 'layouts/default/index';
|
||||
}
|
||||
parent.meta = {
|
||||
...(parent.meta || {}),
|
||||
icon: (parent.meta as any)?.icon || group.icon,
|
||||
};
|
||||
}
|
||||
parent.children = parent.children || [];
|
||||
if (leaf.path === group.redirect) {
|
||||
@@ -137,6 +202,49 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
|
||||
return routeList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 去掉无查看权限的询价叶子(角色树勾了菜单但没勾按钮、或历史脏数据)。
|
||||
* 父节点若子级删空则一并去掉,避免侧栏空分组。
|
||||
*/
|
||||
function pruneInquiryLeavesWithoutViewPerm(
|
||||
routeList: AppRouteRecordRaw[],
|
||||
permCodes: Array<string | number> = [],
|
||||
): AppRouteRecordRaw[] {
|
||||
const codeSet = new Set((permCodes || []).map(String));
|
||||
// 权限码尚未就绪时禁止裁剪:否则会把 getMenuList 结果清空 → 重登 404
|
||||
if (codeSet.size === 0) {
|
||||
return routeList;
|
||||
}
|
||||
const norm = (p: string) => {
|
||||
const s = String(p || '');
|
||||
return s.startsWith('/') ? s : `/${s}`;
|
||||
};
|
||||
const walk = (list: AppRouteRecordRaw[] = []): AppRouteRecordRaw[] => {
|
||||
const out: AppRouteRecordRaw[] = [];
|
||||
for (const item of list) {
|
||||
const path = norm(item.path || '');
|
||||
const need = INQUIRY_PAGE_VIEW_PERMS[path];
|
||||
if (need && !codeSet.has(need)) {
|
||||
continue;
|
||||
}
|
||||
const next = { ...item };
|
||||
if (next.children?.length) {
|
||||
next.children = walk(next.children as AppRouteRecordRaw[]);
|
||||
// 询价分组下无子页则不保留空壳
|
||||
if (
|
||||
(path === '/inquiry' || path === '/inquiry-sys') &&
|
||||
(!next.children || next.children.length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(routeList);
|
||||
}
|
||||
|
||||
|
||||
// 系统权限
|
||||
interface AuthItem {
|
||||
@@ -323,21 +431,27 @@ export const usePermissionStore = defineStore({
|
||||
// 这个函数可能只需要执行一次,并且实际的项目可以在正确的时间被放置
|
||||
let routeList: AppRouteRecordRaw[] = [];
|
||||
try {
|
||||
this.changePermissionCode();
|
||||
// 必须 await:logout 会清空 permCodeList;若此处不候等,后面 prune 会在空权限码下
|
||||
// 把询价叶子全删掉 → 侧栏仍能靠稍后到的权限码显示,点进去却是 404。
|
||||
await this.changePermissionCode();
|
||||
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
||||
// Jeecg 原逻辑:顶层没有 layouts/default/index 且图标无冒号 → 弹窗并调用 /sys/switchVue3Menu
|
||||
// (把 sys_permission rename 成 v2,再把 sys_permission_v3 rename 成正式表)。
|
||||
// 询价库 inquiry_robot 菜单种子已是 Vue3,且测服没有 sys_permission_v3;点确认会弄挂侧栏。
|
||||
// 因此禁用自动切表弹窗;后面仍会 ensureInquiryHandwrittenRoutes 补齐询价页。
|
||||
// update-begin----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
|
||||
// update-end----author:ytd---date:20261007------for: 禁用危险的 Vue2→Vue3 菜单切表提示 ---
|
||||
// 永久禁止 Vue2→Vue3 切表弹窗(shouldPromptVue2MenuSwitch 恒 false)
|
||||
if (shouldPromptVue2MenuSwitch(routeList)) {
|
||||
// 不可达;保留分支防止以后误合并 Jeecg 原弹窗代码
|
||||
}
|
||||
// 非管理员菜单常缺 Vue3 标记 → 误弹「菜单表是 Vue2」;先规范化再往下走
|
||||
routeList = normalizeVue3MenuMarkers(routeList || []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
||||
routeList = Array.isArray(routeList) ? routeList : [];
|
||||
}
|
||||
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
|
||||
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
|
||||
// 只纠正/补挂「已有菜单或已有查看权限」的手写页,禁止灌入未授权路由
|
||||
const permCodes = this.getPermCodeList || [];
|
||||
routeList = ensureInquiryHandwrittenRoutes(routeList || [], permCodes);
|
||||
routeList = pruneInquiryLeavesWithoutViewPerm(routeList, permCodes);
|
||||
// 补挂后再标一次(ensure 新建的父级也要带 Vue3 标记)
|
||||
routeList = normalizeVue3MenuMarkers(routeList);
|
||||
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
||||
routeList = addSlashToRouteComponent(routeList);
|
||||
// 动态引入组件
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
|
||||
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
|
||||
|
||||
const rows = ref<AccountRow[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -299,7 +305,7 @@ function removeAccount(row: AccountRow) {
|
||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||
<ProtoIcon name="download" :size="14" />导出
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
||||
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="plus" :size="14" />新建账号
|
||||
</button>
|
||||
</div>
|
||||
@@ -369,7 +375,7 @@ function removeAccount(row: AccountRow) {
|
||||
<td>
|
||||
<div class="account-row-actions">
|
||||
<button
|
||||
v-if="!row.isSuperAdmin"
|
||||
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="openEdit(row)"
|
||||
@@ -378,6 +384,7 @@ function removeAccount(row: AccountRow) {
|
||||
</button>
|
||||
<!-- 超管只隐藏编辑/删除;重置密码仍可用。不展示「不可编辑」占位文案 -->
|
||||
<button
|
||||
v-if="canEditAccount()"
|
||||
class="link-action link-muted"
|
||||
type="button"
|
||||
@click="resetPassword(row)"
|
||||
@@ -385,7 +392,7 @@ function removeAccount(row: AccountRow) {
|
||||
重置密码
|
||||
</button>
|
||||
<button
|
||||
v-if="!row.isSuperAdmin"
|
||||
v-if="canEditAccount() && !row.isSuperAdmin"
|
||||
class="link-action link-danger"
|
||||
type="button"
|
||||
@click="removeAccount(row)"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 机器人回复语:对接主账 /inquiry/botReply。
|
||||
* 分类壳用本地元数据;条目内容全部来自库。
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
} from "/@/views/inquiry/botReply/replyCategories";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canAdd = () => hasPermission(InquiryBtnPerm.replyAdd);
|
||||
const canEdit = () => hasPermission(InquiryBtnPerm.replyEdit);
|
||||
const canDisable = () => hasPermission(InquiryBtnPerm.replyDisable);
|
||||
|
||||
const categories = ref<ReplyCategory[]>(emptyCategories());
|
||||
const loading = ref(false);
|
||||
@@ -152,7 +159,7 @@ async function save() {
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<button type="button" class="btn btn-primary" @click="openCreate()">
|
||||
<button v-if="canAdd()" type="button" class="btn btn-primary" @click="openCreate()">
|
||||
<ProtoIcon name="plus" :size="16" />添加话术
|
||||
</button>
|
||||
</Teleport>
|
||||
@@ -177,13 +184,21 @@ async function save() {
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
||||
<button
|
||||
v-if="canDisable()"
|
||||
type="button"
|
||||
class="switch"
|
||||
:class="{ 'is-on': item.enabled }"
|
||||
:aria-label="item.enabled ? '停用' : '启用'"
|
||||
@click="toggle(item)"
|
||||
/>
|
||||
<button type="button" class="link-action" @click="openEdit(cat.id, item)">编辑</button>
|
||||
<button
|
||||
v-if="canEdit()"
|
||||
type="button"
|
||||
class="link-action"
|
||||
@click="openEdit(cat.id, item)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -666,9 +666,11 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
||||
}
|
||||
|
||||
.dash-route-menu {
|
||||
/* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.dash-cost-footnote {
|
||||
|
||||
@@ -26,6 +26,11 @@ import {
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import { joinPermissionIds } from "/@/views/inquiry/role/rolePermission";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canEditRole = () => hasPermission(InquiryBtnPerm.roleEdit);
|
||||
|
||||
const rows = ref<RoleRow[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -250,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
||||
<button v-if="canEditRole()" type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="plus" :size="14" />新增角色
|
||||
</button>
|
||||
</Teleport>
|
||||
@@ -295,7 +300,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="row.editable">
|
||||
<template v-if="canEditRole() && row.editable">
|
||||
<div class="role-row-actions">
|
||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="link-action link-danger" type="button" @click="removeRole(row)">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 询价后台页面级权限码:侧栏显隐与「查看」按钮同源。
|
||||
* <p>
|
||||
* 为何:侧栏曾写死全量菜单,且 ensureInquiryHandwrittenRoutes 会把未授权页补进路由,
|
||||
* 导致无 reply:list 等权限的人仍能看到「机器人回复语」。以 Jeecg 权限码为准过滤。
|
||||
*/
|
||||
|
||||
/** 侧栏路径 → 进入该页至少需要的查看类权限码 */
|
||||
export const INQUIRY_PAGE_VIEW_PERMS: Record<string, string> = {
|
||||
'/inquiry/dashboard': 'inquiry:dashboard:view',
|
||||
'/inquiry/work-order': 'inquiry:workorder:list',
|
||||
'/inquiry/staff-role': 'inquiry:staff:list',
|
||||
'/inquiry/template': 'inquiry:template:view',
|
||||
'/inquiry/bot-reply': 'inquiry:reply:list',
|
||||
'/inquiry-sys/system-setting': 'inquiry:timeout:list',
|
||||
'/inquiry-sys/user': 'inquiry:account:view',
|
||||
'/inquiry-sys/role': 'inquiry:role:view',
|
||||
};
|
||||
|
||||
/** 按钮细粒度权限码(与 sys_permission / @RequiresPermissions 对齐) */
|
||||
export const InquiryBtnPerm = {
|
||||
workorderList: 'inquiry:workorder:list',
|
||||
workorderDetail: 'inquiry:workorder:detail',
|
||||
workorderTransition: 'inquiry:workorder:transition',
|
||||
workorderExport: 'inquiry:workorder:export',
|
||||
replyList: 'inquiry:reply:list',
|
||||
replyAdd: 'inquiry:reply:add',
|
||||
replyEdit: 'inquiry:reply:edit',
|
||||
replyDisable: 'inquiry:reply:disable',
|
||||
staffList: 'inquiry:staff:list',
|
||||
staffAdd: 'inquiry:staff:add',
|
||||
staffEdit: 'inquiry:staff:edit',
|
||||
staffDisable: 'inquiry:staff:disable',
|
||||
templateView: 'inquiry:template:view',
|
||||
templateUpload: 'inquiry:template:upload',
|
||||
templateEdit: 'inquiry:template:edit',
|
||||
templateToggle: 'inquiry:template:toggle',
|
||||
accountView: 'inquiry:account:view',
|
||||
accountEdit: 'inquiry:account:edit',
|
||||
roleView: 'inquiry:role:view',
|
||||
roleEdit: 'inquiry:role:edit',
|
||||
timeoutList: 'inquiry:timeout:list',
|
||||
timeoutEdit: 'inquiry:timeout:edit',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 当前权限码是否允许进入某侧栏路径。
|
||||
* @param path 侧栏 key,如 /inquiry/bot-reply
|
||||
* @param hasPerm hasPermission 回调
|
||||
*/
|
||||
export function canSeeInquiryPage(
|
||||
path: string,
|
||||
hasPerm: (code: string) => boolean,
|
||||
): boolean {
|
||||
const code = INQUIRY_PAGE_VIEW_PERMS[path];
|
||||
if (!code) {
|
||||
return true;
|
||||
}
|
||||
return hasPerm(code);
|
||||
}
|
||||
@@ -25,6 +25,13 @@ import {
|
||||
dataPermTitle,
|
||||
normalizeStaffRouteKeys,
|
||||
} from "/@/views/inquiry/shared/dataPermCatalog";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canAddStaff = () => hasPermission(InquiryBtnPerm.staffAdd);
|
||||
const canEditStaff = () => hasPermission(InquiryBtnPerm.staffEdit);
|
||||
const canDisableStaff = () => hasPermission(InquiryBtnPerm.staffDisable);
|
||||
|
||||
type StaffRole = "sales" | "air" | "sea" | "land";
|
||||
|
||||
@@ -485,10 +492,16 @@ function filterRouteOption(input: string, option: any) {
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div class="staff-topbar-actions">
|
||||
<button type="button" class="btn btn-secondary" :disabled="syncing" @click="syncWecom">
|
||||
<button
|
||||
v-if="canEditStaff()"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="syncing"
|
||||
@click="syncWecom"
|
||||
>
|
||||
<ProtoIcon name="refresh" :size="16" />{{ syncing ? "同步中…" : "同步企业微信" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="openCreate">
|
||||
<button v-if="canAddStaff()" type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="userPlus" :size="16" />新增员工
|
||||
</button>
|
||||
</div>
|
||||
@@ -586,9 +599,16 @@ function filterRouteOption(input: string, option: any) {
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button
|
||||
v-if="row.status === 'active'"
|
||||
v-if="canEditStaff()"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canDisableStaff() && row.status === 'active'"
|
||||
class="link-action link-danger"
|
||||
type="button"
|
||||
@click="removeStaff(row)"
|
||||
|
||||
@@ -181,6 +181,7 @@ body {
|
||||
padding: 0 14px !important;
|
||||
gap: 12px !important;
|
||||
min-width: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
/* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -1127,6 +1128,7 @@ h6 {
|
||||
/* 分析看板:内容区不裁切,滚动交给 .main-stage */
|
||||
overflow: visible !important;
|
||||
max-height: none !important;
|
||||
align-content: start !important;
|
||||
}
|
||||
|
||||
/* 看板缩窄时允许卡片/图表收缩,避免右侧被裁且无横滚 */
|
||||
@@ -1589,3 +1591,37 @@ textarea.field.field-tall {
|
||||
background: #f8fbff !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* 分析看板比一屏高。整页被 html / 主区锁死时,内容区即使设了 overflow,
|
||||
* 格子也会跟着内容长高,再被主区裁掉,滚轮没有可滚的对象。
|
||||
* 看板单独放开文档滚动;列表页仍锁在一屏里,表格自己滚。
|
||||
*/
|
||||
html:has(.ref-surface--metrics),
|
||||
body:has(.ref-surface--metrics) {
|
||||
height: auto !important;
|
||||
min-height: 100% !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
#app:has(.ref-surface--metrics),
|
||||
.admin-shell:has(.ref-surface--metrics) {
|
||||
height: auto !important;
|
||||
min-height: 100vh !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.main-stage:has(.ref-surface--metrics) {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
min-height: 100vh !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.admin-content:has(.ref-surface--metrics) {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
flex: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -776,9 +776,13 @@
|
||||
color: #155eef;
|
||||
}
|
||||
.ref-route-filter__menu {
|
||||
position: fixed;
|
||||
/* 必须用固定宽度。百分比会按「线路筛选」按钮的宽度算,面板只剩一字宽,中文被挤成竖排,全选也被裁掉。 */
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 120;
|
||||
width: min(360px, calc(100% - 24px));
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 48px);
|
||||
border: 1px solid #dfe5ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
@@ -797,12 +801,14 @@
|
||||
color: #0f172a;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-route-filter__menu-sub {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-route-filter__actions.tabs-pill {
|
||||
flex-shrink: 0;
|
||||
@@ -861,6 +867,7 @@
|
||||
}
|
||||
.ref-route-chip__text {
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.month-range-picker {
|
||||
position: relative;
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
getTimeoutListAll,
|
||||
saveTimeoutHours,
|
||||
} from '/@/views/inquiry/systemSetting/systemSetting.api';
|
||||
import { usePermission } from '/@/hooks/web/usePermission';
|
||||
import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
|
||||
|
||||
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
||||
const MODE_BLOCKS = [
|
||||
@@ -153,7 +158,7 @@ async function commitHours(row) {
|
||||
min="0"
|
||||
step="0.01"
|
||||
:value="draftHours[row.id]"
|
||||
:disabled="savingId === row.id"
|
||||
:disabled="!canEditTimeout() || savingId === row.id"
|
||||
@input="onHoursInput(row.id, $event.target.value)"
|
||||
@blur="commitHours(row)"
|
||||
@keydown.enter.prevent="commitHours(row)"
|
||||
|
||||
@@ -27,6 +27,13 @@ import {
|
||||
summarizeMappingParse,
|
||||
type MappingParseSummary,
|
||||
} from "/@/views/inquiry/template/previewSample";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canUploadTemplate = () => hasPermission(InquiryBtnPerm.templateUpload);
|
||||
const canEditTemplate = () => hasPermission(InquiryBtnPerm.templateEdit);
|
||||
const canToggleTemplate = () => hasPermission(InquiryBtnPerm.templateToggle);
|
||||
|
||||
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
|
||||
|
||||
@@ -683,7 +690,12 @@ async function downloadPreviewOrOriginal() {
|
||||
<template>
|
||||
<!-- 唯一上传入口:顶栏 -->
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<button type="button" class="btn btn-primary" @click.stop.prevent="openCreate">
|
||||
<button
|
||||
v-if="canUploadTemplate()"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click.stop.prevent="openCreate"
|
||||
>
|
||||
<ProtoIcon name="upload" :size="16" />上传模板
|
||||
</button>
|
||||
</Teleport>
|
||||
@@ -773,8 +785,16 @@ async function downloadPreviewOrOriginal() {
|
||||
<button type="button" class="link-action link-muted" @click.stop="openPreview(row)">
|
||||
预览
|
||||
</button>
|
||||
<button type="button" class="link-action" @click.stop="openEdit(row)">编辑</button>
|
||||
<button
|
||||
v-if="canEditTemplate()"
|
||||
type="button"
|
||||
class="link-action"
|
||||
@click.stop="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canEditTemplate()"
|
||||
type="button"
|
||||
class="link-action"
|
||||
:disabled="row.parseStatus === '解析中'"
|
||||
@@ -788,6 +808,7 @@ async function downloadPreviewOrOriginal() {
|
||||
重新解析
|
||||
</button>
|
||||
<button
|
||||
v-if="canToggleTemplate()"
|
||||
type="button"
|
||||
class="link-action"
|
||||
:class="row.status === 'enabled' ? 'link-danger' : ''"
|
||||
@@ -795,7 +816,12 @@ async function downloadPreviewOrOriginal() {
|
||||
>
|
||||
{{ row.status === "enabled" ? "禁用" : "启用" }}
|
||||
</button>
|
||||
<button type="button" class="link-action link-danger" @click.stop="removeTemplate(row)">
|
||||
<button
|
||||
v-if="canEditTemplate()"
|
||||
type="button"
|
||||
class="link-action link-danger"
|
||||
@click.stop="removeTemplate(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,23 @@ import {
|
||||
quantitySummary,
|
||||
type DemandField,
|
||||
} from "/@/views/inquiry/workOrder/demandFields";
|
||||
import { usePermission } from "/@/hooks/web/usePermission";
|
||||
import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canViewDetail = () =>
|
||||
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
|
||||
|
||||
/**
|
||||
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
|
||||
* 已成交/未成交/已关闭为终态,禁止再显示「关闭工单」(否则点确认会报不允许流转)。
|
||||
*/
|
||||
const CLOSEABLE_STATUSES = new Set(["询价中", "已报价", "协商中", "转人工"]);
|
||||
function canShowCloseForStatus(status: string) {
|
||||
return canCloseTicket() && CLOSEABLE_STATUSES.has(String(status || "").trim());
|
||||
}
|
||||
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
@@ -615,6 +632,10 @@ async function openDetail(row: TicketRow) {
|
||||
}
|
||||
|
||||
function openClose(row: TicketRow) {
|
||||
if (!canShowCloseForStatus(row.status)) {
|
||||
message.warning(`「${row.status}」为终态,不能关闭工单`);
|
||||
return;
|
||||
}
|
||||
current.value = row;
|
||||
closeReason.value = "";
|
||||
closeOpen.value = true;
|
||||
@@ -903,7 +924,7 @@ function refresh() {
|
||||
<td class="align-top col-actions col-sticky-right">
|
||||
<div class="ticket-row-actions">
|
||||
<button
|
||||
v-if="row.systemException !== '-' && row.status !== '已关闭'"
|
||||
v-if="canRetryTicket() && row.systemException !== '-' && row.status !== '已关闭'"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="retryTicket(row)"
|
||||
@@ -911,14 +932,21 @@ function refresh() {
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status !== '已关闭'"
|
||||
v-if="canShowCloseForStatus(row.status)"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="openClose(row)"
|
||||
>
|
||||
关闭工单
|
||||
</button>
|
||||
<button class="link-action" type="button" @click="openDetail(row)">查看详情</button>
|
||||
<button
|
||||
v-if="canViewDetail()"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="openDetail(row)"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -946,7 +974,12 @@ function refresh() {
|
||||
<template #titleExtra>
|
||||
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
||||
<button
|
||||
v-if="current && current.systemException !== '-' && current.status !== '已关闭'"
|
||||
v-if="
|
||||
canRetryTicket() &&
|
||||
current &&
|
||||
current.systemException !== '-' &&
|
||||
current.status !== '已关闭'
|
||||
"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="retryTicket(current)"
|
||||
|
||||
Reference in New Issue
Block a user