Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afe909230 | ||
|
|
a878b046d2 | ||
|
|
fc1b85113d | ||
|
|
1bab19f1a2 | ||
|
|
9e27bada94 | ||
|
|
1c742353b8 | ||
|
|
c24d8c19c0 | ||
|
|
2527901efd | ||
|
|
ef0424a28f | ||
|
|
4381966c4e | ||
|
|
a5e73c01ae | ||
|
|
20c27ae293 | ||
|
|
6140eaf932 | ||
|
|
19acbb9156 | ||
|
|
b3b3cf2903 | ||
|
|
88a66d40bb | ||
|
|
698ac43878 | ||
|
|
79caddef0e | ||
|
|
91bde83e9b | ||
|
|
3a6884e27d | ||
|
|
27aeebb593 | ||
|
|
54b818915e |
@@ -158,6 +158,14 @@ 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
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
return _handle_bound(
|
||||
engine,
|
||||
message,
|
||||
|
||||
@@ -100,6 +100,15 @@ def handle_group_inbound(
|
||||
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
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
|
||||
sess = _session_of_ticket(engine, ticket)
|
||||
if is_handoff_ticket(ticket, sess):
|
||||
logger.info(
|
||||
|
||||
@@ -36,6 +36,44 @@ def _front_bookmark(sess) -> bool:
|
||||
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
|
||||
|
||||
|
||||
def _drop_other_mode_drafts(*, owner: str, sender_id: str) -> None:
|
||||
"""
|
||||
本句已点名空运/海运/陆运:丢掉其它方式未出号的补问/选项草稿。
|
||||
|
||||
测服复现:旧陆运「选项未齐」书签还在,海运补「贸易条款 / 运输分类」会被陆运抢走,
|
||||
回「参数提取不全」并发送填写样例图。已出号会话留在工单号索引,不当当前草稿。
|
||||
"""
|
||||
kind = (owner or "").upper()
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid or kind not in {"SEA", "AIR", "LAND"}:
|
||||
return
|
||||
land = get_land_text_flow()
|
||||
air = get_air_text_flow()
|
||||
sea = get_sea_text_flow()
|
||||
others = []
|
||||
if kind != "LAND":
|
||||
others.append(land)
|
||||
if kind != "AIR":
|
||||
others.append(air)
|
||||
if kind != "SEA":
|
||||
others.append(sea)
|
||||
for flow in others:
|
||||
sess = flow.session_of(sid)
|
||||
phase = (getattr(sess, "phase", None) or "").strip() if sess else ""
|
||||
if _front_bookmark(sess) or phase == "need_mode":
|
||||
flow.drop_current_bookmark(sid)
|
||||
logger.info(
|
||||
"text_inquiry 丢掉其它方式草稿 owner=%s dropped_phase=%s sender=%s",
|
||||
kind,
|
||||
phase or "-",
|
||||
sid,
|
||||
)
|
||||
if kind != "LAND":
|
||||
clearer = getattr(land, "_clear_parked_confirm", None)
|
||||
if callable(clearer):
|
||||
clearer(sid)
|
||||
|
||||
|
||||
def should_resume_other_as_inquiry(message: InboundMessage) -> bool:
|
||||
"""
|
||||
A 把补问短答标成闲聊时,仍续填当前询价。
|
||||
@@ -63,7 +101,9 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
"""
|
||||
按当前书签或本句运输方式选空运/海运/陆运流程。
|
||||
|
||||
陆运核对卡上的确定优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
陆运核对卡上的「确定」优先于旧海运书签,避免「确定」被海运/空运吃掉。
|
||||
本句点名海运/空运/陆运时盖掉其它方式的旧补问草稿。
|
||||
核对卡优先于选项补问;海运/空运补问优先于旧陆运选项草稿,避免补贸易条款发陆运样例图。
|
||||
正在补问时,补问书签优先于已报价调价,避免第 3 次反问出不了转人工卡。
|
||||
A 已判成调价且当前不是补问:交给当前已报价工单,避免旧海运书签抢走。
|
||||
图片识图在 Worker 写下的补问书签,HTTP 必须从 Redis 读到,否则「托盘」会变成再问运输方式。
|
||||
@@ -82,11 +122,24 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
return land
|
||||
sea = get_sea_text_flow()
|
||||
air = get_air_text_flow()
|
||||
spoken = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
# 本句点名运输方式:盖掉其它方式的旧补问,避免海运补字段走陆运样例图。
|
||||
if spoken in {"SEA", "AIR", "LAND"}:
|
||||
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
|
||||
if spoken == "SEA":
|
||||
return sea
|
||||
if spoken == "LAND":
|
||||
return land
|
||||
return air
|
||||
sea_sess = sea.session_of(sender_id)
|
||||
land_sess = land.session_of(sender_id)
|
||||
air_sess = air.session_of(sender_id)
|
||||
# 补问/核对手稿优先于「已报价调价」,避免第 3 次反问被调价路由冲掉、出不了转人工卡。
|
||||
# 核对卡仍优先:销售正在改体积/点确定,不能被另一方式的补问草稿抢走。
|
||||
for sess, flow in ((land_sess, land), (air_sess, air), (sea_sess, sea)):
|
||||
if sess is not None and (sess.phase or "").strip() == "wait_confirm" and not is_handoff_frozen(sess):
|
||||
return flow
|
||||
# 补问书签:海运/空运优先于旧陆运选项草稿。陆运「确定」已在上面拦过。
|
||||
for sess, flow in ((sea_sess, sea), (air_sess, air), (land_sess, land)):
|
||||
if _front_bookmark(sess):
|
||||
return flow
|
||||
if prefer_quoted:
|
||||
@@ -97,11 +150,6 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
return sea
|
||||
if land_sess and not is_handoff_frozen(land_sess) and (land_sess.business_line or "").upper() == "LAND":
|
||||
return land
|
||||
mode = (injected_mode or detect_transport_mode(text) or "").upper()
|
||||
if mode == "SEA":
|
||||
return sea
|
||||
if mode == "LAND":
|
||||
return land
|
||||
return air
|
||||
|
||||
|
||||
@@ -208,6 +256,28 @@ def handle_text_inquiry(
|
||||
logger.info("text_inquiry 出站 ok=%s info=%s phase_reply=%s", ok, info, body[:80])
|
||||
return info if ok else ""
|
||||
|
||||
from agent.policy.system_exception import (
|
||||
extract_work_order_nos,
|
||||
reply_paused_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
|
||||
ledger = getattr(engine, "_ledger", None)
|
||||
for no in extract_work_order_nos(message.content or ""):
|
||||
ticket = None
|
||||
if ledger is not None:
|
||||
getter = getattr(ledger, "get_ticket", None)
|
||||
if callable(getter):
|
||||
ticket = getter(work_order_no=no)
|
||||
if ticket is None:
|
||||
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):
|
||||
reply_paused_ticket(ticket=ticket, reply=reply)
|
||||
logger.info("text_inquiry 暂停工单只回兜底 wo=%s sender=%s", no, message.sender_id)
|
||||
return "system_exception_paused"
|
||||
|
||||
if prefer_handoff:
|
||||
phase = engine.offer_handoff(
|
||||
sender_id=message.sender_id,
|
||||
|
||||
@@ -112,6 +112,7 @@ class WorkerRuntime:
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
|
||||
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
|
||||
threading.Thread(target=self._event_exception_loop, name="event-exception", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
@@ -290,6 +291,22 @@ class WorkerRuntime:
|
||||
logger.exception("协商再问轮询失败")
|
||||
self._stop.wait(60.0)
|
||||
|
||||
def _event_exception_loop(self) -> None:
|
||||
"""
|
||||
HTTP 槽巡检事件异常。独立线程,不占回调、不与 soffice 串队。
|
||||
"""
|
||||
from agent.jobs.event_exception_poll import poll_event_exceptions
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.http_jobs.run(
|
||||
lambda: poll_event_exceptions(settings=self.settings),
|
||||
label="event-exception",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("事件异常巡检失败")
|
||||
self._stop.wait(60.0)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
|
||||
@@ -39,14 +39,14 @@ def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = Non
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
if _reask_one(row, settings=cfg):
|
||||
if _reask_one(row, settings=cfg, ledger=book):
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
|
||||
return sent
|
||||
|
||||
|
||||
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
|
||||
def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -> bool:
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if not no:
|
||||
return False
|
||||
@@ -68,7 +68,9 @@ def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
|
||||
return ok
|
||||
return False
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
if not sales:
|
||||
logger.warning("协商再问无销售 userid wo=%s", no)
|
||||
return False
|
||||
@@ -84,7 +86,16 @@ def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
|
||||
return bool(ok)
|
||||
return False
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
|
||||
|
||||
def _start_deal_wait(ledger: Any, work_order_no: str) -> None:
|
||||
"""再问发出后重新盯成交跟进。ledger 可空。"""
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
start_wait(ledger, work_order_no=work_order_no, wait_kind=WAIT_DEAL)
|
||||
|
||||
|
||||
def _send_group_reask(
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
事件异常巡检:Worker HTTP 槽轮询到点工单并告警。
|
||||
|
||||
本文件职责:读主账等待、按工作日对照超时小时、会话喊人、可选应用卡片、再记摘要。
|
||||
禁止:回调线程调用;占 LibreOffice 槽;改六态;反复催已告警的等待。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import SHANGHAI, workday_elapsed_hours
|
||||
from agent.policy.event_exception import (
|
||||
card_title,
|
||||
conversation_of,
|
||||
exception_type,
|
||||
pick_app_targets,
|
||||
pick_mentions,
|
||||
timeout_code,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SendGroupFn = Callable[..., bool]
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
TimeoutLookupFn = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def _parse_started(raw: Any) -> Optional[datetime]:
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
text = text.replace("T", " ")[:19]
|
||||
try:
|
||||
naive = datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||||
return naive.replace(tzinfo=SHANGHAI)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _hours_of(row: dict[str, Any]) -> float:
|
||||
try:
|
||||
return float(row.get("timeoutHours") if row.get("timeoutHours") is not None else row.get("timeout_hours") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def poll_event_exceptions(
|
||||
*,
|
||||
ledger: Any = None,
|
||||
settings: Optional[Settings] = None,
|
||||
now: Optional[datetime] = None,
|
||||
enqueue: Optional[EnqueueFn] = None,
|
||||
send_group: Optional[SendGroupFn] = None,
|
||||
timeout_lookup: Optional[TimeoutLookupFn] = None,
|
||||
notify_ready: Optional[bool] = None,
|
||||
) -> int:
|
||||
"""
|
||||
巡检未告警等待。返回本轮成功记下异常的工单数。
|
||||
|
||||
会话成功才 record;卡片失败不影响会话。禁止进企微回调线程。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
book = ledger
|
||||
if book is None:
|
||||
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
|
||||
return 0
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
|
||||
book = HttpLedger()
|
||||
lister = getattr(book, "list_event_waits", None)
|
||||
if not callable(lister):
|
||||
return 0
|
||||
stamp = now or datetime.now(SHANGHAI)
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=SHANGHAI)
|
||||
# 测服会话/卡片也不加 [test],与销售询价正文一致。
|
||||
test_prefix = False
|
||||
lookup = timeout_lookup or getattr(book, "get_timeout_by_code", None)
|
||||
ready = notify_app_flag(notify_ready)
|
||||
sent = 0
|
||||
for row in lister() or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
if _handle_one(
|
||||
row,
|
||||
book=book,
|
||||
now=stamp,
|
||||
test_prefix=test_prefix,
|
||||
enqueue=enqueue,
|
||||
send_group=send_group,
|
||||
timeout_lookup=lookup,
|
||||
notify_ready=ready,
|
||||
settings=cfg,
|
||||
):
|
||||
sent += 1
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"事件异常巡检失败 wo=%s",
|
||||
row.get("work_order_no") or row.get("workOrderNo"),
|
||||
)
|
||||
return sent
|
||||
|
||||
|
||||
def notify_app_flag(override: Optional[bool]) -> bool:
|
||||
if override is not None:
|
||||
return bool(override)
|
||||
from agent.policy.deal_outcome import notify_app_ready
|
||||
|
||||
return notify_app_ready()
|
||||
|
||||
|
||||
def _handle_one(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
book: Any,
|
||||
now: datetime,
|
||||
test_prefix: bool,
|
||||
enqueue: Optional[EnqueueFn],
|
||||
send_group: Optional[SendGroupFn],
|
||||
timeout_lookup: Any,
|
||||
notify_ready: bool,
|
||||
settings: Settings,
|
||||
) -> bool:
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
kind = str(row.get("event_wait_kind") or row.get("eventWaitKind") or "").strip()
|
||||
if not no or not kind:
|
||||
return False
|
||||
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()
|
||||
code = timeout_code(wait_kind=kind, business_line=line, transport_class=klass)
|
||||
if not code or not callable(timeout_lookup):
|
||||
return False
|
||||
timeout_row = timeout_lookup(timeout_code=code) or {}
|
||||
if not timeout_row.get("found"):
|
||||
return False
|
||||
hours = _hours_of(timeout_row)
|
||||
if hours <= 0:
|
||||
return False
|
||||
started = _parse_started(row.get("event_wait_started_at") or row.get("eventWaitStartedAt"))
|
||||
if started is None:
|
||||
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,
|
||||
}
|
||||
people = pick_mentions(wait_kind=kind, sales=sales, members=watchers, business_line=line)
|
||||
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"
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type=exception_type(kind),
|
||||
work_order_no=no,
|
||||
names=names,
|
||||
hours=hours,
|
||||
mention=mention,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
ok_session = False
|
||||
if where == "group":
|
||||
if not chat_id:
|
||||
logger.warning("事件异常无协同群 wo=%s kind=%s", no, kind)
|
||||
return False
|
||||
sender = send_group or _default_send_group
|
||||
ok_session = bool(sender(chat_id=chat_id, content=text, business_line=line))
|
||||
else:
|
||||
uid = str((people[0].get("wecomId") if people else "") or sales.get("wecomId") or "").strip()
|
||||
if not uid:
|
||||
logger.warning("事件异常无私聊对象 wo=%s kind=%s", no, kind)
|
||||
return False
|
||||
enq = enqueue or _default_enqueue
|
||||
ok_session, info = enq(
|
||||
touser=uid,
|
||||
content=text,
|
||||
dedupe_key=f"event-ex:{no}:{kind}",
|
||||
payload={},
|
||||
)
|
||||
if not ok_session:
|
||||
logger.warning("事件异常私聊入队失败 wo=%s info=%s", no, info)
|
||||
if not ok_session:
|
||||
return False
|
||||
if notify_ready:
|
||||
_send_app_cards(
|
||||
people=people,
|
||||
kind=kind,
|
||||
work_order_no=no,
|
||||
hours=hours,
|
||||
test_prefix=test_prefix,
|
||||
enqueue=enqueue or _default_enqueue,
|
||||
)
|
||||
recorder = getattr(book, "record_event_exception", None)
|
||||
if callable(recorder):
|
||||
recorder(work_order_no=no, event_exception=exception_type(kind))
|
||||
return True
|
||||
|
||||
|
||||
def _send_app_cards(
|
||||
*,
|
||||
people: list[dict[str, Any]],
|
||||
kind: str,
|
||||
work_order_no: str,
|
||||
hours: float,
|
||||
test_prefix: bool,
|
||||
enqueue: EnqueueFn,
|
||||
) -> None:
|
||||
payload = copy.event_exception_alert_payload(
|
||||
card_title=card_title(kind),
|
||||
work_order_no=work_order_no,
|
||||
hours=hours,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
fallback = copy.event_exception_alert_text(
|
||||
card_title=card_title(kind),
|
||||
work_order_no=work_order_no,
|
||||
hours=hours,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
for person in pick_app_targets(people):
|
||||
uid = str(person.get("wecomId") or "").strip()
|
||||
if not uid:
|
||||
continue
|
||||
ok, info = enqueue(
|
||||
touser=uid,
|
||||
content=fallback,
|
||||
dedupe_key=f"event-ex-app:{work_order_no}:{kind}:{uid}",
|
||||
payload=payload,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("事件异常应用卡片入队失败 uid=%s info=%s", uid, info)
|
||||
|
||||
|
||||
def _default_enqueue(**kwargs: Any) -> tuple[bool, str]:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
return get_message_store().enqueue_outbound(**kwargs)
|
||||
|
||||
|
||||
def _default_send_group(*, chat_id: str, content: str, business_line: str = "") -> bool:
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "AIR":
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
|
||||
out = AibotReplyClient().send_group(chat_id=chat_id, content=content)
|
||||
return bool(out.get("ok"))
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
cfg = get_settings()
|
||||
client = WeComAppClient(
|
||||
corp_id=cfg.wecom_corp_id,
|
||||
secret=cfg.wecom_secret,
|
||||
agent_id=int(cfg.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
out = client.send_group(chat_id=chat_id, content=content)
|
||||
return bool(out.get("ok"))
|
||||
@@ -163,7 +163,14 @@ def _deliver_quote_file(data: dict[str, Any], result: LibreOfficeResult) -> None
|
||||
if wo:
|
||||
try:
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
flow = get_air_text_flow()
|
||||
start_wait(
|
||||
getattr(flow, "_ledger", None) or getattr(flow, "ledger", None),
|
||||
work_order_no=wo,
|
||||
wait_kind=WAIT_DEAL,
|
||||
)
|
||||
get_air_text_flow().mark_wait_deal(touser, wo)
|
||||
except Exception:
|
||||
logger.exception("PDF 出文件后未能切到成交跟进 inquiry=%s", wo)
|
||||
|
||||
@@ -73,8 +73,19 @@ def claim_and_apply_wake(
|
||||
kind=str(data.get("kind") or ""),
|
||||
detail=dict(data.get("detail") or {}),
|
||||
)
|
||||
graph.wake_from_worker(wake)
|
||||
logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind)
|
||||
if wake.kind == "system_exception_retry":
|
||||
from agent.policy.system_exception import run_admin_retry
|
||||
|
||||
result = run_admin_retry(wake.inquiry_no)
|
||||
logger.info(
|
||||
"系统异常重试 id=%s wo=%s ok=%s",
|
||||
entry_id,
|
||||
wake.inquiry_no,
|
||||
result.get("ok"),
|
||||
)
|
||||
else:
|
||||
graph.wake_from_worker(wake)
|
||||
logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("wake 处理失败 id=%s", entry_id)
|
||||
finally:
|
||||
|
||||
@@ -433,3 +433,93 @@ class HttpLedger:
|
||||
body,
|
||||
idem=f"manual-quote:{work_order_no}:{sig}",
|
||||
)
|
||||
|
||||
def mark_system_exception(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
system_exception: str,
|
||||
system_exception_reason: str,
|
||||
step: str = "",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""写入工单系统异常两列。已关闭由主账拒绝。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/markSystemException",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"systemException": system_exception,
|
||||
"systemExceptionReason": system_exception_reason,
|
||||
"step": step,
|
||||
"payload": dict(payload or {}),
|
||||
},
|
||||
)
|
||||
if out.get("ok") is False:
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
return out
|
||||
|
||||
def clear_system_exception(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""重试成功后清两列。"""
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/clearSystemException",
|
||||
{"workOrderNo": work_order_no},
|
||||
)
|
||||
if out.get("ok") is False:
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
return out
|
||||
|
||||
def list_exception_notify_users(self) -> list[dict[str, Any]]:
|
||||
"""角色勾了系统异常消息通知、且账号已绑企微。"""
|
||||
out = self._post("/inquiry/agent/account/listExceptionNotify", {})
|
||||
rows = out.get("staff") or out.get("result") or []
|
||||
if isinstance(rows, list):
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
def start_event_wait(self, *, work_order_no: str, wait_kind: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/startEventWait",
|
||||
{"workOrderNo": work_order_no, "waitKind": wait_kind},
|
||||
)
|
||||
if out.get("ok") is False:
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
return out
|
||||
|
||||
def clear_event_wait(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/clearEventWait",
|
||||
{"workOrderNo": work_order_no},
|
||||
)
|
||||
if out.get("ok") is False:
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
return out
|
||||
|
||||
def list_event_waits(self) -> list[dict[str, Any]]:
|
||||
out = self._post("/inquiry/agent/ticket/listEventWaits", {})
|
||||
rows = out.get("result") if isinstance(out.get("result"), list) else None
|
||||
if rows is None:
|
||||
rows = out.get("tickets") if isinstance(out.get("tickets"), list) else []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
|
||||
def record_event_exception(self, *, work_order_no: str, event_exception: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/ticket/recordEventException",
|
||||
{"workOrderNo": work_order_no, "eventException": event_exception},
|
||||
)
|
||||
if out.get("ok") is False:
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
return out
|
||||
|
||||
def get_timeout_by_code(self, *, timeout_code: str) -> dict[str, Any]:
|
||||
out = self._post(
|
||||
"/inquiry/agent/responseTimeout/byCode",
|
||||
{"timeoutCode": timeout_code},
|
||||
)
|
||||
if "found" not in out:
|
||||
return {"found": False, "timeoutCode": timeout_code}
|
||||
return out
|
||||
|
||||
@@ -44,6 +44,14 @@ class MemoryTicket:
|
||||
lost_reason: str = ""
|
||||
next_followup_at: str = ""
|
||||
deal_notify_summary: str = ""
|
||||
event_exception: str = ""
|
||||
event_wait_kind: str = ""
|
||||
event_wait_started_at: str = ""
|
||||
event_wait_alerted: int = 0
|
||||
system_exception: str = ""
|
||||
system_exception_reason: str = ""
|
||||
system_exception_step: str = ""
|
||||
system_exception_payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MemoryLedger:
|
||||
@@ -72,6 +80,8 @@ class MemoryLedger:
|
||||
self._land_staff: list[dict[str, Any]] = []
|
||||
self._staff_by_wecom: dict[str, dict[str, Any]] = {}
|
||||
self._staff_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._exception_notify_users: list[dict[str, Any]] = []
|
||||
self._timeouts: dict[str, float] = {}
|
||||
|
||||
def _next_no(self) -> str:
|
||||
self._seq += 1
|
||||
@@ -373,8 +383,69 @@ class MemoryLedger:
|
||||
"lostReason": ticket.lost_reason,
|
||||
"deal_notify_summary": ticket.deal_notify_summary,
|
||||
"next_followup_at": ticket.next_followup_at,
|
||||
"system_exception": ticket.system_exception,
|
||||
"systemException": ticket.system_exception,
|
||||
"system_exception_reason": ticket.system_exception_reason,
|
||||
"systemExceptionReason": ticket.system_exception_reason,
|
||||
"system_exception_step": ticket.system_exception_step,
|
||||
"system_exception_payload": dict(ticket.system_exception_payload),
|
||||
}
|
||||
|
||||
def upsert_exception_notify_user(self, row: dict[str, Any]) -> None:
|
||||
"""单测注入「系统异常消息通知」接收人(后台账号已绑企微)。"""
|
||||
data = dict(row)
|
||||
uid = str(data.get("wecomId") or data.get("wecom_id") or "").strip()
|
||||
if not uid:
|
||||
return
|
||||
data["wecomId"] = uid
|
||||
with self._lock:
|
||||
self._exception_notify_users = [
|
||||
x for x in self._exception_notify_users
|
||||
if str(x.get("wecomId") or "") != uid
|
||||
]
|
||||
self._exception_notify_users.append(data)
|
||||
|
||||
def list_exception_notify_users(self) -> list[dict[str, Any]]:
|
||||
"""角色勾了系统异常消息通知、且账号已绑企微的人。"""
|
||||
with self._lock:
|
||||
return [dict(x) for x in self._exception_notify_users]
|
||||
|
||||
def mark_system_exception(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
system_exception: str,
|
||||
system_exception_reason: str,
|
||||
step: str = "",
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""写入工单两列与重试上下文;已关闭拒绝。不改六态。"""
|
||||
no = (work_order_no or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
if ticket.status == "已关闭":
|
||||
return {"ok": False, "error": "closed"}
|
||||
ticket.system_exception = (system_exception or "").strip()
|
||||
ticket.system_exception_reason = (system_exception_reason or "").strip()
|
||||
ticket.system_exception_step = (step or "").strip()
|
||||
ticket.system_exception_payload = dict(payload or {})
|
||||
return {"ok": True, "status": ticket.status}
|
||||
|
||||
def clear_system_exception(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
"""重试成功后清两列。"""
|
||||
no = (work_order_no or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
ticket.system_exception = ""
|
||||
ticket.system_exception_reason = ""
|
||||
ticket.system_exception_step = ""
|
||||
ticket.system_exception_payload = {}
|
||||
return {"ok": True}
|
||||
|
||||
def upsert_staff(self, row: dict[str, Any]) -> None:
|
||||
"""单测注入任意岗位(含会话存档账号「询价机器人」)。"""
|
||||
data = dict(row)
|
||||
@@ -661,3 +732,119 @@ class MemoryLedger:
|
||||
"releaseId": f"CA-{no}-RLSE",
|
||||
"tmsRequestId": f"TMS-RLSE-{no}",
|
||||
}
|
||||
|
||||
def start_event_wait(self, *, work_order_no: str, wait_kind: str) -> dict[str, Any]:
|
||||
"""开始盯等待。同种类不重置起点。不改六态。"""
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
kind = (wait_kind or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
if ticket.status in {"已成交", "已关闭", "转人工"} or not kind:
|
||||
return {"ok": False, "error": "not_watchable", "status": ticket.status}
|
||||
if ticket.event_wait_kind == kind and ticket.event_wait_started_at:
|
||||
return {"ok": True, "work_order_no": no, "eventWaitKind": kind}
|
||||
ticket.event_wait_kind = kind
|
||||
ticket.event_wait_started_at = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
|
||||
ticket.event_wait_alerted = 0
|
||||
return {"ok": True, "work_order_no": no, "eventWaitKind": kind}
|
||||
|
||||
def clear_event_wait(self, *, work_order_no: str) -> dict[str, Any]:
|
||||
no = (work_order_no or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
ticket.event_wait_kind = ""
|
||||
ticket.event_wait_started_at = ""
|
||||
ticket.event_wait_alerted = 0
|
||||
return {"ok": True, "work_order_no": no}
|
||||
|
||||
def list_event_waits(self) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
for ticket in self._tickets.values():
|
||||
if not (ticket.event_wait_kind or "").strip():
|
||||
continue
|
||||
if int(ticket.event_wait_alerted or 0) != 0:
|
||||
continue
|
||||
if ticket.status in {"已成交", "已关闭", "转人工"}:
|
||||
continue
|
||||
watchers: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
sales_id = (ticket.sales_wecom_id or "").strip()
|
||||
if sales_id:
|
||||
row = dict(self._staff_by_wecom.get(sales_id) or {})
|
||||
watchers.append(
|
||||
{
|
||||
"wecomId": sales_id,
|
||||
"name": str(row.get("name") or ticket.sales_wecom_id),
|
||||
"roleCode": str(row.get("roleCode") or row.get("role_code") or "sales"),
|
||||
"notifyEventException": row.get("notifyEventException", 0),
|
||||
}
|
||||
)
|
||||
seen.add(sales_id)
|
||||
for uid in ticket.collab_member_ids:
|
||||
key = str(uid).strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
row = dict(self._staff_by_wecom.get(key) or {})
|
||||
if not row:
|
||||
continue
|
||||
watchers.append(
|
||||
{
|
||||
"wecomId": key,
|
||||
"name": str(row.get("name") or key),
|
||||
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
|
||||
"notifyEventException": row.get("notifyEventException", 0),
|
||||
}
|
||||
)
|
||||
seen.add(key)
|
||||
out.append(
|
||||
{
|
||||
"work_order_no": ticket.work_order_no,
|
||||
"workOrderNo": ticket.work_order_no,
|
||||
"status": ticket.status,
|
||||
"business_line": ticket.business_line,
|
||||
"businessLine": ticket.business_line,
|
||||
"event_wait_kind": ticket.event_wait_kind,
|
||||
"eventWaitKind": ticket.event_wait_kind,
|
||||
"event_wait_started_at": ticket.event_wait_started_at,
|
||||
"eventWaitStartedAt": ticket.event_wait_started_at,
|
||||
"sales_wecom_id": ticket.sales_wecom_id,
|
||||
"salesWecomId": ticket.sales_wecom_id,
|
||||
"sales_name": str(self._staff_by_wecom.get(sales_id, {}).get("name") or "销售"),
|
||||
"collab_chat_id": ticket.collab_chat_id,
|
||||
"collabChatId": ticket.collab_chat_id,
|
||||
"collab_member_ids": list(ticket.collab_member_ids),
|
||||
"facts": dict(ticket.facts),
|
||||
"eventException": ticket.event_exception,
|
||||
"watchers": watchers,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def record_event_exception(self, *, work_order_no: str, event_exception: str) -> dict[str, Any]:
|
||||
from agent.policy.event_exception import append_summary
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
with self._lock:
|
||||
ticket = self._tickets.get(no)
|
||||
if ticket is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
ticket.event_exception = append_summary(ticket.event_exception, event_exception)
|
||||
ticket.event_wait_alerted = 1
|
||||
return {
|
||||
"ok": True,
|
||||
"work_order_no": no,
|
||||
"eventException": ticket.event_exception,
|
||||
}
|
||||
|
||||
def get_timeout_by_code(self, *, timeout_code: str) -> dict[str, Any]:
|
||||
code = (timeout_code or "").strip()
|
||||
hours = self._timeouts.get(code)
|
||||
if hours is None:
|
||||
return {"found": False, "timeoutCode": code}
|
||||
return {"found": True, "timeoutCode": code, "timeoutHours": hours}
|
||||
|
||||
@@ -22,13 +22,13 @@ _MODE_WORDS = (
|
||||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||||
_LABEL_LINE = re.compile(
|
||||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_LABEL_INLINE = re.compile(
|
||||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型/数量|车型数量|"
|
||||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
|
||||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
|
||||
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
|
||||
)
|
||||
@@ -145,6 +145,19 @@ def extract_inquiry_snapshot(
|
||||
b_payload = invoke_extract_fields(
|
||||
text, allow_network=allow_b, chat_fn=chat_fn
|
||||
)
|
||||
if isinstance(b_payload, dict) and not b_payload.get("ok"):
|
||||
err = str(b_payload.get("error") or "").strip()
|
||||
if err not in {"b_network_disabled", "extract_fields_shell_not_implemented"} and (
|
||||
allow_b or chat_fn is not None
|
||||
):
|
||||
return {
|
||||
"business_line": mode,
|
||||
"land_subtype": "",
|
||||
"facts": {},
|
||||
"source": "llm_tech_fail",
|
||||
"tech_fail": True,
|
||||
"tech_error": err or "LLM_FAIL",
|
||||
}
|
||||
if b_payload and b_payload.get("ok"):
|
||||
merged = dict(b_payload.get("facts") or {})
|
||||
merged.update(facts)
|
||||
@@ -246,7 +259,7 @@ def extract_collab_fields(
|
||||
"""
|
||||
群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。
|
||||
|
||||
allowed_keys 传入则只返回本单该有的项。不传则海运六项 ∪ 陆运协同键,给意图分类。
|
||||
allowed_keys 传入则只返回本单该有的项。不传则海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键,给意图分类。
|
||||
闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。
|
||||
"""
|
||||
from agent.schema.field_validate import (
|
||||
|
||||
@@ -138,6 +138,9 @@ def activate_ticket(
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
from agent.policy.event_exception import WAIT_AIR_QUOTE, start_wait
|
||||
|
||||
start_wait(flow.ledger, work_order_no=no, wait_kind=WAIT_AIR_QUOTE)
|
||||
return ticket, "activated"
|
||||
|
||||
|
||||
@@ -447,6 +450,9 @@ def apply_air_file(
|
||||
return "ledger_fail"
|
||||
view["collab_chat_id"] = chat_id
|
||||
view["quote"] = quote
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(flow.ledger, work_order_no=no)
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "airline_file_quote"
|
||||
|
||||
@@ -532,6 +538,9 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id:
|
||||
business_line="AIR",
|
||||
)
|
||||
notify_quote_attachment_saved(flow, chat_id, no, count=1)
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(flow.ledger, work_order_no=no)
|
||||
emit_group_deal(flow=flow, ticket=view, quote=quote)
|
||||
return "quote_confirmed"
|
||||
|
||||
@@ -586,6 +595,14 @@ def apply_air_lock(
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
no = ticket_no(ticket)
|
||||
name = operator_name(flow, sender_id)
|
||||
quote = ticket_view(ticket).get("quote") or {}
|
||||
@@ -612,24 +629,43 @@ def apply_air_lock(
|
||||
copy.air_lock_fail(work_order_no=no, operator=name, reason="主账未开放锁舱"),
|
||||
)
|
||||
return "lock_unavailable"
|
||||
out = locker(
|
||||
work_order_no=no,
|
||||
option_no=picked,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
) or {}
|
||||
if not out.get("ok"):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_lock_fail(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
reason=str(out.get("failReason") or out.get("error") or "锁舱失败"),
|
||||
),
|
||||
from agent.policy.system_exception import (
|
||||
STEP_TMS_CABIN,
|
||||
TYPE_TMS,
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
confirm_system_exception,
|
||||
)
|
||||
|
||||
out = call_with_retries(
|
||||
lambda: locker(
|
||||
work_order_no=no,
|
||||
option_no=picked,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
)
|
||||
return "lock_fail"
|
||||
or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
reason="空运锁舱接口调用失败",
|
||||
service="空运锁舱",
|
||||
error_code=str(out.get("failReason") or out.get("error") or "LOCK_FAIL"),
|
||||
work_order_no=no,
|
||||
ticket_status=ticket_status(ticket),
|
||||
conversation_kind="group",
|
||||
conversation_target=chat_id,
|
||||
extra={"option_no": picked, "instruction": instruction, "sender_id": sender_id},
|
||||
),
|
||||
ledger=flow.ledger,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception"
|
||||
exec_no = str(out.get("lockId") or out.get("tmsRequestId") or "").strip()
|
||||
quote_no = str(out.get("quoteId") or quote_id).strip()
|
||||
flow.ledger.patch_collab_facts(
|
||||
@@ -671,6 +707,14 @@ def apply_air_release(
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
return "not_airline"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception_paused"
|
||||
no = ticket_no(ticket)
|
||||
if ticket_status(ticket) == "已成交":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_release_deal_blocked(no))
|
||||
@@ -689,24 +733,43 @@ def apply_air_release(
|
||||
copy.air_release_fail(work_order_no=no, operator=name, reason="主账未开放释放"),
|
||||
)
|
||||
return "release_unavailable"
|
||||
out = releaser(
|
||||
work_order_no=no,
|
||||
lock_id=lock_id,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
) or {}
|
||||
if not out.get("ok"):
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
chat_id,
|
||||
copy.air_release_fail(
|
||||
work_order_no=no,
|
||||
operator=name,
|
||||
reason=str(out.get("failReason") or out.get("error") or "释放失败"),
|
||||
),
|
||||
from agent.policy.system_exception import (
|
||||
STEP_TMS_CABIN,
|
||||
TYPE_TMS,
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
confirm_system_exception,
|
||||
)
|
||||
|
||||
out = call_with_retries(
|
||||
lambda: releaser(
|
||||
work_order_no=no,
|
||||
lock_id=lock_id,
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
)
|
||||
return "release_fail"
|
||||
or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
reason="空运释放舱位接口调用失败",
|
||||
service="空运舱位",
|
||||
error_code=str(out.get("failReason") or out.get("error") or "RELEASE_FAIL"),
|
||||
work_order_no=no,
|
||||
ticket_status=ticket_status(ticket),
|
||||
conversation_kind="group",
|
||||
conversation_target=chat_id,
|
||||
extra={"lock_id": lock_id, "instruction": instruction, "sender_id": sender_id},
|
||||
),
|
||||
ledger=flow.ledger,
|
||||
reply=lambda text: send_group_text(group_client_of(flow), chat_id, text),
|
||||
)
|
||||
return "system_exception"
|
||||
exec_no = str(out.get("releaseId") or out.get("tmsRequestId") or lock_id).strip()
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
@@ -748,6 +811,9 @@ def _commit_quote(
|
||||
if not up.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
||||
return "ledger_fail"
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(flow.ledger, work_order_no=no)
|
||||
ticket2 = current_ticket(flow, chat_id) or ticket
|
||||
view = ticket_view(ticket2)
|
||||
view["collab_chat_id"] = chat_id
|
||||
|
||||
@@ -395,6 +395,31 @@ class AirTextInquiryFlow:
|
||||
self._save(new_sess)
|
||||
return new_sess.phase or "clarify"
|
||||
|
||||
def _detach_ticketed_for_new_inquiry(self, sess: Optional[FlowSession]) -> Optional[FlowSession]:
|
||||
"""
|
||||
已经出过正式工单号:这一句按新一轮收字段,不带上一单。
|
||||
|
||||
测服 WO202609200012 出号后,下一句海运把旧贸易条款写进 WO202609200013。
|
||||
点按钮、未成交原因、调价已在前面处理。虚拟号还在补问,要接着填。
|
||||
"""
|
||||
if sess is None:
|
||||
return None
|
||||
from agent.policy.handoff_human import is_virtual_no
|
||||
|
||||
no = (sess.work_order_no or "").strip()
|
||||
phase = (sess.phase or "").strip()
|
||||
if not no or is_virtual_no(no):
|
||||
return sess
|
||||
if phase in {"clarify", "need_mode", "handoff_offer", "wait_lost_reason"}:
|
||||
return sess
|
||||
logger.info(
|
||||
"%s.new_over_old phase=%s wo=%s",
|
||||
self.bookmark_kind,
|
||||
phase or "-",
|
||||
no,
|
||||
)
|
||||
return None
|
||||
|
||||
def drop_current_bookmark(self, sender_id: str) -> Optional[FlowSession]:
|
||||
"""
|
||||
发图新开:丢掉当前书签,已出号会话仍留在 _by_ticket。
|
||||
@@ -691,6 +716,7 @@ class AirTextInquiryFlow:
|
||||
adjusted = try_private_adjust(self, sess, text, reply)
|
||||
if adjusted:
|
||||
return adjusted
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -700,6 +726,17 @@ class AirTextInquiryFlow:
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
llm_phase = handle_extract_tech_fail(
|
||||
snap=snap,
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
sender_id=sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
if llm_phase:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
@@ -734,6 +771,8 @@ class AirTextInquiryFlow:
|
||||
if mode == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
# 本句已是陆运:丢掉空运补问草稿。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_land_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -749,6 +788,7 @@ class AirTextInquiryFlow:
|
||||
# 海运交给海运 Owner,空运不再回「本轮先开通空运」
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_sea_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -840,7 +880,12 @@ class AirTextInquiryFlow:
|
||||
# 询价确认必须先到销售手机,再打 TMS。否则查得快时暂无报价卡会抢先发出。
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts)
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts)
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -854,6 +899,16 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
if not tms.get("ok"):
|
||||
# 没拿到 TMS 回包(超时/熔断/主账未出站)不能冒充「暂无报价」
|
||||
if confirm_tms_quote_if_tech(
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
tms=tms,
|
||||
sender_id=sender_id,
|
||||
ticket_status="询价中",
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
@@ -1021,6 +1076,9 @@ class AirTextInquiryFlow:
|
||||
return self._skip_collab(sess, reply, card_meta=card_meta)
|
||||
if action == copy.BTN_REJECT:
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||||
sess.phase = "wait_adjust"
|
||||
sess.allowed = ()
|
||||
sess.wait_version += 1
|
||||
@@ -1073,6 +1131,9 @@ class AirTextInquiryFlow:
|
||||
transport_mode=mode,
|
||||
),
|
||||
)
|
||||
from agent.policy.event_exception import WAIT_ADOPT, start_wait
|
||||
|
||||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_ADOPT)
|
||||
return "wait_adopt"
|
||||
|
||||
def _grey_clicked_card(
|
||||
@@ -1131,6 +1192,9 @@ class AirTextInquiryFlow:
|
||||
reply(str(result.get("message") or copy.quote_template_missing()))
|
||||
return "wait_adopt"
|
||||
filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||||
file_extra = self._quote_file_payload(result, filename=filename)
|
||||
if kind == "PDF" and result.get("needsPdf"):
|
||||
queued = self._enqueue_quote_pdf(
|
||||
@@ -1209,6 +1273,9 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL)
|
||||
return "wait_deal"
|
||||
|
||||
def _emit_deal(self, reply: ReplyFn, sess: FlowSession) -> None:
|
||||
@@ -1368,6 +1435,9 @@ class AirTextInquiryFlow:
|
||||
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
start_wait(self._ledger, work_order_no=sess.work_order_no, wait_kind=WAIT_DEAL)
|
||||
|
||||
def _refresh_wait_file(self, sess: FlowSession) -> None:
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
成交跟进纯规则:工作日顺延、线路对负责人。
|
||||
|
||||
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
|
||||
也给事件异常算工作日已过小时数(非工作日整天不算;工作日当天 0–24 都算)。
|
||||
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
|
||||
"""
|
||||
|
||||
@@ -105,6 +106,30 @@ def next_followup_at(now: datetime | None = None) -> datetime:
|
||||
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
|
||||
|
||||
|
||||
def workday_elapsed_hours(start: datetime, now: datetime) -> float:
|
||||
"""
|
||||
起止之间折合多少工作日小时。
|
||||
|
||||
非工作日整天不算;工作日当天 0–24 都算,不切上午晚上。
|
||||
起点落在休息日:从下一个工作日 00:00 起算。
|
||||
副作用:无。可在 Worker 巡检调用,禁止放进企微回调线程做 IO。
|
||||
"""
|
||||
a = start.astimezone(SHANGHAI) if start.tzinfo else start.replace(tzinfo=SHANGHAI)
|
||||
b = now.astimezone(SHANGHAI) if now.tzinfo else now.replace(tzinfo=SHANGHAI)
|
||||
if b <= a:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
cur = a
|
||||
while cur.date() < b.date():
|
||||
nxt = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
if is_workday(cur.date()):
|
||||
total += (nxt - cur).total_seconds() / 3600.0
|
||||
cur = nxt
|
||||
if is_workday(b.date()):
|
||||
total += (b - cur).total_seconds() / 3600.0
|
||||
return total
|
||||
|
||||
|
||||
def _split_routes(raw: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
|
||||
|
||||
@@ -133,12 +133,17 @@ def finish_deal_outcome(
|
||||
result = writer(work_order_no=no, to_status=status, **extra) or {}
|
||||
if not result.get("ok"):
|
||||
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
|
||||
from agent.policy.event_exception import WAIT_LOST_REASON, clear_wait, start_wait
|
||||
|
||||
if status == "未成交":
|
||||
phase = "wait_lost_reason"
|
||||
start_wait(ledger, work_order_no=no, wait_kind=WAIT_LOST_REASON)
|
||||
elif status == "协商中":
|
||||
phase = "wait_deal"
|
||||
clear_wait(ledger, work_order_no=no)
|
||||
else:
|
||||
phase = "done"
|
||||
clear_wait(ledger, work_order_no=no)
|
||||
return {
|
||||
"ok": True,
|
||||
"phase": phase,
|
||||
@@ -157,4 +162,9 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
|
||||
patcher = getattr(ledger, "patch_lost_reason", None)
|
||||
if not callable(patcher):
|
||||
return {"ok": False, "error": "no_patch"}
|
||||
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
||||
out = patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
||||
if out.get("ok"):
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(ledger, work_order_no=no)
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
事件异常纯规则:等待种类、超时编码、点名对象、卡片接收人。
|
||||
|
||||
本文件职责:给 Worker 巡检判定用,不发企微、不写主账。
|
||||
禁止:在回调线程做 IO;把六态写进本文件;把系统异常混进来。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.land_options import canonicalize_land_class
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WAIT_AIR_QUOTE = "air_quote"
|
||||
WAIT_PRODUCT_QUOTE = "product_quote"
|
||||
WAIT_ADOPT = "adopt"
|
||||
WAIT_DEAL = "deal"
|
||||
WAIT_LOST_REASON = "lost_reason"
|
||||
|
||||
TYPE_AIR = "航线响应超时"
|
||||
TYPE_PRODUCT = "产品响应超时"
|
||||
TYPE_SALES = "销售响应超时"
|
||||
|
||||
CARD_AIR = "航线响应异常"
|
||||
CARD_PRODUCT = "产品响应异常"
|
||||
CARD_SALES = "销售响应异常"
|
||||
|
||||
_SALES_CODES = {
|
||||
"AIR": "air_sales",
|
||||
"SEA": "sea_sales",
|
||||
"LAND": "land_sales",
|
||||
}
|
||||
|
||||
_LAND_PRODUCT_CODES = {
|
||||
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
|
||||
"急件": "land_hk_express",
|
||||
}
|
||||
|
||||
_SALES_WAITS = {WAIT_ADOPT, WAIT_DEAL, WAIT_LOST_REASON}
|
||||
|
||||
|
||||
def timeout_code(*, wait_kind: str, business_line: str, transport_class: str) -> str:
|
||||
"""
|
||||
按等待种类 + 运输方式 + 询价分类,对上后台超时规则编码。
|
||||
|
||||
对不上返回空串,调用方视为本步不监测。副作用:无。
|
||||
"""
|
||||
line = (business_line or "").strip().upper()
|
||||
kind = (wait_kind or "").strip()
|
||||
klass = (transport_class or "").strip()
|
||||
if kind in _SALES_WAITS:
|
||||
return _SALES_CODES.get(line, "")
|
||||
if kind == WAIT_AIR_QUOTE:
|
||||
return "air_route"
|
||||
if kind == WAIT_PRODUCT_QUOTE:
|
||||
if line == "SEA":
|
||||
canon = canonicalize_sea_class(klass) or klass
|
||||
if canon == "Port to Port":
|
||||
return "sea_product_ptp"
|
||||
if canon == "其他":
|
||||
return "sea_product_other"
|
||||
return ""
|
||||
if line == "LAND":
|
||||
canon = canonicalize_land_class(klass) or klass
|
||||
return _LAND_PRODUCT_CODES.get(canon, "")
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def exception_type(wait_kind: str) -> str:
|
||||
"""工单「事件异常」字段用的类型名。"""
|
||||
kind = (wait_kind or "").strip()
|
||||
if kind == WAIT_AIR_QUOTE:
|
||||
return TYPE_AIR
|
||||
if kind == WAIT_PRODUCT_QUOTE:
|
||||
return TYPE_PRODUCT
|
||||
if kind in _SALES_WAITS:
|
||||
return TYPE_SALES
|
||||
return ""
|
||||
|
||||
|
||||
def card_title(wait_kind: str) -> str:
|
||||
"""询价消息服务卡片标题。"""
|
||||
kind = (wait_kind or "").strip()
|
||||
if kind == WAIT_AIR_QUOTE:
|
||||
return CARD_AIR
|
||||
if kind == WAIT_PRODUCT_QUOTE:
|
||||
return CARD_PRODUCT
|
||||
if kind in _SALES_WAITS:
|
||||
return CARD_SALES
|
||||
return ""
|
||||
|
||||
|
||||
def format_timeout_hours(hours: float) -> str:
|
||||
"""话术里的小时数:整数去小数点,0.25 保持原样。"""
|
||||
try:
|
||||
n = float(hours)
|
||||
except (TypeError, ValueError):
|
||||
return "0"
|
||||
if n == int(n):
|
||||
return str(int(n))
|
||||
text = f"{n:.4f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
|
||||
def append_summary(existing: str, type_name: str) -> str:
|
||||
"""多次异常用分号拼接,同一类型不重复。"""
|
||||
add = (type_name or "").strip()
|
||||
cur = (existing or "").strip()
|
||||
if not add:
|
||||
return cur
|
||||
if not cur or cur == "-":
|
||||
return add
|
||||
parts = [p.strip() for p in cur.split(";") if p.strip()]
|
||||
if add in parts:
|
||||
return cur
|
||||
parts.append(add)
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def start_wait(ledger: Any, *, work_order_no: str, wait_kind: str) -> None:
|
||||
"""开始盯等待。失败只打日志,不抛给回调。"""
|
||||
no = (work_order_no or "").strip()
|
||||
kind = (wait_kind or "").strip()
|
||||
if not no or not kind or ledger is None:
|
||||
return
|
||||
fn = getattr(ledger, "start_event_wait", None)
|
||||
if not callable(fn):
|
||||
return
|
||||
try:
|
||||
fn(work_order_no=no, wait_kind=kind)
|
||||
except Exception:
|
||||
logger.exception("start_event_wait 失败 wo=%s kind=%s", no, kind)
|
||||
|
||||
|
||||
def clear_wait(ledger: Any, *, work_order_no: str) -> None:
|
||||
"""清空当前等待,不改事件异常摘要。失败只打日志。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or ledger is None:
|
||||
return
|
||||
fn = getattr(ledger, "clear_event_wait", None)
|
||||
if not callable(fn):
|
||||
return
|
||||
try:
|
||||
fn(work_order_no=no)
|
||||
except Exception:
|
||||
logger.exception("clear_event_wait 失败 wo=%s", no)
|
||||
|
||||
|
||||
def pick_mentions(
|
||||
*,
|
||||
wait_kind: str,
|
||||
sales: dict[str, Any] | None,
|
||||
members: list[dict[str, Any]] | None,
|
||||
business_line: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
本单这次应回而未回、需要被点名的人。
|
||||
|
||||
航线只盯群里航线岗;海运/陆运产品盯对应产品岗;销售三类只盯本单销售。
|
||||
不要询价机器人、不要群里其他人。
|
||||
"""
|
||||
kind = (wait_kind or "").strip()
|
||||
line = (business_line or "").strip().upper()
|
||||
if kind in _SALES_WAITS:
|
||||
person = dict(sales or {})
|
||||
uid = str(person.get("wecomId") or person.get("wecom_id") or "").strip()
|
||||
if not uid:
|
||||
return []
|
||||
person.setdefault("wecomId", uid)
|
||||
person.setdefault("name", str(person.get("name") or "").strip() or "销售")
|
||||
return [person]
|
||||
want = ""
|
||||
if kind == WAIT_AIR_QUOTE:
|
||||
want = "air"
|
||||
elif kind == WAIT_PRODUCT_QUOTE:
|
||||
if line == "SEA":
|
||||
want = "sea"
|
||||
elif line == "LAND":
|
||||
want = "land"
|
||||
if not want:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in members or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
role = str(raw.get("roleCode") or raw.get("role_code") or "").strip().lower()
|
||||
if role != want:
|
||||
continue
|
||||
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
row = dict(raw)
|
||||
row.setdefault("wecomId", uid)
|
||||
row.setdefault("name", str(raw.get("name") or "").strip() or uid)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def pick_app_targets(people: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
"""勾了「接收事件异常消息提醒」且绑了企微的人。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in people or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
flag = raw.get("notifyEventException")
|
||||
if flag not in (1, True, "1"):
|
||||
continue
|
||||
uid = str(raw.get("wecomId") or raw.get("wecom_id") or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
out.append(raw)
|
||||
return out
|
||||
|
||||
|
||||
def conversation_of(*, wait_kind: str, collab_chat_id: str) -> str:
|
||||
"""
|
||||
告警发在哪个会话:group 或 private。
|
||||
|
||||
采用/不采用永远私聊;航线/产品永远群;成交跟进和未成交原因有协同群才群。
|
||||
"""
|
||||
kind = (wait_kind or "").strip()
|
||||
chat = (collab_chat_id or "").strip()
|
||||
if kind == WAIT_ADOPT:
|
||||
return "private"
|
||||
if kind in {WAIT_AIR_QUOTE, WAIT_PRODUCT_QUOTE}:
|
||||
return "group"
|
||||
if kind in {WAIT_DEAL, WAIT_LOST_REASON}:
|
||||
return "group" if chat else "private"
|
||||
return "private"
|
||||
@@ -273,6 +273,9 @@ def try_commit_air_group_handoff(flow: Any, *, chat_id: str, work_order_no: str)
|
||||
else copy.HANDOFF_INTENT_REASON,
|
||||
)
|
||||
sess.status = HANDOFF_STATUS
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(book, work_order_no=real)
|
||||
sess.handoff_committed = True
|
||||
sess.handoff_offer = ""
|
||||
sess.phase = "handoff_done"
|
||||
|
||||
@@ -25,6 +25,7 @@ from agent.schema.field_validate import (
|
||||
)
|
||||
from agent.schema.land_options import option_prompt as _land_option_prompt
|
||||
from agent.schema.quote_adjust import total_price_label
|
||||
from agent.schema.sea_options import sea_class_prompt
|
||||
|
||||
# 空运补问:与合同 8 项一致。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
@@ -41,7 +42,7 @@ _AIR_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
|
||||
)
|
||||
|
||||
# 海运补问:与合同 7 项一致。报价日期默认当天,不进待补充。
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
@@ -49,6 +50,8 @@ _SEA_EXAMPLES = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-15",
|
||||
}
|
||||
_SEA_CLARIFY = tuple(
|
||||
@@ -404,7 +407,10 @@ def ask_clarify(
|
||||
for key, label, example in rows:
|
||||
if key in missing_set:
|
||||
any_missing = True
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if sea and key == "运输分类":
|
||||
lines.append(sea_class_prompt())
|
||||
else:
|
||||
lines.append(f"{label}:(如:{example})")
|
||||
if not any_missing:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
@@ -485,6 +491,8 @@ def inquiry_field_lines(
|
||||
qty = (facts.get("货量") or facts.get("货物数量") or "-").strip() or "-"
|
||||
load = (facts.get("整柜或拼柜") or "-").strip() or "-"
|
||||
box = (facts.get("箱型箱量") or "-").strip() or "-"
|
||||
trade = (facts.get("贸易条款") or "-").strip() or "-"
|
||||
klass = (facts.get("运输分类") or "-").strip() or "-"
|
||||
return [
|
||||
f"工单号:{work_order_no}",
|
||||
f"运输方式:{transport_mode}",
|
||||
@@ -494,6 +502,8 @@ def inquiry_field_lines(
|
||||
f"货物数量:{qty}",
|
||||
f"整柜或拼柜:{load}",
|
||||
f"箱型箱量:{box}",
|
||||
f"贸易条款:{trade}",
|
||||
f"运输分类:{klass}",
|
||||
f"报价日期:{quote_date}",
|
||||
]
|
||||
if (transport_mode or "").strip() == "陆运":
|
||||
@@ -766,6 +776,59 @@ def quote_fee_lines(quote: dict[str, Any] | None) -> list[tuple[str, str]]:
|
||||
return lines
|
||||
|
||||
|
||||
_SEA_FEE_BUCKETS = (
|
||||
("polCostItems", "始发港费用", "始发港费用小计"),
|
||||
("oceanCostItems", "海运费", "海运费小计"),
|
||||
("podCostItems", "目的港费用", "目的港费用小计"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_fee_bucket(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||
"""读 polCostItems / fee_rows_polCostItems,字符串 JSON 也认。"""
|
||||
raw = quote.get(key) or quote.get("fee_rows_" + key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [row for row in raw if isinstance(row, dict)]
|
||||
|
||||
|
||||
def sea_bucket_fee_lines(quote: dict[str, Any] | None) -> list[str]:
|
||||
"""
|
||||
海运三段正文。有任一段桶才用;否则返回空列表,调用方回退扁平 fee_lines。
|
||||
|
||||
费目按该条币种金额原样;小计用 fee_total_* + 表头结算币种。本地不折算。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
sections: list[str] = []
|
||||
any_bucket = False
|
||||
for key, title, subtotal_label in _SEA_FEE_BUCKETS:
|
||||
rows = _parse_fee_bucket(src, key)
|
||||
subtotal = str(src.get("fee_total_" + key) or "").strip()
|
||||
if not rows and not subtotal:
|
||||
continue
|
||||
any_bucket = True
|
||||
sections.append(title)
|
||||
for row in rows:
|
||||
name = str(row.get("name") or row.get("label") or row.get("feeName") or "").strip()
|
||||
amount = str(row.get("amount") or row.get("value") or "").strip()
|
||||
line_ccy = str(row.get("currency") or row.get("currencyCode") or header_ccy).strip()
|
||||
if name and amount and amount != "-":
|
||||
sections.append(f"{name}:{money_text(amount, line_ccy)}")
|
||||
if subtotal and subtotal != "-":
|
||||
sections.append(f"{subtotal_label}:{money_text(subtotal, header_ccy)}")
|
||||
sections.append("")
|
||||
if not any_bucket:
|
||||
return []
|
||||
while sections and sections[-1] == "":
|
||||
sections.pop()
|
||||
return sections
|
||||
|
||||
|
||||
def quote_area_fee_text(
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str = "",
|
||||
@@ -779,7 +842,11 @@ def quote_area_fee_text(
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
bucket_lines = sea_bucket_fee_lines(src)
|
||||
if bucket_lines:
|
||||
fee_lines = list(bucket_lines)
|
||||
else:
|
||||
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
@@ -910,7 +977,14 @@ def quote_detail_lines(
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
bucket_lines = sea_bucket_fee_lines(src) if (business_line or "").upper() == "SEA" or (
|
||||
transport_mode or ""
|
||||
).strip() == "海运" else []
|
||||
if bucket_lines:
|
||||
lines = list(bucket_lines)
|
||||
else:
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
|
||||
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
|
||||
lines.append(f"时效:{tms_eta_display(src)}")
|
||||
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
|
||||
@@ -2017,12 +2091,12 @@ def group_fields_not_recognized(
|
||||
keys: tuple[str, ...] | None = None,
|
||||
business_line: str = "",
|
||||
) -> str:
|
||||
"""没认出协同字段。陆运列出本单可补项;海运保持原六项口。"""
|
||||
"""没认出协同字段。陆运列出本单可补项;海运列出协同五项(不含贸易条款)。"""
|
||||
if keys:
|
||||
line = (business_line or "").strip().upper() or "SEA"
|
||||
names = [collab_display_name(k, line) for k in keys]
|
||||
return "听到了。请直接说" + "、".join(names) + "。"
|
||||
return "听到了。请直接说贸易条款、货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
return "听到了。请直接说货好时间、商品海关编码、是否含油/含电/含磁。"
|
||||
|
||||
|
||||
def group_deal_stale(*, work_order_no: str) -> str:
|
||||
@@ -2365,6 +2439,17 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||
"""
|
||||
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
||||
没有报价也可以锁舱,禁止改写成「报价选项不存在」或「航班已起飞」。
|
||||
flight_date / today 只为兼容旧调用,不再用来改写原文。
|
||||
"""
|
||||
_ = flight_date
|
||||
_ = today
|
||||
return (tms_reason or "").strip()
|
||||
|
||||
|
||||
def air_release_ok(
|
||||
*,
|
||||
work_order_no: str,
|
||||
@@ -2385,9 +2470,10 @@ def air_release_ok(
|
||||
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
return (
|
||||
f"工单{work_order_no} 空运释放舱位失败\n"
|
||||
f"工单{work_order_no} 空运释放舱位失败。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"释放舱位失败的原因:{why}"
|
||||
f"释放舱位失败的原因:{why}\n"
|
||||
"已通知IT运维排查,请稍等。"
|
||||
)
|
||||
|
||||
|
||||
@@ -2399,3 +2485,246 @@ def air_release_deal_blocked(work_order_no: str) -> str:
|
||||
def air_release_need_lock(work_order_no: str) -> str:
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}还没有有效锁舱,不能释放。"
|
||||
|
||||
|
||||
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
|
||||
TYPE_LLM = "大模型异常"
|
||||
TYPE_TMS = "TMS异常"
|
||||
TYPE_GROUP = "建群异常"
|
||||
|
||||
STEP_LLM_NO_TICKET = "llm_no_ticket"
|
||||
STEP_LLM = "llm"
|
||||
STEP_TMS_QUOTE = "tms_quote"
|
||||
STEP_TMS_CABIN = "tms_cabin"
|
||||
STEP_TMS_LOCK = "tms_lock"
|
||||
STEP_TMS_RELEASE = "tms_release"
|
||||
STEP_GROUP = "group"
|
||||
|
||||
SYS_EXC_LLM_FALLBACK = "大模型服务异常,IT排查中..."
|
||||
SYS_EXC_TMS_FALLBACK = "TMS接口异常,IT运维排查中..."
|
||||
SYS_EXC_RECOVERY_TMS = "TMS已恢复正常使用,感谢您的耐心等待。"
|
||||
SYS_EXC_RECOVERY_LLM = "大模型已恢复正常使用,感谢您的耐心等待。"
|
||||
SYS_EXC_RECOVERY_CABIN = "空运舱位接口已恢复正常使用,感谢您的耐心等待。"
|
||||
SYS_EXC_RECOVERY_GROUP = "建群已恢复正常,感谢您的耐心等待。"
|
||||
|
||||
IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
|
||||
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
|
||||
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
|
||||
IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变"
|
||||
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
|
||||
IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变"
|
||||
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
||||
|
||||
|
||||
def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str:
|
||||
"""判定系统异常后,给卡住的会话的兜底话术。"""
|
||||
kind = (exception_type or "").strip()
|
||||
if kind == TYPE_LLM:
|
||||
return SYS_EXC_LLM_FALLBACK
|
||||
if kind == TYPE_GROUP:
|
||||
people = [str(x).strip() for x in (names or []) if str(x).strip()]
|
||||
if not people:
|
||||
return "无法添加同事,请联系管理员检查企业微信配置。"
|
||||
joined = "、".join(people)
|
||||
return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
|
||||
return SYS_EXC_TMS_FALLBACK
|
||||
|
||||
|
||||
def system_exception_recovery(step: str) -> str:
|
||||
"""后台点重试成功后,先发这一句,再补没做完的事。"""
|
||||
key = (step or "").strip()
|
||||
if key == STEP_LLM or key == STEP_LLM_NO_TICKET:
|
||||
return SYS_EXC_RECOVERY_LLM
|
||||
if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
|
||||
return SYS_EXC_RECOVERY_CABIN
|
||||
if key == STEP_GROUP:
|
||||
return SYS_EXC_RECOVERY_GROUP
|
||||
return SYS_EXC_RECOVERY_TMS
|
||||
|
||||
|
||||
def system_exception_impact(step: str) -> str:
|
||||
"""告警卡「影响范围」,按卡住的步骤。"""
|
||||
key = (step or "").strip()
|
||||
if key == STEP_LLM_NO_TICKET:
|
||||
return IMPACT_LLM_NO_TICKET
|
||||
if key == STEP_LLM:
|
||||
return IMPACT_LLM
|
||||
if key == STEP_TMS_LOCK:
|
||||
return IMPACT_TMS_LOCK
|
||||
if key == STEP_TMS_RELEASE:
|
||||
return IMPACT_TMS_RELEASE
|
||||
if key == STEP_TMS_CABIN:
|
||||
return IMPACT_TMS_CABIN
|
||||
if key == STEP_GROUP:
|
||||
return IMPACT_GROUP
|
||||
return IMPACT_TMS_QUOTE
|
||||
|
||||
|
||||
def system_exception_alert_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
exception_type: str,
|
||||
service: str,
|
||||
error_code: str,
|
||||
step: str,
|
||||
occurred_at: str,
|
||||
test_prefix: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
系统异常通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
|
||||
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "无"
|
||||
_ = test_prefix
|
||||
title = "系统异常通知"
|
||||
rows = [
|
||||
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
|
||||
{"keyname": "接口/服务", "value": (service or "").strip() or "-"},
|
||||
{"keyname": "错误码", "value": (error_code or "").strip() or "-"},
|
||||
{"keyname": "影响范围", "value": system_exception_impact(step)},
|
||||
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
|
||||
]
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "询价机器人系统告警", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": title,
|
||||
"desc": f"关联工单 {no}",
|
||||
},
|
||||
"horizontal_content_list": rows,
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def system_exception_alert_text(
|
||||
*,
|
||||
work_order_no: str,
|
||||
exception_type: str,
|
||||
service: str,
|
||||
error_code: str,
|
||||
step: str,
|
||||
occurred_at: str,
|
||||
test_prefix: bool = False,
|
||||
) -> str:
|
||||
"""告警卡纯文本兜底(通道降级时仍能看懂)。标题同样不加 [test]。"""
|
||||
no = (work_order_no or "").strip() or "无"
|
||||
_ = test_prefix
|
||||
head = "系统异常通知"
|
||||
lines = [
|
||||
"询价机器人系统告警",
|
||||
head,
|
||||
f"关联工单 {no}",
|
||||
f"异常类型:{(exception_type or '').strip() or '-'}",
|
||||
f"接口/服务:{(service or '').strip() or '-'}",
|
||||
f"错误码:{(error_code or '').strip() or '-'}",
|
||||
f"影响范围:{system_exception_impact(step)}",
|
||||
f"发生时间:{(occurred_at or '').strip() or '-'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def event_exception_session_text(
|
||||
*,
|
||||
exception_type: str,
|
||||
work_order_no: str,
|
||||
names: list[str] | None,
|
||||
hours: float,
|
||||
mention: bool,
|
||||
test_prefix: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
事件异常会话两行话术。
|
||||
|
||||
mention=True 时姓名前加 @,多人空格拼接。小时数用后台规则值。
|
||||
测服也不加 [test]:这是销售/航线/产品会话,不是运维告警。
|
||||
"""
|
||||
from agent.policy.event_exception import format_timeout_hours
|
||||
|
||||
_ = test_prefix
|
||||
no = (work_order_no or "").strip() or "-"
|
||||
kind = (exception_type or "").strip() or "事件异常"
|
||||
hour_text = format_timeout_hours(hours)
|
||||
people = [str(n).strip() for n in (names or []) if str(n).strip()]
|
||||
if mention:
|
||||
tagged = " ".join(f"@{n}" for n in people) if people else "相关人员"
|
||||
else:
|
||||
tagged = " ".join(people) if people else "您"
|
||||
body = (
|
||||
f"关联询价工单 {no}。{tagged}已超过 {hour_text} 工作小时 无响应,"
|
||||
f"事件异常已记录工单,请及时处理。"
|
||||
)
|
||||
return f"{kind}\n{body}"
|
||||
|
||||
|
||||
def event_exception_alert_payload(
|
||||
*,
|
||||
card_title: str,
|
||||
work_order_no: str,
|
||||
hours: float,
|
||||
test_prefix: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
事件异常通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
对齐现网「询价机器人事件告警」:标题用响应异常,正文用「您已超过」。
|
||||
测服标题也不加 [test];test_prefix 仅保留兼容入参。
|
||||
"""
|
||||
from agent.policy.event_exception import format_timeout_hours
|
||||
|
||||
_ = test_prefix
|
||||
no = (work_order_no or "").strip() or "-"
|
||||
title = (card_title or "").strip() or "事件响应异常"
|
||||
hour_text = format_timeout_hours(hours)
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
"template_card": {
|
||||
"card_type": "text_notice",
|
||||
"source": {"desc": "询价机器人事件告警", "desc_color": 0},
|
||||
"main_title": {
|
||||
"title": title,
|
||||
"desc": f"关联询价工单 {no}",
|
||||
},
|
||||
"sub_title_text": f"您已超过 {hour_text} 工作小时无响应,事件异常已记录工单,请及时处理。",
|
||||
"card_action": {"type": 1, "url": action_url},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def event_exception_alert_text(
|
||||
*,
|
||||
card_title: str,
|
||||
work_order_no: str,
|
||||
hours: float,
|
||||
test_prefix: bool = False,
|
||||
) -> str:
|
||||
"""应用卡片纯文本兜底。"""
|
||||
payload = event_exception_alert_payload(
|
||||
card_title=card_title,
|
||||
work_order_no=work_order_no,
|
||||
hours=hours,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
card = payload.get("template_card") or {}
|
||||
main = card.get("main_title") or {}
|
||||
lines = [
|
||||
str((card.get("source") or {}).get("desc") or "询价机器人事件告警"),
|
||||
str(main.get("title") or ""),
|
||||
str(main.get("desc") or ""),
|
||||
str(card.get("sub_title_text") or ""),
|
||||
]
|
||||
return "\n".join(x for x in lines if x)
|
||||
|
||||
@@ -123,6 +123,17 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
llm_phase = handle_extract_tech_fail(
|
||||
snap=snap,
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
sender_id=sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
if llm_phase:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
snap_facts = dict(snap.get("facts") or {})
|
||||
facts = dict(snap_facts)
|
||||
@@ -169,6 +180,8 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
if mode == "SEA":
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
# 抽成海运后丢掉陆运选项草稿,下一句补字段不能再发陆运样例图。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_sea_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -182,6 +195,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
)
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_air_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -305,7 +319,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
) or ""
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="LAND")
|
||||
from agent.policy.system_exception import query_tms_resilient
|
||||
|
||||
tms = query_tms_resilient(
|
||||
self._ledger, work_order_no=no, facts=facts, business_line="LAND"
|
||||
)
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -332,7 +350,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
reply(copy.LAND_CONFIRM_EXPIRED)
|
||||
return "land_confirm_expired"
|
||||
reply(copy.LAND_TMS_RETRYING.format(wo=no))
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="LAND")
|
||||
from agent.policy.system_exception import query_tms_resilient
|
||||
|
||||
tms = query_tms_resilient(
|
||||
self._ledger, work_order_no=no, facts=facts, business_line="LAND"
|
||||
)
|
||||
return self._apply_land_tms(sess=sess, tms=tms, reply=reply, retrying=True)
|
||||
|
||||
def _apply_land_tms(
|
||||
@@ -347,6 +369,18 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
_ = retrying
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if not tms.get("ok"):
|
||||
from agent.policy.system_exception import confirm_tms_quote_if_tech
|
||||
|
||||
if confirm_tms_quote_if_tech(
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
tms=tms,
|
||||
sender_id=sess.sender_id,
|
||||
ticket_status="询价中",
|
||||
):
|
||||
self.drop_current_bookmark(sess.sender_id)
|
||||
return "system_exception"
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
海运协同群业务:填表进群、补字段重发、产品报价、附件、解散、成交跟进。
|
||||
|
||||
本文件职责:群里副作用(主账+企微群)。私聊按钮仍走 sea_text_flow.on_button。
|
||||
禁止:改询价七项;终态默默忽略;把销售附件当报价。
|
||||
禁止:改询价十项;终态默默忽略;把销售附件当报价。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -1028,6 +1028,9 @@ def emit_group_deal(
|
||||
)
|
||||
if callable(carder):
|
||||
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
start_wait(getattr(flow, "ledger", None), work_order_no=no, wait_kind=WAIT_DEAL)
|
||||
|
||||
|
||||
def apply_group_deal(
|
||||
@@ -1485,6 +1488,10 @@ def _save_product_quote(
|
||||
saved = writer(work_order_no=no, quote=persist, to_status="已报价") or {}
|
||||
if not saved.get("ok") and saved.get("error"):
|
||||
logger.warning("产品报价未入主账 wo=%s err=%s", no, saved.get("error"))
|
||||
elif (kind or "").strip() != "adjust":
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(flow.ledger, work_order_no=no)
|
||||
if sess is not None:
|
||||
sess.quote = dict(quote)
|
||||
sess.status = "已报价"
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
@@ -41,6 +42,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
"""
|
||||
|
||||
bookmark_kind = "sea"
|
||||
# 价格明细文字发出后稍等再发卡,避免企微先画出卡片。
|
||||
_detail_before_card_gap_sec = 0.8
|
||||
|
||||
def __init__(self, ledger: Any = None, group_client: Any = None) -> None:
|
||||
super().__init__(ledger=ledger)
|
||||
@@ -99,6 +102,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
adjusted = try_private_adjust(self, sess, text, reply)
|
||||
if adjusted:
|
||||
return adjusted
|
||||
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
text,
|
||||
@@ -108,6 +113,17 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
llm_phase = handle_extract_tech_fail(
|
||||
snap=snap,
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
sender_id=sender_id,
|
||||
sess=sess,
|
||||
)
|
||||
if llm_phase:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
@@ -139,6 +155,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if mode == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
# 本句已是陆运:丢掉海运补问草稿,避免下一句又被海运吃掉。
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_land_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -153,6 +171,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
# 空运交给空运 Owner,避免海运流程半做空运
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return get_air_text_flow().on_text(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
@@ -165,6 +184,11 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
inbound_raw=inbound_raw,
|
||||
)
|
||||
|
||||
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
|
||||
|
||||
facts = harvest_sea_class(text, facts)
|
||||
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
|
||||
facts = drop_invented_door_trade_terms(text, facts)
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
facts = dict(check["facts"])
|
||||
tms_ready = assemble_sea_query(facts)
|
||||
@@ -249,7 +273,15 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
) or ""
|
||||
self._wait_inquiry_sent(str(out_id), work_order_no=no)
|
||||
|
||||
tms = self._ledger.query_tms(work_order_no=no, facts=facts, business_line="SEA")
|
||||
tms_facts = dict(assemble_sea_query(facts).get("tms_facts") or facts)
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(
|
||||
self._ledger, work_order_no=no, facts=tms_facts, business_line="SEA"
|
||||
)
|
||||
sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
@@ -262,6 +294,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
wait_version=1,
|
||||
)
|
||||
if not tms.get("ok"):
|
||||
if confirm_tms_quote_if_tech(
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
tms=tms,
|
||||
sender_id=sender_id,
|
||||
ticket_status="询价中",
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
@@ -365,16 +407,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
business_line: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
先发完整价格明细文本,再发带按钮的卡。
|
||||
先发完整价格明细文本,等它真正发出,再发带按钮的卡。
|
||||
|
||||
企微只发卡时,引用条超长会被截断;文本不受限。
|
||||
测服 WO202609200020:文字和卡同一拍入队,销售先看到卡,
|
||||
「详见上一条明细」对不上。只等这一条出站,不锁其它工单。
|
||||
"""
|
||||
line = (business_line or getattr(self, "business_line", "") or "SEA").upper()
|
||||
if line not in {"SEA", "LAND"}:
|
||||
line = "SEA"
|
||||
src = dict(quote or {})
|
||||
src["business_line"] = line
|
||||
reply(
|
||||
out_id = reply(
|
||||
copy.sea_quote_detail_text(
|
||||
work_order_no=work_order_no,
|
||||
quote=src,
|
||||
@@ -383,7 +427,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
status=status,
|
||||
business_line=line,
|
||||
)
|
||||
) or ""
|
||||
self._wait_outbound_sent(
|
||||
str(out_id), work_order_no=work_order_no, what="价格明细文本"
|
||||
)
|
||||
gap = float(getattr(self, "_detail_before_card_gap_sec", 0.8) or 0)
|
||||
if out_id and gap > 0:
|
||||
time.sleep(gap)
|
||||
reply(
|
||||
copy.sea_tms_hit_card(
|
||||
work_order_no=work_order_no,
|
||||
@@ -527,9 +577,29 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
userids.append(uid)
|
||||
|
||||
client = self.group_client()
|
||||
created = client.create_group(name=no, userids=userids) or {}
|
||||
from agent.policy.system_exception import call_with_retries, confirm_group_if_tech
|
||||
|
||||
created = call_with_retries(
|
||||
lambda: client.create_group(name=no, userids=userids) or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok") and (x or {}).get("chat_id")),
|
||||
)
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
st = ""
|
||||
if callable(getter):
|
||||
tic = getter(work_order_no=no)
|
||||
st = str(getattr(tic, "status", "") or (tic.get("status") if isinstance(tic, dict) else "") or "")
|
||||
confirm_group_if_tech(
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
sender_id=sess.sender_id,
|
||||
missing_names=product_names,
|
||||
reason=str(created.get("error") or "企微建群失败"),
|
||||
error_code=str(created.get("error") or "GROUP_CREATE"),
|
||||
ticket_status=st,
|
||||
extra={"userids": userids, "product_names": product_names},
|
||||
)
|
||||
return sess.phase or "wait_collab"
|
||||
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
@@ -600,6 +670,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
sess.wait_version += 1
|
||||
self._save(sess)
|
||||
self._grey_clicked_card(reply, card_meta)
|
||||
from agent.policy.event_exception import WAIT_PRODUCT_QUOTE, start_wait
|
||||
|
||||
# 系统建群成功:开始盯产品正式报价。
|
||||
start_wait(self._ledger, work_order_no=no, wait_kind=WAIT_PRODUCT_QUOTE)
|
||||
reply(
|
||||
copy.sea_group_created(
|
||||
work_order_no=no,
|
||||
@@ -685,9 +759,32 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if uid not in userids:
|
||||
userids.append(uid)
|
||||
client = self.group_client()
|
||||
created = client.create_group(name=display_no, userids=userids) or {}
|
||||
from agent.policy.system_exception import call_with_retries, confirm_group_if_tech
|
||||
|
||||
created = call_with_retries(
|
||||
lambda: client.create_group(name=display_no, userids=userids) or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok") and (x or {}).get("chat_id")),
|
||||
)
|
||||
if not created.get("ok") or not created.get("chat_id"):
|
||||
reply(copy.sea_group_create_fail(reason=str(created.get("error") or "")))
|
||||
real = (sess.work_order_no or "").strip()
|
||||
mark_no = real if real.upper().startswith("WO") else ""
|
||||
st = ""
|
||||
if mark_no:
|
||||
getter = getattr(self._ledger, "get_ticket", None)
|
||||
if callable(getter):
|
||||
tic = getter(work_order_no=mark_no)
|
||||
st = str(getattr(tic, "status", "") or (tic.get("status") if isinstance(tic, dict) else "") or "")
|
||||
confirm_group_if_tech(
|
||||
ledger=self._ledger,
|
||||
reply=reply,
|
||||
work_order_no=mark_no,
|
||||
sender_id=sess.sender_id,
|
||||
missing_names=product_names,
|
||||
reason=str(created.get("error") or "企微建群失败"),
|
||||
error_code=str(created.get("error") or "GROUP_CREATE"),
|
||||
ticket_status=st,
|
||||
extra={"userids": userids, "product_names": product_names, "display_no": display_no},
|
||||
)
|
||||
return sess.phase or "handoff_offer"
|
||||
chat_id = str(created.get("chat_id") or "").strip()
|
||||
brief = copy.handoff_group_brief(
|
||||
@@ -718,6 +815,9 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
product_ids=product_ids,
|
||||
)
|
||||
sess.status = HANDOFF_STATUS
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(self._ledger, work_order_no=real)
|
||||
sess.collab_chat_id = chat_id
|
||||
sess.handoff_committed = True
|
||||
sess.handoff_offer = ""
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
"""
|
||||
系统异常(UC024):技术故障判定、默默重试、工单两列、会话兜底、运维告警、后台重试。
|
||||
|
||||
本文件职责:只处理大模型 / TMS / 建群加人的超时、报错、空结果。
|
||||
禁止:把无报价、字段不齐、没配人当成系统异常;禁止为记异常去建空工单;
|
||||
禁止在回调线程等完整 LLM / 查 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
TYPE_LLM = copy.TYPE_LLM
|
||||
TYPE_TMS = copy.TYPE_TMS
|
||||
TYPE_GROUP = copy.TYPE_GROUP
|
||||
STEP_LLM_NO_TICKET = copy.STEP_LLM_NO_TICKET
|
||||
STEP_LLM = copy.STEP_LLM
|
||||
STEP_TMS_QUOTE = copy.STEP_TMS_QUOTE
|
||||
STEP_TMS_CABIN = copy.STEP_TMS_CABIN
|
||||
STEP_GROUP = copy.STEP_GROUP
|
||||
|
||||
MAX_AUTO_RETRIES = 3
|
||||
|
||||
EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
ReplyFn = Callable[[str], Any]
|
||||
OkFn = Callable[[Any], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemExceptionEvent:
|
||||
"""一次已确认的系统异常(自动重试 3 次仍失败之后)。"""
|
||||
|
||||
exception_type: str
|
||||
step: str
|
||||
reason: str
|
||||
service: str
|
||||
error_code: str
|
||||
work_order_no: str = ""
|
||||
ticket_status: str = ""
|
||||
conversation_kind: str = "private"
|
||||
conversation_target: str = ""
|
||||
missing_names: list[str] = field(default_factory=list)
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def is_tms_tech_failure(tms: dict[str, Any] | None) -> bool:
|
||||
"""
|
||||
TMS 技术故障:接口失败/超时。
|
||||
|
||||
正常返回但没报价、字段不齐未出站,都不算。
|
||||
"""
|
||||
row = tms or {}
|
||||
if row.get("ok"):
|
||||
return False
|
||||
cls = str(row.get("classification") or "").strip()
|
||||
if cls in {"TMS_NO_QUOTE_1002", "TMS_QUERY_INCOMPLETE"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def should_mark_ticket(*, work_order_no: str, ticket_status: str) -> bool:
|
||||
"""有正式工单且不是已关闭,才写两列。"""
|
||||
if not (work_order_no or "").strip():
|
||||
return False
|
||||
return (ticket_status or "").strip() != "已关闭"
|
||||
|
||||
|
||||
def ticket_exception_type(ticket: Any) -> str:
|
||||
"""从主账工单(对象或字典)读当前系统异常类型。"""
|
||||
if ticket is None:
|
||||
return ""
|
||||
if isinstance(ticket, dict):
|
||||
return str(
|
||||
ticket.get("system_exception")
|
||||
or ticket.get("systemException")
|
||||
or ""
|
||||
).strip()
|
||||
return str(getattr(ticket, "system_exception", "") or "").strip()
|
||||
|
||||
|
||||
def ticket_is_paused(ticket: Any) -> bool:
|
||||
"""工单两列有值即暂停这一票。"""
|
||||
return bool(ticket_exception_type(ticket))
|
||||
|
||||
|
||||
def extract_work_order_nos(text: str) -> list[str]:
|
||||
"""从原话抽出正式工单号(WO + 日期流水)。不认虚拟号。"""
|
||||
import re
|
||||
|
||||
found = re.findall(r"\bWO\d{8,}\b", text or "", flags=re.IGNORECASE)
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for raw in found:
|
||||
no = raw.upper()
|
||||
if no in seen:
|
||||
continue
|
||||
seen.add(no)
|
||||
out.append(no)
|
||||
return out
|
||||
|
||||
|
||||
def call_with_retries(
|
||||
fn: Callable[[], Any],
|
||||
*,
|
||||
is_ok: OkFn,
|
||||
attempts: int = MAX_AUTO_RETRIES,
|
||||
) -> Any:
|
||||
"""
|
||||
默默重试。中途成功立刻返回;会话里不说「正在重试」。
|
||||
|
||||
调用方在 Worker 槽使用,禁止放进企微回调线程。
|
||||
"""
|
||||
last: Any = None
|
||||
times = max(1, int(attempts))
|
||||
for idx in range(times):
|
||||
try:
|
||||
last = fn()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("系统异常自动重试捕获异常 n=%s/%s err=%s", idx + 1, times, exc)
|
||||
last = {"ok": False, "error": str(exc), "classification": "TECH_EXCEPTION"}
|
||||
continue
|
||||
if is_ok(last):
|
||||
return last
|
||||
logger.info("系统异常自动重试未成功 n=%s/%s", idx + 1, times)
|
||||
return last
|
||||
|
||||
|
||||
def _now_text(now: datetime | None) -> str:
|
||||
stamp = now or datetime.now(_SHANGHAI)
|
||||
return stamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _test_prefix() -> bool:
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
return (get_settings().ytd_env or "").strip().lower() == "test"
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def notify_app_ready() -> bool:
|
||||
"""询价消息服务已配才推告警。"""
|
||||
from agent.policy.deal_outcome import notify_app_ready as deal_ready
|
||||
|
||||
return deal_ready()
|
||||
|
||||
|
||||
def _enqueue_default(
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str,
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[bool, str]:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
return get_message_store().enqueue_outbound(
|
||||
touser=touser,
|
||||
content=content,
|
||||
dedupe_key=dedupe_key,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def _list_notify_users(ledger: Any) -> list[dict[str, Any]]:
|
||||
lister = getattr(ledger, "list_exception_notify_users", None)
|
||||
if not callable(lister):
|
||||
return []
|
||||
try:
|
||||
rows = list(lister() or [])
|
||||
except Exception:
|
||||
logger.exception("列出系统异常通知人失败")
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def confirm_system_exception(
|
||||
*,
|
||||
event: SystemExceptionEvent,
|
||||
ledger: Any = None,
|
||||
reply: Optional[ReplyFn] = None,
|
||||
enqueue: Optional[EnqueueFn] = None,
|
||||
notify_ready: Optional[bool] = None,
|
||||
now: Optional[datetime] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
3 次仍失败后:回话、有单则写两列、推有权限的人。
|
||||
|
||||
无正式工单:不建单、不 mark。已关闭:不 mark。没有可推的人:照样回话。
|
||||
"""
|
||||
no = (event.work_order_no or "").strip()
|
||||
if reply is not None:
|
||||
reply(copy.system_exception_fallback(event.exception_type, names=event.missing_names))
|
||||
|
||||
marked = False
|
||||
if should_mark_ticket(work_order_no=no, ticket_status=event.ticket_status) and ledger is not None:
|
||||
marker = getattr(ledger, "mark_system_exception", None)
|
||||
if callable(marker):
|
||||
payload = {
|
||||
"step": event.step,
|
||||
"conversation_kind": event.conversation_kind,
|
||||
"conversation_target": event.conversation_target,
|
||||
"missing_names": list(event.missing_names),
|
||||
"service": event.service,
|
||||
"error_code": event.error_code,
|
||||
"extra": dict(event.extra or {}),
|
||||
}
|
||||
try:
|
||||
result = marker(
|
||||
work_order_no=no,
|
||||
system_exception=event.exception_type,
|
||||
system_exception_reason=(event.reason or "").strip() or event.exception_type,
|
||||
step=event.step,
|
||||
payload=payload,
|
||||
) or {}
|
||||
marked = bool(result.get("ok", True))
|
||||
except Exception:
|
||||
logger.exception("写入系统异常失败 wo=%s", no)
|
||||
marked = False
|
||||
|
||||
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
|
||||
notified = 0
|
||||
users = _list_notify_users(ledger) if ledger is not None else []
|
||||
if ready and users:
|
||||
occurred = _now_text(now)
|
||||
test_prefix = _test_prefix()
|
||||
payload = copy.system_exception_alert_payload(
|
||||
work_order_no=no,
|
||||
exception_type=event.exception_type,
|
||||
service=event.service,
|
||||
error_code=event.error_code,
|
||||
step=event.step,
|
||||
occurred_at=occurred,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
text = copy.system_exception_alert_text(
|
||||
work_order_no=no,
|
||||
exception_type=event.exception_type,
|
||||
service=event.service,
|
||||
error_code=event.error_code,
|
||||
step=event.step,
|
||||
occurred_at=occurred,
|
||||
test_prefix=test_prefix,
|
||||
)
|
||||
sender = enqueue or _enqueue_default
|
||||
for row in users:
|
||||
uid = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
|
||||
if not uid:
|
||||
continue
|
||||
try:
|
||||
ok, info = sender(
|
||||
touser=uid,
|
||||
content=text,
|
||||
dedupe_key=f"sys-exc:{no or 'none'}:{event.step}:{uid}:{occurred}",
|
||||
payload=dict(payload),
|
||||
)
|
||||
if ok:
|
||||
notified += 1
|
||||
else:
|
||||
logger.warning("系统异常告警入队失败 uid=%s info=%s", uid, info)
|
||||
except Exception:
|
||||
logger.exception("系统异常告警入队异常 uid=%s", uid)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"marked": marked,
|
||||
"notified": notified,
|
||||
"work_order_no": no,
|
||||
"exception_type": event.exception_type,
|
||||
"step": event.step,
|
||||
}
|
||||
|
||||
|
||||
def clear_system_exception(*, ledger: Any, work_order_no: str) -> dict[str, Any]:
|
||||
"""重试成功后清两列。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no:
|
||||
return {"ok": False, "error": "blank"}
|
||||
clearer = getattr(ledger, "clear_system_exception", None)
|
||||
if not callable(clearer):
|
||||
return {"ok": False, "error": "no_clear"}
|
||||
return clearer(work_order_no=no) or {"ok": False}
|
||||
|
||||
|
||||
def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None:
|
||||
"""销售/群提到暂停工单号:只重复兜底,不重跑。"""
|
||||
kind = ticket_exception_type(ticket) or TYPE_TMS
|
||||
names: list[str] = []
|
||||
if isinstance(ticket, dict):
|
||||
payload = ticket.get("system_exception_payload") or ticket.get("systemExceptionPayload") or {}
|
||||
if isinstance(payload, dict):
|
||||
raw = payload.get("missing_names") or []
|
||||
names = [str(x).strip() for x in raw if str(x).strip()]
|
||||
else:
|
||||
payload = getattr(ticket, "system_exception_payload", {}) or {}
|
||||
if isinstance(payload, dict):
|
||||
raw = payload.get("missing_names") or []
|
||||
names = [str(x).strip() for x in raw if str(x).strip()]
|
||||
reply(copy.system_exception_fallback(kind, names=names))
|
||||
|
||||
|
||||
def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
"""TMS 查价:最多 3 次,ok=true(含无价)即成功。"""
|
||||
getter = getattr(ledger, "query_tms", None)
|
||||
if not callable(getter):
|
||||
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
|
||||
out = call_with_retries(
|
||||
lambda: getter(**kwargs),
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
|
||||
|
||||
def confirm_tms_quote_if_tech(
|
||||
*,
|
||||
ledger: Any,
|
||||
reply: ReplyFn,
|
||||
work_order_no: str,
|
||||
tms: dict[str, Any],
|
||||
sender_id: str,
|
||||
ticket_status: str = "询价中",
|
||||
facts: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。"""
|
||||
if not is_tms_tech_failure(tms):
|
||||
return False
|
||||
err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip()
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_QUOTE,
|
||||
reason="TMS /quote/v2 接口调用失败",
|
||||
service="TMS报价",
|
||||
error_code=err or "TMS_TECH_FAILURE",
|
||||
work_order_no=work_order_no,
|
||||
ticket_status=ticket_status,
|
||||
conversation_kind="private",
|
||||
conversation_target=sender_id,
|
||||
extra={"facts": dict(facts or tms.get("facts") or {})},
|
||||
),
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def confirm_group_if_tech(
|
||||
*,
|
||||
ledger: Any,
|
||||
reply: ReplyFn,
|
||||
work_order_no: str,
|
||||
sender_id: str,
|
||||
missing_names: list[str],
|
||||
reason: str,
|
||||
error_code: str,
|
||||
ticket_status: str = "",
|
||||
extra: dict[str, Any] | None = None,
|
||||
chat_id: str = "",
|
||||
) -> None:
|
||||
"""建群或加人技术失败(已重试 3 次)后确认异常。"""
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_GROUP,
|
||||
step=STEP_GROUP,
|
||||
reason=(reason or "").strip() or "企微建群/加人失败",
|
||||
service="企微建群" if not chat_id else "企微加人",
|
||||
error_code=(error_code or "GROUP_FAIL").strip(),
|
||||
work_order_no=work_order_no,
|
||||
ticket_status=ticket_status or "已报价",
|
||||
conversation_kind="private",
|
||||
conversation_target=sender_id,
|
||||
missing_names=list(missing_names),
|
||||
extra=dict(extra or {}),
|
||||
),
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
|
||||
def confirm_llm_if_tech(
|
||||
*,
|
||||
ledger: Any,
|
||||
reply: ReplyFn,
|
||||
sender_id: str,
|
||||
reason: str,
|
||||
work_order_no: str = "",
|
||||
ticket_status: str = "",
|
||||
conversation_kind: str = "private",
|
||||
conversation_target: str = "",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""大模型超时/空/报错。无工单不建单。"""
|
||||
no = (work_order_no or "").strip()
|
||||
step = STEP_LLM if no else STEP_LLM_NO_TICKET
|
||||
return confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_LLM,
|
||||
step=step,
|
||||
reason=(reason or "").strip() or "模型返回空结果",
|
||||
service="大模型识别",
|
||||
error_code="EMPTY" if "空" in (reason or "") else "LLM_FAIL",
|
||||
work_order_no=no,
|
||||
ticket_status=ticket_status,
|
||||
conversation_kind=conversation_kind,
|
||||
conversation_target=conversation_target or sender_id,
|
||||
extra=dict(extra or {}),
|
||||
),
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
|
||||
def handle_extract_tech_fail(
|
||||
*,
|
||||
snap: dict[str, Any],
|
||||
ledger: Any,
|
||||
reply: ReplyFn,
|
||||
sender_id: str,
|
||||
sess: Any = None,
|
||||
) -> str:
|
||||
"""抽字段模型超时/报错:标大模型异常并回话。返回阶段名,未失败返回空串。"""
|
||||
if not isinstance(snap, dict) or not snap.get("tech_fail"):
|
||||
return ""
|
||||
wo = str(getattr(sess, "work_order_no", "") or "") if sess is not None else ""
|
||||
st = str(getattr(sess, "status", "") or "") if sess is not None else ""
|
||||
confirm_llm_if_tech(
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
sender_id=sender_id,
|
||||
reason=str(snap.get("tech_error") or "模型返回空结果"),
|
||||
work_order_no=wo,
|
||||
ticket_status=st,
|
||||
)
|
||||
return "system_exception"
|
||||
|
||||
|
||||
def _send_to_conversation(*, kind: str, target: str, text: str, extra: dict[str, Any] | None = None) -> str:
|
||||
"""恢复/兜底发到卡住的会话:私聊走 outbox,群走 appchat。可带卡片 extra。"""
|
||||
body = (text or "").strip()
|
||||
dest = (target or "").strip()
|
||||
if not body or not dest:
|
||||
return ""
|
||||
if (kind or "").strip() == "group":
|
||||
try:
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
client = WeComAppClient(
|
||||
corp_id=s.wecom_corp_id,
|
||||
secret=s.wecom_secret,
|
||||
agent_id=int(s.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
client.send_group(chat_id=dest, content=body)
|
||||
return dest
|
||||
except Exception:
|
||||
logger.exception("系统异常群回话失败 chat=%s", dest)
|
||||
return ""
|
||||
try:
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
ok, info = get_message_store().enqueue_outbound(
|
||||
touser=dest,
|
||||
content=body,
|
||||
dedupe_key=f"sys-exc-talk:{dest}:{hash(body) & 0xFFFF}",
|
||||
payload=dict(extra or {}),
|
||||
)
|
||||
return str(info or "") if ok else ""
|
||||
except Exception:
|
||||
logger.exception("系统异常私聊回话失败 uid=%s", dest)
|
||||
return ""
|
||||
|
||||
|
||||
def run_admin_retry(work_order_no: str, *, ledger: Any = None) -> dict[str, Any]:
|
||||
"""
|
||||
后台点重试:只重跑失败步。成功先发恢复话术再继续;再失败再标再告警。
|
||||
|
||||
在 Worker HTTP 槽调用,禁止放进回调线程。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
book = ledger
|
||||
if book is None:
|
||||
from agent.policy.air_text_flow import build_ledger
|
||||
|
||||
book = build_ledger()
|
||||
getter = getattr(book, "get_ticket", None)
|
||||
ticket = getter(work_order_no=no) if callable(getter) else None
|
||||
if ticket is None:
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
ticket = getter(work_order_no=no, sender_id="") if callable(getter) else None
|
||||
if not ticket_is_paused(ticket):
|
||||
return {"ok": False, "error": "not_paused"}
|
||||
if isinstance(ticket, dict):
|
||||
step = str(ticket.get("system_exception_step") or "").strip()
|
||||
payload = ticket.get("system_exception_payload") or {}
|
||||
status = str(ticket.get("status") or "")
|
||||
facts = dict(ticket.get("facts") or {})
|
||||
line = str(ticket.get("business_line") or ticket.get("businessLine") or "")
|
||||
else:
|
||||
step = str(getattr(ticket, "system_exception_step", "") or "").strip()
|
||||
payload = dict(getattr(ticket, "system_exception_payload", {}) or {})
|
||||
status = str(getattr(ticket, "status", "") or "")
|
||||
facts = dict(getattr(ticket, "facts", {}) or {})
|
||||
line = str(getattr(ticket, "business_line", "") or "")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
kind = str(payload.get("conversation_kind") or "private")
|
||||
target = str(payload.get("conversation_target") or "")
|
||||
extra = dict(payload.get("extra") or {})
|
||||
if extra.get("facts") and not facts:
|
||||
facts = {str(k): str(v) for k, v in dict(extra.get("facts") or {}).items()}
|
||||
line = (line or str(extra.get("business_line") or "")).upper()
|
||||
|
||||
def reply(text: str, extra_msg: dict[str, Any] | None = None) -> str:
|
||||
return _send_to_conversation(kind=kind, target=target, text=text, extra=extra_msg)
|
||||
|
||||
if step == STEP_TMS_QUOTE or not step:
|
||||
tms = query_tms_resilient(book, work_order_no=no, facts=facts, business_line=line)
|
||||
if is_tms_tech_failure(tms):
|
||||
confirm_tms_quote_if_tech(
|
||||
ledger=book,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
tms=tms,
|
||||
sender_id=target,
|
||||
ticket_status=status,
|
||||
facts=facts,
|
||||
)
|
||||
return {"ok": False, "error": "still_fail", "step": STEP_TMS_QUOTE}
|
||||
reply(copy.system_exception_recovery(STEP_TMS_QUOTE))
|
||||
clear_system_exception(ledger=book, work_order_no=no)
|
||||
sender = target if kind != "group" else str(extra.get("sender_id") or "")
|
||||
_resume_tms_quote(
|
||||
ledger=book,
|
||||
work_order_no=no,
|
||||
facts=facts,
|
||||
line=line,
|
||||
sender_id=sender,
|
||||
tms=tms,
|
||||
reply=reply,
|
||||
)
|
||||
return {"ok": True, "step": STEP_TMS_QUOTE}
|
||||
|
||||
if step == STEP_GROUP:
|
||||
extra = dict(payload.get("extra") or extra)
|
||||
userids = [str(x).strip() for x in (extra.get("userids") or []) if str(x).strip()]
|
||||
chat_id = str(extra.get("chat_id") or payload.get("chat_id") or "").strip()
|
||||
try:
|
||||
from agent.channel.outbox.sender import WeComAppClient
|
||||
from agent.config import get_settings
|
||||
|
||||
s = get_settings()
|
||||
client = WeComAppClient(
|
||||
corp_id=s.wecom_corp_id,
|
||||
secret=s.wecom_secret,
|
||||
agent_id=int(s.wecom_agent_id or "0") or 0,
|
||||
)
|
||||
if chat_id and userids:
|
||||
result = call_with_retries(
|
||||
lambda: client.add_group_members(chat_id=chat_id, userids=userids) or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
elif userids:
|
||||
name = no or str(extra.get("display_no") or "询价协同")
|
||||
result = call_with_retries(
|
||||
lambda: client.create_group(name=name, userids=userids) or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok") and (x or {}).get("chat_id")),
|
||||
)
|
||||
else:
|
||||
result = {"ok": False, "error": "no_users"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = {"ok": False, "error": str(exc)}
|
||||
if not (result or {}).get("ok"):
|
||||
confirm_group_if_tech(
|
||||
ledger=book,
|
||||
reply=reply,
|
||||
work_order_no=no,
|
||||
sender_id=target,
|
||||
missing_names=[str(x) for x in (payload.get("missing_names") or extra.get("product_names") or [])],
|
||||
reason=str((result or {}).get("error") or "企微建群/加人失败"),
|
||||
error_code=str((result or {}).get("error") or "GROUP_FAIL"),
|
||||
ticket_status=status,
|
||||
extra=extra,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
return {"ok": False, "error": "still_fail", "step": STEP_GROUP}
|
||||
new_chat = str((result or {}).get("chat_id") or chat_id).strip()
|
||||
binder = getattr(book, "bind_collab_group", None)
|
||||
if new_chat and callable(binder) and not chat_id:
|
||||
binder(
|
||||
work_order_no=no,
|
||||
chat_id=new_chat,
|
||||
member_ids=userids,
|
||||
product_names=[str(x) for x in (extra.get("product_names") or [])],
|
||||
product_ids=[str(x) for x in (extra.get("product_ids") or [])],
|
||||
)
|
||||
reply(copy.system_exception_recovery(STEP_GROUP))
|
||||
clear_system_exception(ledger=book, work_order_no=no)
|
||||
return {"ok": True, "step": STEP_GROUP}
|
||||
|
||||
if step in {STEP_LLM, STEP_LLM_NO_TICKET}:
|
||||
reply(copy.system_exception_recovery(STEP_LLM))
|
||||
if no:
|
||||
clear_system_exception(ledger=book, work_order_no=no)
|
||||
reply("请把刚才那句话再发一次。")
|
||||
return {"ok": True, "step": step}
|
||||
|
||||
if step == STEP_TMS_CABIN:
|
||||
return _resume_cabin_step(
|
||||
ledger=book,
|
||||
work_order_no=no,
|
||||
extra=extra,
|
||||
target=target,
|
||||
status=status,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
return {"ok": False, "error": "unknown_step", "step": step}
|
||||
|
||||
|
||||
def _flow_for_line(line: str) -> Any:
|
||||
"""按业务线取私聊流程,用来重试后把报价卡/书签补回去。"""
|
||||
key = (line or "").upper()
|
||||
if key == "LAND":
|
||||
from agent.policy.land_text_flow import get_land_text_flow
|
||||
|
||||
return get_land_text_flow()
|
||||
if key == "AIR":
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
return get_air_text_flow()
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
return get_sea_text_flow()
|
||||
|
||||
|
||||
def _resume_tms_quote(
|
||||
*,
|
||||
ledger: Any,
|
||||
work_order_no: str,
|
||||
facts: dict[str, Any],
|
||||
line: str,
|
||||
sender_id: str,
|
||||
tms: dict[str, Any],
|
||||
reply: ReplyFn,
|
||||
) -> None:
|
||||
"""重试查价成功:写入报价并按原流程出卡,销售能继续拉群。"""
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
|
||||
no = (work_order_no or "").strip()
|
||||
biz = (line or "SEA").upper()
|
||||
if biz not in {"SEA", "LAND", "AIR"}:
|
||||
biz = "SEA"
|
||||
flow = _flow_for_line(biz)
|
||||
sender = (sender_id or "").strip()
|
||||
if not tms.get("has_price"):
|
||||
miss = getattr(flow, "_emit_tms_miss", None)
|
||||
if callable(miss):
|
||||
miss(reply, no)
|
||||
else:
|
||||
reply(f"工单{no}暂无 TMS 报价。")
|
||||
if sender:
|
||||
sess = FlowSession(
|
||||
sender_id=sender,
|
||||
thread_id=sender,
|
||||
phase="tms_miss",
|
||||
business_line=biz,
|
||||
facts=dict(facts or {}),
|
||||
work_order_no=no,
|
||||
status="询价中",
|
||||
allowed=(copy.BTN_PULL_COLLAB,) if biz != "AIR" else (),
|
||||
wait_version=1,
|
||||
)
|
||||
flow._save(sess)
|
||||
return
|
||||
quote = dict(tms.get("quote") or {})
|
||||
up = ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
|
||||
if not up.get("ok"):
|
||||
reply("主账写入报价失败,不能当作已报价。")
|
||||
return
|
||||
allowed = (copy.BTN_SKIP_COLLAB,) if biz == "AIR" else (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
|
||||
if sender:
|
||||
sess = FlowSession(
|
||||
sender_id=sender,
|
||||
thread_id=sender,
|
||||
phase="wait_collab",
|
||||
business_line=biz,
|
||||
facts=dict(facts or {}),
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
status="已报价",
|
||||
allowed=allowed,
|
||||
wait_version=1,
|
||||
)
|
||||
copy.remember_tms_quote(sess, quote)
|
||||
flow._save(sess)
|
||||
if biz == "AIR":
|
||||
reply(
|
||||
copy.tms_hit_card(work_order_no=no, quote=quote, status="已报价"),
|
||||
copy.tms_hit_wecom_payload(work_order_no=no, quote=quote, status="已报价"),
|
||||
)
|
||||
return
|
||||
emit = getattr(flow, "_emit_sea_hit", None)
|
||||
if callable(emit):
|
||||
emit(
|
||||
reply,
|
||||
work_order_no=no,
|
||||
quote=quote,
|
||||
status="已报价",
|
||||
business_line=biz,
|
||||
)
|
||||
return
|
||||
reply(f"工单{no}已重新查到报价。")
|
||||
|
||||
|
||||
def _resume_cabin_step(
|
||||
*,
|
||||
ledger: Any,
|
||||
work_order_no: str,
|
||||
extra: dict[str, Any],
|
||||
target: str,
|
||||
status: str,
|
||||
reply: ReplyFn,
|
||||
) -> dict[str, Any]:
|
||||
"""重试空运锁舱或释放:payload 里有 lock_id 就是释放。"""
|
||||
no = (work_order_no or "").strip()
|
||||
lock_id = str(extra.get("lock_id") or "").strip()
|
||||
option_no = str(extra.get("option_no") or "").strip() or "AIR-OPT-01"
|
||||
instruction = str(extra.get("instruction") or "")
|
||||
sender = str(extra.get("sender_id") or target or "").strip()
|
||||
if lock_id:
|
||||
releaser = getattr(ledger, "release_cabin", None)
|
||||
out = call_with_retries(
|
||||
lambda: releaser(
|
||||
work_order_no=no,
|
||||
lock_id=lock_id,
|
||||
sender_id=sender,
|
||||
instruction_text=instruction,
|
||||
)
|
||||
or {}
|
||||
if callable(releaser)
|
||||
else {"ok": False, "error": "no_release"},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
service = "空运舱位"
|
||||
reason = "空运释放舱位接口调用失败"
|
||||
else:
|
||||
locker = getattr(ledger, "lock_cabin", None)
|
||||
out = call_with_retries(
|
||||
lambda: locker(
|
||||
work_order_no=no,
|
||||
option_no=option_no,
|
||||
sender_id=sender,
|
||||
instruction_text=instruction,
|
||||
)
|
||||
or {}
|
||||
if callable(locker)
|
||||
else {"ok": False, "error": "no_lock"},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
service = "空运锁舱"
|
||||
reason = "空运锁舱接口调用失败"
|
||||
if not (out or {}).get("ok"):
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_CABIN,
|
||||
reason=reason,
|
||||
service=service,
|
||||
error_code=str((out or {}).get("failReason") or (out or {}).get("error") or "CABIN_FAIL"),
|
||||
work_order_no=no,
|
||||
ticket_status=status,
|
||||
conversation_kind="group",
|
||||
conversation_target=target,
|
||||
extra=dict(extra),
|
||||
),
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
)
|
||||
return {"ok": False, "error": "still_fail", "step": STEP_TMS_CABIN}
|
||||
reply(copy.system_exception_recovery(STEP_TMS_CABIN))
|
||||
clear_system_exception(ledger=ledger, work_order_no=no)
|
||||
if not lock_id:
|
||||
exec_no = str((out or {}).get("lockId") or (out or {}).get("tmsRequestId") or "").strip()
|
||||
patcher = getattr(ledger, "patch_collab_facts", None)
|
||||
if exec_no and callable(patcher):
|
||||
patcher(
|
||||
work_order_no=no,
|
||||
facts={"__air_lock_id": exec_no, "__air_lock_option": option_no, "__pending_air_lock": ""},
|
||||
sender_id=sender,
|
||||
)
|
||||
reply(copy.air_lock_ok(work_order_no=no, operator="同事", option_no=option_no, exec_no=exec_no, quote_no=""))
|
||||
else:
|
||||
reply(copy.air_release_ok(work_order_no=no, operator="同事", option_no="", exec_no=str((out or {}).get("releaseId") or lock_id), quote_no=""))
|
||||
return {"ok": True, "step": STEP_TMS_CABIN}
|
||||
@@ -123,6 +123,36 @@ def _wrap_route_failed(message: InboundMessage) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _wrap_route_model_failed(message: InboundMessage) -> None:
|
||||
"""路由模型超时/报错/空结果:大模型异常(无工单不建单)。"""
|
||||
from agent.policy.system_exception import confirm_llm_if_tech
|
||||
from agent.ledger.http_ledger import HttpLedger
|
||||
from agent.config import get_settings
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
def reply(text: str) -> None:
|
||||
get_message_store().enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=text,
|
||||
dedupe_key=f"route-llm:{message.message_id}",
|
||||
)
|
||||
|
||||
ledger = None
|
||||
try:
|
||||
if (get_settings().ledger_backend or "").lower() != "memory":
|
||||
ledger = HttpLedger()
|
||||
except Exception:
|
||||
ledger = None
|
||||
confirm_llm_if_tech(
|
||||
ledger=ledger,
|
||||
reply=reply,
|
||||
sender_id=message.sender_id,
|
||||
reason="模型返回空结果",
|
||||
conversation_kind="group" if getattr(message, "chat_id", "") else "private",
|
||||
conversation_target=getattr(message, "chat_id", "") or message.sender_id,
|
||||
)
|
||||
|
||||
|
||||
# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林
|
||||
HANDLER_REGISTRY: dict[str, HandlerFn] = {
|
||||
"echo_text": _wrap_echo,
|
||||
@@ -142,7 +172,7 @@ HANDLER_REGISTRY: dict[str, HandlerFn] = {
|
||||
"reject_no_sender": _noop,
|
||||
"ignore_empty": _noop,
|
||||
"stub_needs_model": _wrap_echo,
|
||||
"route_model_failed": _wrap_route_failed,
|
||||
"route_model_failed": _wrap_route_model_failed,
|
||||
"route_parse_failed": _wrap_route_failed,
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
client = LlmHttpClient.from_settings(settings)
|
||||
system = (
|
||||
"你是海运协同群意图分类。只回复一个英文词,不要解释。"
|
||||
"collab_fields=在补贸易条款/货好时间/商品海关编码/含油含电含磁;"
|
||||
"collab_fields=在补货好时间/商品海关编码/含油含电含磁;"
|
||||
"quote_adjust=销售在调已有费用,口令不写死(上升、上浮、往上抬、加一成、减、改到某金额都算),不是新产品报价;"
|
||||
"product_quote=产品在报价格或费用(含具体金额);"
|
||||
"quote_confirm_tms=产品确认沿用当前TMS报价,没有报新金额;"
|
||||
|
||||
@@ -32,7 +32,7 @@ AIR_REQUIRED_FIELDS = (
|
||||
)
|
||||
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
|
||||
|
||||
# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。
|
||||
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
|
||||
SEA_REQUIRED_FIELDS = (
|
||||
("起运港", "起运港"),
|
||||
("目的港", "目的港"),
|
||||
@@ -40,6 +40,8 @@ SEA_REQUIRED_FIELDS = (
|
||||
("货量", "货物数量"),
|
||||
("整柜或拼柜", "整柜或拼柜"),
|
||||
("箱型箱量", "箱型箱量"),
|
||||
("贸易条款", "贸易条款"),
|
||||
("运输分类", "运输分类"),
|
||||
("报价日期", "报价日期"),
|
||||
)
|
||||
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
|
||||
@@ -57,8 +59,8 @@ LAND_DISPLAY = {
|
||||
}
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
|
||||
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
|
||||
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
|
||||
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
|
||||
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
|
||||
SEA_COLLAB_DISPLAY = {
|
||||
@@ -141,11 +143,11 @@ _LAND_COLLAB_BY_PAIR: dict[tuple[str, str], tuple[str, ...]] = {
|
||||
"是否为危险品",
|
||||
),
|
||||
}
|
||||
# 意图分类用:海运六项 ∪ 陆运全部协同键。
|
||||
# 意图分类用:海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键。
|
||||
ALL_COLLAB_KEYS = tuple(
|
||||
dict.fromkeys(
|
||||
list(SEA_COLLAB_FIELDS)
|
||||
+ ["客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"]
|
||||
+ ["贸易条款", "客户名称", "包装方式", "货值", "是否为危险品", "车型/数量"]
|
||||
)
|
||||
)
|
||||
|
||||
@@ -317,7 +319,7 @@ def land_collab_fields(land_type: str, route: str) -> tuple[str, ...]:
|
||||
"""
|
||||
本单陆运协同内部键,顺序与规格一致。
|
||||
|
||||
对不上填写样例返回空元组,调用方不得按海运六项去列。
|
||||
对不上填写样例返回空元组,调用方不得按海运协同五项去列。
|
||||
海关编码内部仍是 HS编码。副作用:无。
|
||||
"""
|
||||
from agent.schema.land_options import canonicalize_route, canonicalize_transport_type
|
||||
@@ -485,6 +487,13 @@ def validate_required_fields(
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
from agent.schema.sea_options import canonicalize_sea_class
|
||||
|
||||
klass = canonicalize_sea_class(str(normalized.get("运输分类") or ""))
|
||||
if klass:
|
||||
normalized["运输分类"] = klass
|
||||
else:
|
||||
normalized.pop("运输分类", None)
|
||||
if line not in {"SEA", "AIR", "LAND"}:
|
||||
return {
|
||||
"ok": False,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
|
||||
本文件职责:从原话收成标准词、生成补问清单。
|
||||
禁止:发明第三项;把分类带去 TMS;改六态。
|
||||
线程:纯函数,可在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SEA_CLASSES = ("Port to Port", "其他")
|
||||
|
||||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||||
_ALIASES = (
|
||||
("port to port", "Port to Port"),
|
||||
("港到港", "Port to Port"),
|
||||
("p2p", "Port to Port"),
|
||||
("其它", "其他"),
|
||||
("其他", "其他"),
|
||||
)
|
||||
|
||||
|
||||
def canonicalize_sea_class(raw: str) -> str:
|
||||
"""
|
||||
把销售原话收成 Port to Port 或 其他;对不上返回空串。
|
||||
|
||||
「门到门」不算。先匹配更长别名。副作用:无。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
for needle, canon in _ALIASES:
|
||||
if needle in lowered or needle in text:
|
||||
return canon
|
||||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||||
if stripped in SEA_CLASSES:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
当前句能认则覆盖;认不出则保留已有标准值。
|
||||
|
||||
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
|
||||
非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
"""
|
||||
merged = dict(current or {})
|
||||
found = canonicalize_sea_class(text or "")
|
||||
if found:
|
||||
merged["运输分类"] = found
|
||||
return merged
|
||||
kept = canonicalize_sea_class(str(merged.get("运输分类") or ""))
|
||||
if kept:
|
||||
merged["运输分类"] = kept
|
||||
else:
|
||||
merged.pop("运输分类", None)
|
||||
return merged
|
||||
|
||||
|
||||
# 运输分类用语,不能冒充贸易条款。销售原话写了「贸易条款」才保留。
|
||||
_DOOR_TRADE_TERMS = frozenset(
|
||||
{
|
||||
"门到门",
|
||||
"door to door",
|
||||
"door-to-door",
|
||||
"doortodoor",
|
||||
"d2d",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
丢掉冒充贸易条款的「门到门」。
|
||||
|
||||
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
|
||||
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
|
||||
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
trade = str(merged.get("贸易条款") or "").strip()
|
||||
if not trade:
|
||||
return merged
|
||||
if "贸易条款" in (text or ""):
|
||||
return merged
|
||||
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
|
||||
merged.pop("贸易条款", None)
|
||||
return merged
|
||||
|
||||
|
||||
def sea_class_prompt() -> str:
|
||||
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
|
||||
return "运输分类(必填):请回复 Port to Port 或 其他。"
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
海运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
|
||||
|
||||
本文件职责:询价卡片出现前,确认海运七项能拼出查价正文。
|
||||
本文件职责:询价卡片出现前,确认海运十项里查出站项能拼出查价正文。
|
||||
港口只带名称,不做港口字典对应;箱型箱量保持原文字段,containerItems 只透传模型投影。
|
||||
贸易条款必带出站;运输分类只留在工单 facts,不进 tms_facts。
|
||||
禁止:本地拆箱型;因对不上港口字典跳过 TMS;改六态。
|
||||
"""
|
||||
|
||||
@@ -62,6 +63,8 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
load = _load_type(str(src.get("整柜或拼柜") or ""))
|
||||
box = str(src.get("箱型箱量") or "").strip()
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
trade = str(src.get("贸易条款") or src.get("trade_terms") or src.get("tradeTerms") or "").strip()
|
||||
klass = str(src.get("运输分类") or "").strip()
|
||||
|
||||
missing: list[str] = []
|
||||
missing_display: list[str] = []
|
||||
@@ -72,6 +75,7 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
("货量", "货物数量", qty),
|
||||
("整柜或拼柜", "整柜或拼柜", load),
|
||||
("箱型箱量", "箱型箱量", box),
|
||||
("贸易条款", "贸易条款", trade),
|
||||
("报价日期", "报价日期", quote_date),
|
||||
)
|
||||
for key, label, val in pairs:
|
||||
@@ -93,12 +97,18 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
out_facts["整柜或拼柜"] = load
|
||||
if box:
|
||||
out_facts["箱型箱量"] = box
|
||||
if trade:
|
||||
out_facts["贸易条款"] = trade
|
||||
if klass:
|
||||
out_facts["运输分类"] = klass
|
||||
if quote_date:
|
||||
out_facts["报价日期"] = quote_date
|
||||
items = parse_container_items(src)
|
||||
if items:
|
||||
out_facts["containerItems"] = items
|
||||
|
||||
tms_facts = dict(out_facts)
|
||||
tms_facts.pop("运输分类", None)
|
||||
logger.info(
|
||||
"tms.sea.assemble ok=%s origin=%s dest=%s load=%s missing=%s",
|
||||
ok,
|
||||
@@ -112,4 +122,5 @@ def assemble_sea_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"missing": missing,
|
||||
"missing_display": missing_display,
|
||||
"facts": out_facts,
|
||||
"tms_facts": tms_facts,
|
||||
}
|
||||
|
||||
@@ -205,6 +205,9 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo1)
|
||||
self.assertEqual(ticket.event_wait_kind, "air_quote")
|
||||
self.assertTrue(ticket.event_wait_started_at)
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn(f"工单号:{wo1}", joined)
|
||||
self.assertIn("询价字段:", joined)
|
||||
@@ -386,6 +389,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.event_wait_kind, "deal")
|
||||
self.assertTrue(ticket.event_wait_started_at)
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 900")
|
||||
joined = "\n".join(self._texts())
|
||||
|
||||
@@ -47,6 +47,8 @@ SEA_ORAL = """海运
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
贸易条款:FOB
|
||||
运输分类:Port to Port
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from agent.policy.deal_followup import (
|
||||
leader_matches,
|
||||
next_followup_at,
|
||||
shift_workdays,
|
||||
workday_elapsed_hours,
|
||||
)
|
||||
|
||||
|
||||
@@ -37,6 +38,22 @@ class TestWorkdays(unittest.TestCase):
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 24))
|
||||
|
||||
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) # 周一
|
||||
# 周五 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) # 周一
|
||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 10.0, places=4)
|
||||
|
||||
def test_fractional_quarter_hour(self) -> None:
|
||||
start = datetime(2026, 9, 18, 9, 0, tzinfo=SHANGHAI)
|
||||
now = datetime(2026, 9, 18, 9, 15, tzinfo=SHANGHAI)
|
||||
self.assertAlmostEqual(workday_elapsed_hours(start, now), 0.25, places=4)
|
||||
|
||||
|
||||
class TestAirRouteKeys(unittest.TestCase):
|
||||
def test_pair_and_dest(self) -> None:
|
||||
|
||||
@@ -67,6 +67,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "已成交")
|
||||
self.assertEqual(ticket.next_followup_at, "")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
|
||||
def test_deal_notify_card_omits_sales_label(self) -> None:
|
||||
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
|
||||
@@ -126,6 +127,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "未成交")
|
||||
self.assertEqual(ticket.lost_reason, "")
|
||||
self.assertEqual(ticket.event_wait_kind, "lost_reason")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
|
||||
captured = capture_lost_reason(
|
||||
@@ -134,6 +136,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertTrue(captured.get("ok"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.lost_reason, "客人比价了")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "done")
|
||||
|
||||
@@ -150,6 +153,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertNotIn("跟进周期已顺延", text)
|
||||
|
||||
def test_negotiate_keeps_wait_deal(self) -> None:
|
||||
self.ledger.start_event_wait(work_order_no=self.wo, wait_kind="deal")
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
@@ -161,6 +165,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
self.assertTrue(ticket.next_followup_at)
|
||||
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertEqual(view.get("wait_phase"), "wait_deal")
|
||||
@@ -245,3 +250,39 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["work_order_no"], self.wo)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
|
||||
|
||||
def test_reask_starts_deal_wait(self) -> None:
|
||||
"""协商再问发出后,重新盯销售成交跟进。"""
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__), "..", "agent", "jobs", "deal_followup_poll.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("deal_followup_poll_ee", path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
class Cfg:
|
||||
wecom_corp_id = ""
|
||||
wecom_secret = ""
|
||||
wecom_agent_id = "0"
|
||||
|
||||
mod._send_group_reask = lambda **kwargs: True
|
||||
ok = mod._reask_one(
|
||||
{
|
||||
"work_order_no": self.wo,
|
||||
"collab_chat_id": "wr1",
|
||||
"sales_name": "吴吉林",
|
||||
"facts": {},
|
||||
"quote": {},
|
||||
"business_line": "SEA",
|
||||
},
|
||||
settings=Cfg(),
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.event_wait_kind, "deal")
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
事件异常纯规则:超时编码、点名对象、卡片接收人(不打外网)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.event_exception import (
|
||||
WAIT_ADOPT,
|
||||
WAIT_AIR_QUOTE,
|
||||
WAIT_DEAL,
|
||||
WAIT_PRODUCT_QUOTE,
|
||||
conversation_of,
|
||||
format_timeout_hours,
|
||||
pick_app_targets,
|
||||
pick_mentions,
|
||||
timeout_code,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeoutCode(unittest.TestCase):
|
||||
def test_sales_unified(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_ADOPT, business_line="SEA", transport_class="急件"),
|
||||
"sea_sales",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_DEAL, business_line="AIR", transport_class=""),
|
||||
"air_sales",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind="lost_reason", business_line="LAND", transport_class="急件"),
|
||||
"land_sales",
|
||||
)
|
||||
|
||||
def test_air_route(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_AIR_QUOTE, business_line="AIR", transport_class=""),
|
||||
"air_route",
|
||||
)
|
||||
|
||||
def test_sea_and_land_product_class(self) -> None:
|
||||
self.assertEqual(
|
||||
timeout_code(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="Port to Port"
|
||||
),
|
||||
"sea_product_ptp",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class="其他"),
|
||||
"sea_product_other",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
|
||||
"land_hk_express",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
|
||||
"",
|
||||
)
|
||||
|
||||
def test_hours_format(self) -> None:
|
||||
self.assertEqual(format_timeout_hours(0.25), "0.25")
|
||||
self.assertEqual(format_timeout_hours(48), "48")
|
||||
|
||||
|
||||
class TestMentions(unittest.TestCase):
|
||||
def test_product_all_unanswered(self) -> None:
|
||||
sales = {
|
||||
"wecomId": "s1",
|
||||
"name": "销售甲",
|
||||
"roleCode": "sales",
|
||||
"notifyEventException": 1,
|
||||
}
|
||||
members = [
|
||||
{"wecomId": "p1", "name": "张三", "roleCode": "sea", "notifyEventException": 1},
|
||||
{"wecomId": "p2", "name": "李四", "roleCode": "sea", "notifyEventException": 0},
|
||||
{"wecomId": "s1", "name": "销售甲", "roleCode": "sales", "notifyEventException": 1},
|
||||
]
|
||||
hit = pick_mentions(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE,
|
||||
sales=sales,
|
||||
members=members,
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual([p["name"] for p in hit], ["张三", "李四"])
|
||||
apps = pick_app_targets(hit)
|
||||
self.assertEqual([p["wecomId"] for p in apps], ["p1"])
|
||||
|
||||
def test_adopt_is_private(self) -> None:
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_ADOPT, collab_chat_id="wrxxx"), "private")
|
||||
|
||||
def test_deal_uses_group_when_present(self) -> None:
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id="wrxxx"), "group")
|
||||
self.assertEqual(conversation_of(wait_kind=WAIT_DEAL, collab_chat_id=""), "private")
|
||||
|
||||
|
||||
class TestMemoryEventWait(unittest.TestCase):
|
||||
def test_same_kind_keeps_start(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
|
||||
book = MemoryLedger()
|
||||
created = book.create_ticket(sender_id="s1", business_line="SEA", facts={"运输分类": "其他"})
|
||||
wo = created["work_order_no"]
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="product_quote")
|
||||
first = book.get_ticket(work_order_no=wo).event_wait_started_at
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="product_quote")
|
||||
self.assertEqual(book.get_ticket(work_order_no=wo).event_wait_started_at, first)
|
||||
|
||||
def test_record_appends_and_alerts(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
|
||||
book = MemoryLedger()
|
||||
created = book.create_ticket(sender_id="s1", business_line="AIR", facts={})
|
||||
wo = created["work_order_no"]
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="air_quote")
|
||||
book.record_event_exception(work_order_no=wo, event_exception="航线响应超时")
|
||||
book.record_event_exception(work_order_no=wo, event_exception="销售响应超时")
|
||||
t = book.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(t.event_exception, "航线响应超时;销售响应超时")
|
||||
self.assertEqual(t.event_wait_alerted, 1)
|
||||
self.assertEqual(book.list_event_waits(), [])
|
||||
|
||||
|
||||
class TestEventExceptionCopy(unittest.TestCase):
|
||||
def test_group_air(self) -> None:
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type="航线响应超时",
|
||||
work_order_no="WO202606180003",
|
||||
names=["张三"],
|
||||
hours=0.25,
|
||||
mention=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"航线响应超时\n关联询价工单 WO202606180003。@张三已超过 0.25 工作小时 无响应,事件异常已记录工单,请及时处理。",
|
||||
)
|
||||
|
||||
def test_group_two_names(self) -> None:
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type="产品响应超时",
|
||||
work_order_no="WO1",
|
||||
names=["张三", "李四"],
|
||||
hours=2,
|
||||
mention=True,
|
||||
)
|
||||
self.assertIn("@张三 @李四已超过 2 工作小时", text)
|
||||
|
||||
def test_private_sales_no_at(self) -> None:
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type="销售响应超时",
|
||||
work_order_no="WO1",
|
||||
names=["王五"],
|
||||
hours=48,
|
||||
mention=False,
|
||||
)
|
||||
self.assertIn("王五已超过 48 工作小时", text)
|
||||
self.assertNotIn("@王五", text)
|
||||
|
||||
def test_test_prefix(self) -> None:
|
||||
"""测服会话和应用卡片都不加 [test]。"""
|
||||
text = copy.event_exception_session_text(
|
||||
exception_type="销售响应超时",
|
||||
work_order_no="WO1",
|
||||
names=["王五"],
|
||||
hours=1,
|
||||
mention=False,
|
||||
test_prefix=True,
|
||||
)
|
||||
self.assertFalse(text.startswith("[test]"))
|
||||
self.assertNotIn("[test]", text)
|
||||
payload = copy.event_exception_alert_payload(
|
||||
card_title="销售响应异常",
|
||||
work_order_no="WO1",
|
||||
hours=1,
|
||||
test_prefix=True,
|
||||
)
|
||||
title = (payload.get("template_card") or {}).get("main_title", {}).get("title") or ""
|
||||
self.assertEqual(title, "销售响应异常")
|
||||
self.assertNotIn("[test]", title)
|
||||
|
||||
def test_app_card(self) -> None:
|
||||
payload = copy.event_exception_alert_payload(
|
||||
card_title="销售响应异常",
|
||||
work_order_no="WO202609090199",
|
||||
hours=48,
|
||||
)
|
||||
self.assertEqual(payload["wecom_app"], "notify")
|
||||
card = payload["template_card"]
|
||||
self.assertEqual(card["source"]["desc"], "询价机器人事件告警")
|
||||
self.assertEqual(card["main_title"]["title"], "销售响应异常")
|
||||
self.assertEqual(card["main_title"]["desc"], "关联询价工单 WO202609090199")
|
||||
self.assertIn("您已超过 48 工作小时无响应", card["sub_title_text"])
|
||||
|
||||
|
||||
class TestPoll(unittest.TestCase):
|
||||
def test_fires_once_on_timeout(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", path)
|
||||
self.assertIsNotNone(spec)
|
||||
self.assertIsNotNone(spec.loader)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
poll_event_exceptions = mod.poll_event_exceptions
|
||||
|
||||
book = MemoryLedger()
|
||||
book.set_sea_staff(
|
||||
[
|
||||
{
|
||||
"wecomId": "p1",
|
||||
"name": "张三",
|
||||
"roleCode": "sea",
|
||||
"notifyEventException": 1,
|
||||
"status": "active",
|
||||
}
|
||||
]
|
||||
)
|
||||
created = book.create_ticket(sender_id="s1", business_line="SEA", facts={"运输分类": "其他"})
|
||||
wo = created["work_order_no"]
|
||||
book.start_event_wait(work_order_no=wo, wait_kind="product_quote")
|
||||
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"
|
||||
)
|
||||
book._timeouts["sea_product_other"] = 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
|
||||
|
||||
now = datetime.now(SHANGHAI)
|
||||
n = poll_event_exceptions(
|
||||
ledger=book, now=now, send_group=send_group, notify_ready=False
|
||||
)
|
||||
self.assertEqual(n, 1)
|
||||
self.assertEqual(book.get_ticket(work_order_no=wo).event_exception, "产品响应超时")
|
||||
self.assertTrue(groups)
|
||||
self.assertIn("@张三", groups[0]["content"])
|
||||
n2 = poll_event_exceptions(
|
||||
ledger=book, now=now, send_group=send_group, notify_ready=False
|
||||
)
|
||||
self.assertEqual(n2, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -45,6 +45,8 @@ SEA_ORAL = """海运
|
||||
货量:20吨
|
||||
整柜或拼柜:整柜
|
||||
箱型箱量:1x40HQ
|
||||
贸易条款:FOB
|
||||
运输分类:Port to Port
|
||||
"""
|
||||
|
||||
LAND_ORAL = """陆运
|
||||
|
||||
@@ -25,7 +25,7 @@ from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.land_text_flow import get_land_text_flow, reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import reset_sea_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow, reset_sea_text_flow_for_test
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
from tests.test_sea_text_inquiry import COMPLETE_SEA
|
||||
@@ -183,27 +183,31 @@ class LandDispatchTests(unittest.TestCase):
|
||||
last = list(store._outbox.values())[-1].content
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
|
||||
def test_tms_retry_misrouted_as_other(self) -> None:
|
||||
def test_tms_tech_fail_pauses_until_admin_retry(self) -> None:
|
||||
from agent.policy.inquiry_copy import SYS_EXC_TMS_FALLBACK
|
||||
from tests.test_land_text_inquiry import CountingLedger
|
||||
|
||||
class Flaky(CountingLedger):
|
||||
class AlwaysFail(CountingLedger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.query_calls = 0
|
||||
|
||||
def query_tms(self, **kwargs):
|
||||
self.query_calls += 1
|
||||
if self.query_calls == 1:
|
||||
return {
|
||||
"ok": False,
|
||||
"classification": "TMS_TECH_FAILURE",
|
||||
"has_price": False,
|
||||
"quote": {},
|
||||
}
|
||||
return super().query_tms(**kwargs)
|
||||
return {
|
||||
"ok": False,
|
||||
"classification": "TMS_TECH_FAILURE",
|
||||
"has_price": False,
|
||||
"quote": {},
|
||||
}
|
||||
|
||||
land = reset_land_text_flow_for_test()
|
||||
land.ledger = Flaky()
|
||||
ledger = AlwaysFail()
|
||||
land.ledger = ledger
|
||||
get_sea_text_flow().ledger = ledger
|
||||
from agent.policy.air_text_flow import get_air_text_flow
|
||||
|
||||
get_air_text_flow().ledger = ledger
|
||||
facts = {
|
||||
"起运港": "广州",
|
||||
"目的港": "深圳",
|
||||
@@ -231,18 +235,27 @@ class LandDispatchTests(unittest.TestCase):
|
||||
InboundMessage(sender_id="ld5", message_id="m-l5c", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
self.assertEqual(get_land_text_flow().session_of("ld5").phase, "tms_tech")
|
||||
wo = get_land_text_flow().session_of("ld5").work_order_no
|
||||
self.assertGreaterEqual(ledger.query_calls, 3)
|
||||
wo = ledger.last_created_no
|
||||
ticket = ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.system_exception, "TMS异常")
|
||||
bodies = "\n".join(i.content for i in self.store._outbox.values())
|
||||
self.assertIn(SYS_EXC_TMS_FALLBACK, bodies)
|
||||
self.assertIsNone(get_land_text_flow().session_of("ld5"))
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="ld5", message_id="m-l5d", content="确定"),
|
||||
RouteDecision(intent="ordinary_text_other"),
|
||||
)
|
||||
self.assertEqual(name, "ordinary_text_inquiry")
|
||||
self.assertEqual(name, "ordinary_text_other")
|
||||
sess = get_land_text_flow().session_of("ld5")
|
||||
self.assertEqual(sess.phase, "wait_collab")
|
||||
self.assertEqual(sess.work_order_no, wo)
|
||||
if sess is not None:
|
||||
self.assertNotEqual(sess.phase, "wait_collab")
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="ld5", message_id="m-l5e", content=f"看看 {wo}"),
|
||||
store=self.store,
|
||||
)
|
||||
last = list(self.store._outbox.values())[-1].content
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
self.assertIn(SYS_EXC_TMS_FALLBACK, last)
|
||||
|
||||
def test_sea_still_creates_immediately(self) -> None:
|
||||
msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价")
|
||||
@@ -323,6 +336,55 @@ class LandDispatchTests(unittest.TestCase):
|
||||
self.assertNotIn("你好,我是询价机器人", last)
|
||||
self.assertIn("请说明要调哪一项费用", last)
|
||||
|
||||
def test_sea_field_supplement_not_stolen_by_stale_land(self) -> None:
|
||||
"""
|
||||
旧陆运选项草稿不能抢走海运补问。
|
||||
|
||||
测服:先陆运缺选项,再发海运缺贸易条款/运输分类,下一句只补这两项,
|
||||
不能回陆运「参数提取不全」或填写样例图。
|
||||
"""
|
||||
store = get_message_store()
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="mix-sea", message_id="m-land-old", content="陆运询价"),
|
||||
injected_facts={"起运港": "广州", "目的港": "深圳", "品名": "衣服"},
|
||||
injected_mode="LAND",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(get_land_text_flow().session_of("mix-sea").phase, "need_land_options")
|
||||
incomplete = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="mix-sea", message_id="m-sea-ask", content="海运询价"),
|
||||
injected_facts=incomplete,
|
||||
injected_mode="SEA",
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertEqual(get_sea_text_flow().session_of("mix-sea").phase, "clarify")
|
||||
self.assertIsNone(get_land_text_flow().session_of("mix-sea"))
|
||||
before = len(store._outbox)
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(
|
||||
sender_id="mix-sea",
|
||||
message_id="m-sea-fill",
|
||||
content="贸易条款:FOB 运输分类:其他",
|
||||
),
|
||||
injected_facts={"贸易条款": "FOB", "运输分类": "其他"},
|
||||
store=store,
|
||||
)
|
||||
self.assertNotEqual(phase, "need_land_options")
|
||||
self.assertEqual(get_sea_text_flow().session_of("mix-sea").business_line, "SEA")
|
||||
added = "\n".join(i.content for i in list(store._outbox.values())[before:])
|
||||
self.assertNotIn("参数提取不全", added)
|
||||
self.assertNotIn("填写样例", added)
|
||||
self.assertNotIn("运输类型", added)
|
||||
|
||||
def test_clarify_short_reply_misrouted_as_other_stays_on_inquiry(self) -> None:
|
||||
"""补问阶段回「111」即使被标闲聊,也不能发开场白。"""
|
||||
store = get_message_store()
|
||||
|
||||
@@ -333,13 +333,7 @@ class LandTextConfirmTests(unittest.TestCase):
|
||||
injected_mode="LAND",
|
||||
)
|
||||
phase = flow.on_text(sender_id="u1", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "tms_tech")
|
||||
self.assertEqual(ledger.create_calls, 1)
|
||||
wo = ledger.last_created_no
|
||||
replies.clear()
|
||||
flow._by_sender.clear()
|
||||
flow._by_ticket.clear()
|
||||
phase = flow.on_text(sender_id="u1", text="确定", reply=reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertEqual(ledger.create_calls, 1)
|
||||
self.assertEqual(ledger.query_calls, 2)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.schema.sea_options import (
|
||||
canonicalize_sea_class,
|
||||
drop_invented_door_trade_terms,
|
||||
harvest_sea_class,
|
||||
sea_class_prompt,
|
||||
)
|
||||
|
||||
|
||||
class SeaOptionsTest(unittest.TestCase):
|
||||
def test_port_to_port_aliases(self):
|
||||
self.assertEqual(canonicalize_sea_class("Port to Port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
|
||||
|
||||
def test_other_alias(self):
|
||||
self.assertEqual(canonicalize_sea_class("其他"), "其他")
|
||||
self.assertEqual(canonicalize_sea_class("其它"), "其他")
|
||||
|
||||
def test_door_to_door_rejected(self):
|
||||
self.assertEqual(canonicalize_sea_class("门到门"), "")
|
||||
|
||||
def test_harvest_keeps_current_when_text_has_none(self):
|
||||
got = harvest_sea_class("上海到洛杉矶整柜", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "Port to Port")
|
||||
|
||||
def test_harvest_overrides_from_text(self):
|
||||
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
|
||||
def test_door_to_door_not_kept_as_trade_terms(self):
|
||||
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
self.assertNotIn("贸易条款", got)
|
||||
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
|
||||
self.assertEqual(kept["贸易条款"], "门到门")
|
||||
|
||||
def test_prompt_lists_two_options_only(self):
|
||||
text = sea_class_prompt()
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertNotIn("门到门", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,8 +28,9 @@ from agent.handlers.group_collab import handle_group_collab
|
||||
from agent.handlers.text_inquiry import handle_text_inquiry
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
from agent.schema.field_validate import SEA_COLLAB_FIELDS, validate_required_fields
|
||||
from agent.schema.tms_sea_query import assemble_sea_query
|
||||
|
||||
|
||||
@@ -40,9 +41,20 @@ COMPLETE_SEA = {
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
|
||||
|
||||
class SeaCollabContractTests(unittest.TestCase):
|
||||
def test_collab_no_longer_asks_trade_terms(self):
|
||||
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
|
||||
self.assertEqual(
|
||||
SEA_COLLAB_FIELDS,
|
||||
("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"),
|
||||
)
|
||||
|
||||
|
||||
class FakeGroupClient:
|
||||
"""单测假企微建群:不打外网。"""
|
||||
|
||||
@@ -123,6 +135,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"件数": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertTrue(check["ok"])
|
||||
@@ -136,6 +150,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"品名": "茶叶",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
@@ -157,7 +173,164 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("报价日期:(如", text)
|
||||
self.assertIn("报价日期:", text)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_seven_fields(self) -> None:
|
||||
def test_sea_requires_trade_terms_and_class(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
self.assertIn("贸易条款", check["missing"])
|
||||
self.assertIn("运输分类", check["missing"])
|
||||
self.assertIn("贸易条款", check["missing_display"])
|
||||
self.assertIn("运输分类", check["missing_display"])
|
||||
|
||||
def test_sea_rejects_door_to_door_class(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "门到门",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertFalse(check["ok"])
|
||||
self.assertIn("运输分类", check["missing"])
|
||||
|
||||
def test_sea_canonicalizes_p2p(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "到岸",
|
||||
"运输分类": "p2p",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertTrue(check["ok"])
|
||||
self.assertEqual(check["facts"]["运输分类"], "Port to Port")
|
||||
|
||||
def test_inquiry_card_lists_ten_fields(self) -> None:
|
||||
lines = copy.inquiry_field_lines(
|
||||
work_order_no="WO202609200001",
|
||||
facts={
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
"报价日期": "2026-09-20",
|
||||
},
|
||||
transport_mode="海运",
|
||||
)
|
||||
text = "\n".join(lines)
|
||||
self.assertIn("工单号:WO202609200001", text)
|
||||
self.assertIn("贸易条款:FOB", text)
|
||||
self.assertIn("运输分类:Port to Port", text)
|
||||
self.assertLess(text.index("箱型箱量"), text.index("贸易条款"))
|
||||
self.assertLess(text.index("贸易条款"), text.index("运输分类"))
|
||||
|
||||
def test_clarify_asks_class_as_two_options(self) -> None:
|
||||
text = copy.ask_clarify(
|
||||
facts={
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "服装",
|
||||
"货量": "1柜",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
},
|
||||
missing_keys=["运输分类"],
|
||||
transport_mode="海运",
|
||||
)
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertIn("贸易条款:FOB", text)
|
||||
|
||||
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts.pop("贸易条款")
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-miss",
|
||||
text="海运缺条款",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.assertIn("贸易条款", "\n".join(self.replies))
|
||||
self.assertFalse((self.flow.session_of("s-miss").work_order_no or "").startswith("WO"))
|
||||
|
||||
def test_leftover_door_to_door_not_written_as_trade_terms(self) -> None:
|
||||
"""旧草稿里的门到门不能写到确认卡贸易条款。测服 WO202609200013。"""
|
||||
leftover = {
|
||||
"起运港": "纽约港",
|
||||
"目的港": "巴生西港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"贸易条款": "门到门",
|
||||
}
|
||||
self.flow._save(
|
||||
FlowSession(
|
||||
sender_id="s-door",
|
||||
thread_id="s-door",
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=leftover,
|
||||
)
|
||||
)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-door",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
|
||||
reply=self._reply,
|
||||
injected_facts=dict(leftover),
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn("贸易条款:门到门", joined)
|
||||
self.assertIn("贸易条款", joined)
|
||||
self.assertNotEqual(self.flow.session_of("s-door").facts.get("贸易条款"), "门到门")
|
||||
|
||||
def test_harvest_class_from_reply_then_quotes(self) -> None:
|
||||
first = dict(COMPLETE_SEA)
|
||||
first.pop("运输分类")
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-class",
|
||||
text="先缺分类",
|
||||
reply=self._reply,
|
||||
injected_facts=first,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-class",
|
||||
text="Port to Port",
|
||||
reply=self._reply,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("运输分类:Port to Port", joined)
|
||||
self.assertIn("贸易条款:FOB", joined)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
text="海运完整",
|
||||
@@ -173,6 +346,8 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("货物数量:20吨", joined)
|
||||
self.assertIn("整柜或拼柜:整柜", joined)
|
||||
self.assertIn("箱型箱量:1x40HQ", joined)
|
||||
self.assertIn("贸易条款:FOB", joined)
|
||||
self.assertIn("运输分类:Port to Port", joined)
|
||||
self.assertIn("正在查询TMS报价", joined)
|
||||
self.assertNotIn("件数:", joined.split("正在查询TMS报价")[0])
|
||||
self.assertNotIn("本轮先开通空运", joined)
|
||||
@@ -180,6 +355,44 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(sess.business_line, "SEA")
|
||||
self.assertEqual(sess.status, "已报价")
|
||||
|
||||
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
|
||||
"""上一单已出号,新一轮不能带上旧贸易条款。"""
|
||||
first = self.flow.on_text(
|
||||
sender_id="s-round",
|
||||
text="海运第一轮",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(first, "wait_collab")
|
||||
old_no = self.flow.session_of("s-round").work_order_no
|
||||
self.assertTrue((old_no or "").startswith("WO"))
|
||||
self.replies.clear()
|
||||
incomplete = {
|
||||
"起运港": "纽约港",
|
||||
"目的港": "巴生西港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-round",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
|
||||
reply=self._reply,
|
||||
injected_facts=incomplete,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn("贸易条款:FOB", joined)
|
||||
self.assertNotIn("贸易条款:门到门", joined)
|
||||
self.assertIn("贸易条款", joined)
|
||||
sess = self.flow.session_of("s-round")
|
||||
self.assertNotEqual(sess.facts.get("贸易条款"), "FOB")
|
||||
self.assertNotEqual(sess.work_order_no, old_no)
|
||||
|
||||
def test_handler_routes_sea_not_air_deferred(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
@@ -214,11 +427,60 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def test_assemble_sea_query_does_not_need_air_measures(self) -> None:
|
||||
ready = assemble_sea_query(COMPLETE_SEA)
|
||||
ready = assemble_sea_query({**COMPLETE_SEA, "贸易条款": "FOB", "运输分类": "Port to Port"})
|
||||
self.assertTrue(ready["ok"])
|
||||
self.assertEqual(ready["missing"], [])
|
||||
self.assertNotIn("件数", ready.get("missing") or [])
|
||||
|
||||
def test_assemble_requires_trade_terms_not_class(self) -> None:
|
||||
src = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
missing = assemble_sea_query(src)
|
||||
self.assertFalse(missing["ok"])
|
||||
self.assertIn("贸易条款", missing["missing"])
|
||||
self.assertNotIn("运输分类", missing["missing"])
|
||||
|
||||
src["贸易条款"] = "FOB"
|
||||
ready = assemble_sea_query(src)
|
||||
self.assertTrue(ready["ok"])
|
||||
self.assertEqual(ready["tms_facts"]["贸易条款"], "FOB")
|
||||
self.assertNotIn("运输分类", ready["tms_facts"])
|
||||
self.assertEqual(ready["facts"]["运输分类"], "Port to Port")
|
||||
|
||||
def test_sea_quote_text_uses_three_buckets(self) -> None:
|
||||
quote = {
|
||||
"total": "6952.77",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS 标准报价",
|
||||
"polCostItems": [
|
||||
{"name": "操作费", "amount": "1020", "currency": "CNY"},
|
||||
{"name": "舱单", "amount": "50", "currency": "CNY"},
|
||||
],
|
||||
"oceanCostItems": [{"name": "海运费", "amount": "575", "currency": "USD"}],
|
||||
"podCostItems": [
|
||||
{"name": "文件费", "amount": "450", "currency": "CNY"},
|
||||
{"name": "设备费", "amount": "30", "currency": "CNY"},
|
||||
],
|
||||
"fee_total_polCostItems": "1070",
|
||||
"fee_total_oceanCostItems": "4038",
|
||||
"fee_total_podCostItems": "470",
|
||||
}
|
||||
text = copy.sea_quote_detail_text(work_order_no="WO202609200002", quote=quote)
|
||||
self.assertIn("始发港费用\n操作费:CNY 1020", text)
|
||||
self.assertIn("始发港费用小计:CNY 1070", text)
|
||||
self.assertIn("海运费:USD 575", text)
|
||||
self.assertIn("海运费小计:CNY 4038", text)
|
||||
self.assertIn("目的港费用小计:CNY 470", text)
|
||||
self.assertIn("总成本价:CNY 6952.77", text)
|
||||
self.assertNotIn("时效", text)
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
"""海运多项费用:完整明细必须在文本里,卡片引用条不得半截。"""
|
||||
long_quote = {
|
||||
@@ -273,6 +535,37 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.assertIn("时效:1天", sub)
|
||||
self.assertIn("报价有效期:2026-09-30", sub)
|
||||
|
||||
def test_sea_hit_sends_detail_text_before_card(self) -> None:
|
||||
"""
|
||||
有价必须先发出价格明细文字,再发卡。
|
||||
|
||||
测服 WO202609200020:卡上写「详见上一条明细」,销售却先看到卡。
|
||||
"""
|
||||
steps: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
kind = "card" if extra else "text"
|
||||
steps.append(kind)
|
||||
return f"out-{len(steps)}"
|
||||
|
||||
def wait(out_id: str, *, work_order_no: str, what: str) -> None:
|
||||
steps.append(f"wait:{out_id}")
|
||||
|
||||
self.flow._wait_outbound_sent = wait # type: ignore[method-assign]
|
||||
self.flow._detail_before_card_gap_sec = 0.0
|
||||
self.flow._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no="WO202609200020",
|
||||
quote={
|
||||
"total": "14298.53",
|
||||
"currency": "CNY",
|
||||
"source_label": "TMS标准报价",
|
||||
"fee_lines": [{"name": "操作费", "amount": "1220", "currency": "CNY"}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(steps[:3], ["text", "wait:out-1", "card"])
|
||||
self.assertIn("card", steps)
|
||||
|
||||
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s1",
|
||||
@@ -329,9 +622,12 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "tms_tech")
|
||||
self.assertIn("TMS 查询失败", "\n".join(self.replies))
|
||||
self.assertEqual(phase, "system_exception")
|
||||
self.assertIn("TMS接口异常,IT运维排查中", "\n".join(self.replies))
|
||||
self.assertNotIn(copy.BTN_PULL_COLLAB, "\n".join(self.replies))
|
||||
tickets = list(self.flow._ledger._tickets.values())
|
||||
self.assertTrue(tickets)
|
||||
self.assertEqual(tickets[-1].system_exception, "TMS异常")
|
||||
|
||||
|
||||
class SeaSkipAndPullTests(unittest.TestCase):
|
||||
@@ -380,6 +676,9 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
card_meta={"response_code": "rc-sea-1"},
|
||||
)
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.flow.session_of("s1").work_order_no)
|
||||
self.assertEqual(ticket.event_wait_kind, "adopt")
|
||||
self.assertTrue(ticket.event_wait_started_at)
|
||||
self.assertIn("已跳过内部协同", "\n".join(self.replies))
|
||||
scheme = "\n".join(x for x in self.replies if "报价方案" in x)
|
||||
self.assertIn("运输方式:海运", scheme)
|
||||
@@ -426,6 +725,9 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
card_meta={"response_code": "rc-pull-1"},
|
||||
)
|
||||
self.assertEqual(phase, "collab_group")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.event_wait_kind, "product_quote")
|
||||
self.assertTrue(ticket.event_wait_started_at)
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("协同群已创建", joined)
|
||||
self.assertIn(wo, joined)
|
||||
@@ -441,6 +743,9 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
self.assertIn(wo, group_text)
|
||||
self.assertIn("起运港", group_text)
|
||||
self.assertIn("贸易条款", group_text)
|
||||
collab_block = group_text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
|
||||
self.assertNotIn("贸易条款", collab_block)
|
||||
self.assertIn("货好时间", collab_block)
|
||||
self.assertIn("商品海关编码", group_text)
|
||||
self.assertNotIn("HS编码", group_text)
|
||||
self.assertIn("TMS", group_text)
|
||||
@@ -652,7 +957,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
self.replies.append(text)
|
||||
|
||||
def test_group_text_writes_incoterm_silently(self) -> None:
|
||||
def test_group_text_ignores_trade_terms_saves_ready_date(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
[
|
||||
{
|
||||
@@ -680,11 +985,24 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
chat_id = ticket.collab_chat_id
|
||||
n = len(self.replies)
|
||||
phase = handle_group_collab(
|
||||
ignored = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g1",
|
||||
content="贸易条款:FOB",
|
||||
content="贸易条款:CIF",
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(ignored, "collab_not_recognized")
|
||||
self.assertFalse(ticket.collab_facts.get("贸易条款"))
|
||||
phase = handle_group_collab(
|
||||
InboundMessage(
|
||||
sender_id="prod4",
|
||||
message_id="g2",
|
||||
content="货好时间:下周",
|
||||
chat_id=chat_id,
|
||||
chat_type="group",
|
||||
),
|
||||
@@ -693,7 +1011,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "collab_field_saved")
|
||||
self.assertEqual(len(self.replies), n)
|
||||
self.assertEqual(ticket.collab_facts.get("贸易条款"), "FOB")
|
||||
self.assertEqual(ticket.collab_facts.get("货好时间"), "下周")
|
||||
|
||||
def test_group_price_chat_is_ignored(self) -> None:
|
||||
self.ledger.set_sea_staff(
|
||||
@@ -1051,6 +1369,9 @@ class SeaGroupCopyTests(unittest.TestCase):
|
||||
self.assertIn("询价字段:\n运输方式:海运", text)
|
||||
self.assertIn("\n\n协同补充字段:\n", text)
|
||||
self.assertIn("商品海关编码:-", text)
|
||||
self.assertIn("货好时间:-", text)
|
||||
collab_block = text.split("协同补充字段:", 1)[-1].split("TMS报价:", 1)[0]
|
||||
self.assertNotIn("贸易条款", collab_block)
|
||||
self.assertNotIn("HS编码", text)
|
||||
self.assertIn("\n\nTMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertNotIn("未查到合适报价", text)
|
||||
@@ -1272,6 +1593,8 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "product_file_quote")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.event_wait_kind, "deal")
|
||||
self.assertTrue(ticket.event_wait_started_at)
|
||||
self.assertEqual(ticket.attachments[0]["file_name"], "产品报价.pdf")
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
系统异常:话术、判定边界、无单不落库、暂停、告警卡、重试清列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.system_exception import (
|
||||
STEP_GROUP,
|
||||
STEP_LLM,
|
||||
STEP_LLM_NO_TICKET,
|
||||
STEP_TMS_CABIN,
|
||||
STEP_TMS_QUOTE,
|
||||
TYPE_GROUP,
|
||||
TYPE_LLM,
|
||||
TYPE_TMS,
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
confirm_system_exception,
|
||||
extract_work_order_nos,
|
||||
is_tms_tech_failure,
|
||||
should_mark_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
|
||||
|
||||
class TestSystemExceptionCopy(unittest.TestCase):
|
||||
def test_fallback_three_types(self) -> None:
|
||||
self.assertEqual(copy.system_exception_fallback(TYPE_LLM), "大模型服务异常,IT排查中...")
|
||||
self.assertEqual(copy.system_exception_fallback(TYPE_TMS), "TMS接口异常,IT运维排查中...")
|
||||
self.assertEqual(
|
||||
copy.system_exception_fallback(TYPE_GROUP, names=["赵六"]),
|
||||
"无法添加 赵六 同事,请联系管理员检查企业微信配置。",
|
||||
)
|
||||
self.assertIn("李四、王五", copy.system_exception_fallback(TYPE_GROUP, names=["李四", "王五"]))
|
||||
|
||||
def test_recovery_four_steps(self) -> None:
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_TMS_QUOTE), copy.SYS_EXC_RECOVERY_TMS)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_LLM), copy.SYS_EXC_RECOVERY_LLM)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_TMS_CABIN), copy.SYS_EXC_RECOVERY_CABIN)
|
||||
self.assertEqual(copy.system_exception_recovery(STEP_GROUP), copy.SYS_EXC_RECOVERY_GROUP)
|
||||
|
||||
def test_impact_by_step(self) -> None:
|
||||
self.assertEqual(copy.system_exception_impact(STEP_LLM_NO_TICKET), copy.IMPACT_LLM_NO_TICKET)
|
||||
self.assertEqual(copy.system_exception_impact(STEP_LLM), copy.IMPACT_LLM)
|
||||
self.assertEqual(copy.system_exception_impact(STEP_TMS_QUOTE), copy.IMPACT_TMS_QUOTE)
|
||||
self.assertEqual(copy.system_exception_impact(STEP_TMS_CABIN), copy.IMPACT_TMS_CABIN)
|
||||
self.assertEqual(copy.system_exception_impact(STEP_GROUP), copy.IMPACT_GROUP)
|
||||
|
||||
|
||||
class TestSystemExceptionRules(unittest.TestCase):
|
||||
def test_tms_no_price_is_not_tech(self) -> None:
|
||||
self.assertFalse(
|
||||
is_tms_tech_failure(
|
||||
{"ok": True, "classification": "TMS_NO_QUOTE_1002", "has_price": False}
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
is_tms_tech_failure({"ok": False, "classification": "TMS_QUERY_INCOMPLETE"})
|
||||
)
|
||||
self.assertTrue(
|
||||
is_tms_tech_failure({"ok": False, "classification": "TMS_TECH_FAILURE"})
|
||||
)
|
||||
self.assertTrue(is_tms_tech_failure({"ok": False, "error": "timeout"}))
|
||||
|
||||
def test_closed_or_blank_not_marked(self) -> None:
|
||||
self.assertFalse(should_mark_ticket(work_order_no="", ticket_status=""))
|
||||
self.assertFalse(should_mark_ticket(work_order_no="WO1", ticket_status="已关闭"))
|
||||
self.assertTrue(should_mark_ticket(work_order_no="WO1", ticket_status="询价中"))
|
||||
self.assertTrue(should_mark_ticket(work_order_no="WO1", ticket_status="转人工"))
|
||||
self.assertTrue(should_mark_ticket(work_order_no="WO1", ticket_status="已成交"))
|
||||
|
||||
def test_extract_work_order_nos(self) -> None:
|
||||
self.assertEqual(extract_work_order_nos("请看 WO202609210001"), ["WO202609210001"])
|
||||
self.assertEqual(extract_work_order_nos("虚拟 VT202609210001 不算"), [])
|
||||
|
||||
def test_retries_succeed_on_second(self) -> None:
|
||||
hits = {"n": 0}
|
||||
|
||||
def flaky() -> dict:
|
||||
hits["n"] += 1
|
||||
if hits["n"] < 2:
|
||||
return {"ok": False}
|
||||
return {"ok": True, "value": 1}
|
||||
|
||||
out = call_with_retries(flaky, is_ok=lambda x: bool(x.get("ok")), attempts=3)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(hits["n"], 2)
|
||||
|
||||
def test_retries_keep_last_failure(self) -> None:
|
||||
def always_fail() -> dict:
|
||||
return {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
|
||||
out = call_with_retries(always_fail, is_ok=lambda x: bool(x.get("ok")), attempts=3)
|
||||
self.assertFalse(out.get("ok"))
|
||||
self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE")
|
||||
|
||||
|
||||
class TestConfirmSystemException(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="SEA",
|
||||
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
|
||||
)
|
||||
self.wo = str(created.get("work_order_no") or "")
|
||||
self.ledger.upsert_exception_notify_user({"name": "运维甲", "wecomId": "ops-a"})
|
||||
|
||||
def test_no_ticket_replies_and_alerts_but_does_not_create(self) -> None:
|
||||
replies: list[str] = []
|
||||
queued: list[dict] = []
|
||||
|
||||
def enqueue(*, touser, content, dedupe_key, payload):
|
||||
queued.append({"touser": touser, "payload": payload, "content": content})
|
||||
return True, "ok"
|
||||
|
||||
before = len(self.ledger._tickets)
|
||||
out = confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_LLM,
|
||||
step=STEP_LLM_NO_TICKET,
|
||||
reason="模型返回空结果",
|
||||
service="大模型识别",
|
||||
error_code="EMPTY",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
enqueue=enqueue,
|
||||
notify_ready=True,
|
||||
now=datetime(2026, 9, 21, 10, 0, 0, tzinfo=ZoneInfo("Asia/Shanghai")),
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertFalse(out.get("marked"))
|
||||
self.assertEqual(len(self.ledger._tickets), before)
|
||||
self.assertEqual(replies[0], copy.SYS_EXC_LLM_FALLBACK)
|
||||
self.assertEqual(queued[0]["touser"], "ops-a")
|
||||
card = queued[0]["payload"]["template_card"]
|
||||
self.assertEqual(card["source"]["desc"], "询价机器人系统告警")
|
||||
self.assertEqual(card["main_title"]["desc"], "关联工单 无")
|
||||
values = {row["keyname"]: row["value"] for row in card["horizontal_content_list"]}
|
||||
self.assertEqual(values["影响范围"], copy.IMPACT_LLM_NO_TICKET)
|
||||
|
||||
def test_mark_ticket_and_pause(self) -> None:
|
||||
replies: list[str] = []
|
||||
out = confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_QUOTE,
|
||||
reason="TMS /quote/v2 接口调用失败",
|
||||
service="TMS报价",
|
||||
error_code="QUOTE_CALCULATION",
|
||||
work_order_no=self.wo,
|
||||
ticket_status="询价中",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
enqueue=lambda **kwargs: (True, "ok"),
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertTrue(out.get("marked"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "询价中")
|
||||
self.assertEqual(ticket.system_exception, TYPE_TMS)
|
||||
self.assertEqual(ticket.system_exception_reason, "TMS /quote/v2 接口调用失败")
|
||||
self.assertTrue(ticket_is_paused(ticket))
|
||||
self.assertEqual(replies[0], copy.SYS_EXC_TMS_FALLBACK)
|
||||
|
||||
def test_closed_ticket_not_marked(self) -> None:
|
||||
self.ledger.transition(work_order_no=self.wo, to_status="已关闭")
|
||||
out = confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_TMS,
|
||||
step=STEP_TMS_QUOTE,
|
||||
reason="x",
|
||||
service="TMS报价",
|
||||
error_code="TIMEOUT",
|
||||
work_order_no=self.wo,
|
||||
ticket_status="已关闭",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=lambda _t: None,
|
||||
notify_ready=False,
|
||||
)
|
||||
self.assertFalse(out.get("marked"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertFalse(ticket_is_paused(ticket))
|
||||
|
||||
def test_later_ticket_does_not_inherit_pre_ticket_exception(self) -> None:
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
exception_type=TYPE_LLM,
|
||||
step=STEP_LLM_NO_TICKET,
|
||||
reason="空结果",
|
||||
service="大模型识别",
|
||||
error_code="EMPTY",
|
||||
),
|
||||
ledger=self.ledger,
|
||||
reply=lambda _t: None,
|
||||
notify_ready=False,
|
||||
)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertFalse(ticket_is_paused(ticket))
|
||||
|
||||
def test_admin_retry_tms_success_clears(self) -> None:
|
||||
self.ledger.mark_system_exception(
|
||||
work_order_no=self.wo,
|
||||
system_exception=TYPE_TMS,
|
||||
system_exception_reason="TMS /quote/v2 接口调用失败",
|
||||
step=STEP_TMS_QUOTE,
|
||||
payload={"conversation_kind": "private", "conversation_target": "sales1"},
|
||||
)
|
||||
from agent.policy.system_exception import run_admin_retry
|
||||
|
||||
talks: list[str] = []
|
||||
|
||||
def fake_send(*, kind, target, text, extra=None):
|
||||
talks.append(text)
|
||||
|
||||
import agent.policy.system_exception as se
|
||||
|
||||
orig = se._send_to_conversation
|
||||
se._send_to_conversation = fake_send
|
||||
try:
|
||||
out = run_admin_retry(self.wo, ledger=self.ledger)
|
||||
finally:
|
||||
se._send_to_conversation = orig
|
||||
self.assertTrue(out.get("ok"))
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertFalse(ticket_is_paused(ticket))
|
||||
self.assertEqual(ticket.status, "已报价")
|
||||
self.assertTrue(ticket.quote)
|
||||
self.assertTrue(any("TMS已恢复" in x for x in talks))
|
||||
joined = "\n".join(talks)
|
||||
self.assertTrue("USD" in joined or "拉产品" in joined or "已报价" in joined)
|
||||
|
||||
def test_admin_retry_cabin_lock_runs_lock(self) -> None:
|
||||
self.ledger.mark_system_exception(
|
||||
work_order_no=self.wo,
|
||||
system_exception=TYPE_TMS,
|
||||
system_exception_reason="空运锁舱接口调用失败",
|
||||
step=STEP_TMS_CABIN,
|
||||
payload={
|
||||
"conversation_kind": "group",
|
||||
"conversation_target": "chat-air",
|
||||
"extra": {"option_no": "AIR-OPT-01", "instruction": "锁舱"},
|
||||
},
|
||||
)
|
||||
hits = {"n": 0}
|
||||
orig_lock = self.ledger.lock_cabin
|
||||
|
||||
def counted(**kwargs):
|
||||
hits["n"] += 1
|
||||
return orig_lock(**kwargs)
|
||||
|
||||
self.ledger.lock_cabin = counted # type: ignore[method-assign]
|
||||
from agent.policy.system_exception import run_admin_retry
|
||||
|
||||
talks: list[str] = []
|
||||
import agent.policy.system_exception as se
|
||||
|
||||
orig = se._send_to_conversation
|
||||
se._send_to_conversation = lambda **kwargs: talks.append(str(kwargs.get("text") or ""))
|
||||
try:
|
||||
out = run_admin_retry(self.wo, ledger=self.ledger)
|
||||
finally:
|
||||
se._send_to_conversation = orig
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertGreaterEqual(hits["n"], 1)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertFalse(ticket_is_paused(ticket))
|
||||
self.assertTrue(any("空运舱位接口已恢复" in x for x in talks))
|
||||
|
||||
def test_extract_tech_fail_when_model_errors(self) -> None:
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
|
||||
class Boom:
|
||||
ok = False
|
||||
error = "timeout"
|
||||
tool_calls = []
|
||||
content = ""
|
||||
|
||||
snap = extract_inquiry_snapshot("上海到洛杉矶衣服", allow_b=True, chat_fn=lambda **_k: Boom())
|
||||
self.assertTrue(snap.get("tech_fail"))
|
||||
self.assertEqual(snap.get("tech_error"), "timeout")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+76
@@ -164,6 +164,51 @@ public class InquiryAgentController {
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("开始事件异常等待(不改六态)")
|
||||
@PostMapping("/ticket/startEventWait")
|
||||
public Result<Map<String, Object>> startEventWait(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.startEventWait(
|
||||
firstStr(body, "workOrderNo", "work_order_no"),
|
||||
firstStr(body, "waitKind", "wait_kind")));
|
||||
}
|
||||
|
||||
@ApiOperation("清空事件异常等待(不改六态、不清摘要)")
|
||||
@PostMapping("/ticket/clearEventWait")
|
||||
public Result<Map<String, Object>> clearEventWait(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.clearEventWait(firstStr(body, "workOrderNo", "work_order_no")));
|
||||
}
|
||||
|
||||
@ApiOperation("列出未告警的事件异常等待(Worker 巡检)")
|
||||
@PostMapping("/ticket/listEventWaits")
|
||||
public Result<java.util.List<Map<String, Object>>> listEventWaits(
|
||||
@RequestBody(required = false) Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.listEventWaits());
|
||||
}
|
||||
|
||||
@ApiOperation("记下事件异常摘要并标记已告警(不改六态)")
|
||||
@PostMapping("/ticket/recordEventException")
|
||||
public Result<Map<String, Object>> recordEventException(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.recordEventException(
|
||||
firstStr(body, "workOrderNo", "work_order_no"),
|
||||
firstStr(body, "eventException", "event_exception")));
|
||||
}
|
||||
|
||||
@ApiOperation("智能体续办读单(只读,校验发起人)")
|
||||
@PostMapping("/ticket/get")
|
||||
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
|
||||
@@ -204,6 +249,37 @@ public class InquiryAgentController {
|
||||
return Result.OK(rendered);
|
||||
}
|
||||
|
||||
@ApiOperation("智能体写入系统异常两列(已关闭拒绝,不改六态)")
|
||||
@PostMapping("/ticket/markSystemException")
|
||||
public Result<Map<String, Object>> markSystemException(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.markSystemException(body));
|
||||
}
|
||||
|
||||
@ApiOperation("智能体清除系统异常两列(重试成功后)")
|
||||
@PostMapping("/ticket/clearSystemException")
|
||||
public Result<Map<String, Object>> clearSystemException(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.clearSystemException(firstStr(body, "workOrderNo", "work_order_no")));
|
||||
}
|
||||
|
||||
@ApiOperation("系统异常告警接收人:角色勾了通知且账号已绑企微")
|
||||
@PostMapping("/account/listExceptionNotify")
|
||||
public Result<Map<String, Object>> listExceptionNotify(HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
out.put("staff", workOrderService.listExceptionNotifyUsers());
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
@ApiOperation("经主账查真 TMS(有价回报价 / 无价回暂无报价 / 技术失败)")
|
||||
@PostMapping("/tms/query")
|
||||
public Result<Map<String, Object>> tmsQuery(@RequestBody Map<String, Object> body,
|
||||
|
||||
+4
-1
@@ -24,7 +24,7 @@ import java.util.Map;
|
||||
* <p>
|
||||
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
|
||||
* 每条有唯一 timeoutCode(如 sea_sales),智能体单码调用;roleCode 仅为角色维度可重复。
|
||||
* 本接口供管理后台使用;工单超时判定尚未接入。
|
||||
* 本接口供管理后台使用;智能体按 timeoutCode 读小时数做超时判定。
|
||||
* </p>
|
||||
*/
|
||||
@Slf4j
|
||||
@@ -81,6 +81,9 @@ public class InquiryResponseTimeoutController
|
||||
if (body.getTimeoutHours() == null) {
|
||||
return Result.error("超时小时不能为空");
|
||||
}
|
||||
if (body.getTimeoutHours().compareTo(java.math.BigDecimal.ZERO) < 0) {
|
||||
return Result.error("超时小时不能为负数");
|
||||
}
|
||||
try {
|
||||
responseTimeoutService.updateTimeoutHours(body.getId(), body.getTimeoutHours(), currentUsername());
|
||||
return Result.OK("已保存");
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package org.jeecg.modules.inquiry.domain;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 事件异常纯规则:摘要拼接、哪些六态还盯超时、换等待种类才重置计时。
|
||||
* <p>
|
||||
* 不写库、不发企微。工单六态不因事件异常改变。
|
||||
*/
|
||||
public final class EventExceptionRule {
|
||||
|
||||
private EventExceptionRule() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 多次异常拼在一起;同一类型不重复。
|
||||
*/
|
||||
public static String appendSummary(String existing, String type) {
|
||||
String add = StringUtils.trimToEmpty(type);
|
||||
if (add.isEmpty()) {
|
||||
return StringUtils.trimToEmpty(existing);
|
||||
}
|
||||
String cur = StringUtils.trimToEmpty(existing);
|
||||
if (cur.isEmpty() || "-".equals(cur)) {
|
||||
return add;
|
||||
}
|
||||
Set<String> parts = new LinkedHashSet<String>(Arrays.asList(cur.split(";")));
|
||||
if (parts.contains(add)) {
|
||||
return cur;
|
||||
}
|
||||
parts.add(add);
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (String p : parts) {
|
||||
String one = StringUtils.trimToEmpty(p);
|
||||
if (one.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (out.length() > 0) {
|
||||
out.append(';');
|
||||
}
|
||||
out.append(one);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 已成交 / 已关闭 / 转人工不再盯;未成交仍可能在等原因。
|
||||
*/
|
||||
public static boolean isWatchable(String status) {
|
||||
if (StringUtils.isBlank(status)) {
|
||||
return false;
|
||||
}
|
||||
if (TicketStatuses.DEAL.equals(status)
|
||||
|| TicketStatuses.CLOSED.equals(status)
|
||||
|| TicketStatuses.HANDOFF.equals(status)) {
|
||||
return false;
|
||||
}
|
||||
return TicketStatuses.isKnown(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待种类变了才重置起点;同一种类重复 start 不改已过时间。
|
||||
*/
|
||||
public static boolean shouldResetClock(String currentKind, String newKind) {
|
||||
String cur = StringUtils.trimToEmpty(currentKind);
|
||||
String next = StringUtils.trimToEmpty(newKind);
|
||||
if (next.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return !cur.equals(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始等待后的三列新值。
|
||||
*/
|
||||
public static EventWaitState applyStart(String currentKind, Date started, Integer alerted,
|
||||
String newKind, Date now) {
|
||||
String next = StringUtils.trimToEmpty(newKind);
|
||||
if (next.isEmpty()) {
|
||||
return new EventWaitState(currentKind, started, alerted);
|
||||
}
|
||||
if (!shouldResetClock(currentKind, next)) {
|
||||
Date keep = started != null ? started : now;
|
||||
int flag = alerted == null ? 0 : alerted.intValue();
|
||||
return new EventWaitState(next, keep, flag);
|
||||
}
|
||||
return new EventWaitState(next, now, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前等待三列。
|
||||
*/
|
||||
public static final class EventWaitState {
|
||||
public final String kind;
|
||||
public final Date startedAt;
|
||||
public final int alerted;
|
||||
|
||||
public EventWaitState(String kind, Date startedAt, int alerted) {
|
||||
this.kind = kind;
|
||||
this.startedAt = startedAt;
|
||||
this.alerted = alerted;
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -19,7 +19,7 @@ import java.util.Date;
|
||||
* <p>
|
||||
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
|
||||
* timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。
|
||||
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
|
||||
* 本表是配置真相;工单超时判定读 timeoutHours(允许小数,0=不监测)。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
@@ -49,8 +49,8 @@ public class InquiryResponseTimeout implements Serializable {
|
||||
@ApiModelProperty("运输类型(海运/陆运产品有值,其余空串)")
|
||||
private String transportType;
|
||||
|
||||
@ApiModelProperty("超时小时,整数≥0")
|
||||
private Integer timeoutHours;
|
||||
@ApiModelProperty("超时小时,允许小数,0=不监测")
|
||||
private java.math.BigDecimal timeoutHours;
|
||||
|
||||
@ApiModelProperty("展示排序")
|
||||
private Integer sortNo;
|
||||
|
||||
+24
@@ -116,12 +116,36 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("事件异常摘要")
|
||||
private String eventException;
|
||||
|
||||
@ApiModelProperty("事件异常当前等待种类")
|
||||
private String eventWaitKind;
|
||||
|
||||
@ApiModelProperty("事件异常当前等待起点")
|
||||
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date eventWaitStartedAt;
|
||||
|
||||
@ApiModelProperty("当前等待是否已告警 0/1")
|
||||
private Integer eventWaitAlerted;
|
||||
|
||||
@ApiModelProperty("系统异常摘要")
|
||||
private String systemException;
|
||||
|
||||
@ApiModelProperty("系统异常原因")
|
||||
private String systemExceptionReason;
|
||||
|
||||
/**
|
||||
* 后台点重试时重跑哪一步:tms_quote / tms_cabin / llm / group。
|
||||
* 不改六态;成功后与两列一并清空。
|
||||
*/
|
||||
@ApiModelProperty("系统异常步骤")
|
||||
private String systemExceptionStep;
|
||||
|
||||
/**
|
||||
* 重试上下文 JSON:会话目标、未加上的人、业务线等。
|
||||
*/
|
||||
@ApiModelProperty("系统异常重试上下文")
|
||||
private String systemExceptionPayload;
|
||||
|
||||
@ApiModelProperty("关单原因")
|
||||
private String closeReason;
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package org.jeecg.modules.inquiry.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 系统异常告警接收人:后台账号角色勾了「系统异常消息通知」,且工号(企微 userid)非空。
|
||||
* <p>
|
||||
* 不按海陆空过滤。inquiry 模块不依赖 system-biz,直接读同库 Jeecg 表。
|
||||
*/
|
||||
@Mapper
|
||||
public interface InquiryExceptionNotifyMapper {
|
||||
|
||||
/**
|
||||
* 启用账号 + 角色 description 含系统异常通知稳定码 + work_no 已绑企微。
|
||||
*/
|
||||
@Select("SELECT DISTINCT u.work_no AS wecomId, u.realname AS name "
|
||||
+ "FROM sys_role r "
|
||||
+ "INNER JOIN sys_user_role ur ON ur.role_id = r.id "
|
||||
+ "INNER JOIN sys_user u ON u.id = ur.user_id "
|
||||
+ "WHERE u.del_flag = 0 AND u.status = 1 "
|
||||
+ "AND u.work_no IS NOT NULL AND TRIM(u.work_no) <> '' "
|
||||
+ "AND (r.description LIKE '%系统异常·消息通知%' "
|
||||
+ "OR r.description LIKE '%系统异常消息通知%')")
|
||||
List<Map<String, Object>> listBoundAccounts();
|
||||
}
|
||||
+2
-2
@@ -15,10 +15,10 @@ public interface IInquiryResponseTimeoutService extends IService<InquiryResponse
|
||||
* 仅更新超时小时数;维度字段不可改。
|
||||
*
|
||||
* @param id 配置行 id
|
||||
* @param timeoutHours 新小时数(≥0)
|
||||
* @param timeoutHours 新小时数(≥0,允许小数,0=不监测)
|
||||
* @param updateBy 操作人登录名
|
||||
*/
|
||||
void updateTimeoutHours(String id, int timeoutHours, String updateBy);
|
||||
void updateTimeoutHours(String id, java.math.BigDecimal timeoutHours, String updateBy);
|
||||
|
||||
/**
|
||||
* 把陆运产品「普件,…」运输类型对齐产品确认的括号文案。
|
||||
|
||||
+36
-1
@@ -41,10 +41,25 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
void handleAbnormal(TicketAbnormalRequest req, String operator);
|
||||
|
||||
/**
|
||||
* 系统异常重试:累加 retry_count,记日志;真正重跑任务由 Worker 后续认领(本期只落主账状态)。
|
||||
* 系统异常重试:已关闭拒绝;记下重试意图后唤醒智能体(快回,不在本线程重跑)。
|
||||
*/
|
||||
void retryAbnormal(TicketAbnormalRequest req, String operator);
|
||||
|
||||
/**
|
||||
* 智能体写入系统异常两列与重试上下文。已关闭拒绝。不改六态。
|
||||
*/
|
||||
Map<String, Object> markSystemException(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 重试成功后清两列。
|
||||
*/
|
||||
Map<String, Object> clearSystemException(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 后台账号:角色勾了系统异常消息通知,且工号已绑企微。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> listExceptionNotifyUsers();
|
||||
|
||||
/**
|
||||
* 智能体建单:每次出询价确认都新开一单,相同需求不复用未关闭工单。
|
||||
*/
|
||||
@@ -121,4 +136,24 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
* 只改到期行,不加全局锁。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
|
||||
|
||||
/**
|
||||
* 开始盯事件异常等待。种类相同不重置起点;种类不同重置并清已告警。不改六态。
|
||||
*/
|
||||
Map<String, Object> startEventWait(String workOrderNo, String waitKind);
|
||||
|
||||
/**
|
||||
* 清空当前事件异常等待三列,不改 eventException 摘要、不改六态。
|
||||
*/
|
||||
Map<String, Object> clearEventWait(String workOrderNo);
|
||||
|
||||
/**
|
||||
* 列出未告警且可盯的等待中工单,供 Worker 巡检。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> listEventWaits();
|
||||
|
||||
/**
|
||||
* 追加事件异常摘要并标记当前等待已告警。不改六态。
|
||||
*/
|
||||
Map<String, Object> recordEventException(String workOrderNo, String eventException);
|
||||
}
|
||||
|
||||
+2
-1
@@ -797,7 +797,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("destination", first(src, "目的地", "目的港", "destination", "pod"));
|
||||
out.put("commodity", first(src, "品名", "货物品名", "commodity"));
|
||||
out.put("vehicle_qty", first(src, "车型/数量", "车型"));
|
||||
// 海运七项:合同键是 quantity / fcl_or_lcl / container_type_qty,工单事实是中文键
|
||||
// 海运询价栏:合同键是 quantity / fcl_or_lcl / container_type_qty / trade_terms,工单事实是中文键
|
||||
out.put("quantity", first(src, "货量", "货物数量", "quantity"));
|
||||
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
|
||||
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
|
||||
@@ -806,6 +806,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("运输分类", first(src, "运输分类"));
|
||||
out.put("cargo_ready_date", first(src, "货好时间", "cargo_ready_date"));
|
||||
out.put("hs_code", first(src, "商品海关编码", "HS编码", "海关编码", "hs_code"));
|
||||
String oil = first(src, "是否含油", "has_oil");
|
||||
|
||||
+4
-17
@@ -41,6 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件(东南亚、中港常用线路/常规车型)");
|
||||
@@ -50,12 +51,6 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)");
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,常用线路/常规车型",
|
||||
"普件(常用线路/常规车型)");
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,非常用线路/临时线路/特种车型",
|
||||
"普件(非常用线路/临时线路/特种车型)");
|
||||
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000001", "air_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000002", "air_route");
|
||||
@@ -63,19 +58,11 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
|
||||
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000011", "land_ltl_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000012", "land_ltl_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000013", "land_ltl_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000014", "land_city_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000015", "land_city_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000016", "land_city_express");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000017", "land_sea_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000018", "land_sea_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000019", "land_sea_express");
|
||||
}
|
||||
|
||||
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
||||
@@ -84,8 +71,8 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateTimeoutHours(String id, int timeoutHours, String updateBy) {
|
||||
if (timeoutHours < 0) {
|
||||
public void updateTimeoutHours(String id, java.math.BigDecimal timeoutHours, String updateBy) {
|
||||
if (timeoutHours == null || timeoutHours.compareTo(java.math.BigDecimal.ZERO) < 0) {
|
||||
throw new IllegalArgumentException("超时小时不能为负数");
|
||||
}
|
||||
InquiryResponseTimeout row = getById(id);
|
||||
|
||||
+331
-1
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.domain.AirGroupActivateRule;
|
||||
import org.jeecg.modules.inquiry.domain.EventExceptionRule;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
import org.jeecg.modules.inquiry.dto.AgentTicketCreateRequest;
|
||||
import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
|
||||
@@ -20,8 +21,10 @@ import org.jeecg.modules.inquiry.mapper.InquiryAttachmentMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryExceptionCaseMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryExceptionNotifyMapper;
|
||||
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.InquiryQuoteDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
@@ -69,6 +72,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
private InquiryTmsQueryService tmsQueryService;
|
||||
@Autowired
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
@Autowired
|
||||
private InquiryExceptionNotifyMapper exceptionNotifyMapper;
|
||||
@Autowired
|
||||
private AgentSystemExceptionRetryClient exceptionRetryClient;
|
||||
|
||||
@Override
|
||||
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
|
||||
@@ -95,7 +102,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
if ("-".equals(filters.get("eventException"))) {
|
||||
qw.and(w -> w.isNull(InquiryTicket::getEventException).or().eq(InquiryTicket::getEventException, "").or().eq(InquiryTicket::getEventException, "-"));
|
||||
} else {
|
||||
qw.eq(InquiryTicket::getEventException, filters.get("eventException"));
|
||||
qw.like(InquiryTicket::getEventException, filters.get("eventException"));
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(filters.get("systemException"))) {
|
||||
@@ -300,6 +307,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setEventException(null);
|
||||
ticket.setSystemException(null);
|
||||
ticket.setSystemExceptionReason(null);
|
||||
ticket.setSystemExceptionStep(null);
|
||||
ticket.setSystemExceptionPayload(null);
|
||||
ticket.setUpdateBy(operator);
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
@@ -310,6 +319,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void retryAbnormal(TicketAbnormalRequest req, String operator) {
|
||||
InquiryTicket ticket = requireTicket(req);
|
||||
if (TicketStatuses.CLOSED.equals(ticket.getStatus())) {
|
||||
throw new IllegalArgumentException("已关闭工单不能重试");
|
||||
}
|
||||
if (StringUtils.isBlank(ticket.getSystemException())) {
|
||||
throw new IllegalArgumentException("当前没有系统异常");
|
||||
}
|
||||
List<InquiryExceptionCase> openCases = exceptionCaseMapper.selectList(new LambdaQueryWrapper<InquiryExceptionCase>()
|
||||
.eq(InquiryExceptionCase::getWorkOrderNo, ticket.getWorkOrderNo())
|
||||
.eq(InquiryExceptionCase::getCaseType, "SYSTEM")
|
||||
@@ -339,6 +354,110 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
}
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "retry", operator, req.getRemark());
|
||||
String wakeErr = exceptionRetryClient.enqueueRetry(ticket.getWorkOrderNo());
|
||||
if (StringUtils.isNotBlank(wakeErr)) {
|
||||
throw new IllegalArgumentException(wakeErr);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> markSystemException(Map<String, Object> body) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String no = firstBody(body, "workOrderNo", "work_order_no");
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.CLOSED.equals(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "closed");
|
||||
return out;
|
||||
}
|
||||
String type = firstBody(body, "systemException", "system_exception");
|
||||
String reason = firstBody(body, "systemExceptionReason", "system_exception_reason");
|
||||
String step = firstBody(body, "step", "systemExceptionStep", "system_exception_step");
|
||||
Object payloadObj = body == null ? null : body.get("payload");
|
||||
if (payloadObj == null && body != null) {
|
||||
payloadObj = body.get("systemExceptionPayload");
|
||||
}
|
||||
String payloadJson = payloadObj == null ? null
|
||||
: (payloadObj instanceof String ? (String) payloadObj : JSON.toJSONString(payloadObj));
|
||||
ticket.setSystemException(type);
|
||||
ticket.setSystemExceptionReason(reason);
|
||||
ticket.setSystemExceptionStep(step);
|
||||
ticket.setSystemExceptionPayload(payloadJson);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
InquiryExceptionCase created = new InquiryExceptionCase();
|
||||
created.setTicketId(ticket.getId());
|
||||
created.setWorkOrderNo(ticket.getWorkOrderNo());
|
||||
created.setCaseType("SYSTEM");
|
||||
created.setTitle(StringUtils.defaultIfBlank(type, "系统异常"));
|
||||
created.setReason(reason);
|
||||
created.setStatus("OPEN");
|
||||
created.setRetryCount(0);
|
||||
created.setCreateTime(new Date());
|
||||
created.setUpdateTime(new Date());
|
||||
exceptionCaseMapper.insert(created);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "system_exception", "agent", reason);
|
||||
out.put("ok", true);
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> clearSystemException(String workOrderNo) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
InquiryTicket ticket = findByWorkOrderNo(StringUtils.trimToEmpty(workOrderNo));
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
ticket.setSystemException(null);
|
||||
ticket.setSystemExceptionReason(null);
|
||||
ticket.setSystemExceptionStep(null);
|
||||
ticket.setSystemExceptionPayload(null);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
List<InquiryExceptionCase> openCases = exceptionCaseMapper.selectList(new LambdaQueryWrapper<InquiryExceptionCase>()
|
||||
.eq(InquiryExceptionCase::getWorkOrderNo, ticket.getWorkOrderNo())
|
||||
.eq(InquiryExceptionCase::getCaseType, "SYSTEM")
|
||||
.in(InquiryExceptionCase::getStatus, "OPEN", "RETRYING"));
|
||||
Date now = new Date();
|
||||
for (InquiryExceptionCase c : openCases) {
|
||||
c.setStatus("HANDLED");
|
||||
c.setHandledBy("agent");
|
||||
c.setHandledAt(now);
|
||||
c.setUpdateTime(now);
|
||||
exceptionCaseMapper.updateById(c);
|
||||
}
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "system_exception_clear", "agent", "重试成功");
|
||||
out.put("ok", true);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listExceptionNotifyUsers() {
|
||||
List<Map<String, Object>> rows = exceptionNotifyMapper.listBoundAccounts();
|
||||
return rows == null ? Collections.emptyList() : rows;
|
||||
}
|
||||
|
||||
private static String firstBody(Map<String, Object> body, String... keys) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object v = body.get(key);
|
||||
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
|
||||
return String.valueOf(v).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -699,6 +818,19 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
out.put("collab_facts", collabFacts);
|
||||
out.put("collab_member_ids", parseMemberIds(ticket.getCollabMemberIds()));
|
||||
out.put("system_exception", StringUtils.defaultString(ticket.getSystemException()));
|
||||
out.put("systemException", StringUtils.defaultString(ticket.getSystemException()));
|
||||
out.put("system_exception_reason", StringUtils.defaultString(ticket.getSystemExceptionReason()));
|
||||
out.put("systemExceptionReason", StringUtils.defaultString(ticket.getSystemExceptionReason()));
|
||||
out.put("system_exception_step", StringUtils.defaultString(ticket.getSystemExceptionStep()));
|
||||
Map<String, Object> excPayload = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getSystemExceptionPayload())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getSystemExceptionPayload());
|
||||
if (parsed != null) {
|
||||
excPayload.putAll(parsed);
|
||||
}
|
||||
}
|
||||
out.put("system_exception_payload", excPayload);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1274,6 +1406,204 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始盯当前等待。可盯状态才写;同种类不重置起点。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> startEventWait(String workOrderNo, String waitKind) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
String kind = StringUtils.trimToEmpty(waitKind);
|
||||
InquiryTicket ticket = findByWorkOrderNo(no);
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
if (!EventExceptionRule.isWatchable(ticket.getStatus()) || kind.isEmpty()) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_watchable");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
Date now = new Date();
|
||||
EventExceptionRule.EventWaitState next = EventExceptionRule.applyStart(
|
||||
ticket.getEventWaitKind(), ticket.getEventWaitStartedAt(),
|
||||
ticket.getEventWaitAlerted(), kind, now);
|
||||
ticket.setEventWaitKind(next.kind);
|
||||
ticket.setEventWaitStartedAt(next.startedAt);
|
||||
ticket.setEventWaitAlerted(next.alerted);
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", no);
|
||||
out.put("eventWaitKind", next.kind);
|
||||
out.put("eventWaitAlerted", next.alerted);
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> clearEventWait(String workOrderNo) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
InquiryTicket ticket = findByWorkOrderNo(StringUtils.trimToEmpty(workOrderNo));
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
ticket.setEventWaitKind(null);
|
||||
ticket.setEventWaitStartedAt(null);
|
||||
ticket.setEventWaitAlerted(0);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listEventWaits() {
|
||||
List<InquiryTicket> rows = list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.isNotNull(InquiryTicket::getEventWaitKind)
|
||||
.ne(InquiryTicket::getEventWaitKind, "")
|
||||
.and(w -> w.isNull(InquiryTicket::getEventWaitAlerted)
|
||||
.or().eq(InquiryTicket::getEventWaitAlerted, 0))
|
||||
.notIn(InquiryTicket::getStatus,
|
||||
TicketStatuses.DEAL, TicketStatuses.CLOSED, TicketStatuses.HANDOFF));
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
for (InquiryTicket ticket : rows) {
|
||||
if (ticket == null || !EventExceptionRule.isWatchable(ticket.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("workOrderNo", ticket.getWorkOrderNo());
|
||||
one.put("work_order_no", ticket.getWorkOrderNo());
|
||||
one.put("status", ticket.getStatus());
|
||||
one.put("businessLine", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
one.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
|
||||
one.put("eventWaitKind", ticket.getEventWaitKind());
|
||||
one.put("event_wait_kind", ticket.getEventWaitKind());
|
||||
if (ticket.getEventWaitStartedAt() != null) {
|
||||
String started = fmt.format(ticket.getEventWaitStartedAt());
|
||||
one.put("eventWaitStartedAt", started);
|
||||
one.put("event_wait_started_at", started);
|
||||
}
|
||||
one.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
one.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
|
||||
one.put("salesName", StringUtils.defaultString(ticket.getSalesName()));
|
||||
one.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
|
||||
one.put("collabChatId", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
one.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
|
||||
List<String> members = parseMemberIds(ticket.getCollabMemberIds());
|
||||
one.put("collabMemberIds", members);
|
||||
one.put("collab_member_ids", members);
|
||||
one.put("eventException", StringUtils.defaultString(ticket.getEventException()));
|
||||
Map<String, String> facts = new LinkedHashMap<>();
|
||||
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
|
||||
JSONObject parsed = JSON.parseObject(ticket.getFactsJson());
|
||||
if (parsed != null) {
|
||||
for (String key : parsed.keySet()) {
|
||||
if (parsed.get(key) != null) {
|
||||
facts.put(key, String.valueOf(parsed.get(key)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
one.put("facts", facts);
|
||||
if (ticket.getNextFollowupAt() != null) {
|
||||
one.put("nextFollowupAt", fmt.format(ticket.getNextFollowupAt()));
|
||||
one.put("next_followup_at", fmt.format(ticket.getNextFollowupAt()));
|
||||
}
|
||||
one.put("watchers", buildEventWatchers(ticket, members));
|
||||
out.add(one);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> recordEventException(String workOrderNo, String eventException) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
InquiryTicket ticket = findByWorkOrderNo(StringUtils.trimToEmpty(workOrderNo));
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "not_found");
|
||||
return out;
|
||||
}
|
||||
String type = StringUtils.trimToEmpty(eventException);
|
||||
ticket.setEventException(EventExceptionRule.appendSummary(ticket.getEventException(), type));
|
||||
ticket.setEventWaitAlerted(1);
|
||||
ticket.setUpdateTime(new Date());
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "event_exception", "agent", type);
|
||||
out.put("ok", true);
|
||||
out.put("work_order_no", ticket.getWorkOrderNo());
|
||||
out.put("eventException", ticket.getEventException());
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> buildEventWatchers(InquiryTicket ticket, List<String> memberIds) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
Set<String> seen = new HashSet<>();
|
||||
List<String> ids = new ArrayList<>();
|
||||
if (StringUtils.isNotBlank(ticket.getSalesWecomId())) {
|
||||
ids.add(ticket.getSalesWecomId().trim());
|
||||
}
|
||||
if (memberIds != null) {
|
||||
ids.addAll(memberIds);
|
||||
}
|
||||
if (ids.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<InquiryStaffRole> rows = staffRoleMapper.selectList(new LambdaQueryWrapper<InquiryStaffRole>()
|
||||
.in(InquiryStaffRole::getWecomId, ids)
|
||||
.eq(InquiryStaffRole::getStatus, "active"));
|
||||
Map<String, InquiryStaffRole> byId = new HashMap<>();
|
||||
if (rows != null) {
|
||||
for (InquiryStaffRole row : rows) {
|
||||
if (row != null && StringUtils.isNotBlank(row.getWecomId())) {
|
||||
byId.put(row.getWecomId().trim(), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(ticket.getSalesWecomId())) {
|
||||
String sid = ticket.getSalesWecomId().trim();
|
||||
InquiryStaffRole sales = byId.get(sid);
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("wecomId", sid);
|
||||
one.put("name", sales != null ? StringUtils.defaultString(sales.getName())
|
||||
: StringUtils.defaultString(ticket.getSalesName()));
|
||||
one.put("roleCode", sales != null ? StringUtils.defaultString(sales.getRoleCode()) : "sales");
|
||||
one.put("notifyEventException", sales != null && sales.getNotifyEventException() != null
|
||||
? sales.getNotifyEventException() : 0);
|
||||
out.add(one);
|
||||
seen.add(sid);
|
||||
}
|
||||
for (String uid : memberIds == null ? Collections.<String>emptyList() : memberIds) {
|
||||
if (StringUtils.isBlank(uid) || seen.contains(uid)) {
|
||||
continue;
|
||||
}
|
||||
InquiryStaffRole row = byId.get(uid);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("wecomId", uid);
|
||||
one.put("name", StringUtils.defaultString(row.getName()));
|
||||
one.put("roleCode", StringUtils.defaultString(row.getRoleCode()));
|
||||
one.put("notifyEventException", row.getNotifyEventException() == null ? 0 : row.getNotifyEventException());
|
||||
out.add(one);
|
||||
seen.add(uid);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
|
||||
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
|
||||
log.setTicketId(ticket.getId());
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 后台点「重试」后唤醒智能体:只入队,不等 TMS / 大模型 / 建群跑完。
|
||||
* <p>
|
||||
* 打 AGENT_HTTP_BASE_URL /internal/bridge/wake,kind=system_exception_retry。
|
||||
* 禁止在本类同步等 Python Graph。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AgentSystemExceptionRetryClient {
|
||||
|
||||
@Value("${inquiry.agent.base-url:${AGENT_HTTP_BASE_URL:http://127.0.0.1:8910}}")
|
||||
private String agentBaseUrl;
|
||||
|
||||
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
|
||||
private String agentToken;
|
||||
|
||||
@Value("${inquiry.agent.retry-timeout-ms:8000}")
|
||||
private int timeoutMs;
|
||||
|
||||
/**
|
||||
* 入队系统异常重试。返回 null 表示智能体已收下;非空为失败原因。
|
||||
*/
|
||||
public String enqueueRetry(String workOrderNo) {
|
||||
String no = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (StringUtils.isBlank(no)) {
|
||||
return "工单号为空";
|
||||
}
|
||||
String base = StringUtils.removeEnd(StringUtils.defaultIfBlank(agentBaseUrl, ""), "/");
|
||||
if (StringUtils.isBlank(base)) {
|
||||
return "智能体地址未配置(AGENT_HTTP_BASE_URL)";
|
||||
}
|
||||
String url = base + "/internal/bridge/wake";
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(Math.max(2000, timeoutMs));
|
||||
conn.setReadTimeout(Math.max(2000, timeoutMs));
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
if (StringUtils.isNotBlank(agentToken)) {
|
||||
conn.setRequestProperty("X-Agent-Callback-Token", agentToken);
|
||||
}
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("thread_id", no);
|
||||
body.put("inquiry_no", no);
|
||||
body.put("kind", "system_exception_retry");
|
||||
body.put("quote_version", 0);
|
||||
body.put("wait_version", 0);
|
||||
byte[] bytes = JSON.toJSONBytes(body);
|
||||
try (OutputStream out = conn.getOutputStream()) {
|
||||
out.write(bytes);
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
String respText = readQuietly(conn, code >= 200 && code < 300);
|
||||
if (code == 202 || code == 200) {
|
||||
JSONObject resp = safeJson(respText);
|
||||
if (resp != null && "accepted".equals(resp.getString("status"))) {
|
||||
return null;
|
||||
}
|
||||
if (resp != null && StringUtils.isNotBlank(resp.getString("entry_id"))) {
|
||||
return null;
|
||||
}
|
||||
return "智能体未确认入队";
|
||||
}
|
||||
return "无法提交重试: HTTP " + code;
|
||||
} catch (Exception e) {
|
||||
log.warn("系统异常重试唤醒失败 wo={} err={}", no, e.getMessage());
|
||||
return "无法提交重试: " + e.getMessage();
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String readQuietly(HttpURLConnection conn, boolean ok) {
|
||||
try {
|
||||
java.io.InputStream in = ok ? conn.getInputStream() : conn.getErrorStream();
|
||||
if (in == null) {
|
||||
return "";
|
||||
}
|
||||
java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[512];
|
||||
int n;
|
||||
while ((n = in.read(chunk)) > 0) {
|
||||
buf.write(chunk, 0, n);
|
||||
}
|
||||
return new String(buf.toByteArray(), StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static JSONObject safeJson(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parseObject(text);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2484,7 +2484,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
String[] markers = {
|
||||
"起运港", "目的港", "询价日期", "品名", "货物数量", "整柜或拼柜", "箱型箱量",
|
||||
"贸易条款", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"贸易条款", "运输分类", "货好时间", "商品海关编码", "是否含油", "提货地址", "送货地址", "报价有效期", "费用总计",
|
||||
"始发港", "目的地", "Inquiry Date", "Commodity", "Place of Receipt"
|
||||
};
|
||||
for (String m : markers) {
|
||||
|
||||
+15
-2
@@ -93,6 +93,11 @@ public class InquiryTmsQueryService {
|
||||
if (items != null) {
|
||||
segment.put("containerItems", items);
|
||||
}
|
||||
segment.put("transportTerms", first(
|
||||
facts.get("贸易条款"),
|
||||
facts.get("trade_terms"),
|
||||
facts.get("transportTerms"),
|
||||
facts.get("tradeTerms")));
|
||||
} else if ("LAND".equals(line)) {
|
||||
// 陆运不套空运包装/件数;分类和中港装载只在工单,mapper 不会放进出站。
|
||||
segment.putAll(TmsLandSegmentMapper.build(facts));
|
||||
@@ -556,6 +561,10 @@ public class InquiryTmsQueryService {
|
||||
appendFeeLineFromRow(buckets.get(bucket), (Map<?, ?>) one, headerCurrency);
|
||||
}
|
||||
}
|
||||
String bucketTotal = firstText(row.get("totalCostPrice"), row.get("total_cost_price"));
|
||||
if (StringUtils.isNotBlank(bucketTotal)) {
|
||||
quote.put("fee_total_" + bucket, bucketTotal);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, List<Map<String, String>>> e : buckets.entrySet()) {
|
||||
@@ -623,8 +632,8 @@ public class InquiryTmsQueryService {
|
||||
* 对不上机场也要查,TMS 自己回无价或 1003。
|
||||
*/
|
||||
/**
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量。
|
||||
* 不要求机场三字码、件数、重量、体积、包装。
|
||||
* 海运出站必填:工单号、报价日期、起运/目的名称、品名、货量、整柜或拼柜、箱型箱量、贸易条款。
|
||||
* 不要求机场三字码、件数、重量、体积、包装;运输分类不进出站。
|
||||
*/
|
||||
private static List<String> missingSeaQuery(String workOrderNo, String quoteDate,
|
||||
Map<String, Object> segment, Map<String, Object> facts) {
|
||||
@@ -649,6 +658,10 @@ public class InquiryTmsQueryService {
|
||||
if (blank(facts.get("箱型箱量"))) {
|
||||
missing.add("箱型箱量");
|
||||
}
|
||||
if (blank(facts.get("贸易条款")) && blank(facts.get("trade_terms"))
|
||||
&& blank(facts.get("transportTerms")) && blank(facts.get("tradeTerms"))) {
|
||||
missing.add("贸易条款");
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -113,6 +113,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
put(target, "containerType", type);
|
||||
put(target, "containerQty", asInteger(qty));
|
||||
// TMS 查询字段是 transportTerms。工单/卡片仍写「贸易条款」,这里只改出站名。
|
||||
put(target, "transportTerms", first(source, "transportTerms", "tradeTerms", "贸易条款", "trade_terms"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-9
@@ -19,15 +19,7 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_common' WHERE `id`='rt100000000000000000000000000011' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_special' WHERE `id`='rt100000000000000000000000000012' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_express' WHERE `id`='rt100000000000000000000000000013' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_common' WHERE `id`='rt100000000000000000000000000014' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_special' WHERE `id`='rt100000000000000000000000000015' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_express' WHERE `id`='rt100000000000000000000000000016' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_common' WHERE `id`='rt100000000000000000000000000017' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_special' WHERE `id`='rt100000000000000000000000000018' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_express' WHERE `id`='rt100000000000000000000000000019' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
|
||||
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-- 超时小时改为小数,0 表示该规则不监测。
|
||||
-- 仅改 inquiry_robot;幂等说明:重复执行若已是 decimal 会报 1060/1064,可忽略。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_response_timeout`
|
||||
MODIFY COLUMN `timeout_hours` decimal(10,4) NOT NULL DEFAULT 0 COMMENT '超时小时,允许小数,0=不监测';
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
-- 陆运产品运输分类:只保留产品确认的 4 项,删除按线路重复的 ltl/city/sea 共 9 条。
|
||||
-- 保留 land_hk_common / land_hk_cross / land_hk_special / land_hk_express,清空 route_category(分类与线路解耦)。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 1) 删除按线路拆开的重复产品行
|
||||
DELETE FROM `inq_response_timeout`
|
||||
WHERE `timeout_code` IN (
|
||||
'land_ltl_common','land_ltl_special','land_ltl_express',
|
||||
'land_city_common','land_city_special','land_city_express',
|
||||
'land_sea_common','land_sea_special','land_sea_express'
|
||||
);
|
||||
|
||||
DELETE FROM `inq_response_timeout`
|
||||
WHERE `id` IN (
|
||||
'rt100000000000000000000000000011',
|
||||
'rt100000000000000000000000000012',
|
||||
'rt100000000000000000000000000013',
|
||||
'rt100000000000000000000000000014',
|
||||
'rt100000000000000000000000000015',
|
||||
'rt100000000000000000000000000016',
|
||||
'rt100000000000000000000000000017',
|
||||
'rt100000000000000000000000000018',
|
||||
'rt100000000000000000000000000019'
|
||||
);
|
||||
|
||||
-- 2) 保留的 4 项:分类与线路解耦,route_category 置空;文案对齐产品确认
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中港常用线路/常规车型)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000007'
|
||||
OR `timeout_code`='land_hk_common';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及国内+国外段)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000008'
|
||||
OR `timeout_code`='land_hk_cross';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000009'
|
||||
OR `timeout_code`='land_hk_special';
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='急件',
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000010'
|
||||
OR `timeout_code`='land_hk_express';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- 工单事件异常当前等待:种类、起点、是否已告警。
|
||||
-- 仅改 inquiry_robot。列已存在则跳过(手工执行前请 DESCRIBE inq_ticket)。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_ticket`
|
||||
ADD COLUMN `event_wait_kind` varchar(32) NULL COMMENT '事件异常当前等待' AFTER `event_exception`,
|
||||
ADD COLUMN `event_wait_started_at` datetime NULL COMMENT '当前等待起点' AFTER `event_wait_kind`,
|
||||
ADD COLUMN `event_wait_alerted` tinyint NULL DEFAULT 0 COMMENT '当前等待是否已告警' AFTER `event_wait_started_at`;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- 系统异常重试上下文:步骤 + JSON。仅改 inquiry_robot。
|
||||
-- 两列展示字段 system_exception / system_exception_reason 已存在。
|
||||
-- 可重复执行:已有列则跳过。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @exists_step := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'inq_ticket' AND COLUMN_NAME = 'system_exception_step'
|
||||
);
|
||||
SET @sql_step := IF(
|
||||
@exists_step = 0,
|
||||
'ALTER TABLE `inq_ticket` ADD COLUMN `system_exception_step` varchar(64) NULL COMMENT ''系统异常步骤:tms_quote/tms_cabin/llm/group'' AFTER `system_exception_reason`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_step FROM @sql_step;
|
||||
EXECUTE stmt_step;
|
||||
DEALLOCATE PREPARE stmt_step;
|
||||
|
||||
SET @exists_payload := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'inq_ticket' AND COLUMN_NAME = 'system_exception_payload'
|
||||
);
|
||||
SET @sql_payload := IF(
|
||||
@exists_payload = 0,
|
||||
'ALTER TABLE `inq_ticket` ADD COLUMN `system_exception_payload` text NULL COMMENT ''系统异常重试上下文 JSON'' AFTER `system_exception_step`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt_payload FROM @sql_payload;
|
||||
EXECUTE stmt_payload;
|
||||
DEALLOCATE PREPARE stmt_payload;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package org.jeecg.modules.inquiry.domain;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 事件异常纯规则:摘要拼接、可盯状态、换等待种类才重置计时。
|
||||
*/
|
||||
public class EventExceptionRuleTest {
|
||||
|
||||
@Test
|
||||
public void appendKeepsBothTypes() {
|
||||
assertEquals("航线响应超时", EventExceptionRule.appendSummary("", "航线响应超时"));
|
||||
assertEquals("航线响应超时;销售响应超时",
|
||||
EventExceptionRule.appendSummary("航线响应超时", "销售响应超时"));
|
||||
assertEquals("航线响应超时", EventExceptionRule.appendSummary("航线响应超时", "航线响应超时"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lostIsWatchableHandoffIsNot() {
|
||||
assertTrue(EventExceptionRule.isWatchable("未成交"));
|
||||
assertTrue(EventExceptionRule.isWatchable(TicketStatuses.INQUIRING));
|
||||
assertFalse(EventExceptionRule.isWatchable("已成交"));
|
||||
assertFalse(EventExceptionRule.isWatchable("已关闭"));
|
||||
assertFalse(EventExceptionRule.isWatchable("转人工"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameKindDoesNotResetClock() {
|
||||
assertFalse(EventExceptionRule.shouldResetClock("air_quote", "air_quote"));
|
||||
assertTrue(EventExceptionRule.shouldResetClock("air_quote", "adopt"));
|
||||
assertTrue(EventExceptionRule.shouldResetClock("", "product_quote"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applyStartKeepsClockOnSameKind() {
|
||||
java.util.Date first = new java.util.Date(1_000L);
|
||||
java.util.Date now = new java.util.Date(2_000L);
|
||||
EventExceptionRule.EventWaitState keep = EventExceptionRule.applyStart(
|
||||
"air_quote", first, 1, "air_quote", now);
|
||||
assertEquals("air_quote", keep.kind);
|
||||
assertEquals(first, keep.startedAt);
|
||||
assertEquals(1, keep.alerted);
|
||||
EventExceptionRule.EventWaitState reset = EventExceptionRule.applyStart(
|
||||
"air_quote", first, 1, "adopt", now);
|
||||
assertEquals("adopt", reset.kind);
|
||||
assertEquals(now, reset.startedAt);
|
||||
assertEquals(0, reset.alerted);
|
||||
}
|
||||
}
|
||||
+43
@@ -291,6 +291,8 @@ public class QuoteTemplateFillTest {
|
||||
facts.put("货量", "15件");
|
||||
facts.put("整柜或拼柜", "整柜");
|
||||
facts.put("箱型箱量", "20GP*1");
|
||||
facts.put("贸易条款", "FOB");
|
||||
facts.put("运输分类", "Port to Port");
|
||||
facts.put("报价日期", "2026-09-15");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "2287.65");
|
||||
@@ -306,9 +308,50 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("15件", out.getString("quantity"));
|
||||
assertEquals("整柜", out.getString("fcl_or_lcl"));
|
||||
assertEquals("20GP*1", out.getString("container_type_qty"));
|
||||
assertEquals("FOB", out.getString("trade_terms"));
|
||||
assertEquals("Port to Port", out.getString("运输分类"));
|
||||
assertEquals("2287.65", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运询价栏:贸易条款、运输分类按标签右侧原文写入。
|
||||
* 标签格子本身不能被港口/分类值盖掉。
|
||||
*/
|
||||
@Test
|
||||
public void seaInquiryWritesTradeTermsAndClassByLabel() throws Exception {
|
||||
Path src = Files.createTempFile("quote-sea-class-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-sea-class-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("海运询价单");
|
||||
row(sheet, 8).createCell(0).setCellValue("贸易条款");
|
||||
row(sheet, 9).createCell(0).setCellValue("运输分类");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
Map<String, Object> ticket = new HashMap<>();
|
||||
ticket.put("贸易条款", "FOB");
|
||||
ticket.put("运输分类", "Port to Port");
|
||||
ticket.put("起运港", "DONGGUAN");
|
||||
JSONObject facts = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609200010", ticket, new HashMap<>(), "WuJiLin");
|
||||
assertEquals("FOB", facts.getString("trade_terms"));
|
||||
assertEquals("Port to Port", facts.getString("运输分类"));
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("贸易条款", read(sheet, 8, 0));
|
||||
assertEquals("FOB", read(sheet, 8, 1));
|
||||
assertEquals("运输分类", read(sheet, 9, 0));
|
||||
assertEquals("Port to Port", read(sheet, 9, 1));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildFillFactsProjectsCollabFields() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
|
||||
+38
@@ -182,4 +182,42 @@ public class TmsQuoteCurrencyTest {
|
||||
assertTrue(quote.get("fee_lines").contains("运输费"));
|
||||
assertTrue(quote.get("fee_lines").contains("2800"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteKeepsSeaBucketTotals() {
|
||||
Map<String, Object> pol = new HashMap<>();
|
||||
pol.put("feeName", "操作费");
|
||||
pol.put("amount", 1020);
|
||||
pol.put("currency", "CNY");
|
||||
Map<String, Object> polWrap = new java.util.LinkedHashMap<>();
|
||||
polWrap.put("polCostItems", Collections.singletonList(pol));
|
||||
polWrap.put("totalCostPrice", 1070);
|
||||
Map<String, Object> ocean = new HashMap<>();
|
||||
ocean.put("feeName", "海运费");
|
||||
ocean.put("amount", 575);
|
||||
ocean.put("currency", "USD");
|
||||
Map<String, Object> oceanWrap = new java.util.LinkedHashMap<>();
|
||||
oceanWrap.put("oceanCostItems", Collections.singletonList(ocean));
|
||||
oceanWrap.put("totalCostPrice", 4038);
|
||||
Map<String, Object> pod = new HashMap<>();
|
||||
pod.put("feeName", "文件费");
|
||||
pod.put("amount", 450);
|
||||
pod.put("currency", "CNY");
|
||||
Map<String, Object> podWrap = new java.util.LinkedHashMap<>();
|
||||
podWrap.put("podCostItems", Collections.singletonList(pod));
|
||||
podWrap.put("totalCostPrice", 470);
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("totalCostPrice", 6952.77);
|
||||
data.put("currency", "CNY");
|
||||
data.put("feeItems", java.util.Arrays.asList(polWrap, oceanWrap, podWrap));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("1070", quote.get("fee_total_polCostItems"));
|
||||
assertEquals("4038", quote.get("fee_total_oceanCostItems"));
|
||||
assertEquals("470", quote.get("fee_total_podCostItems"));
|
||||
assertTrue(quote.get("fee_rows_oceanCostItems").contains("575"));
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -130,4 +130,30 @@ public class TmsV2RequestMapperTest {
|
||||
assertFalse(segs.get(0).containsKey("landRouteCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landClass"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaSendsTransportTermsOmitsClass() {
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("origin", "上海");
|
||||
seg.put("destination", "洛杉矶");
|
||||
seg.put("cargoName", "普货");
|
||||
seg.put("cargoQty", "20吨");
|
||||
seg.put("loadType", "整柜");
|
||||
seg.put("containerSpec", "1x40HQ");
|
||||
// 测服 WO202609200019:工单仍是贸易条款,出站必须写成 transportTerms,不能写 tradeTerms。
|
||||
seg.put("贸易条款", "4");
|
||||
seg.put("运输分类", "Port to Port");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "SEA");
|
||||
internal.put("quoteDate", "2026-09-15");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals("4", segs.get(0).get("transportTerms"));
|
||||
assertFalse(segs.get(0).containsKey("tradeTerms"));
|
||||
assertFalse(segs.get(0).containsKey("贸易条款"));
|
||||
assertFalse(segs.get(0).containsKey("运输分类"));
|
||||
assertFalse(body.containsKey("运输分类"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
|
||||
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -82,14 +82,14 @@ async function loadList() {
|
||||
onMounted(() => loadList());
|
||||
|
||||
function onHoursInput(id, raw) {
|
||||
const n = Math.floor(Number(raw));
|
||||
const n = Number(raw);
|
||||
draftHours.value[id] = Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
async function commitHours(row) {
|
||||
const hours = draftHours.value[row.id];
|
||||
if (hours == null || !Number.isFinite(hours) || hours < 0) {
|
||||
message.warning('超时小时须为 ≥0 的整数');
|
||||
message.warning('超时小时须为 ≥0 的数字,支持小数');
|
||||
draftHours.value[row.id] = row.timeoutHours;
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ async function commitHours(row) {
|
||||
<template>
|
||||
<div class="sys-setting-page">
|
||||
<div class="toolbar-bar">
|
||||
<span class="hint">固定规则,仅可改超时小时;编码唯一只读,供智能体调用;失焦或回车保存</span>
|
||||
<span class="hint">固定规则,仅可改超时小时(支持小数,0=不监测);编码唯一只读,供智能体调用;失焦或回车保存</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
||||
@@ -151,7 +151,7 @@ async function commitHours(row) {
|
||||
class="field hours-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
:value="draftHours[row.id]"
|
||||
:disabled="savingId === row.id"
|
||||
@input="onHoursInput(row.id, $event.target.value)"
|
||||
|
||||
@@ -598,7 +598,7 @@ function refresh() {
|
||||
<td class="align-top col-actions col-sticky-right">
|
||||
<div class="ticket-row-actions">
|
||||
<button
|
||||
v-if="row.systemException !== '-'"
|
||||
v-if="row.systemException !== '-' && row.status !== '已关闭'"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="retryTicket(row)"
|
||||
@@ -640,6 +640,14 @@ function refresh() {
|
||||
>
|
||||
<template #titleExtra>
|
||||
<span v-if="current" class="ticket-detail-no">{{ current.id }}</span>
|
||||
<button
|
||||
v-if="current && current.systemException !== '-' && current.status !== '已关闭'"
|
||||
class="link-action"
|
||||
type="button"
|
||||
@click="retryTicket(current)"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="current" class="ticket-detail">
|
||||
<section class="ticket-detail-section">
|
||||
|
||||
Reference in New Issue
Block a user