Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8918a5c9 | ||
|
|
2aab38da99 | ||
|
|
5e2a3e29aa | ||
|
|
baa4d81be9 |
@@ -4,7 +4,8 @@
|
|||||||
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
|
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
|
||||||
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
|
手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
|
||||||
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
|
再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
|
||||||
电脑企微即使注入了 WeixinJSBridge,这一下也关不掉当前页,必须按电脑走 JS-SDK。
|
电脑企微 5.x 内置页经常加载不了 JS-SDK,也不会打签名接口。
|
||||||
|
这时 ww.closeWindow 不会执行。电脑改用 WeixinJSBridge.invoke,再补 window.close,只关当前页。
|
||||||
失败、过期页不要挂这段。
|
失败、过期页不要挂这段。
|
||||||
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
|
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
|
||||||
"""
|
"""
|
||||||
@@ -24,8 +25,9 @@ def wecom_auto_close_script() -> str:
|
|||||||
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
|
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
|
||||||
|
|
||||||
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
|
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
|
||||||
为何这样写:电脑企微也会带上 WeixinJSBridge,但关不掉当前页。
|
为何这样写:21:27 的上传日志里,成功页已经返回,但没有 jsapi 签名请求。
|
||||||
若一发现桥就只走手机关闭,成功页会停在「正在返回企微」。手机仍只关一次。
|
电脑企微没跑起 ww.register,只调 ww.closeWindow 等于没关。
|
||||||
|
电脑改为 invoke + window.close,不依赖签名先成功。手机仍只关一次。
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
|
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
|
||||||
@@ -51,7 +53,11 @@ def wecom_auto_close_script() -> str:
|
|||||||
var closed = false;
|
var closed = false;
|
||||||
|
|
||||||
function isPhone() {{
|
function isPhone() {{
|
||||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
|
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) {{
|
function closeOnce(fn) {{
|
||||||
@@ -65,13 +71,22 @@ def wecom_auto_close_script() -> str:
|
|||||||
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// 电脑企微即使有 WeixinJSBridge,也关不掉当前页。注册 JS-SDK 后再关一次。
|
// 电脑企微:call 关不掉,JS-SDK 又经常没装上。invoke 关内置页,window.close 关独立窗口。
|
||||||
// 不要调 window.close:手机会把询价小助手整个退出。
|
|
||||||
function closeDesktop() {{
|
function closeDesktop() {{
|
||||||
closeOnce(function () {{
|
try {{
|
||||||
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
|
if (typeof WeixinJSBridge !== "undefined" && typeof WeixinJSBridge.invoke === "function") {{
|
||||||
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
|
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() {{
|
function boot() {{
|
||||||
@@ -85,21 +100,15 @@ def wecom_auto_close_script() -> str:
|
|||||||
}}, false);
|
}}, false);
|
||||||
return;
|
return;
|
||||||
}}
|
}}
|
||||||
var ready = Promise.resolve();
|
setTimeout(closeDesktop, {delay});
|
||||||
if (window.ww && ww.register && corpId) {{
|
if (!(window.ww && ww.register && corpId)) return;
|
||||||
ready = ww.register({{
|
ww.register({{
|
||||||
corpId: corpId,
|
corpId: corpId,
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
jsApiList: ["closeWindow"],
|
jsApiList: ["closeWindow"],
|
||||||
getConfigSignature: sign("corp"),
|
getConfigSignature: sign("corp"),
|
||||||
getAgentConfigSignature: sign("agent")
|
getAgentConfigSignature: sign("agent")
|
||||||
}}).catch(function () {{ return null; }});
|
}}).then(function () {{
|
||||||
}}
|
|
||||||
var timed = Promise.race([
|
|
||||||
ready,
|
|
||||||
new Promise(function (resolve) {{ setTimeout(resolve, 2500); }})
|
|
||||||
]);
|
|
||||||
timed.then(function () {{
|
|
||||||
setTimeout(closeDesktop, {delay});
|
setTimeout(closeDesktop, {delay});
|
||||||
}}).catch(function () {{
|
}}).catch(function () {{
|
||||||
setTimeout(closeDesktop, {delay});
|
setTimeout(closeDesktop, {delay});
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from agent.policy.event_exception import (
|
|||||||
card_title,
|
card_title,
|
||||||
conversation_of,
|
conversation_of,
|
||||||
exception_type,
|
exception_type,
|
||||||
|
WAIT_ADOPT,
|
||||||
|
WAIT_AIR_QUOTE,
|
||||||
|
WAIT_DEAL,
|
||||||
|
WAIT_LOST_REASON,
|
||||||
|
mentions_for_role,
|
||||||
pick_app_targets,
|
pick_app_targets,
|
||||||
pick_mentions,
|
pick_mentions,
|
||||||
timeout_code,
|
timeout_code,
|
||||||
@@ -25,6 +30,9 @@ from agent.policy.event_exception import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 采用、成交跟进、未成交原因都是在等销售。点名只找本单销售。
|
||||||
|
_SALES_WAIT_KINDS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
|
||||||
|
|
||||||
SendGroupFn = Callable[..., bool]
|
SendGroupFn = Callable[..., bool]
|
||||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||||
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
||||||
@@ -149,19 +157,17 @@ def _handle_one(
|
|||||||
if workday_elapsed_hours(started, now) < hours:
|
if workday_elapsed_hours(started, now) < hours:
|
||||||
return False
|
return False
|
||||||
watchers = [dict(x) for x in (row.get("watchers") or []) if isinstance(x, dict)]
|
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)
|
sales = _ticket_sales(row, watchers)
|
||||||
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,
|
|
||||||
}
|
|
||||||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
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()]
|
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()
|
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)
|
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(
|
text = copy.event_exception_session_text(
|
||||||
exception_type=exception_type(kind),
|
exception_type=exception_type(kind),
|
||||||
work_order_no=no,
|
work_order_no=no,
|
||||||
@@ -208,6 +214,59 @@ def _handle_one(
|
|||||||
return True
|
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(
|
def _send_app_cards(
|
||||||
*,
|
*,
|
||||||
people: list[dict[str, Any]],
|
people: list[dict[str, Any]],
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ _HOLIDAYS = frozenset(
|
|||||||
date(2026, 9, 25),
|
date(2026, 9, 25),
|
||||||
date(2026, 9, 26),
|
date(2026, 9, 26),
|
||||||
date(2026, 9, 27),
|
date(2026, 9, 27),
|
||||||
|
# 国庆只休到 10 月 7 日。10 月 8 日周四正常上班,不能算假期。
|
||||||
date(2026, 10, 1),
|
date(2026, 10, 1),
|
||||||
date(2026, 10, 2),
|
date(2026, 10, 2),
|
||||||
date(2026, 10, 3),
|
date(2026, 10, 3),
|
||||||
@@ -53,7 +54,6 @@ _HOLIDAYS = frozenset(
|
|||||||
date(2026, 10, 5),
|
date(2026, 10, 5),
|
||||||
date(2026, 10, 6),
|
date(2026, 10, 6),
|
||||||
date(2026, 10, 7),
|
date(2026, 10, 7),
|
||||||
date(2026, 10, 8),
|
|
||||||
# 2027 元旦预留
|
# 2027 元旦预留
|
||||||
date(2027, 1, 1),
|
date(2027, 1, 1),
|
||||||
date(2027, 1, 2),
|
date(2027, 1, 2),
|
||||||
@@ -68,6 +68,8 @@ _MAKEUP_WORKDAYS = frozenset(
|
|||||||
date(2026, 2, 14),
|
date(2026, 2, 14),
|
||||||
date(2026, 2, 28),
|
date(2026, 2, 28),
|
||||||
date(2026, 5, 9),
|
date(2026, 5, 9),
|
||||||
|
# 国庆调休:9 月 20 日周日、10 月 10 日周六上班。
|
||||||
|
date(2026, 9, 20),
|
||||||
date(2026, 10, 10),
|
date(2026, 10, 10),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ def pick_mentions(
|
|||||||
"""
|
"""
|
||||||
本单这次应回而未回、需要被点名的人。
|
本单这次应回而未回、需要被点名的人。
|
||||||
|
|
||||||
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。
|
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售,不点航线。
|
||||||
不要询价机器人、不要群里其他人。
|
不要询价机器人、不要群里其他人。
|
||||||
"""
|
"""
|
||||||
kind = (wait_kind or "").strip()
|
kind = (wait_kind or "").strip()
|
||||||
@@ -206,6 +206,48 @@ def pick_mentions(
|
|||||||
return out
|
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]]:
|
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||||
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
本文件职责:核对卡通过后、建单查价前,确认当前运输类型的货物字段能拼出查价正文。
|
||||||
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
带去:运输类型、线路类别、始发/目的/品名/报价日期,以及该类型的体积重量或车型/数量;通关口岸有值才带。
|
||||||
出站类型/线路必须译成 TMS 6.1.1 英文枚举,线路键名 routeCategory。工单与卡片仍用中文。
|
出站类型/线路译成 TMS 用的值,线路键名 routeCategory。工单与卡片仍用销售中文。
|
||||||
中港整车、中港零担/集拼出站类型都是 CHINA_HK。中港整车不带车型/数量。
|
中港整车、中港零担/集拼出站类型都只写「中港」。中港整车不带车型/数量。
|
||||||
禁止:把运输分类放进出站;直连 TMS;改六态。
|
禁止:把运输分类放进出站;直连 TMS;改六态。
|
||||||
线程:纯函数。
|
线程:纯函数。
|
||||||
"""
|
"""
|
||||||
@@ -29,8 +29,8 @@ _TYPE_ENUM = {
|
|||||||
"国内运输整车": "DOMESTIC_FTL",
|
"国内运输整车": "DOMESTIC_FTL",
|
||||||
"跨境集拼": "CROSS_BORDER_LTL",
|
"跨境集拼": "CROSS_BORDER_LTL",
|
||||||
"跨境整车": "CROSS_BORDER_FTL",
|
"跨境整车": "CROSS_BORDER_FTL",
|
||||||
"中港整车": "CHINA_HK",
|
"中港整车": "中港",
|
||||||
"中港零担/集拼": "CHINA_HK",
|
"中港零担/集拼": "中港",
|
||||||
}
|
}
|
||||||
_ROUTE_ENUM = {
|
_ROUTE_ENUM = {
|
||||||
"国内长途/零担": "DOMESTIC_LONG_LTL",
|
"国内长途/零担": "DOMESTIC_LONG_LTL",
|
||||||
@@ -45,7 +45,7 @@ _TAIL_QTY = re.compile(r"^(.*?)[\s,,]+(\d+)\s*(台|辆)?$")
|
|||||||
|
|
||||||
|
|
||||||
def tms_land_type_code(raw: str) -> str:
|
def tms_land_type_code(raw: str) -> str:
|
||||||
"""销售中文运输类型 → TMS 6.1.1 landTransportType。对不上空串。"""
|
"""销售中文运输类型 → 查价 landTransportType。中港两类都是「中港」。对不上空串。"""
|
||||||
text = (raw or "").strip()
|
text = (raw or "").strip()
|
||||||
if text in _TYPE_ENUM:
|
if text in _TYPE_ENUM:
|
||||||
return _TYPE_ENUM[text]
|
return _TYPE_ENUM[text]
|
||||||
|
|||||||
@@ -407,7 +407,10 @@ class AirRouteOptionsTests(unittest.TestCase):
|
|||||||
self.assertIn("closeWindow", ok)
|
self.assertIn("closeWindow", ok)
|
||||||
self.assertIn("wecom-jssdk", ok)
|
self.assertIn("wecom-jssdk", ok)
|
||||||
self.assertIn("jsapi-signature", 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)
|
self.assertNotIn("closeWindow", dead)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ class AttachmentUploadH5Tests(unittest.TestCase):
|
|||||||
self.assertIn("wecom-jssdk", ok)
|
self.assertIn("wecom-jssdk", ok)
|
||||||
self.assertIn("jsapi-signature", ok)
|
self.assertIn("jsapi-signature", ok)
|
||||||
self.assertIn("function isPhone()", ok)
|
self.assertIn("function isPhone()", ok)
|
||||||
self.assertNotIn("window.close()", 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", page)
|
||||||
self.assertNotIn("closeWindow", dead)
|
self.assertNotIn("closeWindow", dead)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from agent.policy.deal_followup import (
|
from agent.policy.deal_followup import (
|
||||||
SHANGHAI,
|
SHANGHAI,
|
||||||
air_route_keys,
|
air_route_keys,
|
||||||
|
is_workday,
|
||||||
leader_matches,
|
leader_matches,
|
||||||
next_followup_at,
|
next_followup_at,
|
||||||
shift_workdays,
|
shift_workdays,
|
||||||
@@ -20,33 +21,42 @@ from agent.policy.deal_followup import (
|
|||||||
|
|
||||||
class TestWorkdays(unittest.TestCase):
|
class TestWorkdays(unittest.TestCase):
|
||||||
def test_skip_weekend(self) -> None:
|
def test_skip_weekend(self) -> None:
|
||||||
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
|
# 普通周末仍跳过。2026-11-13 周五 → 16、17、18 三个工作日,第四个是 19 日。
|
||||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
|
self.assertEqual(shift_workdays(date(2026, 11, 13), 3), date(2026, 11, 18))
|
||||||
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
|
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:
|
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)
|
fourth = shift_workdays(date(2026, 9, 30), 4)
|
||||||
self.assertGreaterEqual(fourth, date(2026, 10, 9))
|
self.assertEqual(fourth, date(2026, 10, 12))
|
||||||
self.assertNotEqual(fourth.weekday(), 5)
|
|
||||||
self.assertNotEqual(fourth.weekday(), 6)
|
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:
|
def test_next_followup_is_9am(self) -> None:
|
||||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||||
self.assertEqual(when.hour, 9)
|
self.assertEqual(when.hour, 9)
|
||||||
self.assertEqual(when.minute, 0)
|
self.assertEqual(when.minute, 0)
|
||||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
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:
|
def test_skip_weekend_keep_weekday_night(self) -> None:
|
||||||
start = datetime(2026, 9, 18, 17, 0, tzinfo=SHANGHAI) # 周五
|
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
|
||||||
now = datetime(2026, 9, 21, 17, 0, tzinfo=SHANGHAI) # 周一
|
now = datetime(2026, 11, 16, 17, 0, tzinfo=SHANGHAI) # 周一
|
||||||
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
|
# 周五 17–24 = 7h,周末不算,周一 0–17 = 17h
|
||||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
|
self.assertAlmostEqual(workday_elapsed_hours(start, now), 24.0, places=4)
|
||||||
|
|
||||||
def test_rest_day_start_counts_from_next_workday(self) -> None:
|
def test_rest_day_start_counts_from_next_workday(self) -> None:
|
||||||
start = datetime(2026, 9, 19, 10, 0, tzinfo=SHANGHAI) # 周六
|
start = datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI) # 周六
|
||||||
now = datetime(2026, 9, 21, 10, 0, tzinfo=SHANGHAI) # 周一
|
now = datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI) # 周一
|
||||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
|
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
|
||||||
|
|
||||||
def test_fractional_quarter_hour(self) -> None:
|
def test_fractional_quarter_hour(self) -> None:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from agent.policy.event_exception import (
|
|||||||
WAIT_PRODUCT_QUOTE,
|
WAIT_PRODUCT_QUOTE,
|
||||||
conversation_of,
|
conversation_of,
|
||||||
format_timeout_hours,
|
format_timeout_hours,
|
||||||
|
mentions_for_role,
|
||||||
pick_app_targets,
|
pick_app_targets,
|
||||||
pick_mentions,
|
pick_mentions,
|
||||||
timeout_code,
|
timeout_code,
|
||||||
@@ -270,6 +271,163 @@ class TestPoll(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(n2, 0)
|
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"]
|
||||||
|
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 = (datetime.now(SHANGHAI) - 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=datetime.now(SHANGHAI),
|
||||||
|
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"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
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 = (datetime.now(SHANGHAI) - 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=datetime.now(SHANGHAI),
|
||||||
|
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=datetime.now(SHANGHAI),
|
||||||
|
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], ["吴季林"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class TmsLandQueryTest(unittest.TestCase):
|
|||||||
got = assemble_land_query(facts)
|
got = assemble_land_query(facts)
|
||||||
self.assertTrue(got["ok"])
|
self.assertTrue(got["ok"])
|
||||||
out = got["outbound"]
|
out = got["outbound"]
|
||||||
self.assertEqual(out["landTransportType"], "CHINA_HK")
|
self.assertEqual(out["landTransportType"], "中港")
|
||||||
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
|
self.assertEqual(out["routeCategory"], "SOUTHEAST_ASIA")
|
||||||
self.assertNotIn("landRouteCategory", out)
|
self.assertNotIn("landRouteCategory", out)
|
||||||
self.assertEqual(out["vehicleType"], "9米6")
|
self.assertEqual(out["vehicleType"], "9米6")
|
||||||
@@ -52,6 +52,7 @@ class TmsLandQueryTest(unittest.TestCase):
|
|||||||
got = assemble_land_query(facts)
|
got = assemble_land_query(facts)
|
||||||
self.assertTrue(got["ok"])
|
self.assertTrue(got["ok"])
|
||||||
out = got["outbound"]
|
out = got["outbound"]
|
||||||
|
self.assertEqual(out["landTransportType"], "中港")
|
||||||
self.assertNotIn("vehicleType", out)
|
self.assertNotIn("vehicleType", out)
|
||||||
self.assertNotIn("quantity", out)
|
self.assertNotIn("quantity", out)
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -4,6 +4,7 @@ package org.jeecg.modules.inquiry.tms;
|
|||||||
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
||||||
* <p>
|
* <p>
|
||||||
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
||||||
|
* 中港整车、中港零担/集拼查价只传「中港」,工单仍留细类。
|
||||||
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
||||||
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
||||||
*/
|
*/
|
||||||
@@ -14,10 +15,15 @@ public final class TmsLandEnumMapper {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param raw 销售中文(或历史英文码)
|
* @param raw 销售中文(或历史英文码)
|
||||||
* @return trim 后原文;空则 ""
|
* @return 中港两类收成「中港」;其余 trim 后原文;空则 ""
|
||||||
*/
|
*/
|
||||||
public static String transportType(String raw) {
|
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 段;运输分类只留在工单,不进出站。
|
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
|
||||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
||||||
* 中港整车不带车型/数量。通关口岸有值才放。
|
* 中港整车不带车型/数量。通关口岸有值才放。
|
||||||
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
|
* 中港整车、中港零担/集拼:出站 landTransportType 只写「中港」,工单事实仍留细类。
|
||||||
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
|
* 类型/线路键名仍是 landTransportType / routeCategory。除中港两类外,值用中文原文。
|
||||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
||||||
*/
|
*/
|
||||||
public final class TmsLandSegmentMapper {
|
public final class TmsLandSegmentMapper {
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ public final class TmsV2RequestMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
|
* 陆运出站:类型+线路用销售中文;中港整车、中港零担/集拼收成「中港」。线路字段名是 routeCategory。
|
||||||
* 分类和中港装载即使误放在 source 也不抄走。
|
* 分类和中港装载即使误放在 source 也不抄走。
|
||||||
*/
|
*/
|
||||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||||
|
|||||||
+7
@@ -27,6 +27,13 @@ public class TmsLandEnumMapperTest {
|
|||||||
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
|
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void hkTypesCollapseToZhonggang() {
|
||||||
|
assertEquals("中港", TmsLandEnumMapper.transportType("中港整车"));
|
||||||
|
assertEquals("中港", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||||
|
assertEquals("中港集拼", TmsLandEnumMapper.transportType("中港集拼"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void legacyEnglishCodesPassThrough() {
|
public void legacyEnglishCodesPassThrough() {
|
||||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
|
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
|
||||||
|
|||||||
+2
-2
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
|
|||||||
facts.put("车型/数量", "9米6/1");
|
facts.put("车型/数量", "9米6/1");
|
||||||
facts.put("体积", "99");
|
facts.put("体积", "99");
|
||||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||||
assertEquals("中港零担/集拼", seg.get("landTransportType"));
|
assertEquals("中港", seg.get("landTransportType"));
|
||||||
assertEquals("9米6", seg.get("vehicleType"));
|
assertEquals("9米6", seg.get("vehicleType"));
|
||||||
assertNull(seg.get("volumeCbm"));
|
assertNull(seg.get("volumeCbm"));
|
||||||
assertFalse(seg.containsKey("hkLoadType"));
|
assertFalse(seg.containsKey("hkLoadType"));
|
||||||
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
|
|||||||
facts.put("目的港", "香港");
|
facts.put("目的港", "香港");
|
||||||
facts.put("品名", "衣服");
|
facts.put("品名", "衣服");
|
||||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||||
assertEquals("中港整车", seg.get("landTransportType"));
|
assertEquals("中港", seg.get("landTransportType"));
|
||||||
assertNull(seg.get("vehicleType"));
|
assertNull(seg.get("vehicleType"));
|
||||||
assertNull(seg.get("quantity"));
|
assertNull(seg.get("quantity"));
|
||||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||||
|
|||||||
@@ -666,9 +666,11 @@ onUnmounted(() => document.removeEventListener("click", onDocClick));
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dash-route-menu {
|
.dash-route-menu {
|
||||||
|
/* 盖过图表区;宽度在 dashboard-prototype.css 里写成 360px,避免按按钮宽度收缩 */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
|
z-index: 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-cost-footnote {
|
.dash-cost-footnote {
|
||||||
|
|||||||
@@ -43,10 +43,16 @@ body {
|
|||||||
padding: 16px 16px 40px !important;
|
padding: 16px 16px 40px !important;
|
||||||
gap: 12px !important;
|
gap: 12px !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
overflow-x: hidden;
|
/*
|
||||||
flex: 1 1 auto !important;
|
* 主区被锁成一屏。flex-basis 用 auto 时,格子会按内容把内容区撑高,
|
||||||
|
* 再被主区裁掉,页面上就没有滚动条,鼠标也滚不动。
|
||||||
|
* basis 取 0、高度先归零,再由 flex 撑满顶栏下方,多出来的内容在这里滚。
|
||||||
|
*/
|
||||||
|
flex: 1 1 0% !important;
|
||||||
|
height: 0 !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
overflow: auto !important;
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -127,6 +133,7 @@ body {
|
|||||||
padding: 0 14px !important;
|
padding: 0 14px !important;
|
||||||
gap: 12px !important;
|
gap: 12px !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
/* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */
|
/* 允许用户下拉露出;业务 Teleport 区单独 overflow:hidden 防幽灵点击层 */
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
@@ -1070,6 +1077,7 @@ h6 {
|
|||||||
.admin-content:has(.ref-surface--metrics) {
|
.admin-content:has(.ref-surface--metrics) {
|
||||||
padding: 14px 14px 36px !important;
|
padding: 14px 14px 36px !important;
|
||||||
gap: 10px !important;
|
gap: 10px !important;
|
||||||
|
align-content: start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 话术 ---- */
|
/* ---- 话术 ---- */
|
||||||
@@ -1523,3 +1531,37 @@ textarea.field.field-tall {
|
|||||||
background: #f8fbff !important;
|
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;
|
color: #155eef;
|
||||||
}
|
}
|
||||||
.ref-route-filter__menu {
|
.ref-route-filter__menu {
|
||||||
position: fixed;
|
/* 必须用固定宽度。百分比会按「线路筛选」按钮的宽度算,面板只剩一字宽,中文被挤成竖排,全选也被裁掉。 */
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 8px);
|
||||||
z-index: 120;
|
z-index: 120;
|
||||||
width: min(360px, calc(100% - 24px));
|
width: 360px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
border: 1px solid #dfe5ee;
|
border: 1px solid #dfe5ee;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -797,12 +801,14 @@
|
|||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ref-route-filter__menu-sub {
|
.ref-route-filter__menu-sub {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ref-route-filter__actions.tabs-pill {
|
.ref-route-filter__actions.tabs-pill {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -861,6 +867,7 @@
|
|||||||
}
|
}
|
||||||
.ref-route-chip__text {
|
.ref-route-chip__text {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.month-range-picker {
|
.month-range-picker {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user