Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419b4a1bf7 | ||
|
|
177dd0b25f | ||
|
|
58df358577 |
@@ -118,7 +118,7 @@ async def _accept_upload_files(
|
||||
return HTMLResponse(expired_upload_page(), status_code=410)
|
||||
if phase in {"reject_type", "need_file"}:
|
||||
return HTMLResponse(render_upload_message("无法上传", hint), status_code=400)
|
||||
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
|
||||
return HTMLResponse(render_upload_message("上传成功", copy.attach_h5_ok()))
|
||||
|
||||
|
||||
@router.get("/upload/{token}", response_class=HTMLResponse)
|
||||
|
||||
@@ -319,4 +319,4 @@ def render_upload_message(title: str, body: str, *, expired: bool = False) -> st
|
||||
|
||||
|
||||
def expired_upload_page() -> str:
|
||||
return render_upload_message("链接已过期", copy.ATTACH_H5_EXPIRED, expired=True)
|
||||
return render_upload_message("链接已过期", copy.attach_h5_expired(), expired=True)
|
||||
|
||||
@@ -207,7 +207,7 @@ def join_attachment_image(
|
||||
if not wave.ack_sent:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=copy.IMAGE_RECOGNIZING,
|
||||
content=copy.image_recognizing(),
|
||||
dedupe_key=f"attach-ack:{wave.wave_id}",
|
||||
)
|
||||
book.mark_ack(message.sender_id, "image")
|
||||
@@ -258,7 +258,7 @@ def accept_h5_uploads(
|
||||
book = get_attach_wave_book()
|
||||
wave = book.active(sid, now=stamp)
|
||||
if wave is None:
|
||||
return "expired", copy.ATTACH_H5_EXPIRED
|
||||
return "expired", copy.attach_h5_expired()
|
||||
incoming = [(str(n or "").strip(), raw or b"") for n, raw in files if str(n or "").strip()]
|
||||
if not incoming:
|
||||
return "need_file", copy.ATTACH_H5_NEED_FILE
|
||||
@@ -268,12 +268,12 @@ def accept_h5_uploads(
|
||||
start_index = len(wave.materials)
|
||||
wave = book.add_files(sid, [n for n, _r in incoming], now=stamp)
|
||||
if wave is None:
|
||||
return "expired", copy.ATTACH_H5_EXPIRED
|
||||
return "expired", copy.attach_h5_expired()
|
||||
msg_store = store or get_message_store()
|
||||
if not wave.ack_sent:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=sid,
|
||||
content=copy.ATTACH_RECOGNIZING,
|
||||
content=copy.attach_recognizing(),
|
||||
dedupe_key=f"attach-ack:{wave.wave_id}",
|
||||
)
|
||||
book.mark_ack(sid, "file")
|
||||
@@ -304,8 +304,8 @@ def accept_h5_uploads(
|
||||
wave = book.active(sid, now=stamp)
|
||||
if process_now and wave is not None and book.pending(wave) <= 0:
|
||||
finish_attach_wave(sid, store=msg_store)
|
||||
return "done", copy.ATTACH_H5_OK
|
||||
return "recognizing", copy.ATTACH_H5_OK
|
||||
return "done", copy.attach_h5_ok()
|
||||
return "recognizing", copy.attach_h5_ok()
|
||||
|
||||
|
||||
def finish_queued_attachment(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -403,7 +403,7 @@ def finish_attach_wave(
|
||||
oral = book.compose_oral(sender_id)
|
||||
reason = ""
|
||||
if not oral.strip():
|
||||
reason = copy.ATTACH_TECH_FAIL if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE
|
||||
reason = copy.attach_tech_fail() if book.has_errors(sender_id) else copy.ATTACH_UNREADABLE
|
||||
|
||||
msg_store = store or get_message_store()
|
||||
prefix_used = {"v": False}
|
||||
@@ -441,7 +441,7 @@ def finish_attach_wave(
|
||||
if reason and not prefix_used["v"]:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=sender_id,
|
||||
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT),
|
||||
content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
|
||||
dedupe_key=f"attach-empty:{(wave.wave_id if wave else inbound.message_id)}",
|
||||
)
|
||||
phase = "need_mode"
|
||||
|
||||
@@ -79,7 +79,7 @@ def handle_image_inquiry(
|
||||
drop_all_private_bookmarks(message.sender_id)
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=copy.IMAGE_RECOGNIZING,
|
||||
content=copy.image_recognizing(),
|
||||
dedupe_key=f"img-ack:{wave.wave_id}",
|
||||
)
|
||||
book.mark_ack(message.sender_id)
|
||||
@@ -341,7 +341,7 @@ def finish_wave(
|
||||
oral = book.compose_oral(sender_id)
|
||||
reason = ""
|
||||
if not oral.strip():
|
||||
reason = copy.IMAGE_TECH_FAIL if (wave and wave.errors) else copy.IMAGE_UNREADABLE
|
||||
reason = copy.image_tech_fail() if (wave and wave.errors) else copy.IMAGE_UNREADABLE
|
||||
|
||||
msg_store = store or get_message_store()
|
||||
prefix_used = {"v": False}
|
||||
@@ -373,7 +373,7 @@ def finish_wave(
|
||||
if reason and not prefix_used["v"]:
|
||||
msg_store.enqueue_outbound(
|
||||
touser=sender_id,
|
||||
content=copy.image_fail_then_clarify(reason, copy.ASK_TRANSPORT),
|
||||
content=copy.image_fail_then_clarify(reason, copy.ask_transport()),
|
||||
dedupe_key=f"img-empty:{inbound.message_id}",
|
||||
)
|
||||
phase = "need_mode"
|
||||
|
||||
@@ -478,18 +478,7 @@ def handle_text_other(message: InboundMessage, *, store=None) -> str:
|
||||
logger.info("text_other 陆运确定/重查,改走询价流程 sender=%s", message.sender_id)
|
||||
return handle_text_inquiry(message, store=msg_store)
|
||||
|
||||
from agent.schema.land_options import looks_like_confirm
|
||||
|
||||
if looks_like_confirm(message.content or ""):
|
||||
body = copy.LAND_CONFIRM_EXPIRED
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=body,
|
||||
dedupe_key=f"land-confirm-expired:{message.message_id}",
|
||||
)
|
||||
return "land_confirm_expired"
|
||||
|
||||
body = copy.FIRST_CHAT
|
||||
body = copy.first_chat()
|
||||
msg_store.enqueue_outbound(
|
||||
touser=message.sender_id,
|
||||
content=body,
|
||||
|
||||
@@ -103,6 +103,15 @@ class LedgerApiShell:
|
||||
params={"enabledOnly": str(bool(enabled_only)).lower()},
|
||||
)
|
||||
|
||||
def list_bot_replies(self) -> LedgerResponse:
|
||||
"""
|
||||
已启用机器人回复语。
|
||||
|
||||
正式路径:GET /inquiry/agent/botReply/listEnabled
|
||||
只读;停用项不在列表里。
|
||||
"""
|
||||
return self.client.request_json("GET", "/inquiry/agent/botReply/listEnabled")
|
||||
|
||||
def wake_agent_ack(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -118,15 +118,28 @@ class HttpLedger:
|
||||
facts: dict[str, str],
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。
|
||||
|
||||
若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。
|
||||
"""
|
||||
body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts}
|
||||
line = (business_line or str((facts or {}).get("业务线") or "")).strip()
|
||||
if line:
|
||||
body["businessLine"] = line
|
||||
out = self._post(
|
||||
"/inquiry/agent/tms/query",
|
||||
body,
|
||||
idem=f"tms:{work_order_no}",
|
||||
)
|
||||
# TMS 出站 + persistHit 常超过默认 10s,超时会被误判成接口异常。
|
||||
prev_timeout = self._client.timeout_seconds
|
||||
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
|
||||
self._client.close()
|
||||
try:
|
||||
out = self._post(
|
||||
"/inquiry/agent/tms/query",
|
||||
body,
|
||||
idem=f"tms:{work_order_no}",
|
||||
)
|
||||
finally:
|
||||
self._client.timeout_seconds = prev_timeout
|
||||
self._client.close()
|
||||
if "classification" not in out and not out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
|
||||
@@ -484,7 +484,7 @@ def _send_air_activate_sheet(
|
||||
return
|
||||
err = str(painted.get("error") or "")
|
||||
if err == "NO_TEMPLATE":
|
||||
send_group_text(client, chat_id, painted.get("message") or copy.QUOTE_TEMPLATE_MISSING)
|
||||
send_group_text(client, chat_id, painted.get("message") or copy.quote_template_missing())
|
||||
return
|
||||
if err in {"no_renderer_or_chat", "no_file_client"}:
|
||||
send_group_text(client, chat_id, copy.group_sheet_send_fail())
|
||||
|
||||
@@ -418,21 +418,21 @@ class AirTextInquiryFlow:
|
||||
hit.business_line = str(view.get("business_line") or "").strip().upper()
|
||||
if hit is None:
|
||||
if not callable(getter):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
reply(copy.continue_ticket_not_found(no))
|
||||
return "ticket_missing"
|
||||
if not view.get("found"):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
reply(copy.continue_ticket_not_found(no))
|
||||
return "ticket_missing"
|
||||
line = str(view.get("business_line") or "").strip().upper()
|
||||
owner = self._handoff_owner_for_line(line)
|
||||
if owner is None:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
return "need_mode"
|
||||
hit = owner._session_from_ledger(sender_id=sender_id, view=view)
|
||||
line = (hit.business_line or "").strip().upper()
|
||||
owner = self._handoff_owner_for_line(line)
|
||||
if owner is None:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
return "need_mode"
|
||||
logger.info(
|
||||
"handoff.named wo=%s line=%s phase=%s chat=%s",
|
||||
@@ -474,7 +474,7 @@ class AirTextInquiryFlow:
|
||||
mode = (sess.business_line if sess else "") or detect_transport_mode(text) or ""
|
||||
mode = (mode or "").upper()
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -538,7 +538,7 @@ class AirTextInquiryFlow:
|
||||
sess.business_line = "SEA"
|
||||
line = (sess.business_line or "").upper()
|
||||
if has_collab_group(sess):
|
||||
reply(copy.HANDOFF_ALREADY_COLLAB)
|
||||
reply(copy.already_collab())
|
||||
return "handoff_already"
|
||||
why = copy.HANDOFF_CLARIFY_REASON if reason == "clarify" else copy.HANDOFF_INTENT_REASON
|
||||
if line == "AIR":
|
||||
@@ -711,7 +711,7 @@ class AirTextInquiryFlow:
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not no:
|
||||
reply("请告诉我要继续的工单号,例如 WO202609140034。")
|
||||
reply(copy.continue_need_wo())
|
||||
return "need_work_order"
|
||||
with self._lock:
|
||||
sess = self._by_ticket.get(no)
|
||||
@@ -730,11 +730,11 @@ class AirTextInquiryFlow:
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
reply(copy.continue_ticket_not_found(no))
|
||||
return "ticket_missing"
|
||||
view = getter(work_order_no=no, sender_id=sender_id) or {}
|
||||
if not view.get("found"):
|
||||
reply(f"找不到工单 {no},请核对工单号后再试。")
|
||||
reply(copy.continue_ticket_not_found(no))
|
||||
return "ticket_missing"
|
||||
if view.get("owned") is False:
|
||||
reply("这张工单不属于当前账号,不能继续。")
|
||||
@@ -879,7 +879,7 @@ class AirTextInquiryFlow:
|
||||
if sess.phase == "wait_file":
|
||||
self._refresh_wait_file(sess)
|
||||
if sess.phase == "wait_file":
|
||||
reply("报价单正在生成,请稍候。")
|
||||
reply(copy.quote_generating())
|
||||
return "wait_file"
|
||||
if sess.phase == "wait_deal":
|
||||
# 成交卡已经绑在报价文件那条出站上。只续办、没说成交结果时不再另发一张卡。
|
||||
@@ -964,9 +964,8 @@ class AirTextInquiryFlow:
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
if (not sess or sess.phase != "wait_confirm") and looks_like_confirm(text) and not sess:
|
||||
reply(copy.AIR_CONFIRM_EXPIRED)
|
||||
return "confirm_expired"
|
||||
# 核对卡不再设时效。没有待确定会话时不当成「已过期」,
|
||||
# 让本句按新询价走,避免正文里的「确认」误伤。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
snap = extract_inquiry_snapshot(
|
||||
@@ -1006,7 +1005,7 @@ class AirTextInquiryFlow:
|
||||
)
|
||||
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -1055,7 +1054,7 @@ class AirTextInquiryFlow:
|
||||
chat_fn=chat_fn,
|
||||
inbound_raw=inbound_raw,
|
||||
)
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
return "need_mode"
|
||||
|
||||
if is_waiting_mode_session(sess):
|
||||
@@ -1212,6 +1211,7 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts)
|
||||
@@ -1238,12 +1238,17 @@ class AirTextInquiryFlow:
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
# 主账已落价但回包超时:按成功续办,禁止再报接口异常
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
if not tms.get("has_price"):
|
||||
# ok=true 才是 TMS 已查过、确认没价
|
||||
self._emit_tms_miss(reply, no)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
后台机器人回复语:按 reply_code 取正文。
|
||||
|
||||
本文件职责:短缓存主账已启用列表,把 {{ticket_no}} 等变量填进模板。
|
||||
禁止:当六态;禁止回调线程同步等完整 LLM;失败必须回退 inquiry_copy 默认句。
|
||||
线程:进程内锁刷新;不同工单可读同一份缓存。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 后台改文案后最多这么久出现在销售侧;不当 checkpoint。
|
||||
_TTL_SECONDS = 60.0
|
||||
_PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_loaded_at = 0.0
|
||||
_by_code: dict[str, str] = {}
|
||||
# 单测注入;None 表示走主账/空缓存。
|
||||
_override: Optional[dict[str, str]] = None
|
||||
|
||||
|
||||
def override_catalog(mapping: Optional[dict[str, str]]) -> None:
|
||||
"""
|
||||
单测注入调用编码 → 正文。传 None 清掉注入。
|
||||
|
||||
副作用:清空加载时间,下次 render 重新取。不打主账。
|
||||
"""
|
||||
global _override, _loaded_at, _by_code
|
||||
with _lock:
|
||||
_override = dict(mapping) if mapping is not None else None
|
||||
_loaded_at = 0.0
|
||||
if mapping is None:
|
||||
_by_code = {}
|
||||
|
||||
|
||||
def fill(template: str, values: dict[str, Any] | None = None) -> str:
|
||||
"""把 {{ticket_no}} 换成实值;未提供的占位原样保留。"""
|
||||
src = dict(values or {})
|
||||
|
||||
def _repl(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
if key not in src:
|
||||
return match.group(0)
|
||||
raw = src.get(key)
|
||||
if raw is None:
|
||||
return ""
|
||||
return str(raw)
|
||||
|
||||
return _PLACEHOLDER.sub(_repl, template or "")
|
||||
|
||||
|
||||
def render(code: str, default: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
取已启用回复语并填变量。
|
||||
|
||||
停用、库空、主账失败、编码对不上:返回 default(已是拼好的默认句)。
|
||||
副作用:缓存过期时 GET 主账;超时/失败吞掉,不抛给销售。
|
||||
"""
|
||||
key = (code or "").strip()
|
||||
fallback = default if default is not None else ""
|
||||
if not key:
|
||||
return fallback
|
||||
tpl = _lookup(key)
|
||||
if not (tpl or "").strip():
|
||||
return fallback
|
||||
return fill(tpl, kwargs)
|
||||
|
||||
|
||||
def _lookup(code: str) -> str:
|
||||
"""读缓存;过期再拉主账。override 优先。"""
|
||||
global _loaded_at, _by_code
|
||||
with _lock:
|
||||
if _override is not None:
|
||||
return str(_override.get(code) or "")
|
||||
now = time.monotonic()
|
||||
if _loaded_at > 0 and (now - _loaded_at) < _TTL_SECONDS:
|
||||
return str(_by_code.get(code) or "")
|
||||
fresh = _fetch()
|
||||
with _lock:
|
||||
if _override is not None:
|
||||
return str(_override.get(code) or "")
|
||||
if fresh is not None:
|
||||
_by_code = fresh
|
||||
_loaded_at = time.monotonic()
|
||||
elif not _by_code:
|
||||
_loaded_at = time.monotonic()
|
||||
return str(_by_code.get(code) or "")
|
||||
|
||||
|
||||
def _fetch() -> Optional[dict[str, str]]:
|
||||
"""
|
||||
GET /inquiry/agent/botReply/listEnabled。
|
||||
|
||||
未配主账地址视为空,返回 {{}} 以免每次空打。失败返回 None 以保留旧缓存。
|
||||
"""
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
cfg = get_settings()
|
||||
backend = str(
|
||||
os.environ.get("LEDGER_BACKEND") or getattr(cfg, "ledger_backend", "") or ""
|
||||
).strip().lower()
|
||||
if backend in {"memory", "mem", "stub"}:
|
||||
return {}
|
||||
if not str(getattr(cfg, "biz_api_base_url", "") or "").strip():
|
||||
return {}
|
||||
from agent.ledger import LedgerClient
|
||||
from agent.ledger.api_shell import LedgerApiShell
|
||||
|
||||
client = LedgerClient.from_settings()
|
||||
client.timeout_seconds = min(float(client.timeout_seconds or 10), 3.0)
|
||||
resp = LedgerApiShell(client=client).list_bot_replies()
|
||||
if not resp.ok:
|
||||
logger.warning("bot_reply listEnabled fail status=%s err=%s", resp.status_code, resp.error)
|
||||
return None
|
||||
rows = _unwrap_rows(resp.data)
|
||||
out: dict[str, str] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
code = str(row.get("replyCode") or row.get("reply_code") or "").strip()
|
||||
text = str(row.get("replyText") or row.get("reply_text") or "")
|
||||
if code and text.strip():
|
||||
out[code] = text
|
||||
return out
|
||||
except Exception:
|
||||
logger.warning("bot_reply listEnabled 异常,回退默认句", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _unwrap_rows(data: Any) -> list[Any]:
|
||||
"""解开 Jeecg Result 列表。"""
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
return []
|
||||
inner = data.get("result")
|
||||
if isinstance(inner, list):
|
||||
return inner
|
||||
if isinstance(inner, dict):
|
||||
rec = inner.get("records")
|
||||
if isinstance(rec, list):
|
||||
return rec
|
||||
rec = data.get("records") or data.get("data")
|
||||
if isinstance(rec, list):
|
||||
return rec
|
||||
return []
|
||||
@@ -2,7 +2,8 @@
|
||||
空运文字询价出站话术(销售可见)。
|
||||
|
||||
本文件职责:集中文案;按钮字面量必须与 handler 精确匹配。
|
||||
禁止:在本文件调主账/TMS;禁止把密钥写进话术。
|
||||
销售可见固定句经 bot_reply 按调用编码读后台;停用/失败回退本文件默认句。
|
||||
禁止:在本文件直接 request 主账;禁止把密钥写进话术。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +28,17 @@ from agent.schema.field_validate import (
|
||||
from agent.schema.land_options import option_prompt as _land_option_prompt
|
||||
from agent.schema.quote_adjust import total_price_label
|
||||
|
||||
def _r(code: str, default: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
按后台调用编码取回复语。
|
||||
|
||||
停用、库空、主账失败返回 default。本函数不打主账,只调 bot_reply 短缓存。
|
||||
"""
|
||||
from agent.policy.bot_reply import render
|
||||
|
||||
return render(code, default, **kwargs)
|
||||
|
||||
|
||||
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
|
||||
_AIR_EXAMPLES = {
|
||||
"起运港": "南京",
|
||||
@@ -50,9 +62,11 @@ _AIR_OPTIONAL_CLARIFY = tuple(
|
||||
(key, label, _AIR_EXAMPLES.get(key, "")) for key, label in AIR_OPTIONAL_FIELDS
|
||||
)
|
||||
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
# 核对卡已取消时效,不再对销售发送此句;常量仅保留避免旧引用报错。
|
||||
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
|
||||
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
|
||||
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
# 核对卡已取消时效,不再对销售发送此句。
|
||||
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
|
||||
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
@@ -125,10 +139,79 @@ ASK_TRANSPORT = (
|
||||
"2.海运\n"
|
||||
"3.空运"
|
||||
)
|
||||
|
||||
|
||||
def ask_transport() -> str:
|
||||
"""未识别运输方式。后台 inquiring.ask_transport。"""
|
||||
return _r("inquiring.ask_transport", ASK_TRANSPORT)
|
||||
|
||||
|
||||
def first_chat() -> str:
|
||||
"""销售首次单聊。后台 inquiring.first_chat。"""
|
||||
return _r("inquiring.first_chat", FIRST_CHAT)
|
||||
|
||||
|
||||
def image_recognizing() -> str:
|
||||
"""正在识别图片。后台 inquiring.image_recognizing。"""
|
||||
return _r("inquiring.image_recognizing", IMAGE_RECOGNIZING)
|
||||
|
||||
|
||||
def image_tech_fail() -> str:
|
||||
"""图片技术失败。后台 inquiring.image_tech_fail。"""
|
||||
return _r("inquiring.image_tech_fail", IMAGE_TECH_FAIL)
|
||||
|
||||
|
||||
def attach_recognizing() -> str:
|
||||
"""正在识别附件。后台 inquiring.attach_recognizing。"""
|
||||
return _r("inquiring.attach_recognizing", ATTACH_RECOGNIZING)
|
||||
|
||||
|
||||
def attach_tech_fail() -> str:
|
||||
"""附件技术失败。后台 inquiring.attach_tech_fail。"""
|
||||
return _r("inquiring.attach_tech_fail", ATTACH_TECH_FAIL)
|
||||
|
||||
|
||||
def attach_h5_expired() -> str:
|
||||
"""H5 上传过期。后台 inquiring.attach_h5_expired。"""
|
||||
return _r("inquiring.attach_h5_expired", ATTACH_H5_EXPIRED)
|
||||
|
||||
|
||||
def attach_h5_ok() -> str:
|
||||
"""H5 上传成功。后台 inquiring.attach_h5_ok。"""
|
||||
return _r("inquiring.attach_h5_ok", ATTACH_H5_OK)
|
||||
|
||||
|
||||
def continue_need_wo() -> str:
|
||||
"""续办但未提供工单号。后台 inquiring.continue_need_wo。"""
|
||||
return _r("inquiring.continue_need_wo", "请告诉我要继续的工单号,例如 WO202609140034。")
|
||||
|
||||
|
||||
def continue_ticket_not_found(work_order_no: str) -> str:
|
||||
"""续办找不到工单。后台 inquiring.continue_not_found(工单号后有空格)。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return _r(
|
||||
"inquiring.continue_not_found",
|
||||
f"找不到工单 {no},请核对工单号后再试。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def quote_generating() -> str:
|
||||
"""报价单生成中。后台 inquiring.quote_generating。"""
|
||||
return _r("inquiring.quote_generating", "报价单正在生成,请稍候。")
|
||||
|
||||
|
||||
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
|
||||
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
|
||||
_PENDING_UNITS = {}
|
||||
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
|
||||
|
||||
|
||||
def already_collab() -> str:
|
||||
"""该工单已拉过产品进群。后台 transfer.already_collab。"""
|
||||
return _r("transfer.already_collab", HANDOFF_ALREADY_COLLAB)
|
||||
|
||||
|
||||
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
|
||||
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
|
||||
CLARIFY_LIMIT_HINT = "连续 3 次反问仍不完整将触发转人工"
|
||||
@@ -214,6 +297,7 @@ def land_sample_image_fallback() -> str:
|
||||
FIRST_CHAT = "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。"
|
||||
ROUTE_MODEL_FAILED = "刚才没听清,请再说一次:是新询价,还是要接着办哪张工单?"
|
||||
LAND_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
# 核对卡已取消时效。仅工单号丢失时内部兜底,不对销售当「过期」主路径。
|
||||
LAND_CONFIRM_EXPIRED = "刚才的陆运核对已过期,请把陆运需求再发一次,出核对卡后再回复确定。"
|
||||
LAND_TMS_RETRY = "工单 {wo} 上次查价没成功。回复「确定」按原需求再查一次,不用重新填。"
|
||||
LAND_TMS_RETRYING = "正在重新查询TMS报价。工单:{wo}"
|
||||
@@ -274,8 +358,11 @@ def land_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
|
||||
|
||||
def land_group_no_staff() -> str:
|
||||
"""没有对上线路的陆运产品,不建空群。"""
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。"
|
||||
"""没有对上线路的陆运产品,不建空群。后台 inquiring.land_no_staff。"""
|
||||
return _r(
|
||||
"inquiring.land_no_staff",
|
||||
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
|
||||
)
|
||||
|
||||
|
||||
def land_group_pair_rejected() -> str:
|
||||
@@ -1354,8 +1441,13 @@ def skip_collab_card_update(*, response_code: str, replace_name: str = "") -> di
|
||||
|
||||
|
||||
def tms_miss_quote_text(*, work_order_no: str) -> str:
|
||||
"""文字兜底里的拉群句,与现网图一致。"""
|
||||
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{work_order_no}"
|
||||
"""请手动拉人进群并发送工单号。后台 inquiring.pull_group_manual。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return _r(
|
||||
"inquiring.pull_group_manual",
|
||||
f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def tms_miss_hint() -> str:
|
||||
@@ -1438,7 +1530,11 @@ def tms_need_more(*, work_order_no: str, hint: str = "") -> str:
|
||||
|
||||
|
||||
def skip_collab_ack() -> str:
|
||||
return "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…"
|
||||
"""销售跳过内部协同。后台 inquiring.skip_collab_ack。"""
|
||||
return _r(
|
||||
"inquiring.skip_collab_ack",
|
||||
"好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…",
|
||||
)
|
||||
|
||||
|
||||
def sea_collab_ask() -> str:
|
||||
@@ -1584,13 +1680,13 @@ def sea_tms_miss_wecom_payload(*, work_order_no: str, action_url: str = "") -> d
|
||||
|
||||
MULTI_CONFIRM_TAIL = "请回复「确定」创建工单,内容有误时,请重新发内容。"
|
||||
MULTI_SAME_MODE = "同一运输方式出现了两段,请拆开再发。"
|
||||
# 核对卡已取消时效,不再对销售发送此句。
|
||||
MULTI_CONFIRM_EXPIRED = "刚才的多段核对已过期,请把多段需求再发一次,出核对后再回复确定。"
|
||||
|
||||
|
||||
def multi_air_manual_hint(*, work_order_no: str) -> str:
|
||||
"""含空运的多段:不自动拉群,请销售把人和智能体拉进原有群并发送工单号。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{no}"
|
||||
"""含空运的多段:不自动拉群。与 inquiring.pull_group_manual 同一条后台。"""
|
||||
return tms_miss_quote_text(work_order_no=work_order_no)
|
||||
|
||||
|
||||
def multi_pull_text(*, work_order_no: str) -> str:
|
||||
@@ -1629,11 +1725,16 @@ def multi_pull_wecom_payload(*, work_order_no: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def handoff_air_hint(*, work_order_no: str) -> str:
|
||||
"""空运转人工:只发文字,不建群。"""
|
||||
"""空运转人工:只发文字,不建群。后台 transfer.air_hint。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if no:
|
||||
return f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
|
||||
return "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。"
|
||||
default = f"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {no}。"
|
||||
return _r("transfer.air_hint", default, ticket_no=no)
|
||||
return _r(
|
||||
"transfer.air_hint",
|
||||
"触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号。",
|
||||
ticket_no="",
|
||||
)
|
||||
|
||||
|
||||
def handoff_offer_text(*, reason: str, transport_mode: str) -> str:
|
||||
@@ -1729,27 +1830,45 @@ def _role_label(name: str, role: str) -> str:
|
||||
|
||||
|
||||
def sea_group_created(*, work_order_no: str, sales_name: str, product_names: list[str]) -> str:
|
||||
"""拉群成功后的私聊。已邀请写成「销售某某、产品某某」,岗位不能省略。"""
|
||||
"""拉群成功后的私聊。后台 inquiring.group_created。已邀请写成「销售某某、产品某某」。"""
|
||||
no = (work_order_no or "").strip()
|
||||
sales = _role_label(sales_name, "销售")
|
||||
products = "、".join(
|
||||
_role_label(n, "产品") for n in (product_names or []) if (n or "").strip()
|
||||
) or "产品"
|
||||
return (
|
||||
sales_raw = (sales_name or "").strip()
|
||||
sales = _role_label(sales_raw, "销售")
|
||||
name_bits = [n.strip() for n in (product_names or []) if (n or "").strip()]
|
||||
products = "、".join(_role_label(n, "产品") for n in name_bits) or "产品"
|
||||
default = (
|
||||
f"协同群已创建,群名与工单号一致:{no}。"
|
||||
f"已邀请:{sales}、{products}。请在群聊中继续确认报价。"
|
||||
)
|
||||
sales_var = sales_raw[2:] if sales_raw.startswith("销售") else sales_raw
|
||||
prod_var = "、".join(
|
||||
(n[2:] if n.startswith("产品") else n) for n in name_bits
|
||||
) or "产品"
|
||||
return _r(
|
||||
"inquiring.group_created",
|
||||
default,
|
||||
ticket_no=no,
|
||||
sales_name=sales_var,
|
||||
product_names=prod_var,
|
||||
)
|
||||
|
||||
|
||||
def sea_group_exists(*, work_order_no: str) -> str:
|
||||
"""同一工单已有群,不再新建。"""
|
||||
"""同一工单已有群,不再新建。后台 inquiring.group_exists。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。"
|
||||
return _r(
|
||||
"inquiring.group_exists",
|
||||
f"协同群已在,群名与工单号一致:{no}。请到该群继续确认报价。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def sea_group_no_staff() -> str:
|
||||
"""没有可拉的产品人员,不建空群。"""
|
||||
return "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。"
|
||||
"""没有可拉的产品人员,不建空群。后台 inquiring.sea_no_staff。"""
|
||||
return _r(
|
||||
"inquiring.sea_no_staff",
|
||||
"没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
|
||||
)
|
||||
|
||||
|
||||
def group_sheet_send_fail() -> str:
|
||||
@@ -1758,11 +1877,10 @@ def group_sheet_send_fail() -> str:
|
||||
|
||||
|
||||
def sea_group_create_fail(*, reason: str = "") -> str:
|
||||
"""建群失败,按钮保持可点。"""
|
||||
"""建群失败,按钮保持可点。后台 inquiring.group_create_fail(无原因短句)。"""
|
||||
extra = (reason or "").strip()
|
||||
if extra:
|
||||
return f"建群失败:{extra}。请稍后重试。"
|
||||
return "建群失败,请稍后重试。"
|
||||
default = f"建群失败:{extra}。请稍后重试。" if extra else "建群失败,请稍后重试。"
|
||||
return _r("inquiring.group_create_fail", default, reason=extra)
|
||||
|
||||
|
||||
def quote_is_product(quote: dict[str, Any] | None) -> bool:
|
||||
@@ -2212,7 +2330,11 @@ def scheme_wecom_payload(
|
||||
|
||||
|
||||
def reject_ack() -> str:
|
||||
return "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。"
|
||||
"""销售选择不采用。后台 quoted.reject_ack。"""
|
||||
return _r(
|
||||
"quoted.reject_ack",
|
||||
"请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
|
||||
)
|
||||
|
||||
|
||||
def adjust_confirm(applied: list[dict[str, Any]]) -> str:
|
||||
@@ -2278,23 +2400,32 @@ def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") ->
|
||||
|
||||
|
||||
def adopt_excel_ack() -> str:
|
||||
return "已确认采用该方案,正在生成 Excel 最终报价单…"
|
||||
"""采用并生成 Excel。后台 quoted.adopt_excel。"""
|
||||
return _r("quoted.adopt_excel", "已确认采用该方案,正在生成 Excel 最终报价单…")
|
||||
|
||||
|
||||
def adopt_pdf_ack() -> str:
|
||||
return "已确认采用该方案,正在生成PDF最终报价单…"
|
||||
"""采用并生成 PDF。后台 quoted.adopt_pdf。"""
|
||||
return _r("quoted.adopt_pdf", "已确认采用该方案,正在生成PDF最终报价单…")
|
||||
|
||||
|
||||
QUOTE_TEMPLATE_MISSING = "生成报价单失败,请联系IT运维维护后台报价模板。"
|
||||
|
||||
|
||||
def quote_template_missing() -> str:
|
||||
"""关键词与默认模板都未维护时的销售提示(产品口径,勿改字)。"""
|
||||
return QUOTE_TEMPLATE_MISSING
|
||||
"""关键词与默认模板都未维护。后台 quoted.template_missing。"""
|
||||
return _r("quoted.template_missing", QUOTE_TEMPLATE_MISSING)
|
||||
|
||||
|
||||
def file_ready(*, kind: str, work_order_no: str, filename: str) -> str:
|
||||
return f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})"
|
||||
"""最终报价单生成完成。后台 quoted.file_ready。"""
|
||||
return _r(
|
||||
"quoted.file_ready",
|
||||
f"已生成{kind}最终报价单:{filename}(工单 {work_order_no})",
|
||||
kind=kind,
|
||||
filename=filename,
|
||||
ticket_no=work_order_no,
|
||||
)
|
||||
|
||||
|
||||
# 未成交原因说明跟在未成交后,不跟在协商中后。验收脚本认这个开关。
|
||||
@@ -2429,7 +2560,11 @@ def group_only_excel_pdf() -> str:
|
||||
|
||||
|
||||
def group_only_product_can_quote() -> str:
|
||||
return "只有产品能报价格,请产品在群里发价格或 Excel/PDF"
|
||||
"""非产品人员在群里报价。后台 inquiring.group_only_product。"""
|
||||
return _r(
|
||||
"inquiring.group_only_product",
|
||||
"只有产品能报价格,请产品在群里发价格或 Excel/PDF",
|
||||
)
|
||||
|
||||
|
||||
_GROUP_QUOTE_TAIL = (
|
||||
@@ -2464,14 +2599,24 @@ def group_no_tms_quote_hint(*, business_line: str = "") -> str:
|
||||
|
||||
|
||||
def quote_attachment_saved_app(work_order_no: str, count: int = 1) -> str:
|
||||
"""群里告诉大家:模板或产品附件已进后台。"""
|
||||
"""
|
||||
群里告诉大家:模板或产品附件已进后台。
|
||||
|
||||
后台 inquiring.group_file_saved。默认句带工单号和份数;后台改成短句则原样用。
|
||||
"""
|
||||
no = (work_order_no or "").strip() or "WO"
|
||||
n = count if count > 0 else 1
|
||||
return f"工单{no}的{n}个附件已保存到后台工单"
|
||||
return _r(
|
||||
"inquiring.group_file_saved",
|
||||
f"工单{no}的{n}个附件已保存到后台工单",
|
||||
ticket_no=no,
|
||||
count=n,
|
||||
)
|
||||
|
||||
|
||||
def group_confirm_need_tms() -> str:
|
||||
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
|
||||
def group_file_saved(*, work_order_no: str = "", count: int = 1) -> str:
|
||||
"""群附件已保存。与 quote_attachment_saved_app 同一条后台编码。"""
|
||||
return quote_attachment_saved_app(work_order_no, count)
|
||||
|
||||
|
||||
def group_ask_tms_or_product() -> str:
|
||||
@@ -2490,8 +2635,8 @@ def group_need_price_before_choice() -> str:
|
||||
return "请产品先发价格,再说在TMS基础上改或不用TMS报价"
|
||||
|
||||
|
||||
def group_file_saved() -> str:
|
||||
return "附件已保存后台"
|
||||
def group_confirm_need_tms() -> str:
|
||||
return "本单还没有可沿用的 TMS 报价,请直接回复协同报价或上传报价附件。"
|
||||
|
||||
|
||||
def group_quote_not_recognized() -> str:
|
||||
@@ -2561,18 +2706,26 @@ def deal_ack(
|
||||
next_followup_at: str = "",
|
||||
lost_reason: str = "",
|
||||
) -> str:
|
||||
"""成交三态回执。协商中写明顺延三个工作日和下次再问的月日 9:00。"""
|
||||
"""成交三态回执。后台 deal.recorded_won / lost.ask_reason / negotiating.recorded。"""
|
||||
no = (work_order_no or "").strip()
|
||||
if outcome == "已成交":
|
||||
return f"已记录为「已成交」。工单 {work_order_no} 已关闭智能体跟进。"
|
||||
return _r(
|
||||
"deal.recorded_won",
|
||||
f"已记录为「已成交」。工单 {no} 已关闭智能体跟进。",
|
||||
ticket_no=no,
|
||||
)
|
||||
if outcome == "未成交":
|
||||
reason = (lost_reason or "").strip()
|
||||
if reason:
|
||||
return f"已记录为「未成交」。{lost_reason_ack(reason)}"
|
||||
return "已记录为「未成交」。请补充未成交原因。"
|
||||
when = _followup_month_day(next_followup_at)
|
||||
if when:
|
||||
return f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。"
|
||||
return "已记录为「协商中」。已顺延三个工作日,第四个工作日早上9:00再次发起成交状态询问。"
|
||||
return _r("lost.ask_reason", "已记录为「未成交」。请补充未成交原因。", ticket_no=no)
|
||||
when = _followup_month_day(next_followup_at) or "第四个工作日"
|
||||
return _r(
|
||||
"negotiating.recorded",
|
||||
f"已记录为「协商中」。已顺延三个工作日,{when}的9:00再次发起成交状态询问。",
|
||||
followup_when=when,
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def _followup_month_day(raw: str) -> str:
|
||||
@@ -2592,11 +2745,13 @@ def _followup_month_day(raw: str) -> str:
|
||||
|
||||
|
||||
def lost_reason_ack(reason: str = "") -> str:
|
||||
"""收下未成交原因后回显原句,不再追问。"""
|
||||
"""收下未成交原因后回显原句。后台 lost.reason_ack。"""
|
||||
text = (reason or "").strip()
|
||||
if text.endswith(("。", ".", "!", "!", "?", "?")):
|
||||
return f"已记下未成交原因:{text}"
|
||||
return f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
|
||||
default = f"已记下未成交原因:{text}"
|
||||
else:
|
||||
default = f"已记下未成交原因:{text}。" if text else "已记下未成交原因。"
|
||||
return _r("lost.reason_ack", default, lost_reason=text)
|
||||
|
||||
|
||||
def deal_notify_card(
|
||||
@@ -2679,29 +2834,49 @@ def air_need_work_order() -> str:
|
||||
|
||||
|
||||
def air_lock_need_work_order() -> str:
|
||||
"""锁舱/释放必须自带工单号,不猜当前单。"""
|
||||
return "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。"
|
||||
"""锁舱/释放必须自带工单号。后台 inquiring.air_lock_need_wo。"""
|
||||
return _r(
|
||||
"inquiring.air_lock_need_wo",
|
||||
"锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
||||
)
|
||||
|
||||
|
||||
def air_ticket_not_found(work_order_no: str) -> str:
|
||||
"""空运群找不到工单。后台 inquiring.air_ticket_not_found。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"找不到工单{no},请核对工单号后再试。"
|
||||
return _r("inquiring.air_ticket_not_found", f"找不到工单{no},请核对工单号后再试。", ticket_no=no)
|
||||
|
||||
|
||||
def air_ticket_not_air(work_order_no: str) -> str:
|
||||
"""工单不是空运单。后台 inquiring.air_ticket_not_air。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}不是空运单,不能在空运群里跟。"
|
||||
return _r(
|
||||
"inquiring.air_ticket_not_air",
|
||||
f"工单{no}不是空运单,不能在空运群里跟。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def air_ticket_closed(work_order_no: str, status: str) -> str:
|
||||
"""空运工单已是终态仍要激活。后台 closed.air_cannot_activate。"""
|
||||
no = (work_order_no or "").strip()
|
||||
st = (status or "").strip() or "已关闭"
|
||||
return f"工单{no}已是「{st}」,不能再激活。"
|
||||
return _r(
|
||||
"closed.air_cannot_activate",
|
||||
f"工单{no}已是「{st}」,不能再激活。",
|
||||
ticket_no=no,
|
||||
status=st,
|
||||
)
|
||||
|
||||
|
||||
def air_ticket_occupied(work_order_no: str) -> str:
|
||||
"""空运工单已在另一个群。后台 inquiring.air_ticket_occupied。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}已经在另一个群跟了,请到那个群继续。"
|
||||
return _r(
|
||||
"inquiring.air_ticket_occupied",
|
||||
f"工单{no}已经在另一个群跟了,请到那个群继续。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def air_group_brief(
|
||||
@@ -2820,7 +2995,11 @@ def air_airline_only_quote() -> str:
|
||||
|
||||
|
||||
def air_airline_only_cabin() -> str:
|
||||
return "只有岗位为「航线·空运」的人能锁舱或释放舱位。"
|
||||
"""只有航线能锁舱或释放。后台 inquiring.air_airline_only_cabin。"""
|
||||
return _r(
|
||||
"inquiring.air_airline_only_cabin",
|
||||
"只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
||||
)
|
||||
|
||||
|
||||
def air_sales_only_deal() -> str:
|
||||
@@ -2844,9 +3023,13 @@ def air_no_tms_quote_to_lock(work_order_no: str) -> str:
|
||||
|
||||
|
||||
def air_lock_need_quote(work_order_no: str) -> str:
|
||||
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
|
||||
"""没有任何报价时的锁舱拒绝。后台 inquiring.air_lock_need_quote。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}还没有报价,不能锁舱。"
|
||||
return _r(
|
||||
"inquiring.air_lock_need_quote",
|
||||
f"工单{no}还没有报价,不能锁舱。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def air_ask_lock_option(options: list) -> str:
|
||||
@@ -2869,23 +3052,41 @@ def air_lock_ok(
|
||||
exec_no: str,
|
||||
quote_no: str,
|
||||
) -> str:
|
||||
return (
|
||||
"""空运锁舱成功。后台 inquiring.air_lock_ok。"""
|
||||
default = (
|
||||
f"工单{work_order_no} 已通知TMS执行空运锁舱,执行成功。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"舱位选项:{option_no}\n"
|
||||
f"执行编号:{exec_no}\n"
|
||||
f"报价编号:{quote_no}"
|
||||
)
|
||||
return _r(
|
||||
"inquiring.air_lock_ok",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
operator=operator,
|
||||
option_no=option_no,
|
||||
exec_no=exec_no,
|
||||
quote_no=quote_no,
|
||||
)
|
||||
|
||||
|
||||
def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
"""空运锁舱失败。后台 inquiring.air_lock_fail。"""
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
return (
|
||||
default = (
|
||||
f"工单{work_order_no} 空运锁舱失败。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"锁舱失败的原因:{why}\n"
|
||||
"已通知IT运维排查,请稍等。"
|
||||
)
|
||||
return _r(
|
||||
"inquiring.air_lock_fail",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
operator=operator,
|
||||
reason=why,
|
||||
)
|
||||
|
||||
|
||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||
@@ -2907,33 +3108,61 @@ def air_release_ok(
|
||||
exec_no: str,
|
||||
quote_no: str,
|
||||
) -> str:
|
||||
return (
|
||||
"""空运释放成功。后台 inquiring.air_release_ok。"""
|
||||
default = (
|
||||
f"工单{work_order_no} 已通知TMS执行空运释放舱位成功\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"舱位选项:{option_no}\n"
|
||||
f"执行编号:{exec_no}\n"
|
||||
f"报价编号:{quote_no}"
|
||||
)
|
||||
return _r(
|
||||
"inquiring.air_release_ok",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
operator=operator,
|
||||
option_no=option_no,
|
||||
exec_no=exec_no,
|
||||
quote_no=quote_no,
|
||||
)
|
||||
|
||||
|
||||
def air_release_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
"""空运释放失败。后台 inquiring.air_release_fail。"""
|
||||
why = (reason or "").strip() or "TMS未返回原因"
|
||||
return (
|
||||
default = (
|
||||
f"工单{work_order_no} 空运释放舱位失败。\n"
|
||||
f"操作人:{operator}\n"
|
||||
f"释放舱位失败的原因:{why}\n"
|
||||
"已通知IT运维排查,请稍等。"
|
||||
)
|
||||
return _r(
|
||||
"inquiring.air_release_fail",
|
||||
default,
|
||||
ticket_no=work_order_no,
|
||||
operator=operator,
|
||||
reason=why,
|
||||
)
|
||||
|
||||
|
||||
def air_release_deal_blocked(work_order_no: str) -> str:
|
||||
"""已成交不能释放舱位。后台 inquiring.air_release_deal_blocked。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"释放舱位失败,工单{no}已成交。"
|
||||
return _r(
|
||||
"inquiring.air_release_deal_blocked",
|
||||
f"释放舱位失败,工单{no}已成交。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
def air_release_need_lock(work_order_no: str) -> str:
|
||||
"""没有有效锁舱不能释放。后台 inquiring.air_release_need_lock。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}还没有有效锁舱,不能释放。"
|
||||
return _r(
|
||||
"inquiring.air_release_need_lock",
|
||||
f"工单{no}还没有有效锁舱,不能释放。",
|
||||
ticket_no=no,
|
||||
)
|
||||
|
||||
|
||||
# —— 系统异常(UC024):销售/群兜底与恢复;告警卡只给有权限的人 ——
|
||||
@@ -2966,29 +3195,34 @@ IMPACT_GROUP = "当前建群/加人暂停,已建群不新建第二个群"
|
||||
|
||||
|
||||
def system_exception_fallback(exception_type: str, *, names: list[str] | None = None) -> str:
|
||||
"""判定系统异常后,给卡住的会话的兜底话术。"""
|
||||
"""判定系统异常后,给卡住的会话的兜底话术。后台 sys.llm_fail / sys.tms_fail / sys.group_add_fail。"""
|
||||
kind = (exception_type or "").strip()
|
||||
if kind == TYPE_LLM:
|
||||
return SYS_EXC_LLM_FALLBACK
|
||||
return _r("sys.llm_fail", SYS_EXC_LLM_FALLBACK)
|
||||
if kind == TYPE_GROUP:
|
||||
people = [str(x).strip() for x in (names or []) if str(x).strip()]
|
||||
if not people:
|
||||
return "无法添加同事,请联系管理员检查企业微信配置。"
|
||||
joined = "、".join(people)
|
||||
return f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
|
||||
return SYS_EXC_TMS_FALLBACK
|
||||
if joined:
|
||||
default = f"无法添加 {joined} 同事,请联系管理员检查企业微信配置。"
|
||||
else:
|
||||
default = "无法添加同事,请联系管理员检查企业微信配置。"
|
||||
text = _r("sys.group_add_fail", default, staff_name=joined)
|
||||
if not joined:
|
||||
text = re.sub(r"无法添加\s+同事", "无法添加同事", text)
|
||||
return text
|
||||
return _r("sys.tms_fail", SYS_EXC_TMS_FALLBACK)
|
||||
|
||||
|
||||
def system_exception_recovery(step: str) -> str:
|
||||
"""后台点重试成功后,先发这一句,再补没做完的事。"""
|
||||
key = (step or "").strip()
|
||||
if key == STEP_LLM or key == STEP_LLM_NO_TICKET:
|
||||
return SYS_EXC_RECOVERY_LLM
|
||||
return _r("sys.llm_recovery", SYS_EXC_RECOVERY_LLM)
|
||||
if key in {STEP_TMS_CABIN, STEP_TMS_LOCK, STEP_TMS_RELEASE}:
|
||||
return SYS_EXC_RECOVERY_CABIN
|
||||
return _r("sys.cabin_recovery", SYS_EXC_RECOVERY_CABIN)
|
||||
if key == STEP_GROUP:
|
||||
return SYS_EXC_RECOVERY_GROUP
|
||||
return SYS_EXC_RECOVERY_TMS
|
||||
return _r("sys.group_recovery", SYS_EXC_RECOVERY_GROUP)
|
||||
return _r("sys.tms_recovery", SYS_EXC_RECOVERY_TMS)
|
||||
|
||||
|
||||
def system_exception_impact(step: str) -> str:
|
||||
|
||||
@@ -174,7 +174,7 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
if not mode:
|
||||
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -364,8 +364,9 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
no = (sess.work_order_no or "").strip()
|
||||
facts = dict(sess.facts or {})
|
||||
if not no or not facts:
|
||||
reply(copy.LAND_CONFIRM_EXPIRED)
|
||||
return "land_confirm_expired"
|
||||
# 核对卡已取消时效,禁止再回「陆运核对已过期」。
|
||||
logger.warning("land.retry 缺工单或字段,不提示过期")
|
||||
return "land_retry_missing"
|
||||
reply(copy.LAND_TMS_RETRYING.format(wo=no))
|
||||
from agent.policy.system_exception import query_tms_resilient
|
||||
|
||||
@@ -390,7 +391,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
_ = retrying
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if not tms.get("ok"):
|
||||
from agent.policy.system_exception import confirm_tms_quote_if_tech
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
if confirm_tms_quote_if_tech(
|
||||
ledger=self._ledger,
|
||||
@@ -402,11 +406,15 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
):
|
||||
self.drop_current_bookmark(sess.sender_id)
|
||||
return "system_exception"
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
self._park_tms_retry(sess)
|
||||
return "tms_tech"
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
self._park_tms_retry(sess)
|
||||
return "tms_tech"
|
||||
self._clear_parked_confirm(sess.sender_id)
|
||||
if not tms.get("has_price"):
|
||||
self._emit_tms_miss(reply, no)
|
||||
|
||||
@@ -256,9 +256,7 @@ class MultiTextInquiryFlow:
|
||||
allow_b=allow_b,
|
||||
chat_fn=chat_fn,
|
||||
)
|
||||
if sess and sess.phase == "wait_confirm":
|
||||
reply(copy.MULTI_CONFIRM_EXPIRED)
|
||||
return "multi_confirm_expired"
|
||||
# 核对卡不再设时效。走到这里说明不是「确定」也不是补字段。
|
||||
reply(copy.MULTI_SAME_MODE if verdict == "same_mode" else "请按段发多段需求,例如段1海运、段2空运。")
|
||||
return "multi_need_text"
|
||||
|
||||
|
||||
@@ -319,7 +319,12 @@ def apply_group_adjust(
|
||||
flow=flow,
|
||||
chat_id=room,
|
||||
work_order_no=no,
|
||||
facts=_facts_for_fill(facts, collab, business_line=line),
|
||||
facts=_facts_for_fill(
|
||||
facts,
|
||||
collab,
|
||||
business_line=line,
|
||||
sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
),
|
||||
quote=result.quote,
|
||||
sales_id=sender_id,
|
||||
business_line=line,
|
||||
|
||||
@@ -232,6 +232,19 @@ def overlay_labeled_facts(
|
||||
return {"filepath": str(tmp), "file_b64": out_b64}
|
||||
|
||||
|
||||
def _band_value_goes_below(ws, label_cell) -> bool:
|
||||
"""同一行凑齐业务员、客户名称、始发地,就是集拼询价条带。"""
|
||||
text = str(label_cell.value or "").replace(" ", "")
|
||||
if not any(key in text for key in ("客户名称", "业务员", "始发地", "货物类型")):
|
||||
return False
|
||||
hits = 0
|
||||
for cell in ws[label_cell.row]:
|
||||
compact = str(cell.value or "").replace(" ", "").replace(":", "").replace(":", "")
|
||||
if compact in {"业务员", "客户名称", "始发地", "货物类型", "条款", "发货日期"}:
|
||||
hits += 1
|
||||
return hits >= 3
|
||||
|
||||
|
||||
def _match_collab_label(text: str) -> tuple[str, ...] | None:
|
||||
raw = (text or "").replace(":", ":").strip()
|
||||
for marker, keys in _COLLAB_LABEL_MARKERS:
|
||||
@@ -241,7 +254,23 @@ def _match_collab_label(text: str) -> tuple[str, ...] | None:
|
||||
|
||||
|
||||
def _label_value_cell(ws, label_cell):
|
||||
"""标签右侧格子;合并标签则从合并区右边开始。"""
|
||||
"""标签右侧格子;合并标签则从合并区右边开始。
|
||||
|
||||
跨境集拼蓝条是「业务员 | 客户名称 | 始发地」,值在下一行。
|
||||
写到右侧会把客户名称填进「始发地」表头。
|
||||
"""
|
||||
if _band_value_goes_below(ws, label_cell):
|
||||
col = label_cell.column
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
if merged is not None:
|
||||
for rng in merged.ranges:
|
||||
if (
|
||||
rng.min_row <= label_cell.row <= rng.max_row
|
||||
and rng.min_col <= label_cell.column <= rng.max_col
|
||||
):
|
||||
col = rng.min_col
|
||||
break
|
||||
return ws.cell(row=label_cell.row + 1, column=col)
|
||||
row_idx = label_cell.row
|
||||
start_col = label_cell.column + 1
|
||||
merged = getattr(ws, "merged_cells", None)
|
||||
|
||||
@@ -678,7 +678,12 @@ def send_filled_sheet(
|
||||
flow=flow,
|
||||
chat_id=room,
|
||||
work_order_no=no,
|
||||
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
|
||||
facts=_facts_for_fill(
|
||||
facts,
|
||||
collab,
|
||||
business_line=line or "SEA",
|
||||
sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
),
|
||||
quote=quote,
|
||||
sales_id=str(
|
||||
(sess.sender_id if sess else "")
|
||||
@@ -724,6 +729,7 @@ def _facts_for_fill(
|
||||
facts: dict[str, str],
|
||||
collab: dict[str, str],
|
||||
business_line: str = "SEA",
|
||||
sales_name: str = "",
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
询价字段 + 本单协同项。中文键和填表英文码一起带上。
|
||||
@@ -744,6 +750,11 @@ def _facts_for_fill(
|
||||
if oem:
|
||||
merged["oil_elec_mag"] = oem
|
||||
merged["是否含油,电,磁"] = oem
|
||||
# 报价单业务员写中文名。企微账号留给主账去对员工表,不要盖掉已有姓名。
|
||||
name = (sales_name or "").strip()
|
||||
if name and any("\u4e00" <= ch <= "\u9fff" for ch in name):
|
||||
merged["sales_name"] = name
|
||||
merged["业务员"] = name
|
||||
return merged
|
||||
|
||||
|
||||
@@ -1056,7 +1067,12 @@ def _persist_quote_sheet(
|
||||
result = renderer(
|
||||
work_order_no=no,
|
||||
business_line=line,
|
||||
facts=_facts_for_fill(facts, collab, business_line=line or "SEA"),
|
||||
facts=_facts_for_fill(
|
||||
facts,
|
||||
collab,
|
||||
business_line=line or "SEA",
|
||||
sales_name=str(view.get("sales_name") or view.get("salesName") or ""),
|
||||
),
|
||||
quote=quote,
|
||||
format="xlsx",
|
||||
sales_wecom_id=str(
|
||||
|
||||
@@ -120,9 +120,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
if not sess and looks_like_confirm(text):
|
||||
reply(copy.SEA_CONFIRM_EXPIRED)
|
||||
return "confirm_expired"
|
||||
# 核对卡不再设时效。没有待确定会话时不当成「已过期」。
|
||||
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
@@ -159,7 +157,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
|
||||
|
||||
if not mode:
|
||||
reply(copy.ASK_TRANSPORT)
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -416,6 +414,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(
|
||||
@@ -443,12 +442,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
return "tms_tech"
|
||||
if not tms.get("has_price"):
|
||||
self._emit_tms_miss(reply, no)
|
||||
sess.phase = "tms_miss"
|
||||
@@ -883,7 +886,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
)
|
||||
|
||||
if sess.collab_ended or (sess.collab_chat_id or "").strip():
|
||||
reply(copy.HANDOFF_ALREADY_COLLAB)
|
||||
reply(copy.already_collab())
|
||||
return "handoff_already"
|
||||
display_no = ensure_virtual_no(sess) if not (sess.work_order_no or "").strip() else sess.work_order_no
|
||||
staff = self._match_handoff_staff(sess)
|
||||
|
||||
@@ -310,8 +310,85 @@ def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None:
|
||||
reply(copy.system_exception_fallback(kind, names=names))
|
||||
|
||||
|
||||
def _quote_has_usable_total(quote: dict[str, Any] | None) -> bool:
|
||||
"""报价包是否已有可用于出卡的总价。"""
|
||||
src = dict(quote or {})
|
||||
total = str(src.get("total") or src.get("salePrice") or src.get("total_amount") or "").strip()
|
||||
return bool(total and total != "-")
|
||||
|
||||
|
||||
def recover_tms_query_from_ledger(ledger: Any, *, work_order_no: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
查价超时/失败后回读主账:若已 persistHit 落成已报价+销售价,按查价成功续办。
|
||||
|
||||
产品口径:主账已经查到价,只是智能体等回包超时,不能标 TMS异常、不能给销售发接口异常,
|
||||
应直接推当前工单报价。无价才走真正的系统异常。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or ledger is None:
|
||||
return None
|
||||
getter = getattr(ledger, "get_for_agent", None)
|
||||
snap: Any = None
|
||||
if callable(getter):
|
||||
try:
|
||||
snap = getter(work_order_no=no, sender_id="")
|
||||
except TypeError:
|
||||
try:
|
||||
snap = getter(work_order_no=no)
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
if not isinstance(snap, dict) or not snap:
|
||||
getter2 = getattr(ledger, "get_ticket", None)
|
||||
if callable(getter2):
|
||||
try:
|
||||
snap = getter2(work_order_no=no)
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
if not isinstance(snap, dict) or not snap:
|
||||
return None
|
||||
status = str(snap.get("status") or "").strip()
|
||||
tms_hit = snap.get("tms_hit") in (1, "1", True, "true")
|
||||
sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip()
|
||||
quote = dict(snap.get("quote") or {})
|
||||
tms_quote = dict(snap.get("tms_quote") or snap.get("tmsQuote") or {})
|
||||
if not _quote_has_usable_total(quote) and _quote_has_usable_total(tms_quote):
|
||||
quote = dict(tms_quote)
|
||||
if not _quote_has_usable_total(quote) and sale and sale != "-":
|
||||
quote = dict(quote)
|
||||
quote["total"] = sale
|
||||
quote.setdefault("source", "TMS")
|
||||
quote.setdefault("source_label", "TMS标准报价")
|
||||
priced = status == "已报价" and (
|
||||
tms_hit or (sale and sale != "-") or _quote_has_usable_total(quote)
|
||||
)
|
||||
if not priced:
|
||||
return None
|
||||
if not _quote_has_usable_total(quote):
|
||||
return None
|
||||
logger.warning(
|
||||
"tms.query 超时/失败但主账已有价,按成功续办 wo=%s total=%s",
|
||||
no,
|
||||
quote.get("total"),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"has_price": True,
|
||||
"classification": "HAS_PRICE",
|
||||
"quote": quote,
|
||||
"recovered": True,
|
||||
"source": "LEDGER_AFTER_TIMEOUT",
|
||||
}
|
||||
|
||||
|
||||
def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
"""TMS 查价:最多 3 次,ok=true(含无价)即成功。"""
|
||||
"""
|
||||
TMS 查价:最多 3 次,ok=true(含无价)即成功。
|
||||
|
||||
三次仍失败时,再读主账:若查价过程中已落库有价(常见于智能体超时、主账已 persistHit),
|
||||
按成功回包返回,避免误标 TMS异常。
|
||||
"""
|
||||
getter = getattr(ledger, "query_tms", None)
|
||||
if not callable(getter):
|
||||
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
|
||||
@@ -319,7 +396,14 @@ def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
lambda: getter(**kwargs),
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
result = out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
if result.get("ok"):
|
||||
return result
|
||||
no = str(kwargs.get("work_order_no") or "").strip()
|
||||
recovered = recover_tms_query_from_ledger(ledger, work_order_no=no) if no else None
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
return result
|
||||
|
||||
|
||||
def confirm_tms_quote_if_tech(
|
||||
@@ -332,9 +416,20 @@ def confirm_tms_quote_if_tech(
|
||||
ticket_status: str = "询价中",
|
||||
facts: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。"""
|
||||
"""
|
||||
技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。
|
||||
|
||||
主账其实已有价时不再标异常(兜底:调用方应优先走 query_tms_resilient 恢复)。
|
||||
"""
|
||||
if not is_tms_tech_failure(tms):
|
||||
return False
|
||||
# 最后一道闸:主账其实已有价时禁止再打 TMS异常(正常应已在 query_tms_resilient 恢复)。
|
||||
if recover_tms_query_from_ledger(ledger, work_order_no=work_order_no) is not None:
|
||||
logger.warning(
|
||||
"confirm_tms_quote_if_tech 跳过标异常:主账已有价 wo=%s",
|
||||
work_order_no,
|
||||
)
|
||||
return False
|
||||
err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip()
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
|
||||
@@ -178,7 +178,8 @@ _GENERIC_KEYWORDS = frozenset(
|
||||
_LAND_KEYWORD_ALIASES = {
|
||||
"国内拼车": ("国内运输拼车", "拼车"),
|
||||
"国内零担": ("国内运输拼车", "国内长途/零担", "零担"),
|
||||
"国内整车": ("国内运输整车", "整车"),
|
||||
# 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字。
|
||||
"国内整车": ("国内运输整车",),
|
||||
"跨境整车": ("跨境整车",),
|
||||
"跨境集拼": ("跨境集拼",),
|
||||
"跨境零担": ("跨境集拼",),
|
||||
@@ -198,6 +199,19 @@ def _keyword_needles(kw: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _needle_in(text: str, needle: str) -> bool:
|
||||
"""
|
||||
陆运针:普通包含。单独「整车」不命中跨境/中港,避免国内整车模板抢走跨境单。
|
||||
"""
|
||||
hay = (text or "").lower()
|
||||
n = (needle or "").strip().lower()
|
||||
if not hay or not n or n not in hay:
|
||||
return False
|
||||
if n == "整车" and ("跨境" in hay or "中港" in hay):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
|
||||
"""
|
||||
运输类型上的命中优先,其次全文词长。
|
||||
@@ -212,11 +226,11 @@ def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
|
||||
if k in _GENERIC_KEYWORDS:
|
||||
continue
|
||||
needles = _keyword_needles(kw)
|
||||
if type_val and any(n in type_val for n in needles):
|
||||
if type_val and any(_needle_in(type_val, n) for n in needles):
|
||||
type_hits += 1
|
||||
length += len(k)
|
||||
any_hit = True
|
||||
elif any(n in haystack for n in needles):
|
||||
elif any(_needle_in(haystack, n) for n in needles):
|
||||
length += len(k)
|
||||
any_hit = True
|
||||
if not any_hit:
|
||||
|
||||
@@ -16,8 +16,6 @@ from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TTL_SEC = 24 * 3600
|
||||
|
||||
|
||||
class BookmarkStore(Protocol):
|
||||
"""单测可注入;生产走 Redis。"""
|
||||
@@ -72,10 +70,10 @@ def save_flow_bookmark(
|
||||
return
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
# 核对卡不设过期。确定前一直留着,离开核对或建单后由 delete 清掉。
|
||||
client.raw.set(
|
||||
client.key(FLOW_BOOKMARK, name, sid),
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
ex=_TTL_SEC,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("写询价书签失败 kind=%s sender=%s", name, sid)
|
||||
|
||||
@@ -16,8 +16,6 @@ from agent.redis_coord.runtime import get_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TTL_SEC = 24 * 3600
|
||||
|
||||
|
||||
def save_pending_land_confirm(
|
||||
sender_id: str,
|
||||
@@ -42,10 +40,10 @@ def save_pending_land_confirm(
|
||||
}
|
||||
try:
|
||||
client = get_redis_runtime().client
|
||||
# 核对卡不设过期。建单成功后由 clear 清掉。
|
||||
client.raw.set(
|
||||
client.key(PENDING_LAND_CONFIRM, sid),
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
ex=_TTL_SEC,
|
||||
)
|
||||
logger.info(
|
||||
"land.pending saved sender=%s phase=%s wo=%s keys=%s",
|
||||
@@ -59,7 +57,7 @@ def save_pending_land_confirm(
|
||||
|
||||
|
||||
def load_pending_land_confirm(sender_id: str) -> dict[str, Any]:
|
||||
"""取出待确定陆运字段。没有或过期返回空。"""
|
||||
"""取出待确定陆运字段。没有则空。"""
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid:
|
||||
return {}
|
||||
|
||||
@@ -504,12 +504,20 @@ def looks_like_confirm(text: str) -> bool:
|
||||
"""
|
||||
判断是否在表示确定查价。
|
||||
|
||||
「确定」「确认」「查价」「可以查了」及相近说法都算。
|
||||
同一句像在改始发/目的/体积/车型/「改成」则不当确定,避免改内容被当成查价。
|
||||
「确定」「确认」「查价」「可以查了」及相近短句才算。
|
||||
长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。
|
||||
同一句像在改始发/目的/体积/车型/「改成」则不当确定。
|
||||
纯函数,无副作用。
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
if any(x in raw for x in _CONFIRM_EXCLUDE):
|
||||
return False
|
||||
return any(p in raw for p in _CONFIRM_PHRASES)
|
||||
# 超过约 20 字一定是需求正文,不是「确定」短答。
|
||||
if len(raw) > 20:
|
||||
return False
|
||||
compact = re.sub(r"[\s。!?!?,,、]+", "", raw)
|
||||
if not compact:
|
||||
return False
|
||||
return any(p in compact for p in _CONFIRM_PHRASES)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""后台回复语:按调用编码填句,失败回退默认。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.bot_reply import fill, override_catalog, render
|
||||
|
||||
|
||||
class BotReplyRenderTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
override_catalog(None)
|
||||
|
||||
def test_fill_placeholder(self) -> None:
|
||||
self.assertEqual(fill("工单{{ticket_no}}。", {"ticket_no": "WO1"}), "工单WO1。")
|
||||
self.assertEqual(fill("工单{{ ticket_no }}。", {"ticket_no": "WO1"}), "工单WO1。")
|
||||
self.assertIn("{{lost_reason}}", fill("因{{lost_reason}}", {}))
|
||||
|
||||
def test_default_when_empty(self) -> None:
|
||||
override_catalog({})
|
||||
self.assertEqual(copy.first_chat(), copy.FIRST_CHAT)
|
||||
self.assertEqual(copy.ask_transport(), copy.ASK_TRANSPORT)
|
||||
self.assertEqual(copy.deal_ack("未成交", "WO1"), "已记录为「未成交」。请补充未成交原因。")
|
||||
|
||||
def test_override_first_chat(self) -> None:
|
||||
override_catalog({"inquiring.first_chat": "你好,测试问候。"})
|
||||
self.assertEqual(copy.first_chat(), "你好,测试问候。")
|
||||
self.assertEqual(copy.FIRST_CHAT, "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。")
|
||||
|
||||
def test_group_created_fills_names(self) -> None:
|
||||
override_catalog(
|
||||
{
|
||||
"inquiring.group_created": (
|
||||
"协同群已创建,群名与工单号一致:{{ticket_no}}。"
|
||||
"已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。"
|
||||
)
|
||||
}
|
||||
)
|
||||
text = copy.sea_group_created(
|
||||
work_order_no="WO202610060001",
|
||||
sales_name="张三",
|
||||
product_names=["李四"],
|
||||
)
|
||||
self.assertIn("WO202610060001", text)
|
||||
self.assertIn("销售张三", text)
|
||||
self.assertIn("产品李四", text)
|
||||
self.assertNotIn("销售销售", text)
|
||||
|
||||
def test_disabled_falls_back(self) -> None:
|
||||
override_catalog({"quoted.adopt_excel": ""})
|
||||
self.assertEqual(copy.adopt_excel_ack(), "已确认采用该方案,正在生成 Excel 最终报价单…")
|
||||
|
||||
def test_group_file_saved_uses_admin(self) -> None:
|
||||
override_catalog({"inquiring.group_file_saved": "附件已保存后台"})
|
||||
self.assertEqual(
|
||||
copy.quote_attachment_saved_app("WO202610060001", 2),
|
||||
"附件已保存后台",
|
||||
)
|
||||
override_catalog({})
|
||||
self.assertEqual(
|
||||
copy.quote_attachment_saved_app("WO202610060001", 2),
|
||||
"工单WO202610060001的2个附件已保存到后台工单",
|
||||
)
|
||||
|
||||
def test_negotiate_without_date_still_reads_admin(self) -> None:
|
||||
override_catalog(
|
||||
{
|
||||
"negotiating.recorded": (
|
||||
"已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。"
|
||||
)
|
||||
}
|
||||
)
|
||||
text = copy.deal_ack("协商中", "WO1", next_followup_at="")
|
||||
self.assertIn("第四个工作日", text)
|
||||
self.assertIn("9:00", text)
|
||||
|
||||
def test_group_add_fail_empty_names_reads_admin(self) -> None:
|
||||
override_catalog(
|
||||
{"sys.group_add_fail": "无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。"}
|
||||
)
|
||||
text = copy.system_exception_fallback(copy.TYPE_GROUP, names=[])
|
||||
self.assertEqual(text, "无法添加同事,请联系管理员检查企业微信配置。")
|
||||
named = copy.system_exception_fallback(copy.TYPE_GROUP, names=["张三"])
|
||||
self.assertIn("张三", named)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -220,9 +220,16 @@ class LandOptionsTest(unittest.TestCase):
|
||||
|
||||
def test_confirm_intent(self):
|
||||
self.assertTrue(looks_like_confirm("确认"))
|
||||
self.assertTrue(looks_like_confirm("确定"))
|
||||
self.assertTrue(looks_like_confirm("可以查了"))
|
||||
self.assertTrue(looks_like_confirm("查价"))
|
||||
self.assertFalse(looks_like_confirm("广州到深圳衣服"))
|
||||
# 询价正文里的「确认」不是点核对卡。
|
||||
self.assertFalse(
|
||||
looks_like_confirm(
|
||||
"服务器、不带电,预计7月初货好,因货值较高 派送需确认安全运输方案,比如押运。"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -143,6 +143,61 @@ class QuoteTemplateMatchTests(unittest.TestCase):
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "跨境整车")
|
||||
|
||||
def test_land_admin_domestic_ftl_keyword_does_not_steal_cross(self) -> None:
|
||||
"""后台「国内整车」不得靠「整车」二字抢走跨境整车(WO202610030032)。"""
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
{
|
||||
"templateId": "dom",
|
||||
"name": "国内陆运整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["国内整车"],
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"templateId": "xb",
|
||||
"name": "跨境整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["东南亚"],
|
||||
"isDefault": False,
|
||||
},
|
||||
],
|
||||
business_line="LAND",
|
||||
facts={
|
||||
"运输类型": "跨境整车",
|
||||
"线路类别": "东南亚",
|
||||
"运输分类": "普件(东南亚、中亚、中欧涉及国内+国外段)",
|
||||
"起运港": "上海",
|
||||
"目的港": "春疆货场",
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "跨境整车报价单")
|
||||
|
||||
def test_land_admin_domestic_ftl_still_hits_domestic_type(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
{
|
||||
"templateId": "dom",
|
||||
"name": "国内陆运整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["国内整车"],
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"templateId": "xb",
|
||||
"name": "跨境整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["东南亚"],
|
||||
"isDefault": False,
|
||||
},
|
||||
],
|
||||
business_line="LAND",
|
||||
facts={"运输类型": "国内运输整车", "线路类别": "国内长途/零担"},
|
||||
)
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "国内陆运整车报价单")
|
||||
|
||||
def test_land_falls_back_to_default(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
|
||||
@@ -22,8 +22,11 @@ from agent.policy.system_exception import (
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
confirm_system_exception,
|
||||
confirm_tms_quote_if_tech,
|
||||
extract_work_order_nos,
|
||||
is_tms_tech_failure,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
should_mark_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
@@ -196,6 +199,89 @@ class TestSystemExceptionRules(unittest.TestCase):
|
||||
self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE")
|
||||
|
||||
|
||||
class TestTmsTimeoutRecoverPriced(unittest.TestCase):
|
||||
"""查到价但智能体超时:主账已有价则按成功续办,不标 TMS异常。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
|
||||
)
|
||||
self.wo = str(created.get("work_order_no") or "")
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=self.wo,
|
||||
quote={
|
||||
"total": "CNY 5001.00",
|
||||
"currency": "CNY",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
},
|
||||
to_status="已报价",
|
||||
)
|
||||
|
||||
def test_recover_from_ledger_when_already_quoted(self) -> None:
|
||||
got = recover_tms_query_from_ledger(self.ledger, work_order_no=self.wo)
|
||||
self.assertIsNotNone(got)
|
||||
assert got is not None
|
||||
self.assertTrue(got.get("ok"))
|
||||
self.assertTrue(got.get("has_price"))
|
||||
self.assertTrue(got.get("recovered"))
|
||||
self.assertIn("5001", str((got.get("quote") or {}).get("total") or ""))
|
||||
|
||||
def test_query_resilient_recovers_after_tech_fail(self) -> None:
|
||||
self.ledger.query_tms = ( # type: ignore[method-assign]
|
||||
lambda **_kwargs: {
|
||||
"ok": False,
|
||||
"classification": "TMS_TECH_FAILURE",
|
||||
"has_price": False,
|
||||
"quote": {},
|
||||
}
|
||||
)
|
||||
out = query_tms_resilient(
|
||||
self.ledger, work_order_no=self.wo, facts={"起运港": "南京", "目的港": "吉隆坡"}
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertTrue(out.get("has_price"))
|
||||
self.assertTrue(out.get("recovered"))
|
||||
self.assertEqual(out.get("classification"), "HAS_PRICE")
|
||||
|
||||
def test_confirm_skips_mark_when_ledger_priced(self) -> None:
|
||||
replies: list[str] = []
|
||||
marked = confirm_tms_quote_if_tech(
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
work_order_no=self.wo,
|
||||
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
|
||||
sender_id="sales1",
|
||||
ticket_status="询价中",
|
||||
)
|
||||
self.assertFalse(marked)
|
||||
self.assertEqual(replies, [])
|
||||
snap = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertFalse(str(snap.get("system_exception") or "").strip())
|
||||
|
||||
def test_true_tech_fail_still_marks_when_no_price(self) -> None:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales2",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
|
||||
)
|
||||
wo2 = str(created.get("work_order_no") or "")
|
||||
replies: list[str] = []
|
||||
marked = confirm_tms_quote_if_tech(
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
work_order_no=wo2,
|
||||
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
|
||||
sender_id="sales2",
|
||||
ticket_status="询价中",
|
||||
)
|
||||
self.assertTrue(marked)
|
||||
self.assertTrue(any("TMS接口异常" in x for x in replies))
|
||||
|
||||
|
||||
class TestConfirmSystemException(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
|
||||
+16
@@ -44,12 +44,28 @@ public class InquiryAgentController {
|
||||
@Autowired
|
||||
private org.jeecg.modules.inquiry.tms.InquiryTmsCabinService cabinService;
|
||||
|
||||
@Autowired
|
||||
private org.jeecg.modules.inquiry.service.IInquiryBotReplyService botReplyService;
|
||||
|
||||
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
|
||||
private String agentToken;
|
||||
|
||||
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
|
||||
private String bizToken;
|
||||
|
||||
/**
|
||||
* 智能体只读回复语。停用或不在列表里的编码由智能体回退默认句。
|
||||
* 鉴权同其它 /inquiry/agent/** ,禁止后台菜单权限码。
|
||||
*/
|
||||
@ApiOperation("智能体-已启用回复语")
|
||||
@GetMapping("/botReply/listEnabled")
|
||||
public Result<java.util.List<java.util.Map<String, Object>>> botReplyListEnabled(HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(botReplyService.listEnabledForAgent());
|
||||
}
|
||||
|
||||
@ApiOperation("智能体-回写模板 AI 映射结果")
|
||||
@PutMapping("/quoteTemplate/parseResult")
|
||||
public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body,
|
||||
|
||||
+12
-13
@@ -16,12 +16,12 @@ import org.jeecg.modules.inquiry.service.IInquiryBotReplyService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 机器人回复语 API。
|
||||
* 支持按分类拉取全部条目(后台九分类页)与分页查询。
|
||||
* 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。
|
||||
*/
|
||||
@Slf4j
|
||||
@Api(tags = "询价-机器人回复语")
|
||||
@@ -50,13 +50,20 @@ public class InquiryBotReplyController extends JeecgController<InquiryBotReply,
|
||||
return Result.OK(botReplyService.page(new Page<>(pageNo, pageSize), qw));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量列表,供分类页一次加载。
|
||||
* 必须显式大分页:Jeecg 分页拦截器可能把无 Page 的 list() 收成前 10 条,后台会看起来像没显示全。
|
||||
*/
|
||||
@ApiOperation("全量列表(按分类排序,供分类页一次加载)")
|
||||
@RequiresPermissions("inquiry:reply:list")
|
||||
@GetMapping("/listAll")
|
||||
public Result<List<InquiryBotReply>> listAll() {
|
||||
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
|
||||
qw.orderByAsc("category_code").orderByAsc("sort_no");
|
||||
return Result.OK(botReplyService.list(qw));
|
||||
qw.orderByAsc("category_code").orderByAsc("sort_no").orderByAsc("id");
|
||||
Page<InquiryBotReply> page = new Page<>(1, 2000);
|
||||
page.setSearchCount(false);
|
||||
page.setMaxLimit(2000L);
|
||||
return Result.OK(botReplyService.page(page, qw).getRecords());
|
||||
}
|
||||
|
||||
@AutoLog(value = "回复语-新增")
|
||||
@@ -64,14 +71,7 @@ public class InquiryBotReplyController extends JeecgController<InquiryBotReply,
|
||||
@PostMapping("/add")
|
||||
public Result<String> add(@RequestBody InquiryBotReply body) {
|
||||
validate(body);
|
||||
body.setCreateTime(new Date());
|
||||
if (body.getEnabled() == null) {
|
||||
body.setEnabled(1);
|
||||
}
|
||||
if (body.getSortNo() == null) {
|
||||
body.setSortNo(0);
|
||||
}
|
||||
botReplyService.save(body);
|
||||
botReplyService.createReply(body);
|
||||
return Result.OK("添加成功");
|
||||
}
|
||||
|
||||
@@ -83,8 +83,7 @@ public class InquiryBotReplyController extends JeecgController<InquiryBotReply,
|
||||
return Result.error("id 不能为空");
|
||||
}
|
||||
validate(body);
|
||||
body.setUpdateTime(new Date());
|
||||
botReplyService.updateById(body);
|
||||
botReplyService.updateReply(body);
|
||||
return Result.OK("更新成功");
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -16,7 +16,8 @@ import java.util.Date;
|
||||
|
||||
/**
|
||||
* 机器人回复语,对应 inq_bot_reply。
|
||||
* 按分类维护触发场景与正文;智能体出站文案可后续读取本表(本期后台 CRUD)。
|
||||
* 按分类维护触发场景与正文;列表以现网整理表为准(migration_inq_bot_reply_replace_v1)。
|
||||
* 智能体出站文案可后续按场景读取本表。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@@ -36,6 +37,13 @@ public class InquiryBotReply implements Serializable {
|
||||
@ApiModelProperty("分类标题")
|
||||
private String categoryTitle;
|
||||
|
||||
/**
|
||||
* 智能体日后按此码取文案,与触发说明解耦。
|
||||
* 系统预置项只读;空表示尚未接入智能体。
|
||||
*/
|
||||
@ApiModelProperty("调用编码")
|
||||
private String replyCode;
|
||||
|
||||
@ApiModelProperty("触发场景")
|
||||
private String triggerScene;
|
||||
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable {
|
||||
private List<InquiryPriceLine> saleLines;
|
||||
|
||||
/**
|
||||
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
|
||||
* 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@ApiModelProperty("利润点逐行(展示)")
|
||||
|
||||
+13
@@ -2,8 +2,21 @@ package org.jeecg.modules.inquiry.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
|
||||
/**
|
||||
* 询价员工岗位。报价单业务员要中文名时,员工表没有姓名再读后台账号 realname。
|
||||
*/
|
||||
@Mapper
|
||||
public interface InquiryStaffRoleMapper extends BaseMapper<InquiryStaffRole> {
|
||||
|
||||
/**
|
||||
* 后台账号中文名。工号 work_no 与企微 userid 相同。
|
||||
* 只读,查不到返回空。
|
||||
*/
|
||||
@Select("SELECT realname FROM sys_user WHERE del_flag = 0 AND status = 1 AND work_no = #{wecomId} "
|
||||
+ "AND realname IS NOT NULL AND TRIM(realname) <> '' LIMIT 1")
|
||||
String findRealnameByWorkNo(@Param("wecomId") String wecomId);
|
||||
}
|
||||
|
||||
+17
@@ -5,10 +5,27 @@ import org.jeecg.modules.inquiry.entity.InquiryBotReply;
|
||||
|
||||
/**
|
||||
* 机器人回复语服务。
|
||||
* 预置调用编码不可改;编码全局唯一(空值允许多条未接入项)。
|
||||
*/
|
||||
public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
|
||||
|
||||
void enable(String id);
|
||||
|
||||
void disable(String id);
|
||||
|
||||
/**
|
||||
* 新增。校验调用编码格式与唯一;空白编码存 null。
|
||||
*/
|
||||
void createReply(InquiryBotReply body);
|
||||
|
||||
/**
|
||||
* 编辑正文/启停。已有调用编码不可被请求覆盖。
|
||||
*/
|
||||
void updateReply(InquiryBotReply body);
|
||||
|
||||
/**
|
||||
* 智能体只读:已启用且已有调用编码的正文。
|
||||
* 停用项不返回,智能体回退默认句。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> listEnabledForAgent();
|
||||
}
|
||||
|
||||
+101
@@ -1,21 +1,34 @@
|
||||
package org.jeecg.modules.inquiry.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryBotReply;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryBotReplyMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryBotReplyService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 回复语启停与 CRUD。
|
||||
* 副作用:写 inq_bot_reply;调用编码唯一。不打 TMS、不唤醒智能体。
|
||||
*/
|
||||
@Service
|
||||
public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMapper, InquiryBotReply>
|
||||
implements IInquiryBotReplyService {
|
||||
|
||||
/** 调用编码:小写字母开头,点分段,供智能体日后按码查找。 */
|
||||
private static final Pattern REPLY_CODE = Pattern.compile("^[a-z][a-z0-9_.]{1,62}$");
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void enable(String id) {
|
||||
@@ -28,6 +41,39 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
|
||||
setEnabled(id, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void createReply(InquiryBotReply body) {
|
||||
normalizeCode(body);
|
||||
assertCodeUnique(body.getReplyCode(), null);
|
||||
if (body.getEnabled() == null) {
|
||||
body.setEnabled(1);
|
||||
}
|
||||
if (body.getSortNo() == null) {
|
||||
body.setSortNo(0);
|
||||
}
|
||||
body.setCreateTime(new Date());
|
||||
save(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateReply(InquiryBotReply body) {
|
||||
InquiryBotReply old = getById(body.getId());
|
||||
if (old == null) {
|
||||
throw new IllegalArgumentException("回复语不存在: " + body.getId());
|
||||
}
|
||||
// 已落库的系统编码不能改,避免智能体日后对不上。
|
||||
if (StringUtils.isNotBlank(old.getReplyCode())) {
|
||||
body.setReplyCode(old.getReplyCode());
|
||||
} else {
|
||||
normalizeCode(body);
|
||||
assertCodeUnique(body.getReplyCode(), old.getId());
|
||||
}
|
||||
body.setUpdateTime(new Date());
|
||||
updateById(body);
|
||||
}
|
||||
|
||||
private void setEnabled(String id, int enabled) {
|
||||
InquiryBotReply row = getById(id);
|
||||
if (row == null) {
|
||||
@@ -37,4 +83,59 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
|
||||
row.setUpdateTime(new Date());
|
||||
updateById(row);
|
||||
}
|
||||
|
||||
private void normalizeCode(InquiryBotReply body) {
|
||||
String code = StringUtils.trimToNull(body.getReplyCode());
|
||||
if (code == null) {
|
||||
body.setReplyCode(null);
|
||||
return;
|
||||
}
|
||||
if (!REPLY_CODE.matcher(code).matches()) {
|
||||
throw new IllegalArgumentException("调用编码格式不正确,须为小写字母开头的点分段码");
|
||||
}
|
||||
body.setReplyCode(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> listEnabledForAgent() {
|
||||
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
|
||||
qw.eq("enabled", 1);
|
||||
qw.isNotNull("reply_code");
|
||||
qw.ne("reply_code", "");
|
||||
qw.orderByAsc("sort_no").orderByAsc("id");
|
||||
Page<InquiryBotReply> page = new Page<>(1, 2000);
|
||||
page.setSearchCount(false);
|
||||
page.setMaxLimit(2000L);
|
||||
List<InquiryBotReply> rows = page(page, qw).getRecords();
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryBotReply row : rows) {
|
||||
if (row == null || StringUtils.isBlank(row.getReplyCode())) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("replyCode", row.getReplyCode());
|
||||
item.put("replyText", row.getReplyText());
|
||||
item.put("triggerScene", row.getTriggerScene());
|
||||
item.put("categoryCode", row.getCategoryCode());
|
||||
out.add(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void assertCodeUnique(String replyCode, String excludeId) {
|
||||
if (StringUtils.isBlank(replyCode)) {
|
||||
return;
|
||||
}
|
||||
QueryWrapper<InquiryBotReply> qw = new QueryWrapper<>();
|
||||
qw.eq("reply_code", replyCode);
|
||||
if (StringUtils.isNotBlank(excludeId)) {
|
||||
qw.ne("id", excludeId);
|
||||
}
|
||||
if (count(qw) > 0) {
|
||||
throw new IllegalArgumentException("调用编码已存在: " + replyCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+259
-25
@@ -5,10 +5,14 @@ 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.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -22,6 +26,7 @@ import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
@@ -34,8 +39,11 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* 看板实时聚合实现(无快照依赖)。
|
||||
* <p>
|
||||
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
||||
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
|
||||
* 九指标与原型看板一致(5+4 栅格):询价总量、报价工单、标准报价命中率、
|
||||
* 平均报价销售额、手工工单占比、待处理工单、报价成功率、报价有效率、
|
||||
* 内部报价平均响应。问号 tip 只写计算公式,禁止「本期无协同明细 / N单无勾选…」占位。
|
||||
* 内部报价平均响应 = AVG(首次 PRODUCT 报价时间 − 分发时间),
|
||||
* 分发=bind_collab_group/activate_air_group。
|
||||
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
|
||||
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
|
||||
* 线程:只读查询,无写锁。
|
||||
@@ -46,6 +54,9 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final int COST_ROUTE_LIMIT = 16;
|
||||
/** 协同「分发」:海运/陆运建群、空运群激活。 */
|
||||
private static final List<String> COLLAB_DISPATCH_ACTIONS = Arrays.asList(
|
||||
"bind_collab_group", "activate_air_group");
|
||||
private static final String[] COST_COLORS = {
|
||||
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
|
||||
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
|
||||
@@ -62,6 +73,10 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
@Autowired
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
@Autowired
|
||||
private InquiryTicketStatusLogMapper statusLogMapper;
|
||||
@Autowired
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> metrics(String range) {
|
||||
@@ -94,28 +109,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
long pending = total - deal - lost - closed;
|
||||
long tmsHit = tickets.stream().filter(t -> t.getTmsHit() != null && t.getTmsHit() == 1).count();
|
||||
long manual = tickets.stream().filter(t -> t.getManualQuote() != null && t.getManualQuote() == 1).count();
|
||||
long hasEventExc = tickets.stream().filter(t -> hasExc(t.getEventException())).count();
|
||||
long hasSysExc = tickets.stream().filter(t -> hasExc(t.getSystemException())).count();
|
||||
long abnormalTotal = hasEventExc + hasSysExc;
|
||||
// 简化:已关闭且曾有异常视为异常处理完成
|
||||
long abnormalDone = tickets.stream()
|
||||
.filter(t -> TicketStatuses.CLOSED.equals(t.getStatus()))
|
||||
.filter(t -> hasExc(t.getEventException()) || hasExc(t.getSystemException())
|
||||
|| (t.getCloseReason() != null && t.getCloseReason().contains("异常")))
|
||||
.count();
|
||||
|
||||
String avgResp = avgCollabResponse(tickets);
|
||||
String avgSale = avgHighlightedSale(tickets);
|
||||
|
||||
List<Map<String, Object>> metricCards = new ArrayList<>();
|
||||
metricCards.add(card("inquiry", "询价总数", "周期内询价工单总数", String.valueOf(total), false));
|
||||
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
|
||||
// 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应
|
||||
metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false));
|
||||
metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)",
|
||||
quoted + " 单", false));
|
||||
metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false));
|
||||
metricCards.add(card("abnormal", "异常处理完成率", "已关闭异常工单数 ÷ 异常工单总数 × 100%",
|
||||
abnormalTotal == 0 ? "100%" : pct(abnormalDone, Math.max(abnormalTotal, 1)), false));
|
||||
metricCards.add(card("manual", "手工报价占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
|
||||
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
|
||||
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||
metricCards.add(card("avgSale", "平均报价销售额", "AVG(已勾选报价的销售价)", avgSale, false));
|
||||
metricCards.add(card("manual", "手工工单占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
|
||||
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
|
||||
metricCards.add(card("dealRate", "报价成功率", "已成交数 ÷ 已报价数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||
long feedbackNumer = deal + lost;
|
||||
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
|
||||
metricCards.add(card("feedback", "报价有效率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
@@ -416,10 +427,6 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return time.toInstant().atZone(ZONE).toLocalDate();
|
||||
}
|
||||
|
||||
private static boolean hasExc(String v) {
|
||||
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
|
||||
}
|
||||
|
||||
private static String pct(long numer, long denom) {
|
||||
if (denom <= 0) {
|
||||
return "0.0%";
|
||||
@@ -428,6 +435,217 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return p.toPlainString() + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* 平均报价销售额:勾选报价(highlighted=1)的销售价平均。
|
||||
* <p>
|
||||
* 金额优先报价 priceText,没有再用工单 salePrice。没有可解析样本返回「-」,
|
||||
* 禁止写成 0.0%(这不是比率)。问号文案由调用方固定为公式,这里不算 tip。
|
||||
*/
|
||||
private String avgHighlightedSale(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
List<String> nos = tickets.stream()
|
||||
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
|
||||
.map(InquiryTicket::getWorkOrderNo)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (nos.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, String> ticketSale = new HashMap<>();
|
||||
for (InquiryTicket t : tickets) {
|
||||
if (t == null || StringUtils.isBlank(t.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(t.getSalePrice())) {
|
||||
ticketSale.put(t.getWorkOrderNo().trim(), t.getSalePrice());
|
||||
}
|
||||
}
|
||||
Map<String, String> saleByWo = new HashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryQuote>()
|
||||
.in(InquiryQuote::getWorkOrderNo, nos)
|
||||
.eq(InquiryQuote::getHighlighted, 1));
|
||||
if (quotes != null) {
|
||||
for (InquiryQuote q : quotes) {
|
||||
if (q == null || StringUtils.isBlank(q.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
String wo = q.getWorkOrderNo().trim();
|
||||
String amt = firstNonBlank(q.getPriceText(), ticketSale.get(wo));
|
||||
if (StringUtils.isNotBlank(amt)) {
|
||||
saleByWo.put(wo, amt);
|
||||
}
|
||||
}
|
||||
}
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
int n = 0;
|
||||
for (String amt : saleByWo.values()) {
|
||||
BigDecimal money = parseMoney(amt);
|
||||
if (money == null) {
|
||||
continue;
|
||||
}
|
||||
sum = sum.add(money);
|
||||
n++;
|
||||
}
|
||||
if (n <= 0) {
|
||||
return "-";
|
||||
}
|
||||
return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
/** 平均销售额展示:整数不留小数,否则最多两位。 */
|
||||
static String formatAvgSale(BigDecimal avg) {
|
||||
if (avg == null) {
|
||||
return "-";
|
||||
}
|
||||
BigDecimal stripped = avg.stripTrailingZeros();
|
||||
if (stripped.scale() <= 0) {
|
||||
return stripped.toPlainString();
|
||||
}
|
||||
return avg.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (StringUtils.isNotBlank(a) && !"-".equals(a.trim())) {
|
||||
return a.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(b) && !"-".equals(b.trim())) {
|
||||
return b.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部协同平均响应时长。
|
||||
* <p>
|
||||
* 分发时间:状态日志最早的 bind_collab_group / activate_air_group;
|
||||
* 首次有效回复:分发之后最早的 PRODUCT 报价(航线/产品手工价,不含销售调价与 TMS)。
|
||||
* 无样本返回「-」。
|
||||
*/
|
||||
private String avgCollabResponse(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
List<String> nos = tickets.stream()
|
||||
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
|
||||
.map(InquiryTicket::getWorkOrderNo)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (nos.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, Date> dispatchByWo = loadEarliestDispatch(nos);
|
||||
if (dispatchByWo.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, List<Date>> productTimesByWo = loadProductQuoteTimes(
|
||||
new ArrayList<>(dispatchByWo.keySet()));
|
||||
long sumMs = 0L;
|
||||
int samples = 0;
|
||||
for (Map.Entry<String, Date> e : dispatchByWo.entrySet()) {
|
||||
Date dispatch = e.getValue();
|
||||
if (dispatch == null) {
|
||||
continue;
|
||||
}
|
||||
Date reply = firstReplyAfter(productTimesByWo.get(e.getKey()), dispatch);
|
||||
if (reply == null) {
|
||||
continue;
|
||||
}
|
||||
long ms = reply.getTime() - dispatch.getTime();
|
||||
if (ms < 0) {
|
||||
continue;
|
||||
}
|
||||
sumMs += ms;
|
||||
samples++;
|
||||
}
|
||||
if (samples <= 0) {
|
||||
return "-";
|
||||
}
|
||||
return formatDurationMs(sumMs / samples);
|
||||
}
|
||||
|
||||
private Map<String, Date> loadEarliestDispatch(List<String> workOrderNos) {
|
||||
Map<String, Date> out = new HashMap<>();
|
||||
if (workOrderNos == null || workOrderNos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<InquiryTicketStatusLog> logs = statusLogMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryTicketStatusLog>()
|
||||
.in(InquiryTicketStatusLog::getWorkOrderNo, workOrderNos)
|
||||
.in(InquiryTicketStatusLog::getAction, COLLAB_DISPATCH_ACTIONS)
|
||||
.orderByAsc(InquiryTicketStatusLog::getCreateTime));
|
||||
if (logs == null) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicketStatusLog log : logs) {
|
||||
if (log == null || log.getCreateTime() == null || StringUtils.isBlank(log.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
out.putIfAbsent(log.getWorkOrderNo().trim(), log.getCreateTime());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, List<Date>> loadProductQuoteTimes(List<String> workOrderNos) {
|
||||
Map<String, List<Date>> out = new HashMap<>();
|
||||
if (workOrderNos == null || workOrderNos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryQuote>()
|
||||
.in(InquiryQuote::getWorkOrderNo, workOrderNos)
|
||||
.eq(InquiryQuote::getSource, "PRODUCT")
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
if (quotes == null) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryQuote q : quotes) {
|
||||
if (q == null || q.getCreateTime() == null || StringUtils.isBlank(q.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
out.computeIfAbsent(q.getWorkOrderNo().trim(), k -> new ArrayList<>()).add(q.getCreateTime());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 分发之后的首条 PRODUCT 报价时间;没有则 null。 */
|
||||
static Date firstReplyAfter(List<Date> times, Date dispatch) {
|
||||
if (times == null || times.isEmpty() || dispatch == null) {
|
||||
return null;
|
||||
}
|
||||
long from = dispatch.getTime();
|
||||
for (Date t : times) {
|
||||
if (t != null && t.getTime() >= from) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 毫秒 → 展示串:不足 1 分钟用秒;否则 XmYs / XhYm。
|
||||
* 单测可直接调。
|
||||
*/
|
||||
static String formatDurationMs(long ms) {
|
||||
if (ms < 0) {
|
||||
return "-";
|
||||
}
|
||||
long totalSec = ms / 1000L;
|
||||
if (totalSec < 60) {
|
||||
return totalSec + "s";
|
||||
}
|
||||
long totalMin = totalSec / 60L;
|
||||
long sec = totalSec % 60L;
|
||||
if (totalMin < 60) {
|
||||
return totalMin + "m" + sec + "s";
|
||||
}
|
||||
long hours = totalMin / 60L;
|
||||
long min = totalMin % 60L;
|
||||
return hours + "h" + min + "m";
|
||||
}
|
||||
|
||||
private static Map<String, Object> card(String key, String label, String tip, String value, boolean isTime) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("key", key);
|
||||
@@ -468,7 +686,7 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return;
|
||||
}
|
||||
String username = StringUtils.trimToEmpty(login.getUsername());
|
||||
String wecom = StringUtils.trimToEmpty(login.getWorkNo());
|
||||
String wecom = dashboardWecomId(login);
|
||||
qw.and(w -> {
|
||||
boolean any = false;
|
||||
if (StringUtils.isNotBlank(wecom)) {
|
||||
@@ -516,4 +734,20 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板「只看自己」用的企微工号。
|
||||
* 不用直接调 getWorkNo:测服打包时基座 LoginUser 可能还没有这个方法。
|
||||
*/
|
||||
private static String dashboardWecomId(LoginUser login) {
|
||||
if (login == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Object v = login.getClass().getMethod("getWorkNo").invoke(login);
|
||||
return v == null ? "" : StringUtils.trimToEmpty(String.valueOf(v));
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+141
-13
@@ -3,6 +3,7 @@ package org.jeecg.modules.inquiry.service.impl;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
@@ -13,8 +14,13 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
|
||||
@@ -65,6 +71,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
@Autowired
|
||||
private InquiryQuoteTemplateVersionMapper versionMapper;
|
||||
|
||||
/** 出单时把企微账号换成员工管理里的姓名。测试构造服务时可以没有。 */
|
||||
@Autowired(required = false)
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
|
||||
/** 出单时补工单上的客户名称。测试构造服务时可以没有。 */
|
||||
@Autowired(required = false)
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
|
||||
@Autowired
|
||||
private QuoteTemplateObjectStore objectStore;
|
||||
|
||||
@@ -646,13 +660,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 查找已缓存预览图。
|
||||
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
|
||||
* v5:不再经 POI 改打印属性,避免主题蓝底/合并条带被拆格。
|
||||
*/
|
||||
private Path findCachedSnapshot(String versionId) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v5.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v5.pdf");
|
||||
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
|
||||
return cachedPng;
|
||||
}
|
||||
@@ -665,8 +679,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v5.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
|
||||
Path target = rendered.isImage() ? cachedPng : cachedPdf;
|
||||
@@ -756,7 +770,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage());
|
||||
return fail;
|
||||
}
|
||||
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId, businessLine);
|
||||
JSONObject fillFacts = buildFillFacts(
|
||||
workOrderNo, enrichQuotePartyFacts(workOrderNo, facts, salesWecomId), quote, salesWecomId, businessLine);
|
||||
String safeNo = StringUtils.defaultIfBlank(workOrderNo, "WO").replaceAll("[^A-Za-z0-9_-]", "");
|
||||
Path targetDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "render");
|
||||
Path target = targetDir.resolve(safeNo + "-" + System.currentTimeMillis() + ".xlsx");
|
||||
@@ -791,6 +806,108 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return buildFillFacts(workOrderNo, facts, quote, salesWecomId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单前补业务员姓名和客户名称。
|
||||
* <p>
|
||||
* 业务员:员工管理姓名优先,其次工单上已有的中文名。企微 userid 不写进报价单。
|
||||
* 客户名称:询价事实或工单客户。没填就空着,不用业务员账号去占。
|
||||
* 副作用:只读员工表和工单,不改库。
|
||||
*/
|
||||
private Map<String, Object> enrichQuotePartyFacts(String workOrderNo, Map<String, Object> facts,
|
||||
String salesWecomId) {
|
||||
Map<String, Object> src = facts == null ? new LinkedHashMap<>() : new LinkedHashMap<>(facts);
|
||||
InquiryTicket ticket = findTicket(workOrderNo);
|
||||
String wecomId = StringUtils.defaultIfBlank(salesWecomId, ticket == null ? "" : ticket.getSalesWecomId());
|
||||
String staffName = lookupStaffName(wecomId);
|
||||
String fromTicket = chineseName(ticket == null ? "" : ticket.getSalesName());
|
||||
String fromFacts = chineseName(first(src, "sales_name", "业务员", "salesName"));
|
||||
String fromJson = chineseName(factFromTicketJson(ticket, "业务员", "sales_name", "salesName"));
|
||||
String named = StringUtils.defaultIfBlank(staffName, StringUtils.defaultIfBlank(fromTicket,
|
||||
StringUtils.defaultIfBlank(fromFacts, fromJson)));
|
||||
if (StringUtils.isNotBlank(named)) {
|
||||
src.put("sales_name", named);
|
||||
src.put("业务员", named);
|
||||
} else {
|
||||
src.put("sales_name", "");
|
||||
src.put("业务员", "");
|
||||
}
|
||||
String customer = chineseName(first(src, "customer_name", "客户名称", "customerName"));
|
||||
if (StringUtils.isBlank(customer) && ticket != null) {
|
||||
customer = chineseName(ticket.getCustomerName());
|
||||
}
|
||||
if (StringUtils.isBlank(customer)) {
|
||||
customer = chineseName(factFromTicketJson(ticket, "客户名称", "customer_name", "customerName"));
|
||||
}
|
||||
if (customer.equals(named) || customer.equals(StringUtils.trimToEmpty(wecomId))) {
|
||||
customer = "";
|
||||
}
|
||||
src.put("customer_name", customer);
|
||||
src.put("客户名称", customer);
|
||||
return src;
|
||||
}
|
||||
|
||||
/** 按工单号读主单。没有表或没有单号时返回空,出单仍继续。 */
|
||||
private InquiryTicket findTicket(String workOrderNo) {
|
||||
if (ticketMapper == null || StringUtils.isBlank(workOrderNo)) {
|
||||
return null;
|
||||
}
|
||||
return ticketMapper.selectOne(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo.trim())
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 员工中文名。先员工管理,没有再读后台账号的真实姓名。
|
||||
* 企微账号、纯英文拼音不算姓名。查不到返回空,报价单不回退成账号。
|
||||
*/
|
||||
private String lookupStaffName(String salesWecomId) {
|
||||
if (staffRoleMapper == null || StringUtils.isBlank(salesWecomId)) {
|
||||
return "";
|
||||
}
|
||||
String uid = salesWecomId.trim();
|
||||
List<InquiryStaffRole> rows = staffRoleMapper.selectList(new LambdaQueryWrapper<InquiryStaffRole>()
|
||||
.eq(InquiryStaffRole::getWecomId, uid)
|
||||
.eq(InquiryStaffRole::getStatus, "active"));
|
||||
if (rows != null) {
|
||||
for (InquiryStaffRole row : rows) {
|
||||
String name = chineseName(row == null ? "" : row.getName());
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return chineseName(staffRoleMapper.findRealnameByWorkNo(uid));
|
||||
}
|
||||
|
||||
/** 工单事实里的中文名。factsJson 不是合法 JSON 时当没有。 */
|
||||
private static String factFromTicketJson(InquiryTicket ticket, String... keys) {
|
||||
if (ticket == null || StringUtils.isBlank(ticket.getFactsJson())) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
JSONObject obj = JSON.parseObject(ticket.getFactsJson());
|
||||
return obj == null ? "" : first(obj, keys);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 报价单只收中文姓名。企微账号和纯英文拼音返回空。 */
|
||||
private static String chineseName(String raw) {
|
||||
String text = StringUtils.trimToEmpty(raw);
|
||||
if (StringUtils.isBlank(text) || InquiryTicketDisplaySupport.looksLikeWecomUserId(text)) {
|
||||
return "";
|
||||
}
|
||||
boolean han = false;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN) {
|
||||
han = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return han ? text : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单填表投影。空运不写时效:业务确认空运没有时效字段。
|
||||
*
|
||||
@@ -905,7 +1022,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
out.put("currency", currency);
|
||||
}
|
||||
out.put("sales_name", StringUtils.defaultString(salesWecomId));
|
||||
// 报价单「业务员」「客户名称」只写中文。企微账号 LinMianXun 不回退到格子里。
|
||||
String named = chineseName(first(src, "sales_name", "业务员", "salesName"));
|
||||
out.put("sales_name", named);
|
||||
String customer = chineseName(first(src, "customer_name", "客户名称", "customerName"));
|
||||
if (customer.equals(named) || customer.equals(StringUtils.trimToEmpty(salesWecomId))) {
|
||||
customer = "";
|
||||
}
|
||||
out.put("customer_name", customer);
|
||||
out.put("客户名称", customer);
|
||||
boolean hideAirEta = looksAirInquiry(src, q, businessLine);
|
||||
String eta = first(q, "eta", "transit_days", "transitDays");
|
||||
if (!hideAirEta && StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
@@ -1013,9 +1138,12 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
}
|
||||
}
|
||||
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
|
||||
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
if (landSegs.isEmpty()) {
|
||||
landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
|
||||
JSONArray routeRows = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
JSONArray rawLand = QuoteTemplateExcelSupport.parseFeeRows(q.get("landSegments"));
|
||||
JSONArray landSegs = routeRows.isEmpty() ? rawLand : routeRows;
|
||||
// 已落库的 route_rows 是规范化扁平行,分档公斤价还在原始 landSegments 上。
|
||||
if (!routeRows.isEmpty()) {
|
||||
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
@@ -1077,9 +1205,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
one.put("salePrice", landAmt);
|
||||
}
|
||||
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(one);
|
||||
out.put("route_rows", routeRows);
|
||||
JSONArray fallbackRows = new JSONArray();
|
||||
fallbackRows.add(one);
|
||||
out.put("route_rows", fallbackRows);
|
||||
}
|
||||
}
|
||||
if (hideAirEta) {
|
||||
|
||||
+21
-4
@@ -19,7 +19,9 @@ import java.util.List;
|
||||
* <p>
|
||||
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
|
||||
* 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
|
||||
* 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。
|
||||
* 空运:成本整列空;销售是当前生效价。
|
||||
* 未调价:各项利润点与费用合计都是 0%,禁止拿金额去填利润点列。
|
||||
* 已调价:利润点对调价前同名行。
|
||||
* 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
|
||||
* 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
|
||||
*/
|
||||
@@ -126,7 +128,7 @@ public final class InquiryQuoteLineSupport {
|
||||
profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
|
||||
ticket.setProfitRate(profitTotal);
|
||||
} else if (!adjusted) {
|
||||
ticket.setProfitRate("");
|
||||
ticket.setProfitRate("0%");
|
||||
}
|
||||
|
||||
if (air) {
|
||||
@@ -134,13 +136,13 @@ public final class InquiryQuoteLineSupport {
|
||||
ticket.setSaleLines(moneyLines(saleFees, saleTotal));
|
||||
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
|
||||
? profitAgainst(baselineFees, saleFees, profitTotal)
|
||||
: Collections.<InquiryPriceLine>emptyList());
|
||||
: zeroPercentLines(saleFees));
|
||||
return;
|
||||
}
|
||||
if (!adjusted) {
|
||||
ticket.setCostLines(moneyLines(costFees, costTotal));
|
||||
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
|
||||
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList());
|
||||
ticket.setProfitLines(zeroPercentLines(costFees));
|
||||
return;
|
||||
}
|
||||
List<Aligned> rows = align(costFees, saleFees);
|
||||
@@ -216,6 +218,21 @@ public final class InquiryQuoteLineSupport {
|
||||
return formatMoney(base.add(delta), totalCcy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未调价:每一项费用和费用合计都写 0%。
|
||||
* 列表/详情都走这里,避免利润点列空白或误把金额填进去。
|
||||
*/
|
||||
private static List<InquiryPriceLine> zeroPercentLines(List<FeeRow> fees) {
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
if (fees != null) {
|
||||
for (FeeRow fee : fees) {
|
||||
lines.add(line(fee.name, "0%"));
|
||||
}
|
||||
}
|
||||
lines.add(line(TOTAL_NAME, "0%"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static List<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) {
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
if (fees != null) {
|
||||
|
||||
+4
-3
@@ -14,7 +14,8 @@ import java.util.regex.Pattern;
|
||||
* 后台成本价 / 销售价 / 利润点:按业务线从报价记录重算。
|
||||
* <p>
|
||||
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
|
||||
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
|
||||
* 空运:成本价始终空;TMS/航线/调价都是销售侧。
|
||||
* 未调价时利润点固定 0%(含各项),调过价才按差额算。
|
||||
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
|
||||
*/
|
||||
public final class InquiryTicketPriceSupport {
|
||||
@@ -63,7 +64,7 @@ public final class InquiryTicketPriceSupport {
|
||||
ticket.setProfitRate(profitText(adjustPrice, baseline));
|
||||
} else {
|
||||
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
|
||||
ticket.setProfitRate("");
|
||||
ticket.setProfitRate("0%");
|
||||
}
|
||||
} else {
|
||||
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
|
||||
@@ -73,7 +74,7 @@ public final class InquiryTicketPriceSupport {
|
||||
ticket.setProfitRate(profitText(adjustPrice, cost));
|
||||
} else {
|
||||
ticket.setSalePrice("");
|
||||
ticket.setProfitRate("");
|
||||
ticket.setProfitRate("0%");
|
||||
}
|
||||
}
|
||||
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
|
||||
|
||||
+1981
-21
File diff suppressed because it is too large
Load Diff
+22
-4
@@ -145,7 +145,8 @@ public final class QuoteTemplateMatcher {
|
||||
static {
|
||||
LAND_KEYWORD_ALIASES.put("国内拼车", new String[] {"国内运输拼车", "拼车"});
|
||||
LAND_KEYWORD_ALIASES.put("国内零担", new String[] {"国内运输拼车", "国内长途/零担", "零担"});
|
||||
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车", "整车"});
|
||||
// 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字,会把跨境单配成国内表。
|
||||
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境整车", new String[] {"跨境整车"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境集拼", new String[] {"跨境集拼"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境零担", new String[] {"跨境集拼"});
|
||||
@@ -155,8 +156,7 @@ public final class QuoteTemplateMatcher {
|
||||
if (text == null || text.isEmpty() || kw == null || kw.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String hay = text.toLowerCase(Locale.ROOT);
|
||||
if (hay.contains(kw.toLowerCase(Locale.ROOT))) {
|
||||
if (containsLandNeedle(text, kw)) {
|
||||
return true;
|
||||
}
|
||||
String[] aliases = LAND_KEYWORD_ALIASES.get(kw);
|
||||
@@ -164,13 +164,31 @@ public final class QuoteTemplateMatcher {
|
||||
return false;
|
||||
}
|
||||
for (String alias : aliases) {
|
||||
if (hay.contains(alias.toLowerCase(Locale.ROOT))) {
|
||||
if (containsLandNeedle(text, alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运针:普通 contains;单独「整车」不命中跨境/中港(避免国内整车模板抢走跨境单)。
|
||||
*/
|
||||
static boolean containsLandNeedle(String text, String needle) {
|
||||
if (text == null || needle == null || needle.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String hay = text.toLowerCase(Locale.ROOT);
|
||||
String n = needle.toLowerCase(Locale.ROOT);
|
||||
if (!hay.contains(n)) {
|
||||
return false;
|
||||
}
|
||||
if ("整车".equals(n) && (hay.contains("跨境") || hay.contains("中港"))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean skipKeyword(String kw) {
|
||||
if (kw == null || kw.isEmpty()) {
|
||||
return true;
|
||||
|
||||
+3
-82
@@ -5,11 +5,6 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -18,7 +13,6 @@ import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -36,6 +30,7 @@ import java.util.stream.Stream;
|
||||
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
|
||||
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
|
||||
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
|
||||
* 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。
|
||||
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
|
||||
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
|
||||
*/
|
||||
@@ -89,8 +84,8 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
try {
|
||||
Path localXlsx = workDir.resolve("source.xlsx");
|
||||
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
|
||||
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
|
||||
preparePreviewPrintLayout(localXlsx);
|
||||
// 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。
|
||||
// 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。
|
||||
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
|
||||
if (pdf == null || !Files.isRegularFile(pdf)) {
|
||||
throw new IllegalStateException("LibreOffice 未生成 PDF");
|
||||
@@ -112,80 +107,6 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
|
||||
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
|
||||
*/
|
||||
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(xlsx);
|
||||
Workbook wb = WorkbookFactory.create(in)) {
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
boolean landscape = preferLandscape(sheet);
|
||||
sheet.setMargin(Sheet.LeftMargin, 0.4);
|
||||
sheet.setMargin(Sheet.RightMargin, 0.4);
|
||||
sheet.setMargin(Sheet.TopMargin, 0.5);
|
||||
sheet.setMargin(Sheet.BottomMargin, 0.5);
|
||||
sheet.setHorizontallyCenter(true);
|
||||
sheet.setVerticallyCenter(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.setFitToPage(true);
|
||||
PrintSetup ps = sheet.getPrintSetup();
|
||||
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
|
||||
ps.setLandscape(landscape);
|
||||
ps.setFitWidth((short) 1);
|
||||
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
|
||||
ps.setFitHeight((short) 0);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
|
||||
* 单位换算只需相对比较,不要求绝对毫米精度。
|
||||
*/
|
||||
private static boolean preferLandscape(Sheet sheet) {
|
||||
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
if (lastRow < firstRow) {
|
||||
return false;
|
||||
}
|
||||
int maxCol = 0;
|
||||
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
short last = row.getLastCellNum();
|
||||
if (last > maxCol) {
|
||||
maxCol = last;
|
||||
}
|
||||
}
|
||||
if (maxCol <= 0) {
|
||||
return false;
|
||||
}
|
||||
double widthPx = 0;
|
||||
for (int c = 0; c < maxCol; c++) {
|
||||
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
|
||||
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
|
||||
}
|
||||
double heightPx = 0;
|
||||
int rowLimit = Math.min(lastRow, firstRow + 120);
|
||||
for (int r = firstRow; r <= rowLimit; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
|
||||
heightPx += points * (96.0 / 72.0);
|
||||
}
|
||||
return widthPx > heightPx * 1.08;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
|
||||
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
|
||||
|
||||
+12
-1
@@ -463,6 +463,17 @@ public class InquiryTmsQueryService {
|
||||
putExtra(row, "transitDays", transit);
|
||||
putExtra(row, "transit_time", transit);
|
||||
putExtra(row, "remark", firstText(seg.get("remark"), seg.get("note"), seg.get("description")));
|
||||
Object tiers = seg.get("weightPrices");
|
||||
if (tiers == null) {
|
||||
tiers = seg.get("weight_prices");
|
||||
}
|
||||
if (tiers != null) {
|
||||
String tierJson = tiers instanceof String ? String.valueOf(tiers) : JSON.toJSONString(tiers);
|
||||
if (StringUtils.isNotBlank(tierJson) && !"null".equalsIgnoreCase(tierJson)
|
||||
&& !"[]".equals(tierJson.trim())) {
|
||||
row.put("weightPrices", tierJson);
|
||||
}
|
||||
}
|
||||
// 腿上若带分项费率扩展字段,原样带上供「提货费/元/CBM」列匹配
|
||||
String[][] rateKeys = {
|
||||
{"pickupFee", "pickup_fee"}, {"deliveryFee", "delivery_fee"},
|
||||
@@ -678,7 +689,7 @@ public class InquiryTmsQueryService {
|
||||
|
||||
/**
|
||||
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
|
||||
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。
|
||||
* 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。
|
||||
*/
|
||||
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
|
||||
if (ticket == null || quote == null) {
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
-- 机器人回复语:用现网整理表覆盖旧原型数据,并写入调用编码 reply_code。
|
||||
-- 仅改 inquiry_robot.inq_bot_reply。可重复执行(先清空再插入)。
|
||||
-- 智能体本期不读本表;编码先落库供后台展示与日后按码取文案。
|
||||
-- 若尚无 reply_code 列会先加;列已存在时本句报 1060,可忽略后继续 DELETE/INSERT。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_bot_reply`
|
||||
ADD COLUMN `reply_code` varchar(64) NULL COMMENT '智能体调用编码' AFTER `category_title`;
|
||||
|
||||
DELETE FROM `inq_bot_reply`;
|
||||
|
||||
INSERT INTO `inq_bot_reply`
|
||||
(`id`,`category_code`,`category_title`,`reply_code`,`trigger_scene`,`reply_text`,`enabled`,`sort_no`,`create_by`,`create_time`)
|
||||
VALUES
|
||||
('brv10000000000000000000000000001','inquiring','询价中','inquiring.first_chat','销售首次单聊机器人','你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000002','inquiring','询价中','inquiring.ask_transport','未识别运输方式','请回复数字或文字,告知我询价的运输方式:
|
||||
1.陆运
|
||||
2.海运
|
||||
3.空运',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000003','inquiring','询价中','inquiring.continue_need_wo','续办但未提供工单号','请告诉我要继续的工单号,例如 WO202609140034。',1,3,'admin',NOW()),
|
||||
('brv10000000000000000000000000004','inquiring','询价中','inquiring.continue_not_found','续办找不到工单','找不到工单 {{ticket_no}},请核对工单号后再试。',1,4,'admin',NOW()),
|
||||
('brv10000000000000000000000000005','inquiring','询价中','inquiring.image_recognizing','销售发送图片','正在识别图片',1,5,'admin',NOW()),
|
||||
('brv10000000000000000000000000006','inquiring','询价中','inquiring.image_tech_fail','图片识别失败','图片暂时没认出来,请再发一张或直接打字。',1,6,'admin',NOW()),
|
||||
('brv10000000000000000000000000007','inquiring','询价中','inquiring.attach_recognizing','销售发送附件','正在识别附件',1,7,'admin',NOW()),
|
||||
('brv10000000000000000000000000008','inquiring','询价中','inquiring.attach_tech_fail','附件识别失败','附件暂时没认出来,请再传一次或直接打字。',1,8,'admin',NOW()),
|
||||
('brv10000000000000000000000000009','inquiring','询价中','inquiring.attach_h5_expired','H5 上传链接过期','已过期,请回企微重新说附件询价。',1,9,'admin',NOW()),
|
||||
('brv10000000000000000000000000010','inquiring','询价中','inquiring.attach_h5_ok','H5 上传成功','已收到,请回企微查看识别结果。',1,10,'admin',NOW()),
|
||||
('brv10000000000000000000000000011','inquiring','询价中','inquiring.pull_group_manual','请手动拉人进群并发送工单号','请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{{ticket_no}}',1,11,'admin',NOW()),
|
||||
('brv10000000000000000000000000012','inquiring','询价中','inquiring.skip_collab_ack','销售跳过内部协同','好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…',1,12,'admin',NOW()),
|
||||
('brv10000000000000000000000000013','inquiring','询价中','inquiring.group_created','协同群创建成功','协同群已创建,群名与工单号一致:{{ticket_no}}。已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。',1,13,'admin',NOW()),
|
||||
('brv10000000000000000000000000014','inquiring','询价中','inquiring.group_exists','协同群已存在','协同群已在,群名与工单号一致:{{ticket_no}}。请到该群继续确认报价。',1,14,'admin',NOW()),
|
||||
('brv10000000000000000000000000015','inquiring','询价中','inquiring.sea_no_staff','海运没有可拉的产品人员','没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。',1,15,'admin',NOW()),
|
||||
('brv10000000000000000000000000016','inquiring','询价中','inquiring.land_no_staff','陆运没有可拉的产品人员','没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。',1,16,'admin',NOW()),
|
||||
('brv10000000000000000000000000017','inquiring','询价中','inquiring.group_create_fail','建群失败','建群失败,请稍后重试。',1,17,'admin',NOW()),
|
||||
('brv10000000000000000000000000018','inquiring','询价中','inquiring.quote_generating','报价单生成中','报价单正在生成,请稍候。',1,18,'admin',NOW()),
|
||||
('brv10000000000000000000000000019','inquiring','询价中','inquiring.air_ticket_not_found','空运群找不到工单','找不到工单{{ticket_no}},请核对工单号后再试。',1,19,'admin',NOW()),
|
||||
('brv10000000000000000000000000020','inquiring','询价中','inquiring.air_ticket_not_air','工单不是空运单','工单{{ticket_no}}不是空运单,不能在空运群里跟。',1,20,'admin',NOW()),
|
||||
('brv10000000000000000000000000021','inquiring','询价中','inquiring.air_ticket_occupied','空运工单已在另一个群','工单{{ticket_no}}已经在另一个群跟了,请到那个群继续。',1,21,'admin',NOW()),
|
||||
('brv10000000000000000000000000022','inquiring','询价中','inquiring.group_only_product','非产品人员在群里报价','只有产品能报价格,请产品在群里发价格或 Excel/PDF',1,22,'admin',NOW()),
|
||||
('brv10000000000000000000000000023','inquiring','询价中','inquiring.group_file_saved','群附件已保存','附件已保存后台',1,23,'admin',NOW()),
|
||||
('brv10000000000000000000000000024','inquiring','询价中','inquiring.air_lock_need_wo','空运锁舱/释放未带工单号','锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。',1,24,'admin',NOW()),
|
||||
('brv10000000000000000000000000025','inquiring','询价中','inquiring.air_lock_need_quote','没有报价不能锁舱','工单{{ticket_no}}还没有报价,不能锁舱。',1,25,'admin',NOW()),
|
||||
('brv10000000000000000000000000026','inquiring','询价中','inquiring.air_airline_only_cabin','只有航线能锁舱或释放','只有岗位为「航线·空运」的人能锁舱或释放舱位。',1,26,'admin',NOW()),
|
||||
('brv10000000000000000000000000027','inquiring','询价中','inquiring.air_lock_ok','空运锁舱成功','工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。
|
||||
操作人:{{operator}}
|
||||
舱位选项:{{option_no}}
|
||||
执行编号:{{exec_no}}
|
||||
报价编号:{{quote_no}}',1,27,'admin',NOW()),
|
||||
('brv10000000000000000000000000028','inquiring','询价中','inquiring.air_lock_fail','空运锁舱失败','工单{{ticket_no}} 空运锁舱失败。
|
||||
操作人:{{operator}}
|
||||
锁舱失败的原因:{{reason}}
|
||||
已通知IT运维排查,请稍等。',1,28,'admin',NOW()),
|
||||
('brv10000000000000000000000000029','inquiring','询价中','inquiring.air_release_ok','空运释放成功','工单{{ticket_no}} 已通知TMS执行空运释放舱位成功
|
||||
操作人:{{operator}}
|
||||
舱位选项:{{option_no}}
|
||||
执行编号:{{exec_no}}
|
||||
报价编号:{{quote_no}}',1,29,'admin',NOW()),
|
||||
('brv10000000000000000000000000030','inquiring','询价中','inquiring.air_release_fail','空运释放失败','工单{{ticket_no}} 空运释放舱位失败。
|
||||
操作人:{{operator}}
|
||||
释放舱位失败的原因:{{reason}}
|
||||
已通知IT运维排查,请稍等。',1,30,'admin',NOW()),
|
||||
('brv10000000000000000000000000031','inquiring','询价中','inquiring.air_release_deal_blocked','已成交不能释放舱位','释放舱位失败,工单{{ticket_no}}已成交。',1,31,'admin',NOW()),
|
||||
('brv10000000000000000000000000032','inquiring','询价中','inquiring.air_release_need_lock','没有有效锁舱不能释放','工单{{ticket_no}}还没有有效锁舱,不能释放。',1,32,'admin',NOW()),
|
||||
('brv10000000000000000000000000033','quoted','已报价','quoted.reject_ack','销售选择「不采用」','请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000034','quoted','已报价','quoted.adopt_excel','销售确认采用 → 生成 Excel','已确认采用该方案,正在生成 Excel 最终报价单…',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000035','quoted','已报价','quoted.adopt_pdf','销售确认采用 → 生成 PDF','已确认采用该方案,正在生成PDF最终报价单…',1,3,'admin',NOW()),
|
||||
('brv10000000000000000000000000036','quoted','已报价','quoted.template_missing','后台未维护报价模板','生成报价单失败,请联系IT运维维护后台报价模板。',1,4,'admin',NOW()),
|
||||
('brv10000000000000000000000000037','quoted','已报价','quoted.file_ready','最终报价单生成完成','已生成{{kind}}最终报价单:{{filename}}(工单 {{ticket_no}})',1,5,'admin',NOW()),
|
||||
('brv10000000000000000000000000038','deal','已成交','deal.recorded_won','销售选择「成交」','已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000039','negotiating','协商中','negotiating.recorded','成交跟进中选择「协商中」','已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000040','lost','未成交','lost.ask_reason','成交跟进中选择「未成交」且未写原因','已记录为「未成交」。请补充未成交原因。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000041','lost','未成交','lost.reason_ack','销售回复未成交原因','已记下未成交原因:{{lost_reason}}。',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000042','closed','已关闭','closed.air_cannot_activate','空运工单已是终态仍要激活','工单{{ticket_no}}已是「{{status}}」,不能再激活。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000043','sys-ex','系统异常','sys.llm_fail','大模型调用失败(销售侧)','大模型服务异常,IT排查中...',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000044','sys-ex','系统异常','sys.tms_fail','TMS 接口失败(销售侧)','TMS接口异常,IT运维排查中...',1,2,'admin',NOW()),
|
||||
('brv10000000000000000000000000045','sys-ex','系统异常','sys.group_add_fail','企微添加成员失败','无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。',1,3,'admin',NOW()),
|
||||
('brv10000000000000000000000000046','sys-ex','系统异常','sys.tms_recovery','TMS 已恢复','TMS已恢复正常使用,感谢您的耐心等待。',1,4,'admin',NOW()),
|
||||
('brv10000000000000000000000000047','sys-ex','系统异常','sys.llm_recovery','大模型已恢复','大模型已恢复正常使用,感谢您的耐心等待。',1,5,'admin',NOW()),
|
||||
('brv10000000000000000000000000048','sys-ex','系统异常','sys.cabin_recovery','空运舱位接口已恢复','空运舱位接口已恢复正常使用,感谢您的耐心等待。',1,6,'admin',NOW()),
|
||||
('brv10000000000000000000000000049','sys-ex','系统异常','sys.group_recovery','建群已恢复','建群已恢复正常,感谢您的耐心等待。',1,7,'admin',NOW()),
|
||||
('brv10000000000000000000000000050','transfer','转人工','transfer.already_collab','该工单已拉过产品进群','该工单已拉产品进群协同。',1,1,'admin',NOW()),
|
||||
('brv10000000000000000000000000051','transfer','转人工','transfer.air_hint','空运转人工(不自动建群)','触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {{ticket_no}}。',1,2,'admin',NOW());
|
||||
|
||||
ALTER TABLE `inq_bot_reply`
|
||||
ADD UNIQUE INDEX `uk_inq_bot_reply_reply_code` (`reply_code`);
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
-- 为 inq_bot_reply 增加智能体调用编码 reply_code,并回填现网 51 条。
|
||||
-- 仅改 inquiry_robot。列已存在时 ADD 会报 1060,可忽略后继续执行 UPDATE。
|
||||
-- 本期智能体不读本表。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `inq_bot_reply`
|
||||
ADD COLUMN `reply_code` varchar(64) NULL COMMENT '智能体调用编码' AFTER `category_title`;
|
||||
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.first_chat' WHERE `id` = 'brv10000000000000000000000000001' OR (`reply_code` IS NULL AND `trigger_scene` = '销售首次单聊机器人');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.ask_transport' WHERE `id` = 'brv10000000000000000000000000002' OR (`reply_code` IS NULL AND `trigger_scene` = '未识别运输方式');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.continue_need_wo' WHERE `id` = 'brv10000000000000000000000000003' OR (`reply_code` IS NULL AND `trigger_scene` = '续办但未提供工单号');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.continue_not_found' WHERE `id` = 'brv10000000000000000000000000004' OR (`reply_code` IS NULL AND `trigger_scene` = '续办找不到工单');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.image_recognizing' WHERE `id` = 'brv10000000000000000000000000005' OR (`reply_code` IS NULL AND `trigger_scene` = '销售发送图片');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.image_tech_fail' WHERE `id` = 'brv10000000000000000000000000006' OR (`reply_code` IS NULL AND `trigger_scene` = '图片识别失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_recognizing' WHERE `id` = 'brv10000000000000000000000000007' OR (`reply_code` IS NULL AND `trigger_scene` = '销售发送附件');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_tech_fail' WHERE `id` = 'brv10000000000000000000000000008' OR (`reply_code` IS NULL AND `trigger_scene` = '附件识别失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_h5_expired' WHERE `id` = 'brv10000000000000000000000000009' OR (`reply_code` IS NULL AND `trigger_scene` = 'H5 上传链接过期');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.attach_h5_ok' WHERE `id` = 'brv10000000000000000000000000010' OR (`reply_code` IS NULL AND `trigger_scene` = 'H5 上传成功');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.pull_group_manual' WHERE `id` = 'brv10000000000000000000000000011' OR (`reply_code` IS NULL AND `trigger_scene` = '请手动拉人进群并发送工单号');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.skip_collab_ack' WHERE `id` = 'brv10000000000000000000000000012' OR (`reply_code` IS NULL AND `trigger_scene` = '销售跳过内部协同');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_created' WHERE `id` = 'brv10000000000000000000000000013' OR (`reply_code` IS NULL AND `trigger_scene` = '协同群创建成功');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_exists' WHERE `id` = 'brv10000000000000000000000000014' OR (`reply_code` IS NULL AND `trigger_scene` = '协同群已存在');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.sea_no_staff' WHERE `id` = 'brv10000000000000000000000000015' OR (`reply_code` IS NULL AND `trigger_scene` = '海运没有可拉的产品人员');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.land_no_staff' WHERE `id` = 'brv10000000000000000000000000016' OR (`reply_code` IS NULL AND `trigger_scene` = '陆运没有可拉的产品人员');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_create_fail' WHERE `id` = 'brv10000000000000000000000000017' OR (`reply_code` IS NULL AND `trigger_scene` = '建群失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.quote_generating' WHERE `id` = 'brv10000000000000000000000000018' OR (`reply_code` IS NULL AND `trigger_scene` = '报价单生成中');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_not_found' WHERE `id` = 'brv10000000000000000000000000019' OR (`reply_code` IS NULL AND `trigger_scene` = '空运群找不到工单');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_not_air' WHERE `id` = 'brv10000000000000000000000000020' OR (`reply_code` IS NULL AND `trigger_scene` = '工单不是空运单');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_ticket_occupied' WHERE `id` = 'brv10000000000000000000000000021' OR (`reply_code` IS NULL AND `trigger_scene` = '空运工单已在另一个群');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_only_product' WHERE `id` = 'brv10000000000000000000000000022' OR (`reply_code` IS NULL AND `trigger_scene` = '非产品人员在群里报价');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.group_file_saved' WHERE `id` = 'brv10000000000000000000000000023' OR (`reply_code` IS NULL AND `trigger_scene` = '群附件已保存');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_need_wo' WHERE `id` = 'brv10000000000000000000000000024' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱/释放未带工单号');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_need_quote' WHERE `id` = 'brv10000000000000000000000000025' OR (`reply_code` IS NULL AND `trigger_scene` = '没有报价不能锁舱');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_airline_only_cabin' WHERE `id` = 'brv10000000000000000000000000026' OR (`reply_code` IS NULL AND `trigger_scene` = '只有航线能锁舱或释放');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_ok' WHERE `id` = 'brv10000000000000000000000000027' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱成功');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_lock_fail' WHERE `id` = 'brv10000000000000000000000000028' OR (`reply_code` IS NULL AND `trigger_scene` = '空运锁舱失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_ok' WHERE `id` = 'brv10000000000000000000000000029' OR (`reply_code` IS NULL AND `trigger_scene` = '空运释放成功');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_fail' WHERE `id` = 'brv10000000000000000000000000030' OR (`reply_code` IS NULL AND `trigger_scene` = '空运释放失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_deal_blocked' WHERE `id` = 'brv10000000000000000000000000031' OR (`reply_code` IS NULL AND `trigger_scene` = '已成交不能释放舱位');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'inquiring.air_release_need_lock' WHERE `id` = 'brv10000000000000000000000000032' OR (`reply_code` IS NULL AND `trigger_scene` = '没有有效锁舱不能释放');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.reject_ack' WHERE `id` = 'brv10000000000000000000000000033' OR (`reply_code` IS NULL AND `trigger_scene` = '销售选择「不采用」');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.adopt_excel' WHERE `id` = 'brv10000000000000000000000000034' OR (`reply_code` IS NULL AND `trigger_scene` = '销售确认采用 → 生成 Excel');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.adopt_pdf' WHERE `id` = 'brv10000000000000000000000000035' OR (`reply_code` IS NULL AND `trigger_scene` = '销售确认采用 → 生成 PDF');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.template_missing' WHERE `id` = 'brv10000000000000000000000000036' OR (`reply_code` IS NULL AND `trigger_scene` = '后台未维护报价模板');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'quoted.file_ready' WHERE `id` = 'brv10000000000000000000000000037' OR (`reply_code` IS NULL AND `trigger_scene` = '最终报价单生成完成');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'deal.recorded_won' WHERE `id` = 'brv10000000000000000000000000038' OR (`reply_code` IS NULL AND `trigger_scene` = '销售选择「成交」');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'negotiating.recorded' WHERE `id` = 'brv10000000000000000000000000039' OR (`reply_code` IS NULL AND `trigger_scene` = '成交跟进中选择「协商中」');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'lost.ask_reason' WHERE `id` = 'brv10000000000000000000000000040' OR (`reply_code` IS NULL AND `trigger_scene` = '成交跟进中选择「未成交」且未写原因');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'lost.reason_ack' WHERE `id` = 'brv10000000000000000000000000041' OR (`reply_code` IS NULL AND `trigger_scene` = '销售回复未成交原因');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'closed.air_cannot_activate' WHERE `id` = 'brv10000000000000000000000000042' OR (`reply_code` IS NULL AND `trigger_scene` = '空运工单已是终态仍要激活');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.llm_fail' WHERE `id` = 'brv10000000000000000000000000043' OR (`reply_code` IS NULL AND `trigger_scene` = '大模型调用失败(销售侧)');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.tms_fail' WHERE `id` = 'brv10000000000000000000000000044' OR (`reply_code` IS NULL AND `trigger_scene` = 'TMS 接口失败(销售侧)');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.group_add_fail' WHERE `id` = 'brv10000000000000000000000000045' OR (`reply_code` IS NULL AND `trigger_scene` = '企微添加成员失败');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.tms_recovery' WHERE `id` = 'brv10000000000000000000000000046' OR (`reply_code` IS NULL AND `trigger_scene` = 'TMS 已恢复');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.llm_recovery' WHERE `id` = 'brv10000000000000000000000000047' OR (`reply_code` IS NULL AND `trigger_scene` = '大模型已恢复');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.cabin_recovery' WHERE `id` = 'brv10000000000000000000000000048' OR (`reply_code` IS NULL AND `trigger_scene` = '空运舱位接口已恢复');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'sys.group_recovery' WHERE `id` = 'brv10000000000000000000000000049' OR (`reply_code` IS NULL AND `trigger_scene` = '建群已恢复');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'transfer.already_collab' WHERE `id` = 'brv10000000000000000000000000050' OR (`reply_code` IS NULL AND `trigger_scene` = '该工单已拉过产品进群');
|
||||
UPDATE `inq_bot_reply` SET `reply_code` = 'transfer.air_hint' WHERE `id` = 'brv10000000000000000000000000051' OR (`reply_code` IS NULL AND `trigger_scene` = '空运转人工(不自动建群)');
|
||||
|
||||
ALTER TABLE `inq_bot_reply`
|
||||
ADD UNIQUE INDEX `uk_inq_bot_reply_reply_code` (`reply_code`);
|
||||
|
||||
+28
@@ -30,4 +30,32 @@ public class InquiryDashboardServiceImplTest {
|
||||
air.setBusinessLine("AIR");
|
||||
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatDurationMsMatchesDashboardDisplay() {
|
||||
Assert.assertEquals("42s", InquiryDashboardServiceImpl.formatDurationMs(42_000L));
|
||||
Assert.assertEquals("8m42s", InquiryDashboardServiceImpl.formatDurationMs((8 * 60 + 42) * 1000L));
|
||||
Assert.assertEquals("1h5m", InquiryDashboardServiceImpl.formatDurationMs((65 * 60) * 1000L));
|
||||
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatDurationMs(-1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAvgSaleIsAmountNotPercent() {
|
||||
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null));
|
||||
Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00")));
|
||||
Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstReplyAfterSkipsQuotesBeforeDispatch() {
|
||||
java.util.Date dispatch = new java.util.Date(1_000_000L);
|
||||
java.util.List<java.util.Date> times = java.util.Arrays.asList(
|
||||
new java.util.Date(500_000L),
|
||||
new java.util.Date(1_200_000L),
|
||||
new java.util.Date(2_000_000L));
|
||||
Assert.assertEquals(new java.util.Date(1_200_000L),
|
||||
InquiryDashboardServiceImpl.firstReplyAfter(times, dispatch));
|
||||
Assert.assertNull(InquiryDashboardServiceImpl.firstReplyAfter(
|
||||
java.util.Collections.singletonList(new java.util.Date(100L)), dispatch));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -28,13 +28,16 @@ public class InquiryQuoteLineSupportTest {
|
||||
+ "]}")));
|
||||
assertEquals("CNY 1200.00", ticket.getCostPrice());
|
||||
assertEquals("", ticket.getSalePrice());
|
||||
assertEquals("", ticket.getProfitRate());
|
||||
assertEquals("0%", ticket.getProfitRate());
|
||||
assertLines(ticket.getCostLines(),
|
||||
"运输费", "CNY 1000",
|
||||
"卸货费", "CNY 200",
|
||||
"费用合计", "CNY 1200.00");
|
||||
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
|
||||
assertTrue(ticket.getProfitLines() == null || ticket.getProfitLines().isEmpty());
|
||||
assertLines(ticket.getProfitLines(),
|
||||
"运输费", "0%",
|
||||
"卸货费", "0%",
|
||||
"费用合计", "0%");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -113,7 +116,12 @@ public class InquiryQuoteLineSupportTest {
|
||||
assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
|
||||
assertEquals("", before.getCostPrice());
|
||||
assertEquals("CNY 2000.00", before.getSalePrice());
|
||||
assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty());
|
||||
assertEquals("0%", before.getProfitRate());
|
||||
assertLines(before.getProfitLines(),
|
||||
"空运费", "0%",
|
||||
"增点费", "0%",
|
||||
"拆板费", "0%",
|
||||
"费用合计", "0%");
|
||||
assertLines(before.getSaleLines(),
|
||||
"空运费", "CNY 1800",
|
||||
"增点费", "CNY 100",
|
||||
@@ -189,6 +197,11 @@ public class InquiryQuoteLineSupportTest {
|
||||
"费用合计", "CNY 1320.00");
|
||||
assertEquals("CNY 1320.00", ticket.getCostPrice());
|
||||
assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
|
||||
assertLines(ticket.getProfitLines(),
|
||||
"运输费", "0%",
|
||||
"卸货费", "0%",
|
||||
"配送费", "0%",
|
||||
"费用合计", "0%");
|
||||
}
|
||||
|
||||
private static void assertLines(List<InquiryPriceLine> lines, String... nameText) {
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ public class InquiryTicketPriceSupportTest {
|
||||
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "CNY 121.00")));
|
||||
assertEquals("CNY 121.00", ticket.getCostPrice());
|
||||
assertEquals("", ticket.getSalePrice());
|
||||
assertEquals("", ticket.getProfitRate());
|
||||
assertEquals("0%", ticket.getProfitRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -53,7 +53,7 @@ public class InquiryTicketPriceSupportTest {
|
||||
InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote("TMS", "TMS标准报价", "USD 1280")));
|
||||
assertEquals("", ticket.getCostPrice());
|
||||
assertEquals("USD 1280", ticket.getSalePrice());
|
||||
assertEquals("", ticket.getProfitRate());
|
||||
assertEquals("0%", ticket.getProfitRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+764
@@ -1135,6 +1135,515 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境集拼线路矩阵:dataStartRow 若落在蓝底「始发地/目的地」表头,必须下移;
|
||||
* 不得把城市名写进表头格盖掉蓝底。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageRouteRegionSkipsBlueMatrixHeader() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-matrix-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-matrix-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
|
||||
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
|
||||
org.apache.poi.ss.usermodel.Cell o = head.createCell(0);
|
||||
o.setCellValue("始发地");
|
||||
o.setCellStyle(blue);
|
||||
org.apache.poi.ss.usermodel.Cell d = head.createCell(1);
|
||||
d.setCellValue("目的地");
|
||||
d.setCellStyle(blue);
|
||||
org.apache.poi.ss.usermodel.Cell kg = head.createCell(3);
|
||||
kg.setCellValue("2-500KG\n元/KG");
|
||||
kg.setCellStyle(blue);
|
||||
row(sheet, 8);
|
||||
row(sheet, 16).createCell(0).setCellValue("查验处理费用");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境集拼");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 8);
|
||||
region.put("stopAtText", JSONArray.parseArray("[\"查验处理费用\"]"));
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "A");
|
||||
columns.put("destination", "B");
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("始发地", read(sheet, 7, 0));
|
||||
assertEquals("目的地", read(sheet, 7, 1));
|
||||
assertEquals("上海", read(sheet, 8, 0));
|
||||
assertEquals("胡志明", read(sheet, 8, 1));
|
||||
org.apache.poi.ss.usermodel.CellStyle st = sheet.getRow(7).getCell(0).getCellStyle();
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, st.getFillPattern());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运跨境集拼:蓝底询价条带是表头(业务员/客户名称/始发地/货物类型),
|
||||
* 值写下一空白行,禁止盖掉表头字与蓝底。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageHeaderBandWritesValuesOnNextRow() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
|
||||
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 4);
|
||||
String[] labels = {"业务员", "客户名称", "始发地", "条款", "发货日期", "货物类型"};
|
||||
int[] cols = {1, 3, 5, 7, 9, 13};
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]);
|
||||
c.setCellValue(labels[i]);
|
||||
c.setCellStyle(blue);
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, cols[i], cols[i] + 1));
|
||||
}
|
||||
row(sheet, 5);
|
||||
org.apache.poi.ss.usermodel.Row matrixHead = row(sheet, 7);
|
||||
matrixHead.createCell(0).setCellValue("始发地");
|
||||
matrixHead.createCell(1).setCellValue("目的地");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("sales_name", "LuoXiaoHua");
|
||||
facts.put("origin", "上海");
|
||||
facts.put("commodity", "茶叶");
|
||||
facts.put("quote_date", "2026-10-09");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("业务员", read(sheet, 4, 1));
|
||||
assertEquals("客户名称", read(sheet, 4, 3));
|
||||
assertEquals("始发地", read(sheet, 4, 5));
|
||||
assertEquals("货物类型", read(sheet, 4, 13));
|
||||
assertEquals("LuoXiaoHua", read(sheet, 5, 1));
|
||||
assertEquals("上海", read(sheet, 5, 5));
|
||||
assertEquals("茶叶", read(sheet, 5, 13));
|
||||
assertEquals("2026-10-09", read(sheet, 5, 9));
|
||||
org.apache.poi.ss.usermodel.CellStyle salesStyle = sheet.getRow(4).getCell(1).getCellStyle();
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, salesStyle.getFillPattern());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 现网跨境集拼:第 5 行蓝字是表头(客户名称/始发地/货物类型被合并),
|
||||
* 第 8–9 行是「始发地 | 目的地 | 最低收费 | 元/KG」。
|
||||
* 城市和品名不得盖掉这些字。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageKeepsTemplateHeaderWords() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-real", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-real-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("中越集拼报价");
|
||||
org.apache.poi.ss.usermodel.Row band = row(sheet, 4);
|
||||
band.createCell(1).setCellValue("业务员");
|
||||
band.createCell(2).setCellValue("客户名称");
|
||||
band.createCell(4).setCellValue("始发地");
|
||||
band.createCell(6).setCellValue("条款");
|
||||
band.createCell(10).setCellValue("发货日期");
|
||||
band.createCell(11).setCellValue("时效");
|
||||
band.createCell(13).setCellValue("货物类型");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 2, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 5));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 13, 14));
|
||||
row(sheet, 5);
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
matrix.createCell(1).setCellValue("始发地");
|
||||
matrix.createCell(2).setCellValue("目的地");
|
||||
matrix.createCell(4).setCellValue("2-500KG");
|
||||
matrix.createCell(9).setCellValue("币种");
|
||||
matrix.createCell(10).setCellValue("燃油附加费率");
|
||||
org.apache.poi.ss.usermodel.Row sub = row(sheet, 8);
|
||||
sub.createCell(3).setCellValue("最低收费");
|
||||
sub.createCell(4).setCellValue("元/KG");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 1, 1));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2));
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "中越集拼报价");
|
||||
JSONObject fields = new JSONObject(true);
|
||||
fields.put("sales_name", "中越集拼报价!C5");
|
||||
fields.put("origin", "中越集拼报价!F5");
|
||||
fields.put("commodity", "中越集拼报价!O5");
|
||||
fields.put("quote_date", "中越集拼报价!K5");
|
||||
mapping.put("fields", fields);
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 9);
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "B");
|
||||
columns.put("destination", "C");
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("sales_name", "LinMianXun");
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
facts.put("commodity", "茶叶");
|
||||
facts.put("quote_date", "2026-10-09");
|
||||
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("客户名称", read(sheet, 4, 2));
|
||||
assertEquals("始发地", read(sheet, 4, 4));
|
||||
assertEquals("货物类型", read(sheet, 4, 13));
|
||||
assertEquals("发货日期", read(sheet, 4, 10));
|
||||
assertEquals("LinMianXun", read(sheet, 5, 1));
|
||||
assertEquals("", read(sheet, 5, 2));
|
||||
assertEquals("上海", read(sheet, 5, 4));
|
||||
assertEquals("茶叶", read(sheet, 5, 13));
|
||||
assertEquals("2026-10-09", read(sheet, 5, 10));
|
||||
assertEquals("始发地", read(sheet, 7, 1));
|
||||
assertEquals("目的地", read(sheet, 7, 2));
|
||||
assertEquals("最低收费", read(sheet, 8, 3));
|
||||
assertEquals("", read(sheet, 7, 3));
|
||||
assertEquals("上海", read(sheet, 9, 1));
|
||||
assertEquals("胡志明", read(sheet, 9, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售没填客户名称时,客户名称格保持空白,不能写成业务员。
|
||||
* 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageLeavesCustomerBlankAndFillsTransitAndKgPrices() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-price", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-price-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("中越集拼报价");
|
||||
org.apache.poi.ss.usermodel.Row band = row(sheet, 4);
|
||||
band.createCell(1).setCellValue("业务员");
|
||||
band.createCell(2).setCellValue("客户名称");
|
||||
band.createCell(4).setCellValue("始发地");
|
||||
band.createCell(10).setCellValue("发货日期");
|
||||
band.createCell(11).setCellValue("时效");
|
||||
band.createCell(13).setCellValue("货物类型");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 2, 3));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 4, 5));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 11, 12));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, 13, 14));
|
||||
row(sheet, 5);
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(5, 5, 11, 12));
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
matrix.createCell(1).setCellValue("始发地");
|
||||
matrix.createCell(2).setCellValue("目的地");
|
||||
matrix.createCell(4).setCellValue("2-500KG");
|
||||
matrix.createCell(5).setCellValue("501-1000KG");
|
||||
matrix.createCell(6).setCellValue("1001-3000KG");
|
||||
matrix.createCell(7).setCellValue("3001-5000KG");
|
||||
matrix.createCell(8).setCellValue("5001KG以上");
|
||||
matrix.createCell(15).setCellValue("时效");
|
||||
org.apache.poi.ss.usermodel.Row sub = row(sheet, 8);
|
||||
sub.createCell(3).setCellValue("最低收费");
|
||||
sub.createCell(4).setCellValue("元/KG");
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 1, 1));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2));
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONArray routeRows = new JSONArray();
|
||||
JSONObject route = new JSONObject(true);
|
||||
route.put("origin", "广西壮族自治区");
|
||||
route.put("destination", "胡志明");
|
||||
route.put("currency", "CNY");
|
||||
route.put("transit_time", "N+3");
|
||||
routeRows.add(route);
|
||||
JSONArray rawLand = new JSONArray();
|
||||
JSONObject seg = new JSONObject(true);
|
||||
seg.put("origin", "广西壮族自治区");
|
||||
seg.put("destination", "胡志明");
|
||||
seg.put("weightPrices", JSONArray.parseArray(
|
||||
"[{\"min\":2,\"max\":500,\"price\":12.0},{\"min\":501,\"max\":1000,\"price\":11.5},"
|
||||
+ "{\"min\":1001,\"max\":3000,\"price\":11.0},{\"min\":3001,\"max\":5000,\"price\":10.5},"
|
||||
+ "{\"min\":5001,\"price\":10.0}]"));
|
||||
rawLand.add(seg);
|
||||
QuoteTemplateExcelSupport.copyMissingWeightPrices(routeRows, rawLand);
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "中越集拼报价");
|
||||
JSONObject fields = new JSONObject(true);
|
||||
fields.put("sales_name", "中越集拼报价!C5");
|
||||
fields.put("customer_name", "中越集拼报价!D5");
|
||||
fields.put("transit_time", "中越集拼报价!M5");
|
||||
mapping.put("fields", fields);
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 9);
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "B");
|
||||
columns.put("destination", "C");
|
||||
columns.put("transit_time", "P");
|
||||
JSONObject vehicleRates = new JSONObject(true);
|
||||
vehicleRates.put("2-500KG", "E");
|
||||
vehicleRates.put("501-1000KG", "F");
|
||||
vehicleRates.put("1001-3000KG", "G");
|
||||
vehicleRates.put("3001-5000KG", "H");
|
||||
vehicleRates.put("5001KG以上", "I");
|
||||
columns.put("vehicleRates", vehicleRates);
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("sales_name", "LuoXiaoHua");
|
||||
facts.put("transit_time", "N+3");
|
||||
facts.put("transit_days", "N+3");
|
||||
facts.put("route_rows", routeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("客户名称", read(sheet, 4, 2));
|
||||
assertEquals("时效", read(sheet, 4, 11));
|
||||
assertEquals("LuoXiaoHua", read(sheet, 5, 1));
|
||||
assertEquals("", read(sheet, 5, 2));
|
||||
assertEquals("N+3", read(sheet, 5, 11));
|
||||
assertEquals("2-500KG", read(sheet, 7, 4));
|
||||
assertEquals("时效", read(sheet, 7, 15));
|
||||
assertEquals("12.0", read(sheet, 9, 4));
|
||||
assertEquals("11.5", read(sheet, 9, 5));
|
||||
assertEquals("11.0", read(sheet, 9, 6));
|
||||
assertEquals("10.5", read(sheet, 9, 7));
|
||||
assertEquals("10.0", read(sheet, 9, 8));
|
||||
assertEquals("N+3", read(sheet, 9, 15));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配送费用表头上的「时效」不能把 N+3 写到右边空白格,列头蓝底和文字都要留着。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageDoesNotSpillTransitBesideDeliveryHeader() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-delivery-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-delivery-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("中越集拼报价");
|
||||
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
|
||||
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row band = row(sheet, 4);
|
||||
band.createCell(1).setCellValue("业务员");
|
||||
band.createCell(2).setCellValue("客户名称");
|
||||
band.createCell(4).setCellValue("始发地");
|
||||
band.createCell(11).setCellValue("时效");
|
||||
band.getCell(11).setCellStyle(blue);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 18);
|
||||
String[] labels = {"城市类别", "覆盖区域", "单价(元/票)", "燃油附加费率", "时效"};
|
||||
int[] cols = {8, 10, 12, 13, 14};
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]);
|
||||
c.setCellValue(labels[i]);
|
||||
c.setCellStyle(blue);
|
||||
}
|
||||
row(sheet, 19);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("transit_time", "N+3");
|
||||
facts.put("transit_days", "N+3");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("时效", read(sheet, 18, 14));
|
||||
assertEquals("燃油附加费率", read(sheet, 18, 13));
|
||||
assertEquals("", read(sheet, 18, 15));
|
||||
assertEquals("", read(sheet, 19, 15));
|
||||
assertEquals("N+3", read(sheet, 5, 11));
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND,
|
||||
sheet.getRow(18).getCell(14).getCellStyle().getFillPattern());
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND,
|
||||
sheet.getRow(18).getCell(13).getCellStyle().getFillPattern());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 目的地长地名不能按备注换行顶进蓝表头,红字要改回黑字。
|
||||
* 配送费的覆盖区域、燃油附加费率不能写成 CNY,币种只留在运价表「币种」列。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageKeepsCityOutOfHeaderAndCurrencyOutOfFuel() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-city-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-city-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("中越集拼报价");
|
||||
org.apache.poi.ss.usermodel.Font white = wb.createFont();
|
||||
white.setColor(org.apache.poi.ss.usermodel.IndexedColors.WHITE.getIndex());
|
||||
white.setBold(true);
|
||||
org.apache.poi.ss.usermodel.CellStyle band = wb.createCellStyle();
|
||||
band.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
band.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
band.setFont(white);
|
||||
org.apache.poi.ss.usermodel.Row bandRow = row(sheet, 4);
|
||||
org.apache.poi.ss.usermodel.Cell sales = bandRow.createCell(1);
|
||||
sales.setCellValue("业务员");
|
||||
sales.setCellStyle(band);
|
||||
org.apache.poi.ss.usermodel.CellStyle matrixBlue = wb.createCellStyle();
|
||||
matrixBlue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
|
||||
matrixBlue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
matrixBlue.setFont(white);
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
org.apache.poi.ss.usermodel.Cell originHead = matrix.createCell(1);
|
||||
originHead.setCellValue("始发地");
|
||||
originHead.setCellStyle(matrixBlue);
|
||||
org.apache.poi.ss.usermodel.Cell destHead = matrix.createCell(2);
|
||||
destHead.setCellValue("目的地");
|
||||
destHead.setCellStyle(matrixBlue);
|
||||
org.apache.poi.ss.usermodel.Cell currencyHead = matrix.createCell(9);
|
||||
currencyHead.setCellValue("币种");
|
||||
// 运价表头同样有燃油附加费率和时效。不能因此整段跳过,否则始发地、目的地都不写。
|
||||
matrix.createCell(10).setCellValue("燃油附加费率");
|
||||
matrix.createCell(15).setCellValue("时效");
|
||||
matrix.getCell(15).setCellStyle(matrixBlue);
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 2, 2));
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(7, 8, 9, 9));
|
||||
org.apache.poi.ss.usermodel.Row sub = row(sheet, 8);
|
||||
sub.createCell(3).setCellValue("最低收费");
|
||||
sub.createCell(4).setCellValue("元/KG");
|
||||
org.apache.poi.ss.usermodel.Font red = wb.createFont();
|
||||
red.setColor(org.apache.poi.ss.usermodel.IndexedColors.RED.getIndex());
|
||||
org.apache.poi.ss.usermodel.CellStyle redOnSky = wb.createCellStyle();
|
||||
redOnSky.cloneStyleFrom(matrixBlue);
|
||||
redOnSky.setFont(red);
|
||||
redOnSky.setWrapText(true);
|
||||
redOnSky.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.TOP);
|
||||
currencyHead.setCellStyle(redOnSky);
|
||||
matrix.getCell(10).setCellStyle(redOnSky);
|
||||
org.apache.poi.ss.usermodel.Row data = row(sheet, 9);
|
||||
org.apache.poi.ss.usermodel.Font blackData = wb.createFont();
|
||||
blackData.setFontName("宋体");
|
||||
blackData.setFontHeightInPoints((short) 10);
|
||||
blackData.setColor(org.apache.poi.ss.usermodel.IndexedColors.BLACK.getIndex());
|
||||
org.apache.poi.ss.usermodel.CellStyle priceStyle = wb.createCellStyle();
|
||||
priceStyle.setFont(blackData);
|
||||
data.createCell(4).setCellValue(12);
|
||||
data.getCell(4).setCellStyle(priceStyle);
|
||||
org.apache.poi.ss.usermodel.Cell dest = data.createCell(2);
|
||||
dest.setCellStyle(redOnSky);
|
||||
org.apache.poi.ss.usermodel.Row delivery = row(sheet, 18);
|
||||
delivery.createCell(8).setCellValue("城市类别");
|
||||
delivery.getCell(8).setCellStyle(matrixBlue);
|
||||
delivery.createCell(10).setCellValue("覆盖区域");
|
||||
delivery.getCell(10).setCellStyle(matrixBlue);
|
||||
delivery.createCell(12).setCellValue("单价(元/票)");
|
||||
delivery.getCell(12).setCellStyle(matrixBlue);
|
||||
delivery.createCell(13).setCellValue("燃油附加费率");
|
||||
delivery.getCell(13).setCellStyle(redOnSky);
|
||||
delivery.createCell(14).setCellValue("时效");
|
||||
delivery.getCell(14).setCellStyle(matrixBlue);
|
||||
row(sheet, 19).createCell(12).setCellValue(200);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONArray routeRows = new JSONArray();
|
||||
JSONObject route = new JSONObject(true);
|
||||
route.put("origin", "广西壮族自治区");
|
||||
route.put("destination", "胡志明Hồ Chí Minh");
|
||||
route.put("currency", "CNY");
|
||||
routeRows.add(route);
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "中越集拼报价");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject rate = new JSONObject(true);
|
||||
rate.put("headerRow", 8);
|
||||
rate.put("dataStartRow", 10);
|
||||
JSONObject rateCols = new JSONObject(true);
|
||||
rateCols.put("origin", "B");
|
||||
rateCols.put("destination", "C");
|
||||
rateCols.put("currency", "J");
|
||||
rate.put("columns", rateCols);
|
||||
regions.add(rate);
|
||||
JSONObject deliveryRegion = new JSONObject(true);
|
||||
deliveryRegion.put("headerRow", 19);
|
||||
deliveryRegion.put("dataStartRow", 20);
|
||||
JSONObject deliveryCols = new JSONObject(true);
|
||||
deliveryCols.put("origin", "I");
|
||||
deliveryCols.put("destination", "K");
|
||||
deliveryCols.put("currency", "N");
|
||||
deliveryRegion.put("columns", deliveryCols);
|
||||
regions.add(deliveryRegion);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("route_rows", routeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("目的地", read(sheet, 7, 2));
|
||||
assertEquals("胡志明Hồ Chí Minh", read(sheet, 9, 2));
|
||||
assertEquals("CNY", read(sheet, 9, 9));
|
||||
assertEquals(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER,
|
||||
sheet.getRow(9).getCell(2).getCellStyle().getVerticalAlignment());
|
||||
assertFalse(QuoteTemplateExcelSupport.fontIsRed(sheet.getRow(9).getCell(2)));
|
||||
assertFalse(sheet.getRow(9).getCell(2).getCellStyle().getShrinkToFit());
|
||||
assertEquals(QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(4)),
|
||||
QuoteTemplateExcelSupport.fontHeight(sheet.getRow(9).getCell(2)));
|
||||
assertEquals("始发地", read(sheet, 7, 1));
|
||||
assertEquals("币种", read(sheet, 7, 9));
|
||||
byte[] originFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
|
||||
byte[] currencyFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(9));
|
||||
byte[] salesFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(4).getCell(1));
|
||||
byte[] deliveryFuelFill = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(18).getCell(13));
|
||||
assertArrayEquals(originFill, currencyFill);
|
||||
assertFalse(java.util.Arrays.equals(originFill, salesFill));
|
||||
assertArrayEquals(originFill, deliveryFuelFill);
|
||||
assertEquals("", read(sheet, 19, 8));
|
||||
assertEquals("", read(sheet, 19, 10));
|
||||
assertEquals("燃油附加费率", read(sheet, 18, 13));
|
||||
assertEquals("", read(sheet, 19, 13));
|
||||
assertEquals("200.0", read(sheet, 19, 12));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。
|
||||
* TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。
|
||||
@@ -1183,6 +1692,133 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境整车:列头是「起始地」,城市必须写在下一行白格,不能盖掉列名。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsOriginHeader() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-ftl-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-ftl-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
org.apache.poi.ss.usermodel.Row top = row(sheet, 6);
|
||||
top.createCell(1).setCellValue("路线");
|
||||
top.createCell(3).setCellValue("车型");
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
|
||||
head.createCell(1).setCellValue("起始地");
|
||||
head.createCell(2).setCellValue("目的地");
|
||||
head.createCell(3).setCellValue("3T");
|
||||
head.createCell(4).setCellValue("5T");
|
||||
head.createCell(5).setCellValue("8T");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车");
|
||||
JSONObject fields = new JSONObject(true);
|
||||
fields.put("origin", "跨境整车!B8");
|
||||
fields.put("destination", "跨境整车!C8");
|
||||
mapping.put("fields", fields);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "郑州");
|
||||
facts.put("destination", "河内");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("起始地", read(sheet, 7, 1));
|
||||
assertEquals("目的地", read(sheet, 7, 2));
|
||||
assertEquals("郑州", read(sheet, 8, 1));
|
||||
assertEquals("河内", read(sheet, 8, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境整车国内段只写境内起点,境外段只写境外终点,不能两段都写成郑州→吉隆坡。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlSplitsDomesticAndOverseasOd() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-split-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-split-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
|
||||
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
|
||||
head1.createCell(1).setCellValue("起始地");
|
||||
head1.createCell(2).setCellValue("目的地");
|
||||
head1.createCell(3).setCellValue("3T");
|
||||
head1.createCell(4).setCellValue("5T");
|
||||
head1.createCell(15).setCellValue("时效");
|
||||
head1.createCell(16).setCellValue("币种");
|
||||
head1.createCell(17).setCellValue("燃油附加费率");
|
||||
head1.createCell(18).setCellValue("含税合计");
|
||||
row(sheet, 24).createCell(0).setCellValue("二、境外段报价");
|
||||
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
|
||||
head2.createCell(1).setCellValue("起始地");
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(4).setCellValue("2.5T");
|
||||
head2.createCell(15).setCellValue("时效");
|
||||
head2.createCell(16).setCellValue("币种");
|
||||
head2.createCell(17).setCellValue("燃油附加费率");
|
||||
head2.createCell(18).setCellValue("含税合计");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject inland = new JSONObject(true);
|
||||
inland.put("title", "国内段报价");
|
||||
inland.put("headerRow", 8);
|
||||
inland.put("dataStartRow", 8);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外段报价");
|
||||
overseas.put("headerRow", 27);
|
||||
overseas.put("dataStartRow", 27);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "郑州");
|
||||
facts.put("destination", "吉隆坡");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("transit_days", "N+3");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("起始地", read(sheet, 7, 1));
|
||||
assertEquals("郑州", read(sheet, 8, 1));
|
||||
assertEquals("", read(sheet, 8, 2));
|
||||
assertEquals("起始地", read(sheet, 26, 1));
|
||||
assertEquals("", read(sheet, 27, 1));
|
||||
assertEquals("吉隆坡", read(sheet, 27, 2));
|
||||
assertEquals("N+3", read(sheet, 8, 15));
|
||||
assertEquals("CNY", read(sheet, 8, 16));
|
||||
assertEquals("", read(sheet, 8, 17));
|
||||
assertEquals("", read(sheet, 8, 18));
|
||||
assertEquals("N+3", read(sheet, 27, 15));
|
||||
assertEquals("CNY", read(sheet, 27, 16));
|
||||
assertEquals("", read(sheet, 27, 17));
|
||||
assertEquals("", read(sheet, 27, 18));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车:4.2米/3T 这类车型费用也是标签右侧,不是海运费目表。
|
||||
*/
|
||||
@@ -1213,6 +1849,27 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务员写中文名,不写企微账号。客户名称只留销售填的,账号不能占这一格。
|
||||
*/
|
||||
@Test
|
||||
public void buildFillFactsUsesStaffNameAndKeepsCustomer() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("sales_name", "林绵勋");
|
||||
facts.put("客户名称", "南宁茶业");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO1", facts, new HashMap<>(), "LinMianXun");
|
||||
assertEquals("林绵勋", out.getString("sales_name"));
|
||||
assertEquals("南宁茶业", out.getString("customer_name"));
|
||||
|
||||
facts.put("customer_name", "LinMianXun");
|
||||
facts.remove("客户名称");
|
||||
JSONObject cleared = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO1", facts, new HashMap<>(), "LinMianXun");
|
||||
assertEquals("林绵勋", cleared.getString("sales_name"));
|
||||
assertEquals("", cleared.getString("customer_name"));
|
||||
}
|
||||
|
||||
/**
|
||||
* TMS 陆运 fee_lines 要投影成模板标签码,写出单时才能对上 元/CBM。
|
||||
*/
|
||||
@@ -1451,6 +2108,113 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内拼车:元/CBM、元/KG 必须跟「始发站」同一浅蓝,不能留成顶栏深蓝。
|
||||
*/
|
||||
@Test
|
||||
public void landDomesticLtlUnitHeaderRowIsKept() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-ltl-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-ltl-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("国内拼车");
|
||||
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
|
||||
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
|
||||
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
|
||||
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
|
||||
head.createCell(1).setCellValue("始发站");
|
||||
head.getCell(1).setCellStyle(sky);
|
||||
head.createCell(2).setCellValue("目的地");
|
||||
head.getCell(2).setCellStyle(sky);
|
||||
head.createCell(5).setCellValue("运输费");
|
||||
head.getCell(5).setCellStyle(sky);
|
||||
org.apache.poi.ss.usermodel.Row units = row(sheet, 8);
|
||||
units.createCell(5).setCellValue("元/CBM");
|
||||
units.getCell(5).setCellStyle(navy);
|
||||
units.createCell(6).setCellValue("元/KG");
|
||||
units.getCell(6).setCellStyle(navy);
|
||||
row(sheet, 9);
|
||||
boolean[] keep = QuoteTemplateExcelSupport.markLandHeaderRows(sheet);
|
||||
assertTrue(keep[7]);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "江苏南京市");
|
||||
facts.put("price_per_cbm", "100");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("元/CBM", read(sheet, 8, 5));
|
||||
assertEquals("元/KG", read(sheet, 8, 6));
|
||||
byte[] unitRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(5));
|
||||
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
|
||||
assertTrue(unitRgb != null && unitRgb.length >= 3);
|
||||
assertTrue(headRgb != null && headRgb.length >= 3);
|
||||
assertArrayEquals(headRgb, unitRgb);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车:车型格「4.2米/3T」是浅蓝表头,出单后不能变成业务员那种深蓝。
|
||||
*/
|
||||
@Test
|
||||
public void landDomesticFtlVehicleHeaderFillSurvives() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-ftl-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-ftl-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("国内整车");
|
||||
org.apache.poi.ss.usermodel.CellStyle sky = wb.createCellStyle();
|
||||
sky.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.SKY_BLUE.getIndex());
|
||||
sky.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.CellStyle navy = wb.createCellStyle();
|
||||
navy.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
navy.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
|
||||
head.createCell(1).setCellValue("始发站");
|
||||
head.getCell(1).setCellStyle(sky);
|
||||
head.createCell(2).setCellValue("目的地");
|
||||
head.getCell(2).setCellStyle(sky);
|
||||
head.createCell(3).setCellValue("车型/费用");
|
||||
head.getCell(3).setCellStyle(sky);
|
||||
head.createCell(8).setCellValue("时效");
|
||||
head.getCell(8).setCellStyle(sky);
|
||||
org.apache.poi.ss.usermodel.Row veh = row(sheet, 8);
|
||||
veh.createCell(3).setCellValue("4.2米/3T");
|
||||
veh.getCell(3).setCellStyle(navy);
|
||||
veh.createCell(4).setCellValue("7.6米/8T");
|
||||
veh.getCell(4).setCellStyle(navy);
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "北京");
|
||||
facts.put("destination", "上海");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("4.2米/3T", read(sheet, 8, 3));
|
||||
assertEquals("7.6米/8T", read(sheet, 8, 4));
|
||||
byte[] vehRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(8).getCell(3));
|
||||
byte[] headRgb = QuoteTemplateExcelSupport.readFillRgb(sheet.getRow(7).getCell(1));
|
||||
assertTrue(vehRgb != null && vehRgb.length >= 3);
|
||||
assertTrue(headRgb != null && headRgb.length >= 3);
|
||||
assertArrayEquals(headRgb, vehRgb);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
|
||||
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
|
||||
return exist == null ? sheet.createRow(r) : exist;
|
||||
|
||||
+30
@@ -133,6 +133,36 @@ public class QuoteTemplateMatcherTest {
|
||||
assertEquals("跨境整车", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminDomesticFtlKeywordDoesNotStealCrossFtl() {
|
||||
// 后台国内整车模板关键词常写「国内整车」;别名「整车」不得抢走跨境整车(WO202610030032)。
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
|
||||
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输类型", "跨境整车");
|
||||
facts.put("线路类别", "东南亚");
|
||||
facts.put("运输分类", "普件(东南亚、中亚、中欧涉及国内+国外段)");
|
||||
facts.put("起运港", "上海");
|
||||
facts.put("目的港", "春疆货场");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("跨境整车报价单", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminDomesticFtlStillHitsDomesticType() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
|
||||
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输类型", "国内运输整车");
|
||||
facts.put("线路类别", "国内长途/零担");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("国内陆运整车报价单", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminKeywordDomesticLclMatchesTransportType() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
VITE_USE_MOCK = false
|
||||
|
||||
# 网站标题(浏览器标签后缀;缺省会显示「页面名 undefined」)
|
||||
VITE_GLOB_APP_TITLE = 友通达询价系统
|
||||
VITE_GLOB_APP_TITLE = 友通达智能体运营后台
|
||||
|
||||
# 简称,用于生产 _app.config.js 变量名;勿空格/数字开头
|
||||
VITE_GLOB_APP_SHORT_NAME = YtdInquiryAdmin
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useTitle() {
|
||||
|
||||
const tTitle = t(route?.meta?.title as string);
|
||||
// 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」
|
||||
const appTitle = title || '友通达询价系统';
|
||||
const appTitle = title || '友通达智能体运营后台';
|
||||
pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`;
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
const profileOpen = ref(false);
|
||||
const passwordOpen = ref(false);
|
||||
|
||||
const pageTitle = computed(() => String(route.meta?.title || '询价机器人后台'));
|
||||
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
||||
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
||||
|
||||
const username = computed(() => userStore.getUserInfo?.username || '');
|
||||
@@ -207,7 +207,7 @@
|
||||
<div class="admin-brand-icon">
|
||||
<ProtoIcon name="bot" :size="16" />
|
||||
</div>
|
||||
<div class="admin-brand-title">询价机器人</div>
|
||||
<div class="admin-brand-title">友通达智能体运营后台</div>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
<template v-for="group in navGroups" :key="group.title">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 账号管理:对接 Jeecg /sys/user(主账登录账号)。
|
||||
* 与员工岗位页分离——本页管后台登录,员工页管企微业务角色。
|
||||
@@ -305,7 +305,7 @@ function removeAccount(row: AccountRow) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card is-list-page">
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BasicColumn, FormSchema } from '/@/components/Table';
|
||||
export const columns: BasicColumn[] = [
|
||||
{ title: '分类', dataIndex: 'categoryTitle', width: 100 },
|
||||
{ title: '分类码', dataIndex: 'categoryCode', width: 120 },
|
||||
{ title: '调用编码', dataIndex: 'replyCode', width: 180 },
|
||||
{ title: '触发场景', dataIndex: 'triggerScene', width: 220 },
|
||||
{ title: '回复正文', dataIndex: 'replyText', align: 'left' },
|
||||
{
|
||||
@@ -28,6 +29,9 @@ export const searchFormSchema: FormSchema[] = [
|
||||
{ label: '协商中', value: 'negotiating' },
|
||||
{ label: '未成交', value: 'lost' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
{ label: '事件异常', value: 'event-ex' },
|
||||
{ label: '系统异常', value: 'sys-ex' },
|
||||
{ label: '转人工', value: 'transfer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -48,10 +52,19 @@ export const formSchema: FormSchema[] = [
|
||||
{ label: '协商中 negotiating', value: 'negotiating' },
|
||||
{ label: '未成交 lost', value: 'lost' },
|
||||
{ label: '已关闭 closed', value: 'closed' },
|
||||
{ label: '事件异常 event-ex', value: 'event-ex' },
|
||||
{ label: '系统异常 sys-ex', value: 'sys-ex' },
|
||||
{ label: '转人工 transfer', value: 'transfer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ field: 'categoryTitle', label: '分类标题', component: 'Input', required: true },
|
||||
{
|
||||
field: 'replyCode',
|
||||
label: '调用编码',
|
||||
component: 'Input',
|
||||
componentProps: { placeholder: '如 lost.ask_reason,已有编码不可改' },
|
||||
},
|
||||
{ field: 'triggerScene', label: '触发场景', component: 'Input', required: true },
|
||||
{
|
||||
field: 'replyText',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
/**
|
||||
* 机器人回复语:对接主账 /inquiry/botReply。
|
||||
* 分类壳用本地元数据;条目内容全部来自库。
|
||||
* 调用编码仅展示/预置,本期不向智能体下发。
|
||||
*/
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
@@ -21,12 +22,14 @@ import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
|
||||
const categories = ref<ReplyCategory[]>(emptyCategories());
|
||||
const loading = ref(false);
|
||||
const totalCount = ref(0);
|
||||
const modalOpen = ref(false);
|
||||
const editing = ref<{ categoryId: string; item: ReplyItem | null }>({
|
||||
categoryId: "",
|
||||
item: null,
|
||||
});
|
||||
const form = reactive({
|
||||
replyCode: "",
|
||||
trigger: "",
|
||||
text: "",
|
||||
enabled: true,
|
||||
@@ -52,6 +55,7 @@ function groupByCategory(list: any[]) {
|
||||
if (row.categoryTitle) cat.title = row.categoryTitle;
|
||||
cat.items.push({
|
||||
id: String(row.id),
|
||||
replyCode: row.replyCode || "",
|
||||
trigger: row.triggerScene || "",
|
||||
text: row.replyText || "",
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
@@ -64,7 +68,14 @@ async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getReplyListAll();
|
||||
const list = Array.isArray(res) ? res : res?.result || res?.records || [];
|
||||
const list = Array.isArray(res)
|
||||
? res
|
||||
: Array.isArray(res?.result)
|
||||
? res.result
|
||||
: Array.isArray(res?.records)
|
||||
? res.records
|
||||
: [];
|
||||
totalCount.value = list.length;
|
||||
categories.value = groupByCategory(list as any[]);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || "加载回复语失败");
|
||||
@@ -93,13 +104,14 @@ async function toggle(item: ReplyItem) {
|
||||
|
||||
function openCreate(categoryId?: string) {
|
||||
editing.value = { categoryId: categoryId || categories.value[0]?.id || "inquiring", item: null };
|
||||
Object.assign(form, { trigger: "", text: "", enabled: true });
|
||||
Object.assign(form, { replyCode: "", trigger: "", text: "", enabled: true });
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(categoryId: string, item: ReplyItem) {
|
||||
editing.value = { categoryId, item };
|
||||
Object.assign(form, {
|
||||
replyCode: item.replyCode || "",
|
||||
trigger: item.trigger,
|
||||
text: item.text,
|
||||
enabled: item.enabled,
|
||||
@@ -114,7 +126,7 @@ async function save() {
|
||||
}
|
||||
const cat = categories.value.find((c) => c.id === editing.value.categoryId);
|
||||
if (!cat) return;
|
||||
const body = {
|
||||
const body: Record<string, any> = {
|
||||
id: editing.value.item?.id,
|
||||
categoryCode: cat.id,
|
||||
categoryTitle: cat.title,
|
||||
@@ -122,6 +134,11 @@ async function save() {
|
||||
replyText: form.text.trim(),
|
||||
enabled: form.enabled ? 1 : 0,
|
||||
};
|
||||
if (editing.value.item?.replyCode) {
|
||||
body.replyCode = editing.value.item.replyCode;
|
||||
} else if (form.replyCode.trim()) {
|
||||
body.replyCode = form.replyCode.trim();
|
||||
}
|
||||
try {
|
||||
await saveReply(body, Boolean(editing.value.item));
|
||||
message.success(editing.value.item ? "话术已更新" : "话术已添加");
|
||||
@@ -141,6 +158,7 @@ async function save() {
|
||||
</Teleport>
|
||||
|
||||
<div v-if="loading" style="padding: 24px; color: #94a3b8">加载中…</div>
|
||||
<p v-else class="reply-total">共 {{ totalCount }} 条(按现网整理表应为 51 条;事件异常本期无条目)</p>
|
||||
|
||||
<article v-for="cat in categories" :key="cat.id" class="reply-category">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between">
|
||||
@@ -154,6 +172,7 @@ async function save() {
|
||||
<div v-for="item in cat.items" :key="item.id" class="reply-item">
|
||||
<div style="min-width: 0">
|
||||
<div class="reply-meta">触发:{{ item.trigger }}</div>
|
||||
<div v-if="item.replyCode" class="reply-meta">调用编码:{{ item.replyCode }}</div>
|
||||
<div class="reply-text">{{ item.text }}</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; flex-shrink: 0">
|
||||
@@ -172,7 +191,7 @@ async function save() {
|
||||
<AdminModal
|
||||
v-model:open="modalOpen"
|
||||
:title="editing.item ? '编辑话术' : '添加话术'"
|
||||
size="md"
|
||||
size="lg"
|
||||
ok-text="保存"
|
||||
@ok="save"
|
||||
>
|
||||
@@ -182,13 +201,28 @@ async function save() {
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">调用编码</label>
|
||||
<input
|
||||
v-model="form.replyCode"
|
||||
class="field"
|
||||
type="text"
|
||||
:disabled="Boolean(editing.item?.replyCode)"
|
||||
placeholder="如 lost.ask_reason;已有编码不可改"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">触发条件 <span class="req">*</span></label>
|
||||
<input v-model="form.trigger" class="field" type="text" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-row form-row-tall">
|
||||
<label class="lbl">回复正文 <span class="req">*</span></label>
|
||||
<textarea v-model="form.text" class="field" rows="5" placeholder="支持 {{ticket_no}} 等变量" />
|
||||
<textarea
|
||||
v-model="form.text"
|
||||
class="field field-tall"
|
||||
rows="12"
|
||||
placeholder="支持 {{ticket_no}} 等变量"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="lbl">状态</label>
|
||||
@@ -201,6 +235,22 @@ async function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reply-total {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #667085;
|
||||
}
|
||||
.form-row-tall {
|
||||
align-items: start;
|
||||
}
|
||||
textarea.field-tall {
|
||||
height: 240px !important;
|
||||
min-height: 240px !important;
|
||||
padding: 8px 10px !important;
|
||||
line-height: 1.5 !important;
|
||||
resize: vertical;
|
||||
}
|
||||
.check-line {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
export type ReplyItem = {
|
||||
id: string;
|
||||
/** 智能体日后按此码取文案;空表示未接入。 */
|
||||
replyCode?: string;
|
||||
trigger: string;
|
||||
text: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 长内容折叠:超过 maxHeight 时收起,底部下箭头点开展开,再点收起。
|
||||
* 用于工单列表「询价需求 / 报价信息」和详情同名区块,避免一行撑满整屏。
|
||||
* 副作用:无写库。内容变化(contentKey)时重新量高并默认收起。
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 收起时最大高度(px) */
|
||||
maxHeight?: number;
|
||||
/** 内容身份:工单号/区块名变化时重测并收起 */
|
||||
contentKey?: string | number;
|
||||
/** 有费用明细等:即使收起后高度不够,也显示下箭头 */
|
||||
forceToggle?: boolean;
|
||||
/**
|
||||
* 收起时是否用 maxHeight 裁切内容。
|
||||
* 列表报价只藏费用明细、摘要必须完整露出时关掉,避免「CNY / 第二条报价」被切半。
|
||||
*/
|
||||
clipCollapsed?: boolean;
|
||||
}>(),
|
||||
{
|
||||
maxHeight: 120,
|
||||
contentKey: "",
|
||||
forceToggle: false,
|
||||
clipCollapsed: true,
|
||||
},
|
||||
);
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
const expanded = ref(false);
|
||||
const overflow = ref(false);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function measure() {
|
||||
const el = bodyRef.value;
|
||||
if (!el) {
|
||||
overflow.value = !!props.forceToggle;
|
||||
return;
|
||||
}
|
||||
const natural = el.scrollHeight;
|
||||
overflow.value = !!props.forceToggle || natural > props.maxHeight + 2;
|
||||
if (!overflow.value) {
|
||||
expanded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!overflow.value) return;
|
||||
expanded.value = !expanded.value;
|
||||
nextTick(measure);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(measure);
|
||||
if (typeof ResizeObserver !== "undefined" && bodyRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(bodyRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.contentKey, props.maxHeight, props.forceToggle],
|
||||
() => {
|
||||
expanded.value = false;
|
||||
nextTick(measure);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="expand-clamp"
|
||||
:class="{ 'is-expanded': expanded, 'is-overflow': props.clipCollapsed && overflow && !expanded }"
|
||||
>
|
||||
<div
|
||||
ref="bodyRef"
|
||||
class="expand-clamp-body"
|
||||
:style="
|
||||
props.clipCollapsed && overflow && !expanded ? { maxHeight: maxHeight + 'px' } : undefined
|
||||
"
|
||||
>
|
||||
<slot :expanded="expanded" />
|
||||
</div>
|
||||
<button
|
||||
v-if="overflow"
|
||||
type="button"
|
||||
class="expand-clamp-toggle"
|
||||
:class="{ 'is-open': expanded }"
|
||||
:title="expanded ? '收起' : '展开全部'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ProtoIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expand-clamp {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.expand-clamp-body {
|
||||
overflow: visible;
|
||||
}
|
||||
.expand-clamp.is-overflow .expand-clamp-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-clamp.is-overflow:not(.is-expanded)::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
height: 28px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff 90%);
|
||||
}
|
||||
.expand-clamp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 2px 0 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.expand-clamp-toggle:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.expand-clamp-toggle :deep(svg) {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.expand-clamp-toggle.is-open :deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="login-brand-mark">
|
||||
<ProtoIcon name="bot" :size="22" />
|
||||
</div>
|
||||
<h1>询价机器人后台</h1>
|
||||
<h1>友通达智能体运营后台</h1>
|
||||
<p>管理询价工单、人员配置与系统权限</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 机器人回复语:完整照搬原型 admin-bot-replies.html(V1.0.24)。
|
||||
* 共 9 类 37 条;与 deploy/sql/inquiry_bot_reply_proto_seed_v1.sql 同源。
|
||||
* 机器人回复语 mock:与现网整理表、migration_inq_bot_reply_replace_v1.sql 同源。
|
||||
* 正式页走主账 /inquiry/botReply;本文件仅对照,共 51 条。
|
||||
*/
|
||||
|
||||
export type ReplyItem = {
|
||||
id: string;
|
||||
replyCode?: string;
|
||||
trigger: string;
|
||||
text: string;
|
||||
enabled: boolean;
|
||||
@@ -26,47 +27,229 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-blue",
|
||||
items: [
|
||||
{
|
||||
id: "r1",
|
||||
id: "brv10000000000000000000000000001",
|
||||
replyCode: "inquiring.first_chat",
|
||||
trigger: "销售首次单聊机器人",
|
||||
text: "你好,我是询价机器人。请直接描述客户需求,例如「上海到洛杉矶空运,3 件 180kg,下周三前到」,我会解析需求并查询 TMS 标准报价。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
trigger: "销售上传 Excel / 图片附件",
|
||||
text: "已收到附件,正在识别路线、货量、品名和时效要求,原始文件将存为工单需求附件。工单 {{ticket_no}} 状态:询价中。",
|
||||
id: "brv10000000000000000000000000002",
|
||||
replyCode: "inquiring.ask_transport",
|
||||
trigger: "未识别运输方式",
|
||||
text: "请回复数字或文字,告知我询价的运输方式:\n1.陆运\n2.海运\n3.空运",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
trigger: "AI 解析成功 → 创建工单",
|
||||
text: "已识别:{{biz_type}} · {{origin}} → {{destination}} · {{cargo_desc}}。工单 {{ticket_no}} 已创建,正在查询 TMS 标准报价…",
|
||||
id: "brv10000000000000000000000000003",
|
||||
replyCode: "inquiring.continue_need_wo",
|
||||
trigger: "续办但未提供工单号",
|
||||
text: "请告诉我要继续的工单号,例如 WO202609140034。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
trigger: "AI 解析失败 → 要求补充参数",
|
||||
text: "未能完全理解需求,请补充或确认:起运地?目的地?件数?毛重?体积?如有品名和时效要求,也请一并补充。",
|
||||
id: "brv10000000000000000000000000004",
|
||||
replyCode: "inquiring.continue_not_found",
|
||||
trigger: "续办找不到工单",
|
||||
text: "找不到工单 {{ticket_no}},请核对工单号后再试。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r5",
|
||||
trigger: "TMS 命中标准价 → 询问是否内部协同",
|
||||
text: "TMS 标准报价合计 {{system_quote}},预计时效 {{eta}}。价格明细:空运价、操作费、文件费、报关费、提货费、目的地杂费、其他附加费。{{history_hint}}是否需要咨询内部{{internal_role_label}}人员,或直接生成报价单?",
|
||||
id: "brv10000000000000000000000000005",
|
||||
replyCode: "inquiring.image_recognizing",
|
||||
trigger: "销售发送图片",
|
||||
text: "正在识别图片",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r6",
|
||||
trigger: "销售确认创建内部协同群",
|
||||
text: "正在创建内部协同群「{{group_name}}」,并拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人。工单 {{ticket_no}} 保持「询价中」,等待内部反馈。",
|
||||
id: "brv10000000000000000000000000006",
|
||||
replyCode: "inquiring.image_tech_fail",
|
||||
trigger: "图片识别失败",
|
||||
text: "图片暂时没认出来,请再发一张或直接打字。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r7",
|
||||
trigger: "内部人员回复手工报价",
|
||||
text: "收到 {{staff_name}}({{staff_role}})的手工报价,已结构化处理。报价 {{manual_quote}},附件 {{quote_attachment}} 已存入工单,正在整理报价方案…",
|
||||
id: "brv10000000000000000000000000007",
|
||||
replyCode: "inquiring.attach_recognizing",
|
||||
trigger: "销售发送附件",
|
||||
text: "正在识别附件",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000008",
|
||||
replyCode: "inquiring.attach_tech_fail",
|
||||
trigger: "附件识别失败",
|
||||
text: "附件暂时没认出来,请再传一次或直接打字。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000009",
|
||||
replyCode: "inquiring.attach_h5_expired",
|
||||
trigger: "H5 上传链接过期",
|
||||
text: "已过期,请回企微重新说附件询价。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000010",
|
||||
replyCode: "inquiring.attach_h5_ok",
|
||||
trigger: "H5 上传成功",
|
||||
text: "已收到,请回企微查看识别结果。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000011",
|
||||
replyCode: "inquiring.pull_group_manual",
|
||||
trigger: "请手动拉人进群并发送工单号",
|
||||
text: "请手动拉相关人员和智能体到原有群,并在群聊发送工单编号{{ticket_no}}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000012",
|
||||
replyCode: "inquiring.skip_collab_ack",
|
||||
trigger: "销售跳过内部协同",
|
||||
text: "好的,已跳过内部协同。正在按 TMS 标准报价生成报价单草稿…",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000013",
|
||||
replyCode: "inquiring.group_created",
|
||||
trigger: "协同群创建成功",
|
||||
text: "协同群已创建,群名与工单号一致:{{ticket_no}}。已邀请:销售{{sales_name}}、产品{{product_names}}。请在群聊中继续确认报价。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000014",
|
||||
replyCode: "inquiring.group_exists",
|
||||
trigger: "协同群已存在",
|
||||
text: "协同群已在,群名与工单号一致:{{ticket_no}}。请到该群继续确认报价。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000015",
|
||||
replyCode: "inquiring.sea_no_staff",
|
||||
trigger: "海运没有可拉的产品人员",
|
||||
text: "没有可拉的产品人员。请先在后台「员工管理」维护本线路的海运产品岗后再试。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000016",
|
||||
replyCode: "inquiring.land_no_staff",
|
||||
trigger: "陆运没有可拉的产品人员",
|
||||
text: "没有可拉的产品人员。请先在后台「员工管理」维护本线路的陆运产品岗后再试。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000017",
|
||||
replyCode: "inquiring.group_create_fail",
|
||||
trigger: "建群失败",
|
||||
text: "建群失败,请稍后重试。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000018",
|
||||
replyCode: "inquiring.quote_generating",
|
||||
trigger: "报价单生成中",
|
||||
text: "报价单正在生成,请稍候。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000019",
|
||||
replyCode: "inquiring.air_ticket_not_found",
|
||||
trigger: "空运群找不到工单",
|
||||
text: "找不到工单{{ticket_no}},请核对工单号后再试。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000020",
|
||||
replyCode: "inquiring.air_ticket_not_air",
|
||||
trigger: "工单不是空运单",
|
||||
text: "工单{{ticket_no}}不是空运单,不能在空运群里跟。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000021",
|
||||
replyCode: "inquiring.air_ticket_occupied",
|
||||
trigger: "空运工单已在另一个群",
|
||||
text: "工单{{ticket_no}}已经在另一个群跟了,请到那个群继续。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000022",
|
||||
replyCode: "inquiring.group_only_product",
|
||||
trigger: "非产品人员在群里报价",
|
||||
text: "只有产品能报价格,请产品在群里发价格或 Excel/PDF",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000023",
|
||||
replyCode: "inquiring.group_file_saved",
|
||||
trigger: "群附件已保存",
|
||||
text: "附件已保存后台",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000024",
|
||||
replyCode: "inquiring.air_lock_need_wo",
|
||||
trigger: "空运锁舱/释放未带工单号",
|
||||
text: "锁舱或释放舱位请带上工单号,例如 WO202609160001锁舱。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000025",
|
||||
replyCode: "inquiring.air_lock_need_quote",
|
||||
trigger: "没有报价不能锁舱",
|
||||
text: "工单{{ticket_no}}还没有报价,不能锁舱。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000026",
|
||||
replyCode: "inquiring.air_airline_only_cabin",
|
||||
trigger: "只有航线能锁舱或释放",
|
||||
text: "只有岗位为「航线·空运」的人能锁舱或释放舱位。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000027",
|
||||
replyCode: "inquiring.air_lock_ok",
|
||||
trigger: "空运锁舱成功",
|
||||
text: "工单{{ticket_no}} 已通知TMS执行空运锁舱,执行成功。\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000028",
|
||||
replyCode: "inquiring.air_lock_fail",
|
||||
trigger: "空运锁舱失败",
|
||||
text: "工单{{ticket_no}} 空运锁舱失败。\n操作人:{{operator}}\n锁舱失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000029",
|
||||
replyCode: "inquiring.air_release_ok",
|
||||
trigger: "空运释放成功",
|
||||
text: "工单{{ticket_no}} 已通知TMS执行空运释放舱位成功\n操作人:{{operator}}\n舱位选项:{{option_no}}\n执行编号:{{exec_no}}\n报价编号:{{quote_no}}",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000030",
|
||||
replyCode: "inquiring.air_release_fail",
|
||||
trigger: "空运释放失败",
|
||||
text: "工单{{ticket_no}} 空运释放舱位失败。\n操作人:{{operator}}\n释放舱位失败的原因:{{reason}}\n已通知IT运维排查,请稍等。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000031",
|
||||
replyCode: "inquiring.air_release_deal_blocked",
|
||||
trigger: "已成交不能释放舱位",
|
||||
text: "释放舱位失败,工单{{ticket_no}}已成交。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000032",
|
||||
replyCode: "inquiring.air_release_need_lock",
|
||||
trigger: "没有有效锁舱不能释放",
|
||||
text: "工单{{ticket_no}}还没有有效锁舱,不能释放。",
|
||||
enabled: true,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -76,41 +259,40 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-blue",
|
||||
items: [
|
||||
{
|
||||
id: "r8",
|
||||
trigger: "报价方案整理完成 → 询问是否采用",
|
||||
text: "工单 {{ticket_no}} 状态已更新为「已报价」。整理后的报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。请确认是否采用:(采用,生成 Excel 报价单 / 采用,生成 PDF 报价单 / 不采用)",
|
||||
id: "brv10000000000000000000000000033",
|
||||
replyCode: "quoted.reject_ack",
|
||||
trigger: "销售选择「不采用」",
|
||||
text: "请问如何调整报价单,以满足您的需求?请说明要调哪一项费用,例如「提货费加 10%」。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r9",
|
||||
trigger: "销售确认采用 → 生成最终报价单",
|
||||
text: "已确认采用该方案,正在生成 {{quote_format}} 最终报价单…工单保持「已报价」,完成后可直接转发给客户。",
|
||||
id: "brv10000000000000000000000000034",
|
||||
replyCode: "quoted.adopt_excel",
|
||||
trigger: "销售确认采用 → 生成 Excel",
|
||||
text: "已确认采用该方案,正在生成 Excel 最终报价单…",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r10",
|
||||
id: "brv10000000000000000000000000035",
|
||||
replyCode: "quoted.adopt_pdf",
|
||||
trigger: "销售确认采用 → 生成 PDF",
|
||||
text: "已确认采用该方案,正在生成PDF最终报价单…",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000036",
|
||||
replyCode: "quoted.template_missing",
|
||||
trigger: "后台未维护报价模板",
|
||||
text: "生成报价单失败,请联系IT运维维护后台报价模板。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000037",
|
||||
replyCode: "quoted.file_ready",
|
||||
trigger: "最终报价单生成完成",
|
||||
text: "最终报价单已生成:{{quote_file}}({{quote_amount}} · {{route_summary}})。工单 {{ticket_no}} 保持「已报价」,报价单已归档。",
|
||||
text: "已生成{{kind}}最终报价单:{{filename}}(工单 {{ticket_no}})",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r11",
|
||||
trigger: "销售发送比例调价指令",
|
||||
text: "已识别调价指令:在当前报价基础上{{adjust_direction}}{{adjust_percent}}%。原报价 {{original_quote}} → 新报价 {{new_quote}}。正在刷新报价并重新生成报价单,工单保持「已报价」。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r12",
|
||||
trigger: "二次询价 · 关联历史工单",
|
||||
text: "识别为二次询价,已关联历史工单 {{history_ticket_no}}。TMS 标准报价 {{system_quote}};上次报价 {{history_quote}},历史报价文档已引用至当前会话。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r13",
|
||||
trigger: "报价单出具满*小时 → 发起成交跟进",
|
||||
text: "工单 {{ticket_no}} 已于 {{quote_time}} 出具报价({{route_summary}} · {{quote_amount}})。请确认当前与客户的成交状态:(成交 / 未成交 / 协商中)",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -120,17 +302,12 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-green",
|
||||
items: [
|
||||
{
|
||||
id: "r14",
|
||||
trigger: "销售在成交跟进中选择「成交」",
|
||||
text: "已记录为「已成交」。工单 {{ticket_no}} 状态已更新,成交确认时间 {{deal_time}} 已写入后台。已推送成交通知至 {{manager_name}}({{manager_role}})。",
|
||||
id: "brv10000000000000000000000000038",
|
||||
replyCode: "deal.recorded_won",
|
||||
trigger: "销售选择「成交」",
|
||||
text: "已记录为「已成交」。工单 {{ticket_no}} 已关闭智能体跟进。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r15",
|
||||
trigger: "成交确认 → 企微推送负责人",
|
||||
text: "【成交通知】工单 {{ticket_no}} 已成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}报价:{{quote_amount}} · 确认时间:{{deal_time}}推送对象:{{manager_name}}({{manager_role}})。请知悉并安排后续履约跟进。",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -140,29 +317,12 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-amber",
|
||||
items: [
|
||||
{
|
||||
id: "r16",
|
||||
trigger: "销售选择「不采用」当前报价方案",
|
||||
text: "工单 {{ticket_no}} 状态已更新为「协商中」。请问希望如何调整?可说明调价幅度、换舱/时效要求,或是否需要重新拉内部{{internal_role_label}}协同。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r17",
|
||||
id: "brv10000000000000000000000000039",
|
||||
replyCode: "negotiating.recorded",
|
||||
trigger: "成交跟进中选择「协商中」",
|
||||
text: "已记录为「协商中」。工单跟进周期已顺延 {{negotiate_extend_days}}个工作日,将于 {{next_followup_time}} 再次向您发起成交状态询问。",
|
||||
text: "已记录为「协商中」。已顺延三个工作日,{{followup_when}}的9:00再次发起成交状态询问。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r18",
|
||||
trigger: "协商跟进周期到期 → 再次询问",
|
||||
text: "工单 {{ticket_no}} 协商跟进周期已到({{route_summary}} · {{quote_amount}})。请再次确认当前成交状态:(成交 / 未成交 / 协商中)",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r19",
|
||||
trigger: "调价或重新协同完成 → 出具新方案",
|
||||
text: "协商调整已完成,新报价方案:{{route_summary}} · {{quote_amount}} · {{eta}}。工单状态更新为「已报价」,请确认是否采用。",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -172,23 +332,19 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-rose",
|
||||
items: [
|
||||
{
|
||||
id: "r20",
|
||||
trigger: "成交跟进中选择「未成交」",
|
||||
text: "请告知{{ticket_no}}「未成交」的原因(必填)。回复后系统将记录至工单 {{ticket_no}},并推送未成交通知至对应负责人。",
|
||||
id: "brv10000000000000000000000000040",
|
||||
replyCode: "lost.ask_reason",
|
||||
trigger: "成交跟进中选择「未成交」且未写原因",
|
||||
text: "已记录为「未成交」。请补充未成交原因。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r21",
|
||||
trigger: "销售回复未成交原因 → 关单并通知负责人",
|
||||
text: "已记录为「未成交」。原因:{{reject_reason}}。登记时间 {{reject_time}},已推送未成交通知至 {{manager_name}}({{manager_role}})。工单 {{ticket_no}} 已归档。",
|
||||
id: "brv10000000000000000000000000041",
|
||||
replyCode: "lost.reason_ack",
|
||||
trigger: "销售回复未成交原因",
|
||||
text: "已记下未成交原因:{{lost_reason}}。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r22",
|
||||
trigger: "未成交确认 → 企微推送负责人",
|
||||
text: "【未成交通知】工单 {{ticket_no}} 未成交。销售:{{sales_name}} · 业务:{{biz_type}} · 路线:{{route_summary}}未成交原因:{{reject_reason}}登记时间:{{reject_time}} · 推送对象:{{manager_name}}({{manager_role}})。请知悉本单未成单情况。",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -198,17 +354,12 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-rose",
|
||||
items: [
|
||||
{
|
||||
id: "r23",
|
||||
trigger: "达到配置超时时间 → 系统自动关闭",
|
||||
text: "工单 {{ticket_no}} 因「{{close_reason}}」已达关闭条件,系统已自动关闭。关闭时间:{{close_time}}。本单不再发起成交跟进,亦不触发成交/未成交通知。",
|
||||
id: "brv10000000000000000000000000042",
|
||||
replyCode: "closed.air_cannot_activate",
|
||||
trigger: "空运工单已是终态仍要激活",
|
||||
text: "工单{{ticket_no}}已是「{{status}}」,不能再激活。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r24",
|
||||
trigger: "销售/客服手动关闭工单",
|
||||
text: "工单 {{ticket_no}} 已由 {{operator_name}} 手动关闭。关闭原因:{{close_reason}}。如需重新询价,请发起新的询价对话。",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -217,24 +368,7 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
desc: "业务流程中参与方响应超时等业务侧异常,写入工单「事件异常」字段,需人工跟进。",
|
||||
tagClass: "tag-amber",
|
||||
items: [
|
||||
{
|
||||
id: "r25",
|
||||
trigger: "内部协同*小时无响应 → 群内提醒",
|
||||
text: "@{{staff_name}} @{{sales_name}} 工单 {{ticket_no}} 已超过 {{timeout_hours}} 小时未收到内部协同回复,请尽快确认。事件异常:{{event_exception_type}}。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r26",
|
||||
trigger: "内部协同超时达告警阈值 → 生成告警工单",
|
||||
text: "工单 {{ticket_no}} 内部协同已超过 {{alert_hours}} 小时未响应,已生成「{{event_exception_type}}」告警,请管理员跟进。未响应人员:{{staff_name}}({{staff_role}})。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r27",
|
||||
trigger: "成交跟进多次无响应 → 标记销售响应超时",
|
||||
text: "工单 {{ticket_no}} 成交跟进已发起 {{followup_count}} 次,销售仍未回复。事件异常:销售响应超时。如继续无响应,系统将按配置自动关闭工单。",
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -244,29 +378,54 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-rose",
|
||||
items: [
|
||||
{
|
||||
id: "r28",
|
||||
trigger: "大模型调用失败",
|
||||
text: "AI 解析暂时不可用({{system_error_detail}})。工单 {{ticket_no}} 已标记系统异常:大模型异常。请稍后重试,或回复「人工报价」转人工处理。",
|
||||
id: "brv10000000000000000000000000043",
|
||||
replyCode: "sys.llm_fail",
|
||||
trigger: "大模型调用失败(销售侧)",
|
||||
text: "大模型服务异常,IT排查中...",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r29",
|
||||
trigger: "TMS 接口调用失败",
|
||||
text: "TMS 标准价库查询失败({{system_error_detail}})。工单已标记系统异常:TMS异常。可稍后重试,或选择转人工由内部人员报价。",
|
||||
id: "brv10000000000000000000000000044",
|
||||
replyCode: "sys.tms_fail",
|
||||
trigger: "TMS 接口失败(销售侧)",
|
||||
text: "TMS接口异常,IT运维排查中...",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r30",
|
||||
trigger: "企微建群 / 添加成员失败",
|
||||
text: "创建协同群失败,无法添加 {{staff_name}}({{staff_wechat_id}})。系统异常:建群异常 · {{system_error_detail}}。已在后台生成异常工单,请联系管理员检查人员配置。",
|
||||
id: "brv10000000000000000000000000045",
|
||||
replyCode: "sys.group_add_fail",
|
||||
trigger: "企微添加成员失败",
|
||||
text: "无法添加 {{staff_name}} 同事,请联系管理员检查企业微信配置。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r31",
|
||||
trigger: "系统异常处理完成",
|
||||
text: "工单 {{ticket_no}} 系统异常「{{system_exception_type}}」已处理完毕,工单状态恢复为「{{restored_status}}」。处理记录:{{handle_note}}",
|
||||
id: "brv10000000000000000000000000046",
|
||||
replyCode: "sys.tms_recovery",
|
||||
trigger: "TMS 已恢复",
|
||||
text: "TMS已恢复正常使用,感谢您的耐心等待。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000047",
|
||||
replyCode: "sys.llm_recovery",
|
||||
trigger: "大模型已恢复",
|
||||
text: "大模型已恢复正常使用,感谢您的耐心等待。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000048",
|
||||
replyCode: "sys.cabin_recovery",
|
||||
trigger: "空运舱位接口已恢复",
|
||||
text: "空运舱位接口已恢复正常使用,感谢您的耐心等待。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "brv10000000000000000000000000049",
|
||||
replyCode: "sys.group_recovery",
|
||||
trigger: "建群已恢复",
|
||||
text: "建群已恢复正常,感谢您的耐心等待。",
|
||||
enabled: true,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -276,41 +435,19 @@ export const mockReplyCategories: ReplyCategory[] = [
|
||||
tagClass: "tag-violet",
|
||||
items: [
|
||||
{
|
||||
id: "r32",
|
||||
trigger: "TMS 暂无报价",
|
||||
text: "暂无 TMS 标准报价。触发转人工:{{transfer_reason}}。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||
id: "brv10000000000000000000000000050",
|
||||
replyCode: "transfer.already_collab",
|
||||
trigger: "该工单已拉过产品进群",
|
||||
text: "该工单已拉产品进群协同。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r33",
|
||||
trigger: "意图识别失败",
|
||||
text: "已尝试 {{intent_retry}} 次澄清,仍无法识别询价意图。触发转人工:意图识别失败。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||
id: "brv10000000000000000000000000051",
|
||||
replyCode: "transfer.air_hint",
|
||||
trigger: "空运转人工(不自动建群)",
|
||||
text: "触发转人工,请手动把航线和智能体拉进原有空运群,并发送工单号 {{ticket_no}}。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r34",
|
||||
trigger: "参数提取不全",
|
||||
text: "已连续 {{param_retry}} 次反问补充询价参数仍未完整。触发转人工:参数提取不全。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r35",
|
||||
trigger: "报价解析失败",
|
||||
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。是否拉{{internal_role_label}}进群协同?(是 / 否)",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r36",
|
||||
trigger: "空运锁舱/释放舱位失败",
|
||||
text: "触发转人工:{{transfer_reason}}(已连续失败 {{fail_count}} 次)。请航线人员在 TMS 侧手工处理,或手动拉相关人员协助。请务必在群聊发送工单编号 {{ticket_no}}。",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "r37",
|
||||
trigger: "销售发送「人工报价」",
|
||||
text: "已识别转人工指令。{{confirm_pull_group}}正在创建内部协同群,拉入销售 {{sales_name}}、{{internal_staff}} 及询价机器人…工单 {{ticket_no}} 进入「询价中」。",
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
@@ -31,19 +31,19 @@ export type CostRoute = {
|
||||
points: Array<{ x: number; y: number; index: number }>;
|
||||
};
|
||||
|
||||
/** 今日视角九指标(与原型默认展示一致) */
|
||||
/** 今日视角九指标(与原型 5 列栅格、主账 InquiryDashboardServiceImpl 一致) */
|
||||
export const todayMetrics: MetricCard[] = [
|
||||
{ key: "inquiry", label: "询价总数", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "inquiry", label: "询价总量", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "quoted", label: "报价工单", tip: "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", value: "92 单" },
|
||||
{ key: "tms", label: "标准报价命中率", tip: "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", value: "67.8%" },
|
||||
{ key: "abnormal", label: "异常处理完成率", tip: "已关闭异常工单数 ÷ 异常工单总数 × 100%", value: "86.4%" },
|
||||
{ key: "manual", label: "手工报价占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "accepted", label: "已成交工单", tip: "状态=已成交的工单数量", value: "31" },
|
||||
{ key: "dealRate", label: "报价成交率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价反馈率", tip: "(已成交 + 未成交 − 关闭)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{ key: "avgSale", label: "平均报价销售额", tip: "AVG(已勾选报价的销售价)", value: "12800" },
|
||||
{ key: "manual", label: "手工工单占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "dealRate", label: "报价成功率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价有效率", tip: "(已成交 + 未成交)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{
|
||||
key: "avgResp",
|
||||
label: "内部协同平均响应时长",
|
||||
label: "内部报价平均响应",
|
||||
tip: "AVG(首次有效回复时间 − 分发时间)",
|
||||
value: "8m42s",
|
||||
isTime: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 角色管理:对接 Jeecg /sys/role 与菜单权限树。
|
||||
* 数据权限按 Excel 线路目录树勾选,写入角色 description 的 [dataPerms:…],不另造 RBAC。
|
||||
@@ -255,7 +255,7 @@ function onDataPermCheck(checked: string[] | { checked: string[] }) {
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card is-list-page">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
|
||||
@@ -494,7 +494,7 @@ function filterRouteOption(input: string, option: any) {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-card is-list-page">
|
||||
<div class="tabs-line-bar">
|
||||
<div class="tabs-line">
|
||||
<button type="button" :class="{ 'is-active': tab === 'all' }" @click="tab = 'all'">
|
||||
|
||||
@@ -30,6 +30,13 @@ body {
|
||||
background: #f7f8fb !important;
|
||||
min-width: 0 !important;
|
||||
overflow-x: hidden;
|
||||
/* 主区锁视口高度:列表页表格区内滚;看板等非列表页在 .admin-content 内滚 */
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
min-height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
@@ -37,6 +44,9 @@ body {
|
||||
gap: 12px !important;
|
||||
min-width: 0 !important;
|
||||
overflow-x: hidden;
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -70,6 +80,9 @@ body {
|
||||
font-size: 11px !important;
|
||||
font-weight: 900 !important;
|
||||
line-height: 1.25 !important;
|
||||
white-space: normal !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
@@ -476,9 +489,19 @@ body {
|
||||
.ticket-list-table td.col-sticky-left,
|
||||
.ticket-list-table td.col-sticky-right,
|
||||
.ticket-list-table th.col-sticky-left,
|
||||
.ticket-list-table th.col-sticky-right {
|
||||
.ticket-list-table th.col-sticky-right,
|
||||
.ticket-list-table td.col-quote,
|
||||
.ticket-list-table td.col-price,
|
||||
.ticket-list-table td.col-demand {
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ticket-list-table .quote-source,
|
||||
.ticket-list-table .quote-price,
|
||||
.ticket-list-table .quote-fee-line,
|
||||
.ticket-list-table .ticket-price-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-list-table .attach-tags {
|
||||
@@ -546,19 +569,18 @@ body {
|
||||
gap: 12px 16px !important;
|
||||
}
|
||||
|
||||
/* 中间列横滑;左右 sticky 列固定 */
|
||||
/* 中间列横滑;左右 sticky 列固定。竖滚改由 .is-list-page 表格区承接,勿再用 visible 把滚动甩给整页 */
|
||||
.ticket-page .table-wrap,
|
||||
.ticket-table-scroll {
|
||||
border-radius: 10px !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: visible;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ticket-list-table {
|
||||
table-layout: fixed !important;
|
||||
width: max-content !important;
|
||||
min-width: 1480px !important;
|
||||
min-width: 1640px !important;
|
||||
max-width: none !important;
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
@@ -566,8 +588,8 @@ body {
|
||||
|
||||
.ticket-list-table .col-w-id { width: 132px; }
|
||||
.ticket-list-table .col-w-user { width: 72px; }
|
||||
.ticket-list-table .col-w-demand { width: 200px; }
|
||||
.ticket-list-table .col-w-quote { width: 140px; }
|
||||
.ticket-list-table .col-w-demand { width: 220px; }
|
||||
.ticket-list-table .col-w-quote { width: 250px; }
|
||||
.ticket-list-table .col-w-attach { width: 160px; }
|
||||
.ticket-list-table .col-w-status { width: 84px; }
|
||||
.ticket-list-table .col-w-price { width: 168px; }
|
||||
@@ -1386,3 +1408,118 @@ html {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗多行正文:必须压过上面 .field 的 height:34px。
|
||||
* AdminModal 会 Teleport 到 body,页面 scoped 样式经常盖不住这条全局规则。
|
||||
*/
|
||||
textarea.field.field-tall {
|
||||
height: 240px !important;
|
||||
min-height: 240px !important;
|
||||
padding: 8px 10px !important;
|
||||
line-height: 1.5 !important;
|
||||
resize: vertical !important;
|
||||
}
|
||||
|
||||
.form-row.form-row-tall {
|
||||
align-items: start !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页固定搜索区 / 表头 / 底部分页,表格区内双轴滚动。
|
||||
* 根节点加 .is-list-page(工单、员工、账号、角色、报价模板)。
|
||||
* 这样横向滚动条始终贴在表格可视区底部,不必滚到文档末尾才看到。
|
||||
*/
|
||||
.admin-content:has(> .is-list-page) {
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
padding-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.admin-content > .is-list-page {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* 工单页:外壳 is-list-page,内层 section-card 撑满 */
|
||||
.admin-content > .is-list-page > .section-card {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
overflow: hidden !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* 账号/员工/角色/模板:section-card 自身即 is-list-page */
|
||||
.admin-content > .section-card.is-list-page {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.is-list-page .section-card-header,
|
||||
.is-list-page .tabs-line-bar,
|
||||
.is-list-page .toolbar,
|
||||
.is-list-page .pagination {
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.is-list-page .table-wrap,
|
||||
.is-list-page .qt-template-table-wrap {
|
||||
flex: 1 1 auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 竖向滚动时表头钉在表格区顶部 */
|
||||
.is-list-page .data-table thead th {
|
||||
position: sticky !important;
|
||||
top: 0 !important;
|
||||
z-index: 4 !important;
|
||||
background: #f8fafc !important;
|
||||
box-shadow: 0 1px 0 var(--line, #d9e2ef);
|
||||
}
|
||||
|
||||
/* 左/右冻结列与表头交叉处抬高层级,避免横滑时被盖住 */
|
||||
.is-list-page .data-table thead th:first-child,
|
||||
.is-list-page .data-table thead th.col-sticky-left {
|
||||
left: 0 !important;
|
||||
z-index: 6 !important;
|
||||
}
|
||||
|
||||
.is-list-page .data-table thead th:last-child,
|
||||
.is-list-page .data-table thead th.col-sticky-right {
|
||||
right: 0 !important;
|
||||
z-index: 6 !important;
|
||||
}
|
||||
|
||||
.is-list-page .data-table tbody td:first-child,
|
||||
.is-list-page .data-table tbody td.col-sticky-left {
|
||||
position: sticky !important;
|
||||
left: 0 !important;
|
||||
z-index: 3 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.is-list-page .data-table tbody td:last-child,
|
||||
.is-list-page .data-table tbody td.col-sticky-right {
|
||||
position: sticky !important;
|
||||
right: 0 !important;
|
||||
z-index: 3 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
.is-list-page .data-table tbody tr:hover td:first-child,
|
||||
.is-list-page .data-table tbody tr:hover td:last-child,
|
||||
.is-list-page .data-table tbody tr:hover td.col-sticky-left,
|
||||
.is-list-page .data-table tbody tr:hover td.col-sticky-right {
|
||||
background: #f8fbff !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1680,6 +1680,11 @@ textarea:focus-visible {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-modal-body textarea.field.field-tall {
|
||||
height: 240px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.admin-modal-body .perm-tree-box {
|
||||
max-height: none;
|
||||
border: 1px solid var(--line);
|
||||
|
||||
@@ -469,11 +469,11 @@
|
||||
}
|
||||
.ref-metric-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 6;
|
||||
z-index: 40;
|
||||
width: max-content;
|
||||
max-width: 240px;
|
||||
max-width: 280px;
|
||||
border: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
@@ -485,26 +485,26 @@
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transform: translate(-50%, 4px);
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
white-space: normal;
|
||||
}
|
||||
.ref-metric-tip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1px solid #dfe5ee;
|
||||
border-bottom: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
transform: translateY(-4px) rotate(45deg);
|
||||
transform: translate(-50%, -4px) rotate(45deg);
|
||||
}
|
||||
.ref-metric-help:hover .ref-metric-tip,
|
||||
.ref-metric-help:focus-visible .ref-metric-tip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.ref-quality-value {
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -688,7 +688,7 @@ async function downloadPreviewOrOriginal() {
|
||||
</button>
|
||||
</Teleport>
|
||||
|
||||
<div class="section-card qt-template-page">
|
||||
<div class="section-card qt-template-page is-list-page">
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<ProtoIcon class="search-icon" name="search" :size="16" />
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。
|
||||
*
|
||||
* 口径对齐销售侧询价确认卡(inquiry_field_lines):
|
||||
* 空运看件数/重量/体积/包装;海运看货量/整柜拼柜/箱型箱量;
|
||||
* 陆运拼车看体积重量,整车/中港零担看车型数量,中港整车不强制车型。
|
||||
* 只做展示,不改六态、不写主账。
|
||||
*/
|
||||
|
||||
/** 详情格子一行:标签 + 值(空则「-」) */
|
||||
export type DemandField = { label: string; value: string };
|
||||
|
||||
const LAND_FTL_TYPES = new Set(["国内运输整车", "跨境整车", "中港零担/集拼"]);
|
||||
const LAND_CUSTOMS_TYPES = new Set(["跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"]);
|
||||
|
||||
const LAND_LABEL: Record<string, string> = {
|
||||
起运港: "始发站",
|
||||
目的港: "目的地",
|
||||
品名: "货物品名",
|
||||
毛重: "重量(KG)",
|
||||
体积: "体积(CBM)",
|
||||
"车型/数量": "车型/数量",
|
||||
通关口岸: "通关口岸",
|
||||
报价日期: "报价日期",
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
|
||||
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
|
||||
*/
|
||||
export function parseFactsMap(raw: unknown): Record<string, string> {
|
||||
if (raw == null) return {};
|
||||
let obj: any = raw;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (val == null) continue;
|
||||
if (typeof val === "object") continue;
|
||||
const text = String(val).trim();
|
||||
if (text) out[key] = text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
|
||||
export function mergeInquiryFacts(ticket: any): Record<string, string> {
|
||||
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
|
||||
const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts);
|
||||
const out: Record<string, string> = { ...inquiry };
|
||||
for (const [k, v] of Object.entries(collab)) {
|
||||
if (!out[k]) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pick(facts: Record<string, string>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const v = (facts[key] || "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function dash(v: unknown): string {
|
||||
const s = (v ?? "").toString().trim();
|
||||
return s ? s : "-";
|
||||
}
|
||||
|
||||
function columnText(v: unknown): string {
|
||||
if (v == null || v === "") return "";
|
||||
const s = String(v).trim();
|
||||
if (!s || s === "-") return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
function businessLineOf(ticket: any): "AIR" | "SEA" | "LAND" | "MULTI" | "" {
|
||||
const raw = String(ticket?.businessLine ?? "").trim().toUpperCase();
|
||||
if (raw === "AIR" || raw === "SEA" || raw === "LAND" || raw === "MULTI") return raw;
|
||||
const label = String(ticket?.bizTypeLabel ?? ticket?.bizType ?? "").trim();
|
||||
if (label.includes("空运")) return "AIR";
|
||||
if (label.includes("海运")) return "SEA";
|
||||
if (label.includes("陆运")) return "LAND";
|
||||
if (label.includes("多段") || label.includes("联运")) return "MULTI";
|
||||
return "";
|
||||
}
|
||||
|
||||
function transportModeLabel(ticket: any, line: string): string {
|
||||
const label = String(ticket?.bizTypeLabel ?? "").trim();
|
||||
if (label) return label;
|
||||
if (line === "AIR") return "空运";
|
||||
if (line === "SEA") return "海运";
|
||||
if (line === "LAND") return "陆运";
|
||||
if (line === "MULTI") return "多段联运";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 当前陆运类型要展示的货物内部键。中港整车不要把车型当成询价必填。 */
|
||||
function landCargoKeys(landType: string): string[] {
|
||||
if (landType === "中港整车") {
|
||||
return ["起运港", "目的港", "品名", "报价日期"];
|
||||
}
|
||||
if (LAND_FTL_TYPES.has(landType)) {
|
||||
return ["起运港", "目的港", "品名", "车型/数量", "报价日期"];
|
||||
}
|
||||
return ["起运港", "目的港", "品名", "体积", "毛重", "报价日期"];
|
||||
}
|
||||
|
||||
function push(fields: DemandField[], label: string, value: string) {
|
||||
fields.push({ label, value: dash(value) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单详情询价需求格子。
|
||||
* 列字段(origin / pieces / containerSpec 等)作 facts 空时的兜底,兼容旧单。
|
||||
*/
|
||||
export function buildDemandFields(ticket: any): DemandField[] {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const mode = transportModeLabel(ticket, line);
|
||||
const origin = pick(facts, "起运港", "起运地") || columnText(ticket?.origin);
|
||||
const dest = pick(facts, "目的港", "目的地") || columnText(ticket?.destination);
|
||||
const goods = pick(facts, "品名", "货物品名") || columnText(ticket?.goodsName);
|
||||
const quoteDate =
|
||||
pick(facts, "报价日期") || columnText(ticket?.quoteDate)?.toString().slice(0, 10) || "";
|
||||
const customer = pick(facts, "客户名称") || columnText(ticket?.customerName);
|
||||
const fields: DemandField[] = [];
|
||||
|
||||
push(fields, "运输方式", mode);
|
||||
|
||||
if (line === "SEA") {
|
||||
push(fields, "起运港", origin);
|
||||
push(fields, "目的港", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "货物数量", pick(facts, "货量", "货物数量", "件数") || columnText(ticket?.cargoQty) || columnText(ticket?.pieces));
|
||||
push(fields, "整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
push(fields, "箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
push(fields, "贸易条款", pick(facts, "贸易条款"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else if (line === "LAND") {
|
||||
const landType = pick(facts, "运输类型");
|
||||
push(fields, "运输类型", landType);
|
||||
push(fields, "线路类别", pick(facts, "线路类别"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
const cargoVals: Record<string, string> = {
|
||||
起运港: origin,
|
||||
目的港: dest,
|
||||
品名: goods,
|
||||
体积: pick(facts, "体积") || columnText(ticket?.volumeCbm),
|
||||
毛重: pick(facts, "毛重", "重量") || columnText(ticket?.weightKg),
|
||||
"车型/数量": pick(facts, "车型/数量", "车型"),
|
||||
报价日期: quoteDate,
|
||||
};
|
||||
for (const key of landCargoKeys(landType)) {
|
||||
push(fields, LAND_LABEL[key] || key, cargoVals[key] || pick(facts, key));
|
||||
}
|
||||
if (LAND_CUSTOMS_TYPES.has(landType)) {
|
||||
push(fields, "通关口岸", pick(facts, "通关口岸"));
|
||||
}
|
||||
// 中港整车询价不强制车型,协同里有值仍展示,避免后台看不到 53 尺这类原文。
|
||||
if (landType === "中港整车" && pick(facts, "车型/数量", "车型")) {
|
||||
push(fields, "车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
}
|
||||
} else if (line === "AIR") {
|
||||
push(fields, "起运地", origin);
|
||||
push(fields, "目的地", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else {
|
||||
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
|
||||
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
const box = pick(facts, "箱型箱量") || columnText(ticket?.containerSpec);
|
||||
if (box) push(fields, "箱型箱量", box);
|
||||
const vehicle = pick(facts, "车型/数量", "车型");
|
||||
if (vehicle) push(fields, "车型/数量", vehicle);
|
||||
}
|
||||
|
||||
push(fields, "客户", customer);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「询价需求」第二行货量:按方式只写该方式关心的量,避免陆运整车全是件数「-」。
|
||||
*/
|
||||
export function quantitySummary(ticket: any): string {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const landType = pick(facts, "运输类型");
|
||||
const parts: string[] = [];
|
||||
const add = (label: string, raw: string) => {
|
||||
const v = (raw || "").trim();
|
||||
if (v && v !== "-") parts.push(`${label} ${v}`);
|
||||
};
|
||||
if (line === "SEA") {
|
||||
add("货物数量", pick(facts, "货量", "货物数量") || columnText(ticket?.cargoQty));
|
||||
add("整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
add("箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
if (line === "LAND") {
|
||||
if (landType) add("运输类型", landType);
|
||||
if (LAND_FTL_TYPES.has(landType) || (landType === "中港整车" && pick(facts, "车型/数量"))) {
|
||||
add("车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
} else {
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
add("件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import ExpandableClamp from "/@/views/inquiry/components/ExpandableClamp.vue";
|
||||
import {
|
||||
buildDemandFields,
|
||||
quantitySummary,
|
||||
type DemandField,
|
||||
} from "/@/views/inquiry/workOrder/demandFields";
|
||||
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
@@ -61,6 +67,8 @@ type TicketRow = {
|
||||
weightKg: string;
|
||||
volumeCbm: string;
|
||||
goodsName: string;
|
||||
/** 询价需求格子:按运输方式/运输类型变化 */
|
||||
demandFields: DemandField[];
|
||||
updateTime: string;
|
||||
lostReason: string;
|
||||
nextFollowupAt: string;
|
||||
@@ -118,6 +126,17 @@ function asPriceLines(raw: any): PriceLine[] {
|
||||
.filter((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未调价利润点必须是 0%。主账应已逐行给 0%;旧接口没给时,按成本/销售费用名补 0%。
|
||||
* 禁止把金额拿来填利润点列。
|
||||
*/
|
||||
function displayProfitLines(row: TicketRow): PriceLine[] {
|
||||
if (row.profitLines.length) return row.profitLines;
|
||||
const src = row.saleLines.length ? row.saleLines : row.costLines;
|
||||
if (!src.length) return [];
|
||||
return src.map((line) => ({ name: line.name, text: "0%" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
|
||||
* 禁止把产品报价或销售调价再显示成 TMS。
|
||||
@@ -300,7 +319,8 @@ function looksLikeRawFp(v: any) {
|
||||
|
||||
/**
|
||||
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
|
||||
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
|
||||
* 首行 = 业务线 · 路线;后续 = 该运输方式关心的货量 / 品名。
|
||||
* 陆运整车写车型数量,海运写箱型箱量;不信 demandDetail 管道串。
|
||||
*/
|
||||
function requirementSummaryLines(t: any): string[] {
|
||||
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
|
||||
@@ -313,37 +333,11 @@ function requirementSummaryLines(t: any): string[] {
|
||||
const lines: string[] = [];
|
||||
if (heading) lines.push(heading);
|
||||
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const quantities: string[] = [];
|
||||
const pieces = t?.pieces;
|
||||
const weight = t?.weightKg;
|
||||
const volume = t?.volumeCbm;
|
||||
const plain = (v: any) => {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? String(n) : String(v).trim();
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
const qty = quantitySummary(t);
|
||||
if (qty) lines.push(qty);
|
||||
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const cargo: string[] = [];
|
||||
const goods = String(t?.goodsName ?? "").trim();
|
||||
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
|
||||
@@ -418,6 +412,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
weightKg: fmtMeasure(t.weightKg, "kg"),
|
||||
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
|
||||
goodsName: dash(t.goodsName),
|
||||
demandFields: buildDemandFields(t),
|
||||
updateTime: fmtTime(t.updateTime || t.createTime),
|
||||
lostReason: dash(t.lostReason || t.lost_reason),
|
||||
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
|
||||
@@ -425,11 +420,46 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
};
|
||||
}
|
||||
|
||||
function routeText(row: TicketRow) {
|
||||
if (row.origin !== "-" || row.destination !== "-") {
|
||||
return `${row.origin === "-" ? "" : row.origin} → ${row.destination === "-" ? "" : row.destination}`.trim();
|
||||
}
|
||||
return row.demandTitle || "-";
|
||||
function quoteHasFees(row: TicketRow) {
|
||||
return (row.quotes || []).some((q) => (q.fees || []).length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价信息 / 成本价 / 销售价 / 利润点共用一把「展开」:
|
||||
* 收起时只留摘要(报价来源+总额、费用合计行),展开才看逐费明细。
|
||||
* 避免只收起报价列、成本/利润仍撑很高。
|
||||
*/
|
||||
const priceExpandedMap = reactive<Record<string, boolean>>({});
|
||||
const detailPriceExpanded = ref(false);
|
||||
|
||||
function detailPriceNeedsToggle(row: TicketRow | null) {
|
||||
if (!row) return false;
|
||||
return priceBlockNeedsToggle(row);
|
||||
}
|
||||
|
||||
function isPriceExpanded(id: string) {
|
||||
return !!priceExpandedMap[id];
|
||||
}
|
||||
|
||||
function togglePriceExpanded(id: string) {
|
||||
priceExpandedMap[id] = !priceExpandedMap[id];
|
||||
}
|
||||
|
||||
function priceBlockNeedsToggle(row: TicketRow) {
|
||||
return (
|
||||
quoteHasFees(row) ||
|
||||
row.costLines.length > 1 ||
|
||||
row.saleLines.length > 1 ||
|
||||
displayProfitLines(row).length > 1
|
||||
);
|
||||
}
|
||||
|
||||
/** 收起:优先只留「费用合计」;没有合计则留首行,避免整列空白。 */
|
||||
function visiblePriceLines(lines: PriceLine[], expanded: boolean): PriceLine[] {
|
||||
if (!lines.length) return [];
|
||||
if (expanded || lines.length <= 1) return lines;
|
||||
const totals = lines.filter((l) => l.name === "费用合计");
|
||||
return totals.length ? totals : lines.slice(0, 1);
|
||||
}
|
||||
|
||||
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
|
||||
@@ -491,6 +521,7 @@ onMounted(() => loadList());
|
||||
|
||||
async function openDetail(row: TicketRow) {
|
||||
current.value = row;
|
||||
detailPriceExpanded.value = false;
|
||||
detailOpen.value = true;
|
||||
try {
|
||||
const res: any = await getWorkOrderDetail(row.id);
|
||||
@@ -559,7 +590,7 @@ function refresh() {
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="ticket-page">
|
||||
<div class="ticket-page is-list-page">
|
||||
<div class="section-card">
|
||||
<div class="section-card-header">
|
||||
<h2 class="section-card-title">询价工单列表</h2>
|
||||
@@ -670,38 +701,54 @@ function refresh() {
|
||||
<td class="align-top">
|
||||
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<template v-if="row.quotes.length">
|
||||
<div
|
||||
v-for="q in row.quotes"
|
||||
:key="q.source + q.price"
|
||||
class="quote-row"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<div class="quote-source">{{ q.source }}</div>
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
<td class="align-top col-demand">
|
||||
<ExpandableClamp :content-key="row.id + '-demand'" :max-height="132">
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
v-for="(line, idx) in demandDetailLines(row)"
|
||||
:key="row.id + '-demand-' + idx"
|
||||
>{{ line }}</span
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
|
||||
{{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else style="color: #94a3b8">-</span>
|
||||
</ExpandableClamp>
|
||||
</td>
|
||||
<td class="align-top col-quote">
|
||||
<div class="ticket-price-expand">
|
||||
<template v-if="row.quotes.length">
|
||||
<div
|
||||
v-for="q in row.quotes"
|
||||
:key="q.source + q.price"
|
||||
class="quote-row"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<div class="quote-source">{{ q.source }}</div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
|
||||
{{ q.price }}
|
||||
</div>
|
||||
<template v-if="isPriceExpanded(row.id) && q.fees.length">
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else style="color: #94a3b8">-</span>
|
||||
<button
|
||||
v-if="priceBlockNeedsToggle(row)"
|
||||
type="button"
|
||||
class="ticket-price-toggle"
|
||||
:class="{ 'is-open': isPriceExpanded(row.id) }"
|
||||
:title="isPriceExpanded(row.id) ? '收起费用明细' : '展开费用明细'"
|
||||
@click.stop="togglePriceExpanded(row.id)"
|
||||
>
|
||||
<ProtoIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.attachments.length" class="attach-tags">
|
||||
@@ -711,9 +758,12 @@ function refresh() {
|
||||
</td>
|
||||
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.costLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-if="visiblePriceLines(row.costLines, isPriceExpanded(row.id)).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
<div
|
||||
v-for="(line, i) in row.costLines"
|
||||
v-for="(line, i) in visiblePriceLines(row.costLines, isPriceExpanded(row.id))"
|
||||
:key="row.id + '-cost-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
@@ -724,9 +774,12 @@ function refresh() {
|
||||
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.saleLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-if="visiblePriceLines(row.saleLines, isPriceExpanded(row.id)).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
<div
|
||||
v-for="(line, i) in row.saleLines"
|
||||
v-for="(line, i) in visiblePriceLines(row.saleLines, isPriceExpanded(row.id))"
|
||||
:key="row.id + '-sale-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
@@ -737,9 +790,12 @@ function refresh() {
|
||||
<span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div v-if="row.profitLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-if="visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id)).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
<div
|
||||
v-for="(line, i) in row.profitLines"
|
||||
v-for="(line, i) in visiblePriceLines(displayProfitLines(row), isPriceExpanded(row.id))"
|
||||
:key="row.id + '-profit-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
@@ -747,7 +803,7 @@ function refresh() {
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="ticket-margin-cell">{{ row.profit }}</span>
|
||||
<span v-else class="ticket-margin-cell">{{ row.profit === "-" ? "0%" : row.profit }}</span>
|
||||
</td>
|
||||
<td class="col-center">
|
||||
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
|
||||
@@ -852,51 +908,60 @@ function refresh() {
|
||||
<div class="ticket-detail-title">
|
||||
<ProtoIcon name="gauge" :size="14" />价格与利润
|
||||
</div>
|
||||
<div class="ticket-detail-grid">
|
||||
<div class="ticket-detail-item">
|
||||
<label>成本价</label>
|
||||
<div v-if="current.costLines.length" class="ticket-price-lines">
|
||||
<div class="ticket-price-expand">
|
||||
<div class="ticket-detail-grid">
|
||||
<div class="ticket-detail-item">
|
||||
<label>成本价</label>
|
||||
<div
|
||||
v-for="(line, i) in current.costLines"
|
||||
:key="'detail-cost-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
v-if="visiblePriceLines(current.costLines, detailPriceExpanded).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
<div
|
||||
v-for="(line, i) in visiblePriceLines(current.costLines, detailPriceExpanded)"
|
||||
:key="'detail-cost-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>{{ current.costPrice }}</span>
|
||||
</div>
|
||||
<span v-else>{{ current.costPrice }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-item">
|
||||
<label>销售价</label>
|
||||
<div v-if="current.saleLines.length" class="ticket-price-lines">
|
||||
<div class="ticket-detail-item">
|
||||
<label>销售价</label>
|
||||
<div
|
||||
v-for="(line, i) in current.saleLines"
|
||||
:key="'detail-sale-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
v-if="visiblePriceLines(current.saleLines, detailPriceExpanded).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
<div
|
||||
v-for="(line, i) in visiblePriceLines(current.saleLines, detailPriceExpanded)"
|
||||
:key="'detail-sale-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>{{ current.salePrice }}</span>
|
||||
</div>
|
||||
<span v-else>{{ current.salePrice }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-item">
|
||||
<label>利润点</label>
|
||||
<div v-if="current.profitLines.length" class="ticket-price-lines">
|
||||
<div class="ticket-detail-item">
|
||||
<label>利润点</label>
|
||||
<div
|
||||
v-for="(line, i) in current.profitLines"
|
||||
:key="'detail-profit-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
v-if="visiblePriceLines(displayProfitLines(current), detailPriceExpanded).length"
|
||||
class="ticket-price-lines"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
<div
|
||||
v-for="(line, i) in visiblePriceLines(displayProfitLines(current), detailPriceExpanded)"
|
||||
:key="'detail-profit-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>{{ current.profit === "-" ? "0%" : current.profit }}</span>
|
||||
</div>
|
||||
<span v-else>{{ current.profit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
<div
|
||||
v-for="q in current.quotes"
|
||||
:key="q.source + q.price + (q.time || '')"
|
||||
@@ -905,7 +970,7 @@ function refresh() {
|
||||
<div class="quote-source">
|
||||
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
|
||||
</div>
|
||||
<div v-if="q.fees.length" class="quote-fee-list">
|
||||
<div v-if="detailPriceExpanded && q.fees.length" class="quote-fee-list">
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-detail-fee-' + fi"
|
||||
@@ -922,6 +987,16 @@ function refresh() {
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="detailPriceNeedsToggle(current)"
|
||||
type="button"
|
||||
class="ticket-price-toggle"
|
||||
:class="{ 'is-open': detailPriceExpanded }"
|
||||
:title="detailPriceExpanded ? '收起费用明细' : '展开费用明细'"
|
||||
@click="detailPriceExpanded = !detailPriceExpanded"
|
||||
>
|
||||
<ProtoIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -929,21 +1004,25 @@ function refresh() {
|
||||
<div class="ticket-detail-title">
|
||||
<ProtoIcon name="route" :size="14" />询价需求
|
||||
</div>
|
||||
<div class="ticket-detail-demand">
|
||||
<ExpandableClamp
|
||||
class="ticket-detail-demand"
|
||||
:content-key="current.id + '-detail-demand-' + current.demandFields.length"
|
||||
:max-height="220"
|
||||
>
|
||||
<div class="summary-cell">
|
||||
<strong>{{ current.demandTitle }}</strong>
|
||||
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-grid" style="margin-top: 8px">
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>起运 / 目的</label><span>{{ routeText(current) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>件数</label><span>{{ current.pieces }}</span></div>
|
||||
<div class="ticket-detail-item"><label>重量(KG)</label><span>{{ current.weightKg }}</span></div>
|
||||
<div class="ticket-detail-item"><label>体积(CBM)</label><span>{{ current.volumeCbm }}</span></div>
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
<div
|
||||
v-for="(item, idx) in current.demandFields"
|
||||
:key="'demand-field-' + idx + '-' + item.label"
|
||||
class="ticket-detail-item"
|
||||
>
|
||||
<label>{{ item.label }}</label><span>{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</section>
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
@@ -1032,4 +1111,32 @@ function refresh() {
|
||||
color: #708090;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 报价+成本+利润共用展开:箭头管整块费用明细 */
|
||||
.ticket-price-expand {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.ticket-price-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 2px 0 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.ticket-price-toggle:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.ticket-price-toggle :deep(svg) {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.ticket-price-toggle.is-open :deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="app-loading-dots">
|
||||
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
<div class="app-loading-title">友通达询价系统</div>
|
||||
<div class="app-loading-title">友通达智能体运营后台</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { buildDemandFields, quantitySummary } from "/@/views/inquiry/workOrder/demandFields";
|
||||
|
||||
test("land FTL shows vehicle not pieces", () => {
|
||||
const rows = buildDemandFields({
|
||||
businessLine: "LAND",
|
||||
bizTypeLabel: "陆运",
|
||||
origin: "上海",
|
||||
destination: "春疆货场",
|
||||
goodsName: "茶叶",
|
||||
factsJson: JSON.stringify({
|
||||
运输类型: "国内运输整车",
|
||||
线路类别: "国内长途/零担",
|
||||
运输分类: "普件",
|
||||
"车型/数量": "53尺(16.5厢车)",
|
||||
起运港: "上海",
|
||||
目的港: "春疆货场",
|
||||
品名: "茶叶",
|
||||
报价日期: "2026-10-03",
|
||||
}),
|
||||
});
|
||||
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
|
||||
expect(map["运输方式"]).toBe("陆运");
|
||||
expect(map["运输类型"]).toBe("国内运输整车");
|
||||
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
|
||||
expect(map["始发站"]).toBe("上海");
|
||||
expect(map["货物品名"]).toBe("茶叶");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sea shows container spec", () => {
|
||||
const rows = buildDemandFields({
|
||||
businessLine: "SEA",
|
||||
bizTypeLabel: "海运",
|
||||
factsJson: JSON.stringify({
|
||||
起运港: "上海",
|
||||
目的港: "汉堡",
|
||||
品名: "配件",
|
||||
货量: "1x40HQ",
|
||||
整柜或拼柜: "整柜",
|
||||
箱型箱量: "40HQ*1",
|
||||
贸易条款: "FOB",
|
||||
运输分类: "Port to Port",
|
||||
报价日期: "2026-10-03",
|
||||
}),
|
||||
});
|
||||
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
|
||||
expect(map["箱型箱量"]).toBe("40HQ*1");
|
||||
expect(map["整柜或拼柜"]).toBe("整柜");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("land LTL quantity summary uses volume", () => {
|
||||
const qty = quantitySummary({
|
||||
businessLine: "LAND",
|
||||
factsJson: JSON.stringify({
|
||||
运输类型: "国内运输拼车",
|
||||
体积: "10CBM",
|
||||
毛重: "50kg",
|
||||
}),
|
||||
});
|
||||
expect(qty).toContain("体积(CBM) 10CBM");
|
||||
expect(qty).not.toContain("车型");
|
||||
});
|
||||
Reference in New Issue
Block a user