Compare commits

...
Author SHA1 Message Date
jillion886andCursor 1a2e1d6cb2 补齐成交跟进:按线路推成交通知、收下未成交原因、协商中按工作日再问。
成交走询价消息服务;陆运/海运私聊下一句能记下原因;协商中回执写明顺延三个工作日和下次 9:00。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 16:08:48 +08:00
linmianxunandCursor 9bb69ae72e 账号支持只看本账号看板;模板重新解析改为保留原映射并多轮补缺。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-19 10:38:53 +08:00
linmianxunandCursor 6cd6803147 补齐超时响应唯一 timeoutCode 与智能体按码查询,并增强报价模板预览与解析落库。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 22:25:04 +08:00
60 changed files with 3125 additions and 153 deletions
+3
View File
@@ -22,6 +22,9 @@ WECOM_CALLBACK_TOKEN=
WECOM_ENCODING_AES_KEY=
WECOM_CALLBACK_REPLY_TEXT=询价智能体接通成功。已收到:{content}
WECOM_OPS_AGENT_ID=1000014
# 现网「询价消息服务」= 运维应用 1000014(与 WECOM_OPERATIONS_* 同一套)。空则只成交不推。
WECOM_NOTIFY_AGENT_ID=1000014
WECOM_NOTIFY_SECRET=
# 会话存档席:后台员工管理里「询价机器人」的企微 userid,每个协同群必拉。可逗号分隔多个。
WECOM_ARCHIVE_SEAT_USER_IDS=
# 微盛会话存档(Worker 按存档席拉群消息)。游标只写 REDIS_KEY_PREFIX,禁止推现网官方 seq。
@@ -37,6 +37,9 @@ class QuoteTemplateParseBody(BaseModel):
yellow_count: int = Field(default=0, alias="yellowCount")
truncated: bool = False
request_id: str = Field(default="", alias="requestId")
# 未换文件重新解析:true + existingMapping,回写时旧字段优先补缺
merge_existing: bool = Field(default=False, alias="mergeExisting")
existing_mapping: Optional[dict[str, Any]] = Field(default=None, alias="existingMapping")
model_config = {"populate_by_name": True}
@@ -103,7 +106,10 @@ def enqueue_parse(
"cellCount": body.cell_count or len(inventory),
"yellowCount": body.yellow_count,
"truncated": bool(body.truncated),
"mergeExisting": bool(body.merge_existing),
}
if body.merge_existing and isinstance(body.existing_mapping, dict):
payload["existingMapping"] = body.existing_mapping
result = enqueue_recognition(
kind="quote_template_parse",
payload=payload,
+21 -2
View File
@@ -43,6 +43,15 @@ class ChannelRuntime:
secret=settings.wecom_secret,
agent_id=agent_id,
)
notify_id = int(getattr(settings, "wecom_notify_agent_id", "") or "0") or 0
notify_secret = str(getattr(settings, "wecom_notify_secret", "") or "").strip()
self._notify_client = None
if notify_id and notify_secret:
self._notify_client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=notify_secret,
agent_id=notify_id,
)
@property
def store(self):
@@ -105,10 +114,20 @@ class ChannelRuntime:
logger.warning("outbox 过期丢弃 id=%s", item.id)
self._store.complete_outbound(item.id)
continue
result = self._client.send_outbound(
payload = getattr(item, "payload", None) or {}
client = self._client
if payload.get("wecom_app") == "notify":
if self._notify_client is None:
logger.error("询价消息服务未配置,成交通知丢弃 id=%s", item.id)
self._store.complete_outbound(
item.id, error="notify_app_missing", permanent=True
)
continue
client = self._notify_client
result = client.send_outbound(
touser=item.touser,
content=item.content,
payload=getattr(item, "payload", None) or {},
payload=payload,
)
if result.ok:
self._store.complete_outbound(item.id)
+3
View File
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
wecom_agent_id: str = Field(default="1000010", alias="WECOM_AGENT_ID")
wecom_secret: str = Field(default="", alias="WECOM_SECRET")
# 现网「询价消息服务」:只推成交通知,不走询价小助手 1000010。
wecom_notify_agent_id: str = Field(default="", alias="WECOM_NOTIFY_AGENT_ID")
wecom_notify_secret: str = Field(default="", alias="WECOM_NOTIFY_SECRET")
wecom_archive_seat_user_ids: str = Field(
default="",
alias="WECOM_ARCHIVE_SEAT_USER_IDS",
@@ -27,7 +27,12 @@ from agent.policy.air_group_ops import (
resolve_cabin_ticket,
)
from agent.policy.air_text_flow import get_air_text_flow
from agent.policy.sea_group_ops import group_client_of, send_group_text, ticket_view
from agent.policy.sea_group_ops import (
capture_group_lost_reason,
group_client_of,
send_group_text,
ticket_view,
)
from agent.routing.air_group_intent import (
INTENT_ACTIVATE,
INTENT_CONFIRM_TMS,
@@ -233,6 +238,14 @@ def _handle_bound(
injected_quote: Optional[dict[str, Any]],
) -> str:
chat_id = message.chat_id
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
)
if lost:
return lost
facts = dict(ticket_view(ticket).get("collab_facts") or {})
if intent == INTENT_PICK_OPTION or (
intent == INTENT_OTHER and str(facts.get("__pending_air_lock") or "") == "1"
@@ -17,6 +17,7 @@ from agent.policy.sea_group_ops import (
apply_group_deal,
apply_product_quote,
apply_tms_quote_choice,
capture_group_lost_reason,
dissolve_group,
group_client_of,
handle_group_file,
@@ -138,6 +139,16 @@ def handle_group_inbound(
classify_group_text,
)
lost = capture_group_lost_reason(
flow=engine,
ticket=ticket,
sender_id=message.sender_id,
text=message.content or "",
sess=sess,
)
if lost:
return lost
intent = classify_group_text(message.content or "", injected=injected_intent)
logger.info(
"group_inbound 文字 chat=%s sender=%s intent=%s chars=%s",
@@ -198,6 +198,8 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
sess = engine.session_for_action(
message.sender_id, message.raw, text=message.content or ""
)
if sess and sess.phase == "wait_lost_reason":
return handle_text_inquiry(message, store=msg_store)
btn = engine.match_button(message.content or "", sess)
if btn:
return handle_text_inquiry(message, store=msg_store)
+17
View File
@@ -111,6 +111,7 @@ class WorkerRuntime:
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
threading.Thread(target=self._wshoto_poll_loop, name="wshoto-poll", daemon=True),
threading.Thread(target=self._deal_followup_loop, name="deal-followup", daemon=True),
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
]
for t in self._threads:
@@ -273,6 +274,22 @@ class WorkerRuntime:
wait = 1.0 if has_hot_room() else interval
wait_wshoto_poll(wait)
def _deal_followup_loop(self) -> None:
"""
HTTP 槽轮询协商再问。独立线程,不占回调、不与 soffice 串队。
"""
from agent.jobs.deal_followup_poll import poll_due_negotiate
while not self._stop.is_set():
try:
self.http_jobs.run(
lambda: poll_due_negotiate(settings=self.settings),
label="deal-followup",
)
except Exception:
logger.exception("协商再问轮询失败")
self._stop.wait(60.0)
def _pdf_loop(self) -> None:
"""仅 LibreOffice 单槽消费 PDF Stream。"""
if not self.redis:
@@ -0,0 +1,120 @@
"""
协商中到期再问:Worker HTTP 槽轮询,回到当初那个会话。
本文件职责:认领主账到期单、发成交跟进。不在回调线程跑。
禁止:群解散后改发私聊;占 LibreOffice 槽;把六态写进 Redis。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from agent.config import Settings, get_settings
from agent.policy import inquiry_copy as copy
logger = logging.getLogger(__name__)
def poll_due_negotiate(*, ledger: Any = None, settings: Optional[Settings] = None) -> int:
"""
认领到期协商中工单并再问一次。
返回发出条数。主账已清空 next_followup_at,本轮失败也不再自动补发。
"""
cfg = settings or get_settings()
book = ledger
if book is None:
if str(getattr(cfg, "ledger_backend", "http") or "http").lower() == "memory":
return 0
from agent.ledger.http_ledger import HttpLedger
book = HttpLedger()
claimer = getattr(book, "claim_due_negotiate", None)
if not callable(claimer):
return 0
rows = claimer() or []
sent = 0
for row in rows:
if not isinstance(row, dict):
continue
try:
if _reask_one(row, settings=cfg):
sent += 1
except Exception:
logger.exception("协商再问失败 wo=%s", row.get("work_order_no") or row.get("workOrderNo"))
return sent
def _reask_one(row: dict[str, Any], *, settings: Settings) -> bool:
no = str(row.get("work_order_no") or row.get("workOrderNo") or "").strip()
if not 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 "销售"
facts = dict(row.get("facts") or {})
quote = dict(row.get("quote") or {})
line = str(row.get("business_line") or row.get("businessLine") or "").strip().upper()
if chat_id:
ok = _send_group_reask(
chat_id=chat_id,
work_order_no=no,
sales_name=name,
facts=facts,
quote=quote,
business_line=line,
settings=settings,
)
if not ok:
logger.warning("协商再问原群失败且不补发私聊 wo=%s chat=%s", no, chat_id)
return ok
if not sales:
logger.warning("协商再问无销售 userid wo=%s", no)
return False
from agent.channel.queue import get_message_store
text = copy.deal_card(work_order_no=no, quote=quote, facts=facts)
payload = copy.deal_wecom_payload(work_order_no=no, quote=quote, facts=facts)
ok, info = get_message_store().enqueue_outbound(
touser=sales,
content=text,
dedupe_key=f"deal-reask:{no}",
payload=payload,
)
if not ok:
logger.warning("协商再问私聊入队失败 wo=%s info=%s", no, info)
return bool(ok)
def _send_group_reask(
*,
chat_id: str,
work_order_no: str,
sales_name: str,
facts: dict[str, str],
quote: dict[str, Any],
business_line: str,
settings: Settings,
) -> bool:
text = copy.group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
if business_line == "AIR":
from agent.channel.aibot.reply import AibotReplyClient
out = AibotReplyClient().send_group(chat_id=chat_id, content=text)
return bool(out.get("ok"))
from agent.channel.outbox.sender import WeComAppClient
client = WeComAppClient(
corp_id=settings.wecom_corp_id,
secret=settings.wecom_secret,
agent_id=int(settings.wecom_agent_id or "0") or 0,
)
out = client.send_group(chat_id=chat_id, content=text)
if not out.get("ok"):
return False
carder = getattr(client, "send_group_card", None)
if callable(carder):
payload = copy.deal_wecom_payload(work_order_no=work_order_no, quote=quote, facts=facts)
carder(chat_id=chat_id, template_card=payload.get("template_card") or {})
return True
+34 -9
View File
@@ -111,28 +111,50 @@ def process_recognition(data: dict[str, Any], *, allow_network: bool = True) ->
def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]:
"""跑 sheet_map 并回写主账;失败也必须回写 failed+原因。"""
"""
跑 sheet_map 并回写主账;失败也必须回写 failed+原因。
mergeExisting=true(未换文件重新解析)时:与 existingMapping 补缺合并后再回写,
旧字段优先保留;换文件/首传不传该标记,整份覆盖。
"""
from agent.config import get_settings
from agent.llm.mode_sheet_map import invoke_sheet_map
from agent.llm.mode_sheet_map import (
assess_mapping_gaps,
invoke_sheet_map,
merge_mapping_prefer_existing,
)
version_id = str(payload.get("versionId") or "").strip()
settings = get_settings()
net = bool(allow_network and settings.llm_allow_network)
result = invoke_sheet_map(payload, allow_network=net)
result = invoke_sheet_map(payload, allow_network=net, timeout_seconds=240.0, max_rounds=3)
merge_existing = bool(payload.get("mergeExisting") or payload.get("merge_existing"))
existing = payload.get("existingMapping") or payload.get("existing_mapping")
body: dict[str, Any]
if result.get("ok") and isinstance(result.get("mapping"), dict):
mapping = dict(result["mapping"])
gaps = result.get("gaps_after")
if not isinstance(gaps, list):
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
mapping["mappingGaps"] = [str(g) for g in gaps if g]
if merge_existing and isinstance(existing, dict):
mapping = merge_mapping_prefer_existing(existing, mapping)
# 合并后再对照清单复评缺口,避免角标沿用合并前数字
remaining = assess_mapping_gaps(payload, mapping)
mapping["mappingGaps"] = [str(g) for g in remaining if g]
if not remaining:
mapping["unmappedRequired"] = []
mapping["unmappedLabels"] = []
else:
gaps = result.get("gaps_after")
if not isinstance(gaps, list):
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
mapping["mappingGaps"] = [str(g) for g in gaps if g]
# 运营可读摘要:点数之外固定带上未解析项
n_fields = len(mapping.get("fields") or {})
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
points = n_fields + n_fee + n_route
parse_message = f"已解析 {points} 个映射点"
if mapping["mappingGaps"]:
if merge_existing:
parse_message += "(保留原映射并补缺)"
if mapping.get("mappingGaps"):
preview = ";".join(mapping["mappingGaps"][:8])
more = len(mapping["mappingGaps"]) - 8
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
@@ -144,6 +166,7 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
"mappingJson": mapping,
"parseStatus": "parsed",
"parseMessage": parse_message,
"mergeExisting": merge_existing,
}
else:
err = str(result.get("error") or "AI 解析失败")
@@ -152,12 +175,14 @@ def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: boo
"ok": False,
"parseStatus": "failed",
"parseMessage": err,
"mergeExisting": merge_existing,
}
posted = _post_parse_result(body)
logger.info(
"quote_template_parse done versionId=%s ok=%s posted=%s",
"quote_template_parse done versionId=%s ok=%s merge=%s posted=%s",
version_id,
body.get("ok"),
merge_existing,
posted.get("ok"),
)
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
+36 -5
View File
@@ -193,15 +193,46 @@ class HttpLedger:
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
out = self._post(
"/inquiry/agent/ticket/transition",
{"workOrderNo": work_order_no, "toStatus": to_status},
)
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
body: dict[str, Any] = {"workOrderNo": work_order_no, "toStatus": to_status}
if remark:
body["remark"] = remark
if lost_reason:
body["lostReason"] = lost_reason
if next_followup_at:
body["nextFollowupAt"] = next_followup_at
if deal_notify_summary:
body["dealNotifySummary"] = deal_notify_summary
out = self._post("/inquiry/agent/ticket/transition", body)
if not out.get("ok") and out.get("status"):
out["ok"] = True
return out if out.get("ok") else {"ok": False, "error": out.get("error") or "transition_failed"}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
"""未成交后记下原因。不改六态。"""
out = self._post(
"/inquiry/agent/ticket/patchLostReason",
{"workOrderNo": work_order_no, "lostReason": lost_reason},
)
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]]:
"""认领到期协商再问,主账会清空再问时刻防重发。"""
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 []
def match_sea_staff(self, *, origin: str, destination: str) -> list[dict[str, Any]]:
"""主账按线路返回启用的海运产品岗。"""
out = self._post(
+69 -2
View File
@@ -41,6 +41,9 @@ class MemoryTicket:
collab_ended: bool = False
attachments: list[dict[str, Any]] = field(default_factory=list)
quote_file_ready: bool = False
lost_reason: str = ""
next_followup_at: str = ""
deal_notify_summary: str = ""
class MemoryLedger:
@@ -207,14 +210,70 @@ class MemoryLedger:
"status": ticket.status,
}
def transition(self, *, work_order_no: str, to_status: str) -> dict[str, Any]:
def transition(
self,
*,
work_order_no: str,
to_status: str,
remark: str = "",
lost_reason: str = "",
next_followup_at: str = "",
deal_notify_summary: str = "",
) -> dict[str, Any]:
_ = remark
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.status = to_status
if to_status == "未成交":
ticket.next_followup_at = ""
if lost_reason:
ticket.lost_reason = lost_reason
elif to_status == "已成交":
ticket.next_followup_at = ""
if deal_notify_summary:
ticket.deal_notify_summary = deal_notify_summary
elif to_status == "协商中":
ticket.next_followup_at = next_followup_at
return {"ok": True, "status": ticket.status}
def patch_lost_reason(self, *, work_order_no: str, lost_reason: str) -> dict[str, Any]:
reason = (lost_reason or "").strip()
with self._lock:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
if ticket.status != "未成交":
return {"ok": False, "error": "not_lost", "status": ticket.status}
if not reason:
return {"ok": False, "error": "reason_blank"}
ticket.lost_reason = reason
return {"ok": True, "work_order_no": work_order_no, "lost_reason": reason}
def claim_due_negotiate(self) -> list[dict[str, Any]]:
now = datetime.now(_SHANGHAI).strftime("%Y-%m-%d %H:%M:%S")
hits: list[dict[str, Any]] = []
with self._lock:
for ticket in self._tickets.values():
due = (ticket.next_followup_at or "").strip()
if ticket.status != "协商中" or not due or due > now:
continue
ticket.next_followup_at = ""
hits.append(
{
"work_order_no": ticket.work_order_no,
"sales_wecom_id": ticket.sales_wecom_id,
"sales_name": "",
"business_line": ticket.business_line,
"collab_chat_id": ticket.collab_chat_id,
"status": ticket.status,
"facts": dict(ticket.facts),
"quote": dict(ticket.quote),
}
)
return hits
def get_ticket(self, *, work_order_no: str) -> MemoryTicket | None:
with self._lock:
return self._tickets.get(work_order_no)
@@ -282,8 +341,12 @@ class MemoryLedger:
status = ticket.status
quote = dict(ticket.quote or {})
wait_phase = "done"
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
wait_phase = "done"
elif status == "未成交":
wait_phase = "wait_lost_reason" if not (ticket.lost_reason or "").strip() else "done"
elif status == "协商中":
wait_phase = "wait_deal"
elif status == "已报价" and quote:
# 文件已发给销售 → 成交跟进;否则海运协同卡、空运方案卡
if ticket.quote_file_ready or quote.get("quoteFileIssued"):
@@ -306,6 +369,10 @@ class MemoryLedger:
"wait_phase": wait_phase,
"collab_chat_id": ticket.collab_chat_id,
"collab_facts": dict(ticket.collab_facts),
"lost_reason": ticket.lost_reason,
"lostReason": ticket.lost_reason,
"deal_notify_summary": ticket.deal_notify_summary,
"next_followup_at": ticket.next_followup_at,
}
def upsert_staff(self, row: dict[str, Any]) -> None:
+188 -48
View File
@@ -2,7 +2,7 @@
报价工作表映射 mode:上传时用千问产出 mappingJson。
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
对照单元格清单做缺项检测,必要时再补跑一轮。
对照单元格清单做缺项检测;有缺口则多轮补跑(默认最多 3 轮),目标一次流程内齐全。
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
"""
@@ -22,30 +22,58 @@ MODE = LlmMode.SHEET_MAP
_PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
(("有效期至", "有效期", "Validity"), "valid_until"),
(("客户名称", "Customer Name", "Customer"), "customer_name"),
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
(("电话", "Tel", "Phone", "Mobile"), "contact_phone"),
(("币别", "Currency"), "currency"),
(("业务员", "Sales"), "sales_name"),
(("品名", "Commodity", "Goods Description"), "commodity"),
]
_LABEL_TO_CODE_SEA: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery"), "delivery_address"),
(("货值", "Cargo Value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁"), "oil_elec_mag"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
(("开船日", "On Board"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
(("要求货物到达", "Requested Arrival"), "eta_request"),
(("运输时效", "Transit Time", "航程时间"), "transit_time"),
(("船期", "Sailing Schedule"), "sailing_schedule"),
(("开船日", "On Board Date"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Company"), "shipping_line"),
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
(("贸易条款", "Terms"), "trade_terms"),
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
(("起运港", "POL"), "pol"),
(("目的港", "POD"), "pod"),
(("品名", "Commodity"), "commodity"),
(("箱型箱量", "箱型", "柜量", "Type & Qty"), "container_type_qty"),
(("起运港", "Port of Loading", "POL"), "pol"),
(("目的港", "Port of Discharge", "POD"), "pod"),
(("货物数量", "Quantity"), "quantity"),
(("询价日期", "Inquiry Date", "报价日期"), "quote_date"),
(("RFQ", "询价单号", "报价单编号"), "quote_no"),
(("有效期", "period"), "valid_until"),
]
_LABEL_TO_CODE_AIR: list[tuple[tuple[str, ...], str]] = [
(("起运地", "起运港", "Departure", "Origin Airport"), "origin"),
(("目的地", "目的港", "Destination Airport"), "destination"),
(("件数", "Pieces", "Pkgs"), "pieces"),
(("毛重", "重量", "Gross Weight", "Weight"), "weight_kg"),
(("体积", "Volume", "CBM"), "volume_cbm"),
(("计费重", "Chargeable Weight"), "chargeable_weight_kg"),
(("航班", "Flight"), "flight_no"),
(("中转机场", "Transfer Airport"), "transfer_airport"),
(("贸易条款", "Trade Terms", "Incoterms"), "trade_terms"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
]
_LABEL_TO_CODE_LAND: list[tuple[tuple[str, ...], str]] = [
(("始发地", "起运地", "发站", "Origin"), "origin"),
(("目的地", "到站", "Destination"), "destination"),
(("运输时效", "时效", "Transit Time"), "transit_time"),
(("特殊说明", "特别说明"), "special_remarks"),
]
_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
@@ -65,6 +93,33 @@ _SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
]
def _labels_for_mode(mode: str) -> list[tuple[tuple[str, ...], str]]:
m = (mode or "sea").strip().lower()
out = list(_LABEL_TO_CODE_COMMON)
if m in ("sea", "海运"):
out.extend(_LABEL_TO_CODE_SEA)
elif m in ("air", "空运"):
out.extend(_LABEL_TO_CODE_AIR)
elif m in ("land", "陆运"):
out.extend(_LABEL_TO_CODE_LAND)
else:
out.extend(_LABEL_TO_CODE_SEA)
return out
def _label_hit(blob: str, labels: tuple[str, ...]) -> bool:
"""清单命中:中文标签直接包含;英文至少 4 字符,降低误报。"""
for lab in labels:
if not lab:
continue
if any("\u4e00" <= ch <= "\u9fff" for ch in lab):
if lab in blob:
return True
elif len(lab) >= 4 and lab.lower() in blob.lower():
return True
return False
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
@@ -178,7 +233,7 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
"""
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
不打外网。按运输方式过滤字段词典;海运另检查三段费用区。
"""
blob = _inventory_blob(payload)
if not blob.strip():
@@ -196,24 +251,89 @@ def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None)
for r in fee_regions
if isinstance(r, dict)
).lower()
route_regions = mapping.get("routeRegions") if isinstance(mapping.get("routeRegions"), list) else []
gaps: list[str] = []
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
# 字段匹配用:去掉费用段标题,避免「目的港费用」误报成 pod 等字段缺口
field_blob = blob
if mode in ("sea", "海运"):
for markers, matchers in _SEA_FEE_MARKERS:
if not any(m in blob for m in markers):
continue
if not any(x in fee_blob for x in matchers):
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
for m in markers:
field_blob = field_blob.replace(m, " ")
elif mode in ("land", "陆运"):
# 陆运主表常见项目/线路矩阵:清单有「项目」或车型价列但无 routeRegions
if (
("项目" in blob or "车型" in blob or "3T" in blob or "拼车" in blob)
and not route_regions
):
gaps.append("线路区缺失:陆运表须建 routeRegions 承接线路/车型报价")
for labels, code in _LABEL_TO_CODE:
if code in field_codes:
# 陆运已建 routeRegions 时,始发地/目的地/时效常在线路矩阵列头,不再强制 fields
land_route_covered = mode in ("land", "陆运") and bool(route_regions)
land_skip_codes = {"origin", "destination", "transit_time"} if land_route_covered else set()
for labels, code in _labels_for_mode(mode):
if code in field_codes or code in land_skip_codes:
continue
if any(lab in blob for lab in labels):
if _label_hit(field_blob, labels):
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
return gaps
def merge_mapping_prefer_existing(
old_mapping: dict[str, Any] | None,
new_mapping: dict[str, Any] | None,
) -> dict[str, Any]:
"""
补缺合并:旧 fields / 区域优先,新结果只补充旧里没有的 code/id。
用于「未换文件重新解析」:避免已对上的字段被新一轮 AI 冲掉。
"""
new_mapping = dict(new_mapping or {})
old_mapping = old_mapping if isinstance(old_mapping, dict) else {}
out = dict(new_mapping)
new_fields = new_mapping.get("fields") if isinstance(new_mapping.get("fields"), dict) else {}
old_fields = old_mapping.get("fields") if isinstance(old_mapping.get("fields"), dict) else {}
fields: dict[str, Any] = dict(new_fields)
for code, cell in old_fields.items():
if cell is not None and str(cell).strip():
fields[str(code)] = cell
out["fields"] = fields
out["feeRegions"] = _merge_regions_by_id(
old_mapping.get("feeRegions"), new_mapping.get("feeRegions")
)
out["routeRegions"] = _merge_regions_by_id(
old_mapping.get("routeRegions"), new_mapping.get("routeRegions")
)
return out
def _merge_regions_by_id(old_arr: Any, new_arr: Any) -> list[dict[str, Any]]:
"""区域按 id 合并:旧整段保留,新 id 追加。"""
out: list[dict[str, Any]] = []
seen: set[str] = set()
for src in (old_arr, new_arr):
if not isinstance(src, list):
continue
for item in src:
if not isinstance(item, dict):
continue
rid = str(item.get("id") or "").strip()
if rid and rid in seen:
continue
if rid:
seen.add(rid)
out.append(dict(item))
return out
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
@@ -273,10 +393,12 @@ def invoke_sheet_map(
*,
allow_network: bool = False,
chat_fn: Optional[Any] = None,
timeout_seconds: float = 180.0,
timeout_seconds: float = 240.0,
max_rounds: int = 3,
) -> dict[str, Any]:
"""
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
调用千问产出 mappingJson;对照清单有缺口则补跑,最多 max_rounds 轮(默认 3),
目标一次流程内尽量齐全,缺口写进 mappingGaps。
allow_network=False 且无 chat_fn:不打外网。
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
@@ -293,36 +415,54 @@ def invoke_sheet_map(
else:
chat = chat_fn
rounds_cap = max(1, min(int(max_rounds or 3), 4))
first = _chat_once(chat=chat, payload=payload, gaps=None)
if not first.get("ok"):
return first
mapping = first["mapping"]
gaps = assess_mapping_gaps(payload, mapping)
rounds = 1
if gaps:
logger.info(
"sheet_map gaps versionId=%s n=%s sample=%s",
payload.get("versionId"),
len(gaps),
gaps[:5],
)
second = _chat_once(chat=chat, payload=payload, gaps=gaps)
rounds = 2
if second.get("ok") and second.get("mapping"):
mapping = second["mapping"]
# 第二轮仍失败结构时保留第一轮
elif not second.get("ok"):
logger.warning(
"sheet_map 补跑失败,保留第一轮 versionId=%s err=%s",
payload.get("versionId"),
second.get("error"),
)
remaining = assess_mapping_gaps(payload, mapping)
# 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」
# 有缺口就补跑,直到齐、轮次用尽、或本轮未减少缺口
while remaining and rounds < rounds_cap:
logger.info(
"sheet_map gaps versionId=%s round=%s n=%s sample=%s",
payload.get("versionId"),
rounds,
len(remaining),
remaining[:5],
)
nxt = _chat_once(chat=chat, payload=payload, gaps=remaining)
rounds += 1
if not (nxt.get("ok") and nxt.get("mapping")):
logger.warning(
"sheet_map 补跑失败,保留上一轮 versionId=%s err=%s",
payload.get("versionId"),
nxt.get("error"),
)
break
mapping = nxt["mapping"]
after = assess_mapping_gaps(payload, mapping)
if len(after) >= len(remaining):
# 未继续收敛则停止,避免空转烧额度
logger.info(
"sheet_map gaps 未收敛 versionId=%s before=%s after=%s stop",
payload.get("versionId"),
len(remaining),
len(after),
)
remaining = after
break
remaining = after
# 缺口落进 mappingJson;齐全时清空 AI 自报未映射,避免列表角标被说明性标签抬高
if isinstance(mapping, dict):
mapping["mappingGaps"] = list(remaining)
if not remaining:
mapping["unmappedRequired"] = []
mapping["unmappedLabels"] = []
elif not isinstance(mapping.get("unmappedLabels"), list):
mapping["unmappedLabels"] = []
logger.info(
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
payload.get("versionId"),
+19 -3
View File
@@ -539,12 +539,28 @@ def apply_air_deal(
copy.ticket_locked(work_order_no=ticket_no(ticket), status=ticket_status(ticket)),
)
return "ticket_locked"
result = flow.ledger.transition(work_order_no=ticket_no(ticket), to_status=outcome) or {}
from agent.policy.deal_outcome import finish_deal_outcome
from agent.policy.sea_group_ops import facts_of_group
view = ticket_view(ticket)
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=ticket_no(ticket),
outcome=outcome,
facts=facts_of_group(view),
quote=dict(view.get("quote") or {}),
business_line="AIR",
sales_name=str(view.get("sales_name") or ""),
)
if not result.get("ok"):
send_group_text(group_client_of(flow), chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
send_group_text(group_client_of(flow), chat_id, copy.deal_ack(outcome, ticket_no(ticket)))
return "done"
send_group_text(
group_client_of(flow),
chat_id,
copy.deal_ack(outcome, ticket_no(ticket), str(result.get("next_followup_at") or "")),
)
return str(result.get("phase") or "done")
def apply_air_lock(
+54 -3
View File
@@ -322,6 +322,8 @@ class AirTextInquiryFlow:
allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
elif phase == "wait_deal":
allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
elif phase == "wait_lost_reason":
allowed = ()
return FlowSession(
sender_id=sender_id,
thread_id=sender_id,
@@ -333,11 +335,17 @@ class AirTextInquiryFlow:
status=status,
allowed=allowed,
wait_version=1,
invited_sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
collab_chat_id=str(view.get("collab_chat_id") or ""),
)
def _infer_wait_phase(self, *, status: str, quote: dict[str, Any]) -> str:
if status in {"已成交", "未成交", "协商中", "已关闭"}:
if status in {"已成交", "已关闭"}:
return "done"
if status == "未成交":
return "wait_lost_reason"
if status == "协商中":
return "wait_deal"
if status == "已报价" and quote:
return "wait_adopt"
return "tms_miss"
@@ -413,6 +421,9 @@ class AirTextInquiryFlow:
# 成交卡已经绑在报价文件那条出站上。这里再发一张独立卡,
# 会在文件还在上传时被另一条出站线程抢走,销售又先看到成交跟进。
return "wait_deal"
if sess.phase == "wait_lost_reason":
reply(copy.deal_ack("未成交", sess.work_order_no))
return "wait_lost_reason"
if sess.phase == "done":
reply(copy.deal_ack(sess.status or "已关闭", sess.work_order_no))
return "done"
@@ -462,6 +473,13 @@ class AirTextInquiryFlow:
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因;完整新询价(注入字段)则空着原因开新单。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
@@ -1056,15 +1074,48 @@ class AirTextInquiryFlow:
) -> str:
"""点成交/未成交/协商中:先整卡置灰,再入主账。"""
self._grey_clicked_card(reply, card_meta, replace_name=status)
result = self._ledger.transition(work_order_no=sess.work_order_no, to_status=status)
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=self._ledger,
work_order_no=sess.work_order_no,
outcome=status,
facts=sess.facts,
quote=sess.quote,
business_line=sess.business_line,
sales_name=sess.invited_sales_name,
)
if not result.get("ok"):
reply("主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
sess.status = status
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no, str(result.get("next_followup_at") or "")))
return phase
def _capture_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
"""私聊未成交后收下原因,不再追问。"""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(
ledger=self._ledger,
work_order_no=sess.work_order_no,
reason=text,
)
if not result.get("ok"):
reply("未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
sess.phase = "done"
sess.allowed = ()
self._save(sess)
reply(copy.deal_ack(status, sess.work_order_no))
reply(copy.lost_reason_ack(text))
return "done"
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
+210
View File
@@ -0,0 +1,210 @@
"""
成交跟进纯规则:工作日顺延、线路对负责人。
本文件职责:给私聊/群聊成交共用,不发企微、不写主账。
禁止:在回调线程算完还同步推负责人;把六态写进本文件。
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, time
from typing import Any, Iterable
from zoneinfo import ZoneInfo
from agent.schema.tms_air_query import resolve_airport_code
SHANGHAI = ZoneInfo("Asia/Shanghai")
# 法定休息日(不含调休上班)。调休日放进 MAKEUP_WORKDAYS,周末也算工作日。
_HOLIDAYS = frozenset(
{
# 2026
date(2026, 1, 1),
date(2026, 1, 2),
date(2026, 1, 3),
date(2026, 2, 15),
date(2026, 2, 16),
date(2026, 2, 17),
date(2026, 2, 18),
date(2026, 2, 19),
date(2026, 2, 20),
date(2026, 2, 21),
date(2026, 2, 22),
date(2026, 2, 23),
date(2026, 4, 4),
date(2026, 4, 5),
date(2026, 4, 6),
date(2026, 5, 1),
date(2026, 5, 2),
date(2026, 5, 3),
date(2026, 5, 4),
date(2026, 5, 5),
date(2026, 6, 19),
date(2026, 6, 20),
date(2026, 6, 21),
date(2026, 9, 25),
date(2026, 9, 26),
date(2026, 9, 27),
date(2026, 10, 1),
date(2026, 10, 2),
date(2026, 10, 3),
date(2026, 10, 4),
date(2026, 10, 5),
date(2026, 10, 6),
date(2026, 10, 7),
date(2026, 10, 8),
# 2027 元旦预留
date(2027, 1, 1),
date(2027, 1, 2),
date(2027, 1, 3),
}
)
# 周末调休上班:算工作日。
_MAKEUP_WORKDAYS = frozenset(
{
date(2026, 1, 4),
date(2026, 2, 14),
date(2026, 2, 28),
date(2026, 5, 9),
date(2026, 10, 10),
}
)
def is_workday(day: date) -> bool:
"""周六日默认休息;法定假休息;调休上班算工作日。"""
if day in _HOLIDAYS:
return False
if day in _MAKEUP_WORKDAYS:
return True
return day.weekday() < 5
def shift_workdays(start: date, count: int) -> date:
"""
从 start 当天之后数 count 个工作日。
成交选协商中:顺延 3 个工作日后,第四个工作日再问,故 count=4。
"""
if count < 1:
return start
cur = start
seen = 0
while seen < count:
cur = cur + timedelta(days=1)
if is_workday(cur):
seen += 1
return cur
def next_followup_at(now: datetime | None = None) -> datetime:
"""第四个工作日 Asia/Shanghai 09:00。"""
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)
def _split_routes(raw: str) -> list[str]:
out: list[str] = []
for part in (raw or "").replace(",", ",").replace("、", ",").split(","):
t = part.strip()
if t:
out.append(t)
return out
def air_route_keys(facts: dict[str, str] | None) -> set[str]:
"""本单可对上的空运线路名:三字码对 + 目的港。"""
src = dict(facts or {})
origin = resolve_airport_code(str(src.get("起运港") or src.get("originCode") or ""))
dest = resolve_airport_code(str(src.get("目的港") or src.get("destinationCode") or ""))
keys: set[str] = set()
if dest:
keys.add(dest)
if origin and dest:
keys.add(f"{origin}-{dest}")
return keys
def _is_waicai(facts: dict[str, str] | None) -> bool:
src = dict(facts or {})
for key in ("外采", "是否外采", "采购方式"):
val = str(src.get(key) or "").strip().lower()
if val in {"1", "是", "true", "yes", "外采"}:
return True
blob = "".join(str(v) for v in src.values())
return "外采" in blob
def leader_matches(
*,
role_code: str,
routes: str,
is_leader: bool,
business_line: str,
facts: dict[str, str] | None,
) -> bool:
"""
员工管理「是负责人 + 负责线路对得上」。
海运:海运全线。陆运:线路类别整词。空运:三字码对 / 目的港 / 全线;外采须工单标明。
"""
if not is_leader:
return False
line = (business_line or "").strip().upper()
code = (role_code or "").strip().lower()
tokens = _split_routes(routes)
if line == "SEA":
return code == "sea" and "海运全线" in tokens
if line == "LAND":
want = str((facts or {}).get("线路类别") or "").strip()
return code == "land" and bool(want) and want in tokens
if line == "AIR":
if code != "air":
return False
if "全线" in tokens:
return True
if "外采-空运" in tokens and _is_waicai(facts):
return True
keys = air_route_keys(facts)
return any(t in keys for t in tokens)
return False
def pick_leaders(
staff: Iterable[dict[str, Any]],
*,
business_line: str,
facts: dict[str, str] | None,
) -> list[dict[str, str]]:
"""从员工行筛出该推的负责人(启用且有企微 id)。"""
out: list[dict[str, str]] = []
seen: set[str] = set()
for row in staff:
if str(row.get("status") or "active").lower() != "active":
continue
wecom = str(row.get("wecomId") or row.get("wecom_id") or "").strip()
if not wecom or wecom in seen:
continue
leader_flag = row.get("isLeader")
if leader_flag is None:
leader_flag = row.get("is_leader")
is_leader = leader_flag in {True, 1, "1", "true", "True"}
if not leader_matches(
role_code=str(row.get("roleCode") or row.get("role_code") or ""),
routes=str(row.get("routes") or ""),
is_leader=bool(is_leader),
business_line=business_line,
facts=facts,
):
continue
seen.add(wecom)
out.append(
{
"name": str(row.get("name") or "").strip(),
"wecomId": wecom,
"roleCode": str(row.get("roleCode") or row.get("role_code") or ""),
}
)
return out
+160
View File
@@ -0,0 +1,160 @@
"""
成交三态落地:改主账六态、推负责人、收下未成交原因、排协商再问。
本文件职责:私聊/群聊共用。不在回调线程等 LLM。推送只入 outbox。
禁止:把图成功当主账成功;成交失败还去推负责人;未成交推负责人。
"""
from __future__ import annotations
import logging
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
logger = logging.getLogger(__name__)
EnqueueFn = Callable[..., tuple[bool, str]]
def notify_app_ready() -> bool:
"""询价消息服务已配 AgentId+Secret 才推。空则只成交不推。"""
settings = get_settings()
return bool(
str(getattr(settings, "wecom_notify_agent_id", "") or "").strip()
and str(getattr(settings, "wecom_notify_secret", "") or "").strip()
)
def _role_of(business_line: str) -> str:
line = (business_line or "").strip().upper()
if line == "SEA":
return "sea"
if line == "LAND":
return "land"
return "air"
def _enqueue_default(
*,
touser: str,
content: str,
dedupe_key: str,
payload: dict[str, Any],
) -> tuple[bool, str]:
from agent.channel.queue import get_message_store
return get_message_store().enqueue_outbound(
touser=touser,
content=content,
dedupe_key=dedupe_key,
payload=payload,
)
def finish_deal_outcome(
*,
ledger: Any,
work_order_no: str,
outcome: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
sales_name: str = "",
enqueue: Optional[EnqueueFn] = None,
notify_ready: Optional[bool] = None,
) -> dict[str, Any]:
"""
先入主账,再按结果推人或排再问。
返回 ok / phase / notify_summary。phase:
已成交 done;未成交 wait_lost_reason;协商中 wait_deal。
"""
no = (work_order_no or "").strip()
status = (outcome or "").strip()
extra: dict[str, Any] = {}
summary = ""
if status == "协商中":
extra["next_followup_at"] = next_followup_at().strftime("%Y-%m-%d %H:%M:%S")
if status == "已成交":
staff: list[dict[str, Any]] = []
lister = getattr(ledger, "list_staff_by_role", None)
if callable(lister):
try:
staff = list(lister(role_code=_role_of(business_line)) or [])
except Exception:
logger.exception("成交匹配负责人失败 wo=%s", no)
leaders = pick_leaders(staff, business_line=business_line, facts=facts)
ready = notify_app_ready() if notify_ready is None else bool(notify_ready)
if not leaders:
summary = "无匹配负责人"
elif not ready:
summary = "通知未发出"
else:
names = [str(x.get("name") or x.get("wecomId") or "").strip() for x in leaders]
summary = "已推:" + "、".join(n for n in names if n)
sender = enqueue or _enqueue_default
payload = copy.deal_notify_wecom_payload(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
text = copy.deal_notify_card(
work_order_no=no,
sales_name=sales_name,
facts=facts or {},
quote=quote or {},
business_line=business_line,
)
for row in leaders:
uid = str(row.get("wecomId") or "").strip()
if not uid:
continue
try:
ok, info = sender(
touser=uid,
content=text,
dedupe_key=f"deal-notify:{no}:{uid}",
payload=dict(payload),
)
if not ok:
logger.warning("成交通知入队失败 wo=%s uid=%s info=%s", no, uid, info)
except Exception:
logger.exception("成交通知入队异常 wo=%s uid=%s", no, uid)
extra["deal_notify_summary"] = summary
extra["remark"] = summary
writer = getattr(ledger, "transition", None)
if not callable(writer):
return {"ok": False, "error": "no_transition", "phase": "ledger_fail"}
result = writer(work_order_no=no, to_status=status, **extra) or {}
if not result.get("ok"):
return {"ok": False, "error": result.get("error") or "transition_failed", "phase": "ledger_fail"}
if status == "未成交":
phase = "wait_lost_reason"
elif status == "协商中":
phase = "wait_deal"
else:
phase = "done"
return {
"ok": True,
"phase": phase,
"notify_summary": summary,
"status": status,
"next_followup_at": str(extra.get("next_followup_at") or ""),
}
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
"""把销售下一句写进未成交原因。"""
text = (reason or "").strip()
no = (work_order_no or "").strip()
if not no or not text:
return {"ok": False, "error": "blank"}
patcher = getattr(ledger, "patch_lost_reason", None)
if not callable(patcher):
return {"ok": False, "error": "no_patch"}
return patcher(work_order_no=no, lost_reason=text) or {"ok": False}
+94 -2
View File
@@ -1732,12 +1732,104 @@ def group_deal_mention(*, sales_name: str, work_order_no: str) -> str:
return group_deal_followup(sales_name=sales_name, work_order_no=work_order_no)
def deal_ack(outcome: str, work_order_no: str) -> str:
def deal_ack(outcome: str, work_order_no: str, next_followup_at: str = "") -> str:
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
if outcome == "已成交":
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
if outcome == "未成交":
return "已记录为「未成交」。请补充未成交原因。"
return f"已记录为「协商中」。工单 {work_order_no} 跟进周期已顺延。"
when = _followup_month_day(next_followup_at)
if when:
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
def _followup_month_day(raw: str) -> str:
"""把主账再问时刻收成「9月24日」。"""
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)):
try:
dt = datetime.strptime(text[:n], fmt)
return f"{dt.month}月{dt.day}日"
except ValueError:
continue
return ""
def lost_reason_ack(reason: str = "") -> str:
"""收下未成交原因后回显原句,不再追问。"""
text = (reason or "").strip()
if text.endswith(("。", ".", "!", "!", "?", "?")):
return f"已记下未成交原因:{text}"
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
def deal_notify_card(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> str:
"""成交通知文字兜底:方案卡压缩。不写销售,避免标题出现「销售 销售」。"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
lines = [f"成交通知 工单 {no}"]
lines.extend(f"{row['keyname']}:{row['value']}" for row in rows)
return "\n".join(lines)
def deal_notify_wecom_payload(
*,
work_order_no: str,
sales_name: str,
facts: dict[str, str] | None,
quote: dict[str, Any] | None,
business_line: str,
) -> dict[str, Any]:
"""
成交通知卡:走询价消息服务,不走询价小助手。
字段按报价方案卡压缩,避免超过 6 格。标题不写销售。
"""
no = (work_order_no or "").strip()
_ = sales_name
mode = scheme_transport_mode(business_line)
rows = scheme_horizontal_rows(
work_order_no=no,
facts=dict(facts or {}),
quote=dict(quote or {}),
transport_mode=mode,
)
from agent.config import get_settings
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
action_url = base or "https://work.weixin.qq.com"
return {
"msgtype": "template_card",
"wecom_app": "notify",
"template_card": {
"card_type": "text_notice",
"source": {"desc": "成交通知", "desc_color": 0},
"main_title": {
"title": f"成交通知 工单 {no}",
},
"horizontal_content_list": rows,
"card_action": {"type": 1, "url": action_url},
},
}
def air_need_work_order() -> str:
@@ -91,6 +91,13 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
reply=reply,
sess=sess,
)
# 未成交后下一句任意文字即原因。不能走下面的 new_over_old,否则会当新询价丢掉。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
if sess and phase not in _LAND_FRONT:
# 查价后半段书签还在时,销售又发新陆运:只走陆运前半段。
# 禁止再 super 去海运 on_text,海运会把 LAND 转回来,递归到死、一句都不回。
+66 -7
View File
@@ -64,6 +64,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
view.get("productNames") or view.get("product_names") or [],
)
view.setdefault("sales_name", view.get("salesName") or view.get("sales_name") or "")
view.setdefault("lost_reason", view.get("lostReason") or view.get("lost_reason") or "")
facts = dict(view.get("collab_facts") or view.get("collabFacts") or {})
view.setdefault("collab_facts", facts)
flag = str(facts.get("__ended") or view.get("collab_ended") or view.get("collabEnded") or "").strip().lower()
@@ -89,6 +90,7 @@ def ticket_view(ticket: object) -> dict[str, Any]:
"product_ids": list(getattr(ticket, "product_ids", []) or []),
"product_names": list(getattr(ticket, "product_names", []) or []),
"sales_name": str(getattr(ticket, "sales_name", "") or ""),
"lost_reason": str(getattr(ticket, "lost_reason", "") or ""),
}
@@ -987,18 +989,75 @@ def apply_group_deal(
if not (quote.get("total") or _quote_fee_rows(quote) or status_now in {"已报价", "协商中"}):
send_group_text(client, chat_id, copy.group_deal_need_quote())
return "deal_need_quote"
writer = getattr(flow.ledger, "transition", None)
if callable(writer):
result = writer(work_order_no=no, to_status=outcome) or {}
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
from agent.policy.deal_outcome import finish_deal_outcome
result = finish_deal_outcome(
ledger=flow.ledger,
work_order_no=no,
outcome=outcome,
facts=facts_of_group(view, sess),
quote=quote,
business_line=str(view.get("business_line") or (sess.business_line if sess else "SEA") or "SEA"),
sales_name=str(view.get("sales_name") or (getattr(sess, "invited_sales_name", "") if sess else "") or ""),
)
if not result.get("ok"):
send_group_text(client, chat_id, "主账状态更新失败,成交结果未入账。")
return "ledger_fail"
phase = str(result.get("phase") or "done")
if sess is not None:
sess.status = outcome
sess.phase = phase
if phase == "wait_deal":
sess.allowed = (copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE)
else:
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no, str(result.get("next_followup_at") or "")))
return phase
def facts_of_group(view: dict[str, Any], sess: Any = None) -> dict[str, str]:
"""主账字段打底,书签覆盖;给负责人匹配和成交通知卡用。"""
facts = dict(view.get("facts") or {})
if sess is not None and getattr(sess, "facts", None):
facts.update(dict(sess.facts or {}))
return facts
def capture_group_lost_reason(
*,
flow: Any,
ticket: object,
sender_id: str,
text: str,
sess: Any = None,
) -> str:
"""
群里未成交后,本单销售下一句任意文字即原因。
空运群是否要 @ 由入站通道先拦,这里不再判。
"""
ticket = hydrate_ticket(flow, ticket)
view = ticket_view(ticket)
no = ticket_no(ticket)
chat_id = str((sess.collab_chat_id if sess else "") or view.get("collab_chat_id") or "")
client = group_client_of(flow)
if ticket_status(ticket) != "未成交":
return ""
if str(view.get("lost_reason") or "").strip():
return ""
if not is_sales(ticket, sender_id):
return ""
from agent.policy.deal_outcome import capture_lost_reason
result = capture_lost_reason(ledger=flow.ledger, work_order_no=no, reason=text)
if not result.get("ok"):
send_group_text(client, chat_id, "未成交原因没有记下,请再发一句。")
return "wait_lost_reason"
if sess is not None:
sess.phase = "done"
sess.allowed = ()
flow._save(sess)
send_group_text(client, chat_id, copy.deal_ack(outcome, no))
send_group_text(client, chat_id, copy.lost_reason_ack(text))
return "done"
@@ -85,6 +85,13 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
reply=reply,
card_meta=inbound_raw,
)
# 未成交后下一句任意文字即原因,不能先抽字段当成新询价。
if (
sess
and sess.phase == "wait_lost_reason"
and not (injected_facts and injected_mode)
):
return self._capture_lost_reason(sess, text, reply)
snap = extract_inquiry_snapshot(
text,
@@ -360,6 +360,8 @@ transportMode:sea | air | land
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
8. **一次齐全**:第一轮就要尽量映射清单中全部可见业务标签;宁可多写 fields/feeRegions/routeRegions,也不要省略。补跑时输出完整 mappingJson(覆盖式),不要只回增量。
9. 说明性/公式性文字(如「1CBM=***KG」「重量体积取大」)无法落白名单字段时写入 unmappedLabels,不要假装成业务字段缺口。
【用户消息将提供】
运输方式:海运 | 空运 | 陆运
@@ -368,4 +370,4 @@ transportMode:sea | air | land
(可选)TMS 样例或出单投影说明——仅对齐字段
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。
输出前自检:fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签;有缺口则先补齐再输出。只输出 mappingJson。
+144
View File
@@ -0,0 +1,144 @@
"""
成交跟进:工作日顺延、线路对负责人、未成交收下原因(不打外网)。
"""
from __future__ import annotations
import unittest
from datetime import date, datetime
from zoneinfo import ZoneInfo
from agent.policy.deal_followup import (
SHANGHAI,
air_route_keys,
leader_matches,
next_followup_at,
shift_workdays,
)
class TestWorkdays(unittest.TestCase):
def test_skip_weekend(self) -> None:
# 2026-09-18 周五,顺延 3 个工作日 → 21、22、23,第四个工作日 24
self.assertEqual(shift_workdays(date(2026, 9, 18), 3), date(2026, 9, 23))
self.assertEqual(shift_workdays(date(2026, 9, 18), 4), date(2026, 9, 24))
def test_skip_national_day(self) -> None:
# 2026-09-30 周三,国庆休 → 第四个工作日落到节后
fourth = shift_workdays(date(2026, 9, 30), 4)
self.assertGreaterEqual(fourth, date(2026, 10, 9))
self.assertNotEqual(fourth.weekday(), 5)
self.assertNotEqual(fourth.weekday(), 6)
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)
self.assertEqual(when.tzinfo, SHANGHAI)
self.assertEqual(when.date(), date(2026, 9, 24))
class TestAirRouteKeys(unittest.TestCase):
def test_pair_and_dest(self) -> None:
keys = air_route_keys({"起运港": "SZX", "目的港": "KUL"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
def test_chinese_city_projects_iata(self) -> None:
keys = air_route_keys({"起运港": "深圳", "目的港": "吉隆坡"})
self.assertIn("SZX-KUL", keys)
self.assertIn("KUL", keys)
class TestLeaderMatch(unittest.TestCase):
def test_sea_all_line(self) -> None:
self.assertTrue(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=True,
business_line="SEA",
facts={},
)
)
self.assertFalse(
leader_matches(
role_code="sea",
routes="海运全线",
is_leader=False,
business_line="SEA",
facts={},
)
)
def test_land_route_category(self) -> None:
self.assertTrue(
leader_matches(
role_code="land",
routes="国内长途/零担,东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
self.assertFalse(
leader_matches(
role_code="land",
routes="东南亚",
is_leader=True,
business_line="LAND",
facts={"线路类别": "国内长途/零担"},
)
)
def test_air_pair_dest_and_all(self) -> None:
facts = {"起运港": "SZX", "目的港": "KUL"}
self.assertTrue(
leader_matches(
role_code="air",
routes="SZX-KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="KUL",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="全线",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertFalse(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts=facts,
)
)
self.assertTrue(
leader_matches(
role_code="air",
routes="外采-空运",
is_leader=True,
business_line="AIR",
facts={**facts, "外采": "1"},
)
)
if __name__ == "__main__":
unittest.main()
+247
View File
@@ -0,0 +1,247 @@
"""
成交三态:入账、推负责人、收下未成交原因、协商中不停会话。
"""
from __future__ import annotations
import unittest
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import AirTextInquiryFlow, FlowSession
from agent.policy.deal_outcome import capture_lost_reason, finish_deal_outcome
from agent.policy.sea_text_flow import SeaTextInquiryFlow
class TestDealOutcome(unittest.TestCase):
def setUp(self) -> None:
self.ledger = MemoryLedger()
created = self.ledger.create_ticket(
sender_id="sales1",
business_line="SEA",
facts={"起运港": "上海", "目的港": "洛杉矶", "品名": "衣服"},
)
self.wo = str(created.get("work_order_no") or "")
self.ledger.transition(work_order_no=self.wo, to_status="已报价")
self.ledger.upsert_staff(
{
"name": "海运负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 1,
"status": "active",
}
)
def test_deal_notifies_leaders(self) -> None:
queued: list[dict] = []
def enqueue(*, touser, content, dedupe_key, payload):
queued.append(
{
"touser": touser,
"content": content,
"dedupe_key": dedupe_key,
"payload": payload,
}
)
return True, "ok"
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={"起运港": "上海", "目的港": "洛杉矶"},
quote={"total": "1000", "currency": "USD"},
business_line="SEA",
sales_name="吴吉林",
enqueue=enqueue,
notify_ready=True,
)
self.assertTrue(out.get("ok"))
self.assertEqual(out.get("phase"), "done")
self.assertIn("海运负责人", str(out.get("notify_summary") or ""))
self.assertEqual(queued[0]["touser"], "lead-sea")
self.assertEqual(queued[0]["payload"].get("wecom_app"), "notify")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "已成交")
self.assertEqual(ticket.next_followup_at, "")
def test_deal_notify_card_omits_sales_label(self) -> None:
"""成交通知不要「销售」二字:标题副标和正文都不写。"""
text = copy.deal_notify_card(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
self.assertNotIn("销售", text)
payload = copy.deal_notify_wecom_payload(
work_order_no="WO202609180008",
sales_name="吴吉林",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
quote={},
business_line="LAND",
)
title = (payload.get("template_card") or {}).get("main_title") or {}
blob = str(title.get("title") or "") + str(title.get("desc") or "")
self.assertNotIn("销售", blob)
self.assertNotIn("desc", title)
def test_deal_without_leader_still_closes(self) -> None:
self.ledger.upsert_staff(
{
"name": "不是负责人",
"wecomId": "lead-sea",
"roleCode": "sea",
"routes": "海运全线",
"isLeader": 0,
"status": "active",
}
)
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="已成交",
facts={},
quote={},
business_line="SEA",
notify_ready=True,
)
self.assertEqual(out.get("notify_summary"), "无匹配负责人")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "已成交")
def test_lost_waits_for_reason(self) -> None:
out = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="未成交",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(out.get("phase"), "wait_lost_reason")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "未成交")
self.assertEqual(ticket.lost_reason, "")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_lost_reason")
captured = capture_lost_reason(
ledger=self.ledger, work_order_no=self.wo, reason="客人比价了"
)
self.assertTrue(captured.get("ok"))
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.lost_reason, "客人比价了")
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "done")
def test_lost_reason_ack_repeats_user_text(self) -> None:
self.assertEqual(copy.lost_reason_ack("客户不要了"), "已记下未成交原因:客户不要了。")
self.assertEqual(copy.lost_reason_ack("价格高。"), "已记下未成交原因:价格高。")
def test_negotiate_ack_names_followup_day(self) -> None:
text = copy.deal_ack("协商中", "WO202609190007", "2026-09-24 09:00:00")
self.assertEqual(
text,
"已记录为「协商中」。已顺延三个工作日,9月24日的9:00再次发起成交状态询问。",
)
self.assertNotIn("跟进周期已顺延", text)
def test_negotiate_keeps_wait_deal(self) -> None:
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")
ticket = self.ledger.get_ticket(work_order_no=self.wo)
self.assertEqual(ticket.status, "协商中")
self.assertTrue(ticket.next_followup_at)
view = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
self.assertEqual(view.get("wait_phase"), "wait_deal")
again = finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
self.assertEqual(again.get("phase"), "wait_deal")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).status, "协商中")
self.assertTrue(again.get("next_followup_at"))
def test_private_next_text_is_lost_reason(self) -> None:
flow = AirTextInquiryFlow(self.ledger)
sess = FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
flow._save(sess)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
def test_sea_private_next_text_is_lost_reason(self) -> None:
flow = SeaTextInquiryFlow(self.ledger)
flow._save(
FlowSession(
sender_id="sales1",
thread_id="sales1",
phase="wait_deal",
business_line="SEA",
work_order_no=self.wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="sales1",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
phase = flow.on_text(
sender_id="sales1",
text="客人比价了",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).lost_reason, "客人比价了")
def test_claim_due_negotiate_clears_schedule(self) -> None:
finish_deal_outcome(
ledger=self.ledger,
work_order_no=self.wo,
outcome="协商中",
facts={},
quote={},
business_line="SEA",
)
ticket = self.ledger.get_ticket(work_order_no=self.wo)
ticket.next_followup_at = "2020-01-01 09:00:00"
rows = self.ledger.claim_due_negotiate()
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["work_order_no"], self.wo)
self.assertEqual(self.ledger.get_ticket(work_order_no=self.wo).next_followup_at, "")
@@ -26,6 +26,7 @@ os.environ.setdefault("LEDGER_BACKEND", "memory")
from agent.ledger.memory_ledger import MemoryLedger
from agent.policy import inquiry_copy as copy
from agent.policy.air_text_flow import FlowSession
from agent.policy.land_text_flow import LandTextInquiryFlow
@@ -623,5 +624,46 @@ class LandTextPullTests(unittest.TestCase):
self.assertTrue(is_product(ticket, "u1", flow=self.flow))
class LandLostReasonTests(unittest.TestCase):
"""陆运私聊未成交后,下一句任意文字要记下原因,不能当新询价丢掉。"""
def test_land_private_next_text_is_lost_reason(self) -> None:
ledger = MemoryLedger()
created = ledger.create_ticket(
sender_id="WuJiLin",
business_line="LAND",
facts={"起运地": "泉州", "目的地": "南京", "线路类别": "国内城配/拖车/打包"},
)
wo = str(created.get("work_order_no") or "")
ledger.transition(work_order_no=wo, to_status="已报价")
flow = LandTextInquiryFlow(ledger)
flow._save(
FlowSession(
sender_id="WuJiLin",
thread_id="WuJiLin",
phase="wait_deal",
business_line="LAND",
work_order_no=wo,
allowed=(copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE),
)
)
replies: list[str] = []
phase = flow.on_button(
sender_id="WuJiLin",
action=copy.BTN_LOST,
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(ledger.get_ticket(work_order_no=wo).status, "未成交")
phase = flow.on_text(
sender_id="WuJiLin",
text="价格高",
reply=lambda t, extra=None: replies.append(t),
)
self.assertEqual(phase, "done")
self.assertEqual(ledger.get_ticket(work_order_no=wo).lost_reason, "价格高")
self.assertTrue(any(t == "已记下未成交原因:价格高。" for t in replies))
if __name__ == "__main__":
unittest.main()
+28 -1
View File
@@ -1522,9 +1522,36 @@ class SeaGroupOpsTests(unittest.TestCase):
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(phase, "wait_lost_reason")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).status, "未成交")
def test_sales_next_text_is_lost_reason(self) -> None:
wo, chat = self._open_group()
handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-1",
content="未成交",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
phase = handle_group_collab(
InboundMessage(
sender_id="sales1",
message_id="lost-2",
content="客人比价了",
chat_id=chat,
chat_type="group",
),
flow=self.flow,
ledger=self.ledger,
)
self.assertEqual(phase, "done")
self.assertEqual(self.ledger.get_ticket(work_order_no=wo).lost_reason, "客人比价了")
def test_sales_can_close_deal_by_text(self) -> None:
"""群里销售说成交,要入账并回话,不能装没听见。相近说法也认。"""
wo, chat = self._open_group()
+145
View File
@@ -101,6 +101,93 @@ class TestSheetMapParse(unittest.TestCase):
}
self.assertEqual(assess_mapping_gaps(payload, mapping), [])
def test_assess_gaps_air_ignores_sea_labels(self) -> None:
"""空运不按海运词典报「提货地址/货值」缺口。"""
payload = {
"transportMode": "air",
"cellInventory": [
"A!B2 | 起运地/Origin Airport | - | -",
"A!B3 | 毛重/Gross Weight | - | -",
"A!Z99 | 提货地址/Place of Receipt | - | -",
],
}
mapping = {"fields": {"origin": "A!C2", "weight_kg": "A!C3"}, "feeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("pickup_address" in g for g in gaps))
self.assertEqual(gaps, [])
def test_assess_gaps_short_en_no_false_positive(self) -> None:
"""短英文(如 POL)单独出现不误报;须中文或 ≥4 字符英文标签。"""
payload = {
"transportMode": "sea",
"cellInventory": ["S!A1 | POL code table | - | -"],
}
mapping = {"fields": {}, "feeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("→ 应映射 pol" in g for g in gaps))
def test_assess_gaps_land_needs_route_regions(self) -> None:
payload = {
"transportMode": "land",
"cellInventory": [
"L!A1 | 项目 | - | -",
"L!B1 | 3T | - | -",
"L!C1 | 拼车 | - | -",
],
}
mapping = {"fields": {}, "feeRegions": [], "routeRegions": []}
gaps = assess_mapping_gaps(payload, mapping)
self.assertTrue(any("routeRegions" in g for g in gaps))
def test_assess_gaps_land_route_skips_matrix_fields(self) -> None:
"""陆运已有 routeRegions 时,不把矩阵列头始发地/目的地当 fields 缺口。"""
payload = {
"transportMode": "land",
"cellInventory": [
"L!A1 | 项目 | - | -",
"L!B1 | 始发地 | - | -",
"L!C1 | 目的地 | - | -",
"L!D1 | 运输时效 | - | -",
"L!E1 | 特殊说明 | - | -",
],
}
mapping = {
"fields": {},
"feeRegions": [],
"routeRegions": [{"id": "main", "title": "线路", "anchor": "L!A2"}],
}
gaps = assess_mapping_gaps(payload, mapping)
self.assertFalse(any("origin" in g for g in gaps))
self.assertFalse(any("destination" in g for g in gaps))
self.assertFalse(any("transit_time" in g for g in gaps))
self.assertTrue(any("special_remarks" in g for g in gaps))
def test_merge_prefer_existing_keeps_old_field(self) -> None:
from agent.llm.mode_sheet_map import merge_mapping_prefer_existing
old = {
"fields": {"quote_no": "S!B3", "pol": "S!B5"},
"feeRegions": [{"id": "origin_charges", "title": "旧始发港"}],
"routeRegions": [],
}
new = {
"fields": {"quote_no": "S!Z99", "pod": "S!B6"},
"feeRegions": [
{"id": "origin_charges", "title": "新始发港"},
{"id": "ocean_freight", "title": "海运费用"},
],
"routeRegions": [],
"mappingGaps": [],
}
merged = merge_mapping_prefer_existing(old, new)
self.assertEqual(merged["fields"]["quote_no"], "S!B3") # 旧优先
self.assertEqual(merged["fields"]["pol"], "S!B5") # 旧保留
self.assertEqual(merged["fields"]["pod"], "S!B6") # 新补缺
ids = [r["id"] for r in merged["feeRegions"]]
self.assertEqual(ids[0], "origin_charges")
self.assertEqual(merged["feeRegions"][0]["title"], "旧始发港")
self.assertIn("ocean_freight", ids)
def test_invoke_retries_when_gaps(self) -> None:
calls: list[str] = []
@@ -145,6 +232,64 @@ class TestSheetMapParse(unittest.TestCase):
self.assertEqual(out.get("gaps_after"), out["mapping"]["mappingGaps"])
self.assertEqual(out["mapping"]["mappingGaps"], [])
def test_invoke_multi_round_until_complete(self) -> None:
"""最多 3 轮:第 1 残缺 → 第 2 仍缺 → 第 3 齐。"""
calls: list[int] = []
def fake_chat(*, mode, messages, temperature=0.0): # noqa: ARG001
calls.append(1)
n = len(calls)
if n == 1:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3"},"feeRegions":[]}'
)
elif n == 2:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6"},'
'"feeRegions":[{"id":"origin_charges","title":"始发港费用",'
'"tmsSource":"polCostItems","anchor":"S!A19","stopAtText":["海运费用"],'
'"grow":"insert_before_stop","styleCopyRows":1}]}'
)
else:
content = (
'{"schemaVersion":"quote-template-mapping-v1",'
'"fields":{"quote_no":"S!B3","pickup_address":"S!H6","cargo_value":"S!B11"},'
'"feeRegions":['
'{"id":"origin_charges","title":"始发港费用","tmsSource":"polCostItems",'
'"anchor":"S!A19","stopAtText":["海运费用"],'
'"grow":"insert_before_stop","styleCopyRows":1},'
'{"id":"ocean_freight","title":"海运费用","tmsSource":"oceanCostItems",'
'"anchor":"S!A31","stopAtText":["目的港费用"],'
'"grow":"insert_before_stop","styleCopyRows":1},'
'{"id":"destination_charges","title":"目的港费用","tmsSource":"podCostItems",'
'"anchor":"S!A36","stopAtText":["备注"],'
'"grow":"insert_before_stop","styleCopyRows":1}'
']}'
)
return SimpleNamespace(ok=True, content=content, provider="stub", error="")
payload = {
"versionId": "v2",
"transportMode": "sea",
"fileName": "sea.xlsx",
"sheet": "S",
"cellInventory": [
"S!A18 | 始发港费用/Origin Charges | - | -",
"S!A30 | 海运费用/Ocean Freight | - | -",
"S!A35 | 目的港费用/Destination Charges | - | -",
"S!E6 | 提货地址/Place of Receipt | - | -",
"S!A11 | 货值/Cargo Value | - | -",
],
}
out = invoke_sheet_map(payload, chat_fn=fake_chat, allow_network=False, max_rounds=3)
self.assertTrue(out["ok"])
self.assertEqual(out.get("rounds"), 3)
self.assertEqual(len(calls), 3)
self.assertEqual(out["mapping"]["mappingGaps"], [])
self.assertEqual(len(out["mapping"]["feeRegions"]), 3)
if __name__ == "__main__":
unittest.main()
@@ -127,4 +127,15 @@ public class LoginUser {
/**设备id uniapp推送用*/
private String clientId;
/**
* 工号(询价账号页暂存企微 userid)
*/
@SensitiveField
private String workNo;
/**
* 只看本账号看板数据:登录态透传;看板聚合读取后收紧范围。
*/
private Boolean dashboardSelfOnly;
}
@@ -38,6 +38,9 @@ public class InquiryAgentController {
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryStaffRoleService staffRoleService;
@Autowired
private org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService responseTimeoutService;
@Autowired
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
@@ -67,8 +70,15 @@ public class InquiryAgentController {
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus"));
boolean mergeExisting = false;
Object mergeFlag = body.get("mergeExisting");
if (mergeFlag instanceof Boolean) {
mergeExisting = (Boolean) mergeFlag;
} else if (mergeFlag != null) {
mergeExisting = "true".equalsIgnoreCase(String.valueOf(mergeFlag));
}
try {
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus);
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus, mergeExisting);
return Result.OK("ok");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
@@ -112,6 +122,10 @@ public class InquiryAgentController {
Object to = body == null ? null : body.get("toStatus");
req.setWorkOrderNo(no == null ? "" : String.valueOf(no));
req.setToStatus(to == null ? "" : String.valueOf(to));
req.setRemark(firstStr(body, "remark"));
req.setLostReason(firstStr(body, "lostReason", "lost_reason"));
req.setNextFollowupAt(firstStr(body, "nextFollowupAt", "next_followup_at"));
req.setDealNotifySummary(firstStr(body, "dealNotifySummary", "deal_notify_summary"));
try {
org.jeecg.modules.inquiry.entity.InquiryTicket ticket =
workOrderService.transition(req, "agent");
@@ -125,6 +139,31 @@ public class InquiryAgentController {
}
}
@ApiOperation("智能体补未成交原因(不改六态)")
@PostMapping("/ticket/patchLostReason")
public Result<Map<String, Object>> patchLostReason(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
return Result.OK(workOrderService.patchLostReason(
firstStr(body, "workOrderNo", "work_order_no"),
firstStr(body, "lostReason", "lost_reason"),
"agent"));
}
@ApiOperation("认领到期协商再问(Worker HTTP 槽)")
@PostMapping("/ticket/claimDueNegotiate")
public Result<Map<String, Object>> claimDueNegotiate(HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("ok", true);
out.put("tickets", workOrderService.claimDueNegotiate());
return Result.OK(out);
}
@ApiOperation("智能体续办读单(只读,校验发起人)")
@PostMapping("/ticket/get")
public Result<Map<String, Object>> get(@RequestBody Map<String, Object> body,
@@ -234,6 +273,36 @@ public class InquiryAgentController {
return Result.OK(staffRoleService.findDisplayByName(firstStr(body, "name")));
}
@ApiOperation("按唯一超时编码取小时数(timeoutCode,如 sea_sales)")
@PostMapping("/responseTimeout/byCode")
public Result<Map<String, Object>> responseTimeoutByCode(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String code = firstStr(body, "timeoutCode", "timeout_code", "code");
if (StringUtils.isBlank(code)) {
return Result.error("timeoutCode 必填");
}
responseTimeoutService.ensureTimeoutCodes();
org.jeecg.modules.inquiry.entity.InquiryResponseTimeout row =
responseTimeoutService.getByTimeoutCode(code);
Map<String, Object> out = new java.util.LinkedHashMap<>();
if (row == null) {
out.put("found", false);
out.put("timeoutCode", code);
return Result.OK(out);
}
out.put("found", true);
out.put("timeoutCode", row.getTimeoutCode());
out.put("timeoutHours", row.getTimeoutHours());
out.put("transportMode", row.getTransportMode());
out.put("roleCode", row.getRoleCode());
out.put("routeCategory", row.getRouteCategory());
out.put("transportType", row.getTransportType());
return Result.OK(out);
}
@ApiOperation("记下海运协同群(不改六态)")
@PostMapping("/ticket/bindCollabGroup")
public Result<Map<String, Object>> bindCollabGroup(@RequestBody Map<String, Object> body,
@@ -185,8 +185,14 @@ public class InquiryQuoteTemplateController {
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/reparse")
public Result<String> reparse(@RequestParam String id) {
String msg = quoteTemplateService.reparseLatest(id);
public Result<String> reparse(@RequestParam(required = false) String id,
@RequestParam(required = false) String templateId) {
// 兼容旧前端只传 id、新前端传 templateId,任一即可。
String key = StringUtils.isNotBlank(templateId) ? templateId : id;
if (StringUtils.isBlank(key)) {
return Result.error("templateId 不能为空");
}
String msg = quoteTemplateService.reparseLatest(key);
return Result.OK(msg);
}
@@ -20,9 +20,10 @@ import java.util.List;
import java.util.Map;
/**
* 响应超时配置 API(系统设置页)。
* 响应超时配置 API(后台「超时响应」页)。
* <p>
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
* 每条有唯一 timeoutCode(如 sea_sales),智能体单码调用;roleCode 仅为角色维度可重复。
* 本接口供管理后台使用;工单超时判定尚未接入。
* </p>
*/
@@ -59,8 +60,9 @@ public class InquiryResponseTimeoutController
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
@GetMapping("/listAll")
public Result<List<InquiryResponseTimeout>> listAll() {
// 打开系统设置时顺带纠偏旧运输类型文案(幂等,已是新文案则无写)
// 打开超时响应页时顺带纠偏旧运输类型文案、回填唯一编码(幂等)
responseTimeoutService.ensureCanonicalTransportTypeLabels();
responseTimeoutService.ensureTimeoutCodes();
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
qw.orderByAsc("sort_no");
return Result.OK(responseTimeoutService.list(qw));
@@ -30,6 +30,7 @@ public final class TicketStatuses {
/**
* from -> 允许的 to 集合。
* 终态(已成交/未成交/已关闭)默认不可再流转,除非显式「关闭」从非终态进入已关闭。
* 协商中允许再进协商中:只顺延再问,不重开询价。
*/
private static final Map<String, Set<String>> ALLOWED;
@@ -37,7 +38,8 @@ public final class TicketStatuses {
Map<String, Set<String>> m = new HashMap<>();
m.put(INQUIRING, setOf(QUOTED, NEGOTIATING, CLOSED));
m.put(QUOTED, setOf(NEGOTIATING, DEAL, LOST, CLOSED));
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED));
// 协商中可再选协商中:顺延下一轮再问,次数不限。
m.put(NEGOTIATING, setOf(QUOTED, DEAL, LOST, CLOSED, NEGOTIATING));
m.put(DEAL, Collections.emptySet());
m.put(LOST, Collections.emptySet());
m.put(CLOSED, Collections.emptySet());
@@ -20,4 +20,13 @@ public class TicketTransitionRequest {
@ApiModelProperty("备注/关单原因")
private String remark;
@ApiModelProperty("未成交原因(仅未成交)")
private String lostReason;
@ApiModelProperty("协商中下次再问时间")
private String nextFollowupAt;
@ApiModelProperty("成交通知结果摘要")
private String dealNotifySummary;
}
@@ -18,6 +18,7 @@ import java.util.Date;
* 响应超时配置,对应 inq_response_timeout。
* <p>
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
* timeoutCode 每条唯一,供智能体单码查询超时小时;roleCode 仅为角色维度,可重复。
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
* </p>
*/
@@ -33,10 +34,13 @@ public class InquiryResponseTimeout implements Serializable {
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("唯一业务编码(智能体调用),如 sea_sales / land_hk_express")
private String timeoutCode;
@ApiModelProperty("运输方式 AIR/SEA/LAND")
private String transportMode;
@ApiModelProperty("角色 sales/product/air_route")
@ApiModelProperty("角色维度 sales/product/air_route(可重复,非调用主键)")
private String roleCode;
@ApiModelProperty("线路类别(仅陆运产品有值,其余空串)")
@@ -115,6 +115,28 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("关单原因")
private String closeReason;
/**
* 未成交原因:销售下一句任意文字;没回则空着。
* 不是 closeReason,后台要单独展示。
*/
@ApiModelProperty("未成交原因")
private String lostReason;
/**
* 协商中下次再问时刻(第四个工作日 09:00)。
* 中途改成交/未成交或再问发出后清空。
*/
@ApiModelProperty("协商中下次再问时间")
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date nextFollowupAt;
/**
* 成交通知结果:已推哪些负责人 / 无匹配负责人 / 通知未发出。
*/
@ApiModelProperty("成交通知结果")
private String dealNotifySummary;
@ApiModelProperty("业务版本")
private Integer version;
@@ -89,8 +89,11 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
* <p>
* 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。
*
* @param mergeExisting true=未换文件重新解析:旧字段优先保留,只补新映射;false=整份覆盖
*/
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus);
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
String parseStatus, boolean mergeExisting);
/**
* 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。
@@ -28,4 +28,15 @@ public interface IInquiryResponseTimeoutService extends IService<InquiryResponse
* </p>
*/
void ensureCanonicalTransportTypeLabels();
/**
* 回填每条规则的唯一 timeoutCode(已有则跳过)。
* 智能体只凭 timeoutCode 取超时小时。
*/
void ensureTimeoutCodes();
/**
* 按唯一业务编码查超时配置;无则 null。
*/
InquiryResponseTimeout getByTimeoutCode(String timeoutCode);
}
@@ -109,4 +109,15 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
* 空运群激活/切换当前工单。一单一群,未结案才能绑。不改六态。
*/
Map<String, Object> activateAirGroup(Map<String, Object> body);
/**
* 未成交后补原因。已成交/其它六态拒绝。不改六态。
*/
Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator);
/**
* 认领已到期的协商中再问:清空 nextFollowupAt,避免 Worker 重复发。
* 只改到期行,不加全局锁。
*/
java.util.List<java.util.Map<String, Object>> claimDueNegotiate();
}
@@ -2,6 +2,8 @@ package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.domain.TicketStatuses;
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
@@ -75,9 +77,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
List<InquiryTicket> tickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, from)
.le(InquiryTicket::getCreateTime, to));
.le(InquiryTicket::getCreateTime, to);
applyDashboardSelfOnly(qw);
List<InquiryTicket> tickets = ticketMapper.selectList(qw);
long total = tickets.size();
long deal = tickets.stream().filter(t -> TicketStatuses.DEAL.equals(t.getStatus())).count();
@@ -115,9 +119,11 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
List<InquiryTicket> weekTickets = ticketMapper.selectList(new LambdaQueryWrapper<InquiryTicket>()
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
.ge(InquiryTicket::getCreateTime, weekFrom)
.le(InquiryTicket::getCreateTime, to));
.le(InquiryTicket::getCreateTime, to);
applyDashboardSelfOnly(weekQw);
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
List<String> trendDates = new ArrayList<>(7);
List<Map<String, Object>> trend = new ArrayList<>(7);
@@ -442,4 +448,72 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
}
return "sales";
}
/**
* 账号勾选「只看自己数据」时收紧看板工单范围。
* <p>
* 口径对齐原项目:非超管且 dashboardSelfOnly=true →
* 工单 salesWecomId = 当前账号工号(企微 id)或 createBy = 登录名。
* 超管(username=admin 或角色 admin)忽略开关。
*/
private void applyDashboardSelfOnly(LambdaQueryWrapper<InquiryTicket> qw) {
LoginUser login = currentLoginUser();
if (login == null) {
return;
}
if (isAdministrator(login)) {
return;
}
if (!Boolean.TRUE.equals(login.getDashboardSelfOnly())) {
return;
}
String username = StringUtils.trimToEmpty(login.getUsername());
String wecom = StringUtils.trimToEmpty(login.getWorkNo());
qw.and(w -> {
boolean any = false;
if (StringUtils.isNotBlank(wecom)) {
w.eq(InquiryTicket::getSalesWecomId, wecom);
any = true;
}
if (StringUtils.isNotBlank(username)) {
if (any) {
w.or();
}
w.eq(InquiryTicket::getCreateBy, username);
any = true;
}
if (!any) {
// 未绑企微且无用户名:宁可不看数据,也不漏看全站
w.apply("1 = 0");
}
});
}
private static LoginUser currentLoginUser() {
try {
Object p = SecurityUtils.getSubject().getPrincipal();
if (p instanceof LoginUser) {
return (LoginUser) p;
}
} catch (Exception ignored) {
// 无登录上下文时不做过滤
}
return null;
}
/** 超管:登录名 admin,或 Shiro 角色 admin/管理员。 */
private static boolean isAdministrator(LoginUser login) {
if (login == null) {
return false;
}
if ("admin".equalsIgnoreCase(StringUtils.trimToEmpty(login.getUsername()))) {
return true;
}
try {
return SecurityUtils.getSubject().hasRole("admin")
|| SecurityUtils.getSubject().hasRole("管理员");
} catch (Exception e) {
return false;
}
}
}
@@ -132,7 +132,10 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
m.put("objectKey", ver.getObjectKey());
m.put("parseMessage", ver.getParseMessage());
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
m.put("unmappedLabels", extractUnmappedLabels(ver.getMappingJson()));
m.put("mappingGaps", extractMappingGaps(ver.getMappingJson()));
// 列表提示用:合并后的未解析条数(有则运营要点开看)
m.put("unparsedHintCount", countUnparsedHints(ver.getMappingJson()));
} else {
m.put("versionId", null);
m.put("fileName", "");
@@ -242,8 +245,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
versionMapper.insert(version);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见;换文件 = 整份覆盖
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes, false);
}
if (isDefault) {
@@ -266,8 +269,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 事务提交后提交 AI 解析;无事务时立刻提交。
*
* @param mergeExisting true=未换文件的重新解析,回写时与旧 mapping 补缺合并;false=换文件/新上传,整份覆盖
*/
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes) {
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes,
boolean mergeExisting) {
Runnable job = () -> {
try {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
@@ -276,7 +282,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
return;
}
String enqueueErr = submitAiParse(ver, template, fileBytes);
String enqueueErr = submitAiParse(ver, template, fileBytes, mergeExisting);
if (enqueueErr != null) {
ver.setParseStatus("failed");
ver.setParseMessage(enqueueErr);
@@ -310,8 +316,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。
*
* @param mergeExisting 重新解析时 true:带上旧 mapping,回写补缺合并
*/
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, byte[] fileBytes) {
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template,
byte[] fileBytes, boolean mergeExisting) {
try {
JSONObject inventory;
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
@@ -332,6 +341,19 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
body.put("cellCount", inventory.getIntValue("cellCount"));
body.put("yellowCount", inventory.getIntValue("yellowCount"));
body.put("truncated", inventory.getBooleanValue("truncated"));
body.put("mergeExisting", mergeExisting);
// 仅补缺合并时附带旧映射;换文件不传,避免模型/回写误用
if (mergeExisting && StringUtils.isNotBlank(version.getMappingJson())) {
try {
JSONObject old = JSON.parseObject(version.getMappingJson());
if (old != null && old.getJSONObject("fields") != null
&& !old.getJSONObject("fields").isEmpty()) {
body.put("existingMapping", old);
}
} catch (Exception e) {
log.warn("重新解析附带旧 mapping 失败 versionId={} err={}", version.getId(), e.getMessage());
}
}
body.put("requestId", version.getId() + "-" + System.currentTimeMillis());
return aiParseClient.enqueueParse(body);
} catch (Exception e) {
@@ -977,7 +999,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
ver.setParseMessage("解析中,请稍候");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes);
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes, true);
enqueueSnapshotPreview(ver.getId());
} catch (Exception e) {
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
@@ -990,10 +1012,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
/**
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
* <p>
* mergeExisting=true 时:与库中旧 mapping 补缺合并(旧字段/旧区域优先,新结果只补没有的)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) {
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage,
String parseStatus, boolean mergeExisting) {
if (StringUtils.isBlank(versionId)) {
throw new IllegalArgumentException("versionId 必填");
}
@@ -1029,6 +1054,20 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
versionMapper.updateById(ver);
return;
}
// 未换文件重新解析:旧映射优先,避免已对上的字段被新一轮 AI 冲掉
if (mergeExisting && StringUtils.isNotBlank(ver.getMappingJson())) {
try {
JSONObject old = JSON.parseObject(ver.getMappingJson());
if (old != null) {
mapping = mergeMappingPreferExisting(old, mapping);
log.info("模板映射补缺合并 versionId={} keepFields={}",
versionId,
old.getJSONObject("fields") == null ? 0 : old.getJSONObject("fields").size());
}
} catch (Exception e) {
log.warn("旧 mapping 合并跳过 versionId={} err={}", versionId, e.getMessage());
}
}
String validateErr = validateAiMapping(mapping);
if (validateErr != null) {
ver.setParseStatus("failed");
@@ -1046,12 +1085,103 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
ver.setParseMessage(parseMessage);
} else if (mergeExisting && !"failed".equals(ver.getParseStatus())) {
// 合并后重算摘要点数,避免沿用 AI 单轮字数
int pts = ver.getMappingPointCount() == null ? 0 : ver.getMappingPointCount();
String base = "已解析 " + pts + " 个映射点(保留原映射并补缺)";
if (StringUtils.isNotBlank(parseMessage) && parseMessage.contains("未解析到")) {
int idx = parseMessage.indexOf(";未解析到");
if (idx > 0) {
base = base + parseMessage.substring(idx);
}
}
ver.setParseMessage(base);
}
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
}
/**
* 补缺合并:旧 fields / feeRegions / routeRegions 优先;新结果只补充旧里没有的 code/id。
* 不删旧项;mappingGaps 以新结果为准(智能体已按合并后复评时更准,此处兜底保留新 gaps)。
*/
static JSONObject mergeMappingPreferExisting(JSONObject oldMapping, JSONObject newMapping) {
JSONObject out = new JSONObject(true);
if (newMapping != null) {
out.putAll(newMapping);
}
if (oldMapping == null) {
return out;
}
// fields:先放新的,再用旧的覆盖同名 → 旧优先
JSONObject fields = new JSONObject(true);
JSONObject newFields = newMapping == null ? null : newMapping.getJSONObject("fields");
JSONObject oldFields = oldMapping.getJSONObject("fields");
if (newFields != null) {
fields.putAll(newFields);
}
if (oldFields != null) {
for (String code : oldFields.keySet()) {
Object cell = oldFields.get(code);
if (cell != null && StringUtils.isNotBlank(String.valueOf(cell))) {
fields.put(code, cell);
}
}
}
out.put("fields", fields);
out.put("feeRegions", mergeRegionsById(
oldMapping.getJSONArray("feeRegions"),
newMapping == null ? null : newMapping.getJSONArray("feeRegions")));
out.put("routeRegions", mergeRegionsById(
oldMapping.getJSONArray("routeRegions"),
newMapping == null ? null : newMapping.getJSONArray("routeRegions")));
// 说明性标签:齐全时清空;否则保留新的
if (out.getJSONArray("mappingGaps") != null && out.getJSONArray("mappingGaps").isEmpty()) {
out.put("unmappedRequired", new JSONArray());
out.put("unmappedLabels", new JSONArray());
}
return out;
}
/**
* 区域按 id 合并:旧 id 整段保留;新 id 追加在后。
*/
private static JSONArray mergeRegionsById(JSONArray oldArr, JSONArray newArr) {
JSONArray out = new JSONArray();
Set<String> seen = new LinkedHashSet<>();
if (oldArr != null) {
for (int i = 0; i < oldArr.size(); i++) {
JSONObject r = oldArr.getJSONObject(i);
if (r == null) {
continue;
}
String id = StringUtils.defaultString(r.getString("id"));
if (StringUtils.isNotBlank(id)) {
seen.add(id);
}
out.add(r);
}
}
if (newArr != null) {
for (int i = 0; i < newArr.size(); i++) {
JSONObject r = newArr.getJSONObject(i);
if (r == null) {
continue;
}
String id = StringUtils.defaultString(r.getString("id"));
if (StringUtils.isNotBlank(id) && seen.contains(id)) {
continue;
}
if (StringUtils.isNotBlank(id)) {
seen.add(id);
}
out.add(r);
}
}
return out;
}
/**
* 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。
*/
@@ -1355,14 +1485,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
/**
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
* AI 自报「表上标签未落点」原文。
*/
private static List<String> extractMappingGaps(String mappingJson) {
private static List<String> extractUnmappedLabels(String mappingJson) {
if (StringUtils.isBlank(mappingJson)) {
return Collections.emptyList();
}
try {
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("mappingGaps");
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("unmappedLabels");
if (arr == null || arr.isEmpty()) {
return Collections.emptyList();
}
@@ -1379,6 +1509,66 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
/**
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
* <p>
* 口径:有 {@code mappingGaps} 字段时(含空数组)只认它——空数组即「系统判定齐全、角标 0」;
* 仅旧数据完全没有该字段时,才用 unmappedLabels / unmappedRequired 兜底。
* 说明性 unmappedLabels 不得在齐全时抬高列表「未解析」角标。
*/
private static List<String> extractMappingGaps(String mappingJson) {
if (StringUtils.isBlank(mappingJson)) {
return Collections.emptyList();
}
try {
JSONObject obj = JSON.parseObject(mappingJson);
if (obj == null) {
return Collections.emptyList();
}
List<String> out = new ArrayList<>();
// 显式有 mappingGaps 键:空也算齐全,禁止再用 AI 自报标签抬角标
if (obj.containsKey("mappingGaps")) {
JSONArray arr = obj.getJSONArray("mappingGaps");
if (arr != null) {
for (int i = 0; i < arr.size(); i++) {
String s = arr.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
}
return out;
}
// 旧版映射无 mappingGaps:才兜底
JSONArray labels = obj.getJSONArray("unmappedLabels");
if (labels != null) {
for (int i = 0; i < labels.size(); i++) {
String s = labels.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add("表标签未落点:" + s);
}
}
}
JSONArray required = obj.getJSONArray("unmappedRequired");
if (required != null) {
for (int i = 0; i < required.size(); i++) {
String s = required.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add("字段未映射:" + s);
}
}
}
return out;
} catch (Exception e) {
return Collections.emptyList();
}
}
/** 列表角标用的未解析条数(与 extractMappingGaps 口径一致)。 */
private static int countUnparsedHints(String mappingJson) {
return extractMappingGaps(mappingJson).size();
}
private static String mapParseStatusLabel(String status) {
if ("parsed".equals(status)) {
return "已解析";
@@ -1,5 +1,6 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
import org.jeecg.modules.inquiry.mapper.InquiryResponseTimeoutMapper;
@@ -9,13 +10,15 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏除外)。
* 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏与编码回填除外)。
* <p>
* 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。
* 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。
* timeoutCode 每条唯一,供智能体单码调用。
* </p>
*/
@Service
@@ -23,12 +26,20 @@ public class InquiryResponseTimeoutServiceImpl
extends ServiceImpl<InquiryResponseTimeoutMapper, InquiryResponseTimeout>
implements IInquiryResponseTimeoutService {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(InquiryResponseTimeoutServiceImpl.class);
/**
* 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。
* 急件文案不变,不在此映射。
*/
private static final Map<String, String> LAND_PRODUCT_TYPE_RENAMES = new LinkedHashMap<>();
/**
* 种子 id → 唯一业务编码。智能体只认 timeoutCode,不认 roleCode。
*/
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
static {
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中港、东南亚常用线路/常规车型",
@@ -45,10 +56,31 @@ public class InquiryResponseTimeoutServiceImpl
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,非常用线路/临时线路/特种车型",
"普件(非常用线路/临时线路/特种车型)");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000001", "air_sales");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000002", "air_route");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000003", "sea_sales");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000010", "land_hk_express");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000011", "land_ltl_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000012", "land_ltl_special");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000013", "land_ltl_express");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000014", "land_city_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000015", "land_city_special");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000016", "land_city_express");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000017", "land_sea_common");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000018", "land_sea_special");
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000019", "land_sea_express");
}
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
private volatile boolean labelsEnsured;
private volatile boolean codesEnsured;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -94,4 +126,51 @@ public class InquiryResponseTimeoutServiceImpl
labelsEnsured = true;
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void ensureTimeoutCodes() {
if (codesEnsured) {
return;
}
synchronized (this) {
if (codesEnsured) {
return;
}
try {
Date now = new Date();
List<InquiryResponseTimeout> all = list();
for (InquiryResponseTimeout row : all) {
if (row == null || StringUtils.isNotBlank(row.getTimeoutCode())) {
continue;
}
String code = ID_TO_TIMEOUT_CODE.get(row.getId());
if (StringUtils.isBlank(code)) {
// 非种子行:用 id 兜底,保证唯一
code = "rt_" + row.getId();
}
lambdaUpdate()
.eq(InquiryResponseTimeout::getId, row.getId())
.set(InquiryResponseTimeout::getTimeoutCode, code)
.set(InquiryResponseTimeout::getUpdateBy, "system")
.set(InquiryResponseTimeout::getUpdateTime, now)
.update();
}
codesEnsured = true;
} catch (Exception e) {
// 列未迁移时不挡列表;运维执行 migration_inq_response_timeout_code_v1.sql 后即可
log.warn("ensureTimeoutCodes 跳过(请确认已执行 timeout_code 迁移): {}", e.getMessage());
}
}
}
@Override
public InquiryResponseTimeout getByTimeoutCode(String timeoutCode) {
if (StringUtils.isBlank(timeoutCode)) {
return null;
}
return lambdaQuery()
.eq(InquiryResponseTimeout::getTimeoutCode, timeoutCode.trim())
.one();
}
}
@@ -174,6 +174,9 @@ public class InquiryStaffRoleServiceImpl extends ServiceImpl<InquiryStaffRoleMap
one.put("wecomId", row.getWecomId());
one.put("roleCode", row.getRoleCode());
one.put("status", row.getStatus());
// 成交跟进按「是负责人 + 负责线路」匹配,缺这两项智能体对不上人。
one.put("isLeader", row.getIsLeader());
one.put("routes", StringUtils.defaultString(row.getRoutes()));
out.add(one);
}
return out;
@@ -209,11 +209,27 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
if (TicketStatuses.CLOSED.equals(to) && StringUtils.isNotBlank(req.getRemark())) {
ticket.setCloseReason(req.getRemark());
}
if (TicketStatuses.LOST.equals(to)) {
ticket.setNextFollowupAt(null);
if (StringUtils.isNotBlank(req.getLostReason())) {
ticket.setLostReason(StringUtils.left(req.getLostReason().trim(), 500));
}
} else if (TicketStatuses.DEAL.equals(to)) {
ticket.setNextFollowupAt(null);
if (StringUtils.isNotBlank(req.getDealNotifySummary())) {
ticket.setDealNotifySummary(StringUtils.left(req.getDealNotifySummary().trim(), 500));
}
} else if (TicketStatuses.NEGOTIATING.equals(to)) {
Date follow = parseDateTime(req.getNextFollowupAt());
ticket.setNextFollowupAt(follow);
}
ticket.setVersion(ticket.getVersion() == null ? 2 : ticket.getVersion() + 1);
ticket.setUpdateBy(operator);
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, from, to, "transition", operator, req.getRemark());
String remark = StringUtils.defaultIfBlank(req.getRemark(),
StringUtils.defaultIfBlank(req.getDealNotifySummary(), req.getLostReason()));
appendLog(ticket, from, to, "transition", operator, remark);
return ticket;
}
@@ -569,9 +585,13 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
}
String status = StringUtils.defaultString(ticket.getStatus());
String waitPhase;
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.LOST.equals(status)
|| TicketStatuses.NEGOTIATING.equals(status) || TicketStatuses.CLOSED.equals(status)) {
if (TicketStatuses.DEAL.equals(status) || TicketStatuses.CLOSED.equals(status)) {
waitPhase = "done";
} else if (TicketStatuses.LOST.equals(status)) {
waitPhase = StringUtils.isBlank(ticket.getLostReason()) ? "wait_lost_reason" : "done";
} else if (TicketStatuses.NEGOTIATING.equals(status)) {
// 协商中会话不能结束:还要再问成交,也可中途改成交/未成交。
waitPhase = "wait_deal";
} else if (TicketStatuses.QUOTED.equals(status) && !quote.isEmpty()) {
// 文件已发出:成交跟进。六态仍是已报价,不能退回协同/出方案卡
if (quoteFileAlreadyIssued(no, quote)) {
@@ -592,6 +612,12 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("facts", facts);
out.put("quote", quote);
out.put("wait_phase", waitPhase);
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
out.put("deal_notify_summary", StringUtils.defaultString(ticket.getDealNotifySummary()));
if (ticket.getNextFollowupAt() != null) {
out.put("next_followup_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(ticket.getNextFollowupAt()));
}
out.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
Map<String, String> collabFacts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getCollabFactsJson())) {
@@ -773,6 +799,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
out.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
out.put("facts", parseFactsJson(ticket.getFactsJson()));
out.put("quote", latestQuotePayload(ticket.getWorkOrderNo()));
out.put("lost_reason", StringUtils.defaultString(ticket.getLostReason()));
out.put("lostReason", StringUtils.defaultString(ticket.getLostReason()));
return out;
}
@@ -1027,6 +1055,130 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
.last("LIMIT 1"), false);
}
/**
* 未成交后补原因。不改六态;已有原因可覆盖。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> patchLostReason(String workOrderNo, String lostReason, String operator) {
Map<String, Object> out = new LinkedHashMap<>();
String no = StringUtils.trimToEmpty(workOrderNo);
String reason = StringUtils.left(StringUtils.trimToEmpty(lostReason), 500);
out.put("ok", false);
if (StringUtils.isBlank(no) || StringUtils.isBlank(reason)) {
out.put("error", "workOrderNo_or_reason_blank");
return out;
}
InquiryTicket ticket = findByWorkOrderNo(no);
if (ticket == null) {
out.put("error", "ticket_not_found");
return out;
}
if (!TicketStatuses.LOST.equals(ticket.getStatus())) {
out.put("error", "not_lost");
out.put("status", ticket.getStatus());
return out;
}
ticket.setLostReason(reason);
ticket.setUpdateBy(StringUtils.defaultIfBlank(operator, "agent"));
ticket.setUpdateTime(new Date());
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "lost_reason", operator, reason);
out.put("ok", true);
out.put("work_order_no", no);
out.put("lost_reason", reason);
return out;
}
/**
* 认领到期协商再问:状态仍是协商中且 next_followup_at 已到。
* 清空再问时刻,Worker 只发这一轮;销售再选协商中才会排下一轮。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public List<Map<String, Object>> claimDueNegotiate() {
Date now = new Date();
List<InquiryTicket> due = list(new LambdaQueryWrapper<InquiryTicket>()
.eq(InquiryTicket::getStatus, TicketStatuses.NEGOTIATING)
.isNotNull(InquiryTicket::getNextFollowupAt)
.le(InquiryTicket::getNextFollowupAt, now)
.last("LIMIT 50"));
List<Map<String, Object>> out = new ArrayList<>();
if (due == null || due.isEmpty()) {
return out;
}
for (InquiryTicket ticket : due) {
ticket.setNextFollowupAt(null);
ticket.setUpdateBy("agent");
ticket.setUpdateTime(now);
updateById(ticket);
appendLog(ticket, ticket.getStatus(), ticket.getStatus(), "negotiate_followup", "agent", "协商再问已发出");
out.add(toDueNegotiateRow(ticket));
}
return out;
}
/**
* Worker 再问需要的最小快照:会话通道、字段、报价。
*/
private Map<String, Object> toDueNegotiateRow(InquiryTicket ticket) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("work_order_no", ticket.getWorkOrderNo());
row.put("workOrderNo", ticket.getWorkOrderNo());
row.put("sales_wecom_id", StringUtils.defaultString(ticket.getSalesWecomId()));
row.put("salesWecomId", StringUtils.defaultString(ticket.getSalesWecomId()));
row.put("sales_name", StringUtils.defaultString(ticket.getSalesName()));
row.put("business_line", StringUtils.defaultString(ticket.getBusinessLine()));
row.put("collab_chat_id", StringUtils.defaultString(ticket.getCollabChatId()));
row.put("status", ticket.getStatus());
Map<String, String> facts = new LinkedHashMap<>();
if (StringUtils.isNotBlank(ticket.getFactsJson())) {
JSONObject stored = JSON.parseObject(ticket.getFactsJson());
if (stored != null) {
for (String key : stored.keySet()) {
if (stored.get(key) != null) {
facts.put(key, String.valueOf(stored.get(key)));
}
}
}
}
if (StringUtils.isNotBlank(ticket.getOrigin())) {
facts.putIfAbsent("起运港", ticket.getOrigin());
}
if (StringUtils.isNotBlank(ticket.getDestination())) {
facts.putIfAbsent("目的港", ticket.getDestination());
}
row.put("facts", facts);
Map<String, Object> quote = new LinkedHashMap<>();
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
.eq(InquiryQuote::getWorkOrderNo, ticket.getWorkOrderNo())
.orderByDesc(InquiryQuote::getCreateTime)
.last("LIMIT 1"));
if (quotes != null && !quotes.isEmpty() && StringUtils.isNotBlank(quotes.get(0).getPayloadJson())) {
JSONObject parsed = JSON.parseObject(quotes.get(0).getPayloadJson());
if (parsed != null) {
quote.putAll(parsed);
}
}
row.put("quote", quote);
return row;
}
private static Date parseDateTime(String raw) {
if (StringUtils.isBlank(raw)) {
return null;
}
String text = raw.trim().replace("T", " ");
if (text.length() >= 19) {
text = text.substring(0, 19);
}
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text);
} catch (Exception e) {
return parseDate(raw);
}
}
private void appendLog(InquiryTicket ticket, String from, String to, String action, String operator, String remark) {
InquiryTicketStatusLog log = new InquiryTicketStatusLog();
log.setTicketId(ticket.getId());
@@ -0,0 +1,37 @@
-- 超时响应:每条规则增加唯一业务编码 timeout_code,供智能体单码调用。
-- 仅改 inquiry_robot;幂等可重复执行。
-- 库:inquiry_robot
SET NAMES utf8mb4;
-- 1) 加列(已存在则跳过:手工执行前请先 DESCRIBE 确认)
ALTER TABLE `inq_response_timeout`
ADD COLUMN `timeout_code` varchar(64) NULL COMMENT '唯一业务编码,智能体调用' AFTER `id`;
-- 2) 按主键回填(与种子 id 对齐)
UPDATE `inq_response_timeout` SET `timeout_code`='air_sales' WHERE `id`='rt100000000000000000000000000001' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='air_route' WHERE `id`='rt100000000000000000000000000002' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='sea_sales' WHERE `id`='rt100000000000000000000000000003' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_sales' WHERE `id`='rt100000000000000000000000000004' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='sea_product_ptp' WHERE `id`='rt100000000000000000000000000005' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='sea_product_other' WHERE `id`='rt100000000000000000000000000006' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt100000000000000000000000000007' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_common' WHERE `id`='rt100000000000000000000000000011' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_special' WHERE `id`='rt100000000000000000000000000012' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_ltl_express' WHERE `id`='rt100000000000000000000000000013' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_common' WHERE `id`='rt100000000000000000000000000014' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_special' WHERE `id`='rt100000000000000000000000000015' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_city_express' WHERE `id`='rt100000000000000000000000000016' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_common' WHERE `id`='rt100000000000000000000000000017' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_special' WHERE `id`='rt100000000000000000000000000018' AND (`timeout_code` IS NULL OR `timeout_code`='');
UPDATE `inq_response_timeout` SET `timeout_code`='land_sea_express' WHERE `id`='rt100000000000000000000000000019' AND (`timeout_code` IS NULL OR `timeout_code`='');
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
ALTER TABLE `inq_response_timeout`
MODIFY COLUMN `timeout_code` varchar(64) NOT NULL COMMENT '唯一业务编码,智能体调用';
ALTER TABLE `inq_response_timeout`
ADD UNIQUE KEY `uk_inq_resp_timeout_code` (`timeout_code`);
@@ -0,0 +1,14 @@
-- 成交跟进:未成交原因 / 协商再问时刻 / 成交通知摘要。
-- 仅改 inquiry_robot;幂等需手工确认列是否已存在。
-- 库:inquiry_robot
SET NAMES utf8mb4;
ALTER TABLE `inq_ticket`
ADD COLUMN `lost_reason` varchar(500) NULL COMMENT '未成交原因,不是关单原因' AFTER `close_reason`;
ALTER TABLE `inq_ticket`
ADD COLUMN `next_followup_at` datetime NULL COMMENT '协商中下次再问(工作日 09:00)' AFTER `lost_reason`;
ALTER TABLE `inq_ticket`
ADD COLUMN `deal_notify_summary` varchar(500) NULL COMMENT '成交通知:已推负责人/无匹配/未发出' AFTER `next_followup_at`;
@@ -0,0 +1,26 @@
-- 账号「只看本账号看板数据」+ 确保 admin 绑定超级管理员角色
-- 库:inquiry_robot;幂等。
-- 1) 列
SET @has_col := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'sys_user'
AND column_name = 'dashboard_self_only'
);
SET @sql := IF(@has_col = 0,
'ALTER TABLE sys_user ADD COLUMN dashboard_self_only tinyint(1) NOT NULL DEFAULT 0 COMMENT ''只看本账号看板数据:1=是,0=否;超管忽略''',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2) admin 绑定 role_code=admin(无则补一行)
INSERT INTO sys_user_role (id, user_id, role_id)
SELECT REPLACE(UUID(), '-', ''), u.id, r.id
FROM sys_user u
JOIN sys_role r ON r.role_code = 'admin'
WHERE u.username = 'admin'
AND NOT EXISTS (
SELECT 1 FROM sys_user_role ur
WHERE ur.user_id = u.id AND ur.role_id = r.id
)
LIMIT 1;
@@ -187,6 +187,9 @@ public class SysUserController {
baseCommonService.addLog("编辑用户,username: " +sysUser.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
if(sysUser==null) {
result.error500("未找到对应实体");
}else if("admin".equalsIgnoreCase(String.valueOf(sysUser.getUsername()))) {
// 询价后台:内置超级管理员不可被运营编辑(角色/资料/开关)
result.error500("超级管理员不可在后台编辑");
}else {
SysUser user = JSON.parseObject(jsonObject.toJSONString(), SysUser.class);
user.setUpdateTime(new Date());
@@ -204,4 +204,11 @@ public class SysUser implements Serializable {
* 流程状态
*/
private String bpmStatus;
/**
* 只看本账号看板数据:true/1 时分析看板仅含本人发起/归属工单;超级管理员忽略。
* 对应列 dashboard_self_only;原项目同名字段。
*/
@TableField("dashboard_self_only")
private Boolean dashboardSelfOnly;
}
+1 -1
View File
@@ -25,7 +25,7 @@ pnpm run dev
| 机器人回复语 | `inquiry/botReply/index` |
| 账号管理 | `inquiry/account/index` |
| 角色管理 | `inquiry/role/index` |
| 系统设置 | `inquiry/systemSetting/index` |
| 超时响应 | `inquiry/systemSetting/index` |
菜单由后端 `sys_permission` 种子下发(见 `deploy/sql/inquiry_admin_menu_seed_v1.sql`)。登录后刷新权限即可看到询价菜单。
@@ -49,7 +49,7 @@
{
title: '系统配置',
items: [
{ key: '/inquiry-sys/system-setting', label: '系统设置', icon: 'clock' },
{ key: '/inquiry-sys/system-setting', label: '超时响应', icon: 'clock' },
{ key: '/inquiry-sys/user', label: '账号管理', icon: 'account' },
{ key: '/inquiry-sys/role', label: '角色管理', icon: 'role' },
],
@@ -46,7 +46,7 @@ const INQUIRY_LEAVES: InquiryLeaf[] = [
path: '/inquiry-sys/system-setting',
name: 'inquiry-system-setting',
component: 'inquiry/systemSetting/index',
title: '系统设置',
title: '超时响应',
},
{ path: '/inquiry-sys/user', name: 'inquiry-sys-user', component: 'inquiry/account/index', title: '账号管理' },
{ path: '/inquiry-sys/role', name: 'inquiry-sys-role', component: 'inquiry/role/index', title: '角色管理' },
@@ -102,8 +102,9 @@ function ensureInquiryHandwrittenRoutes(routeList: AppRouteRecordRaw[]) {
const comp = String((existing as any).component || '');
if (!comp.includes(leaf.component) && !comp.includes(leaf.component.replace(/\/index$/, ''))) {
(existing as any).component = leaf.component;
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
}
// 侧栏/页题以手写清单为准(例:系统设置 → 超时响应)
existing.meta = { ...(existing.meta || {}), title: leaf.title, ignoreKeepAlive: true };
continue;
}
const node: AppRouteRecordRaw = {
@@ -31,6 +31,10 @@ export type AccountRow = {
phoneMasked: string;
lastLogin: string;
status: 'active' | 'disabled';
/** 只看本账号看板数据 */
dashboardSelfOnly: boolean;
/** 内置超管:列表禁编、展示固定为超级管理员 */
isSuperAdmin: boolean;
};
const ROLE_TAG_CYCLE = ['tag-violet', 'tag-blue', 'tag-teal', 'tag-amber'];
@@ -39,6 +43,16 @@ export function roleTagFor(index: number) {
return ROLE_TAG_CYCLE[index % ROLE_TAG_CYCLE.length];
}
/** username=admin 或角色码/名含超级管理员 → 内置超管 */
export function isSuperAdminUser(u: { username?: string; roleLabel?: string; roleCode?: string }) {
const name = String(u.username || '').trim().toLowerCase();
if (name === 'admin') return true;
const code = String(u.roleCode || '').trim().toLowerCase();
if (code === 'admin') return true;
const label = String(u.roleLabel || '');
return label.includes('超级管理员');
}
/** Jeecg status:1 正常 → active;其余视为禁用。 */
export function mapUserToAccount(
u: any,
@@ -46,16 +60,20 @@ export function mapUserToAccount(
roleLabel = '未绑定角色',
roleTag = 'tag-blue',
): AccountRow {
const username = u.username || '';
const superAdmin = isSuperAdminUser({ username, roleLabel });
return {
id: String(u.id || ''),
username: u.username || '',
username,
name: u.realname || u.username || '',
wecomUserId: u.workNo || u.wecomId || u.thirdId || '',
roleId,
roleLabel,
roleTag,
roleLabel: superAdmin ? '超级管理员' : roleLabel,
roleTag: superAdmin ? 'tag-violet' : roleTag,
phoneMasked: u.phone || '未填写',
lastLogin: u.lastLoginTime || u.createTime || '—',
status: Number(u.status) === 1 ? 'active' : 'disabled',
dashboardSelfOnly: Boolean(u.dashboardSelfOnly === true || u.dashboardSelfOnly === 1),
isSuperAdmin: superAdmin,
};
}
@@ -25,7 +25,7 @@ const loading = ref(false);
const total = ref(0);
const pageNo = ref(1);
const pageSize = ref(10);
const roleOptions = ref<{ value: string; label: string; tag: string }[]>([]);
const roleOptions = ref<{ value: string; label: string; tag: string; roleCode?: string }[]>([]);
const filters = reactive({ keyword: "", status: "" as "" | "active" | "disabled" });
const modalOpen = ref(false);
const editing = ref<AccountRow | null>(null);
@@ -37,6 +37,8 @@ const form = reactive({
phone: "",
status: "active" as AccountRow["status"],
password: "",
/** 只看本账号看板数据(对齐原项目 dashboardSelfOnly) */
dashboardSelfOnly: false,
});
const modalTitle = computed(() => (editing.value ? "编辑账号" : "新建账号"));
@@ -52,9 +54,11 @@ async function loadRoles() {
value: String(r.id),
label: r.roleName || r.roleCode || r.id,
tag: roleTagFor(i),
roleCode: String(r.roleCode || ""),
}));
if (!form.roleId && roleOptions.value.length) {
form.roleId = roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
form.roleId =
roleOptions.value.find((r) => r.label.includes("运营"))?.value || roleOptions.value[0].value;
}
}
@@ -97,6 +101,23 @@ async function loadList() {
const hit = roleOptions.value.find((r) => r.value === roleId);
roleLabel = hit?.label || roleLabel;
roleTag = hit?.tag || roleTag;
// 用角色码辅助识别超管
if (hit?.roleCode === "admin" || (hit?.label || "").includes("超级管理员")) {
roleLabel = hit?.label || "超级管理员";
}
} else if (String(u.username || "").toLowerCase() === "admin") {
// 库里未绑角色时仍展示超管,并尝试选中 admin 角色以便保存时能补绑
const adminRole = roleOptions.value.find(
(r) => r.roleCode === "admin" || r.label.includes("超级管理员"),
);
if (adminRole) {
roleId = adminRole.value;
roleLabel = adminRole.label || "超级管理员";
roleTag = adminRole.tag;
} else {
roleLabel = "超级管理员";
roleTag = "tag-violet";
}
}
} catch {
/* 无角色仍展示账号 */
@@ -142,11 +163,16 @@ function openCreate() {
phone: "",
status: "active",
password: "",
dashboardSelfOnly: false,
});
modalOpen.value = true;
}
function openEdit(row: AccountRow) {
if (row.isSuperAdmin) {
message.warning("超级管理员不可在后台编辑");
return;
}
editing.value = row;
Object.assign(form, {
username: row.username,
@@ -156,11 +182,16 @@ function openEdit(row: AccountRow) {
phone: row.phoneMasked === "未填写" ? "" : row.phoneMasked,
status: row.status,
password: "",
dashboardSelfOnly: Boolean(row.dashboardSelfOnly),
});
modalOpen.value = true;
}
async function save() {
if (editing.value?.isSuperAdmin) {
message.warning("超级管理员不可在后台编辑");
return;
}
if (!form.username.trim() || !form.name.trim()) {
message.warning("请填写账号与姓名");
return;
@@ -185,6 +216,7 @@ async function save() {
phone: form.phone.trim() || undefined,
status: form.status === "active" ? 1 : 2,
selectedroles: form.roleId,
dashboardSelfOnly: form.dashboardSelfOnly ? 1 : 0,
};
if (!editing.value) payload.password = form.password;
try {
@@ -321,12 +353,25 @@ function removeAccount(row: AccountRow) {
</td>
<td>
<div class="account-row-actions">
<button class="link-action" type="button" @click="openEdit(row)">编辑</button>
<button class="link-action link-muted" type="button" @click="resetPassword(row)">
<button
v-if="!row.isSuperAdmin"
class="link-action"
type="button"
@click="openEdit(row)"
>
编辑
</button>
<span v-else class="account-locked" title="超级管理员不可在后台编辑">不可编辑</span>
<button
v-if="!row.isSuperAdmin"
class="link-action link-muted"
type="button"
@click="resetPassword(row)"
>
重置密码
</button>
<button
v-if="row.username !== 'admin'"
v-if="!row.isSuperAdmin"
class="link-action link-danger"
type="button"
@click="removeAccount(row)"
@@ -400,6 +445,14 @@ function removeAccount(row: AccountRow) {
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</div>
<div class="form-row">
<label class="lbl">数据范围</label>
<label class="account-self-only">
<input v-model="form.dashboardSelfOnly" type="checkbox" />
<span>只看自己数据</span>
<em>勾选后,分析看板仅统计本账号企微归属的工单</em>
</label>
</div>
<div class="form-row">
<label class="lbl">状态</label>
<div class="status-toggle">
@@ -466,4 +519,26 @@ function removeAccount(row: AccountRow) {
align-items: center;
gap: 10px;
}
.account-locked {
color: #94a3b8;
font-size: 12px;
}
.account-self-only {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 13px;
color: #0f172a;
cursor: pointer;
}
.account-self-only em {
flex: 1 1 100%;
font-style: normal;
font-size: 12px;
color: #64748b;
}
</style>
@@ -28,8 +28,11 @@ export type TemplateRow = {
currentVersion?: number;
parseMessage?: string;
unmappedRequired?: string[];
unmappedLabels?: string[];
/** 表上有、对照清单未映射上的中文说明(智能体 mappingGaps) */
mappingGaps?: string[];
/** 列表提示:未解析条数(主账合并口径) */
unparsedHintCount?: number;
objectKey?: string;
};
@@ -61,7 +64,9 @@ export function mapApiRow(raw: any): TemplateRow {
currentVersion: raw.currentVersion,
parseMessage: raw.parseMessage,
unmappedRequired: Array.isArray(raw.unmappedRequired) ? raw.unmappedRequired.map(String) : [],
unmappedLabels: Array.isArray(raw.unmappedLabels) ? raw.unmappedLabels.map(String) : [],
mappingGaps: Array.isArray(raw.mappingGaps) ? raw.mappingGaps.map(String) : [],
unparsedHintCount: Number(raw.unparsedHintCount || 0),
objectKey: raw.objectKey,
};
}
@@ -1,7 +1,9 @@
<script setup>
/**
* 系统设置:响应超时小时配置。
* 海运 / 空运 / 陆运同页分三块展示,只允许改超时小时;无增删、无 Tab。
* 超时响应:响应超时小时配置。
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
* 不展示线路类别(维度仍在主账,仅后台列表隐藏)。
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
*/
@@ -50,6 +52,12 @@ function displayOrDash(v) {
return s || '—';
}
/** 唯一业务编码;缺省时提示需迁移 */
function timeoutCodeOf(row) {
const c = String(row.timeoutCode || '').trim();
return c || '—';
}
async function loadList() {
loading.value = true;
try {
@@ -105,7 +113,7 @@ async function commitHours(row) {
<template>
<div class="sys-setting-page">
<div class="toolbar-bar">
<span class="hint">固定规则,仅可改超时小时;失焦或回车保存</span>
<span class="hint">固定规则,仅可改超时小时;编码唯一只读,供智能体调用;失焦或回车保存</span>
</div>
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
@@ -120,16 +128,22 @@ async function commitHours(row) {
<thead>
<tr>
<th style="width: 88px">角色</th>
<th>运输类型</th>
<th style="width: 160px" title="每条唯一;智能体用此码取超时小时">编码</th>
<th>运输分类</th>
<th style="width: 140px">超时设置(小时)</th>
</tr>
</thead>
<tbody>
<tr v-if="!block.rows.length">
<td colspan="3" style="color: #94a3b8">暂无配置</td>
<td colspan="4" style="color: #94a3b8">暂无配置</td>
</tr>
<tr v-for="row in block.rows" :key="row.id">
<td>{{ roleLabel(row.roleCode) }}</td>
<td>
<code class="role-code" :title="'智能体调用编码:' + timeoutCodeOf(row)">{{
timeoutCodeOf(row)
}}</code>
</td>
<td>{{ displayOrDash(row.transportType) }}</td>
<td>
<div class="hours-cell">
@@ -202,4 +216,16 @@ async function commitHours(row) {
color: #667085;
font-weight: 600;
}
.role-code {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 600;
color: #344054;
background: #f2f4f7;
border: 1px solid #e4e7ec;
}
</style>
@@ -1,6 +1,7 @@
/**
* 系统设置 · 响应超时配置 API。
* 超时响应配置 API。
* 对接主账 /inquiry/responseTimeout;仅 listAll + 改小时数。
* timeoutCode 每条唯一(如 sea_sales),智能体 POST /inquiry/agent/responseTimeout/byCode 单码查询。
*/
import { defHttp } from '/@/utils/http/axios';
@@ -11,6 +12,8 @@ enum Api {
export type ResponseTimeoutRow = {
id: string;
/** 唯一业务编码,智能体调用主键 */
timeoutCode?: string;
transportMode: string;
roleCode: string;
routeCategory?: string;
@@ -3,7 +3,8 @@
* 报价模板页:对接主账 /inquiry/quoteTemplate。
* - 上传按钮只在顶栏一处;弹窗用原生 overlay
* - 版式由上传 Excel 决定;列表展示「映射点数」而非假占位符
* - 预览:原表截图(LibreOffice 转图),不是 AI 画格子
* - 预览:原表截图(LibreOffice 转图)
* - 解析状态列可点:弹窗看已解析 / 未解析清单
*/
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
@@ -17,9 +18,15 @@ import {
enableTemplate,
fetchTemplateSnapshotBlob,
getTemplateList,
getTemplateVersion,
reparseTemplate,
uploadTemplate,
} from "/@/views/inquiry/template/template.api";
import {
FIELD_LABELS,
summarizeMappingParse,
type MappingParseSummary,
} from "/@/views/inquiry/template/previewSample";
// buildTemplateFileUrl 已由 downloadTemplateFile 替代(鉴权 blob 下载)
@@ -45,7 +52,17 @@ const formOpen = ref(false);
const editing = ref<TemplateRow | null>(null);
const previewOpen = ref(false);
const previewRow = ref<TemplateRow | null>(null);
/** 原表截图预览状态 */
/** 空映射摘要 */
const emptyMappingSummary = (): MappingParseSummary => ({
fields: [],
regions: [],
gaps: [],
unmappedRequired: [],
unmappedLabels: [],
pointCount: 0,
});
/** 原表截图预览状态(不含映射清单) */
const previewState = reactive({
loading: false,
error: "",
@@ -54,6 +71,29 @@ const previewState = reactive({
mappedCount: 0,
fileName: "",
});
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
const parseDetailOpen = ref(false);
const parseDetailRow = ref<TemplateRow | null>(null);
const parseDetailState = reactive({
loading: false,
mappedCount: 0,
mapping: emptyMappingSummary() as MappingParseSummary,
});
/** 未解析清单:只认系统 mappingGaps;齐全(空数组)时不展示 AI 自报标签 */
const parseDetailUnparsed = computed(() => {
const m = parseDetailState.mapping;
if (Array.isArray(m.gaps)) {
return (m.gaps || []).filter(Boolean).map(String);
}
const list: string[] = [];
for (const c of m.unmappedRequired || []) {
list.push(`字段未映射:${FIELD_LABELS[c] || c}`);
}
return list;
});
const fileInputRef = ref<HTMLInputElement | null>(null);
const pendingFile = ref<File | null>(null);
@@ -98,7 +138,7 @@ function revokePreviewUrl() {
}
/**
* 打开预览:读取上传后已缓存的截图(未就绪则现转)。
* 打开预览:仅原表截图。
* 副作用:HTTP 读 previewSnapshot;本地 blob URL;不写库。
*/
async function openPreview(row: TemplateRow) {
@@ -132,6 +172,57 @@ async function openPreview(row: TemplateRow) {
}
}
/**
* 列表点「解析状态」:弹窗展示已解析 / 未解析清单。
* 副作用:HTTP 读 version.mappingJson;不写库。
*/
async function openParseDetail(row: TemplateRow) {
if (row.parseStatus === "解析中" || row.parseStatus === "未上传") {
message.info(row.parseStatus === "解析中" ? "解析中,请稍候再看" : "请先上传模板文件");
return;
}
if (!row.versionId) {
message.warning("无可用版本");
return;
}
parseDetailRow.value = row;
parseDetailOpen.value = true;
parseDetailState.loading = true;
parseDetailState.mappedCount = row.mappingPointCount || 0;
parseDetailState.mapping = emptyMappingSummary();
try {
const ver: any = await getTemplateVersion(row.versionId);
const mappingJson = ver?.mappingJson ?? ver?.result?.mappingJson ?? null;
let summary = summarizeMappingParse(mappingJson);
if (!summary.gaps.length && row.mappingGaps?.length) {
summary = { ...summary, gaps: [...row.mappingGaps] };
}
if (!summary.unmappedRequired.length && row.unmappedRequired?.length) {
summary = { ...summary, unmappedRequired: [...row.unmappedRequired] };
}
parseDetailState.mapping = summary;
if (summary.pointCount > 0) {
parseDetailState.mappedCount = summary.pointCount;
}
} catch {
parseDetailState.mapping = {
...emptyMappingSummary(),
gaps: listMappingGaps(row),
unmappedRequired: row.unmappedRequired || [],
pointCount: row.mappingPointCount || 0,
};
} finally {
parseDetailState.loading = false;
}
}
function closeParseDetail() {
parseDetailOpen.value = false;
parseDetailRow.value = null;
parseDetailState.loading = false;
parseDetailState.mapping = emptyMappingSummary();
}
function closeForm() {
formOpen.value = false;
}
@@ -281,13 +372,23 @@ function openEdit(row: TemplateRow) {
formOpen.value = true;
}
/** 运营可见的未解析项:优先 mappingGaps,其次 AI 自报 unmappedRequired */
/** 运营可见的未解析项:只认系统对照清单 mappingGaps;齐全时角标为 0 */
function listMappingGaps(row: TemplateRow): string[] {
if (row.mappingGaps?.length) return [...row.mappingGaps];
if (row.unmappedRequired?.length) {
return row.unmappedRequired.map((c) => `字段未映射:${c}`);
// 主账已带 mappingGaps(含空数组)时,不再用 AI 自报 unmapped* 抬角标
if (Array.isArray(row.mappingGaps)) {
return row.mappingGaps.filter(Boolean).map(String);
}
return [];
const list: string[] = [];
for (const c of row.unmappedRequired || []) {
if (c) list.push(`字段未映射:${c}`);
}
return list;
}
/** 列表角标条数:优先主账 unparsedHintCount(与 mappingGaps 同口径) */
function unparsedCount(row: TemplateRow): number {
if (typeof row.unparsedHintCount === "number") return Math.max(0, row.unparsedHintCount);
return listMappingGaps(row).length;
}
function normalizeKeywordItems(items: string[]): string[] {
@@ -333,6 +434,7 @@ async function toggleStatus(row: TemplateRow) {
/**
* 重新提交 AI 映射解析(已解析也可点;解析中禁用)。
* 未换文件:主账带 mergeExisting,回写时保留已有字段只补缺。
* 副作用:主账入队;列表进入解析中并轮询。
*/
async function reparseRow(row: TemplateRow) {
@@ -543,24 +645,29 @@ async function downloadPreviewOrOriginal() {
{{ formatKeywords(row.keywords) || "—" }}
</td>
<td
class="qt-col-muted"
class="qt-col-muted qt-parse-cell-click"
:class="{
'qt-parse-fail': row.parseStatus === '失败' || row.parseStatus === '映射未完成',
'qt-parse-gaps': listMappingGaps(row).length > 0 && row.parseStatus === '已解析',
'qt-parse-gaps': unparsedCount(row) > 0 && row.parseStatus === '已解析',
}"
:title="row.parseMessage || row.parseStatus"
:title="
unparsedCount(row) > 0
? `有 ${unparsedCount(row)} 项未解析,点击查看`
: '点击查看已解析 / 未解析内容'
"
@click.stop="openParseDetail(row)"
>
<span>{{ row.parseStatus }}</span>
<span v-if="listMappingGaps(row).length" class="qt-gap-badge">
未解析 {{ listMappingGaps(row).length }}
</span>
<div class="qt-parse-status-row">
<span class="qt-parse-status-link">{{ row.parseStatus }}</span>
<span v-if="unparsedCount(row) > 0" class="qt-gap-badge">未解析 {{ unparsedCount(row) }}</span>
</div>
<div v-if="unparsedCount(row) > 0" class="qt-parse-gap-hint">有未解析内容,点击查看</div>
<div
v-if="
v-else-if="
row.parseMessage &&
(row.parseStatus === '失败' ||
row.parseStatus === '映射未完成' ||
row.parseStatus === '解析中' ||
listMappingGaps(row).length > 0)
row.parseStatus === '解析中')
"
class="qt-parse-msg"
>
@@ -581,7 +688,11 @@ async function downloadPreviewOrOriginal() {
type="button"
class="link-action"
:disabled="row.parseStatus === '解析中'"
:title="row.parseStatus === '解析中' ? '解析中,请稍候' : '重新提交 AI 映射解析'"
:title="
row.parseStatus === '解析中'
? '解析中,请稍候'
: '重新解析:保留已有映射,只补缺;换文件请用编辑上传'
"
@click.stop="reparseRow(row)"
>
重新解析
@@ -830,6 +941,86 @@ async function downloadPreviewOrOriginal() {
</div>
</Teleport>
<!-- 解析状态弹窗:已解析 / 未解析清单 -->
<Teleport to="body">
<div
v-if="parseDetailOpen && parseDetailRow"
class="modal-overlay"
role="dialog"
aria-modal="true"
@click.self="closeParseDetail"
>
<div class="modal-dialog qt-parse-detail-dialog" @click.stop>
<div class="qt-modal-head">
<div class="qt-modal-title-row">
<div class="qt-modal-icon qt-modal-icon--green">
<ProtoIcon name="fileSpreadsheet" :size="16" />
</div>
<div>
<div class="qt-modal-sub">解析结果</div>
<div class="qt-modal-title">{{ parseDetailRow.name }}</div>
</div>
</div>
<button type="button" class="btn btn-ghost" aria-label="关闭" @click="closeParseDetail">
<ProtoIcon name="x" :size="18" />
</button>
</div>
<div class="qt-preview-modal-body">
<div class="qt-preview-tags">
<span class="tag" :class="bizTag(parseDetailRow.bizType)">{{ parseDetailRow.bizType }}</span>
<span class="tag tag-green">已解析 {{ parseDetailState.mappedCount }} 项</span>
<span v-if="parseDetailUnparsed.length" class="tag tag-amber"
>未解析 {{ parseDetailUnparsed.length }} 项</span
>
</div>
<div class="qt-parse-panels">
<div class="qt-parse-panel qt-parse-panel--ok">
<div class="qt-parse-panel-title">已解析内容</div>
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
<template
v-else-if="parseDetailState.mapping.fields.length || parseDetailState.mapping.regions.length"
>
<ul class="qt-parse-panel-list">
<li v-for="f in parseDetailState.mapping.fields" :key="'f-' + f.code">
<span class="qt-parse-name">{{ f.label }}</span>
<code class="qt-parse-code">{{ f.code }}</code>
<span class="qt-parse-cell">{{ f.cell }}</span>
</li>
<li v-for="r in parseDetailState.mapping.regions" :key="'r-' + r.kind + r.id">
<span class="qt-parse-name"
>{{ r.kind === "fee" ? "费用区" : "线路区" }} · {{ r.title }}</span
>
<code v-if="r.id" class="qt-parse-code">{{ r.id }}</code>
</li>
</ul>
</template>
<div v-else class="qt-parse-panel-empty">暂无已映射字段(解析中或未完成)</div>
</div>
<div
class="qt-parse-panel"
:class="parseDetailUnparsed.length ? 'qt-parse-panel--gap' : 'qt-parse-panel--ok'"
>
<div class="qt-parse-panel-title">未解析内容</div>
<div v-if="parseDetailState.loading" class="qt-parse-panel-empty">加载映射中…</div>
<ul v-else-if="parseDetailUnparsed.length" class="qt-parse-panel-list">
<li v-for="(g, i) in parseDetailUnparsed" :key="'g-' + i">{{ g }}</li>
</ul>
<div v-else class="qt-parse-panel-empty">
暂无未解析项。若刚升级功能,可点「重新解析」刷新缺口清单。
</div>
</div>
</div>
</div>
<div class="qt-preview-modal-footer">
<button type="button" class="btn btn-secondary" @click="closeParseDetail">关闭</button>
</div>
</div>
</div>
</Teleport>
<!-- 截图放大灯箱:1:1 比例,仅关灯箱不关预览弹窗 -->
<Teleport to="body">
<div
@@ -855,6 +1046,95 @@ async function downloadPreviewOrOriginal() {
max-width: min(96vw, 980px) !important;
width: min(96vw, 980px);
}
.qt-parse-detail-dialog {
max-width: min(96vw, 720px) !important;
width: min(96vw, 720px);
}
.qt-parse-cell-click {
cursor: pointer;
}
.qt-parse-cell-click:hover .qt-parse-status-link {
color: #155eef;
text-decoration: underline;
}
.qt-parse-status-link {
font-weight: 700;
}
.qt-parse-panels {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 0;
}
@media (max-width: 800px) {
.qt-parse-panels {
grid-template-columns: 1fr;
}
}
.qt-parse-panel {
border-radius: 8px;
border: 1px solid #e4e7ec;
background: #f9fafb;
padding: 10px 12px;
min-height: 88px;
max-height: min(50vh, 360px);
overflow: auto;
}
.qt-parse-panel--ok {
border-color: #abefc6;
background: #f0fdf4;
}
.qt-parse-panel--gap {
border-color: #fec84b;
background: #fffaeb;
}
.qt-parse-panel-title {
font-size: 12px;
font-weight: 800;
color: #344054;
margin-bottom: 6px;
}
.qt-parse-panel--gap .qt-parse-panel-title {
color: #b54708;
}
.qt-parse-panel-empty {
font-size: 11px;
color: #98a2b3;
line-height: 1.4;
}
.qt-parse-panel-list {
margin: 0;
padding-left: 16px;
font-size: 11px;
color: #344054;
line-height: 1.45;
}
.qt-parse-panel-list li {
margin-bottom: 4px;
}
.qt-parse-name {
font-weight: 700;
margin-right: 6px;
}
.qt-parse-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
color: #475467;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 3px;
padding: 0 4px;
margin-right: 6px;
}
.qt-parse-cell {
color: #667085;
font-size: 10px;
}
.tag-amber {
background: #fffaeb;
color: #b54708;
border: 1px solid #fec84b;
}
.qt-col-name {
font-weight: 800;
color: #0f172a;
@@ -875,9 +1155,21 @@ async function downloadPreviewOrOriginal() {
.qt-parse-gaps {
color: #b45309;
}
.qt-parse-status-row {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.qt-parse-gap-hint {
margin-top: 3px;
font-size: 10px;
font-weight: 700;
color: #b54708;
line-height: 1.3;
}
.qt-gap-badge {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: 999px;
font-size: 10px;
@@ -1,6 +1,6 @@
/**
* 报价模板预览:Excel 样式网格(上传原文),辅以映射摘要。
* 主数据来自 /previewSheet;mappingJson 仅用于未映射必填提示。
* 主数据来自 /previewSheet;mappingJson 用于已映射/未映射清单。
*/
/** 系统字段 code → 中文标签 */
@@ -27,8 +27,131 @@ export const FIELD_LABELS: Record<string, string> = {
weight_kg: "重量",
volume_cbm: "体积",
total_amount: "报价合计",
chargeable_weight_kg: "计费重",
delivery_address: "送货地址",
pickup_address: "提货地址",
transit_time: "运输时效",
cargo_value: "货值",
hs_code: "HS编码",
oil_elec_mag: "油电磁",
has_battery: "是否带电",
on_board_date: "开船日",
special_remarks: "特殊备注",
eta_request: "要求到港",
sailing_schedule: "船期",
shipping_line: "船公司",
flight_no: "航班号",
transfer_airport: "中转机场",
effective_date: "生效日期",
fuel_surcharge_rate: "燃油附加费率",
tax_rate: "税率",
has_oil: "是否含油",
has_magnet: "是否含磁",
vehicle_qty: "车型/数量",
};
/** 解析结果摘要:供预览弹窗展示「已解析 / 未解析」 */
export type MappingFieldItem = {
code: string;
label: string;
cell: string;
};
export type MappingRegionItem = {
kind: "fee" | "route";
id: string;
title: string;
};
export type MappingParseSummary = {
fields: MappingFieldItem[];
regions: MappingRegionItem[];
gaps: string[];
unmappedRequired: string[];
unmappedLabels: string[];
pointCount: number;
};
function asMappingObject(mappingJson: unknown): Record<string, any> | null {
if (!mappingJson) return null;
if (typeof mappingJson === "object") return mappingJson as Record<string, any>;
if (typeof mappingJson === "string") {
try {
const obj = JSON.parse(mappingJson);
return obj && typeof obj === "object" ? obj : null;
} catch {
return null;
}
}
return null;
}
/**
* 从 mappingJson 拆出运营可读的已映射 / 未映射清单。
* 不打接口;mappingGaps 为空时(旧版解析)仍可看 fields 与 unmapped*。
*/
export function summarizeMappingParse(mappingJson: unknown): MappingParseSummary {
const obj = asMappingObject(mappingJson);
const empty: MappingParseSummary = {
fields: [],
regions: [],
gaps: [],
unmappedRequired: [],
unmappedLabels: [],
pointCount: 0,
};
if (!obj) return empty;
const fieldsObj = obj.fields && typeof obj.fields === "object" ? obj.fields : {};
const fields: MappingFieldItem[] = Object.keys(fieldsObj)
.filter((k) => fieldsObj[k])
.map((code) => ({
code,
label: FIELD_LABELS[code] || code,
cell: String(fieldsObj[code] || ""),
}))
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
const regions: MappingRegionItem[] = [];
const feeRegions = Array.isArray(obj.feeRegions) ? obj.feeRegions : [];
for (const r of feeRegions) {
if (!r || typeof r !== "object") continue;
regions.push({
kind: "fee",
id: String(r.id || ""),
title: String(r.title || r.id || "费用区"),
});
}
const routeRegions = Array.isArray(obj.routeRegions) ? obj.routeRegions : [];
for (const r of routeRegions) {
if (!r || typeof r !== "object") continue;
regions.push({
kind: "route",
id: String(r.id || ""),
title: String(r.title || r.id || "线路区"),
});
}
const gaps = Array.isArray(obj.mappingGaps)
? obj.mappingGaps.map((x: any) => String(x)).filter(Boolean)
: [];
const unmappedRequired = Array.isArray(obj.unmappedRequired)
? obj.unmappedRequired.map((x: any) => String(x)).filter(Boolean)
: [];
const unmappedLabels = Array.isArray(obj.unmappedLabels)
? obj.unmappedLabels.map((x: any) => String(x)).filter(Boolean)
: [];
return {
fields,
regions,
gaps,
unmappedRequired,
unmappedLabels,
pointCount: fields.length + regions.length,
};
}
export type SheetPreviewCell = {
addr: string;
text: string;
@@ -91,11 +91,18 @@ export const listTemplateVersions = (templateId: string) =>
export const saveTemplateMapping = (versionId: string, mappingJson: string) =>
defHttp.put({ url: Api.mapping, data: { versionId, mappingJson } });
/** 非 GET 时 params 会进 JSON 体,@RequestParam 读不到,必须拼到查询串。 */
export const enableTemplate = (templateId: string, versionId: string) =>
defHttp.post({ url: Api.enable, params: { templateId, versionId } });
defHttp.post(
{ url: Api.enable, params: { templateId, versionId } },
{ joinParamsToUrl: true },
);
export const disableTemplate = (templateId: string) =>
defHttp.post({ url: Api.disable, params: { templateId } });
defHttp.post(
{ url: Api.disable, params: { templateId } },
{ joinParamsToUrl: true },
);
/**
* 删除模板逻辑头及全部版本;不可恢复。
@@ -109,7 +116,10 @@ export const deleteTemplate = (templateId: string) =>
);
export const setTemplateDefault = (templateId: string, isDefault = true) =>
defHttp.post({ url: Api.setDefault, params: { templateId, isDefault } });
defHttp.post(
{ url: Api.setDefault, params: { templateId, isDefault } },
{ joinParamsToUrl: true },
);
/** 只改名称/业务/关键词/默认标记,不换文件;走 JSON 体对接主账 @RequestBody */
export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data });
@@ -119,7 +129,7 @@ export const editTemplateMeta = (data) => defHttp.put({ url: Api.editMeta, data
*/
export const reparseTemplate = (templateId: string) =>
defHttp.post(
{ url: Api.reparse, params: { id: templateId }, timeout: 60 * 1000 },
{ url: Api.reparse, params: { id: templateId, templateId }, timeout: 60 * 1000 },
{ joinParamsToUrl: true },
);
@@ -48,6 +48,9 @@ type TicketRow = {
volumeCbm: string;
goodsName: string;
updateTime: string;
lostReason: string;
nextFollowupAt: string;
dealNotifySummary: string;
};
const rows = ref<TicketRow[]>([]);
@@ -265,6 +268,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
goodsName: dash(t.goodsName),
updateTime: fmtTime(t.updateTime || t.createTime),
lostReason: dash(t.lostReason || t.lost_reason),
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
dealNotifySummary: dash(t.dealNotifySummary || t.deal_notify_summary),
};
}
@@ -630,6 +636,18 @@ function refresh() {
<div class="ticket-detail-item"><label>事件异常</label><span>{{ current.eventException }}</span></div>
<div class="ticket-detail-item"><label>系统异常</label><span>{{ current.systemException }}</span></div>
<div class="ticket-detail-item"><label>系统异常原因</label><span>{{ current.systemExceptionReason }}</span></div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交时间</label><span>{{ current.updateTime }}</span>
</div>
<div v-if="current.status === '已成交'" class="ticket-detail-item">
<label>成交通知</label><span>{{ current.dealNotifySummary }}</span>
</div>
<div v-if="current.status === '未成交'" class="ticket-detail-item">
<label>未成交原因</label><span>{{ current.lostReason }}</span>
</div>
<div v-if="current.status === '协商中'" class="ticket-detail-item">
<label>下次再问</label><span>{{ current.nextFollowupAt }}</span>
</div>
</div>
</section>