diff --git a/inquiry-agent/agent/channel/h5/air_route_page.py b/inquiry-agent/agent/channel/h5/air_route_page.py
index c35fe83..5e60ccc 100644
--- a/inquiry-agent/agent/channel/h5/air_route_page.py
+++ b/inquiry-agent/agent/channel/h5/air_route_page.py
@@ -230,7 +230,7 @@ def render_air_route_ok() -> str:
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
- "
",
+ "",
auto_close=True,
)
diff --git a/inquiry-agent/agent/channel/h5/land_route_page.py b/inquiry-agent/agent/channel/h5/land_route_page.py
index d0ba0cd..6d336cb 100644
--- a/inquiry-agent/agent/channel/h5/land_route_page.py
+++ b/inquiry-agent/agent/channel/h5/land_route_page.py
@@ -59,7 +59,7 @@ def render_land_route_ok() -> str:
"""选定成功:短暂展示后关企微 H5。"""
return _wrap(
"已选定",
- "",
+ "",
auto_close=True,
)
diff --git a/inquiry-agent/agent/channel/h5/wecom_close.py b/inquiry-agent/agent/channel/h5/wecom_close.py
index 73faaf5..c67cb59 100644
--- a/inquiry-agent/agent/channel/h5/wecom_close.py
+++ b/inquiry-agent/agent/channel/h5/wecom_close.py
@@ -2,8 +2,9 @@
企微内嵌 H5 成功后自动关页。
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
-手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样
-先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。
+手机端只调一次 WeixinJSBridge.closeWindow,关掉当前 H5 后留在询价对话里。
+再调 ww/wx/window.close 会把询价小助手整个退出,回到企业微信首页。
+电脑企微没有这套桥,加载 wecom-jssdk、用 /inquiry-form/jsapi-signature 注册后再关一次。
失败、过期页不要挂这段。
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
"""
@@ -46,30 +47,37 @@ def wecom_auto_close_script() -> str:
}};
}}
- function nativeClose() {{
- try {{
- if (typeof WeixinJSBridge !== "undefined") {{
- WeixinJSBridge.call("closeWindow");
- }}
- }} catch (e) {{}}
- try {{
- if (window.ww && typeof ww.closeWindow === "function") {{
- ww.closeWindow();
- }}
- }} catch (e) {{}}
- try {{
- if (window.wx && typeof wx.closeWindow === "function") {{
- wx.closeWindow();
- }}
- }} catch (e) {{}}
- try {{ window.close(); }} catch (e) {{}}
+ var closed = false;
+
+ function closeOnce(fn) {{
+ if (closed) return;
+ closed = true;
+ try {{ fn(); }} catch (e) {{}}
+ }}
+
+ // 手机企微有 WeixinJSBridge:只关当前 H5。再调第二套关闭会把询价小助手整个退出,回到企业微信首页。
+ function closeMobile() {{
+ closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
+ }}
+
+ // 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
+ function closeDesktop() {{
+ closeOnce(function () {{
+ if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
+ else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
+ }});
}}
function boot() {{
if (typeof WeixinJSBridge !== "undefined") {{
- setTimeout(nativeClose, {delay});
+ setTimeout(closeMobile, {delay});
+ return;
}}
- document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
+ var sawBridge = false;
+ document.addEventListener("WeixinJSBridgeReady", function () {{
+ sawBridge = true;
+ setTimeout(closeMobile, {delay});
+ }}, false);
var ready = Promise.resolve();
if (window.ww && ww.register && corpId) {{
ready = ww.register({{
@@ -81,9 +89,17 @@ def wecom_auto_close_script() -> str:
}}).catch(function () {{ return null; }});
}}
ready.then(function () {{
- setTimeout(nativeClose, {delay});
+ setTimeout(function () {{
+ if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
+ if (!closed) closeMobile();
+ return;
+ }}
+ closeDesktop();
+ }}, {delay});
}}).catch(function () {{
- setTimeout(nativeClose, {delay});
+ setTimeout(function () {{
+ if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
+ }}, {delay});
}});
}}
diff --git a/inquiry-agent/agent/ledger/http_ledger.py b/inquiry-agent/agent/ledger/http_ledger.py
index ac7a745..92984c8 100644
--- a/inquiry-agent/agent/ledger/http_ledger.py
+++ b/inquiry-agent/agent/ledger/http_ledger.py
@@ -245,6 +245,10 @@ class HttpLedger:
"error": out.get("error") or "NO_TEMPLATE",
"message": out.get("message") or FAIL_MESSAGE,
}
+ from agent.quote_templates.filename import filename_from_render
+
+ # 主账旧版本仍回 WO_quote.xlsx 时,这里按模板名改成销售看到的名字。
+ out["fileName"] = filename_from_render(work_order_no, out)
out.setdefault("needsPdf", str(format).lower() == "pdf")
return out
diff --git a/inquiry-agent/agent/ledger/memory_ledger.py b/inquiry-agent/agent/ledger/memory_ledger.py
index ace1b1f..0e415e1 100644
--- a/inquiry-agent/agent/ledger/memory_ledger.py
+++ b/inquiry-agent/agent/ledger/memory_ledger.py
@@ -425,13 +425,16 @@ class MemoryLedger:
out = {"ok": False, "error": "NO_TEMPLATE", "message": FAIL_MESSAGE, "facts": dict(facts or {})}
self.last_render = out
return out
- ext = "pdf" if str(format).lower() == "pdf" else "xlsx"
+ from agent.quote_templates.filename import quote_sheet_filename
+
+ suffix = ".pdf" if str(format).lower() == "pdf" else ".xlsx"
out = {
"ok": True,
"matchType": picked.match_type,
"templateId": picked.template_id,
"templateName": picked.name,
- "fileName": f"{work_order_no}_quote.{ext}",
+ "fileName": quote_sheet_filename(work_order_no, picked.name, suffix=suffix)
+ or f"{work_order_no}_quote{suffix}",
"fileBase64": "UEsDBAoAAAAAA",
"needsPdf": False,
"facts": dict(facts or {}),
diff --git a/inquiry-agent/agent/llm/extract_text.py b/inquiry-agent/agent/llm/extract_text.py
index 35cd85f..342a55c 100644
--- a/inquiry-agent/agent/llm/extract_text.py
+++ b/inquiry-agent/agent/llm/extract_text.py
@@ -57,6 +57,7 @@ _OPTIONAL_INQUIRY_LABELS = (
"提货地址(非必填)",
"收货地址(非必填)",
"贸易条款(非必填)",
+ "起运地(非必填)",
"起运港(非必填)",
"货源地",
"货好时间",
@@ -129,6 +130,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"整柜或拼柜",
"箱型箱量",
"贸易条款",
+ "货好时间",
"运输分类",
"报价日期",
*QUOTE_DATE_SYNONYMS,
@@ -136,7 +138,52 @@ _SHEET_CLEAN_KEYS = frozenset(
"包装类型",
}
)
+# 报价单从这里往下是费用和条款,不是询价栏。切掉后再抽字段。
+_QUOTE_BODY_MARKS = (
+ "报价明细",
+ "报价条件",
+ "预估费用",
+ "空舱费",
+ "托运方",
+ "承运方",
+)
+# 表头常被拉成「体 积」「计 费 重」。只收这些词中间的空格,不跨行。
+_TIGHTEN_LABELS = tuple(
+ sorted(
+ {
+ *_QUOTE_BODY_MARKS,
+ "计费重",
+ "时效要求",
+ "派送地址",
+ "提货地址",
+ "收货地址",
+ "货好时间",
+ "贸易条款",
+ "报价日期",
+ "起运地",
+ "起运港",
+ "目的地",
+ "目的港",
+ "包装类型",
+ "包装方式",
+ "体积",
+ "重量",
+ "品名",
+ "件数",
+ "货源地",
+ "运输方式",
+ },
+ key=len,
+ reverse=True,
+ )
+)
+_FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$")
_SHEET_STOP = (
+ "报价明细",
+ "报价条件",
+ "预估费用",
+ "计费重",
+ "时效要求",
"要求运输时效",
"Requested Transit",
"费用汇总",
@@ -291,6 +338,52 @@ def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
return merged
+def prepare_sheet_oral(text: str) -> str:
+ """
+ 报价单只留询价栏。
+
+ 费用明细、报价条件、落款整段丢掉。表头里被空格拉开的「体 积」收回成「体积」,
+ 否则上一栏会把后面的报价条件整段吞进去。计费重不是毛重,单独拿掉。
+ 副作用:无。
+ """
+ body = _tighten_sheet_labels(text or "")
+ cut = len(body)
+ for mark in _QUOTE_BODY_MARKS:
+ pos = body.find(mark)
+ if 0 <= pos < cut:
+ cut = pos
+ body = body[:cut]
+ body = re.sub(r"计费重\s*[::]?\s*[0-9.]+\s*(?:KG|KGS|kg|公斤)?", " ", body)
+ return body.strip()
+
+
+def _tighten_sheet_labels(text: str) -> str:
+ """已知表头中间只允许空格。不跨行,避免把两行并成一个词。"""
+ body = text or ""
+ gap = r"[ \t\u3000]{0,6}"
+ for label in _TIGHTEN_LABELS:
+ if len(label) < 2:
+ continue
+ pat = re.compile(
+ r"(? dict[str, str]:
+ """起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。"""
+ out = dict(facts or {})
+ for key in ("起运港", "起运地", "目的港", "目的地"):
+ val = str(out.get(key) or "").strip()
+ if not val:
+ continue
+ bits = [p.strip(" *::\t") for p in re.split(r"[\n/、,,]+", val) if p.strip()]
+ if bits and all(_FEE_NAME.match(p) for p in bits):
+ out.pop(key, None)
+ return out
+
+
def extract_inquiry_snapshot(
text: str,
*,
@@ -317,7 +410,7 @@ def extract_inquiry_snapshot(
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
- facts = _clip_sheet_inquiry_facts(facts)
+ facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
subtype = str(facts.get("运输类型") or "").strip()
return {
"business_line": mode,
@@ -325,6 +418,8 @@ def extract_inquiry_snapshot(
"facts": facts,
"source": "injected",
}
+ # 附件常是整张报价单。先丢掉费用明细和条款,再抽询价栏。
+ text = prepare_sheet_oral(text)
mode = detect_transport_mode(text)
facts = parse_labeled_facts(text)
source = "labeled_or_mode"
@@ -361,7 +456,7 @@ def extract_inquiry_snapshot(
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
- facts = _clip_sheet_inquiry_facts(facts)
+ facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts))
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = land_subtype
@@ -453,6 +548,10 @@ def clip_sheet_field_value(raw: str) -> str:
parts = [p for p in parts if p and not _SHEET_HEADER_NOISE.search(p)]
if not parts:
return ""
+ # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。
+ token = parts[0].strip().strip("::")
+ if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS:
+ return ""
return parts[0].strip()
diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py
index 87113c8..0247839 100644
--- a/inquiry-agent/agent/policy/air_text_flow.py
+++ b/inquiry-agent/agent/policy/air_text_flow.py
@@ -337,13 +337,134 @@ class AirTextInquiryFlow:
self.drop_current_bookmark(sender_id)
return None
+ def _handoff_flows(self) -> list[Any]:
+ """陆运、海运、空运各查自己的工单索引。当前流程放前面,避免单测实例对不上全局。"""
+ from agent.policy.land_text_flow import get_land_text_flow
+ from agent.policy.sea_text_flow import get_sea_text_flow
+
+ flows: list[Any] = [self]
+ for flow in (get_land_text_flow(), get_sea_text_flow(), get_air_text_flow()):
+ if all(flow is not item for item in flows):
+ flows.append(flow)
+ return flows
+
+ def _handoff_owner_for_line(self, line: str):
+ """
+ 转人工入口跟工单运输方式走,不跟当前私聊里的另一张单走。
+
+ 空运、海运、陆运各用自己的流程收书签。对不上就返回空,不能默认成空运。
+ """
+ from agent.policy.land_text_flow import LandTextInquiryFlow, get_land_text_flow
+ from agent.policy.sea_text_flow import SeaTextInquiryFlow, get_sea_text_flow
+
+ code = (line or "").strip().upper()
+ if code == "LAND":
+ return self if isinstance(self, LandTextInquiryFlow) else get_land_text_flow()
+ if code == "SEA":
+ return (
+ self
+ if isinstance(self, SeaTextInquiryFlow)
+ and not isinstance(self, LandTextInquiryFlow)
+ else get_sea_text_flow()
+ )
+ if code == "AIR":
+ return self if type(self) is AirTextInquiryFlow else get_air_text_flow()
+ return None
+
+ def _offer_handoff_for_named_ticket(
+ self,
+ *,
+ sender_id: str,
+ work_order_no: str,
+ text: str,
+ reply: ReplyFn,
+ ) -> str:
+ """
+ 按原话里的工单号转人工。
+
+ 有没有协同群只决定拦不拦。话术只看这张单是空运、海运还是陆运。
+ 销售私聊里另一张已建群的单,不能拿来拒绝,也不能拿来决定话术。
+ 副作用:命中后把这张单收到对应运输方式的转人工入口;读主账。不建群。
+ """
+ from agent.policy.handoff_human import is_closed_for_handoff
+
+ no = (work_order_no or "").strip().upper()
+ hit = None
+ hit_flow = None
+ for flow in self._handoff_flows():
+ found = flow.session_by_ticket(sender_id, no)
+ if found is None:
+ continue
+ hit = found
+ hit_flow = flow
+ break
+ getter = getattr((hit_flow or self)._ledger, "get_for_agent", None)
+ view: dict[str, Any] = {}
+ if callable(getter):
+ try:
+ view = getter(work_order_no=no, sender_id=sender_id) or {}
+ except Exception:
+ logger.exception("转人工读主账失败 wo=%s", no)
+ view = {}
+ if view.get("found") and view.get("owned") is False:
+ reply("这张工单不属于当前账号,不能转人工。")
+ return "ticket_forbidden"
+ if hit is not None and view.get("found"):
+ # 主账上的运输方式和群号盖过进程里的旧书签。
+ hit.collab_chat_id = str(view.get("collab_chat_id") or "").strip()
+ if str(view.get("status") or "").strip():
+ hit.status = str(view.get("status") or "").strip()
+ if str(view.get("business_line") or "").strip():
+ hit.business_line = str(view.get("business_line") or "").strip().upper()
+ if hit is None:
+ if not callable(getter):
+ reply(f"找不到工单 {no},请核对工单号后再试。")
+ return "ticket_missing"
+ if not view.get("found"):
+ reply(f"找不到工单 {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)
+ 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)
+ return "need_mode"
+ logger.info(
+ "handoff.named wo=%s line=%s phase=%s chat=%s",
+ no,
+ line or "-",
+ hit.phase or "-",
+ "yes" if (hit.collab_chat_id or "").strip() else "no",
+ )
+ if is_closed_for_handoff(hit.status):
+ reply("该工单已结束,不能再转人工。")
+ return "handoff_closed"
+ if owner._block_if_handoff_committed(hit, reply):
+ return "handoff_done"
+ return owner._offer_handoff_now(
+ sender_id=sender_id, reason="intent", reply=reply, text=text, sess=hit
+ )
+
def offer_handoff(self, *, sender_id: str, text: str, reply: ReplyFn) -> str:
"""
路由判定销售明确要求人来接管。运输方式不明则继续补问运输方式。
+
+ 原话里写了工单号时,只处理那一张。
+ 有没有协同群只决定拦不拦;空运、海运、陆运各走各的转人工。
"""
from agent.llm.extract_text import detect_transport_mode
from agent.policy.handoff_human import is_closed_for_handoff
+ named = copy.extract_work_order_no(text)
+ if named:
+ return self._offer_handoff_for_named_ticket(
+ sender_id=sender_id, work_order_no=named, text=text, reply=reply
+ )
sess = self.session_of(sender_id)
if sess and is_closed_for_handoff(sess.status):
reply("该工单已结束,不能再转人工。")
@@ -1572,7 +1693,15 @@ class AirTextInquiryFlow:
if not result.get("ok"):
reply(str(result.get("message") or copy.quote_template_missing()))
return "wait_adopt"
- filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
+ from agent.quote_templates.filename import filename_from_render
+
+ # 私聊直接生成与群里同一套名字:工单号 + 模板名 + 后缀。PDF 用 .pdf,表格用 .xlsx。
+ xlsx_name = filename_from_render(sess.work_order_no, result, suffix=".xlsx")
+ filename = (
+ filename_from_render(sess.work_order_no, result, suffix=".pdf")
+ if kind == "PDF"
+ else xlsx_name
+ )
from agent.policy.event_exception import clear_wait
clear_wait(self._ledger, work_order_no=sess.work_order_no)
@@ -1582,20 +1711,18 @@ class AirTextInquiryFlow:
result,
work_order_no=sess.work_order_no,
sender_id=sess.sender_id,
- filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
+ filename=filename,
quote=dict(sess.quote or {}),
facts=dict(sess.facts or {}),
)
if not queued:
- fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
- if not fallback.lower().endswith(".xlsx"):
- fallback = f"{sess.work_order_no}_quote.xlsx"
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
+ file_extra = self._quote_file_payload(result, filename=xlsx_name)
return self._emit_file_then_deal(
sess,
reply,
kind="Excel",
- filename=fallback,
+ filename=xlsx_name,
file_extra=file_extra,
)
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
diff --git a/inquiry-agent/agent/policy/inquiry_copy.py b/inquiry-agent/agent/policy/inquiry_copy.py
index 92fa222..4b27d66 100644
--- a/inquiry-agent/agent/policy/inquiry_copy.py
+++ b/inquiry-agent/agent/policy/inquiry_copy.py
@@ -454,9 +454,7 @@ def _air_field_line(key: str, value: str, *, required: bool, example: str = "")
def AIR_DISPLAY_LABEL(key: str) -> str:
- """核对/补问对外名。起运港按产品口径写起运港。"""
- if key == "起运港":
- return "起运港"
+ """核对/补问对外名。空运起运港对外写起运地,内部键不变。"""
return display_name(key, "AIR")
@@ -464,10 +462,11 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
"""
确定查价前的核对卡。没有工单号。
- 必填 7 项 + 6 个非必填都列出;空着的非必填留空行。
+ 起运地(非必填)紧挨在目的地(必填)上面。其余非必填仍在报价日期后面。
"""
src = dict(facts or {})
lines = ["运输方式(必填):空运"]
+ lines.append(_air_field_line("起运港", _air_known_value(src, "起运港"), required=False))
for key, _label in AIR_REQUIRED_FIELDS:
if key == "报价日期":
val = _air_known_value(src, key) or (src.get("报价日期") or "").strip()
@@ -475,6 +474,8 @@ def air_confirm_card(facts: dict[str, str] | None) -> str:
continue
lines.append(_air_field_line(key, _air_known_value(src, key), required=True))
for key, _label in AIR_OPTIONAL_FIELDS:
+ if key == "起运港":
+ continue
lines.append(_air_field_line(key, _air_known_value(src, key), required=False))
lines.append("")
lines.append(AIR_CONFIRM_TAIL)
@@ -523,14 +524,15 @@ def ask_clarify(
lines = ["请确认并补充以下信息:"]
if transport_mode:
lines.append(f"运输方式:{transport_mode}")
+ if not sea:
+ origin = known(src, "起运港")
+ if origin:
+ lines.append(f"起运地:{origin}")
for key, label, _example in rows:
val = known(src, key)
if val:
lines.append(f"{label}:{val}")
if not sea:
- origin = known(src, "起运港")
- if origin:
- lines.append(f"起运地:{origin}")
for key, label, _example in _AIR_OPTIONAL_CLARIFY:
if key == "起运港":
continue
@@ -545,6 +547,10 @@ def ask_clarify(
any_missing = True
seq += 1
lines.append(_pending_field_block(seq, "运输方式", "空运"))
+ if not sea and not known(src, "起运港"):
+ any_missing = True
+ seq += 1
+ lines.append(_pending_field_block(seq, "起运地", _AIR_EXAMPLES["起运港"], optional=True))
for key, label, example in rows:
if key in missing_set:
any_missing = True
@@ -564,13 +570,12 @@ def ask_clarify(
optional_pending = [
(key, label, example)
for key, label, example in _AIR_OPTIONAL_CLARIFY
- if not known(src, key)
+ if key != "起运港" and not known(src, key)
]
for key, label, example in optional_pending:
any_missing = True
seq += 1
- shown = "起运港" if key == "起运港" else label
- lines.append(_pending_field_block(seq, shown, example, optional=True))
+ lines.append(_pending_field_block(seq, label, example, optional=True))
if not any_missing:
return ""
return "\n".join(lines)
diff --git a/inquiry-agent/agent/policy/sea_group_ops.py b/inquiry-agent/agent/policy/sea_group_ops.py
index 80ff673..fd62f19 100644
--- a/inquiry-agent/agent/policy/sea_group_ops.py
+++ b/inquiry-agent/agent/policy/sea_group_ops.py
@@ -778,7 +778,10 @@ def _render_and_send_sheet(
if not result.get("ok"):
logger.warning("群填表失败 wo=%s line=%s err=%s", work_order_no, line, result.get("error"))
return result
- filename = str(result.get("fileName") or f"{work_order_no}_quote.xlsx")
+ from agent.quote_templates.filename import filename_from_render
+
+ # 群里文件名:工单号 + 后台模板名 + .xlsx。调价后再发也是这个名字。
+ filename = filename_from_render(work_order_no, result)
sender = getattr(client, "send_group_file", None)
if not callable(sender):
logger.warning("群发文件无客户端 wo=%s chat=%s", work_order_no, chat_id)
@@ -1039,6 +1042,8 @@ def _persist_quote_sheet(
if sess is not None and getattr(sess, "collab_facts", None):
collab.update(dict(sess.collab_facts or {}))
renderer = getattr(flow.ledger, "render_quote", None)
+ from agent.quote_templates.filename import filename_from_render
+
filename = f"{no}_quote.xlsx"
object_key = filename
line = str(
@@ -1062,8 +1067,8 @@ def _persist_quote_sheet(
),
) or {}
if result.get("ok"):
- filename = str(result.get("fileName") or filename)
- object_key = str(result.get("objectKey") or result.get("fileName") or filename)
+ filename = filename_from_render(no, result)
+ object_key = str(result.get("objectKey") or filename)
_archive_file(flow, no, filename, {"media_id": object_key}, sender_id)
diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py
index 94e9004..5bd626e 100644
--- a/inquiry-agent/agent/policy/sea_text_flow.py
+++ b/inquiry-agent/agent/policy/sea_text_flow.py
@@ -598,7 +598,14 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
)
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
- if sess.business_line == "SEA" and sess.phase == "wait_collab":
+ """
+ 海运、陆运停在拉群问句时,继续工单必须把同一张卡拉回来。
+
+ 空运父类的无价卡没有「拉产品进群」按钮。陆运以前被当成空运重放,
+ 销售继续工单就看不到这张卡。
+ """
+ line = (sess.business_line or "").upper()
+ if line in {"SEA", "LAND"} and sess.phase == "wait_collab":
self._emit_sea_hit(
reply,
work_order_no=sess.work_order_no,
@@ -606,10 +613,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status=sess.status or "已报价",
- business_line=sess.business_line or "SEA",
+ business_line=line,
)
return "wait_collab"
- if sess.business_line == "SEA" and sess.phase == "tms_miss":
+ if line in {"SEA", "LAND"} and sess.phase == "tms_miss":
self._emit_tms_miss(reply, sess.work_order_no)
return "tms_miss"
return super()._replay_wait(sess, reply, source=source)
diff --git a/inquiry-agent/agent/quote_templates/filename.py b/inquiry-agent/agent/quote_templates/filename.py
new file mode 100644
index 0000000..7db1ac7
--- /dev/null
+++ b/inquiry-agent/agent/quote_templates/filename.py
@@ -0,0 +1,83 @@
+"""
+系统生成报价单的对外文件名。
+
+协同群发出、私聊直接生成、调价后再发,都用「工单号 + 后台模板名 + 后缀」。
+表格是 .xlsx,转成 PDF 再发出去是 .pdf。
+航线、产品自己上传的附件不走这里,原文件名存后台。
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+# 模板名若自带这些后缀,发出去时去掉。中文名本身不动。
+_SUFFIXES = (".xlsx", ".xls", ".xlsm", ".pdf")
+# Windows / 企微文件名不允许的字符。其余(含中文)原样保留。
+_ILLEGAL = set('\\/:*?"<>|')
+
+
+def quote_sheet_filename(
+ work_order_no: str,
+ template_name: str,
+ *,
+ suffix: str = ".xlsx",
+) -> str:
+ """
+ 工单号紧挨后台模板名,再加文件后缀。没有模板名则返回空。
+
+ 模板名里如果已经写了 .xlsx / .pdf,先去掉再按这次要发的格式重加,避免两个后缀。
+ 副作用:无。纯字符串,可在请求线程调用。
+ """
+ no = str(work_order_no or "").strip()
+ name = _plain_name(template_name)
+ if not no or not name:
+ return ""
+ return f"{no}{name}{_suffix(suffix)}"
+
+
+def filename_from_render(
+ work_order_no: str,
+ result: dict[str, Any] | None,
+ *,
+ suffix: str = ".xlsx",
+) -> str:
+ """
+ 出单结果里的对外文件名。有模板名就用工单号拼接,并带上这次的后缀。
+
+ 主账即使仍回 WO_quote.xlsx,只要带了 templateName,销售侧仍是「工单号+模板名.xlsx」。
+ PDF 出站传入 suffix=".pdf"。
+ """
+ src = result or {}
+ ext = _suffix(suffix)
+ shown = quote_sheet_filename(
+ work_order_no, str(src.get("templateName") or ""), suffix=ext
+ )
+ if shown:
+ return shown
+ raw = _plain_name(str(src.get("fileName") or ""))
+ if raw:
+ return f"{raw}{ext}"
+ no = str(work_order_no or "").strip()
+ return f"{no}_quote{ext}" if no else f"quote{ext}"
+
+
+def _suffix(suffix: str) -> str:
+ """只接受已知表格/PDF 后缀,其余一律按 Excel。"""
+ text = str(suffix or "").strip().lower()
+ if not text.startswith("."):
+ text = f".{text}" if text else ""
+ if text in _SUFFIXES:
+ return text
+ return ".xlsx"
+
+
+def _plain_name(name: str) -> str:
+ """去掉已知后缀和非法字符。空则返回空。"""
+ text = str(name or "").strip()
+ lower = text.lower()
+ for ext in _SUFFIXES:
+ if lower.endswith(ext):
+ text = text[: -len(ext)].rstrip(" .")
+ break
+ cleaned = "".join(ch for ch in text if ch not in _ILLEGAL and ord(ch) >= 32)
+ return cleaned.strip()
diff --git a/inquiry-agent/agent/schema/field_validate.py b/inquiry-agent/agent/schema/field_validate.py
index cfd6731..170ba48 100644
--- a/inquiry-agent/agent/schema/field_validate.py
+++ b/inquiry-agent/agent/schema/field_validate.py
@@ -31,7 +31,7 @@ AIR_REQUIRED_FIELDS = (
)
# 空运询价非必填:有值收下;贸易条款只记工单,不挡查价。
AIR_OPTIONAL_FIELDS = (
- ("起运港", "起运港"),
+ ("起运港", "起运地"),
("货源地", "货源地"),
("货好时间", "货好时间"),
("提货地址", "提货地址"),
@@ -40,7 +40,7 @@ AIR_OPTIONAL_FIELDS = (
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
AIR_DISPLAY.update({key: label for key, label in AIR_OPTIONAL_FIELDS})
-# 补问回显仍用「起运地」,与历史卡片一致。
+# 内部键仍是起运港(主账/TMS)。销售侧一律写起运地。
AIR_DISPLAY["起运港"] = "起运地"
# 海运询价必填 10 项(销售侧含运输方式):内部键「货量」对外叫「货物数量」。
@@ -235,6 +235,7 @@ _ALIASES = {
"收货地址(非必填)": "收货地址",
"贸易条款(非必填)": "贸易条款",
"起运港(非必填)": "起运港",
+ "起运地(非必填)": "起运港",
}
for _quote_alias in QUOTE_DATE_SYNONYMS:
_ALIASES[_quote_alias] = "报价日期"
@@ -327,7 +328,30 @@ def _quote_date_labeled_value(raw: str) -> str:
best_val = hit.group(1).strip().strip(",,。;;、")
if not best_val:
return ""
- return _harvest_quote_date(best_val) or best_val
+ return _harvest_quote_date(best_val) or _excel_serial_date(best_val) or best_val
+
+
+def _excel_serial_date(token: str) -> str:
+ """
+ Excel 把日期存成序列号时(如 46307),收成 yyyy-MM-dd。
+
+ 只认整段就是 5 位数字。件数、重量不走这里。
+ """
+ from datetime import datetime, timedelta
+
+ text = (token or "").strip()
+ if text.endswith(".0"):
+ text = text[:-2]
+ if not re.fullmatch(r"\d{5}", text):
+ return ""
+ serial = int(text)
+ # 约 1982–2064。再外的 5 位数不当日期。
+ if serial < 30000 or serial > 60000:
+ return ""
+ try:
+ return (datetime(1899, 12, 30) + timedelta(days=serial)).date().isoformat()
+ except OverflowError:
+ return ""
def _harvest_quote_date(raw: str) -> str:
diff --git a/inquiry-agent/tests/test_air_field_contract.py b/inquiry-agent/tests/test_air_field_contract.py
index f63a042..258e46e 100644
--- a/inquiry-agent/tests/test_air_field_contract.py
+++ b/inquiry-agent/tests/test_air_field_contract.py
@@ -113,12 +113,25 @@ class AirFieldContractTests(unittest.TestCase):
}
)
self.assertNotIn("工单号", text)
+ self.assertIn("起运地(非必填):", text)
+ self.assertNotIn("起运港(非必填)", text)
+ self.assertLess(text.index("起运地(非必填):"), text.index("目的地(必填):克拉克"))
self.assertIn("目的地(必填):克拉克", text)
- self.assertIn("起运港(非必填):", text)
self.assertIn("货源地(非必填):", text)
self.assertIn("贸易条款(非必填):", text)
self.assertIn(copy.AIR_CONFIRM_TAIL, text)
+ def test_clarify_puts_origin_above_destination(self) -> None:
+ text = copy.ask_clarify(
+ facts={},
+ missing_keys=["目的港", "品名"],
+ transport_mode="空运",
+ )
+ self.assertIn("1.起运地(非必填):\n(如:南京)", text)
+ self.assertIn("2.目的地(必填):\n(如:吉隆坡)", text)
+ self.assertLess(text.index("起运地(非必填)"), text.index("目的地(必填)"))
+ self.assertNotIn("起运港(非必填)", text)
+
def test_empty_origin_confirm_then_quote(self) -> None:
flow = AirTextInquiryFlow(MemoryLedger())
replies: list[str] = []
diff --git a/inquiry-agent/tests/test_air_route_options.py b/inquiry-agent/tests/test_air_route_options.py
index f3013be..856a97e 100644
--- a/inquiry-agent/tests/test_air_route_options.py
+++ b/inquiry-agent/tests/test_air_route_options.py
@@ -331,6 +331,7 @@ class AirRouteOptionsTests(unittest.TestCase):
self.assertIn("closeWindow", ok)
self.assertIn("wecom-jssdk", ok)
self.assertIn("jsapi-signature", ok)
+ self.assertNotIn("window.close()", ok)
self.assertNotIn("closeWindow", dead)
diff --git a/inquiry-agent/tests/test_air_text_inquiry.py b/inquiry-agent/tests/test_air_text_inquiry.py
index 684999d..7503290 100644
--- a/inquiry-agent/tests/test_air_text_inquiry.py
+++ b/inquiry-agent/tests/test_air_text_inquiry.py
@@ -93,6 +93,7 @@ class AirTextInquiryTests(unittest.TestCase):
self.assertIn("请确认并补充以下信息:", text)
self.assertIn("运输方式:空运", text)
self.assertIn("起运地:珠海", text)
+ self.assertLess(text.index("起运地:珠海"), text.index("目的地:克拉克"))
self.assertIn("目的地:克拉克", text)
self.assertIn("品名:茶叶", text)
self.assertIn("件数:10件", text)
diff --git a/inquiry-agent/tests/test_extract_oral.py b/inquiry-agent/tests/test_extract_oral.py
index afb982e..3eeaf2b 100644
--- a/inquiry-agent/tests/test_extract_oral.py
+++ b/inquiry-agent/tests/test_extract_oral.py
@@ -572,5 +572,58 @@ class SeaSheetHeaderClipTests(unittest.TestCase):
self.assertEqual(oral["facts"].get("报价日期"), "2026-09-15")
+# 测服 2026-10-05:空运报价单整表被抽进补问,费用和条款进了起运地、货好时间。
+AIR_QUOTE_SHEET = """
+运输方式:空运
+目的地:CRK
+品名:化妆品
+件数:11
+重量:1KG
+体 积: 100 CBM
+报价日期:46307
+货好时间:
+体 积: 100 CBM 时效要求:
+计 费 重: 10 KG
+派送地址:
+二、报价明细
+项目 费用名称 单位 币别 单价 数量 金额 备注
+空运段 空运费
+起运港 操作费
+文件费 如需多个分单另计
+*代理报关费 发生时收取
+*提货费 发生时收取
+其他 *其他杂费 发生时收取
+预估费用 0
+三、报价条件
+1、报价适用范围:一般货物(非危险品)
+贸易条款:DAP
+托运方:某某有限公司 承运方:深圳市友通达国际货运代理有限公司
+"""
+
+
+class AirQuoteSheetInquiryOnlyTests(unittest.TestCase):
+ def test_quote_sheet_keeps_inquiry_fields_only(self) -> None:
+ """费用明细、报价条件、落款不能进补问。起运地不能收成操作费。"""
+ snap = extract_inquiry_snapshot(AIR_QUOTE_SHEET, allow_b=False)
+ facts = snap["facts"]
+ self.assertEqual(snap["business_line"], "AIR")
+ self.assertEqual(facts.get("目的港"), "CRK")
+ self.assertEqual(facts.get("品名"), "化妆品")
+ self.assertIn("11", str(facts.get("件数") or ""))
+ self.assertIn("1", str(facts.get("毛重") or ""))
+ self.assertNotIn("10", str(facts.get("毛重") or ""))
+ self.assertIn("100", str(facts.get("体积") or ""))
+ self.assertEqual(facts.get("报价日期"), "2026-10-12")
+ self.assertNotIn("起运港", facts)
+ self.assertNotIn("货好时间", facts)
+ self.assertNotIn("贸易条款", facts)
+ blob = "\n".join(facts.values())
+ self.assertNotIn("操作费", blob)
+ self.assertNotIn("报价明细", blob)
+ self.assertNotIn("报价条件", blob)
+ self.assertNotIn("空舱", blob)
+ self.assertNotIn("友通达", blob)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/inquiry-agent/tests/test_handoff_human.py b/inquiry-agent/tests/test_handoff_human.py
index 06a3f5e..29ffc0e 100644
--- a/inquiry-agent/tests/test_handoff_human.py
+++ b/inquiry-agent/tests/test_handoff_human.py
@@ -345,5 +345,231 @@ class HandoffFlowOfferTests(unittest.TestCase):
self.assertIn(copy.BTN_PULL_COLLAB, bodies)
+class NamedTicketHandoffTests(unittest.TestCase):
+ """点名工单号转人工时,只看那一张单,不看私聊里另一张已建群的单。"""
+
+ def _flow(self):
+ from agent.policy.land_text_flow import LandTextInquiryFlow
+ from agent.ledger.memory_ledger import MemoryLedger
+
+ return LandTextInquiryFlow(MemoryLedger())
+
+ def test_deal_followup_not_blocked_by_other_group(self) -> None:
+ flow = self._flow()
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="wait_deal",
+ business_line="LAND",
+ work_order_no="WO202610040002",
+ status="已报价",
+ quote={"total": "CNY 800", "quoteFileIssued": True},
+ )
+ )
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="collab_group",
+ business_line="LAND",
+ work_order_no="WO202610040004",
+ collab_chat_id="wr_other",
+ status="询价中",
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(
+ sender_id="s1", text="WO202610040002转人工", reply=reply
+ )
+ self.assertEqual(phase, "handoff_offer")
+ self.assertIn("运输方式:陆运", replies[-1])
+ self.assertIn("是否拉产品人员进群协同", replies[-1])
+ self.assertNotIn("原有空运群", replies[-1])
+ self.assertNotEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB)
+ named = flow.session_by_ticket("s1", "WO202610040002")
+ self.assertEqual(named.phase, "handoff_offer")
+ self.assertFalse((named.collab_chat_id or "").strip())
+
+ def test_named_ticket_with_group_still_blocked(self) -> None:
+ flow = self._flow()
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="collab_group",
+ business_line="LAND",
+ work_order_no="WO202610040002",
+ collab_chat_id="wr_real",
+ status="询价中",
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(
+ sender_id="s1", text="WO202610040002转人工", reply=reply
+ )
+ self.assertEqual(phase, "handoff_already")
+ self.assertEqual(replies[-1], copy.HANDOFF_ALREADY_COLLAB)
+
+ def test_ledger_deal_ticket_without_group_offers_card(self) -> None:
+ from agent.ledger.memory_ledger import MemoryTicket
+
+ flow = self._flow()
+ no = "WO202610040012"
+ flow.ledger._tickets[no] = MemoryTicket(
+ work_order_no=no,
+ sales_wecom_id="s1",
+ business_line="LAND",
+ facts={"运输方式": "陆运"},
+ fingerprint="land-deal",
+ status="已报价",
+ quote={"total": "CNY 800", "quoteFileIssued": True},
+ quote_file_ready=True,
+ )
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="collab_group",
+ business_line="LAND",
+ work_order_no="WO202610040004",
+ collab_chat_id="wr_other",
+ status="询价中",
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
+ self.assertEqual(phase, "handoff_offer")
+ self.assertIn("是否拉产品人员进群协同", "\n".join(replies))
+ self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, replies)
+
+ def test_air_deal_ticket_ignores_other_land_group(self) -> None:
+ """空运成交跟进没有协同群时,不能被另一张陆运已拉群的单挡成「已拉产品进群」。"""
+ from agent.ledger.memory_ledger import MemoryTicket
+
+ flow = self._flow()
+ no = "WO202610040002"
+ flow.ledger._tickets[no] = MemoryTicket(
+ work_order_no=no,
+ sales_wecom_id="s1",
+ business_line="AIR",
+ facts={"运输方式": "空运", "目的港": "CRK"},
+ fingerprint="air-deal",
+ status="已报价",
+ quote={"total": "CNY 800", "quoteFileIssued": True},
+ quote_file_ready=True,
+ )
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="collab_group",
+ business_line="LAND",
+ work_order_no="WO202610040004",
+ collab_chat_id="wr_other",
+ status="询价中",
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
+ joined = "\n".join(replies)
+ self.assertEqual(phase, "handoff_offer")
+ self.assertIn("原有空运群", joined)
+ self.assertNotIn("是否拉产品人员进群协同", joined)
+ self.assertNotIn(copy.HANDOFF_ALREADY_COLLAB, joined)
+
+ def test_ledger_mode_wins_over_stale_bookmark(self) -> None:
+ """主账是空运时,进程里标成陆运的旧书签也不能改成陆运拉群卡。"""
+ from agent.ledger.memory_ledger import MemoryTicket
+
+ flow = self._flow()
+ no = "WO202610040002"
+ flow.ledger._tickets[no] = MemoryTicket(
+ work_order_no=no,
+ sales_wecom_id="s1",
+ business_line="AIR",
+ facts={},
+ fingerprint="air-wins",
+ status="已报价",
+ quote={"total": "CNY 800", "quoteFileIssued": True},
+ quote_file_ready=True,
+ )
+ flow._save(
+ FlowSession(
+ sender_id="s1",
+ thread_id="s1",
+ phase="wait_deal",
+ business_line="LAND",
+ work_order_no=no,
+ status="已报价",
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
+ joined = "\n".join(replies)
+ self.assertEqual(phase, "handoff_offer")
+ self.assertIn("原有空运群", joined)
+ self.assertNotIn("运输方式:陆运", joined)
+
+ def test_sea_deal_ticket_uses_sea_card(self) -> None:
+ from agent.ledger.memory_ledger import MemoryTicket
+
+ flow = self._flow()
+ no = "WO202610040013"
+ flow.ledger._tickets[no] = MemoryTicket(
+ work_order_no=no,
+ sales_wecom_id="s1",
+ business_line="SEA",
+ facts={"运输方式": "海运"},
+ fingerprint="sea-deal",
+ status="已报价",
+ quote={"total": "USD 100", "quoteFileIssued": True},
+ quote_file_ready=True,
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.offer_handoff(sender_id="s1", text=f"{no}转人工", reply=reply)
+ joined = "\n".join(replies)
+ self.assertEqual(phase, "handoff_offer")
+ self.assertIn("运输方式:海运", joined)
+ self.assertIn("是否拉产品人员进群协同", joined)
+ self.assertNotIn("原有空运群", joined)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/inquiry-agent/tests/test_land_text_inquiry.py b/inquiry-agent/tests/test_land_text_inquiry.py
index 8bed4a1..28ecc1d 100644
--- a/inquiry-agent/tests/test_land_text_inquiry.py
+++ b/inquiry-agent/tests/test_land_text_inquiry.py
@@ -711,5 +711,69 @@ class LandSkipAfterRestartTests(unittest.TestCase):
self.assertIn(f"{wo}报价方案", joined)
+class LandContinuePullCardTests(unittest.TestCase):
+ def test_tms_miss_resume_shows_pull_card(self) -> None:
+ """陆运没查到价时继续工单,要再出「是否拉产品进群」卡,不能出空运无价卡。"""
+ flow = LandTextInquiryFlow(MemoryLedger())
+ flow._save(
+ FlowSession(
+ sender_id="land-miss",
+ thread_id="land-miss",
+ phase="tms_miss",
+ business_line="LAND",
+ work_order_no="WO202610040004",
+ status="询价中",
+ allowed=(copy.BTN_PULL_COLLAB,),
+ )
+ )
+ replies: list[str] = []
+ extras: list[dict] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ extras.append(dict(extra) if extra else {})
+ return ""
+
+ phase = flow.resume_historical(
+ sender_id="land-miss", work_order_no="WO202610040004", reply=reply
+ )
+ self.assertEqual(phase, "tms_miss")
+ joined = "\n".join(replies)
+ self.assertIn("是否需要拉内部产品人员进群", joined)
+ self.assertIn(copy.BTN_PULL_COLLAB, joined)
+ buttons = (extras[-1].get("template_card") or {}).get("button_list") or []
+ self.assertTrue(any(copy.BTN_PULL_COLLAB == b.get("text") for b in buttons))
+
+ def test_priced_resume_shows_pull_card(self) -> None:
+ """陆运已有价、还在问要不要拉群时,继续工单要出带拉群按钮的价格卡。"""
+ flow = LandTextInquiryFlow(MemoryLedger())
+ flow._save(
+ FlowSession(
+ sender_id="land-hit",
+ thread_id="land-hit",
+ phase="wait_collab",
+ business_line="LAND",
+ work_order_no="WO202610040004",
+ status="已报价",
+ quote={"total": "CNY 800", "source_label": "TMS 标准报价"},
+ allowed=(copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB),
+ )
+ )
+ replies: list[str] = []
+
+ def reply(text: str, extra=None) -> str:
+ replies.append(text)
+ _ = extra
+ return ""
+
+ phase = flow.resume_historical(
+ sender_id="land-hit", work_order_no="WO202610040004", reply=reply
+ )
+ self.assertEqual(phase, "wait_collab")
+ joined = "\n".join(replies)
+ self.assertIn("是否拉产品人员进群协同", joined)
+ self.assertIn(copy.BTN_PULL_COLLAB, joined)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/inquiry-agent/tests/test_quote_sheet_filename.py b/inquiry-agent/tests/test_quote_sheet_filename.py
new file mode 100644
index 0000000..10897b9
--- /dev/null
+++ b/inquiry-agent/tests/test_quote_sheet_filename.py
@@ -0,0 +1,50 @@
+"""系统报价单对外文件名:工单号接后台模板名,并带文件后缀。"""
+
+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)
+
+from agent.quote_templates.filename import filename_from_render, quote_sheet_filename
+
+
+class QuoteSheetFilenameTests(unittest.TestCase):
+ def test_joins_work_order_and_backend_template_name(self) -> None:
+ self.assertEqual(
+ quote_sheet_filename("WO202609100001", "陆运国内运输拼车报价单"),
+ "WO202609100001陆运国内运输拼车报价单.xlsx",
+ )
+
+ def test_drops_suffix_on_template_name(self) -> None:
+ self.assertEqual(
+ quote_sheet_filename("WO202609100001", "海运报价单模板.xlsx"),
+ "WO202609100001海运报价单模板.xlsx",
+ )
+
+ def test_pdf_uses_pdf_suffix(self) -> None:
+ self.assertEqual(
+ quote_sheet_filename("WO202609100001", "默认空运", suffix=".pdf"),
+ "WO202609100001默认空运.pdf",
+ )
+
+ def test_render_prefers_template_name_over_old_file_name(self) -> None:
+ shown = filename_from_render(
+ "WO202609100001",
+ {
+ "templateName": "国内运输拼车报价单",
+ "fileName": "WO202609100001_quote.xlsx",
+ },
+ )
+ self.assertEqual(shown, "WO202609100001国内运输拼车报价单.xlsx")
+
+ def test_blank_template_keeps_xlsx_suffix(self) -> None:
+ shown = filename_from_render(
+ "WO202609100001",
+ {"fileName": "WO202609100001_quote.xlsx"},
+ )
+ self.assertEqual(shown, "WO202609100001_quote.xlsx")
diff --git a/inquiry-agent/tests/test_sea_text_inquiry.py b/inquiry-agent/tests/test_sea_text_inquiry.py
index e56366a..b1a6a4f 100644
--- a/inquiry-agent/tests/test_sea_text_inquiry.py
+++ b/inquiry-agent/tests/test_sea_text_inquiry.py
@@ -2404,7 +2404,8 @@ class SeaGroupOpsTests(unittest.TestCase):
ticket = self.ledger.get_ticket(work_order_no=wo)
self.assertEqual(ticket.status, "已报价")
self.assertTrue(ticket.attachments)
- self.assertIn("quote", str(ticket.attachments[0].get("file_name") or "").lower())
+ saved_name = str(ticket.attachments[0].get("file_name") or "")
+ self.assertEqual(saved_name, f"{wo}海运默认.xlsx")
texts = [x.get("content") or "" for x in self.groups.sent if x.get("kind") == "text"]
self.assertIn(f"工单{wo}的1个附件已保存到后台工单", texts)
self.assertTrue(any("成交" in t for t in texts))
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java
new file mode 100644
index 0000000..79d7b63
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryPriceLine.java
@@ -0,0 +1,27 @@
+package org.jeecg.modules.inquiry.entity;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+
+/**
+ * 后台成本价 / 销售价 / 利润点的一行。不入库,列表和详情响应里临时带上。
+ *
+ * name 是费用名,最后一行固定为「费用合计」。text 是金额或百分比。
+ */
+@Data
+@Accessors(chain = true)
+@ApiModel("报价逐行展示")
+public class InquiryPriceLine implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("费用名")
+ private String name;
+
+ @ApiModelProperty("金额或百分比")
+ private String text;
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
index 2d10446..dbdef8f 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/entity/InquiryTicket.java
@@ -107,6 +107,28 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("毛利率展示")
private String profitRate;
+ /**
+ * 成本价逐行。不入库,列表/详情 enrich 时按报价记录重算。
+ * 空运整列为空。最后一行是「费用合计」。
+ */
+ @TableField(exist = false)
+ @ApiModelProperty("成本价逐行(展示)")
+ private List costLines;
+
+ /**
+ * 销售价逐行。不入库。还没调价的海运/陆运整列为空。
+ */
+ @TableField(exist = false)
+ @ApiModelProperty("销售价逐行(展示)")
+ private List saleLines;
+
+ /**
+ * 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
+ */
+ @TableField(exist = false)
+ @ApiModelProperty("利润点逐行(展示)")
+ private List profitLines;
+
@ApiModelProperty("是否命中 TMS")
private Integer tmsHit;
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
index bfff547..2041af1 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/service/impl/InquiryQuoteTemplateServiceImpl.java
@@ -18,6 +18,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
+import org.jeecg.modules.inquiry.template.QuoteSheetFileName;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
@@ -770,7 +771,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl
+ * 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
+ * 海运/陆运:成本是调价前生效价(手工同名盖 TMS,多出来的补上);销售是最新调价。
+ * 空运:成本整列空;销售是当前生效价;利润点只在调过价之后,对调价前销售价。
+ * 副作用:改 ticket 的三列逐行;手工盖价算出新的官方总价时,同时改成本价和利润点字符串。不写库。
+ * 调用:{@link InquiryTicketPriceSupport#apply} 末尾。列表 enrich 与保存报价后的 update 都走那里。
+ */
+public final class InquiryQuoteLineSupport {
+
+ static final String TOTAL_NAME = "费用合计";
+
+ private static final String[] BUCKETS = {"polCostItems", "oceanCostItems", "podCostItems"};
+
+ private InquiryQuoteLineSupport() {
+ }
+
+ /**
+ * 按已经算好的总价字符串,补上三列逐行。
+ *
+ * @param ticket 已写入 costPrice / salePrice / profitRate 的工单
+ * @param quotes 该单全部报价,顺序不限,内部按来源取最后一条
+ */
+ public static void fill(InquiryTicket ticket, List quotes) {
+ if (ticket == null) {
+ return;
+ }
+ boolean air = InquiryTicketPriceSupport.isAir(ticket.getBusinessLine());
+ InquiryQuote lastTms = null;
+ InquiryQuote lastManual = null;
+ InquiryQuote lastAdjust = null;
+ if (quotes != null) {
+ for (InquiryQuote quote : quotes) {
+ if (quote == null) {
+ continue;
+ }
+ if (InquiryQuoteDisplaySupport.isSalesAdjust(quote.getSource(), quote.getSourceLabel())) {
+ lastAdjust = quote;
+ } else if (InquiryQuoteDisplaySupport.isTms(quote.getSource(), quote.getSourceLabel())) {
+ lastTms = quote;
+ } else {
+ lastManual = quote;
+ }
+ }
+ }
+ List tmsFees = feesOf(lastTms);
+ List manualFees = feesOf(lastManual);
+ List adjustFees = feesOf(lastAdjust);
+
+ String costTotal = "";
+ List costFees = new ArrayList();
+ if (!air) {
+ costTotal = StringUtils.defaultString(ticket.getCostPrice());
+ if (!manualFees.isEmpty() && !tmsFees.isEmpty()) {
+ costFees = overlay(tmsFees, manualFees);
+ String recomputed = overlayTotal(priceText(lastTms), tmsFees, costFees);
+ if (StringUtils.isNotBlank(recomputed)) {
+ costTotal = recomputed;
+ ticket.setCostPrice(costTotal);
+ }
+ } else if (!manualFees.isEmpty()) {
+ costFees = manualFees;
+ } else {
+ costFees = tmsFees;
+ }
+ } else {
+ ticket.setCostPrice("");
+ }
+
+ boolean adjusted = lastAdjust != null;
+ String saleTotal = "";
+ List saleFees = new ArrayList();
+ if (air) {
+ saleTotal = StringUtils.defaultString(ticket.getSalePrice());
+ InquiryQuote saleQuote = lastAdjust != null ? lastAdjust : (lastManual != null ? lastManual : lastTms);
+ saleFees = feesOf(saleQuote);
+ } else if (adjusted) {
+ saleTotal = StringUtils.defaultString(ticket.getSalePrice());
+ saleFees = adjustFees;
+ } else {
+ ticket.setSalePrice("");
+ ticket.setProfitRate("");
+ }
+
+ List baselineFees;
+ if (air) {
+ if (!manualFees.isEmpty() && !tmsFees.isEmpty()) {
+ baselineFees = overlay(tmsFees, manualFees);
+ } else if (!manualFees.isEmpty()) {
+ baselineFees = manualFees;
+ } else {
+ baselineFees = tmsFees;
+ }
+ } else {
+ baselineFees = costFees;
+ }
+
+ String profitTotal = "";
+ if (adjusted && StringUtils.isNotBlank(saleTotal)) {
+ String locked = profitBaseline(lastAdjust);
+ String baseTotal;
+ if (StringUtils.isNotBlank(locked)) {
+ baseTotal = locked;
+ } else if (air) {
+ baseTotal = lastManual != null ? priceText(lastManual) : priceText(lastTms);
+ } else {
+ baseTotal = costTotal;
+ }
+ profitTotal = StringUtils.defaultString(InquiryTicketPriceSupport.profitText(saleTotal, baseTotal));
+ ticket.setProfitRate(profitTotal);
+ } else if (!adjusted) {
+ ticket.setProfitRate("");
+ }
+
+ if (air) {
+ ticket.setCostLines(Collections.emptyList());
+ ticket.setSaleLines(moneyLines(saleFees, saleTotal));
+ ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
+ ? profitAgainst(baselineFees, saleFees, profitTotal)
+ : Collections.emptyList());
+ return;
+ }
+ if (!adjusted) {
+ ticket.setCostLines(moneyLines(costFees, costTotal));
+ ticket.setSaleLines(Collections.emptyList());
+ ticket.setProfitLines(Collections.emptyList());
+ return;
+ }
+ List rows = align(costFees, saleFees);
+ ticket.setCostLines(project(rows, true, costTotal));
+ ticket.setSaleLines(project(rows, false, saleTotal));
+ ticket.setProfitLines(profitFromAligned(rows, profitTotal));
+ }
+
+ /**
+ * 手工盖 TMS:同名按出现顺序盖金额,没有的保留,手工多出来的补在后面。
+ * 同名两行不并成一行。
+ */
+ static List overlay(List base, List incoming) {
+ List rows = new ArrayList();
+ for (FeeRow row : base) {
+ rows.add(row.copy());
+ }
+ boolean[] used = new boolean[rows.size()];
+ if (incoming == null) {
+ return rows;
+ }
+ for (FeeRow item : incoming) {
+ int idx = firstUnused(rows, used, item.name);
+ if (idx < 0) {
+ rows.add(item.copy());
+ continue;
+ }
+ used[idx] = true;
+ FeeRow old = rows.get(idx).copy();
+ old.text = item.text;
+ old.amount = item.amount;
+ if (StringUtils.isNotBlank(item.currency)) {
+ old.currency = item.currency;
+ }
+ rows.set(idx, old);
+ }
+ return rows;
+ }
+
+ /**
+ * 费用合计:先用 TMS 官方总价,再加同币种差额。币种不同的行不进差额,也不做汇率。
+ */
+ static String overlayTotal(String tmsPriceText, List oldRows, List newRows) {
+ BigDecimal base = InquiryTicketPriceSupport.parseAmount(tmsPriceText);
+ if (base == null) {
+ return StringUtils.trimToEmpty(tmsPriceText);
+ }
+ String totalCcy = currencyOf(tmsPriceText);
+ boolean[] used = new boolean[oldRows == null ? 0 : oldRows.size()];
+ BigDecimal delta = BigDecimal.ZERO;
+ if (newRows != null) {
+ for (FeeRow neu : newRows) {
+ if (neu.amount == null) {
+ continue;
+ }
+ String lineCcy = StringUtils.isNotBlank(neu.currency) ? neu.currency : totalCcy;
+ if (StringUtils.isNotBlank(lineCcy) && StringUtils.isNotBlank(totalCcy) && !lineCcy.equals(totalCcy)) {
+ continue;
+ }
+ int idx = firstUnused(oldRows, used, neu.name);
+ if (idx < 0) {
+ delta = delta.add(neu.amount);
+ continue;
+ }
+ used[idx] = true;
+ FeeRow old = oldRows.get(idx);
+ if (old.amount == null) {
+ continue;
+ }
+ delta = delta.add(neu.amount.subtract(old.amount));
+ }
+ }
+ return formatMoney(base.add(delta), totalCcy);
+ }
+
+ private static List moneyLines(List fees, String totalText) {
+ List lines = new ArrayList();
+ if (fees != null) {
+ for (FeeRow fee : fees) {
+ lines.add(line(fee.name, fee.text));
+ }
+ }
+ if (StringUtils.isNotBlank(totalText)) {
+ lines.add(line(TOTAL_NAME, totalText));
+ }
+ return lines;
+ }
+
+ private static List project(List rows, boolean costSide, String totalText) {
+ List lines = new ArrayList();
+ for (Aligned row : rows) {
+ FeeRow side = costSide ? row.left : row.right;
+ lines.add(line(row.name, side == null ? "" : side.text));
+ }
+ if (StringUtils.isNotBlank(totalText)) {
+ lines.add(line(TOTAL_NAME, totalText));
+ }
+ return lines;
+ }
+
+ /**
+ * 利润点按销售价的费用顺序,对调价前同名行(按出现顺序,不并行)。
+ * 对不上或调价前金额为 0:这一行留空。费用合计用整单利润点。
+ */
+ private static List profitAgainst(List baseline, List sale, String totalProfit) {
+ boolean[] used = new boolean[baseline == null ? 0 : baseline.size()];
+ List lines = new ArrayList();
+ if (sale != null) {
+ for (FeeRow row : sale) {
+ int idx = firstUnused(baseline, used, row.name);
+ FeeRow base = idx < 0 ? null : baseline.get(idx);
+ if (idx >= 0) {
+ used[idx] = true;
+ }
+ lines.add(line(row.name, percentOf(base, row)));
+ }
+ }
+ if (StringUtils.isNotBlank(totalProfit)) {
+ lines.add(line(TOTAL_NAME, totalProfit));
+ }
+ return lines;
+ }
+
+ private static List profitFromAligned(List rows, String totalProfit) {
+ List lines = new ArrayList();
+ for (Aligned row : rows) {
+ lines.add(line(row.name, percentOf(row.left, row.right)));
+ }
+ if (StringUtils.isNotBlank(totalProfit)) {
+ lines.add(line(TOTAL_NAME, totalProfit));
+ }
+ return lines;
+ }
+
+ private static String percentOf(FeeRow before, FeeRow after) {
+ if (before == null || after == null || before.amount == null || after.amount == null) {
+ return "";
+ }
+ if (before.amount.compareTo(BigDecimal.ZERO) == 0) {
+ return "";
+ }
+ return InquiryTicketPriceSupport.profitText(after.text, before.text);
+ }
+
+ private static List align(List left, List right) {
+ List safeRight = right == null ? Collections.emptyList() : right;
+ boolean[] used = new boolean[safeRight.size()];
+ List out = new ArrayList();
+ if (left != null) {
+ for (FeeRow row : left) {
+ int idx = firstUnused(safeRight, used, row.name);
+ FeeRow match = null;
+ if (idx >= 0) {
+ used[idx] = true;
+ match = safeRight.get(idx);
+ }
+ out.add(new Aligned(row.name, row, match));
+ }
+ }
+ for (int i = 0; i < safeRight.size(); i++) {
+ if (!used[i]) {
+ FeeRow row = safeRight.get(i);
+ out.add(new Aligned(row.name, null, row));
+ }
+ }
+ return out;
+ }
+
+ private static int firstUnused(List rows, boolean[] used, String name) {
+ if (rows == null) {
+ return -1;
+ }
+ for (int i = 0; i < rows.size(); i++) {
+ if (used != null && i < used.length && used[i]) {
+ continue;
+ }
+ if (sameFee(name, rows.get(i).name)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * 费用名对齐:去空格后全等,或只差「费 / 费用 / 用」。操作费不等于码头操作费。
+ */
+ static boolean sameFee(String left, String right) {
+ String a = feeKey(left);
+ String b = feeKey(right);
+ if (a.isEmpty() || b.isEmpty()) {
+ return false;
+ }
+ if (a.equals(b)) {
+ return true;
+ }
+ if (a.equals(b + "用") || a.equals(b + "费用") || b.equals(a + "用") || b.equals(a + "费用")) {
+ return true;
+ }
+ return (a + "费").equals(b) || (b + "费").equals(a);
+ }
+
+ private static String feeKey(String name) {
+ String raw = StringUtils.trimToEmpty(name).replace(" ", "").replace("(", "(").replace(")", ")");
+ int slash = raw.indexOf('/');
+ if (slash < 0) {
+ slash = raw.indexOf('/');
+ }
+ if (slash > 0) {
+ raw = raw.substring(0, slash);
+ }
+ return raw.trim();
+ }
+
+ private static List feesOf(InquiryQuote quote) {
+ JSONObject payload = payloadOf(quote);
+ if (payload == null) {
+ return new ArrayList();
+ }
+ List rows = readFees(payload.get("fee_lines"));
+ if (rows.isEmpty()) {
+ rows = readFees(payload.get("fee_rows"));
+ }
+ if (rows.isEmpty()) {
+ JSONObject selected = asObject(payload.get("selected_air_option"));
+ if (selected == null) {
+ selected = asObject(payload.get("selectedAirOption"));
+ }
+ if (selected != null) {
+ rows = readFees(firstPresent(selected, "departureCharges", "departure_charges", "feeItems"));
+ }
+ }
+ if (rows.isEmpty()) {
+ JSONArray options = asArray(payload.get("airOptions"));
+ if (options == null) {
+ options = asArray(payload.get("air_options"));
+ }
+ if (options != null) {
+ for (int i = 0; i < options.size(); i++) {
+ JSONObject option = asObject(options.get(i));
+ if (option == null) {
+ continue;
+ }
+ rows = readFees(firstPresent(option, "departureCharges", "departure_charges", "feeItems"));
+ if (!rows.isEmpty()) {
+ break;
+ }
+ }
+ }
+ }
+ // 扁平明细为空才读海运三段。某一段有 fee_rows_ 就用它,没有才用原桶,避免同一段加两遍。
+ if (rows.isEmpty()) {
+ for (String bucket : BUCKETS) {
+ List keyed = readFees(payload.get("fee_rows_" + bucket));
+ if (!keyed.isEmpty()) {
+ rows.addAll(keyed);
+ } else {
+ rows.addAll(readFees(payload.get(bucket)));
+ }
+ }
+ }
+ return rows;
+ }
+
+ private static Object firstPresent(JSONObject obj, String... keys) {
+ for (String key : keys) {
+ if (obj.get(key) != null) {
+ return obj.get(key);
+ }
+ }
+ return null;
+ }
+
+ private static List readFees(Object raw) {
+ JSONArray array = asArray(raw);
+ List rows = new ArrayList();
+ if (array == null) {
+ return rows;
+ }
+ for (int i = 0; i < array.size(); i++) {
+ FeeRow row = toFee(asObject(array.get(i)));
+ if (row != null) {
+ rows.add(row);
+ }
+ }
+ return rows;
+ }
+
+ private static FeeRow toFee(JSONObject item) {
+ if (item == null) {
+ return null;
+ }
+ String name = firstText(item, "name", "chargeName", "feeName", "label");
+ if (StringUtils.isBlank(name) || isSubtotal(name) || isTotalAlias(name)) {
+ return null;
+ }
+ String amount = firstText(item, "amount", "totalPrice", "value");
+ if (StringUtils.isBlank(amount) || "-".equals(amount)) {
+ amount = firstText(item, "unitPrice", "unit_price");
+ }
+ if (StringUtils.isBlank(amount) || "-".equals(amount)) {
+ return null;
+ }
+ String currency = firstText(item, "currency", "currencyCode").toUpperCase();
+ if ("RMB".equals(currency)) {
+ currency = "CNY";
+ }
+ String text = amount;
+ String upper = amount.toUpperCase();
+ if (StringUtils.isNotBlank(currency) && !upper.startsWith(currency) && !upper.startsWith("RMB")) {
+ text = currency + " " + amount;
+ }
+ FeeRow row = new FeeRow();
+ row.name = name;
+ row.text = text;
+ row.currency = StringUtils.isNotBlank(currency) ? currency : currencyOf(text);
+ row.amount = InquiryTicketPriceSupport.parseAmount(text);
+ return row;
+ }
+
+ private static boolean isSubtotal(String name) {
+ return feeKey(name).contains("小计");
+ }
+
+ private static boolean isTotalAlias(String name) {
+ String key = feeKey(name);
+ return "费用合计".equals(key) || "费用总计".equals(key) || "总销售价".equals(key)
+ || "总成本价".equals(key) || "汇总价".equals(key) || "总价".equals(key)
+ || "报价合计".equals(key);
+ }
+
+ private static String profitBaseline(InquiryQuote quote) {
+ JSONObject payload = payloadOf(quote);
+ if (payload == null) {
+ return "";
+ }
+ String locked = firstText(payload, "profit_baseline", "profitBaseline");
+ return StringUtils.trimToEmpty(locked);
+ }
+
+ private static JSONObject payloadOf(InquiryQuote quote) {
+ if (quote == null || StringUtils.isBlank(quote.getPayloadJson())) {
+ return null;
+ }
+ try {
+ return JSON.parseObject(quote.getPayloadJson());
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+
+ private static JSONObject asObject(Object raw) {
+ if (raw instanceof JSONObject) {
+ return (JSONObject) raw;
+ }
+ if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
+ try {
+ Object parsed = JSON.parse((String) raw);
+ if (parsed instanceof String) {
+ parsed = JSON.parse((String) parsed);
+ }
+ if (parsed instanceof JSONObject) {
+ return (JSONObject) parsed;
+ }
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private static JSONArray asArray(Object raw) {
+ if (raw instanceof JSONArray) {
+ return (JSONArray) raw;
+ }
+ if (raw instanceof List) {
+ JSONArray array = new JSONArray();
+ for (Object item : (List>) raw) {
+ array.add(item);
+ }
+ return array;
+ }
+ if (raw instanceof String && StringUtils.isNotBlank((String) raw)) {
+ try {
+ Object parsed = JSON.parse((String) raw);
+ if (parsed instanceof String) {
+ parsed = JSON.parse((String) parsed);
+ }
+ if (parsed instanceof JSONArray) {
+ return (JSONArray) parsed;
+ }
+ } catch (Exception ignored) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private static String firstText(JSONObject obj, String... keys) {
+ for (String key : keys) {
+ String text = StringUtils.trimToEmpty(obj.getString(key));
+ if (StringUtils.isNotBlank(text)) {
+ return text;
+ }
+ }
+ return "";
+ }
+
+ private static String priceText(InquiryQuote quote) {
+ return quote == null ? "" : StringUtils.trimToEmpty(quote.getPriceText());
+ }
+
+ private static String currencyOf(String priceText) {
+ String text = StringUtils.trimToEmpty(priceText).toUpperCase();
+ if (text.startsWith("RMB")) {
+ return "CNY";
+ }
+ if (text.length() >= 3 && Character.isLetter(text.charAt(0))
+ && Character.isLetter(text.charAt(1)) && Character.isLetter(text.charAt(2))) {
+ return text.substring(0, 3);
+ }
+ return "";
+ }
+
+ private static String formatMoney(BigDecimal amount, String currency) {
+ String body = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
+ if (StringUtils.isBlank(currency)) {
+ return body;
+ }
+ return currency + " " + body;
+ }
+
+ private static InquiryPriceLine line(String name, String text) {
+ InquiryPriceLine row = new InquiryPriceLine();
+ row.setName(name);
+ row.setText(StringUtils.defaultString(text));
+ return row;
+ }
+
+ /** 一条费用的展示金额。amount 是比利润用的数字,text 是原样带币种的写法。 */
+ static final class FeeRow {
+ private String name;
+ private String text;
+ private String currency;
+ private BigDecimal amount;
+
+ private FeeRow copy() {
+ FeeRow row = new FeeRow();
+ row.name = name;
+ row.text = text;
+ row.currency = currency;
+ row.amount = amount;
+ return row;
+ }
+ }
+
+ /** 成本侧与销售侧对上的一行。某一侧没有时为 null。 */
+ private static final class Aligned {
+ private final String name;
+ private final FeeRow left;
+ private final FeeRow right;
+
+ private Aligned(String name, FeeRow left, FeeRow right) {
+ this.name = name;
+ this.left = left;
+ this.right = right;
+ }
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java
index 0434f6c..2cfb41e 100644
--- a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/support/InquiryTicketPriceSupport.java
@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
*
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
- * 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。
+ * 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
*/
public final class InquiryTicketPriceSupport {
@@ -65,17 +65,19 @@ public final class InquiryTicketPriceSupport {
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
ticket.setProfitRate("");
}
- return;
- }
- String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
- ticket.setCostPrice(StringUtils.defaultString(cost));
- if (StringUtils.isNotBlank(adjustPrice)) {
- ticket.setSalePrice(adjustPrice);
- ticket.setProfitRate(profitText(adjustPrice, cost));
} else {
- ticket.setSalePrice("");
- ticket.setProfitRate("");
+ String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
+ ticket.setCostPrice(StringUtils.defaultString(cost));
+ if (StringUtils.isNotBlank(adjustPrice)) {
+ ticket.setSalePrice(adjustPrice);
+ ticket.setProfitRate(profitText(adjustPrice, cost));
+ } else {
+ ticket.setSalePrice("");
+ ticket.setProfitRate("");
+ }
}
+ // 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
+ InquiryQuoteLineSupport.fill(ticket, quotes);
}
static boolean isAir(String businessLine) {
diff --git a/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java
new file mode 100644
index 0000000..1650913
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/main/java/org/jeecg/modules/inquiry/template/QuoteSheetFileName.java
@@ -0,0 +1,57 @@
+package org.jeecg.modules.inquiry.template;
+
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * 系统生成报价单的对外文件名:工单号接后台模板名,再加 .xlsx。
+ *
+ *
主账填的是 Excel。PDF 由智能体转完后再改成 .pdf。
+ * 航线、产品上传的附件不走本类。
+ */
+public final class QuoteSheetFileName {
+
+ private static final String[] SUFFIXES = {".xlsx", ".xls", ".xlsm", ".pdf"};
+
+ private QuoteSheetFileName() {
+ }
+
+ /**
+ * 工单号原样接上后台模板名,并带 .xlsx。模板名为空时退回「工单号_quote.xlsx」。
+ *
+ * @param workOrderNo 工单号
+ * @param templateName 后台模板名称
+ * @return 对外文件名
+ */
+ public static String of(String workOrderNo, String templateName) {
+ String no = StringUtils.defaultString(workOrderNo).trim();
+ String name = plain(templateName);
+ if (StringUtils.isBlank(no)) {
+ return StringUtils.defaultIfBlank(name, "quote") + ".xlsx";
+ }
+ if (StringUtils.isBlank(name)) {
+ return no + "_quote.xlsx";
+ }
+ return no + name + ".xlsx";
+ }
+
+ /** 去掉已知后缀和路径非法字符,中文保留。 */
+ static String plain(String name) {
+ String text = StringUtils.defaultString(name).trim();
+ String lower = text.toLowerCase();
+ for (String ext : SUFFIXES) {
+ if (lower.endsWith(ext)) {
+ text = text.substring(0, text.length() - ext.length()).trim();
+ break;
+ }
+ }
+ StringBuilder out = new StringBuilder();
+ for (int i = 0; i < text.length(); i++) {
+ char ch = text.charAt(i);
+ if (ch < 32 || "\\/:*?\"<>|".indexOf(ch) >= 0) {
+ continue;
+ }
+ out.append(ch);
+ }
+ return out.toString().trim();
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java
new file mode 100644
index 0000000..88bbbc1
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/support/InquiryQuoteLineSupportTest.java
@@ -0,0 +1,216 @@
+package org.jeecg.modules.inquiry.support;
+
+import org.jeecg.modules.inquiry.entity.InquiryPriceLine;
+import org.jeecg.modules.inquiry.entity.InquiryQuote;
+import org.jeecg.modules.inquiry.entity.InquiryTicket;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 后台三列逐行:费用名 + 金额,最后一行费用合计用官方总价。
+ */
+public class InquiryQuoteLineSupportTest {
+
+ @Test
+ public void seaUnadjustedShowsCostLinesAndLeavesSaleEmpty() {
+ InquiryTicket ticket = ticket("SEA");
+ InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote(
+ "TMS", "TMS标准报价", "CNY 1200.00",
+ "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}"
+ + "]}")));
+ assertEquals("CNY 1200.00", ticket.getCostPrice());
+ assertEquals("", ticket.getSalePrice());
+ assertEquals("", 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());
+ }
+
+ @Test
+ public void seaAdjustsOneFeeAndKeepsOfficialTotal() {
+ InquiryTicket ticket = ticket("LAND");
+ InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
+ quote("TMS", "TMS标准报价", "CNY 1200.00",
+ "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"),
+ quote("SALES_ADJUST", "销售调价", "CNY 1220.00",
+ "{\"currency\":\"CNY\",\"total\":\"1220.00\",\"profit_baseline\":\"CNY 1200.00\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"卸货费\",\"amount\":\"220\",\"currency\":\"CNY\"}]}")));
+ assertLines(ticket.getCostLines(),
+ "运输费", "CNY 1000",
+ "卸货费", "CNY 200",
+ "费用合计", "CNY 1200.00");
+ assertLines(ticket.getSaleLines(),
+ "运输费", "CNY 1000",
+ "卸货费", "CNY 220",
+ "费用合计", "CNY 1220.00");
+ assertLines(ticket.getProfitLines(),
+ "运输费", "0%",
+ "卸货费", "10%",
+ "费用合计", "1.67%");
+ assertEquals("1.67%", ticket.getProfitRate());
+ }
+
+ @Test
+ public void totalOnlyAdjustLeavesFeePercentsAtZero() {
+ InquiryTicket ticket = ticket("SEA");
+ InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
+ quote("TMS", "TMS标准报价", "CNY 2000.00",
+ "{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":["
+ + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}"),
+ quote("SALES_ADJUST", "销售调价", "CNY 2100.00",
+ "{\"currency\":\"CNY\",\"total\":\"2100.00\",\"fee_lines\":["
+ + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}")));
+ assertLines(ticket.getProfitLines(),
+ "增点费", "0%",
+ "拆板费", "0%",
+ "费用合计", "5%");
+ assertEquals("费用合计", ticket.getSaleLines().get(2).getName());
+ assertEquals("CNY 2100.00", ticket.getSaleLines().get(2).getText());
+ }
+
+ @Test
+ public void secondAdjustComparesToFirstBaseline() {
+ InquiryTicket ticket = ticket("LAND");
+ InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
+ quote("TMS", "TMS标准报价", "CNY 1000.00",
+ "{\"currency\":\"CNY\",\"total\":\"1000\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"}]}"),
+ quote("SALES_ADJUST", "销售调价", "CNY 1100.00",
+ "{\"currency\":\"CNY\",\"total\":\"1100\",\"profit_baseline\":\"1000\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1100\",\"currency\":\"CNY\"}]}"),
+ quote("SALES_ADJUST", "销售调价", "CNY 1300.00",
+ "{\"currency\":\"CNY\",\"total\":\"1300\",\"profit_baseline\":\"1000\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1300\",\"currency\":\"CNY\"}]}")));
+ assertLines(ticket.getSaleLines(), "运输费", "CNY 1300", "费用合计", "CNY 1300.00");
+ assertLines(ticket.getProfitLines(), "运输费", "30%", "费用合计", "30%");
+ }
+
+ @Test
+ public void airCostStaysEmptyUntilAdjustThenProfitUsesSaleBaseline() {
+ InquiryTicket before = ticket("AIR");
+ InquiryQuote tms = quote("TMS", "TMS标准报价", "CNY 2000.00",
+ "{\"currency\":\"CNY\",\"total\":\"2000.00\",\"fee_lines\":["
+ + "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"拆板费\",\"amount\":\"100\",\"currency\":\"CNY\"}]}");
+ InquiryTicketPriceSupport.apply(before, Collections.singletonList(tms));
+ assertTrue(before.getCostLines() == null || before.getCostLines().isEmpty());
+ assertEquals("", before.getCostPrice());
+ assertEquals("CNY 2000.00", before.getSalePrice());
+ assertTrue(before.getProfitLines() == null || before.getProfitLines().isEmpty());
+ assertLines(before.getSaleLines(),
+ "空运费", "CNY 1800",
+ "增点费", "CNY 100",
+ "拆板费", "CNY 100",
+ "费用合计", "CNY 2000.00");
+
+ InquiryTicket after = ticket("空运");
+ InquiryTicketPriceSupport.apply(after, Arrays.asList(
+ tms,
+ quote("SALES_ADJUST", "销售调价", "CNY 2100.00",
+ "{\"currency\":\"CNY\",\"total\":\"2100.00\",\"profit_baseline\":\"CNY 2000.00\",\"fee_lines\":["
+ + "{\"name\":\"空运费\",\"amount\":\"1800\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"增点费\",\"amount\":\"100\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"拆板费\",\"amount\":\"110\",\"currency\":\"CNY\"}]}")));
+ assertTrue(after.getCostLines() == null || after.getCostLines().isEmpty());
+ assertLines(after.getProfitLines(),
+ "空运费", "0%",
+ "增点费", "0%",
+ "拆板费", "10%",
+ "费用合计", "5%");
+ }
+
+ @Test
+ public void airOfficialTotalIsNotTheSumOfDepartureCharges() {
+ InquiryTicket ticket = ticket("AIR");
+ InquiryTicketPriceSupport.apply(ticket, Collections.singletonList(quote(
+ "TMS", "TMS标准报价", "CNY 7918.25",
+ "{\"currency\":\"CNY\",\"total\":\"7918.25\",\"selected_air_option\":{"
+ + "\"departureCharges\":["
+ + "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\"}"
+ + "]}}")));
+ assertLines(ticket.getSaleLines(),
+ "增点费", "CNY 100.0",
+ "拆板费", "USD 100.0",
+ "费用合计", "CNY 7918.25");
+ }
+
+ @Test
+ public void duplicateFeeNamesStaySeparate() {
+ InquiryTicket ticket = ticket("LAND");
+ InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
+ quote("TMS", "TMS标准报价", "CNY 10000.00",
+ "{\"currency\":\"CNY\",\"total\":\"10000\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"7000\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"运输费\",\"amount\":\"3000\",\"currency\":\"CNY\"}]}"),
+ quote("SALES_ADJUST", "销售调价", "CNY 11000.00",
+ "{\"currency\":\"CNY\",\"total\":\"11000\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"7700\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"运输费\",\"amount\":\"3300\",\"currency\":\"CNY\"}]}")));
+ assertEquals(3, ticket.getCostLines().size());
+ assertEquals("CNY 7000", ticket.getCostLines().get(0).getText());
+ assertEquals("CNY 3000", ticket.getCostLines().get(1).getText());
+ assertLines(ticket.getProfitLines(), "运输费", "10%", "运输费", "10%", "费用合计", "10%");
+ }
+
+ @Test
+ public void manualOverlayKeepsUntouchedTmsFee() {
+ InquiryTicket ticket = ticket("SEA");
+ InquiryTicketPriceSupport.apply(ticket, Arrays.asList(
+ quote("TMS", "TMS标准报价", "CNY 1200.00",
+ "{\"currency\":\"CNY\",\"total\":\"1200.00\",\"fee_lines\":["
+ + "{\"name\":\"运输费\",\"amount\":\"1000\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"卸货费\",\"amount\":\"200\",\"currency\":\"CNY\"}]}"),
+ quote("PRODUCT", "手工报价", "CNY 320.00",
+ "{\"currency\":\"CNY\",\"total\":\"320\",\"fee_lines\":["
+ + "{\"name\":\"卸货费\",\"amount\":\"300\",\"currency\":\"CNY\"},"
+ + "{\"name\":\"配送费\",\"amount\":\"20\",\"currency\":\"CNY\"}]}")));
+ assertLines(ticket.getCostLines(),
+ "运输费", "CNY 1000",
+ "卸货费", "CNY 300",
+ "配送费", "CNY 20",
+ "费用合计", "CNY 1320.00");
+ assertEquals("CNY 1320.00", ticket.getCostPrice());
+ assertTrue(ticket.getSaleLines() == null || ticket.getSaleLines().isEmpty());
+ }
+
+ private static void assertLines(List lines, String... nameText) {
+ assertEquals(nameText.length / 2, lines.size());
+ for (int i = 0; i < nameText.length; i += 2) {
+ assertEquals(nameText[i], lines.get(i / 2).getName());
+ assertEquals(nameText[i + 1], lines.get(i / 2).getText());
+ }
+ }
+
+ private static InquiryTicket ticket(String line) {
+ InquiryTicket t = new InquiryTicket();
+ t.setBusinessLine(line);
+ return t;
+ }
+
+ private static InquiryQuote quote(String source, String label, String price, String payload) {
+ InquiryQuote q = new InquiryQuote();
+ q.setSource(source);
+ q.setSourceLabel(label);
+ q.setPriceText(price);
+ q.setPayloadJson(payload);
+ return q;
+ }
+}
diff --git a/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java
new file mode 100644
index 0000000..cd142f3
--- /dev/null
+++ b/inquiry-api/jeecg-module-inquiry/src/test/java/org/jeecg/modules/inquiry/template/QuoteSheetFileNameTest.java
@@ -0,0 +1,30 @@
+package org.jeecg.modules.inquiry.template;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 系统报价单对外文件名:工单号接模板名,并带 .xlsx。
+ */
+public class QuoteSheetFileNameTest {
+
+ @Test
+ public void joinsWorkOrderAndTemplateNameWithoutSuffix() {
+ assertEquals(
+ "WO202609100001陆运国内运输拼车报价单.xlsx",
+ QuoteSheetFileName.of("WO202609100001", "陆运国内运输拼车报价单"));
+ }
+
+ @Test
+ public void dropsExtensionOnTemplateName() {
+ assertEquals(
+ "WO202609100001海运报价单模板.xlsx",
+ QuoteSheetFileName.of("WO202609100001", "海运报价单模板.xlsx"));
+ }
+
+ @Test
+ public void blankTemplateFallsBackWithoutSuffix() {
+ assertEquals("WO202609100001_quote.xlsx", QuoteSheetFileName.of("WO202609100001", ""));
+ }
+}
diff --git a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
index a1fd4e2..699d581 100644
--- a/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
+++ b/inquiry-backend/src/views/inquiry/styles/admin-fidelity.css
@@ -570,8 +570,8 @@ body {
.ticket-list-table .col-w-quote { width: 140px; }
.ticket-list-table .col-w-attach { width: 160px; }
.ticket-list-table .col-w-status { width: 84px; }
-.ticket-list-table .col-w-price { width: 100px; }
-.ticket-list-table .col-w-margin { width: 72px; }
+.ticket-list-table .col-w-price { width: 168px; }
+.ticket-list-table .col-w-margin { width: 132px; }
.ticket-list-table .col-w-ex { width: 110px; }
.ticket-list-table .col-w-reason { width: 140px; }
.ticket-list-table .col-w-time { width: 132px; }
@@ -627,6 +627,20 @@ body {
white-space: nowrap;
}
+.ticket-price-lines {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ white-space: normal;
+ font-size: 11px;
+ line-height: 1.45;
+ color: #0f172a;
+}
+
+.ticket-price-line.is-total {
+ font-weight: 800;
+}
+
.ticket-status {
display: inline-flex !important;
align-items: center !important;
diff --git a/inquiry-backend/src/views/inquiry/workOrder/index.vue b/inquiry-backend/src/views/inquiry/workOrder/index.vue
index 9a92289..b6d9535 100644
--- a/inquiry-backend/src/views/inquiry/workOrder/index.vue
+++ b/inquiry-backend/src/views/inquiry/workOrder/index.vue
@@ -19,6 +19,8 @@ import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
type TicketQuoteFee = { name: string; amount: string };
+/** 成本价 / 销售价 / 利润点的一行。最后一行名称是「费用合计」。 */
+type PriceLine = { name: string; text: string };
type TicketQuote = {
source: string;
price: string;
@@ -43,6 +45,9 @@ type TicketRow = {
costPrice: string;
salePrice: string;
profit: string;
+ costLines: PriceLine[];
+ saleLines: PriceLine[];
+ profitLines: PriceLine[];
eventException: string;
systemException: string;
systemExceptionReason: string;
@@ -102,6 +107,17 @@ function dash(v: any) {
return s ? s : "-";
}
+/** 主账逐行。没有行时页面退回原来的总价字符串。 */
+function asPriceLines(raw: any): PriceLine[] {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((row) => ({
+ name: String(row?.name ?? "").trim(),
+ text: String(row?.text ?? "").trim(),
+ }))
+ .filter((row) => row.name);
+}
+
/**
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
* 禁止把产品报价或销售调价再显示成 TMS。
@@ -386,6 +402,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
costPrice: dash(t.costPrice),
salePrice: dash(t.salePrice),
profit: dash(t.profitRate),
+ costLines: asPriceLines(t.costLines),
+ saleLines: asPriceLines(t.saleLines),
+ profitLines: asPriceLines(t.profitLines),
eventException: dash(t.eventException),
systemException: dash(t.systemException),
systemExceptionReason: dash(t.systemExceptionReason),
@@ -692,13 +711,43 @@ function refresh() {
| {{ row.status }} |
- {{ row.costPrice }}
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ row.costPrice }}
|
- {{ row.salePrice }}
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ row.salePrice }}
|
- {{ row.profit }}
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ row.profit }}
|
{{ row.eventException }}
@@ -804,9 +853,48 @@ function refresh() {
价格与利润
- {{ current.costPrice }}
- {{ current.salePrice }}
- {{ current.profit }}
+
+
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ current.costPrice }}
+
+
+
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ current.salePrice }}
+
+
+
+
+
+ {{ line.name }}:{{ line.text || "-" }}
+
+
+ {{ current.profit }}
+
|