From 0b2f0b76c436784d4228b0364b09c6dc954ce5fb Mon Sep 17 00:00:00 2001
From: linmianxun <634841751@qq.com>
Date: Tue, 13 Oct 2026 12:34:03 +0800
Subject: [PATCH] =?UTF-8?q?=E5=8D=8F=E5=95=86=E4=B8=AD=E5=86=8D=E9=97=AE?=
=?UTF-8?q?=E6=8C=89=E5=90=8E=E5=8F=B0=E8=B6=85=E6=97=B6=E5=B0=8F=E6=97=B6?=
=?UTF-8?q?=E9=A1=BA=E5=BB=B6=E5=B9=B6=E5=8F=AF=E5=BC=BA=E5=88=B6=E6=B8=85?=
=?UTF-8?q?=E8=B7=9F=E8=BF=9B=E6=97=B6=E5=88=BB=EF=BC=9B=E5=B7=A5=E5=8D=95?=
=?UTF-8?q?=E4=B8=8E=E8=B4=A6=E5=8F=B7=E6=94=AF=E6=8C=81=E6=8C=89=E7=AD=9B?=
=?UTF-8?q?=E9=80=89=E5=AF=BC=E5=87=BA=20Excel=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Cursor
---
.../agent/jobs/deal_followup_poll.py | 42 ++++++
inquiry-agent/agent/ledger/http_ledger.py | 65 ++++++++-
inquiry-agent/agent/ledger/memory_ledger.py | 3 +-
inquiry-agent/agent/llm/extract_text.py | 9 +-
inquiry-agent/agent/policy/air_group_ops.py | 5 +
inquiry-agent/agent/policy/air_text_flow.py | 1 +
inquiry-agent/agent/policy/deal_followup.py | 50 ++++++-
inquiry-agent/agent/policy/deal_outcome.py | 44 +++++-
inquiry-agent/agent/policy/inquiry_copy.py | 27 +++-
inquiry-agent/agent/policy/sea_group_ops.py | 8 +-
inquiry-agent/tests/test_bot_reply.py | 9 +-
inquiry-agent/tests/test_deal_followup.py | 17 ++-
inquiry-agent/tests/test_deal_outcome.py | 28 +++-
inquiry-agent/tests/test_sea_text_inquiry.py | 16 +++
.../controller/InquiryAgentController.java | 2 +
.../InquiryResponseTimeoutController.java | 4 +-
.../InquiryWorkOrderController.java | 22 +++
.../NegotiateFollowupClearController.java | 73 ++++++++++
.../entity/InquiryResponseTimeout.java | 1 +
.../modules/inquiry/entity/InquiryTicket.java | 2 +-
.../IInquiryResponseTimeoutService.java | 8 ++
.../service/IInquiryWorkOrderService.java | 9 ++
.../InquiryResponseTimeoutServiceImpl.java | 47 +++++++
.../impl/InquiryWorkOrderServiceImpl.java | 62 ++++++++-
.../support/InquiryWorkOrderExcelExport.java | 128 ++++++++++++++++++
...ation_inq_bot_reply_negotiate_hours_v1.sql | 13 ++
.../db/migration_inq_bot_reply_replace_v1.sql | 2 +-
...inq_response_timeout_deal_negotiate_v1.sql | 29 ++++
.../src/views/inquiry/account/account.api.ts | 8 ++
.../src/views/inquiry/account/index.vue | 66 +++++++--
.../src/views/inquiry/mock/botReplies.ts | 2 +-
.../inquiry/shared/inquiryAuthDownload.ts | 63 +++++++++
.../src/views/inquiry/systemSetting/index.vue | 10 +-
.../systemSetting/systemSetting.api.ts | 3 +-
.../src/views/inquiry/workOrder/index.vue | 43 +++++-
.../views/inquiry/workOrder/workOrder.api.ts | 10 +-
36 files changed, 867 insertions(+), 64 deletions(-)
create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/NegotiateFollowupClearController.java
create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryWorkOrderExcelExport.java
create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_negotiate_hours_v1.sql
create mode 100644 inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_deal_negotiate_v1.sql
create mode 100644 inquiry-backend/src/views/inquiry/shared/inquiryAuthDownload.ts
diff --git a/inquiry-agent/agent/jobs/deal_followup_poll.py b/inquiry-agent/agent/jobs/deal_followup_poll.py
index 1a41039..e052426 100644
--- a/inquiry-agent/agent/jobs/deal_followup_poll.py
+++ b/inquiry-agent/agent/jobs/deal_followup_poll.py
@@ -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
diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py
index ac205a8..6519327 100644
--- a/inquiry-agent/agent/ledger/http_ledger.py
+++ b/inquiry-agent/agent/ledger/http_ledger.py
@@ -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,
diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py
index 15c6014..c1411b9 100644
--- a/inquiry-agent/agent/ledger/memory_ledger.py
+++ b/inquiry-agent/agent/ledger/memory_ledger.py
@@ -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
diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py
index 6177bb1..5909341 100644
--- a/inquiry-agent/agent/llm/extract_text.py
+++ b/inquiry-agent/agent/llm/extract_text.py
@@ -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[\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{_CCY_TOKEN})\s*)?"
r"(?P\d+(?:\.\d+)?)"
- r"(?:\s*[,,、]\s*数量\s*(?P\d+(?:\.\d+)?))?"
- r"(?:\s*[,,、]\s*金额\s*"
+ r"(?:\s*[,,、]?\s*数量\s*(?P\d+(?:\.\d+)?))?"
+ r"(?:\s*[,,、]?\s*金额\s*"
rf"(?:(?P{_CCY_TOKEN})\s*)?"
r"(?P\d+(?:\.\d+)?))?",
re.I,
diff --git a/inquiry-agent/agent/policy/air_group_ops.py b/inquiry-agent/agent/policy/air_group_ops.py
index 732bb48..99dda29 100644
--- a/inquiry-agent/agent/policy/air_group_ops.py
+++ b/inquiry-agent/agent/policy/air_group_ops.py
@@ -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 "")
diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py
index 92d97d8..5ce7395 100644
--- a/inquiry-agent/agent/policy/air_text_flow.py
+++ b/inquiry-agent/agent/policy/air_text_flow.py
@@ -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
diff --git a/inquiry-agent/agent/policy/deal_followup.py b/inquiry-agent/agent/policy/deal_followup.py
index 14b91b0..c33b5b2 100644
--- a/inquiry-agent/agent/policy/deal_followup.py
+++ b/inquiry-agent/agent/policy/deal_followup.py
@@ -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:
diff --git a/inquiry-agent/agent/policy/deal_outcome.py b/inquiry-agent/agent/policy/deal_outcome.py
index 18fc2df..c18ddfa 100644
--- a/inquiry-agent/agent/policy/deal_outcome.py
+++ b/inquiry-agent/agent/policy/deal_outcome.py
@@ -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]],
*,
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
index a06cf1e..6b10a53 100644
--- a/inquiry-agent/agent/policy/inquiry_copy.py
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -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 ""
diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py
index 26cf357..3b27bbe 100644
--- a/inquiry-agent/agent/policy/sea_group_ops.py
+++ b/inquiry-agent/agent/policy/sea_group_ops.py
@@ -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
diff --git a/inquiry-agent/tests/test_bot_reply.py b/inquiry-agent/tests/test_bot_reply.py
index 519978f..d55be45 100644
--- a/inquiry-agent/tests/test_bot_reply.py
+++ b/inquiry-agent/tests/test_bot_reply.py
@@ -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(
diff --git a/inquiry-agent/tests/test_deal_followup.py b/inquiry-agent/tests/test_deal_followup.py
index 66565df..3cb62f1 100644
--- a/inquiry-agent/tests/test_deal_followup.py
+++ b/inquiry-agent/tests/test_deal_followup.py
@@ -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) # 周五
diff --git a/inquiry-agent/tests/test_deal_outcome.py b/inquiry-agent/tests/test_deal_outcome.py
index 6e62350..28e89db 100644
--- a/inquiry-agent/tests/test_deal_outcome.py
+++ b/inquiry-agent/tests/test_deal_outcome.py
@@ -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(
diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py
index 024d302..99bbd7b 100644
--- a/inquiry-agent/tests/test_sea_text_inquiry.py
+++ b/inquiry-agent/tests/test_sea_text_inquiry.py
@@ -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
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java
index 971bc50..a24e656 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryAgentController.java
@@ -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 out = new java.util.LinkedHashMap<>();
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java
index 3b25509..5751652 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryResponseTimeoutController.java
@@ -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> listAll() {
- // 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码(幂等)
+ // 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码、补协商再问种子(幂等)
responseTimeoutService.ensureCanonicalTransportTypeLabels();
responseTimeoutService.ensureTimeoutCodes();
+ responseTimeoutService.ensureDealNegotiateRow();
QueryWrapper qw = new QueryWrapper<>();
qw.orderByAsc("sort_no");
return Result.OK(responseTimeoutService.list(qw));
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java
index f1013c4..7c3a9fe 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/InquiryWorkOrderController.java
@@ -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 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")
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/NegotiateFollowupClearController.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/NegotiateFollowupClearController.java
new file mode 100644
index 0000000..d0e0918
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/controller/NegotiateFollowupClearController.java
@@ -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;
+
+/**
+ * 强制清空协商再问时刻。
+ *
+ * 背景: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
*/
@Data
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
index 538bdfa..99d9bf4 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
@@ -179,7 +179,7 @@ public class InquiryTicket implements Serializable {
private String lostReason;
/**
- * 协商中下次再问时刻(第四个工作日 09:00)。
+ * 协商中下次再问时刻(工作小时顺延,小时数读 deal_negotiate 超时配置)。
* 中途改成交/未成交或再问发出后清空。
*/
@ApiModelProperty("协商中下次再问时间")
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java
index 9ee11fa..506d774 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryResponseTimeoutService.java
@@ -35,6 +35,14 @@ public interface IInquiryResponseTimeoutService extends IService
+ * 打开超时响应页时幂等插入;已有则不动小时数,避免覆盖运营改过的值。
+ *
+ */
+ void ensureDealNegotiateRow();
+
/**
* 按唯一业务编码查超时配置;无则 null。
*/
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java
index fa0eb44..1280096 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/IInquiryWorkOrderService.java
@@ -22,6 +22,15 @@ public interface IInquiryWorkOrderService extends IService {
*/
IPage pageView(Page page, Map filters);
+ /**
+ * 按与 {@link #pageView} 相同的筛选条件导出 Excel(不分页,有行数上限)。
+ * 副作用:只写 HTTP 响应流,不改工单六态。
+ *
+ * @param filters 与列表同源的筛选
+ * @param response HTTP 响应
+ */
+ void exportExcel(Map filters, javax.servlet.http.HttpServletResponse response) throws java.io.IOException;
+
/**
* 聚合详情:工单 + 报价列表 + 附件 + 时间线 + 开放异常。
*/
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java
index 3390610..ff6e1da 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryResponseTimeoutServiceImpl.java
@@ -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)) {
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
index 90c492d..cb4efd8 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryWorkOrderServiceImpl.java
@@ -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 pageView(Page page, Map filters) {
+ LambdaQueryWrapper qw = buildListQuery(filters);
+ IPage result = page(page, qw);
+ enrichDisplayFields(result.getRecords());
+ return result;
+ }
+
+ @Override
+ public void exportExcel(Map filters, HttpServletResponse response) throws IOException {
+ // 与列表同筛选;分页上限防止一次导出拖垮内存
+ Page page = new Page<>(1, InquiryWorkOrderExcelExport.MAX_ROWS);
+ IPage result = page(page, buildListQuery(filters));
+ enrichDisplayFields(result.getRecords());
+ InquiryWorkOrderExcelExport.write(result.getRecords(), response);
+ }
+
+ /**
+ * 列表 / 导出共用筛选:关键字、六态、业务类型、异常、创建日、数据范围。
+ */
+ private LambdaQueryWrapper buildListQuery(Map filters) {
+ if (filters == null) {
+ filters = Collections.emptyMap();
+ }
LambdaQueryWrapper qw = new LambdaQueryWrapper<>();
String keyword = filters.get("keyword");
if (StringUtils.isNotBlank(keyword)) {
@@ -126,9 +151,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl result = page(page, qw);
- enrichDisplayFields(result.getRecords());
- return result;
+ return qw;
}
@Override
@@ -1520,7 +1543,10 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl
+ * 必须用 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
+ * 职责:按列表同口径列写出文件流;不查库、不改六态。
+ * 线程:仅在导出请求线程写响应,禁止塞进回调/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 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 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();
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_negotiate_hours_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_negotiate_hours_v1.sql
new file mode 100644
index 0000000..6431e1e
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_negotiate_hours_v1.sql
@@ -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` = '成交跟进中选择「协商中」';
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql
index e110b09..ca3da2e 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql
+++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_bot_reply_replace_v1.sql
@@ -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()),
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_deal_negotiate_v1.sql b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_deal_negotiate_v1.sql
new file mode 100644
index 0000000..2deda3b
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/resources/db/migration_inq_response_timeout_deal_negotiate_v1.sql
@@ -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`
+);
diff --git a/inquiry-backend/src/views/inquiry/account/account.api.ts b/inquiry-backend/src/views/inquiry/account/account.api.ts
index 2461e70..8c1add3 100644
--- a/inquiry-backend/src/views/inquiry/account/account.api.ts
+++ b/inquiry-backend/src/views/inquiry/account/account.api.ts
@@ -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) =>
+ downloadAuthGet(getExportUrl, params, '账号列表.xlsx');
+
export type AccountRow = {
id: string;
username: string;
diff --git a/inquiry-backend/src/views/inquiry/account/index.vue b/inquiry-backend/src/views/inquiry/account/index.vue
index 42b5ae1..516b093 100644
--- a/inquiry-backend/src/views/inquiry/account/index.vue
+++ b/inquiry-backend/src/views/inquiry/account/index.vue
@@ -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([]);
const loading = ref(false);
@@ -68,27 +73,34 @@ async function loadRoles() {
}
}
+/** 与列表同源的 Jeecg 查询参数(不含分页)。 */
+function buildAccountFilterParams(): Record {
+ const kw = filters.keyword.trim();
+ const params: Record = {};
+ // 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 = {
+ ...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;
+ }
+}
-