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> clearNextFollowup( + @RequestBody Map body, + HttpServletRequest http) { + String no = first(body, "workOrderNo", "work_order_no"); + Map 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() + .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 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 ""; + } +} diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java index 6d9f090..73301d6 100644 --- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java +++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryResponseTimeout.java @@ -20,6 +20,7 @@ import java.util.Date; * 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。 * timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。 * 本表是配置真相;工单超时判定读 timeoutHours(允许小数,0=不监测)。 + * 成交跟进 deal_negotiate:协商中再问工作小时(0=不排再问),transportMode=DEAL。 *

*/ @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; + } +}