Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ad26742f | ||
|
|
8afe909230 | ||
|
|
a878b046d2 | ||
|
|
fc1b85113d | ||
|
|
1bab19f1a2 | ||
|
|
9e27bada94 | ||
|
|
1c742353b8 | ||
|
|
c24d8c19c0 | ||
|
|
2527901efd | ||
|
|
ef0424a28f |
@@ -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)
|
||||
|
||||
@@ -477,3 +477,49 @@ class HttpLedger:
|
||||
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,10 @@ 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 = ""
|
||||
@@ -77,6 +81,7 @@ class MemoryLedger:
|
||||
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
|
||||
@@ -727,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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -802,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
|
||||
|
||||
@@ -1076,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
|
||||
@@ -1128,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(
|
||||
@@ -1186,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(
|
||||
@@ -1264,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:
|
||||
@@ -1423,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"
|
||||
|
||||
@@ -2439,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,
|
||||
@@ -2459,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运维排查,请稍等。"
|
||||
)
|
||||
|
||||
|
||||
@@ -2484,6 +2496,8 @@ 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排查中..."
|
||||
@@ -2497,6 +2511,8 @@ IMPACT_LLM_NO_TICKET = "尚未建单,不落后台工单"
|
||||
IMPACT_LLM = "当前识别暂停,已有字段与工单状态保持不变"
|
||||
IMPACT_TMS_QUOTE = "当前询价流程暂停,识别字段与原业务节点保持不变"
|
||||
IMPACT_TMS_CABIN = "当前锁舱/释放暂停,工单状态保持不变"
|
||||
IMPACT_TMS_LOCK = "锁舱失败,工单状态保持不变"
|
||||
IMPACT_TMS_RELEASE = "释放舱位失败,工单状态保持不变"
|
||||
IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
||||
|
||||
|
||||
@@ -2519,7 +2535,7 @@ 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 == STEP_TMS_CABIN:
|
||||
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
|
||||
@@ -2533,6 +2549,10 @@ def system_exception_impact(step: str) -> str:
|
||||
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:
|
||||
@@ -2554,11 +2574,11 @@ def system_exception_alert_payload(
|
||||
系统异常通知卡:走询价消息服务,不走询价小助手。
|
||||
|
||||
五行对齐现网示例:异常类型、接口/服务、错误码、影响范围、发生时间。
|
||||
标题不加 [test]:运维要看的就是这张卡本身,test_prefix 仅保留兼容入参。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "无"
|
||||
_ = test_prefix
|
||||
title = "系统异常通知"
|
||||
if test_prefix:
|
||||
title = "[test] " + title
|
||||
rows = [
|
||||
{"keyname": "异常类型", "value": (exception_type or "").strip() or "-"},
|
||||
{"keyname": "接口/服务", "value": (service or "").strip() or "-"},
|
||||
@@ -2596,11 +2616,10 @@ def system_exception_alert_text(
|
||||
occurred_at: str,
|
||||
test_prefix: bool = False,
|
||||
) -> str:
|
||||
"""告警卡纯文本兜底(通道降级时仍能看懂)。"""
|
||||
"""告警卡纯文本兜底(通道降级时仍能看懂)。标题同样不加 [test]。"""
|
||||
no = (work_order_no or "").strip() or "无"
|
||||
_ = test_prefix
|
||||
head = "系统异常通知"
|
||||
if test_prefix:
|
||||
head = "[test] " + head
|
||||
lines = [
|
||||
"询价机器人系统告警",
|
||||
head,
|
||||
@@ -2612,3 +2631,100 @@ def system_exception_alert_text(
|
||||
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)
|
||||
|
||||
@@ -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 = "已报价"
|
||||
|
||||
@@ -670,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,
|
||||
@@ -811,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 = ""
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
@@ -676,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)
|
||||
@@ -722,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)
|
||||
@@ -1587,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"]
|
||||
|
||||
+45
@@ -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,
|
||||
|
||||
+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;
|
||||
|
||||
+11
@@ -116,6 +116,17 @@ 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;
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
/**
|
||||
* 把陆运产品「普件,…」运输类型对齐产品确认的括号文案。
|
||||
|
||||
+20
@@ -136,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);
|
||||
}
|
||||
|
||||
+27
@@ -21,6 +21,7 @@ import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -885,6 +886,32 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
}
|
||||
}
|
||||
// 空运:已落库的旧明细若只有费率,出单时按计费重补 单位/单价/数量/行金额
|
||||
if (!rows.isEmpty() && StringUtils.isNotBlank(chargeable)) {
|
||||
List<Map<String, String>> lineMaps = new ArrayList<>();
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject one = rows.getJSONObject(i);
|
||||
if (one == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
for (String k : one.keySet()) {
|
||||
Object v = one.get(k);
|
||||
if (v != null) {
|
||||
m.put(k, String.valueOf(v));
|
||||
}
|
||||
}
|
||||
lineMaps.add(m);
|
||||
}
|
||||
Map<String, Object> enrichCtx = new LinkedHashMap<>();
|
||||
enrichCtx.put("chargeableWeightKg", chargeable);
|
||||
InquiryTmsQueryService.enrichAirWeightFeeLines(lineMaps, enrichCtx);
|
||||
JSONArray enriched = new JSONArray();
|
||||
for (Map<String, String> m : lineMaps) {
|
||||
enriched.add(new JSONObject(m));
|
||||
}
|
||||
rows = enriched;
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject one = rows.getJSONObject(i);
|
||||
if (one == null) {
|
||||
|
||||
+2
-2
@@ -71,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);
|
||||
|
||||
+200
-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;
|
||||
@@ -101,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"))) {
|
||||
@@ -1405,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());
|
||||
|
||||
+42
@@ -51,6 +51,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
FILL_BY_LABEL.put("有效期至", "valid_until");
|
||||
FILL_BY_LABEL.put("费用总计", "total_amount");
|
||||
FILL_BY_LABEL.put("报价合计", "total_amount");
|
||||
FILL_BY_LABEL.put("预估费用", "total_amount");
|
||||
FILL_BY_LABEL.put("预估总价", "total_amount");
|
||||
FILL_BY_LABEL.put("总销售价", "total_amount");
|
||||
FILL_BY_LABEL.put("RFQ No", "quote_no");
|
||||
FILL_BY_LABEL.put("报价单编号", "quote_no");
|
||||
FILL_BY_LABEL.put("报价编号", "quote_no");
|
||||
@@ -592,6 +595,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
|
||||
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
|
||||
refreshFormulas(wb);
|
||||
// 刷公式后:预估费用若 SUM 了「发生时收取」固定费,会与 TMS 总销售价不一致。
|
||||
// 有 total_amount 时再按标签写回,以总销售价为准。
|
||||
writeFactsByLabel(wb, facts, yellow);
|
||||
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||
applyQuotePrintLayout(wb);
|
||||
Files.createDirectories(targetXlsx.getParent());
|
||||
@@ -740,6 +746,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
sheet = wb.getSheetAt(0);
|
||||
}
|
||||
int startRow = resolveFeeDataStartRow(sheet, region, anchorRef.row);
|
||||
// 映射缺单位/单价/数量列时,按表头补齐,否则明细有值也写不进 Excel
|
||||
columns = mergeFeeColumnsFromHeader(sheet, startRow, columns);
|
||||
// 数据起点整行已标黄:本段是模板固定占位(如陆运「项目二」),整段跳过
|
||||
if (rowLooksYellowFixed(sheet, startRow)) {
|
||||
log.info("费用区起点为黄底固定区,跳过赋值 region={} startRow={}", region.getString("id"), startRow);
|
||||
@@ -1446,6 +1454,40 @@ public final class QuoteTemplateExcelSupport {
|
||||
applyRemarkWrap(sheet, row, cell, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI mapping 常只标 name/amount/currency;空运表头有「单位/单价/数量」时现场补列字母。
|
||||
* 不覆盖 mapping 已有列,避免改错已对齐的金额列。
|
||||
*/
|
||||
static JSONObject mergeFeeColumnsFromHeader(Sheet sheet, int dataStartRow, JSONObject mapped) {
|
||||
JSONObject out = mapped == null ? new JSONObject(true) : new JSONObject(mapped);
|
||||
if (sheet == null) {
|
||||
return out;
|
||||
}
|
||||
boolean needUnit = StringUtils.isBlank(out.getString("unit"));
|
||||
boolean needPrice = StringUtils.isBlank(out.getString("unit_price"));
|
||||
boolean needQty = StringUtils.isBlank(out.getString("quantity"));
|
||||
if (!needUnit && !needPrice && !needQty) {
|
||||
return out;
|
||||
}
|
||||
Map<String, Integer> header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0));
|
||||
if (header == null || header.isEmpty()) {
|
||||
header = findFeeTableColumns(sheet);
|
||||
}
|
||||
if (header == null || header.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
if (needUnit && header.get("unit") != null) {
|
||||
out.put("unit", CellReference.convertNumToColString(header.get("unit")));
|
||||
}
|
||||
if (needPrice && header.get("unit_price") != null) {
|
||||
out.put("unit_price", CellReference.convertNumToColString(header.get("unit_price")));
|
||||
}
|
||||
if (needQty && header.get("quantity") != null) {
|
||||
out.put("quantity", CellReference.convertNumToColString(header.get("quantity")));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
|
||||
*/
|
||||
|
||||
+84
-2
@@ -526,9 +526,81 @@ public class InquiryTmsQueryService {
|
||||
addFeeLine(lines, "目的地杂费", firstText(data.get("destFee"), data.get("destinationFee")), headerCurrency, null);
|
||||
addFeeLine(lines, "其他附加费", firstText(other, data.get("otherFee"), data.get("sundryFee")), headerCurrency, null);
|
||||
}
|
||||
// 空运按计费重计价:TMS 常把单价写在 salePrice,单位/数量/单价缺省;补齐后再算行金额
|
||||
enrichAirWeightFeeLines(lines, data);
|
||||
return JSON.toJSONString(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运运输费:单价 × 计费重 = 行金额。
|
||||
* <p>
|
||||
* 现象:feeItems 只给 salePrice=11(实为单价),Excel 金额列写成 11,单位/单价/数量空,
|
||||
* 预估费用 SUM 成 140,而总销售价是 11×计费重。
|
||||
* 只处理按重计的运输/空运费,提送货费、人工费等固定费不动。
|
||||
*/
|
||||
public static void enrichAirWeightFeeLines(List<Map<String, String>> lines, Map<String, Object> data) {
|
||||
if (lines == null || lines.isEmpty() || data == null) {
|
||||
return;
|
||||
}
|
||||
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
|
||||
if (StringUtils.isBlank(chargeable) || "-".equals(chargeable)) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, String> line : lines) {
|
||||
if (line == null || !isAirWeightFeeName(line.get("name"))) {
|
||||
continue;
|
||||
}
|
||||
String unitPrice = firstText(line.get("unit_price"), line.get("unitPrice"));
|
||||
String qty = firstText(line.get("quantity"));
|
||||
String amount = firstText(line.get("amount"));
|
||||
String unit = firstText(line.get("unit"));
|
||||
if (StringUtils.isBlank(qty)) {
|
||||
line.put("quantity", chargeable);
|
||||
qty = chargeable;
|
||||
}
|
||||
if (StringUtils.isBlank(unit)) {
|
||||
line.put("unit", "KG");
|
||||
}
|
||||
// 有数量、无单价:当前 amount 当作单价(TMS 把费率塞进 salePrice/amount)
|
||||
if (StringUtils.isBlank(unitPrice) && StringUtils.isNotBlank(amount)) {
|
||||
line.put("unit_price", amount);
|
||||
unitPrice = amount;
|
||||
}
|
||||
// 单价与金额相同(或金额空):按 单价×数量 重算行金额
|
||||
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(qty)
|
||||
&& (StringUtils.isBlank(amount) || amount.equals(unitPrice))) {
|
||||
String product = multiplyDecimal(unitPrice, qty);
|
||||
if (StringUtils.isNotBlank(product)) {
|
||||
line.put("amount", product);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 空运按计费重的费目:运输费 / 空运费;不含提送货、人工、报关等固定费。 */
|
||||
public static boolean isAirWeightFeeName(String name) {
|
||||
String n = StringUtils.trimToEmpty(name);
|
||||
if (n.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (n.contains("提") || n.contains("送") || n.contains("人工") || n.contains("报关")
|
||||
|| n.contains("操作") || n.contains("文件") || n.contains("杂费")) {
|
||||
return false;
|
||||
}
|
||||
return n.contains("空运") || n.contains("运输费") || "运费".equals(n);
|
||||
}
|
||||
|
||||
static String multiplyDecimal(String left, String right) {
|
||||
try {
|
||||
java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", ""));
|
||||
java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", ""));
|
||||
java.math.BigDecimal product = a.multiply(b);
|
||||
return product.stripTrailingZeros().toPlainString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 海运三段费用桶单独落 quote,供模板 feeRegions 按 tmsSource 分区写,禁止三段共用扁平 fee_lines。
|
||||
*/
|
||||
@@ -579,8 +651,18 @@ public class InquiryTmsQueryService {
|
||||
private static void appendFeeLineFromRow(List<Map<String, String>> lines, Map<?, ?> row, String headerCurrency) {
|
||||
String name = firstText(row.get("feeName"), row.get("name"), row.get("chargeName"),
|
||||
row.get("itemName"), row.get("feeType"));
|
||||
String amount = firstText(row.get("salePrice"), row.get("amount"), row.get("price"),
|
||||
row.get("feeAmount"), row.get("totalPrice"));
|
||||
String unitPrice = firstText(row.get("unitPrice"), row.get("unit_price"));
|
||||
// 行金额优先认 amount/totalPrice;salePrice 与单价相同时当作费率,不当行金额
|
||||
String amount = firstText(row.get("amount"), row.get("totalPrice"), row.get("feeAmount"),
|
||||
row.get("price"));
|
||||
String sale = firstText(row.get("salePrice"));
|
||||
if (StringUtils.isBlank(amount)) {
|
||||
if (StringUtils.isNotBlank(sale) && StringUtils.isNotBlank(unitPrice) && sale.equals(unitPrice)) {
|
||||
amount = sale;
|
||||
} else if (StringUtils.isNotBlank(sale)) {
|
||||
amount = sale;
|
||||
}
|
||||
}
|
||||
String lineCurrency = TmsQuoteCurrency.fromFeeRow(row);
|
||||
addFeeLine(lines, name, amount,
|
||||
StringUtils.isNotBlank(lineCurrency) ? lineCurrency : headerCurrency,
|
||||
|
||||
+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=不监测';
|
||||
+5
-5
@@ -25,10 +25,10 @@ DELETE FROM `inq_response_timeout`
|
||||
'rt100000000000000000000000000019'
|
||||
);
|
||||
|
||||
-- 2) 保留的 4 项:分类与线路解耦,route_category 置空;文案对齐产品确认
|
||||
-- 2) 保留的 4 项:用 UNHEX 写入 UTF-8,避免客户端字符集把中文写成问号
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中港常用线路/常规车型)',
|
||||
`transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade6b8afe5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000007'
|
||||
@@ -36,7 +36,7 @@ UPDATE `inq_response_timeout`
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及国内+国外段)',
|
||||
`transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade4ba9ae38081e4b8ade6aca7e6b689e58f8ae59bbde586852be59bbde5a496e6aeb529') USING utf8mb4),
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000008'
|
||||
@@ -44,7 +44,7 @@ UPDATE `inq_response_timeout`
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)',
|
||||
`transport_type`=CONVERT(UNHEX('e699aee4bbb628e4b89ce58d97e4ba9ae38081e4b8ade4ba9ae38081e4b8ade6aca7e6b689e58f8ae5a283e5a496e6aeb5e6b885e6b4bee7ad89e789b9e6ae8ae8a681e6b18229') USING utf8mb4),
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000009'
|
||||
@@ -52,7 +52,7 @@ UPDATE `inq_response_timeout`
|
||||
|
||||
UPDATE `inq_response_timeout`
|
||||
SET `route_category`='',
|
||||
`transport_type`='急件',
|
||||
`transport_type`=CONVERT(UNHEX('e680a5e4bbb6') USING utf8mb4),
|
||||
`update_by`='system',
|
||||
`update_time`=NOW()
|
||||
WHERE `id`='rt100000000000000000000000000010'
|
||||
|
||||
+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`;
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+40
@@ -220,4 +220,44 @@ public class TmsQuoteCurrencyTest {
|
||||
assertEquals("470", quote.get("fee_total_podCostItems"));
|
||||
assertTrue(quote.get("fee_rows_oceanCostItems").contains("575"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteAirRateTimesChargeableWeight() {
|
||||
// TMS 把运费单价塞进 salePrice,数量/单价空;应用计费重算出行金额
|
||||
Map<String, Object> freight = new HashMap<>();
|
||||
freight.put("feeName", "运输费");
|
||||
freight.put("salePrice", "11.0");
|
||||
freight.put("currency", "CNY");
|
||||
Map<String, Object> pickup = new HashMap<>();
|
||||
pickup.put("feeName", "提送货费");
|
||||
pickup.put("salePrice", "6.0");
|
||||
pickup.put("currency", "CNY");
|
||||
pickup.put("remark", "发生时收取");
|
||||
Map<String, Object> labor = new HashMap<>();
|
||||
labor.put("feeName", "人工费");
|
||||
labor.put("salePrice", "123.0");
|
||||
labor.put("currency", "CNY");
|
||||
labor.put("remark", "发生时收取");
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("totalSalePrice", "183370.0");
|
||||
data.put("currency", "CNY");
|
||||
data.put("chargeableWeightKg", "16670.0000");
|
||||
data.put("feeItems", java.util.Arrays.asList(freight, pickup, labor));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("183370.0", quote.get("total"));
|
||||
assertEquals("16670.0000", quote.get("chargeableWeightKg"));
|
||||
assertTrue(quote.get("fee_lines").contains("\"name\":\"运输费\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"unit_price\":\"11.0\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"quantity\":\"16670.0000\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"unit\":\"KG\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"amount\":\"183370\""));
|
||||
// 固定费保持原金额,不乘计费重
|
||||
assertTrue(quote.get("fee_lines").contains("\"name\":\"提送货费\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"amount\":\"6.0\""));
|
||||
assertTrue(quote.get("fee_lines").contains("\"amount\":\"123.0\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user