Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58df358577 | ||
|
|
e95e8869a6 |
@@ -230,7 +230,7 @@ def render_air_route_ok() -> str:
|
||||
"""选定成功:短暂展示后关企微 H5。"""
|
||||
return _wrap(
|
||||
"已选定",
|
||||
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
|
||||
"<div class='msg'><h1>已选定</h1><p>正在关闭页面,请在对话里查看价格明细。</p></div>",
|
||||
auto_close=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ def render_land_route_ok() -> str:
|
||||
"""选定成功:短暂展示后关企微 H5。"""
|
||||
return _wrap(
|
||||
"已选定",
|
||||
"<div class='msg'><h1>已选定</h1><p>正在返回企微,请查看价格明细。</p></div>",
|
||||
"<div class='msg'><h1>已选定</h1><p>正在关闭页面,请在对话里查看价格明细。</p></div>",
|
||||
auto_close=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -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");
|
||||
var closed = false;
|
||||
|
||||
function closeOnce(fn) {{
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {{ fn(); }} catch (e) {{}}
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") {{
|
||||
ww.closeWindow();
|
||||
|
||||
// 手机企微有 WeixinJSBridge:只关当前 H5。再调第二套关闭会把询价小助手整个退出,回到企业微信首页。
|
||||
function closeMobile() {{
|
||||
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{
|
||||
if (window.wx && typeof wx.closeWindow === "function") {{
|
||||
wx.closeWindow();
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{ window.close(); }} catch (e) {{}}
|
||||
|
||||
// 电脑企微没有 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});
|
||||
}});
|
||||
}}
|
||||
|
||||
|
||||
@@ -118,15 +118,28 @@ class HttpLedger:
|
||||
facts: dict[str, str],
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
打主账查 TMS。查价可能慢于默认 10s:单独放宽,仍禁止无限等。
|
||||
|
||||
若仍超时,上层 query_tms_resilient 会回读主账:已 persistHit 有价则按成功续办。
|
||||
"""
|
||||
body: dict[str, Any] = {"work_order_no": work_order_no, "facts": facts}
|
||||
line = (business_line or str((facts or {}).get("业务线") or "")).strip()
|
||||
if line:
|
||||
body["businessLine"] = line
|
||||
# TMS 出站 + persistHit 常超过默认 10s,超时会被误判成接口异常。
|
||||
prev_timeout = self._client.timeout_seconds
|
||||
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
|
||||
self._client.close()
|
||||
try:
|
||||
out = self._post(
|
||||
"/inquiry/agent/tms/query",
|
||||
body,
|
||||
idem=f"tms:{work_order_no}",
|
||||
)
|
||||
finally:
|
||||
self._client.timeout_seconds = prev_timeout
|
||||
self._client.close()
|
||||
if "classification" not in out and not out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -245,6 +258,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
|
||||
|
||||
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -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"(?<![\u4e00-\u9fffA-Za-z0-9])" + gap.join(re.escape(ch) for ch in label)
|
||||
)
|
||||
body = pat.sub(label, body)
|
||||
return body
|
||||
|
||||
|
||||
def _drop_fee_place_values(facts: dict[str, str]) -> 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()
|
||||
|
||||
|
||||
|
||||
@@ -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("该工单已结束,不能再转人工。")
|
||||
@@ -1091,6 +1212,7 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(self._ledger, work_order_no=no, facts=facts)
|
||||
@@ -1117,6 +1239,11 @@ class AirTextInquiryFlow:
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
# 主账已落价但回包超时:按成功续办,禁止再报接口异常
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
@@ -1572,7 +1699,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 +1717,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 出文件后再出成交卡,这里不提前发卡
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -390,7 +390,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
_ = retrying
|
||||
no = (sess.work_order_no or "").strip()
|
||||
if not tms.get("ok"):
|
||||
from agent.policy.system_exception import confirm_tms_quote_if_tech
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
if confirm_tms_quote_if_tech(
|
||||
ledger=self._ledger,
|
||||
@@ -402,6 +405,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
):
|
||||
self.drop_current_bookmark(sess.sender_id)
|
||||
return "system_exception"
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
sess.phase = "tms_tech"
|
||||
self._save(sess)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -416,6 +416,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
from agent.policy.system_exception import (
|
||||
confirm_tms_quote_if_tech,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
)
|
||||
|
||||
tms = query_tms_resilient(
|
||||
@@ -443,6 +444,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
):
|
||||
self.drop_current_bookmark(sender_id)
|
||||
return "system_exception"
|
||||
recovered = recover_tms_query_from_ledger(self._ledger, work_order_no=no)
|
||||
if recovered is not None:
|
||||
tms = recovered
|
||||
else:
|
||||
if tms.get("classification") == "TMS_QUERY_INCOMPLETE":
|
||||
logger.warning("tms.query 未出站 wo=%s class=%s", no, tms.get("classification"))
|
||||
reply("TMS 查询失败,请稍后重试。工单已创建:" + no)
|
||||
@@ -598,7 +603,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 +618,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)
|
||||
|
||||
@@ -310,8 +310,85 @@ def reply_paused_ticket(*, ticket: Any, reply: ReplyFn) -> None:
|
||||
reply(copy.system_exception_fallback(kind, names=names))
|
||||
|
||||
|
||||
def _quote_has_usable_total(quote: dict[str, Any] | None) -> bool:
|
||||
"""报价包是否已有可用于出卡的总价。"""
|
||||
src = dict(quote or {})
|
||||
total = str(src.get("total") or src.get("salePrice") or src.get("total_amount") or "").strip()
|
||||
return bool(total and total != "-")
|
||||
|
||||
|
||||
def recover_tms_query_from_ledger(ledger: Any, *, work_order_no: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
查价超时/失败后回读主账:若已 persistHit 落成已报价+销售价,按查价成功续办。
|
||||
|
||||
产品口径:主账已经查到价,只是智能体等回包超时,不能标 TMS异常、不能给销售发接口异常,
|
||||
应直接推当前工单报价。无价才走真正的系统异常。
|
||||
"""
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or ledger is None:
|
||||
return None
|
||||
getter = getattr(ledger, "get_for_agent", None)
|
||||
snap: Any = None
|
||||
if callable(getter):
|
||||
try:
|
||||
snap = getter(work_order_no=no, sender_id="")
|
||||
except TypeError:
|
||||
try:
|
||||
snap = getter(work_order_no=no)
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
if not isinstance(snap, dict) or not snap:
|
||||
getter2 = getattr(ledger, "get_ticket", None)
|
||||
if callable(getter2):
|
||||
try:
|
||||
snap = getter2(work_order_no=no)
|
||||
except Exception: # noqa: BLE001
|
||||
snap = None
|
||||
if not isinstance(snap, dict) or not snap:
|
||||
return None
|
||||
status = str(snap.get("status") or "").strip()
|
||||
tms_hit = snap.get("tms_hit") in (1, "1", True, "true")
|
||||
sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip()
|
||||
quote = dict(snap.get("quote") or {})
|
||||
tms_quote = dict(snap.get("tms_quote") or snap.get("tmsQuote") or {})
|
||||
if not _quote_has_usable_total(quote) and _quote_has_usable_total(tms_quote):
|
||||
quote = dict(tms_quote)
|
||||
if not _quote_has_usable_total(quote) and sale and sale != "-":
|
||||
quote = dict(quote)
|
||||
quote["total"] = sale
|
||||
quote.setdefault("source", "TMS")
|
||||
quote.setdefault("source_label", "TMS标准报价")
|
||||
priced = status == "已报价" and (
|
||||
tms_hit or (sale and sale != "-") or _quote_has_usable_total(quote)
|
||||
)
|
||||
if not priced:
|
||||
return None
|
||||
if not _quote_has_usable_total(quote):
|
||||
return None
|
||||
logger.warning(
|
||||
"tms.query 超时/失败但主账已有价,按成功续办 wo=%s total=%s",
|
||||
no,
|
||||
quote.get("total"),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"has_price": True,
|
||||
"classification": "HAS_PRICE",
|
||||
"quote": quote,
|
||||
"recovered": True,
|
||||
"source": "LEDGER_AFTER_TIMEOUT",
|
||||
}
|
||||
|
||||
|
||||
def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
"""TMS 查价:最多 3 次,ok=true(含无价)即成功。"""
|
||||
"""
|
||||
TMS 查价:最多 3 次,ok=true(含无价)即成功。
|
||||
|
||||
三次仍失败时,再读主账:若查价过程中已落库有价(常见于智能体超时、主账已 persistHit),
|
||||
按成功回包返回,避免误标 TMS异常。
|
||||
"""
|
||||
getter = getattr(ledger, "query_tms", None)
|
||||
if not callable(getter):
|
||||
return {"ok": False, "classification": "TMS_TECH_FAILURE", "has_price": False, "quote": {}}
|
||||
@@ -319,7 +396,14 @@ def query_tms_resilient(ledger: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
lambda: getter(**kwargs),
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
return out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
result = out if isinstance(out, dict) else {"ok": False, "classification": "TMS_TECH_FAILURE"}
|
||||
if result.get("ok"):
|
||||
return result
|
||||
no = str(kwargs.get("work_order_no") or "").strip()
|
||||
recovered = recover_tms_query_from_ledger(ledger, work_order_no=no) if no else None
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
return result
|
||||
|
||||
|
||||
def confirm_tms_quote_if_tech(
|
||||
@@ -332,9 +416,20 @@ def confirm_tms_quote_if_tech(
|
||||
ticket_status: str = "询价中",
|
||||
facts: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。"""
|
||||
"""
|
||||
技术失败则标 TMS异常并回话告警。返回是否已按系统异常处理。
|
||||
|
||||
主账其实已有价时不再标异常(兜底:调用方应优先走 query_tms_resilient 恢复)。
|
||||
"""
|
||||
if not is_tms_tech_failure(tms):
|
||||
return False
|
||||
# 最后一道闸:主账其实已有价时禁止再打 TMS异常(正常应已在 query_tms_resilient 恢复)。
|
||||
if recover_tms_query_from_ledger(ledger, work_order_no=work_order_no) is not None:
|
||||
logger.warning(
|
||||
"confirm_tms_quote_if_tech 跳过标异常:主账已有价 wo=%s",
|
||||
work_order_no,
|
||||
)
|
||||
return False
|
||||
err = str(tms.get("classification") or tms.get("error") or "TMS_TECH_FAILURE").strip()
|
||||
confirm_system_exception(
|
||||
event=SystemExceptionEvent(
|
||||
|
||||
@@ -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()
|
||||
@@ -178,7 +178,8 @@ _GENERIC_KEYWORDS = frozenset(
|
||||
_LAND_KEYWORD_ALIASES = {
|
||||
"国内拼车": ("国内运输拼车", "拼车"),
|
||||
"国内零担": ("国内运输拼车", "国内长途/零担", "零担"),
|
||||
"国内整车": ("国内运输整车", "整车"),
|
||||
# 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字。
|
||||
"国内整车": ("国内运输整车",),
|
||||
"跨境整车": ("跨境整车",),
|
||||
"跨境集拼": ("跨境集拼",),
|
||||
"跨境零担": ("跨境集拼",),
|
||||
@@ -198,6 +199,19 @@ def _keyword_needles(kw: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _needle_in(text: str, needle: str) -> bool:
|
||||
"""
|
||||
陆运针:普通包含。单独「整车」不命中跨境/中港,避免国内整车模板抢走跨境单。
|
||||
"""
|
||||
hay = (text or "").lower()
|
||||
n = (needle or "").strip().lower()
|
||||
if not hay or not n or n not in hay:
|
||||
return False
|
||||
if n == "整车" and ("跨境" in hay or "中港" in hay):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
|
||||
"""
|
||||
运输类型上的命中优先,其次全文词长。
|
||||
@@ -212,11 +226,11 @@ def _keyword_score(raw: Any, haystack: str, type_val: str) -> tuple[int, int]:
|
||||
if k in _GENERIC_KEYWORDS:
|
||||
continue
|
||||
needles = _keyword_needles(kw)
|
||||
if type_val and any(n in type_val for n in needles):
|
||||
if type_val and any(_needle_in(type_val, n) for n in needles):
|
||||
type_hits += 1
|
||||
length += len(k)
|
||||
any_hit = True
|
||||
elif any(n in haystack for n in needles):
|
||||
elif any(_needle_in(haystack, n) for n in needles):
|
||||
length += len(k)
|
||||
any_hit = True
|
||||
if not any_hit:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -143,6 +143,61 @@ class QuoteTemplateMatchTests(unittest.TestCase):
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "跨境整车")
|
||||
|
||||
def test_land_admin_domestic_ftl_keyword_does_not_steal_cross(self) -> None:
|
||||
"""后台「国内整车」不得靠「整车」二字抢走跨境整车(WO202610030032)。"""
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
{
|
||||
"templateId": "dom",
|
||||
"name": "国内陆运整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["国内整车"],
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"templateId": "xb",
|
||||
"name": "跨境整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["东南亚"],
|
||||
"isDefault": False,
|
||||
},
|
||||
],
|
||||
business_line="LAND",
|
||||
facts={
|
||||
"运输类型": "跨境整车",
|
||||
"线路类别": "东南亚",
|
||||
"运输分类": "普件(东南亚、中亚、中欧涉及国内+国外段)",
|
||||
"起运港": "上海",
|
||||
"目的港": "春疆货场",
|
||||
},
|
||||
)
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "跨境整车报价单")
|
||||
|
||||
def test_land_admin_domestic_ftl_still_hits_domestic_type(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
{
|
||||
"templateId": "dom",
|
||||
"name": "国内陆运整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["国内整车"],
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"templateId": "xb",
|
||||
"name": "跨境整车报价单",
|
||||
"bizType": "陆运",
|
||||
"keywords": ["东南亚"],
|
||||
"isDefault": False,
|
||||
},
|
||||
],
|
||||
business_line="LAND",
|
||||
facts={"运输类型": "国内运输整车", "线路类别": "国内长途/零担"},
|
||||
)
|
||||
self.assertIsNotNone(hit)
|
||||
self.assertEqual(hit.name, "国内陆运整车报价单")
|
||||
|
||||
def test_land_falls_back_to_default(self) -> None:
|
||||
hit = pick_quote_template(
|
||||
templates=[
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -22,8 +22,11 @@ from agent.policy.system_exception import (
|
||||
SystemExceptionEvent,
|
||||
call_with_retries,
|
||||
confirm_system_exception,
|
||||
confirm_tms_quote_if_tech,
|
||||
extract_work_order_nos,
|
||||
is_tms_tech_failure,
|
||||
query_tms_resilient,
|
||||
recover_tms_query_from_ledger,
|
||||
should_mark_ticket,
|
||||
ticket_is_paused,
|
||||
)
|
||||
@@ -196,6 +199,89 @@ class TestSystemExceptionRules(unittest.TestCase):
|
||||
self.assertEqual(out.get("classification"), "TMS_TECH_FAILURE")
|
||||
|
||||
|
||||
class TestTmsTimeoutRecoverPriced(unittest.TestCase):
|
||||
"""查到价但智能体超时:主账已有价则按成功续办,不标 TMS异常。"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales1",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
|
||||
)
|
||||
self.wo = str(created.get("work_order_no") or "")
|
||||
self.ledger.upsert_quote(
|
||||
work_order_no=self.wo,
|
||||
quote={
|
||||
"total": "CNY 5001.00",
|
||||
"currency": "CNY",
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
},
|
||||
to_status="已报价",
|
||||
)
|
||||
|
||||
def test_recover_from_ledger_when_already_quoted(self) -> None:
|
||||
got = recover_tms_query_from_ledger(self.ledger, work_order_no=self.wo)
|
||||
self.assertIsNotNone(got)
|
||||
assert got is not None
|
||||
self.assertTrue(got.get("ok"))
|
||||
self.assertTrue(got.get("has_price"))
|
||||
self.assertTrue(got.get("recovered"))
|
||||
self.assertIn("5001", str((got.get("quote") or {}).get("total") or ""))
|
||||
|
||||
def test_query_resilient_recovers_after_tech_fail(self) -> None:
|
||||
self.ledger.query_tms = ( # type: ignore[method-assign]
|
||||
lambda **_kwargs: {
|
||||
"ok": False,
|
||||
"classification": "TMS_TECH_FAILURE",
|
||||
"has_price": False,
|
||||
"quote": {},
|
||||
}
|
||||
)
|
||||
out = query_tms_resilient(
|
||||
self.ledger, work_order_no=self.wo, facts={"起运港": "南京", "目的港": "吉隆坡"}
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertTrue(out.get("has_price"))
|
||||
self.assertTrue(out.get("recovered"))
|
||||
self.assertEqual(out.get("classification"), "HAS_PRICE")
|
||||
|
||||
def test_confirm_skips_mark_when_ledger_priced(self) -> None:
|
||||
replies: list[str] = []
|
||||
marked = confirm_tms_quote_if_tech(
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
work_order_no=self.wo,
|
||||
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
|
||||
sender_id="sales1",
|
||||
ticket_status="询价中",
|
||||
)
|
||||
self.assertFalse(marked)
|
||||
self.assertEqual(replies, [])
|
||||
snap = self.ledger.get_for_agent(work_order_no=self.wo, sender_id="sales1")
|
||||
self.assertFalse(str(snap.get("system_exception") or "").strip())
|
||||
|
||||
def test_true_tech_fail_still_marks_when_no_price(self) -> None:
|
||||
created = self.ledger.create_ticket(
|
||||
sender_id="sales2",
|
||||
business_line="AIR",
|
||||
facts={"起运港": "南京", "目的港": "吉隆坡", "品名": "茶叶"},
|
||||
)
|
||||
wo2 = str(created.get("work_order_no") or "")
|
||||
replies: list[str] = []
|
||||
marked = confirm_tms_quote_if_tech(
|
||||
ledger=self.ledger,
|
||||
reply=replies.append,
|
||||
work_order_no=wo2,
|
||||
tms={"ok": False, "classification": "TMS_TECH_FAILURE"},
|
||||
sender_id="sales2",
|
||||
ticket_status="询价中",
|
||||
)
|
||||
self.assertTrue(marked)
|
||||
self.assertTrue(any("TMS接口异常" in x for x in replies))
|
||||
|
||||
|
||||
class TestConfirmSystemException(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ledger = MemoryLedger()
|
||||
|
||||
+27
@@ -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;
|
||||
|
||||
/**
|
||||
* 后台成本价 / 销售价 / 利润点的一行。不入库,列表和详情响应里临时带上。
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
+22
@@ -107,6 +107,28 @@ public class InquiryTicket implements Serializable {
|
||||
@ApiModelProperty("毛利率展示")
|
||||
private String profitRate;
|
||||
|
||||
/**
|
||||
* 成本价逐行。不入库,列表/详情 enrich 时按报价记录重算。
|
||||
* 空运整列为空。最后一行是「费用合计」。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@ApiModelProperty("成本价逐行(展示)")
|
||||
private List<InquiryPriceLine> costLines;
|
||||
|
||||
/**
|
||||
* 销售价逐行。不入库。还没调价的海运/陆运整列为空。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@ApiModelProperty("销售价逐行(展示)")
|
||||
private List<InquiryPriceLine> saleLines;
|
||||
|
||||
/**
|
||||
* 利润点逐行。不入库。没调过价为空。最后一行是整单利润点。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@ApiModelProperty("利润点逐行(展示)")
|
||||
private List<InquiryPriceLine> profitLines;
|
||||
|
||||
@ApiModelProperty("是否命中 TMS")
|
||||
private Integer tmsHit;
|
||||
|
||||
|
||||
+242
-24
@@ -5,10 +5,14 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.modules.inquiry.domain.TicketStatuses;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryStaffRole;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicketStatusLog;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryStaffRoleMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketMapper;
|
||||
import org.jeecg.modules.inquiry.mapper.InquiryTicketStatusLogMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
|
||||
import org.jeecg.modules.inquiry.support.InquiryTicketDisplaySupport;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -22,6 +26,7 @@ import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
@@ -34,8 +39,11 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* 看板实时聚合实现(无快照依赖)。
|
||||
* <p>
|
||||
* 计算口径:询价总数、待处理、TMS 命中率、异常完成率、手工报价占比、
|
||||
* 已成交、成交率、反馈率;内部协同时长本期无协同表时返回「-」。
|
||||
* 九指标与原型看板一致(5+4 栅格):询价总量、报价工单、标准报价命中率、
|
||||
* 平均报价销售额、手工工单占比、待处理工单、报价成功率、报价有效率、
|
||||
* 内部报价平均响应。问号 tip 只写计算公式,禁止「本期无协同明细 / N单无勾选…」占位。
|
||||
* 内部报价平均响应 = AVG(首次 PRODUCT 报价时间 − 分发时间),
|
||||
* 分发=bind_collab_group/activate_air_group。
|
||||
* 人员排名与工单列表同一套发起人解析(企微 id → 员工姓名),禁止只认库内空 sales_name。
|
||||
* 成本趋势:近 7 日工单按线路聚合均价(成本价优先,缺省用销售价),非浏览器直连 TMS。
|
||||
* 线程:只读查询,无写锁。
|
||||
@@ -46,6 +54,9 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final int COST_ROUTE_LIMIT = 16;
|
||||
/** 协同「分发」:海运/陆运建群、空运群激活。 */
|
||||
private static final List<String> COLLAB_DISPATCH_ACTIONS = Arrays.asList(
|
||||
"bind_collab_group", "activate_air_group");
|
||||
private static final String[] COST_COLORS = {
|
||||
"#2563eb", "#3b82f6", "#0891b2", "#0e7490", "#6366f1", "#8b5cf6",
|
||||
"#a855f7", "#c026d3", "#db2777", "#e11d48", "#dc2626", "#ea580c",
|
||||
@@ -62,6 +73,10 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryTicketMapper ticketMapper;
|
||||
@Autowired
|
||||
private InquiryStaffRoleMapper staffRoleMapper;
|
||||
@Autowired
|
||||
private InquiryTicketStatusLogMapper statusLogMapper;
|
||||
@Autowired
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> metrics(String range) {
|
||||
@@ -94,28 +109,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
long pending = total - deal - lost - closed;
|
||||
long tmsHit = tickets.stream().filter(t -> t.getTmsHit() != null && t.getTmsHit() == 1).count();
|
||||
long manual = tickets.stream().filter(t -> t.getManualQuote() != null && t.getManualQuote() == 1).count();
|
||||
long hasEventExc = tickets.stream().filter(t -> hasExc(t.getEventException())).count();
|
||||
long hasSysExc = tickets.stream().filter(t -> hasExc(t.getSystemException())).count();
|
||||
long abnormalTotal = hasEventExc + hasSysExc;
|
||||
// 简化:已关闭且曾有异常视为异常处理完成
|
||||
long abnormalDone = tickets.stream()
|
||||
.filter(t -> TicketStatuses.CLOSED.equals(t.getStatus()))
|
||||
.filter(t -> hasExc(t.getEventException()) || hasExc(t.getSystemException())
|
||||
|| (t.getCloseReason() != null && t.getCloseReason().contains("异常")))
|
||||
.count();
|
||||
|
||||
String avgResp = avgCollabResponse(tickets);
|
||||
String avgSale = avgHighlightedSale(tickets);
|
||||
|
||||
List<Map<String, Object>> metricCards = new ArrayList<>();
|
||||
metricCards.add(card("inquiry", "询价总数", "周期内询价工单总数", String.valueOf(total), false));
|
||||
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
|
||||
// 顺序对齐原型 5 列栅格:上排总量/报价/TMS/销售额/手工,下排待处理/成功率/有效率/响应
|
||||
metricCards.add(card("inquiry", "询价总量", "周期内询价工单总数", String.valueOf(total), false));
|
||||
metricCards.add(card("quoted", "报价工单", "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)",
|
||||
quoted + " 单", false));
|
||||
metricCards.add(card("tms", "标准报价命中率", "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", pct(tmsHit, total), false));
|
||||
metricCards.add(card("abnormal", "异常处理完成率", "已关闭异常工单数 ÷ 异常工单总数 × 100%",
|
||||
abnormalTotal == 0 ? "100%" : pct(abnormalDone, Math.max(abnormalTotal, 1)), false));
|
||||
metricCards.add(card("manual", "手工报价占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
|
||||
metricCards.add(card("accepted", "已成交工单", "状态=已成交的工单数量", String.valueOf(deal), false));
|
||||
metricCards.add(card("dealRate", "报价成交率", "已成交数 ÷ 已报价相关数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||
metricCards.add(card("avgSale", "平均报价销售额", "AVG(已勾选报价的销售价)", avgSale, false));
|
||||
metricCards.add(card("manual", "手工工单占比", "人工报价的询价数 ÷ 询价总数 × 100%", pct(manual, total), false));
|
||||
metricCards.add(card("pending", "待处理工单", "工单总数 − 已成交 − 未成交 − 已关闭", pending + " 单", false));
|
||||
metricCards.add(card("dealRate", "报价成功率", "已成交数 ÷ 已报价数 × 100%", pct(deal, Math.max(quoted, 1)), false));
|
||||
long feedbackNumer = deal + lost;
|
||||
metricCards.add(card("feedback", "报价反馈率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||
metricCards.add(card("avgResp", "内部协同平均响应时长", "本期无协同明细表,暂无数据", "-", true));
|
||||
metricCards.add(card("feedback", "报价有效率", "(已成交 + 未成交)÷ 工单数 × 100%", pct(feedbackNumer, total), false));
|
||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
@@ -416,10 +427,6 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return time.toInstant().atZone(ZONE).toLocalDate();
|
||||
}
|
||||
|
||||
private static boolean hasExc(String v) {
|
||||
return v != null && !v.trim().isEmpty() && !"-".equals(v.trim());
|
||||
}
|
||||
|
||||
private static String pct(long numer, long denom) {
|
||||
if (denom <= 0) {
|
||||
return "0.0%";
|
||||
@@ -428,6 +435,217 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return p.toPlainString() + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* 平均报价销售额:勾选报价(highlighted=1)的销售价平均。
|
||||
* <p>
|
||||
* 金额优先报价 priceText,没有再用工单 salePrice。没有可解析样本返回「-」,
|
||||
* 禁止写成 0.0%(这不是比率)。问号文案由调用方固定为公式,这里不算 tip。
|
||||
*/
|
||||
private String avgHighlightedSale(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
List<String> nos = tickets.stream()
|
||||
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
|
||||
.map(InquiryTicket::getWorkOrderNo)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (nos.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, String> ticketSale = new HashMap<>();
|
||||
for (InquiryTicket t : tickets) {
|
||||
if (t == null || StringUtils.isBlank(t.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(t.getSalePrice())) {
|
||||
ticketSale.put(t.getWorkOrderNo().trim(), t.getSalePrice());
|
||||
}
|
||||
}
|
||||
Map<String, String> saleByWo = new HashMap<>();
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryQuote>()
|
||||
.in(InquiryQuote::getWorkOrderNo, nos)
|
||||
.eq(InquiryQuote::getHighlighted, 1));
|
||||
if (quotes != null) {
|
||||
for (InquiryQuote q : quotes) {
|
||||
if (q == null || StringUtils.isBlank(q.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
String wo = q.getWorkOrderNo().trim();
|
||||
String amt = firstNonBlank(q.getPriceText(), ticketSale.get(wo));
|
||||
if (StringUtils.isNotBlank(amt)) {
|
||||
saleByWo.put(wo, amt);
|
||||
}
|
||||
}
|
||||
}
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
int n = 0;
|
||||
for (String amt : saleByWo.values()) {
|
||||
BigDecimal money = parseMoney(amt);
|
||||
if (money == null) {
|
||||
continue;
|
||||
}
|
||||
sum = sum.add(money);
|
||||
n++;
|
||||
}
|
||||
if (n <= 0) {
|
||||
return "-";
|
||||
}
|
||||
return formatAvgSale(sum.divide(BigDecimal.valueOf(n), 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
/** 平均销售额展示:整数不留小数,否则最多两位。 */
|
||||
static String formatAvgSale(BigDecimal avg) {
|
||||
if (avg == null) {
|
||||
return "-";
|
||||
}
|
||||
BigDecimal stripped = avg.stripTrailingZeros();
|
||||
if (stripped.scale() <= 0) {
|
||||
return stripped.toPlainString();
|
||||
}
|
||||
return avg.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
if (StringUtils.isNotBlank(a) && !"-".equals(a.trim())) {
|
||||
return a.trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(b) && !"-".equals(b.trim())) {
|
||||
return b.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部协同平均响应时长。
|
||||
* <p>
|
||||
* 分发时间:状态日志最早的 bind_collab_group / activate_air_group;
|
||||
* 首次有效回复:分发之后最早的 PRODUCT 报价(航线/产品手工价,不含销售调价与 TMS)。
|
||||
* 无样本返回「-」。
|
||||
*/
|
||||
private String avgCollabResponse(List<InquiryTicket> tickets) {
|
||||
if (tickets == null || tickets.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
List<String> nos = tickets.stream()
|
||||
.filter(t -> t != null && StringUtils.isNotBlank(t.getWorkOrderNo()))
|
||||
.map(InquiryTicket::getWorkOrderNo)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (nos.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, Date> dispatchByWo = loadEarliestDispatch(nos);
|
||||
if (dispatchByWo.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
Map<String, List<Date>> productTimesByWo = loadProductQuoteTimes(
|
||||
new ArrayList<>(dispatchByWo.keySet()));
|
||||
long sumMs = 0L;
|
||||
int samples = 0;
|
||||
for (Map.Entry<String, Date> e : dispatchByWo.entrySet()) {
|
||||
Date dispatch = e.getValue();
|
||||
if (dispatch == null) {
|
||||
continue;
|
||||
}
|
||||
Date reply = firstReplyAfter(productTimesByWo.get(e.getKey()), dispatch);
|
||||
if (reply == null) {
|
||||
continue;
|
||||
}
|
||||
long ms = reply.getTime() - dispatch.getTime();
|
||||
if (ms < 0) {
|
||||
continue;
|
||||
}
|
||||
sumMs += ms;
|
||||
samples++;
|
||||
}
|
||||
if (samples <= 0) {
|
||||
return "-";
|
||||
}
|
||||
return formatDurationMs(sumMs / samples);
|
||||
}
|
||||
|
||||
private Map<String, Date> loadEarliestDispatch(List<String> workOrderNos) {
|
||||
Map<String, Date> out = new HashMap<>();
|
||||
if (workOrderNos == null || workOrderNos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<InquiryTicketStatusLog> logs = statusLogMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryTicketStatusLog>()
|
||||
.in(InquiryTicketStatusLog::getWorkOrderNo, workOrderNos)
|
||||
.in(InquiryTicketStatusLog::getAction, COLLAB_DISPATCH_ACTIONS)
|
||||
.orderByAsc(InquiryTicketStatusLog::getCreateTime));
|
||||
if (logs == null) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryTicketStatusLog log : logs) {
|
||||
if (log == null || log.getCreateTime() == null || StringUtils.isBlank(log.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
out.putIfAbsent(log.getWorkOrderNo().trim(), log.getCreateTime());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, List<Date>> loadProductQuoteTimes(List<String> workOrderNos) {
|
||||
Map<String, List<Date>> out = new HashMap<>();
|
||||
if (workOrderNos == null || workOrderNos.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(
|
||||
new LambdaQueryWrapper<InquiryQuote>()
|
||||
.in(InquiryQuote::getWorkOrderNo, workOrderNos)
|
||||
.eq(InquiryQuote::getSource, "PRODUCT")
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
if (quotes == null) {
|
||||
return out;
|
||||
}
|
||||
for (InquiryQuote q : quotes) {
|
||||
if (q == null || q.getCreateTime() == null || StringUtils.isBlank(q.getWorkOrderNo())) {
|
||||
continue;
|
||||
}
|
||||
out.computeIfAbsent(q.getWorkOrderNo().trim(), k -> new ArrayList<>()).add(q.getCreateTime());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 分发之后的首条 PRODUCT 报价时间;没有则 null。 */
|
||||
static Date firstReplyAfter(List<Date> times, Date dispatch) {
|
||||
if (times == null || times.isEmpty() || dispatch == null) {
|
||||
return null;
|
||||
}
|
||||
long from = dispatch.getTime();
|
||||
for (Date t : times) {
|
||||
if (t != null && t.getTime() >= from) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 毫秒 → 展示串:不足 1 分钟用秒;否则 XmYs / XhYm。
|
||||
* 单测可直接调。
|
||||
*/
|
||||
static String formatDurationMs(long ms) {
|
||||
if (ms < 0) {
|
||||
return "-";
|
||||
}
|
||||
long totalSec = ms / 1000L;
|
||||
if (totalSec < 60) {
|
||||
return totalSec + "s";
|
||||
}
|
||||
long totalMin = totalSec / 60L;
|
||||
long sec = totalSec % 60L;
|
||||
if (totalMin < 60) {
|
||||
return totalMin + "m" + sec + "s";
|
||||
}
|
||||
long hours = totalMin / 60L;
|
||||
long min = totalMin % 60L;
|
||||
return hours + "h" + min + "m";
|
||||
}
|
||||
|
||||
private static Map<String, Object> card(String key, String label, String tip, String value, boolean isTime) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("key", key);
|
||||
|
||||
+8
-6
@@ -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;
|
||||
@@ -645,13 +646,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
|
||||
/**
|
||||
* 查找已缓存预览图。
|
||||
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
|
||||
* v5:不再经 POI 改打印属性,避免主题蓝底/合并条带被拆格。
|
||||
*/
|
||||
private Path findCachedSnapshot(String versionId) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v5.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v5.pdf");
|
||||
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
|
||||
return cachedPng;
|
||||
}
|
||||
@@ -664,8 +665,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v5.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
|
||||
Path target = rendered.isImage() ? cachedPng : cachedPdf;
|
||||
@@ -770,7 +771,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
ok.put("templateId", picked.get("templateId"));
|
||||
ok.put("templateName", picked.get("name"));
|
||||
ok.put("versionNo", picked.get("versionNo"));
|
||||
ok.put("fileName", safeNo + "_quote.xlsx");
|
||||
// 销售看到的名字:工单号 + 后台模板名,不带后缀。磁盘路径仍是带时间戳的 xlsx。
|
||||
ok.put("fileName", QuoteSheetFileName.of(safeNo, String.valueOf(picked.getOrDefault("name", ""))));
|
||||
ok.put("fileBase64", java.util.Base64.getEncoder().encodeToString(bytes));
|
||||
ok.put("fileSize", bytes.length);
|
||||
return ok;
|
||||
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
package org.jeecg.modules.inquiry.support;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryPriceLine;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryQuote;
|
||||
import org.jeecg.modules.inquiry.entity.InquiryTicket;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台成本价 / 销售价 / 利润点逐行。
|
||||
* <p>
|
||||
* 职责:从已保存的报价明细铺开展示。不改调价算法,不把各行金额加总冒充费用合计。
|
||||
* 海运/陆运:成本是调价前生效价(手工同名盖 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<InquiryQuote> 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<FeeRow> tmsFees = feesOf(lastTms);
|
||||
List<FeeRow> manualFees = feesOf(lastManual);
|
||||
List<FeeRow> adjustFees = feesOf(lastAdjust);
|
||||
|
||||
String costTotal = "";
|
||||
List<FeeRow> costFees = new ArrayList<FeeRow>();
|
||||
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<FeeRow> saleFees = new ArrayList<FeeRow>();
|
||||
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<FeeRow> 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.<InquiryPriceLine>emptyList());
|
||||
ticket.setSaleLines(moneyLines(saleFees, saleTotal));
|
||||
ticket.setProfitLines(adjusted && StringUtils.isNotBlank(profitTotal)
|
||||
? profitAgainst(baselineFees, saleFees, profitTotal)
|
||||
: Collections.<InquiryPriceLine>emptyList());
|
||||
return;
|
||||
}
|
||||
if (!adjusted) {
|
||||
ticket.setCostLines(moneyLines(costFees, costTotal));
|
||||
ticket.setSaleLines(Collections.<InquiryPriceLine>emptyList());
|
||||
ticket.setProfitLines(Collections.<InquiryPriceLine>emptyList());
|
||||
return;
|
||||
}
|
||||
List<Aligned> rows = align(costFees, saleFees);
|
||||
ticket.setCostLines(project(rows, true, costTotal));
|
||||
ticket.setSaleLines(project(rows, false, saleTotal));
|
||||
ticket.setProfitLines(profitFromAligned(rows, profitTotal));
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工盖 TMS:同名按出现顺序盖金额,没有的保留,手工多出来的补在后面。
|
||||
* 同名两行不并成一行。
|
||||
*/
|
||||
static List<FeeRow> overlay(List<FeeRow> base, List<FeeRow> incoming) {
|
||||
List<FeeRow> rows = new ArrayList<FeeRow>();
|
||||
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<FeeRow> oldRows, List<FeeRow> 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<InquiryPriceLine> moneyLines(List<FeeRow> fees, String totalText) {
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
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<InquiryPriceLine> project(List<Aligned> rows, boolean costSide, String totalText) {
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
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<InquiryPriceLine> profitAgainst(List<FeeRow> baseline, List<FeeRow> sale, String totalProfit) {
|
||||
boolean[] used = new boolean[baseline == null ? 0 : baseline.size()];
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
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<InquiryPriceLine> profitFromAligned(List<Aligned> rows, String totalProfit) {
|
||||
List<InquiryPriceLine> lines = new ArrayList<InquiryPriceLine>();
|
||||
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<Aligned> align(List<FeeRow> left, List<FeeRow> right) {
|
||||
List<FeeRow> safeRight = right == null ? Collections.<FeeRow>emptyList() : right;
|
||||
boolean[] used = new boolean[safeRight.size()];
|
||||
List<Aligned> out = new ArrayList<Aligned>();
|
||||
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<FeeRow> 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<FeeRow> feesOf(InquiryQuote quote) {
|
||||
JSONObject payload = payloadOf(quote);
|
||||
if (payload == null) {
|
||||
return new ArrayList<FeeRow>();
|
||||
}
|
||||
List<FeeRow> 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<FeeRow> 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<FeeRow> readFees(Object raw) {
|
||||
JSONArray array = asArray(raw);
|
||||
List<FeeRow> rows = new ArrayList<FeeRow>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
|
||||
* <p>
|
||||
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
|
||||
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
|
||||
* 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。
|
||||
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
|
||||
*/
|
||||
public final class InquiryTicketPriceSupport {
|
||||
|
||||
@@ -65,8 +65,7 @@ public final class InquiryTicketPriceSupport {
|
||||
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
|
||||
ticket.setProfitRate("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
|
||||
ticket.setCostPrice(StringUtils.defaultString(cost));
|
||||
if (StringUtils.isNotBlank(adjustPrice)) {
|
||||
@@ -77,6 +76,9 @@ public final class InquiryTicketPriceSupport {
|
||||
ticket.setProfitRate("");
|
||||
}
|
||||
}
|
||||
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
|
||||
InquiryQuoteLineSupport.fill(ticket, quotes);
|
||||
}
|
||||
|
||||
static boolean isAir(String businessLine) {
|
||||
String raw = StringUtils.trimToEmpty(businessLine);
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* 系统生成报价单的对外文件名:工单号接后台模板名,再加 .xlsx。
|
||||
*
|
||||
* <p>主账填的是 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();
|
||||
}
|
||||
}
|
||||
+190
-8
@@ -123,6 +123,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
FILL_BY_LABEL.put("始发站", "origin");
|
||||
FILL_BY_LABEL.put("目的地", "destination");
|
||||
FILL_BY_LABEL.put("货物品名", "commodity");
|
||||
// 陆运跨境集拼表头是「货物类型」,与货物品名同一事实码;禁止用「货物品名」前缀误匹配后写进蓝底表头格
|
||||
FILL_BY_LABEL.put("货物类型", "commodity");
|
||||
FILL_BY_LABEL.put("车型/数量", "vehicle_qty");
|
||||
FILL_BY_LABEL.put("运输类型", "运输类型");
|
||||
FILL_BY_LABEL.put("线路类别", "线路类别");
|
||||
@@ -667,6 +669,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cellRef = redirectLandHeaderBandMapping(wb, cellRef);
|
||||
writeCell(wb, cellRef, String.valueOf(val));
|
||||
}
|
||||
}
|
||||
@@ -2712,21 +2715,81 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」类车型表头,下移一行。
|
||||
* 线路数据起点:若 mapping 的 dataStartRow 落在「4.2米/3T」或「始发地/元/KG」类表头,下移到空白数据行。
|
||||
* 跨境集拼:蓝底「始发地 | 目的地 | 2-500KG…」是表头,禁止当第一行数据写(会把蓝底盖成白底并把「始发地」改成城市名)。
|
||||
*/
|
||||
static int resolveRouteDataStartRow(Sheet sheet, int rowIdx0, int headerRow1Based) {
|
||||
if (sheet == null || rowIdx0 < 0) {
|
||||
return rowIdx0;
|
||||
}
|
||||
if (rowLooksLikeVehicleHeader(sheet, rowIdx0)) {
|
||||
return rowIdx0 + 1;
|
||||
int cur = rowIdx0;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) {
|
||||
cur++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// headerRow 下一行若是车型副表头,再下一行才是数据
|
||||
int header0 = headerRow1Based > 0 ? headerRow1Based - 1 : -1;
|
||||
if (header0 >= 0 && rowIdx0 == header0 + 1 && rowLooksLikeVehicleHeader(sheet, rowIdx0)) {
|
||||
return rowIdx0 + 1;
|
||||
if (header0 >= 0 && cur == header0) {
|
||||
cur = header0 + 1;
|
||||
if (rowLooksLikeVehicleHeader(sheet, cur) || rowLooksLikeLandRouteMatrixHeader(sheet, cur)) {
|
||||
cur++;
|
||||
}
|
||||
return rowIdx0;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运线路矩阵表头:同行有「始发地+目的地」,或「元/KG」分档,或燃油/报关费列名。
|
||||
*/
|
||||
static boolean rowLooksLikeLandRouteMatrixHeader(Sheet sheet, int rowIdx) {
|
||||
if (sheet == null || rowIdx < 0) {
|
||||
return false;
|
||||
}
|
||||
Row row = sheet.getRow(rowIdx);
|
||||
if (row == null) {
|
||||
return false;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
boolean origin = false;
|
||||
boolean dest = false;
|
||||
int feeHits = 0;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell)).replaceAll("\\s+", "");
|
||||
if (text.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if ("始发地".equals(text) || "始发站".equals(text)) {
|
||||
origin = true;
|
||||
}
|
||||
if ("目的地".equals(text)) {
|
||||
dest = true;
|
||||
}
|
||||
if (text.contains("元/KG") || text.contains("元/kg") || text.contains("最低收费")
|
||||
|| text.contains("燃油附加") || text.contains("报关费") || text.contains("提货费")
|
||||
|| text.contains("口岸") || "税率".equals(text) || "币种".equals(text)) {
|
||||
feeHits++;
|
||||
}
|
||||
}
|
||||
return (origin && dest) || feeHits >= 2;
|
||||
}
|
||||
|
||||
/** 线路矩阵单个表头格:始发地、目的地、分档单价、费种名。 */
|
||||
static boolean isLandRouteMatrixHeaderCell(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String t = text.replaceAll("\\s+", "").trim();
|
||||
if ("始发地".equals(t) || "始发站".equals(t) || "目的地".equals(t) || "最低收费".equals(t)
|
||||
|| "币种".equals(t) || "税率".equals(t) || "时效".equals(t)) {
|
||||
return true;
|
||||
}
|
||||
return t.contains("元/KG") || t.contains("元/kg") || t.contains("燃油附加") || t.contains("报关费")
|
||||
|| t.contains("提货费") || t.contains("口岸核放");
|
||||
}
|
||||
|
||||
/** 行内出现「米/」「尺」「3T」「5T」等车型表头文案。 */
|
||||
@@ -3077,6 +3140,12 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (isYellowFixed(cell)) {
|
||||
return;
|
||||
}
|
||||
String exist = readCellText(cell);
|
||||
// 蓝底/费用表头格禁止当数据写:跨境集拼「始发地」被写成「上海」会拆掉主题蓝条
|
||||
if (isFeeTableHeader(exist) || isLandInquiryHeaderBandLabel(exist)
|
||||
|| isLandRouteMatrixHeaderCell(exist) || isSectionTitle(sheet, cell)) {
|
||||
return;
|
||||
}
|
||||
writeNumericOrText(cell, value);
|
||||
// 映射表写出的备注同样要换行,否则只看得见半截
|
||||
applyRemarkWrap(sheet, row, cell, value);
|
||||
@@ -4261,7 +4330,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 标签格「报价编号:」只换冒号后的值,避免整格被工单号顶掉看起来像残缺
|
||||
String existing = readCellText(cell);
|
||||
// 映射点常落在合并标签格 / 20GP 表头上,不能把原字盖掉
|
||||
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)) {
|
||||
if (isFeeTableHeader(existing) || isLabelLike(existing) || isSectionTitle(sheet, cell)
|
||||
|| isLandInquiryHeaderBandLabel(existing)) {
|
||||
return;
|
||||
}
|
||||
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
|
||||
@@ -4362,8 +4432,13 @@ public final class QuoteTemplateExcelSupport {
|
||||
* 标签右侧的可填格。海运询价单标签常是 E4:G4 合并,值在 H4:J4;
|
||||
* 不能再取合并区内的 F4,否则会把「起运港」四字盖掉。
|
||||
* 表头「币别」右侧是 20GP,对不上就空着,不要映到箱型列。
|
||||
* 陆运跨境集拼等:蓝底「业务员/客户名称/始发地/货物类型」是表头,值在下一空白行,禁止写进表头格把蓝底盖掉。
|
||||
*/
|
||||
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
|
||||
String landBandRef = resolveLandHeaderBandFillTarget(sheet, labelCell, sheetName, text);
|
||||
if (StringUtils.isNotBlank(landBandRef)) {
|
||||
return landBandRef;
|
||||
}
|
||||
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
|
||||
// 「RFQ No:」或冒号后已有样例:值写在本格冒号后,不跨到右侧单选/标题合并格
|
||||
// 注意:CellReference(Cell) 的 formatAsString 可能已带 sheet 名,禁止再拼 sheetName! 否则变成 Sheet2!Sheet2!B6
|
||||
@@ -4425,6 +4500,109 @@ public final class QuoteTemplateExcelSupport {
|
||||
return wideEmpty != null ? wideEmpty : firstEmpty;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运询价条带:同一行多个蓝底短标签(业务员、客户名称、始发地、货物类型),
|
||||
* 值写在该标签合并区正下方空白行,不写进表头格,以免蓝底被盖成白底。
|
||||
*
|
||||
* @return 下一行可填格引用;不是条带表头则空
|
||||
*/
|
||||
static String resolveLandHeaderBandFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text) {
|
||||
if (sheet == null || labelCell == null || StringUtils.isBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
if (!isLandInquiryHeaderBandLabel(text)) {
|
||||
return null;
|
||||
}
|
||||
int rowIdx = labelCell.getRowIndex();
|
||||
if (!rowLooksLikeLandInquiryHeaderBand(sheet, rowIdx)) {
|
||||
return null;
|
||||
}
|
||||
int firstCol = labelCell.getColumnIndex();
|
||||
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
|
||||
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
|
||||
if (range.isInRange(rowIdx, labelCell.getColumnIndex())) {
|
||||
firstCol = range.getFirstColumn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
int valueRow = rowIdx + 1;
|
||||
// 表头下若还有车型副表头,再下一行才是询价条带值(与线路矩阵无关的窄条)
|
||||
if (rowLooksLikeVehicleHeader(sheet, valueRow)) {
|
||||
valueRow++;
|
||||
}
|
||||
Row next = sheet.getRow(valueRow);
|
||||
if (next == null) {
|
||||
next = sheet.createRow(valueRow);
|
||||
}
|
||||
int[] dest = resolveMergeOrigin(sheet, valueRow, firstCol);
|
||||
Cell cand = next.getCell(dest[1]);
|
||||
String exist = readCellText(cand);
|
||||
if (isFeeTableHeader(exist) || isLabelLike(exist) || isLandInquiryHeaderBandLabel(exist)
|
||||
|| isSectionTitle(sheet, cand) || isUnsuitableValueCell(exist)) {
|
||||
return null;
|
||||
}
|
||||
return sheetName + "!" + new CellReference(dest[0], dest[1]).formatAsString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境集拼询价条带上的短标签。不含「时效」:费用矩阵也有同名列,不能整行当下表头。
|
||||
*/
|
||||
static boolean isLandInquiryHeaderBandLabel(String text) {
|
||||
if (StringUtils.isBlank(text)) {
|
||||
return false;
|
||||
}
|
||||
String compact = text.replace(":", "").replace(":", "").replaceAll("\\s+", "").trim();
|
||||
return "业务员".equals(compact) || "客户名称".equals(compact) || "始发地".equals(compact)
|
||||
|| "货物类型".equals(compact) || "条款".equals(compact) || "发货日期".equals(compact);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行里出现不少于 3 个询价条带标签,视为陆运蓝底表头行(截图正确版第 5 行)。
|
||||
*/
|
||||
static boolean rowLooksLikeLandInquiryHeaderBand(Sheet sheet, int rowIdx) {
|
||||
if (sheet == null || rowIdx < 0) {
|
||||
return false;
|
||||
}
|
||||
Row row = sheet.getRow(rowIdx);
|
||||
if (row == null) {
|
||||
return false;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
int hits = 0;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String t = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
|
||||
if (isLandInquiryHeaderBandLabel(t)) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
return hits >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* mapping.fields 若误指蓝底询价条带表头格,改写到正下方空白行。
|
||||
*/
|
||||
static String redirectLandHeaderBandMapping(Workbook wb, String cellRef) {
|
||||
ParsedRef pref = ParsedRef.parse(cellRef);
|
||||
if (pref == null || wb == null) {
|
||||
return cellRef;
|
||||
}
|
||||
Sheet sh = wb.getSheet(pref.sheet);
|
||||
if (sh == null && wb.getNumberOfSheets() > 0) {
|
||||
sh = wb.getSheetAt(0);
|
||||
}
|
||||
if (sh == null) {
|
||||
return cellRef;
|
||||
}
|
||||
Row row = sh.getRow(pref.row);
|
||||
Cell cell = row == null ? null : row.getCell(pref.col);
|
||||
String text = readCellText(cell);
|
||||
String redirected = resolveLandHeaderBandFillTarget(sh, cell, sh.getSheetName(), text);
|
||||
return StringUtils.isNotBlank(redirected) ? redirected : cellRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射点不够时,按标签把询价七项 / 合计写到标签右侧空格。
|
||||
* 币别若落在费用表头行则跳过,避免 CNY 盖掉 20GP。
|
||||
@@ -4753,6 +4931,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 禁止反向:词典标签以格子开头(「整柜或拼柜」startsWith「整柜」),
|
||||
// 否则第一次写入的「整柜」会被二次扫描当成标签,跨栏写进送货地址(WO202609240027)。
|
||||
if (dk.length() >= 3 && compact.length() >= dk.length() && compact.startsWith(dk)) {
|
||||
// 「货物类型」不能被「货物品名」前缀命中后写进蓝底表头
|
||||
if ("货物品名".equals(dk) && compact.startsWith("货物类型")) {
|
||||
continue;
|
||||
}
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -145,7 +145,8 @@ public final class QuoteTemplateMatcher {
|
||||
static {
|
||||
LAND_KEYWORD_ALIASES.put("国内拼车", new String[] {"国内运输拼车", "拼车"});
|
||||
LAND_KEYWORD_ALIASES.put("国内零担", new String[] {"国内运输拼车", "国内长途/零担", "零担"});
|
||||
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车", "整车"});
|
||||
// 「整车」不能当国内整车别名:跨境整车 / 中港整车都含这两个字,会把跨境单配成国内表。
|
||||
LAND_KEYWORD_ALIASES.put("国内整车", new String[] {"国内运输整车"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境整车", new String[] {"跨境整车"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境集拼", new String[] {"跨境集拼"});
|
||||
LAND_KEYWORD_ALIASES.put("跨境零担", new String[] {"跨境集拼"});
|
||||
@@ -155,8 +156,7 @@ public final class QuoteTemplateMatcher {
|
||||
if (text == null || text.isEmpty() || kw == null || kw.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String hay = text.toLowerCase(Locale.ROOT);
|
||||
if (hay.contains(kw.toLowerCase(Locale.ROOT))) {
|
||||
if (containsLandNeedle(text, kw)) {
|
||||
return true;
|
||||
}
|
||||
String[] aliases = LAND_KEYWORD_ALIASES.get(kw);
|
||||
@@ -164,13 +164,31 @@ public final class QuoteTemplateMatcher {
|
||||
return false;
|
||||
}
|
||||
for (String alias : aliases) {
|
||||
if (hay.contains(alias.toLowerCase(Locale.ROOT))) {
|
||||
if (containsLandNeedle(text, alias)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运针:普通 contains;单独「整车」不命中跨境/中港(避免国内整车模板抢走跨境单)。
|
||||
*/
|
||||
static boolean containsLandNeedle(String text, String needle) {
|
||||
if (text == null || needle == null || needle.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String hay = text.toLowerCase(Locale.ROOT);
|
||||
String n = needle.toLowerCase(Locale.ROOT);
|
||||
if (!hay.contains(n)) {
|
||||
return false;
|
||||
}
|
||||
if ("整车".equals(n) && (hay.contains("跨境") || hay.contains("中港"))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean skipKeyword(String kw) {
|
||||
if (kw == null || kw.isEmpty()) {
|
||||
return true;
|
||||
|
||||
+3
-82
@@ -5,11 +5,6 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -18,7 +13,6 @@ import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -36,6 +30,7 @@ import java.util.stream.Stream;
|
||||
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
|
||||
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
|
||||
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
|
||||
* 禁止 POI 先改打印再转:会丢掉 WPS 主题色与合并条带(跨境集拼蓝底表头拆成独立格)。
|
||||
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
|
||||
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
|
||||
*/
|
||||
@@ -89,8 +84,8 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
try {
|
||||
Path localXlsx = workDir.resolve("source.xlsx");
|
||||
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
|
||||
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
|
||||
preparePreviewPrintLayout(localXlsx);
|
||||
// 禁止 POI 重写后再转:主题色/合并条带会被拆成独立格子(跨境集拼蓝底表头)。
|
||||
// 原表打印属性原样交给 soffice,保证后台截图与 WPS 打开一致。
|
||||
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
|
||||
if (pdf == null || !Files.isRegularFile(pdf)) {
|
||||
throw new IllegalStateException("LibreOffice 未生成 PDF");
|
||||
@@ -112,80 +107,6 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
|
||||
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
|
||||
*/
|
||||
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(xlsx);
|
||||
Workbook wb = WorkbookFactory.create(in)) {
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
boolean landscape = preferLandscape(sheet);
|
||||
sheet.setMargin(Sheet.LeftMargin, 0.4);
|
||||
sheet.setMargin(Sheet.RightMargin, 0.4);
|
||||
sheet.setMargin(Sheet.TopMargin, 0.5);
|
||||
sheet.setMargin(Sheet.BottomMargin, 0.5);
|
||||
sheet.setHorizontallyCenter(true);
|
||||
sheet.setVerticallyCenter(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.setFitToPage(true);
|
||||
PrintSetup ps = sheet.getPrintSetup();
|
||||
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
|
||||
ps.setLandscape(landscape);
|
||||
ps.setFitWidth((short) 1);
|
||||
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
|
||||
ps.setFitHeight((short) 0);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
|
||||
* 单位换算只需相对比较,不要求绝对毫米精度。
|
||||
*/
|
||||
private static boolean preferLandscape(Sheet sheet) {
|
||||
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
if (lastRow < firstRow) {
|
||||
return false;
|
||||
}
|
||||
int maxCol = 0;
|
||||
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
short last = row.getLastCellNum();
|
||||
if (last > maxCol) {
|
||||
maxCol = last;
|
||||
}
|
||||
}
|
||||
if (maxCol <= 0) {
|
||||
return false;
|
||||
}
|
||||
double widthPx = 0;
|
||||
for (int c = 0; c < maxCol; c++) {
|
||||
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
|
||||
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
|
||||
}
|
||||
double heightPx = 0;
|
||||
int rowLimit = Math.min(lastRow, firstRow + 120);
|
||||
for (int r = firstRow; r <= rowLimit; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
|
||||
heightPx += points * (96.0 / 72.0);
|
||||
}
|
||||
return widthPx > heightPx * 1.08;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
|
||||
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
|
||||
|
||||
+28
@@ -30,4 +30,32 @@ public class InquiryDashboardServiceImplTest {
|
||||
air.setBusinessLine("AIR");
|
||||
Assert.assertEquals("空运全线", InquiryDashboardServiceImpl.routeLabel(air));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatDurationMsMatchesDashboardDisplay() {
|
||||
Assert.assertEquals("42s", InquiryDashboardServiceImpl.formatDurationMs(42_000L));
|
||||
Assert.assertEquals("8m42s", InquiryDashboardServiceImpl.formatDurationMs((8 * 60 + 42) * 1000L));
|
||||
Assert.assertEquals("1h5m", InquiryDashboardServiceImpl.formatDurationMs((65 * 60) * 1000L));
|
||||
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatDurationMs(-1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAvgSaleIsAmountNotPercent() {
|
||||
Assert.assertEquals("-", InquiryDashboardServiceImpl.formatAvgSale(null));
|
||||
Assert.assertEquals("12800", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12800.00")));
|
||||
Assert.assertEquals("12.50", InquiryDashboardServiceImpl.formatAvgSale(new BigDecimal("12.5")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstReplyAfterSkipsQuotesBeforeDispatch() {
|
||||
java.util.Date dispatch = new java.util.Date(1_000_000L);
|
||||
java.util.List<java.util.Date> times = java.util.Arrays.asList(
|
||||
new java.util.Date(500_000L),
|
||||
new java.util.Date(1_200_000L),
|
||||
new java.util.Date(2_000_000L));
|
||||
Assert.assertEquals(new java.util.Date(1_200_000L),
|
||||
InquiryDashboardServiceImpl.firstReplyAfter(times, dispatch));
|
||||
Assert.assertNull(InquiryDashboardServiceImpl.firstReplyAfter(
|
||||
java.util.Collections.singletonList(new java.util.Date(100L)), dispatch));
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -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<InquiryPriceLine> 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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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", ""));
|
||||
}
|
||||
}
|
||||
+116
@@ -1135,6 +1135,122 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨境集拼线路矩阵:dataStartRow 若落在蓝底「始发地/目的地」表头,必须下移;
|
||||
* 不得把城市名写进表头格盖掉蓝底。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageRouteRegionSkipsBlueMatrixHeader() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-matrix-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-matrix-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
|
||||
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 7);
|
||||
org.apache.poi.ss.usermodel.Cell o = head.createCell(0);
|
||||
o.setCellValue("始发地");
|
||||
o.setCellStyle(blue);
|
||||
org.apache.poi.ss.usermodel.Cell d = head.createCell(1);
|
||||
d.setCellValue("目的地");
|
||||
d.setCellStyle(blue);
|
||||
org.apache.poi.ss.usermodel.Cell kg = head.createCell(3);
|
||||
kg.setCellValue("2-500KG\n元/KG");
|
||||
kg.setCellStyle(blue);
|
||||
row(sheet, 8);
|
||||
row(sheet, 16).createCell(0).setCellValue("查验处理费用");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境集拼");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 8);
|
||||
region.put("stopAtText", JSONArray.parseArray("[\"查验处理费用\"]"));
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "A");
|
||||
columns.put("destination", "B");
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
facts.put("route_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"[{\"origin\":\"上海\",\"destination\":\"胡志明\"}]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("始发地", read(sheet, 7, 0));
|
||||
assertEquals("目的地", read(sheet, 7, 1));
|
||||
assertEquals("上海", read(sheet, 8, 0));
|
||||
assertEquals("胡志明", read(sheet, 8, 1));
|
||||
org.apache.poi.ss.usermodel.CellStyle st = sheet.getRow(7).getCell(0).getCellStyle();
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, st.getFillPattern());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运跨境集拼:蓝底询价条带是表头(业务员/客户名称/始发地/货物类型),
|
||||
* 值写下一空白行,禁止盖掉表头字与蓝底。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageHeaderBandWritesValuesOnNextRow() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.CellStyle blue = wb.createCellStyle();
|
||||
blue.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.ROYAL_BLUE.getIndex());
|
||||
blue.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
|
||||
org.apache.poi.ss.usermodel.Row head = row(sheet, 4);
|
||||
String[] labels = {"业务员", "客户名称", "始发地", "条款", "发货日期", "货物类型"};
|
||||
int[] cols = {1, 3, 5, 7, 9, 13};
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
org.apache.poi.ss.usermodel.Cell c = head.createCell(cols[i]);
|
||||
c.setCellValue(labels[i]);
|
||||
c.setCellStyle(blue);
|
||||
sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(4, 4, cols[i], cols[i] + 1));
|
||||
}
|
||||
row(sheet, 5);
|
||||
org.apache.poi.ss.usermodel.Row matrixHead = row(sheet, 7);
|
||||
matrixHead.createCell(0).setCellValue("始发地");
|
||||
matrixHead.createCell(1).setCellValue("目的地");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("sales_name", "LuoXiaoHua");
|
||||
facts.put("origin", "上海");
|
||||
facts.put("commodity", "茶叶");
|
||||
facts.put("quote_date", "2026-10-09");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, new JSONObject(), facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("业务员", read(sheet, 4, 1));
|
||||
assertEquals("客户名称", read(sheet, 4, 3));
|
||||
assertEquals("始发地", read(sheet, 4, 5));
|
||||
assertEquals("货物类型", read(sheet, 4, 13));
|
||||
assertEquals("LuoXiaoHua", read(sheet, 5, 1));
|
||||
assertEquals("上海", read(sheet, 5, 5));
|
||||
assertEquals("茶叶", read(sheet, 5, 13));
|
||||
assertEquals("2026-10-09", read(sheet, 5, 9));
|
||||
org.apache.poi.ss.usermodel.CellStyle salesStyle = sheet.getRow(4).getCell(1).getCellStyle();
|
||||
assertEquals(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND, salesStyle.getFillPattern());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运国内拼车模板是标签栏,不是「费用名称+金额」表。
|
||||
* TMS 的 元/CBM、送货费、时效必须写到标签右侧,不能落到 G 列或整张空着。
|
||||
|
||||
+30
@@ -133,6 +133,36 @@ public class QuoteTemplateMatcherTest {
|
||||
assertEquals("跨境整车", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminDomesticFtlKeywordDoesNotStealCrossFtl() {
|
||||
// 后台国内整车模板关键词常写「国内整车」;别名「整车」不得抢走跨境整车(WO202610030032)。
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
|
||||
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输类型", "跨境整车");
|
||||
facts.put("线路类别", "东南亚");
|
||||
facts.put("运输分类", "普件(东南亚、中亚、中欧涉及国内+国外段)");
|
||||
facts.put("起运港", "上海");
|
||||
facts.put("目的港", "春疆货场");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("跨境整车报价单", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminDomesticFtlStillHitsDomesticType() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
list.add(tpl("国内陆运整车报价单", "陆运", false, "国内整车"));
|
||||
list.add(tpl("跨境整车报价单", "陆运", false, "东南亚"));
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输类型", "国内运输整车");
|
||||
facts.put("线路类别", "国内长途/零担");
|
||||
Map<String, Object> hit = QuoteTemplateMatcher.pick(list, "LAND", facts, null);
|
||||
assertNotNull(hit);
|
||||
assertEquals("国内陆运整车报价单", hit.get("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void landAdminKeywordDomesticLclMatchesTransportType() {
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
VITE_USE_MOCK = false
|
||||
|
||||
# 网站标题(浏览器标签后缀;缺省会显示「页面名 undefined」)
|
||||
VITE_GLOB_APP_TITLE = 友通达询价系统
|
||||
VITE_GLOB_APP_TITLE = 友通达智能体运营后台
|
||||
|
||||
# 简称,用于生产 _app.config.js 变量名;勿空格/数字开头
|
||||
VITE_GLOB_APP_SHORT_NAME = YtdInquiryAdmin
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useTitle() {
|
||||
|
||||
const tTitle = t(route?.meta?.title as string);
|
||||
// 生产 _app.config.js 若漏打 VITE_GLOB_APP_TITLE,title 会是 undefined → 标签出现「报价模板 undefined」
|
||||
const appTitle = title || '友通达询价系统';
|
||||
const appTitle = title || '友通达智能体运营后台';
|
||||
pageTitle.value = tTitle ? `${tTitle} - ${appTitle}` : `${appTitle}`;
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
const profileOpen = ref(false);
|
||||
const passwordOpen = ref(false);
|
||||
|
||||
const pageTitle = computed(() => String(route.meta?.title || '询价机器人后台'));
|
||||
const pageTitle = computed(() => String(route.meta?.title || '友通达智能体运营后台'));
|
||||
const crumb = computed(() => (route.meta?.crumb ? String(route.meta.crumb) : ''));
|
||||
|
||||
const username = computed(() => userStore.getUserInfo?.username || '');
|
||||
@@ -207,7 +207,7 @@
|
||||
<div class="admin-brand-icon">
|
||||
<ProtoIcon name="bot" :size="16" />
|
||||
</div>
|
||||
<div class="admin-brand-title">询价机器人</div>
|
||||
<div class="admin-brand-title">友通达智能体运营后台</div>
|
||||
</div>
|
||||
<nav class="admin-nav">
|
||||
<template v-for="group in navGroups" :key="group.title">
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 长内容折叠:超过 maxHeight 时收起,底部下箭头点开展开,再点收起。
|
||||
* 用于工单列表「询价需求 / 报价信息」和详情同名区块,避免一行撑满整屏。
|
||||
* 副作用:无写库。内容变化(contentKey)时重新量高并默认收起。
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 收起时最大高度(px) */
|
||||
maxHeight?: number;
|
||||
/** 内容身份:工单号/区块名变化时重测并收起 */
|
||||
contentKey?: string | number;
|
||||
/** 有费用明细等:即使收起后高度不够,也显示下箭头 */
|
||||
forceToggle?: boolean;
|
||||
/**
|
||||
* 收起时是否用 maxHeight 裁切内容。
|
||||
* 列表报价只藏费用明细、摘要必须完整露出时关掉,避免「CNY / 第二条报价」被切半。
|
||||
*/
|
||||
clipCollapsed?: boolean;
|
||||
}>(),
|
||||
{
|
||||
maxHeight: 120,
|
||||
contentKey: "",
|
||||
forceToggle: false,
|
||||
clipCollapsed: true,
|
||||
},
|
||||
);
|
||||
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
const expanded = ref(false);
|
||||
const overflow = ref(false);
|
||||
let ro: ResizeObserver | null = null;
|
||||
|
||||
function measure() {
|
||||
const el = bodyRef.value;
|
||||
if (!el) {
|
||||
overflow.value = !!props.forceToggle;
|
||||
return;
|
||||
}
|
||||
const natural = el.scrollHeight;
|
||||
overflow.value = !!props.forceToggle || natural > props.maxHeight + 2;
|
||||
if (!overflow.value) {
|
||||
expanded.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!overflow.value) return;
|
||||
expanded.value = !expanded.value;
|
||||
nextTick(measure);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(measure);
|
||||
if (typeof ResizeObserver !== "undefined" && bodyRef.value) {
|
||||
ro = new ResizeObserver(() => measure());
|
||||
ro.observe(bodyRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
ro = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.contentKey, props.maxHeight, props.forceToggle],
|
||||
() => {
|
||||
expanded.value = false;
|
||||
nextTick(measure);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="expand-clamp"
|
||||
:class="{ 'is-expanded': expanded, 'is-overflow': props.clipCollapsed && overflow && !expanded }"
|
||||
>
|
||||
<div
|
||||
ref="bodyRef"
|
||||
class="expand-clamp-body"
|
||||
:style="
|
||||
props.clipCollapsed && overflow && !expanded ? { maxHeight: maxHeight + 'px' } : undefined
|
||||
"
|
||||
>
|
||||
<slot :expanded="expanded" />
|
||||
</div>
|
||||
<button
|
||||
v-if="overflow"
|
||||
type="button"
|
||||
class="expand-clamp-toggle"
|
||||
:class="{ 'is-open': expanded }"
|
||||
:title="expanded ? '收起' : '展开全部'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<ProtoIcon name="chevron" :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.expand-clamp {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.expand-clamp-body {
|
||||
overflow: visible;
|
||||
}
|
||||
.expand-clamp.is-overflow .expand-clamp-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-clamp.is-overflow:not(.is-expanded)::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 22px;
|
||||
height: 28px;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #fff 90%);
|
||||
}
|
||||
.expand-clamp-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 2px 0 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.expand-clamp-toggle:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.expand-clamp-toggle :deep(svg) {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.expand-clamp-toggle.is-open :deep(svg) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="login-brand-mark">
|
||||
<ProtoIcon name="bot" :size="22" />
|
||||
</div>
|
||||
<h1>询价机器人后台</h1>
|
||||
<h1>友通达智能体运营后台</h1>
|
||||
<p>管理询价工单、人员配置与系统权限</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,19 +31,19 @@ export type CostRoute = {
|
||||
points: Array<{ x: number; y: number; index: number }>;
|
||||
};
|
||||
|
||||
/** 今日视角九指标(与原型默认展示一致) */
|
||||
/** 今日视角九指标(与原型 5 列栅格、主账 InquiryDashboardServiceImpl 一致) */
|
||||
export const todayMetrics: MetricCard[] = [
|
||||
{ key: "inquiry", label: "询价总数", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "inquiry", label: "询价总量", tip: "周期内询价工单总数", value: "128" },
|
||||
{ key: "quoted", label: "报价工单", tip: "已报价工单数(已报价 + 议价中 + 已成交 + 未成交)", value: "92 单" },
|
||||
{ key: "tms", label: "标准报价命中率", tip: "命中 TMS 标准价的询价数 ÷ 询价总数 × 100%", value: "67.8%" },
|
||||
{ key: "abnormal", label: "异常处理完成率", tip: "已关闭异常工单数 ÷ 异常工单总数 × 100%", value: "86.4%" },
|
||||
{ key: "manual", label: "手工报价占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "accepted", label: "已成交工单", tip: "状态=已成交的工单数量", value: "31" },
|
||||
{ key: "dealRate", label: "报价成交率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价反馈率", tip: "(已成交 + 未成交 − 关闭)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{ key: "avgSale", label: "平均报价销售额", tip: "AVG(已勾选报价的销售价)", value: "12800" },
|
||||
{ key: "manual", label: "手工工单占比", tip: "人工报价的询价数 ÷ 询价总数 × 100%", value: "32.0%" },
|
||||
{ key: "pending", label: "待处理工单", tip: "工单总数 − 已成交 − 未成交 − 已关闭", value: "39 单" },
|
||||
{ key: "dealRate", label: "报价成功率", tip: "已成交数 ÷ 已报价数 × 100%", value: "34.8%" },
|
||||
{ key: "feedback", label: "报价有效率", tip: "(已成交 + 未成交)÷ 工单数 × 100%", value: "60.2%" },
|
||||
{
|
||||
key: "avgResp",
|
||||
label: "内部协同平均响应时长",
|
||||
label: "内部报价平均响应",
|
||||
tip: "AVG(首次有效回复时间 − 分发时间)",
|
||||
value: "8m42s",
|
||||
isTime: true,
|
||||
|
||||
@@ -70,6 +70,9 @@ body {
|
||||
font-size: 11px !important;
|
||||
font-weight: 900 !important;
|
||||
line-height: 1.25 !important;
|
||||
white-space: normal !important;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-nav {
|
||||
@@ -476,9 +479,19 @@ body {
|
||||
.ticket-list-table td.col-sticky-left,
|
||||
.ticket-list-table td.col-sticky-right,
|
||||
.ticket-list-table th.col-sticky-left,
|
||||
.ticket-list-table th.col-sticky-right {
|
||||
.ticket-list-table th.col-sticky-right,
|
||||
.ticket-list-table td.col-quote,
|
||||
.ticket-list-table td.col-price,
|
||||
.ticket-list-table td.col-demand {
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ticket-list-table .quote-source,
|
||||
.ticket-list-table .quote-price,
|
||||
.ticket-list-table .quote-fee-line,
|
||||
.ticket-list-table .ticket-price-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-list-table .attach-tags {
|
||||
@@ -558,7 +571,7 @@ body {
|
||||
.ticket-list-table {
|
||||
table-layout: fixed !important;
|
||||
width: max-content !important;
|
||||
min-width: 1480px !important;
|
||||
min-width: 1640px !important;
|
||||
max-width: none !important;
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 0 !important;
|
||||
@@ -566,12 +579,12 @@ body {
|
||||
|
||||
.ticket-list-table .col-w-id { width: 132px; }
|
||||
.ticket-list-table .col-w-user { width: 72px; }
|
||||
.ticket-list-table .col-w-demand { width: 200px; }
|
||||
.ticket-list-table .col-w-quote { width: 140px; }
|
||||
.ticket-list-table .col-w-demand { width: 220px; }
|
||||
.ticket-list-table .col-w-quote { width: 250px; }
|
||||
.ticket-list-table .col-w-attach { width: 160px; }
|
||||
.ticket-list-table .col-w-status { width: 84px; }
|
||||
.ticket-list-table .col-w-price { width: 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 +640,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;
|
||||
|
||||
@@ -469,11 +469,11 @@
|
||||
}
|
||||
.ref-metric-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 6;
|
||||
z-index: 40;
|
||||
width: max-content;
|
||||
max-width: 240px;
|
||||
max-width: 280px;
|
||||
border: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
@@ -485,26 +485,26 @@
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transform: translate(-50%, 4px);
|
||||
transition: opacity 140ms ease, transform 140ms ease;
|
||||
white-space: normal;
|
||||
}
|
||||
.ref-metric-tip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
left: 50%;
|
||||
top: 100%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 1px solid #dfe5ee;
|
||||
border-bottom: 1px solid #dfe5ee;
|
||||
background: #0f172a;
|
||||
transform: translateY(-4px) rotate(45deg);
|
||||
transform: translate(-50%, -4px) rotate(45deg);
|
||||
}
|
||||
.ref-metric-help:hover .ref-metric-tip,
|
||||
.ref-metric-help:focus-visible .ref-metric-tip {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.ref-quality-value {
|
||||
margin-top: 14px;
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 工单详情「询价需求」格子:按运输方式 + 陆运运输类型展示不同必填项。
|
||||
*
|
||||
* 口径对齐销售侧询价确认卡(inquiry_field_lines):
|
||||
* 空运看件数/重量/体积/包装;海运看货量/整柜拼柜/箱型箱量;
|
||||
* 陆运拼车看体积重量,整车/中港零担看车型数量,中港整车不强制车型。
|
||||
* 只做展示,不改六态、不写主账。
|
||||
*/
|
||||
|
||||
/** 详情格子一行:标签 + 值(空则「-」) */
|
||||
export type DemandField = { label: string; value: string };
|
||||
|
||||
const LAND_FTL_TYPES = new Set(["国内运输整车", "跨境整车", "中港零担/集拼"]);
|
||||
const LAND_CUSTOMS_TYPES = new Set(["跨境集拼", "跨境整车", "中港整车", "中港零担/集拼"]);
|
||||
|
||||
const LAND_LABEL: Record<string, string> = {
|
||||
起运港: "始发站",
|
||||
目的港: "目的地",
|
||||
品名: "货物品名",
|
||||
毛重: "重量(KG)",
|
||||
体积: "体积(CBM)",
|
||||
"车型/数量": "车型/数量",
|
||||
通关口岸: "通关口岸",
|
||||
报价日期: "报价日期",
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON 字符串 / 对象都解开成扁平键值。坏 JSON 当空。
|
||||
* 协同快照里的车型等也要能展示,调用方自己决定合并顺序。
|
||||
*/
|
||||
export function parseFactsMap(raw: unknown): Record<string, string> {
|
||||
if (raw == null) return {};
|
||||
let obj: any = raw;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (val == null) continue;
|
||||
if (typeof val === "object") continue;
|
||||
const text = String(val).trim();
|
||||
if (text) out[key] = text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 询价事实优先,协同只补空键(中港整车车型常在协同里)。 */
|
||||
export function mergeInquiryFacts(ticket: any): Record<string, string> {
|
||||
const inquiry = parseFactsMap(ticket?.factsJson ?? ticket?.facts);
|
||||
const collab = parseFactsMap(ticket?.collabFactsJson ?? ticket?.collabFacts);
|
||||
const out: Record<string, string> = { ...inquiry };
|
||||
for (const [k, v] of Object.entries(collab)) {
|
||||
if (!out[k]) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pick(facts: Record<string, string>, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const v = (facts[key] || "").trim();
|
||||
if (v) return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function dash(v: unknown): string {
|
||||
const s = (v ?? "").toString().trim();
|
||||
return s ? s : "-";
|
||||
}
|
||||
|
||||
function columnText(v: unknown): string {
|
||||
if (v == null || v === "") return "";
|
||||
const s = String(v).trim();
|
||||
if (!s || s === "-") return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
function businessLineOf(ticket: any): "AIR" | "SEA" | "LAND" | "MULTI" | "" {
|
||||
const raw = String(ticket?.businessLine ?? "").trim().toUpperCase();
|
||||
if (raw === "AIR" || raw === "SEA" || raw === "LAND" || raw === "MULTI") return raw;
|
||||
const label = String(ticket?.bizTypeLabel ?? ticket?.bizType ?? "").trim();
|
||||
if (label.includes("空运")) return "AIR";
|
||||
if (label.includes("海运")) return "SEA";
|
||||
if (label.includes("陆运")) return "LAND";
|
||||
if (label.includes("多段") || label.includes("联运")) return "MULTI";
|
||||
return "";
|
||||
}
|
||||
|
||||
function transportModeLabel(ticket: any, line: string): string {
|
||||
const label = String(ticket?.bizTypeLabel ?? "").trim();
|
||||
if (label) return label;
|
||||
if (line === "AIR") return "空运";
|
||||
if (line === "SEA") return "海运";
|
||||
if (line === "LAND") return "陆运";
|
||||
if (line === "MULTI") return "多段联运";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 当前陆运类型要展示的货物内部键。中港整车不要把车型当成询价必填。 */
|
||||
function landCargoKeys(landType: string): string[] {
|
||||
if (landType === "中港整车") {
|
||||
return ["起运港", "目的港", "品名", "报价日期"];
|
||||
}
|
||||
if (LAND_FTL_TYPES.has(landType)) {
|
||||
return ["起运港", "目的港", "品名", "车型/数量", "报价日期"];
|
||||
}
|
||||
return ["起运港", "目的港", "品名", "体积", "毛重", "报价日期"];
|
||||
}
|
||||
|
||||
function push(fields: DemandField[], label: string, value: string) {
|
||||
fields.push({ label, value: dash(value) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单详情询价需求格子。
|
||||
* 列字段(origin / pieces / containerSpec 等)作 facts 空时的兜底,兼容旧单。
|
||||
*/
|
||||
export function buildDemandFields(ticket: any): DemandField[] {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const mode = transportModeLabel(ticket, line);
|
||||
const origin = pick(facts, "起运港", "起运地") || columnText(ticket?.origin);
|
||||
const dest = pick(facts, "目的港", "目的地") || columnText(ticket?.destination);
|
||||
const goods = pick(facts, "品名", "货物品名") || columnText(ticket?.goodsName);
|
||||
const quoteDate =
|
||||
pick(facts, "报价日期") || columnText(ticket?.quoteDate)?.toString().slice(0, 10) || "";
|
||||
const customer = pick(facts, "客户名称") || columnText(ticket?.customerName);
|
||||
const fields: DemandField[] = [];
|
||||
|
||||
push(fields, "运输方式", mode);
|
||||
|
||||
if (line === "SEA") {
|
||||
push(fields, "起运港", origin);
|
||||
push(fields, "目的港", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "货物数量", pick(facts, "货量", "货物数量", "件数") || columnText(ticket?.cargoQty) || columnText(ticket?.pieces));
|
||||
push(fields, "整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
push(fields, "箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
push(fields, "贸易条款", pick(facts, "贸易条款"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else if (line === "LAND") {
|
||||
const landType = pick(facts, "运输类型");
|
||||
push(fields, "运输类型", landType);
|
||||
push(fields, "线路类别", pick(facts, "线路类别"));
|
||||
push(fields, "运输分类", pick(facts, "运输分类"));
|
||||
const cargoVals: Record<string, string> = {
|
||||
起运港: origin,
|
||||
目的港: dest,
|
||||
品名: goods,
|
||||
体积: pick(facts, "体积") || columnText(ticket?.volumeCbm),
|
||||
毛重: pick(facts, "毛重", "重量") || columnText(ticket?.weightKg),
|
||||
"车型/数量": pick(facts, "车型/数量", "车型"),
|
||||
报价日期: quoteDate,
|
||||
};
|
||||
for (const key of landCargoKeys(landType)) {
|
||||
push(fields, LAND_LABEL[key] || key, cargoVals[key] || pick(facts, key));
|
||||
}
|
||||
if (LAND_CUSTOMS_TYPES.has(landType)) {
|
||||
push(fields, "通关口岸", pick(facts, "通关口岸"));
|
||||
}
|
||||
// 中港整车询价不强制车型,协同里有值仍展示,避免后台看不到 53 尺这类原文。
|
||||
if (landType === "中港整车" && pick(facts, "车型/数量", "车型")) {
|
||||
push(fields, "车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
}
|
||||
} else if (line === "AIR") {
|
||||
push(fields, "起运地", origin);
|
||||
push(fields, "目的地", dest);
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
push(fields, "包装类型", pick(facts, "包装方式", "包装类型"));
|
||||
push(fields, "报价日期", quoteDate);
|
||||
} else {
|
||||
// 多段或未识别:至少把路线和货量列出来,避免整块空白。
|
||||
push(fields, "起运 / 目的", origin || dest ? `${origin || "-"} → ${dest || "-"}` : "");
|
||||
push(fields, "品名", goods);
|
||||
push(fields, "件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
push(fields, "重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
push(fields, "体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
const box = pick(facts, "箱型箱量") || columnText(ticket?.containerSpec);
|
||||
if (box) push(fields, "箱型箱量", box);
|
||||
const vehicle = pick(facts, "车型/数量", "车型");
|
||||
if (vehicle) push(fields, "车型/数量", vehicle);
|
||||
}
|
||||
|
||||
push(fields, "客户", customer);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「询价需求」第二行货量:按方式只写该方式关心的量,避免陆运整车全是件数「-」。
|
||||
*/
|
||||
export function quantitySummary(ticket: any): string {
|
||||
const facts = mergeInquiryFacts(ticket);
|
||||
const line = businessLineOf(ticket);
|
||||
const landType = pick(facts, "运输类型");
|
||||
const parts: string[] = [];
|
||||
const add = (label: string, raw: string) => {
|
||||
const v = (raw || "").trim();
|
||||
if (v && v !== "-") parts.push(`${label} ${v}`);
|
||||
};
|
||||
if (line === "SEA") {
|
||||
add("货物数量", pick(facts, "货量", "货物数量") || columnText(ticket?.cargoQty));
|
||||
add("整柜或拼柜", pick(facts, "整柜或拼柜") || columnText(ticket?.fclLcl));
|
||||
add("箱型箱量", pick(facts, "箱型箱量") || columnText(ticket?.containerSpec));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
if (line === "LAND") {
|
||||
if (landType) add("运输类型", landType);
|
||||
if (LAND_FTL_TYPES.has(landType) || (landType === "中港整车" && pick(facts, "车型/数量"))) {
|
||||
add("车型/数量", pick(facts, "车型/数量", "车型"));
|
||||
} else {
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
add("件数", pick(facts, "件数") || columnText(ticket?.pieces));
|
||||
add("重量(KG)", pick(facts, "毛重", "重量") || columnText(ticket?.weightKg));
|
||||
add("体积(CBM)", pick(facts, "体积") || columnText(ticket?.volumeCbm));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -16,9 +16,17 @@ import {
|
||||
import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
import ExpandableClamp from "/@/views/inquiry/components/ExpandableClamp.vue";
|
||||
import {
|
||||
buildDemandFields,
|
||||
quantitySummary,
|
||||
type DemandField,
|
||||
} from "/@/views/inquiry/workOrder/demandFields";
|
||||
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
/** 成本价 / 销售价 / 利润点的一行。最后一行名称是「费用合计」。 */
|
||||
type PriceLine = { name: string; text: string };
|
||||
type TicketQuote = {
|
||||
source: string;
|
||||
price: string;
|
||||
@@ -43,6 +51,9 @@ type TicketRow = {
|
||||
costPrice: string;
|
||||
salePrice: string;
|
||||
profit: string;
|
||||
costLines: PriceLine[];
|
||||
saleLines: PriceLine[];
|
||||
profitLines: PriceLine[];
|
||||
eventException: string;
|
||||
systemException: string;
|
||||
systemExceptionReason: string;
|
||||
@@ -56,6 +67,8 @@ type TicketRow = {
|
||||
weightKg: string;
|
||||
volumeCbm: string;
|
||||
goodsName: string;
|
||||
/** 询价需求格子:按运输方式/运输类型变化 */
|
||||
demandFields: DemandField[];
|
||||
updateTime: string;
|
||||
lostReason: string;
|
||||
nextFollowupAt: string;
|
||||
@@ -102,6 +115,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。
|
||||
@@ -284,7 +308,8 @@ function looksLikeRawFp(v: any) {
|
||||
|
||||
/**
|
||||
* 询价需求摘要:对齐旧后台 requirementSummaryLines。
|
||||
* 首行 = 业务线 · 路线;后续 = 货量 / 品名。结构化字段优先,不信 demandDetail 管道串。
|
||||
* 首行 = 业务线 · 路线;后续 = 该运输方式关心的货量 / 品名。
|
||||
* 陆运整车写车型数量,海运写箱型箱量;不信 demandDetail 管道串。
|
||||
*/
|
||||
function requirementSummaryLines(t: any): string[] {
|
||||
const businessLine = String(t?.businessLine ?? "").trim().toUpperCase();
|
||||
@@ -297,37 +322,11 @@ function requirementSummaryLines(t: any): string[] {
|
||||
const lines: string[] = [];
|
||||
if (heading) lines.push(heading);
|
||||
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const isSea = businessLine === "SEA" || lineLabel.includes("海运");
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const quantities: string[] = [];
|
||||
const pieces = t?.pieces;
|
||||
const weight = t?.weightKg;
|
||||
const volume = t?.volumeCbm;
|
||||
const plain = (v: any) => {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? String(n) : String(v).trim();
|
||||
};
|
||||
if (isAir) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isSea) {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
} else if (isLand) {
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
} else {
|
||||
if (pieces != null && pieces !== "") quantities.push(`件数 ${plain(pieces)}`);
|
||||
if (weight != null && weight !== "") quantities.push(`重量(KG) ${plain(weight)}kg`);
|
||||
if (volume != null && volume !== "") quantities.push(`体积(CBM) ${plain(volume)}CBM`);
|
||||
}
|
||||
if (quantities.length) lines.push(quantities.join(" · "));
|
||||
const qty = quantitySummary(t);
|
||||
if (qty) lines.push(qty);
|
||||
|
||||
const isLand = businessLine === "LAND" || lineLabel.includes("陆运");
|
||||
const isAir = businessLine === "AIR" || lineLabel.includes("空运");
|
||||
const cargo: string[] = [];
|
||||
const goods = String(t?.goodsName ?? "").trim();
|
||||
if (goods) cargo.push(`${isLand ? "货物品名" : "品名"} ${goods}`);
|
||||
@@ -386,6 +385,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),
|
||||
@@ -399,6 +401,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
weightKg: fmtMeasure(t.weightKg, "kg"),
|
||||
volumeCbm: fmtMeasure(t.volumeCbm, "CBM"),
|
||||
goodsName: dash(t.goodsName),
|
||||
demandFields: buildDemandFields(t),
|
||||
updateTime: fmtTime(t.updateTime || t.createTime),
|
||||
lostReason: dash(t.lostReason || t.lost_reason),
|
||||
nextFollowupAt: fmtTime(t.nextFollowupAt || t.next_followup_at),
|
||||
@@ -406,11 +409,8 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
};
|
||||
}
|
||||
|
||||
function routeText(row: TicketRow) {
|
||||
if (row.origin !== "-" || row.destination !== "-") {
|
||||
return `${row.origin === "-" ? "" : row.origin} → ${row.destination === "-" ? "" : row.destination}`.trim();
|
||||
}
|
||||
return row.demandTitle || "-";
|
||||
function quoteHasFees(row: TicketRow) {
|
||||
return (row.quotes || []).some((q) => (q.fees || []).length > 0);
|
||||
}
|
||||
|
||||
/** 列表询价需求第二行起:与旧后台 summary-cell 多行展示一致。 */
|
||||
@@ -651,7 +651,8 @@ function refresh() {
|
||||
<td class="align-top">
|
||||
<span style="font-size: 12px; font-weight: 700">{{ row.initiator }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<td class="align-top col-demand">
|
||||
<ExpandableClamp :content-key="row.id + '-demand'" :max-height="132">
|
||||
<div class="summary-cell">
|
||||
<strong>{{ row.demandTitle }}</strong>
|
||||
<span
|
||||
@@ -660,9 +661,16 @@ function refresh() {
|
||||
>{{ line }}</span
|
||||
>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<template v-if="row.quotes.length">
|
||||
<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"
|
||||
@@ -670,6 +678,10 @@ function refresh() {
|
||||
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"
|
||||
@@ -677,11 +689,9 @@ function refresh() {
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
|
||||
{{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
<span v-else style="color: #94a3b8">-</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
@@ -692,13 +702,43 @@ function refresh() {
|
||||
</td>
|
||||
<td class="align-top"><span class="ticket-status">{{ row.status }}</span></td>
|
||||
<td class="align-top">
|
||||
<span class="ticket-price-cell">{{ row.costPrice }}</span>
|
||||
<div v-if="row.costLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-for="(line, i) in row.costLines"
|
||||
:key="row.id + '-cost-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="ticket-price-cell">{{ row.costPrice }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<span class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
|
||||
<div v-if="row.saleLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-for="(line, i) in row.saleLines"
|
||||
:key="row.id + '-sale-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="ticket-price-cell" style="font-weight: 800">{{ row.salePrice }}</span>
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<span class="ticket-margin-cell">{{ row.profit }}</span>
|
||||
<div v-if="row.profitLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-for="(line, i) in row.profitLines"
|
||||
:key="row.id + '-profit-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="ticket-margin-cell">{{ row.profit }}</span>
|
||||
</td>
|
||||
<td class="col-center">
|
||||
<span v-if="row.eventException !== '-'" class="tag tag-rose">{{ row.eventException }}</span>
|
||||
@@ -804,11 +844,55 @@ function refresh() {
|
||||
<ProtoIcon name="gauge" :size="14" />价格与利润
|
||||
</div>
|
||||
<div class="ticket-detail-grid">
|
||||
<div class="ticket-detail-item"><label>成本价</label><span>{{ current.costPrice }}</span></div>
|
||||
<div class="ticket-detail-item"><label>销售价</label><span>{{ current.salePrice }}</span></div>
|
||||
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
|
||||
<div class="ticket-detail-item">
|
||||
<label>成本价</label>
|
||||
<div v-if="current.costLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-for="(line, i) in current.costLines"
|
||||
:key="'detail-cost-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
</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
|
||||
v-for="(line, i) in current.saleLines"
|
||||
: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>
|
||||
<div class="ticket-detail-item">
|
||||
<label>利润点</label>
|
||||
<div v-if="current.profitLines.length" class="ticket-price-lines">
|
||||
<div
|
||||
v-for="(line, i) in current.profitLines"
|
||||
:key="'detail-profit-' + i"
|
||||
class="ticket-price-line"
|
||||
:class="{ 'is-total': line.name === '费用合计' }"
|
||||
>
|
||||
{{ line.name }}:{{ line.text || "-" }}
|
||||
</div>
|
||||
</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 || '')"
|
||||
@@ -834,28 +918,32 @@ function refresh() {
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</section>
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
<div class="ticket-detail-title">
|
||||
<ProtoIcon name="route" :size="14" />询价需求
|
||||
</div>
|
||||
<div class="ticket-detail-demand">
|
||||
<ExpandableClamp
|
||||
class="ticket-detail-demand"
|
||||
:content-key="current.id + '-detail-demand-' + current.demandFields.length"
|
||||
:max-height="220"
|
||||
>
|
||||
<div class="summary-cell">
|
||||
<strong>{{ current.demandTitle }}</strong>
|
||||
<span v-for="(line, idx) in demandDetailLines(current)" :key="'detail-demand-' + idx">{{ line }}</span>
|
||||
</div>
|
||||
<div class="ticket-detail-grid" style="margin-top: 8px">
|
||||
<div class="ticket-detail-item"><label>业务类型</label><span>{{ dash(current.bizType) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>起运 / 目的</label><span>{{ routeText(current) }}</span></div>
|
||||
<div class="ticket-detail-item"><label>件数</label><span>{{ current.pieces }}</span></div>
|
||||
<div class="ticket-detail-item"><label>重量(KG)</label><span>{{ current.weightKg }}</span></div>
|
||||
<div class="ticket-detail-item"><label>体积(CBM)</label><span>{{ current.volumeCbm }}</span></div>
|
||||
<div class="ticket-detail-item"><label>品名</label><span>{{ current.goodsName }}</span></div>
|
||||
<div class="ticket-detail-item"><label>客户</label><span>{{ current.customerName }}</span></div>
|
||||
<div
|
||||
v-for="(item, idx) in current.demandFields"
|
||||
:key="'demand-field-' + idx + '-' + item.label"
|
||||
class="ticket-detail-item"
|
||||
>
|
||||
<label>{{ item.label }}</label><span>{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ExpandableClamp>
|
||||
</section>
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="app-loading-dots">
|
||||
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
<div class="app-loading-title">友通达询价系统</div>
|
||||
<div class="app-loading-title">友通达智能体运营后台</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { buildDemandFields, quantitySummary } from "/@/views/inquiry/workOrder/demandFields";
|
||||
|
||||
test("land FTL shows vehicle not pieces", () => {
|
||||
const rows = buildDemandFields({
|
||||
businessLine: "LAND",
|
||||
bizTypeLabel: "陆运",
|
||||
origin: "上海",
|
||||
destination: "春疆货场",
|
||||
goodsName: "茶叶",
|
||||
factsJson: JSON.stringify({
|
||||
运输类型: "国内运输整车",
|
||||
线路类别: "国内长途/零担",
|
||||
运输分类: "普件",
|
||||
"车型/数量": "53尺(16.5厢车)",
|
||||
起运港: "上海",
|
||||
目的港: "春疆货场",
|
||||
品名: "茶叶",
|
||||
报价日期: "2026-10-03",
|
||||
}),
|
||||
});
|
||||
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
|
||||
expect(map["运输方式"]).toBe("陆运");
|
||||
expect(map["运输类型"]).toBe("国内运输整车");
|
||||
expect(map["车型/数量"]).toBe("53尺(16.5厢车)");
|
||||
expect(map["始发站"]).toBe("上海");
|
||||
expect(map["货物品名"]).toBe("茶叶");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sea shows container spec", () => {
|
||||
const rows = buildDemandFields({
|
||||
businessLine: "SEA",
|
||||
bizTypeLabel: "海运",
|
||||
factsJson: JSON.stringify({
|
||||
起运港: "上海",
|
||||
目的港: "汉堡",
|
||||
品名: "配件",
|
||||
货量: "1x40HQ",
|
||||
整柜或拼柜: "整柜",
|
||||
箱型箱量: "40HQ*1",
|
||||
贸易条款: "FOB",
|
||||
运输分类: "Port to Port",
|
||||
报价日期: "2026-10-03",
|
||||
}),
|
||||
});
|
||||
const map = Object.fromEntries(rows.map((r) => [r.label, r.value]));
|
||||
expect(map["箱型箱量"]).toBe("40HQ*1");
|
||||
expect(map["整柜或拼柜"]).toBe("整柜");
|
||||
expect(map["件数"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("land LTL quantity summary uses volume", () => {
|
||||
const qty = quantitySummary({
|
||||
businessLine: "LAND",
|
||||
factsJson: JSON.stringify({
|
||||
运输类型: "国内运输拼车",
|
||||
体积: "10CBM",
|
||||
毛重: "50kg",
|
||||
}),
|
||||
});
|
||||
expect(qty).toContain("体积(CBM) 10CBM");
|
||||
expect(qty).not.toContain("车型");
|
||||
});
|
||||
Reference in New Issue
Block a user