Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419b4a1bf7 |
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -888,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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+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,
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
/**
|
||||
* 机器人回复语 API。
|
||||
* 支持按分类拉取全部条目(后台九分类页)与分页查询。
|
||||
* 调用编码 replyCode 给智能体日后按码取文案;本期只维护、不向智能体下发。
|
||||
* 调用编码 replyCode 供智能体 GET /inquiry/agent/botReply/listEnabled 按码取文案。
|
||||
*/
|
||||
@Slf4j
|
||||
@Api(tags = "询价-机器人回复语")
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ public class InquiryTicket implements Serializable {
|
||||
private List<InquiryPriceLine> saleLines;
|
||||
|
||||
/**
|
||||
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
|
||||
* 利润点逐行。不入库。没调过价每项都是 0%。最后一行是整单利润点。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@ApiModelProperty("利润点逐行(展示)")
|
||||
|
||||
+6
@@ -22,4 +22,10 @@ public interface IInquiryBotReplyService extends IService<InquiryBotReply> {
|
||||
* 编辑正文/启停。已有调用编码不可被请求覆盖。
|
||||
*/
|
||||
void updateReply(InquiryBotReply body);
|
||||
|
||||
/**
|
||||
* 智能体只读:已启用且已有调用编码的正文。
|
||||
* 停用项不返回,智能体回退默认句。
|
||||
*/
|
||||
java.util.List<java.util.Map<String, Object>> listEnabledForAgent();
|
||||
}
|
||||
|
||||
+35
@@ -9,7 +9,13 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,35 @@ public class InquiryBotReplyServiceImpl extends ServiceImpl<InquiryBotReplyMappe
|
||||
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;
|
||||
|
||||
+17
-1
@@ -686,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)) {
|
||||
@@ -734,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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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 时,这里会改写成本价和利润点。
|
||||
|
||||
+1148
-40
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -689,7 +689,7 @@ public class InquiryTmsQueryService {
|
||||
|
||||
/**
|
||||
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
|
||||
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。
|
||||
* 海运/陆运进成本价,空运进销售价;未调价利润点为 0%,调过价才按差额算。
|
||||
*/
|
||||
static void applyQuoteToTicket(InquiryTicket ticket, Map<String, String> quote) {
|
||||
if (ticket == null || quote == null) {
|
||||
|
||||
+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
|
||||
|
||||
+248
@@ -1543,6 +1543,9 @@ public class QuoteTemplateFillTest {
|
||||
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();
|
||||
@@ -1553,6 +1556,14 @@ public class QuoteTemplateFillTest {
|
||||
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);
|
||||
@@ -1610,6 +1621,9 @@ public class QuoteTemplateFillTest {
|
||||
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));
|
||||
@@ -1678,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 这类车型费用也是标签右侧,不是海运费目表。
|
||||
*/
|
||||
@@ -1967,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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -559,12 +569,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1415,3 +1424,102 @@ textarea.field.field-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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -126,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。
|
||||
@@ -413,6 +424,44 @@ 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 多行展示一致。 */
|
||||
function demandDetailLines(row: TicketRow) {
|
||||
return String(row?.demandDetail || "")
|
||||
@@ -472,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);
|
||||
@@ -540,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>
|
||||
@@ -664,35 +714,41 @@ function refresh() {
|
||||
</ExpandableClamp>
|
||||
</td>
|
||||
<td class="align-top col-quote">
|
||||
<ExpandableClamp
|
||||
v-if="row.quotes.length"
|
||||
v-slot="{ expanded }"
|
||||
:content-key="row.id + '-quote-' + row.quotes.length"
|
||||
:force-toggle="quoteHasFees(row)"
|
||||
:clip-collapsed="false"
|
||||
>
|
||||
<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="expanded && 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 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>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
<span v-else style="color: #94a3b8">-</span>
|
||||
<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">
|
||||
@@ -702,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 === '费用合计' }"
|
||||
@@ -715,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 === '费用合计' }"
|
||||
@@ -728,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 === '费用合计' }"
|
||||
@@ -738,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>
|
||||
@@ -843,56 +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>
|
||||
<ExpandableClamp
|
||||
v-if="current.quotes.length"
|
||||
class="ticket-detail-demand"
|
||||
:content-key="current.id + '-detail-quote-' + current.quotes.length"
|
||||
:max-height="220"
|
||||
>
|
||||
<div
|
||||
v-for="q in current.quotes"
|
||||
:key="q.source + q.price + (q.time || '')"
|
||||
@@ -901,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"
|
||||
@@ -918,7 +987,17 @@ function refresh() {
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user