Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2f0b76c4 |
@@ -50,6 +50,10 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if not no:
|
||||
return False
|
||||
# 进程/双 Worker 防重:同一工单同一天只发一轮(主账 next_followup 清不掉时曾连发 WO024)
|
||||
if _already_sent_reask(no, settings):
|
||||
logger.warning("协商再问去重跳过 wo=%s", no)
|
||||
return False
|
||||
chat_id = str(row.get("collab_chat_id") or row.get("collabChatId") or "").strip()
|
||||
sales = 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 "销售"
|
||||
@@ -69,6 +73,7 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
if not ok:
|
||||
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
|
||||
return False
|
||||
_mark_sent_reask(no, settings)
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
if not sales:
|
||||
@@ -87,10 +92,47 @@ def _reask_one(row: dict[str, Any], *, settings: Settings, ledger: Any = None) -
|
||||
if not ok:
|
||||
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
|
||||
return False
|
||||
_mark_sent_reask(no, settings)
|
||||
_start_deal_wait(ledger, no)
|
||||
return True
|
||||
|
||||
|
||||
def _already_sent_reask(work_order_no: str, settings: Settings) -> bool:
|
||||
"""
|
||||
跨进程去重:Redis SET NX。
|
||||
|
||||
返回 True=今天已发过应跳过;False=抢到发送权。
|
||||
主账 next_followup 若因 updateById 清不掉 null,没有这层会每分钟连发。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from agent.policy.deal_followup import SHANGHAI
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
from agent.redis_coord.idempotency import try_acquire
|
||||
|
||||
day = datetime.now(SHANGHAI).strftime("%Y-%m-%d")
|
||||
try:
|
||||
runtime = get_redis_runtime(settings)
|
||||
client = getattr(runtime, "client", None)
|
||||
if client is None:
|
||||
return False
|
||||
first = try_acquire(
|
||||
client,
|
||||
scope="deal_reask",
|
||||
idempotency_key=f"{work_order_no}:{day}",
|
||||
ttl_seconds=86400,
|
||||
)
|
||||
return not first
|
||||
except Exception:
|
||||
logger.exception("协商再问 Redis 去重失败 wo=%s", work_order_no)
|
||||
return False
|
||||
|
||||
|
||||
def _mark_sent_reask(work_order_no: str, settings: Settings) -> None:
|
||||
"""兼容旧调用点;去重键已在发送前 SET NX。"""
|
||||
_ = (work_order_no, settings)
|
||||
|
||||
|
||||
def _start_deal_wait(ledger: Any, work_order_no: str) -> None:
|
||||
"""再问发出后重新盯成交跟进。ledger 可空。"""
|
||||
from agent.policy.event_exception import WAIT_DEAL, start_wait
|
||||
|
||||
@@ -330,12 +330,32 @@ class HttpLedger:
|
||||
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "patch_lost_reason_failed"}
|
||||
|
||||
def claim_due_negotiate(self) -> list[dict[str, Any]]:
|
||||
"""认领到期协商再问,主账会清空再问时刻防重发。"""
|
||||
"""
|
||||
认领到期协商再问。
|
||||
|
||||
主账应用 UpdateWrapper 清空 next_followup_at;若旧包仍用 updateById
|
||||
写不进 null,这里对每单再调 clearNextFollowup 强制清掉,避免每分钟连发。
|
||||
"""
|
||||
out = self._post("/inquiry/agent/ticket/claimDueNegotiate", {})
|
||||
rows = out.get("tickets") or out.get("result") or []
|
||||
if isinstance(rows, list):
|
||||
return [dict(x) for x in rows if isinstance(x, dict)]
|
||||
return []
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
claimed: list[dict[str, Any]] = []
|
||||
for raw in rows:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
row = dict(raw)
|
||||
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
|
||||
if no:
|
||||
try:
|
||||
self._post(
|
||||
"/inquiry/agent/ticket/clearNextFollowup",
|
||||
{"workOrderNo": no, "work_order_no": no},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("强制清空再问时刻失败 wo=%s", no)
|
||||
claimed.append(row)
|
||||
return claimed
|
||||
|
||||
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
|
||||
"""主账按线路返回启用的海运产品岗。"""
|
||||
@@ -451,6 +471,43 @@ class HttpLedger:
|
||||
body["airline"] = airline.strip().upper()
|
||||
if (flight_no or "").strip():
|
||||
body["flightNo"] = flight_no.strip().upper()
|
||||
# #region agent log
|
||||
try:
|
||||
import json as _json
|
||||
import time as _time
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_line = _json.dumps(
|
||||
{
|
||||
"sessionId": "e38e4c",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "H-E",
|
||||
"location": "http_ledger.py:lock_cabin",
|
||||
"message": "ledger_lock_called",
|
||||
"data": {
|
||||
"wo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"airline": (airline or "").strip(),
|
||||
"flightNo": (flight_no or "").strip(),
|
||||
"instruction_head": (instruction_text or "")[:80],
|
||||
},
|
||||
"timestamp": int(_time.time() * 1000),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
for _p in (
|
||||
_Path(r"d:\own\友通达询价智能体(新)\debug-e38e4c.log"),
|
||||
_Path(r"C:/Users/ytd_extdev_0903/incoming/debug-e38e4c.log"),
|
||||
):
|
||||
try:
|
||||
_p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _p.open("a", encoding="utf-8") as _fh:
|
||||
_fh.write(_line)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
body,
|
||||
|
||||
@@ -109,7 +109,8 @@ 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] = {}
|
||||
# 单测默认带协商再问 48 工作小时,与主账 deal_negotiate 种子一致
|
||||
self._timeouts: dict[str, float] = {"deal_negotiate": 48.0}
|
||||
|
||||
def _next_no(self) -> str:
|
||||
self._seq += 1
|
||||
|
||||
@@ -885,13 +885,16 @@ _SKIP_FEE_NAMES = frozenset(
|
||||
{"usd", "cny", "rmb", "hkd", "eur", "jpy", "gbp", "美金", "美元", "人民币", "港币", "港元", "元"}
|
||||
)
|
||||
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
|
||||
# WO202610130003:协同句常见「空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900」——费名与单价之间夹单位;
|
||||
# 数量/金额前也可能只有空格无逗号。漏匹配会落到 _RE_FEE_LINE,把单价 99 当成行金额、数量被补成 1。
|
||||
_RE_FEE_PRICED = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
|
||||
r"\s*单价\s*"
|
||||
r"(?:\s*[,,、]\s*(?:KG|KGS|公斤|千克|TON|CBM|柜|票|板|点|[A-Za-z]{1,8}))?"
|
||||
r"\s*[,,、]?\s*单价\s*"
|
||||
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<unit>\d+(?:\.\d+)?)"
|
||||
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]\s*金额\s*"
|
||||
r"(?:\s*[,,、]?\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]?\s*金额\s*"
|
||||
rf"(?:(?P<amt_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?))?",
|
||||
re.I,
|
||||
|
||||
@@ -48,6 +48,8 @@ _PENDING_LOCK = "__pending_air_lock"
|
||||
_PENDING_LOCK_OPTION = "__pending_lock_option"
|
||||
_PENDING_LOCK_AIRLINE = "__pending_lock_airline"
|
||||
_PENDING_LOCK_FLIGHT = "__pending_lock_flight"
|
||||
|
||||
|
||||
_LOCK_ID = "__air_lock_id"
|
||||
_LOCK_OPTION = "__air_lock_option"
|
||||
_LOCK_QUOTE = "__air_lock_quote"
|
||||
@@ -861,6 +863,7 @@ def apply_air_deal(
|
||||
no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
),
|
||||
)
|
||||
return phase
|
||||
@@ -1518,11 +1521,13 @@ def _resolve_lock_params(
|
||||
air = _normalize_airline(airline or said_air or str(facts.get(_PENDING_LOCK_AIRLINE) or ""))
|
||||
flight = _normalize_flight(flight_no or said_flight or str(facts.get(_PENDING_LOCK_FLIGHT) or ""))
|
||||
chosen = _option_row(quote, option)
|
||||
single_auto = False
|
||||
if chosen is None and not option:
|
||||
options = _air_options(quote)
|
||||
if len(options) == 1:
|
||||
chosen = options[0]
|
||||
option = str(chosen.get("optionNo") or chosen.get("option_no") or "").strip()
|
||||
single_auto = True
|
||||
if not _airline_ready(air) and chosen is not None:
|
||||
air = _normalize_airline(
|
||||
str(chosen.get("airlineCode") or chosen.get("airline") or "")
|
||||
|
||||
@@ -2007,6 +2007,7 @@ class AirTextInquiryFlow:
|
||||
sess.work_order_no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
)
|
||||
)
|
||||
return phase
|
||||
|
||||
@@ -88,7 +88,7 @@ def shift_workdays(start: date, count: int) -> date:
|
||||
"""
|
||||
从 start 当天之后数 count 个工作日。
|
||||
|
||||
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
|
||||
仍给单测与节假日校验用;协商再问已改走 add_workday_hours。
|
||||
"""
|
||||
if count < 1:
|
||||
return start
|
||||
@@ -101,11 +101,51 @@ def shift_workdays(start: date, count: int) -> date:
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None) -> datetime:
|
||||
"""第四个工作日 Asia/Shanghai 09:00。"""
|
||||
# 协商中再问:主账 timeoutCode;缺配置时默认 48 工作小时。
|
||||
TIMEOUT_CODE_DEAL_NEGOTIATE = "deal_negotiate"
|
||||
DEFAULT_NEGOTIATE_HOURS = 48.0
|
||||
|
||||
|
||||
def add_workday_hours(start: datetime, hours: float) -> datetime:
|
||||
"""
|
||||
从 start 起累加 hours 个工作小时;非工作日整天跳过。
|
||||
|
||||
与 workday_elapsed_hours 互逆:工作日当天 0–24 都算。
|
||||
起点落在休息日:从下一个工作日 00:00 起算。
|
||||
hours≤0 时原样返回(调用方应不排期)。
|
||||
"""
|
||||
base = start.astimezone(SHANGHAI) if start.tzinfo else start.replace(tzinfo=SHANGHAI)
|
||||
remaining = float(hours)
|
||||
if remaining <= 0:
|
||||
return base
|
||||
cur = base
|
||||
if not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
while not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
while remaining > 0:
|
||||
if not is_workday(cur.date()):
|
||||
cur = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
continue
|
||||
day_end = datetime.combine(cur.date() + timedelta(days=1), time(0, 0), tzinfo=SHANGHAI)
|
||||
room = (day_end - cur).total_seconds() / 3600.0
|
||||
if remaining <= room + 1e-9:
|
||||
return cur + timedelta(hours=remaining)
|
||||
remaining -= room
|
||||
cur = day_end
|
||||
return cur
|
||||
|
||||
|
||||
def next_followup_at(now: datetime | None = None, *, hours: float | None = None) -> datetime:
|
||||
"""
|
||||
距今 hours 个工作小时后的再问时刻(默认 48)。
|
||||
|
||||
非工作日不计入;提醒时刻不再固定 09:00。
|
||||
hours≤0 时返回起点(调用方应把 0 当作不排再问)。
|
||||
"""
|
||||
base = now.astimezone(SHANGHAI) if now else datetime.now(SHANGHAI)
|
||||
day = shift_workdays(base.date(), 4)
|
||||
return datetime.combine(day, time(9, 0), tzinfo=SHANGHAI)
|
||||
h = DEFAULT_NEGOTIATE_HOURS if hours is None else float(hours)
|
||||
return add_workday_hours(base, h)
|
||||
|
||||
|
||||
def workday_elapsed_hours(start: datetime, now: datetime) -> float:
|
||||
|
||||
@@ -13,7 +13,12 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.deal_followup import next_followup_at, pick_leaders
|
||||
from agent.policy.deal_followup import (
|
||||
DEFAULT_NEGOTIATE_HOURS,
|
||||
TIMEOUT_CODE_DEAL_NEGOTIATE,
|
||||
next_followup_at,
|
||||
pick_leaders,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -156,8 +161,14 @@ def finish_deal_outcome(
|
||||
status = (outcome or "").strip()
|
||||
extra: dict[str, Any] = {}
|
||||
summary = ""
|
||||
negotiate_hours = 0.0
|
||||
if status == "协商中":
|
||||
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
|
||||
# 小时读后台超时响应 deal_negotiate;0=不排再问;缺配置默认 48
|
||||
negotiate_hours = _negotiate_followup_hours(ledger)
|
||||
if negotiate_hours > 0:
|
||||
extra["next_followup_at"] = next_followup_at(hours=negotiate_hours).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
if status == "已成交":
|
||||
staff: list[dict[str, Any]] = []
|
||||
lister = getattr(ledger, "list_staff_by_role", None)
|
||||
@@ -242,9 +253,38 @@ def finish_deal_outcome(
|
||||
"notify_summary": summary,
|
||||
"status": status,
|
||||
"next_followup_at": str(extra.get("next_followup_at") or ""),
|
||||
"negotiate_hours": negotiate_hours,
|
||||
}
|
||||
|
||||
|
||||
def _negotiate_followup_hours(ledger: Any) -> float:
|
||||
"""
|
||||
读主账 deal_negotiate 超时小时;找不到或异常则用默认 48。
|
||||
|
||||
允许小数;负数当 0(不排再问)。不在回调线程打主账以外的慢活。
|
||||
"""
|
||||
lookup = getattr(ledger, "get_timeout_by_code", None)
|
||||
if not callable(lookup):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
try:
|
||||
row = lookup(timeout_code=TIMEOUT_CODE_DEAL_NEGOTIATE) or {}
|
||||
except Exception:
|
||||
logger.exception("读协商再问小时失败,用默认 %s", DEFAULT_NEGOTIATE_HOURS)
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
if not row.get("found"):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
raw = row.get("timeoutHours")
|
||||
if raw is None:
|
||||
raw = row.get("timeout_hours")
|
||||
try:
|
||||
hours = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_NEGOTIATE_HOURS
|
||||
if hours < 0:
|
||||
return 0.0
|
||||
return hours
|
||||
|
||||
|
||||
def _transition_deal_status(
|
||||
writer: Callable[..., dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -2775,6 +2775,7 @@ def deal_ack(
|
||||
work_order_no: str,
|
||||
next_followup_at: str = "",
|
||||
lost_reason: str = "",
|
||||
followup_hours: float | None = None,
|
||||
) -> str:
|
||||
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -2789,26 +2790,42 @@ def deal_ack(
|
||||
if reason:
|
||||
return f"已记录为「未成交」。{lost_reason_ack(reason, work_order_no=no)}"
|
||||
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
|
||||
when = _followup_month_day(next_followup_at) or "第四个工作日"
|
||||
from agent.policy.event_exception import format_timeout_hours
|
||||
|
||||
# 小时缺省按后台默认 48;0 表示运营关掉再问,话术仍说明已记录协商中
|
||||
hours_val = 48.0 if followup_hours is None else float(followup_hours)
|
||||
hours_text = format_timeout_hours(hours_val if hours_val > 0 else 48.0)
|
||||
when = _followup_when_text(next_followup_at) or f"{hours_text} 个工作小时后"
|
||||
return _r(
|
||||
"negotiating.recorded",
|
||||
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
|
||||
f"已记录为「协商中」。已顺延 {hours_text} 个工作小时,将于 {when} 再次发起成交状态询问。",
|
||||
followup_when=when,
|
||||
followup_hours=hours_text,
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def _followup_month_day(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日」。"""
|
||||
"""把主账再问时刻收成「9月24日」(旧模板兼容)。"""
|
||||
when = _followup_when_text(raw)
|
||||
if "日" in when:
|
||||
return when.split("日", 1)[0] + "日"
|
||||
return when
|
||||
|
||||
|
||||
def _followup_when_text(raw: str) -> str:
|
||||
"""把主账再问时刻收成「9月24日15:30」。"""
|
||||
from datetime import datetime
|
||||
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d", 10)):
|
||||
for fmt, n in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||||
try:
|
||||
dt = datetime.strptime(text[:n], fmt)
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
if n <= 10:
|
||||
return f"{dt.month}月{dt.day}日"
|
||||
return f"{dt.month}月{dt.day}日{dt.hour}:{dt.minute:02d}"
|
||||
except ValueError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
@@ -1305,7 +1305,13 @@ def apply_group_deal(
|
||||
send_group_text(
|
||||
client,
|
||||
chat_id,
|
||||
copy.deal_ack(outcome, no, str(result.get("next_followup_at") or ""), lost_reason=ack_reason),
|
||||
copy.deal_ack(
|
||||
outcome,
|
||||
no,
|
||||
str(result.get("next_followup_at") or ""),
|
||||
lost_reason=ack_reason,
|
||||
followup_hours=result.get("negotiate_hours"),
|
||||
),
|
||||
)
|
||||
return phase
|
||||
|
||||
|
||||
@@ -75,13 +75,14 @@ class BotReplyRenderTests(unittest.TestCase):
|
||||
override_catalog(
|
||||
{
|
||||
"negotiating.recorded": (
|
||||
"已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。"
|
||||
"已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,"
|
||||
"将于 {{followup_when}} 再次发起成交状态询问。"
|
||||
)
|
||||
}
|
||||
)
|
||||
text = copy.deal_ack("协商中", "WO1", next_followup_at="")
|
||||
self.assertIn("第四个工作日", text)
|
||||
self.assertIn("9:00", text)
|
||||
text = copy.deal_ack("协商中", "WO1", next_followup_at="", followup_hours=48)
|
||||
self.assertIn("48", text)
|
||||
self.assertIn("工作小时", text)
|
||||
|
||||
def test_group_add_fail_empty_names_reads_admin(self) -> None:
|
||||
override_catalog(
|
||||
|
||||
@@ -10,6 +10,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from agent.policy.deal_followup import (
|
||||
SHANGHAI,
|
||||
add_workday_hours,
|
||||
air_route_keys,
|
||||
is_workday,
|
||||
leader_matches,
|
||||
@@ -41,12 +42,18 @@ class TestWorkdays(unittest.TestCase):
|
||||
self.assertTrue(is_workday(date(2026, 9, 20)))
|
||||
self.assertFalse(is_workday(date(2026, 10, 7)))
|
||||
|
||||
def test_next_followup_is_9am(self) -> None:
|
||||
when = next_followup_at(datetime(2026, 9, 18, 15, 30, tzinfo=SHANGHAI))
|
||||
self.assertEqual(when.hour, 9)
|
||||
self.assertEqual(when.minute, 0)
|
||||
def test_next_followup_48_work_hours_skips_weekend(self) -> None:
|
||||
# 周五 15:30 + 48 工作小时 → 跳过周末 → 周二 15:30
|
||||
when = next_followup_at(
|
||||
datetime(2026, 11, 13, 15, 30, tzinfo=SHANGHAI), hours=48
|
||||
)
|
||||
self.assertEqual(when.tzinfo, SHANGHAI)
|
||||
self.assertEqual(when.date(), date(2026, 9, 23))
|
||||
self.assertEqual(when, datetime(2026, 11, 17, 15, 30, tzinfo=SHANGHAI))
|
||||
|
||||
def test_add_workday_hours_from_rest_day(self) -> None:
|
||||
# 周六起点:从下周一 00:00 起算 10 小时 → 周一 10:00
|
||||
when = add_workday_hours(datetime(2026, 11, 14, 10, 0, tzinfo=SHANGHAI), 10)
|
||||
self.assertEqual(when, datetime(2026, 11, 16, 10, 0, tzinfo=SHANGHAI))
|
||||
|
||||
def test_skip_weekend_keep_weekday_night(self) -> None:
|
||||
start = datetime(2026, 11, 13, 17, 0, tzinfo=SHANGHAI) # 周五
|
||||
|
||||
@@ -277,15 +277,22 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertNotIn("请补充未成交原因", joined)
|
||||
|
||||
def test_negotiate_ack_names_followup_day(self) -> None:
|
||||
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
|
||||
text = copy.deal_ack(
|
||||
"协商中",
|
||||
"WO202609190007",
|
||||
"2026-09-24 15:30:00",
|
||||
followup_hours=48,
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
|
||||
"已记录为「协商中」。已顺延 48 个工作小时,将于 9月24日15:30 再次发起成交状态询问。",
|
||||
)
|
||||
self.assertNotIn("跟进周期已顺延", text)
|
||||
self.assertNotIn("三个工作日", text)
|
||||
|
||||
def test_negotiate_keeps_wait_deal(self) -> None:
|
||||
self.ledger.start_event_wait(work_order_no=self.wo, wait_kind="deal")
|
||||
self.ledger._timeouts["deal_negotiate"] = 48
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
@@ -295,6 +302,7 @@ class TestDealOutcome(unittest.TestCase):
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
self.assertEqual(out.get("negotiate_hours"), 48)
|
||||
ticket = self.ledger.get_ticket(work_order_no=self.wo)
|
||||
self.assertEqual(ticket.status, "协商中")
|
||||
self.assertEqual(ticket.event_wait_kind, "")
|
||||
@@ -313,6 +321,22 @@ class TestDealOutcome(unittest.TestCase):
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
|
||||
self.assertTrue(again.get("next_followup_at"))
|
||||
|
||||
def test_negotiate_zero_hours_skips_schedule(self) -> None:
|
||||
"""后台把 deal_negotiate 设为 0:仍记协商中,但不排再问时刻。"""
|
||||
self.ledger._timeouts["deal_negotiate"] = 0
|
||||
out = finish_deal_outcome(
|
||||
ledger=self.ledger,
|
||||
work_order_no=self.wo,
|
||||
outcome="协商中",
|
||||
facts={},
|
||||
quote={},
|
||||
business_line="SEA",
|
||||
)
|
||||
self.assertEqual(out.get("phase"), "wait_deal")
|
||||
self.assertEqual(out.get("negotiate_hours"), 0)
|
||||
self.assertEqual(out.get("next_followup_at"), "")
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
|
||||
|
||||
def test_private_next_text_is_lost_reason(self) -> None:
|
||||
flow = AirTextInquiryFlow(self.ledger)
|
||||
sess = FlowSession(
|
||||
|
||||
@@ -1376,8 +1376,24 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
|
||||
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
|
||||
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
|
||||
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(quote.get("total") or ""), "199.0")
|
||||
|
||||
def test_air_fee_priced_with_unit_between_name_and_unit_price(self) -> None:
|
||||
"""WO003:空运费, KG, 单价…数量…金额… 中间夹单位时不能把单价当行金额。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"空运段: 空运费, KG, 单价CNY99.00, 数量100, 金额CNY9900.00@询价小助手",
|
||||
business_line="AIR",
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("空运费", rows)
|
||||
self.assertEqual(str(rows["空运费"].get("amount") or ""), "9900.00")
|
||||
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
|
||||
self.assertEqual(str(rows["空运费"].get("quantity") or ""), "100")
|
||||
self.assertEqual(str(quote.get("total") or ""), "9900.0")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
+2
@@ -387,6 +387,8 @@ public class InquiryAgentController {
|
||||
return Result.error("timeoutCode 必填");
|
||||
}
|
||||
responseTimeoutService.ensureTimeoutCodes();
|
||||
// 协商再问配置可能尚未跑 SQL 迁移:首次按码查询时幂等补种子
|
||||
responseTimeoutService.ensureDealNegotiateRow();
|
||||
org.jeecg.modules.inquiry.entity.InquiryResponseTimeout row =
|
||||
responseTimeoutService.getByTimeoutCode(code);
|
||||
Map<String, Object> out = new java.util.LinkedHashMap<>();
|
||||
|
||||
+3
-1
@@ -41,6 +41,7 @@ public class InquiryResponseTimeoutController
|
||||
MODE_LABEL.put("AIR", "空运");
|
||||
MODE_LABEL.put("SEA", "海运");
|
||||
MODE_LABEL.put("LAND", "陆运");
|
||||
MODE_LABEL.put("DEAL", "成交跟进");
|
||||
ROLE_LABEL.put("sales", "销售");
|
||||
ROLE_LABEL.put("product", "产品");
|
||||
ROLE_LABEL.put("air_route", "航线");
|
||||
@@ -60,9 +61,10 @@ public class InquiryResponseTimeoutController
|
||||
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
|
||||
@GetMapping("/listAll")
|
||||
public Result<List<InquiryResponseTimeout>> listAll() {
|
||||
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码(幂等)
|
||||
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码、补协商再问种子(幂等)
|
||||
responseTimeoutService.ensureCanonicalTransportTypeLabels();
|
||||
responseTimeoutService.ensureTimeoutCodes();
|
||||
responseTimeoutService.ensureDealNegotiateRow();
|
||||
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
|
||||
qw.orderByAsc("sort_no");
|
||||
return Result.OK(responseTimeoutService.list(qw));
|
||||
|
||||
+22
@@ -14,10 +14,12 @@ import org.jeecg.modules.inquiry.dto.TicketAbnormalRequest;
|
||||
import org.jeecg.modules.inquiry.dto.TicketTransitionRequest;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
|
||||
import org.apache.shiro.authz.annotation.Logical;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -66,6 +68,26 @@ public class InquiryWorkOrderController {
|
||||
return viewList(pageNo, pageSize, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前列表筛选导出 Excel。有 export 或 list 权限即可(避免只开了查询的角色点导出 403)。
|
||||
*/
|
||||
@AutoLog(value = "工单-导出Excel")
|
||||
@ApiOperation("按筛选导出工单 Excel")
|
||||
@RequiresPermissions(value = {"inquiry:workorder:export", "inquiry:workorder:list"}, logical = Logical.OR)
|
||||
@GetMapping("/view/export")
|
||||
public void viewExport(HttpServletRequest req, HttpServletResponse response) throws Exception {
|
||||
Map<String, String> filters = new HashMap<>();
|
||||
copyParam(req, filters, "keyword");
|
||||
copyParam(req, filters, "status");
|
||||
copyParam(req, filters, "bizType");
|
||||
copyParam(req, filters, "businessLine");
|
||||
copyParam(req, filters, "eventException");
|
||||
copyParam(req, filters, "systemException");
|
||||
copyParam(req, filters, "dateFrom");
|
||||
copyParam(req, filters, "dateTo");
|
||||
workOrderService.exportExcel(filters, response);
|
||||
}
|
||||
|
||||
@AutoLog(value = "工单-聚合详情")
|
||||
@ApiOperation("聚合详情")
|
||||
@RequiresPermissions("inquiry:workorder:list")
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package org.jeecg.modules.inquiry.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 强制清空协商再问时刻。
|
||||
* <p>
|
||||
* 背景:claimDueNegotiate 若用 updateById 写 null,MyBatis-Plus 会跳过,
|
||||
* Worker 每分钟重复认领并群发「确认成交状态」(WO202610060024)。
|
||||
* 本接口用 UpdateWrapper 强制 SET null,供智能体认领后补清。
|
||||
*/
|
||||
@Api(tags = "询价-协商再问清理")
|
||||
@RestController
|
||||
@RequestMapping("/inquiry/agent/ticket")
|
||||
public class NegotiateFollowupClearController {
|
||||
|
||||
@Resource
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
|
||||
@ApiOperation("强制清空 next_followup_at")
|
||||
@PostMapping("/clearNextFollowup")
|
||||
public Result<Map<String, Object>> clearNextFollowup(
|
||||
@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
String no = first(body, "workOrderNo", "work_order_no");
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(no)) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "missing_work_order_no");
|
||||
return Result.OK(out);
|
||||
}
|
||||
boolean ok = ticketMapper.update(
|
||||
null,
|
||||
new LambdaUpdateWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, no.trim())
|
||||
.set(InquiryTicket::getNextFollowupAt, null)
|
||||
.set(InquiryTicket::getUpdateBy, "agent")
|
||||
.set(InquiryTicket::getUpdateTime, new Date())) > 0;
|
||||
out.put("ok", ok);
|
||||
out.put("work_order_no", no.trim());
|
||||
out.put("cleared", ok);
|
||||
return Result.OK(out);
|
||||
}
|
||||
|
||||
private static String first(Map<String, Object> body, String... keys) {
|
||||
if (body == null) {
|
||||
return "";
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object val = body.get(key);
|
||||
if (val != null && StringUtils.isNotBlank(String.valueOf(val))) {
|
||||
return String.valueOf(val).trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+1
@@ -20,6 +20,7 @@ import java.util.Date;
|
||||
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
|
||||
* timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。
|
||||
* 本表是配置真相;工单超时判定读 timeoutHours(允许小数,0=不监测)。
|
||||
* 成交跟进 deal_negotiate:协商中再问工作小时(0=不排再问),transportMode=DEAL。
|
||||
* </p>
|
||||
*/
|
||||
@Data
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ public class InquiryTicket implements Serializable {
|
||||
private String lostReason;
|
||||
|
||||
/**
|
||||
* 协商中下次再问时刻(第四个工作日 09:00)。
|
||||
* 协商中下次再问时刻(工作小时顺延,小时数读 deal_negotiate 超时配置)。
|
||||
* 中途改成交/未成交或再问发出后清空。
|
||||
*/
|
||||
@ApiModelProperty("协商中下次再问时间")
|
||||
|
||||
+8
@@ -35,6 +35,14 @@ public interface IInquiryResponseTimeoutService extends IService<InquiryResponse
|
||||
*/
|
||||
void ensureTimeoutCodes();
|
||||
|
||||
/**
|
||||
* 确保「协商中再问成交」配置行存在(编码 deal_negotiate,默认 48 工作小时)。
|
||||
* <p>
|
||||
* 打开超时响应页时幂等插入;已有则不动小时数,避免覆盖运营改过的值。
|
||||
* </p>
|
||||
*/
|
||||
void ensureDealNegotiateRow();
|
||||
|
||||
/**
|
||||
* 按唯一业务编码查超时配置;无则 null。
|
||||
*/
|
||||
|
||||
+9
@@ -22,6 +22,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
*/
|
||||
IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters);
|
||||
|
||||
/**
|
||||
* 按与 {@link #pageView} 相同的筛选条件导出 Excel(不分页,有行数上限)。
|
||||
* 副作用:只写 HTTP 响应流,不改工单六态。
|
||||
*
|
||||
* @param filters 与列表同源的筛选
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void exportExcel(Map<String, String> filters, javax.servlet.http.HttpServletResponse response) throws java.io.IOException;
|
||||
|
||||
/**
|
||||
* 聚合详情:工单 + 报价列表 + 附件 + 时间线 + 开放异常。
|
||||
*/
|
||||
|
||||
+47
@@ -8,6 +8,7 @@ import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -64,11 +65,19 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
|
||||
// 成交跟进:协商中再问(跨业务线,transport_mode=DEAL)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000030", "deal_negotiate");
|
||||
}
|
||||
|
||||
/** 协商中再问成交:唯一编码,智能体 finish_deal 读此码取工作小时。 */
|
||||
public static final String CODE_DEAL_NEGOTIATE = "deal_negotiate";
|
||||
|
||||
private static final String ID_DEAL_NEGOTIATE = "rt100000000000000000000000000030";
|
||||
|
||||
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
|
||||
private volatile boolean labelsEnsured;
|
||||
private volatile boolean codesEnsured;
|
||||
private volatile boolean dealNegotiateEnsured;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -152,6 +161,44 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void ensureDealNegotiateRow() {
|
||||
if (dealNegotiateEnsured) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (dealNegotiateEnsured) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
InquiryResponseTimeout existed = getByTimeoutCode(CODE_DEAL_NEGOTIATE);
|
||||
if (existed != null) {
|
||||
dealNegotiateEnsured = true;
|
||||
return;
|
||||
}
|
||||
// 缺行则种子插入;小时默认 48,之后只许运营在后台改小时
|
||||
InquiryResponseTimeout row = new InquiryResponseTimeout();
|
||||
row.setId(ID_DEAL_NEGOTIATE);
|
||||
row.setTimeoutCode(CODE_DEAL_NEGOTIATE);
|
||||
row.setTransportMode("DEAL");
|
||||
row.setRoleCode("sales");
|
||||
row.setRouteCategory("");
|
||||
row.setTransportType("协商中再问成交");
|
||||
row.setTimeoutHours(new BigDecimal("48"));
|
||||
row.setSortNo(5);
|
||||
row.setCreateBy("system");
|
||||
row.setCreateTime(new Date());
|
||||
row.setUpdateBy("system");
|
||||
row.setUpdateTime(new Date());
|
||||
save(row);
|
||||
dealNegotiateEnsured = true;
|
||||
} catch (Exception e) {
|
||||
log.warn("ensureDealNegotiateRow 跳过: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InquiryResponseTimeout getByTimeoutCode(String timeoutCode) {
|
||||
if (StringUtils.isBlank(timeoutCode)) {
|
||||
|
||||
+57
-5
@@ -32,6 +32,7 @@ import org.jeecg.modules.inquiry.support.InquiryQuoteFileSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketFactsSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketPriceSupport;
|
||||
import org.jeecg.modules.inquiry.support.InquiryWorkOrderExcelExport;
|
||||
import org.jeecg.modules.inquiry.tms.InquiryTmsQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -39,6 +40,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
@@ -82,6 +85,28 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|
||||
@Override
|
||||
public IPage<InquiryTicket> pageView(Page<InquiryTicket> page, Map<String, String> filters) {
|
||||
LambdaQueryWrapper<InquiryTicket> qw = buildListQuery(filters);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exportExcel(Map<String, String> filters, HttpServletResponse response) throws IOException {
|
||||
// 与列表同筛选;分页上限防止一次导出拖垮内存
|
||||
Page<InquiryTicket> page = new Page<>(1, InquiryWorkOrderExcelExport.MAX_ROWS);
|
||||
IPage<InquiryTicket> result = page(page, buildListQuery(filters));
|
||||
enrichDisplayFields(result.getRecords());
|
||||
InquiryWorkOrderExcelExport.write(result.getRecords(), response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表 / 导出共用筛选:关键字、六态、业务类型、异常、创建日、数据范围。
|
||||
*/
|
||||
private LambdaQueryWrapper<InquiryTicket> buildListQuery(Map<String, String> filters) {
|
||||
if (filters == null) {
|
||||
filters = Collections.emptyMap();
|
||||
}
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<>();
|
||||
String keyword = filters.get("keyword");
|
||||
if (StringUtils.isNotBlank(keyword)) {
|
||||
@@ -126,9 +151,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
// 与看板同源:账号勾「只看自己」时收紧列表(非超管)
|
||||
InquiryDataScopeSupport.applySelfOnly(qw);
|
||||
qw.orderByDesc(InquiryTicket::getCreateTime);
|
||||
IPage<InquiryTicket> result = page(page, qw);
|
||||
enrichDisplayFields(result.getRecords());
|
||||
return result;
|
||||
return qw;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1520,7 +1543,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
|
||||
/**
|
||||
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
|
||||
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
|
||||
* <p>
|
||||
* 必须用 UpdateWrapper 强制把 next_followup_at 写成 null:MyBatis-Plus updateById
|
||||
* 默认跳过 null,库里时刻清不掉,Worker 每分钟会再认领并群发「确认成交状态」
|
||||
* (WO202610060024 同一话术连发多条)。多 Worker 时按行条件更新,更新失败则本行不返回。
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -1536,12 +1562,38 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicket ticket : due) {
|
||||
// 条件更新:仅当再问时刻仍未清空时认领成功,避免双 Worker 各发一轮
|
||||
boolean claimed = lambdaUpdate()
|
||||
.eq(InquiryTicket::getId, ticket.getId())
|
||||
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
|
||||
.isNotNull(InquiryTicket::getNextFollowupAt)
|
||||
.le(InquiryTicket::getNextFollowupAt, now)
|
||||
.set(InquiryTicket::getNextFollowupAt, null)
|
||||
.set(InquiryTicket::getUpdateBy, "agent")
|
||||
.set(InquiryTicket::getUpdateTime, now)
|
||||
.update();
|
||||
if (!claimed) {
|
||||
continue;
|
||||
}
|
||||
ticket.setNextFollowupAt(null);
|
||||
ticket.setUpdateBy("agent");
|
||||
ticket.setUpdateTime(now);
|
||||
updateById(ticket);
|
||||
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
|
||||
out.add(toDueNegotiateRow(ticket));
|
||||
// #region agent log
|
||||
try {
|
||||
java.nio.file.Path dbg = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/debug-44a132.log");
|
||||
String line = "{\"sessionId\":\"44a132\",\"runId\":\"post-fix\",\"hypothesisId\":\"H-dup\","
|
||||
+ "\"location\":\"claimDueNegotiate\",\"message\":\"claimed_clear_followup\","
|
||||
+ "\"data\":{\"workOrderNo\":\"" + ticket.getWorkOrderNo() + "\"},"
|
||||
+ "\"timestamp\":" + System.currentTimeMillis() + "}\n";
|
||||
java.nio.file.Files.write(dbg, line.getBytes(java.nio.charset.StandardCharsets.UTF_8),
|
||||
java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
|
||||
} catch (Exception ignored) {
|
||||
// 调试日志失败不挡认领
|
||||
}
|
||||
// #endregion
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 询价工单列表 → Excel(xlsx)。
|
||||
* <p>
|
||||
* 职责:按列表同口径列写出文件流;不查库、不改六态。
|
||||
* 线程:仅在导出请求线程写响应,禁止塞进回调/Worker。
|
||||
* 为何:后台「导出工单」要按当前筛选落 Excel,与列表筛选同源。
|
||||
*/
|
||||
public final class InquiryWorkOrderExcelExport {
|
||||
|
||||
private static final String[] HEADERS = {
|
||||
"工单编号", "发起人", "业务类型", "询价需求", "起运地", "目的地", "客户",
|
||||
"报价信息", "单据状态", "成本价", "销售价", "利润点",
|
||||
"事件异常", "系统异常", "系统异常原因", "创建时间"
|
||||
};
|
||||
|
||||
/** 单次导出上限,防止一次拖垮 Tomcat 内存;超过则只写前 N 条。 */
|
||||
public static final int MAX_ROWS = 20000;
|
||||
|
||||
private InquiryWorkOrderExcelExport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 写出 xlsx 到 HTTP 响应。
|
||||
*
|
||||
* @param tickets 已 enrich 展示字段的工单
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
public static void write(List<InquiryTicket> tickets, HttpServletResponse response) throws IOException {
|
||||
String fileName = "询价工单_" + new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()) + ".xlsx";
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.setHeader("Content-Disposition",
|
||||
"attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()));
|
||||
|
||||
try (SXSSFWorkbook wb = new SXSSFWorkbook(200)) {
|
||||
Sheet sheet = wb.createSheet("询价工单");
|
||||
Row head = sheet.createRow(0);
|
||||
for (int i = 0; i < HEADERS.length; i++) {
|
||||
Cell c = head.createCell(i);
|
||||
c.setCellValue(HEADERS[i]);
|
||||
}
|
||||
SimpleDateFormat dt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
int limit = Math.min(tickets == null ? 0 : tickets.size(), MAX_ROWS);
|
||||
for (int i = 0; i < limit; i++) {
|
||||
InquiryTicket t = tickets.get(i);
|
||||
Row row = sheet.createRow(i + 1);
|
||||
writeCell(row, 0, t.getWorkOrderNo());
|
||||
writeCell(row, 1, firstNonBlank(t.getSalesName(), t.getSalesWecomId()));
|
||||
writeCell(row, 2, firstNonBlank(t.getBizTypeLabel(), t.getBusinessLine()));
|
||||
writeCell(row, 3, firstNonBlank(t.getDemandTitle(), t.getDemandDetail()));
|
||||
writeCell(row, 4, t.getOrigin());
|
||||
writeCell(row, 5, t.getDestination());
|
||||
writeCell(row, 6, t.getCustomerName());
|
||||
writeCell(row, 7, quoteSummary(t));
|
||||
writeCell(row, 8, t.getStatus());
|
||||
writeCell(row, 9, t.getCostPrice());
|
||||
writeCell(row, 10, t.getSalePrice());
|
||||
writeCell(row, 11, t.getProfitRate());
|
||||
writeCell(row, 12, dash(t.getEventException()));
|
||||
writeCell(row, 13, dash(t.getSystemException()));
|
||||
writeCell(row, 14, dash(t.getSystemExceptionReason()));
|
||||
writeCell(row, 15, t.getCreateTime() == null ? "" : dt.format(t.getCreateTime()));
|
||||
}
|
||||
wb.write(response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
private static String quoteSummary(InquiryTicket t) {
|
||||
List<InquiryQuote> quotes = t.getQuotes();
|
||||
if (quotes == null || quotes.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return quotes.stream()
|
||||
.filter(q -> q != null)
|
||||
.map(q -> {
|
||||
String src = StringUtils.defaultIfBlank(q.getSourceLabel(), q.getSource());
|
||||
String price = StringUtils.defaultString(q.getPriceText());
|
||||
if (StringUtils.isBlank(src) && StringUtils.isBlank(price)) {
|
||||
return "";
|
||||
}
|
||||
return (StringUtils.defaultString(src) + " " + price).trim();
|
||||
})
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.joining(";"));
|
||||
}
|
||||
|
||||
private static void writeCell(Row row, int idx, String value) {
|
||||
Cell c = row.createCell(idx);
|
||||
c.setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (StringUtils.isNotBlank(a)) {
|
||||
return a.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(b)) {
|
||||
return b.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String dash(String v) {
|
||||
if (StringUtils.isBlank(v) || "-".equals(v.trim())) {
|
||||
return "-";
|
||||
}
|
||||
return v.trim();
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- 协商中回执:由「三个工作日 9:00」改为「N 个工作小时 + 具体时刻」。
|
||||
-- 变量 followup_hours=顺延小时文案;followup_when=再问时刻(如 9月24日15:30)。
|
||||
-- 仅改 inquiry_robot;幂等。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
UPDATE `inq_bot_reply`
|
||||
SET `reply_text` = '已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。',
|
||||
`update_by` = 'system',
|
||||
`update_time` = NOW()
|
||||
WHERE `reply_code` = 'negotiating.recorded'
|
||||
OR `id` = 'brv10000000000000000000000000039'
|
||||
OR `trigger_scene` = '成交跟进中选择「协商中」';
|
||||
+1
-1
@@ -67,7 +67,7 @@ VALUES
|
||||
('brv10000000000000000000000000036','quoted','已报价','quoted.template_missing','后台未维护报价模板','生成报价单失败,请联系IT运维维护后台报价模板。',1,4,'admin',NOW()),
|
||||
('brv10000000000000000000000000037','quoted','已报价','quoted.file_ready','最终报价单生成完成','已生成{{kind}}最终报价单:{{filename}}(工单 {{ticket_no}})',1,5,'admin',NOW()),
|
||||
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已将未成交原因“{{lost_reason}}”记录在后台,工单 {{ticket_no}}已关闭,智能体不再跟进该工单,请悉知。',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- 协商中再问成交:按工作小时顺延(默认 48),后台「超时响应」页可改。
|
||||
-- 编码 deal_negotiate;运输方式 DEAL(成交跟进分块);0=不排再问。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `inq_response_timeout` (
|
||||
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
|
||||
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
|
||||
)
|
||||
SELECT
|
||||
'rt100000000000000000000000000030',
|
||||
'deal_negotiate',
|
||||
'DEAL',
|
||||
'sales',
|
||||
'',
|
||||
CONVERT(UNHEX('e58d8fe59586e4b8ade5868de997aee68890e4baa4') USING utf8mb4),
|
||||
48,
|
||||
5,
|
||||
'system',
|
||||
NOW(),
|
||||
'system',
|
||||
NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='deal_negotiate'
|
||||
) AS `existed`
|
||||
);
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
changePassword,
|
||||
deleteUser,
|
||||
getAllRolesListNoByTenant,
|
||||
getExportUrl,
|
||||
getUserRoles,
|
||||
list as listUsers,
|
||||
saveOrUpdateUser,
|
||||
} from '/@/views/system/user/user.api';
|
||||
import { downloadAuthGet } from '/@/views/inquiry/shared/inquiryAuthDownload';
|
||||
|
||||
export {
|
||||
changePassword,
|
||||
@@ -20,6 +22,12 @@ export {
|
||||
saveOrUpdateUser,
|
||||
};
|
||||
|
||||
/**
|
||||
* 按当前筛选导出账号 Excel(Jeecg /sys/user/exportXls,与列表 QueryGenerator 同源)。
|
||||
*/
|
||||
export const exportAccounts = (params: Record<string, any>) =>
|
||||
downloadAuthGet(getExportUrl, params, '账号列表.xlsx');
|
||||
|
||||
export type AccountRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
changePassword,
|
||||
deleteUser,
|
||||
exportAccounts,
|
||||
getAllRolesListNoByTenant,
|
||||
getUserRoles,
|
||||
listUsers,
|
||||
@@ -25,6 +26,10 @@ import { InquiryBtnPerm } from "/@/views/inquiry/shared/inquiryPagePerms";
|
||||
const { hasPermission } = usePermission();
|
||||
/** 账号新建/编辑/删除/重置密码均属 edit 权限 */
|
||||
const canEditAccount = () => hasPermission(InquiryBtnPerm.accountEdit);
|
||||
/** 能看账号页即可导出(与列表同源筛选) */
|
||||
const canExportAccount = () =>
|
||||
hasPermission(InquiryBtnPerm.accountView) || hasPermission(InquiryBtnPerm.accountEdit);
|
||||
const exporting = ref(false);
|
||||
|
||||
const rows = ref<AccountRow[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -68,27 +73,34 @@ async function loadRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 与列表同源的 Jeecg 查询参数(不含分页)。 */
|
||||
function buildAccountFilterParams(): Record<string, any> {
|
||||
const kw = filters.keyword.trim();
|
||||
const params: Record<string, any> = {};
|
||||
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
|
||||
if (kw) {
|
||||
if (/[\u4e00-\u9fff]/.test(kw)) {
|
||||
params.realname = `*${kw}*`;
|
||||
} else if (/^\d{5,}$/.test(kw)) {
|
||||
params.phone = `*${kw}*`;
|
||||
} else {
|
||||
params.username = `*${kw}*`;
|
||||
}
|
||||
}
|
||||
if (filters.status === "active") params.status = 1;
|
||||
if (filters.status === "disabled") params.status = 2;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (!roleOptions.value.length) await loadRoles();
|
||||
const kw = filters.keyword.trim();
|
||||
// Jeecg QueryGenerator:字段值包 * 走 LIKE。多字段 AND 过严,按关键字形态选一个字段。
|
||||
const params: Record<string, any> = {
|
||||
...buildAccountFilterParams(),
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
if (kw) {
|
||||
if (/[\u4e00-\u9fff]/.test(kw)) {
|
||||
params.realname = `*${kw}*`;
|
||||
} else if (/^\d{5,}$/.test(kw)) {
|
||||
params.phone = `*${kw}*`;
|
||||
} else {
|
||||
params.username = `*${kw}*`;
|
||||
}
|
||||
}
|
||||
if (filters.status === "active") params.status = 1;
|
||||
if (filters.status === "disabled") params.status = 2;
|
||||
|
||||
const res: any = await listUsers(params);
|
||||
const page = res?.records ? res : res?.result || {};
|
||||
@@ -297,13 +309,37 @@ function removeAccount(row: AccountRow) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 按当前筛选导出账号 Excel(Jeecg 用户导出)。 */
|
||||
async function exportAccountList() {
|
||||
if (!canExportAccount()) {
|
||||
message.warning("无导出权限");
|
||||
return;
|
||||
}
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await exportAccounts(buildAccountFilterParams());
|
||||
message.success("已开始下载 Excel");
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div class="account-topbar-actions">
|
||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||
<ProtoIcon name="download" :size="14" />导出
|
||||
<button
|
||||
v-if="canExportAccount()"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="exporting"
|
||||
@click="exportAccountList"
|
||||
>
|
||||
<ProtoIcon name="download" :size="14" />{{ exporting ? "导出中…" : "导出" }}
|
||||
</button>
|
||||
<button v-if="canEditAccount()" type="button" class="btn btn-primary" @click="openCreate">
|
||||
<ProtoIcon name="plus" :size="14" />新建账号
|
||||
|
||||
@@ -320,7 +320,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
id: "brv10000000000000000000000000039",
|
||||
replyCode: "negotiating.recorded",
|
||||
trigger: "成交跟进中选择「协商中」",
|
||||
text: "已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。",
|
||||
text: "已记录为「协商中」。已顺延 {{followup_hours}} 个工作小时,将于 {{followup_when}} 再次发起成交状态询问。",
|
||||
enabled: true,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 带鉴权下载二进制文件(Excel 等)。
|
||||
* 为何:window.open 带 token 遇 JSON 错误页难提示;统一走 fetch + blob。
|
||||
*/
|
||||
import { useGlobSetting } from '/@/hooks/setting';
|
||||
import { getToken } from '/@/utils/auth';
|
||||
|
||||
export type AuthDownloadParams = Record<string, string | number | undefined | null>;
|
||||
|
||||
/**
|
||||
* GET 下载附件并触发浏览器保存。
|
||||
* @param path 相对 API 路径,如 /inquiry/workOrder/view/export
|
||||
* @param params query 参数(空值跳过)
|
||||
* @param fallbackName 无 Content-Disposition 时的文件名
|
||||
*/
|
||||
export async function downloadAuthGet(
|
||||
path: string,
|
||||
params: AuthDownloadParams = {},
|
||||
fallbackName = 'download.bin',
|
||||
): Promise<void> {
|
||||
const glob = useGlobSetting();
|
||||
const base = (glob.apiUrl || '').replace(/\/$/, '');
|
||||
const qs = new URLSearchParams();
|
||||
Object.keys(params).forEach((k) => {
|
||||
const v = params[k];
|
||||
if (v === undefined || v === null || String(v).trim() === '') return;
|
||||
qs.set(k, String(v));
|
||||
});
|
||||
const url = `${base}${path}${qs.toString() ? `?${qs.toString()}` : ''}`;
|
||||
const token = getToken() || '';
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Access-Token': String(token),
|
||||
},
|
||||
});
|
||||
const ctype = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!res.ok || ctype.includes('application/json')) {
|
||||
let msg = `下载失败 HTTP ${res.status}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j?.message) msg = String(j.message);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
let name = fallbackName;
|
||||
const cd = res.headers.get('content-disposition') || '';
|
||||
const m = /filename\*=UTF-8''([^;]+)|filename=\"?([^\";]+)\"?/i.exec(cd);
|
||||
if (m) {
|
||||
name = decodeURIComponent((m[1] || m[2] || name).trim());
|
||||
}
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 超时响应:响应超时小时配置。
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* 成交跟进 / 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 deal_negotiate / sea_sales),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
|
||||
* 协商中再问读 deal_negotiate(工作小时,非工作日不算);0=不排再问。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -19,8 +20,9 @@ import { InquiryBtnPerm } from '/@/views/inquiry/shared/inquiryPagePerms';
|
||||
const { hasPermission } = usePermission();
|
||||
const canEditTimeout = () => hasPermission(InquiryBtnPerm.timeoutEdit);
|
||||
|
||||
/** 分块顺序:海运 → 空运 → 陆运(产品确认同页分块) */
|
||||
/** 分块顺序:成交跟进 → 海运 → 空运 → 陆运(产品确认同页分块) */
|
||||
const MODE_BLOCKS = [
|
||||
{ code: 'DEAL', title: '成交跟进' },
|
||||
{ code: 'SEA', title: '海运' },
|
||||
{ code: 'AIR', title: '空运' },
|
||||
{ code: 'LAND', title: '陆运' },
|
||||
@@ -118,7 +120,7 @@ async function commitHours(row) {
|
||||
<template>
|
||||
<div class="sys-setting-page">
|
||||
<div class="toolbar-bar">
|
||||
<span class="hint">固定规则,仅可改超时小时(支持小数,0=不监测);编码唯一只读,供智能体调用;失焦或回车保存</span>
|
||||
<span class="hint">固定规则,仅可改超时小时(支持小数;响应超时 0=不监测,协商再问 0=不排再问);编码唯一只读;失焦或回车保存</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 超时响应配置 API。
|
||||
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
|
||||
* timeoutCode 每条唯一(如 sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
|
||||
* timeoutCode 每条唯一(如 deal_negotiate / sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
|
||||
* deal_negotiate=协商中再问工作小时(成交跟进分块)。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import {
|
||||
exportWorkOrders,
|
||||
getWorkOrderDetail,
|
||||
getWorkOrderList,
|
||||
retryAbnormal,
|
||||
@@ -30,6 +31,10 @@ const canCloseTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canRetryTicket = () => hasPermission(InquiryBtnPerm.workorderTransition);
|
||||
const canViewDetail = () =>
|
||||
hasPermission(InquiryBtnPerm.workorderDetail) || hasPermission(InquiryBtnPerm.workorderList);
|
||||
/** 导出:有 export 或 list 均可(与主账 Logical.OR 对齐) */
|
||||
const canExportTicket = () =>
|
||||
hasPermission(InquiryBtnPerm.workorderExport) || hasPermission(InquiryBtnPerm.workorderList);
|
||||
const exporting = ref(false);
|
||||
|
||||
/**
|
||||
* 与主账 TicketStatuses 一致:仅询价中/已报价/协商中/转人工可关到「已关闭」。
|
||||
@@ -676,13 +681,47 @@ function retryTicket(row: TicketRow) {
|
||||
function refresh() {
|
||||
loadList().then(() => message.success("已刷新"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前筛选导出 Excel(与列表同源,不分页;主账上限 2 万行)。
|
||||
*/
|
||||
async function exportTickets() {
|
||||
if (!canExportTicket()) {
|
||||
message.warning("无导出权限");
|
||||
return;
|
||||
}
|
||||
if (exporting.value) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await exportWorkOrders({
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
eventException: filters.eventException || undefined,
|
||||
systemException: filters.systemException || undefined,
|
||||
bizType: filters.bizType || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
});
|
||||
message.success("已开始下载 Excel");
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport defer to="#page-topbar-actions">
|
||||
<div style="display: flex; align-items: center; gap: 12px">
|
||||
<button type="button" class="btn btn-secondary" disabled title="导出待后续开放">
|
||||
<ProtoIcon name="download" :size="16" />导出工单
|
||||
<button
|
||||
v-if="canExportTicket()"
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="exporting"
|
||||
@click="exportTickets"
|
||||
>
|
||||
<ProtoIcon name="download" :size="16" />{{ exporting ? "导出中…" : "导出工单" }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="refresh">
|
||||
<ProtoIcon name="refresh" :size="16" />刷新
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* 询价工单 API:列表/详情/六态/异常。
|
||||
* 询价工单 API:列表/详情/六态/异常/导出。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
import { downloadAuthGet } from '/@/views/inquiry/shared/inquiryAuthDownload';
|
||||
|
||||
enum Api {
|
||||
list = '/inquiry/workOrder/view/list',
|
||||
detail = '/inquiry/workOrder/view/detail',
|
||||
exportXls = '/inquiry/workOrder/view/export',
|
||||
transition = '/inquiry/workOrder/transition',
|
||||
abnormalHandle = '/inquiry/workOrder/abnormal/handle',
|
||||
abnormalRetry = '/inquiry/workOrder/abnormal/retry',
|
||||
@@ -18,3 +20,9 @@ export const getWorkOrderDetail = (workOrderNo: string) =>
|
||||
export const transitionWorkOrder = (data) => defHttp.post({ url: Api.transition, data });
|
||||
export const handleAbnormal = (data) => defHttp.post({ url: Api.abnormalHandle, data });
|
||||
export const retryAbnormal = (data) => defHttp.post({ url: Api.abnormalRetry, data });
|
||||
|
||||
/**
|
||||
* 按当前筛选导出 Excel(与列表同源参数,不分页)。
|
||||
*/
|
||||
export const exportWorkOrders = (params: Record<string, any>) =>
|
||||
downloadAuthGet(Api.exportXls, params, '询价工单.xlsx');
|
||||
|
||||
Reference in New Issue
Block a user