Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95e8869a6 | ||
|
|
7c77e45e03 |
@@ -15,8 +15,17 @@ function pickReplyReqId({ fromCaller, fromChat, lastAny }) {
|
||||
return String(lastAny || "").trim();
|
||||
}
|
||||
|
||||
// 含星号的尺寸或箱量。已在行内代码里的整段跳过,避免套两层。
|
||||
const STAR_TOKEN = /`[^`]*`|([A-Za-z0-9.]+(?:\s*\*\s*[A-Za-z0-9.]+)+)/g;
|
||||
|
||||
function showLiteralAsterisks(text) {
|
||||
// 企微 markdown 会吃掉星号:1.1*2.1*2.1 显示成 1.12.12.1。
|
||||
// 放进行内代码后,群里仍是星号。智能体若已经套过,这里不再套第二层。
|
||||
return String(text || "").replace(STAR_TOKEN, (full, token) => (token ? "`" + token + "`" : full));
|
||||
}
|
||||
|
||||
function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) {
|
||||
const text = String(content || "");
|
||||
const text = showLiteralAsterisks(String(content || ""));
|
||||
const markdown = { content: text };
|
||||
if (callbackReqId) {
|
||||
return {
|
||||
@@ -42,4 +51,4 @@ function buildGroupMessageFrame({ chatId, content, callbackReqId, reqId }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { buildGroupMessageFrame, pickReplyReqId };
|
||||
module.exports = { buildGroupMessageFrame, pickReplyReqId, showLiteralAsterisks };
|
||||
|
||||
@@ -24,6 +24,21 @@ test("group reply never uses stream", () => {
|
||||
assert.equal(proactive.body.markdown.feedback, undefined);
|
||||
});
|
||||
|
||||
test("dimension asterisks stay visible in group markdown", () => {
|
||||
const frame = buildGroupMessageFrame({
|
||||
chatId: "wr_1",
|
||||
content: "体积(CBM)(必填):1.1*2.1*2.1",
|
||||
callbackReqId: "",
|
||||
});
|
||||
assert.equal(frame.body.markdown.content, "体积(CBM)(必填):`1.1*2.1*2.1`");
|
||||
const again = buildGroupMessageFrame({
|
||||
chatId: "wr_1",
|
||||
content: frame.body.markdown.content,
|
||||
callbackReqId: "",
|
||||
});
|
||||
assert.equal(again.body.markdown.content, "体积(CBM)(必填):`1.1*2.1*2.1`");
|
||||
});
|
||||
|
||||
test("reply req id falls back to last inbound when chat map misses", () => {
|
||||
assert.equal(pickReplyReqId({ fromCaller: "cb_1", fromChat: "old", lastAny: "any" }), "cb_1");
|
||||
assert.equal(pickReplyReqId({ fromCaller: "", fromChat: "chat_cb", lastAny: "any" }), "chat_cb");
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -18,6 +19,26 @@ from agent.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 含星号的尺寸或箱量,如 1.1*2.1*2.1、40HQ*1。已在行内代码里的不再套。
|
||||
_CODE_OR_STAR = re.compile(r"`[^`]*`|([A-Za-z0-9.]+(?:\s*\*\s*[A-Za-z0-9.]+)+)")
|
||||
|
||||
|
||||
def protect_markdown_asterisks(text: str) -> str:
|
||||
"""
|
||||
协同群正文走企微 markdown。星号会被当成强调,1.1*2.1*2.1 会显示成 1.12.12.1。
|
||||
|
||||
把含星号的一段放进行内代码,群里看到的仍是星号,不会变成灰底以外的别的字。
|
||||
已经套过代码的不再套第二层。不改账上的体积原文,只改这一次出站文字。
|
||||
"""
|
||||
|
||||
def _wrap(match: re.Match[str]) -> str:
|
||||
token = match.group(1)
|
||||
if not token:
|
||||
return match.group(0)
|
||||
return f"`{token}`"
|
||||
|
||||
return _CODE_OR_STAR.sub(_wrap, text or "")
|
||||
|
||||
|
||||
class AibotReplyClient:
|
||||
"""
|
||||
@@ -41,7 +62,7 @@ class AibotReplyClient:
|
||||
mention_userids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
_ = mention_userids
|
||||
text = str(content or "").strip()
|
||||
text = protect_markdown_asterisks(str(content or "").strip())
|
||||
self.sent.append({"chat_id": chat_id, "content": text, "kind": "text"})
|
||||
if not text:
|
||||
return {"ok": False, "error": "empty"}
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") {{
|
||||
ww.closeWindow();
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{
|
||||
if (window.wx && typeof wx.closeWindow === "function") {{
|
||||
wx.closeWindow();
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
try {{ window.close(); }} catch (e) {{}}
|
||||
var closed = false;
|
||||
|
||||
function closeOnce(fn) {{
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
try {{ fn(); }} catch (e) {{}}
|
||||
}}
|
||||
|
||||
// 手机企微有 WeixinJSBridge:只关当前 H5。再调第二套关闭会把询价小助手整个退出,回到企业微信首页。
|
||||
function closeMobile() {{
|
||||
closeOnce(function () {{ WeixinJSBridge.call("closeWindow"); }});
|
||||
}}
|
||||
|
||||
// 电脑企微没有 WeixinJSBridge,注册 JS-SDK 后只关一次。
|
||||
function closeDesktop() {{
|
||||
closeOnce(function () {{
|
||||
if (window.ww && typeof ww.closeWindow === "function") ww.closeWindow();
|
||||
else if (window.wx && typeof wx.closeWindow === "function") wx.closeWindow();
|
||||
}});
|
||||
}}
|
||||
|
||||
function boot() {{
|
||||
if (typeof WeixinJSBridge !== "undefined") {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(closeMobile, {delay});
|
||||
return;
|
||||
}}
|
||||
document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
|
||||
var sawBridge = false;
|
||||
document.addEventListener("WeixinJSBridgeReady", function () {{
|
||||
sawBridge = true;
|
||||
setTimeout(closeMobile, {delay});
|
||||
}}, false);
|
||||
var ready = Promise.resolve();
|
||||
if (window.ww && ww.register && corpId) {{
|
||||
ready = ww.register({{
|
||||
@@ -81,9 +89,17 @@ def wecom_auto_close_script() -> str:
|
||||
}}).catch(function () {{ return null; }});
|
||||
}}
|
||||
ready.then(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(function () {{
|
||||
if (sawBridge || typeof WeixinJSBridge !== "undefined") {{
|
||||
if (!closed) closeMobile();
|
||||
return;
|
||||
}}
|
||||
closeDesktop();
|
||||
}}, {delay});
|
||||
}}).catch(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
setTimeout(function () {{
|
||||
if (!closed && typeof WeixinJSBridge === "undefined") closeDesktop();
|
||||
}}, {delay});
|
||||
}});
|
||||
}}
|
||||
|
||||
|
||||
@@ -184,7 +184,8 @@ def handle_air_group(
|
||||
return "handoff_ignore"
|
||||
from agent.policy.system_exception import reply_paused_ticket, ticket_is_paused
|
||||
|
||||
if ticket_is_paused(ticket):
|
||||
# 锁舱失败留下的暂停只拦还要打 TMS 的操作。成交、未成交和补原因不调用 TMS。
|
||||
if ticket_is_paused(ticket) and not _deal_ignores_tms_pause(ticket, intent):
|
||||
reply_paused_ticket(
|
||||
ticket=ticket,
|
||||
reply=lambda text: send_group_text(group_client_of(engine), chat_id, text),
|
||||
@@ -281,6 +282,21 @@ def _handle_cabin(
|
||||
)
|
||||
|
||||
|
||||
def _deal_ignores_tms_pause(ticket: object, intent: str) -> bool:
|
||||
"""
|
||||
成交跟进不因 TMS 暂停而改口。
|
||||
|
||||
上午锁舱失败会把工单标成暂停。之后群里确认未成交、或补未成交原因,
|
||||
不能再回「TMS接口异常」。锁舱、释放仍走各自的重试。
|
||||
"""
|
||||
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
|
||||
return True
|
||||
view = ticket_view(ticket)
|
||||
status = str(view.get("status") or "").strip()
|
||||
reason = str(view.get("lost_reason") or "").strip()
|
||||
return status == "未成交" and not reason
|
||||
|
||||
|
||||
def _handle_bound(
|
||||
engine,
|
||||
message: InboundMessage,
|
||||
|
||||
@@ -452,6 +452,32 @@ def finish_attach_wave(
|
||||
or get_land_text_flow().session_of(sender_id)
|
||||
)
|
||||
wo = (sess.work_order_no if sess else "") or ""
|
||||
if (
|
||||
not wo
|
||||
and sess
|
||||
and wave
|
||||
and (sess.business_line or "").upper() == "SEA"
|
||||
):
|
||||
# 海运先出核对卡,工单要等确定。附件键先留在书签上。
|
||||
files = []
|
||||
for row in wave.materials:
|
||||
key = row.object_key
|
||||
if not key:
|
||||
continue
|
||||
name = row.filename or ("image.jpg" if row.kind == "image" else "attachment.bin")
|
||||
ctype = "image/jpeg" if row.kind == "image" else "application/octet-stream"
|
||||
files.append(
|
||||
{
|
||||
"file_name": name,
|
||||
"object_key": key,
|
||||
"content_type": ctype,
|
||||
}
|
||||
)
|
||||
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
||||
hit = engine.session_of(sender_id)
|
||||
if hit is sess:
|
||||
engine.remember_pending_files(sender_id, files)
|
||||
break
|
||||
if wo and wave:
|
||||
ledger = None
|
||||
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
||||
|
||||
@@ -385,6 +385,31 @@ def finish_wave(
|
||||
or get_land_text_flow().session_of(sender_id)
|
||||
)
|
||||
wo = (sess.work_order_no if sess else "") or ""
|
||||
if (
|
||||
not wo
|
||||
and sess
|
||||
and wave
|
||||
and (sess.business_line or "").upper() == "SEA"
|
||||
and wave.object_keys
|
||||
):
|
||||
# 海运先出核对卡,工单要等确定。图片键先留在书签上。
|
||||
files = []
|
||||
for i, key in enumerate(wave.object_keys):
|
||||
name = wave.filenames[i] if i < len(wave.filenames) else "image.jpg"
|
||||
if not key:
|
||||
continue
|
||||
files.append(
|
||||
{
|
||||
"file_name": name or "image.jpg",
|
||||
"object_key": key,
|
||||
"content_type": "image/jpeg",
|
||||
}
|
||||
)
|
||||
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
||||
hit = engine.session_of(sender_id)
|
||||
if hit is sess:
|
||||
engine.remember_pending_files(sender_id, files)
|
||||
break
|
||||
if wo and wave:
|
||||
ledger = None
|
||||
for engine in (get_sea_text_flow(), get_air_text_flow(), get_land_text_flow()):
|
||||
|
||||
@@ -216,7 +216,10 @@ class HttpLedger:
|
||||
失败(无关键词且无默认)原样带回销售话术。Java 不转 PDF。
|
||||
"""
|
||||
from agent.quote_templates.match import FAIL_MESSAGE
|
||||
from agent.schema.quote_adjust import stamp_prices_for_sheet
|
||||
|
||||
# 出单前把当前费用写入线路表、海运分段、空运舱位。否则报价单仍是旧价。
|
||||
quote = stamp_prices_for_sheet(dict(quote or {}))
|
||||
# 填表+回文件可能超过默认 10s,单独放宽,仍禁止无限等。
|
||||
prev_timeout = self._client.timeout_seconds
|
||||
self._client.timeout_seconds = max(float(prev_timeout or 10), 45.0)
|
||||
@@ -242,6 +245,10 @@ class HttpLedger:
|
||||
"error": out.get("error") or "NO_TEMPLATE",
|
||||
"message": out.get("message") or FAIL_MESSAGE,
|
||||
}
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
# 主账旧版本仍回 WO_quote.xlsx 时,这里按模板名改成销售看到的名字。
|
||||
out["fileName"] = filename_from_render(work_order_no, out)
|
||||
out.setdefault("needsPdf", str(format).lower() == "pdf")
|
||||
return out
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -352,7 +447,8 @@ def extract_inquiry_snapshot(
|
||||
merged = dict(b_payload.get("facts") or {})
|
||||
merged.update(facts)
|
||||
facts = merged
|
||||
mode = mode or str(b_payload.get("business_line") or "")
|
||||
# 运输方式只认原话里的空运/海运/陆运、1/2/3、飞。
|
||||
# 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。
|
||||
source = "deepseek_b"
|
||||
facts = harvest_oral_measures(text, normalize_facts(facts))
|
||||
facts = _merge_spoken_collab(text, facts)
|
||||
@@ -360,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
|
||||
@@ -452,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()
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ def activate_ticket(
|
||||
把本群当前工单切到指定空运未结案单。
|
||||
|
||||
返回 (工单, phase)。失败时工单为 None。
|
||||
航线已经用附件或文字落过价时,再发工单号只回到成交跟进,不再发空白模板。
|
||||
"""
|
||||
book = flow.ledger
|
||||
no = (work_order_no or "").strip()
|
||||
@@ -156,6 +157,12 @@ def activate_ticket(
|
||||
return None, "handoff_ignore"
|
||||
# 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。
|
||||
_seed_air_activate_collab(flow, ticket)
|
||||
if speak and _air_already_quoted_for_deal(ticket):
|
||||
view = ticket_view(ticket)
|
||||
view["collab_chat_id"] = chat_id
|
||||
emit_group_deal(flow=flow, ticket=view, quote=dict(view.get("quote") or {}))
|
||||
_watch_archive_room(chat_id, no)
|
||||
return ticket, "activated_deal"
|
||||
if speak:
|
||||
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
|
||||
_watch_archive_room(chat_id, no)
|
||||
@@ -165,6 +172,25 @@ def activate_ticket(
|
||||
return ticket, "activated"
|
||||
|
||||
|
||||
def _air_already_quoted_for_deal(ticket: object) -> bool:
|
||||
"""
|
||||
航线报价已经落账,再激活应回成交跟进。
|
||||
|
||||
附件报价会把来源标成「航线附件报价」,但报价编号仍可能留着 TMS。
|
||||
只看 source 会误当成还没报价。还只有 TMS 标准价时仍发模板。
|
||||
"""
|
||||
status = ticket_status(ticket)
|
||||
if status == "协商中":
|
||||
return True
|
||||
if status != "已报价":
|
||||
return False
|
||||
quote = dict(ticket_view(ticket).get("quote") or {})
|
||||
label = str(quote.get("source_label") or quote.get("sourceLabel") or "")
|
||||
if "航线" in label or "产品" in label:
|
||||
return True
|
||||
return copy.quote_is_sales_adjust(quote)
|
||||
|
||||
|
||||
def _collab_slot_empty(val: object) -> bool:
|
||||
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
|
||||
return str(val or "").strip() in {"", "-", "—", "-"}
|
||||
|
||||
@@ -64,6 +64,8 @@ class FlowSession:
|
||||
handoff_offer: str = ""
|
||||
handoff_committed: bool = False
|
||||
virtual_work_order_no: str = ""
|
||||
# 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。
|
||||
pending_files: tuple[dict[str, str], ...] = ()
|
||||
|
||||
|
||||
def is_waiting_mode_session(sess) -> bool:
|
||||
@@ -139,6 +141,15 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||||
handoff_offer=str(raw.get("handoff_offer") or ""),
|
||||
handoff_committed=bool(raw.get("handoff_committed")),
|
||||
virtual_work_order_no=str(raw.get("virtual_work_order_no") or ""),
|
||||
pending_files=tuple(
|
||||
{
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"object_key": str(item.get("object_key") or ""),
|
||||
"content_type": str(item.get("content_type") or ""),
|
||||
}
|
||||
for item in (raw.get("pending_files") or [])
|
||||
if isinstance(item, dict) and str(item.get("object_key") or "").strip()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -174,6 +185,29 @@ class AirTextInquiryFlow:
|
||||
"""空运群 BOT 出站;单测注入假客户端。"""
|
||||
return self._group_client
|
||||
|
||||
def remember_pending_files(self, sender_id: str, files: list[dict[str, str]]) -> None:
|
||||
"""
|
||||
核对卡阶段还没有工单号,先把图片/附件键记在当前书签上。
|
||||
|
||||
确定建单后再挂主账。副作用:覆盖该书签的 pending_files 并持久化。
|
||||
"""
|
||||
sess = self.session_of(sender_id)
|
||||
if sess is None:
|
||||
return
|
||||
cleaned = tuple(
|
||||
{
|
||||
"file_name": str(item.get("file_name") or ""),
|
||||
"object_key": str(item.get("object_key") or ""),
|
||||
"content_type": str(item.get("content_type") or ""),
|
||||
}
|
||||
for item in files
|
||||
if str(item.get("object_key") or "").strip()
|
||||
)
|
||||
if not cleaned:
|
||||
return
|
||||
sess.pending_files = cleaned
|
||||
self._save(sess)
|
||||
|
||||
def session_of(self, sender_id: str) -> Optional[FlowSession]:
|
||||
sid = (sender_id or "").strip()
|
||||
with self._lock:
|
||||
@@ -303,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("该工单已结束,不能再转人工。")
|
||||
@@ -417,6 +572,8 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.handoff_human import inherit_pre_create_ticket, note_clarify_round
|
||||
|
||||
if sess is not None:
|
||||
if not new_sess.pending_files:
|
||||
new_sess.pending_files = tuple(getattr(sess, "pending_files", ()) or ())
|
||||
new_sess.clarify_missing_sig = sess.clarify_missing_sig
|
||||
new_sess.clarify_retry = sess.clarify_retry
|
||||
new_sess.handoff_offer = sess.handoff_offer
|
||||
@@ -550,6 +707,7 @@ class AirTextInquiryFlow:
|
||||
按工单号回到当时停点并重出对应卡片。
|
||||
|
||||
先用进程内该书签;没有再读主账推断节点。不重跑 TMS、不新建工单。
|
||||
正在等未成交原因时,原话记成原因,不再把「请补充未成交原因」重发一遍。
|
||||
"""
|
||||
no = (work_order_no or "").strip().upper()
|
||||
if not no:
|
||||
@@ -566,6 +724,9 @@ class AirTextInquiryFlow:
|
||||
closed = self._close_spoken_deal(keep, text, reply)
|
||||
if closed:
|
||||
return closed
|
||||
taken = self._resume_lost_reason(keep, text, reply)
|
||||
if taken:
|
||||
return taken
|
||||
return self._replay_wait(keep, reply, source="session")
|
||||
getter = getattr(self._ledger, "get_for_agent", None)
|
||||
if not callable(getter):
|
||||
@@ -584,6 +745,9 @@ class AirTextInquiryFlow:
|
||||
closed = self._close_spoken_deal(sess, text, reply)
|
||||
if closed:
|
||||
return closed
|
||||
taken = self._resume_lost_reason(sess, text, reply)
|
||||
if taken:
|
||||
return taken
|
||||
return self._replay_wait(sess, reply, source="ledger")
|
||||
|
||||
def _session_from_ledger(self, *, sender_id: str, view: dict[str, Any]) -> FlowSession:
|
||||
@@ -652,6 +816,26 @@ class AirTextInquiryFlow:
|
||||
logger.info("resume 报价文件已出,回到成交跟进 wo=%s was=%s", sess.work_order_no, sess.phase)
|
||||
self._promote_wait_deal(sess)
|
||||
|
||||
def _resume_lost_reason(self, sess: FlowSession, text: str, reply: ReplyFn) -> str:
|
||||
"""
|
||||
等未成交原因时,续办带来的原话就是原因。
|
||||
|
||||
短句常被判成继续工单。继续工单若只重发提示,原因不会入账。
|
||||
原话只有工单号时仍重发提示,不把工单号当成原因。
|
||||
"""
|
||||
if (sess.phase or "") != "wait_lost_reason":
|
||||
return ""
|
||||
body = (text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
named = copy.extract_work_order_no(body)
|
||||
leftover = body
|
||||
if named:
|
||||
leftover = body.replace(named, "", 1).strip(" ,,。.;;")
|
||||
if not leftover:
|
||||
return ""
|
||||
return self._capture_lost_reason(sess, leftover, reply)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
"""按书签重出当时节点,不改六态。"""
|
||||
logger.info(
|
||||
@@ -1509,7 +1693,15 @@ class AirTextInquiryFlow:
|
||||
if not result.get("ok"):
|
||||
reply(str(result.get("message") or copy.quote_template_missing()))
|
||||
return "wait_adopt"
|
||||
filename = str(result.get("fileName") or f"{sess.work_order_no}_quote.{fmt}")
|
||||
from agent.quote_templates.filename import filename_from_render
|
||||
|
||||
# 私聊直接生成与群里同一套名字:工单号 + 模板名 + 后缀。PDF 用 .pdf,表格用 .xlsx。
|
||||
xlsx_name = filename_from_render(sess.work_order_no, result, suffix=".xlsx")
|
||||
filename = (
|
||||
filename_from_render(sess.work_order_no, result, suffix=".pdf")
|
||||
if kind == "PDF"
|
||||
else xlsx_name
|
||||
)
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
clear_wait(self._ledger, work_order_no=sess.work_order_no)
|
||||
@@ -1519,20 +1711,18 @@ class AirTextInquiryFlow:
|
||||
result,
|
||||
work_order_no=sess.work_order_no,
|
||||
sender_id=sess.sender_id,
|
||||
filename=filename if filename.lower().endswith(".pdf") else f"{sess.work_order_no}_quote.pdf",
|
||||
filename=filename,
|
||||
quote=dict(sess.quote or {}),
|
||||
facts=dict(sess.facts or {}),
|
||||
)
|
||||
if not queued:
|
||||
fallback = str(result.get("fileName") or f"{sess.work_order_no}_quote.xlsx")
|
||||
if not fallback.lower().endswith(".xlsx"):
|
||||
fallback = f"{sess.work_order_no}_quote.xlsx"
|
||||
reply("PDF 转换排队失败,已按后台模板发送 Excel 报价单。")
|
||||
file_extra = self._quote_file_payload(result, filename=xlsx_name)
|
||||
return self._emit_file_then_deal(
|
||||
sess,
|
||||
reply,
|
||||
kind="Excel",
|
||||
filename=fallback,
|
||||
filename=xlsx_name,
|
||||
file_extra=file_extra,
|
||||
)
|
||||
# 已入队:等 Worker 出文件后再出成交卡,这里不提前发卡
|
||||
|
||||
@@ -51,6 +51,9 @@ _AIR_OPTIONAL_CLARIFY = tuple(
|
||||
)
|
||||
AIR_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
AIR_CONFIRM_EXPIRED = "刚才的空运核对已过期,请把空运需求再发一次,出核对卡后再回复确定。"
|
||||
# 海运私聊与空运同一句。核对卡没有工单号,回复确定后才建单查价。
|
||||
SEA_CONFIRM_TAIL = "请回复“确定”进行TMS查价,内容有误时,请重新发内容。"
|
||||
SEA_CONFIRM_EXPIRED = "刚才的海运核对已过期,请把海运需求再发一次,出核对卡后再回复确定。"
|
||||
|
||||
# 海运补问:与合同 10 项一致。报价日期默认当天,不进待补充。
|
||||
_SEA_EXAMPLES = {
|
||||
@@ -451,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")
|
||||
|
||||
|
||||
@@ -461,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()
|
||||
@@ -472,12 +474,34 @@ 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)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def sea_confirm_card(facts: dict[str, str] | None) -> str:
|
||||
"""
|
||||
海运确定查价前的核对卡。没有工单号。
|
||||
|
||||
销售侧必填都列出(货物数量对应内部键货量)。报价日期没说则用已经填上的当天。
|
||||
协同字段不在这张卡上,不挡首次查价。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
lines = ["运输方式(必填):海运"]
|
||||
for key, label in SEA_REQUIRED_FIELDS:
|
||||
if key == "货量":
|
||||
val = (src.get("货量") or src.get("货物数量") or "").strip()
|
||||
else:
|
||||
val = (src.get(key) or "").strip()
|
||||
lines.append(f"{label}(必填):{val}")
|
||||
lines.append("")
|
||||
lines.append(SEA_CONFIRM_TAIL)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ask_clarify(
|
||||
*,
|
||||
facts: dict[str, str] | None = None,
|
||||
@@ -500,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
|
||||
@@ -522,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
|
||||
@@ -541,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)
|
||||
@@ -1032,34 +1060,28 @@ def quote_area_fee_text(
|
||||
return "\n".join(fee_lines)
|
||||
|
||||
|
||||
# 企微引用条过长会被客户端裁成省略号。海运常有 6~8 项费用,必须走文本明细。
|
||||
_QUOTE_AREA_SAFE_CHARS = 80
|
||||
|
||||
|
||||
def quote_area_safe_text(
|
||||
quote: dict[str, Any] | None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
卡片引用条:空运有费用行就只留总价,禁止再塞费用。
|
||||
价格明细卡的引用条只留两行:费用合计、共几项费用。
|
||||
|
||||
三项短费用也会被企微裁成「人工费:CNY 123...」。完整明细走文本。
|
||||
海运/陆运仍按字数:短的原样,超长同样只留总价。
|
||||
空运、海运、陆运都这样。完整路段和逐项费用在上一条文字里。
|
||||
引用条再塞路段名或费用明细,企微会裁成半句。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
|
||||
n = len(quote_fee_lines(src))
|
||||
if _is_land_surface(quote=src, business_line=business_line, transport_mode=transport_mode):
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
block_n = sum(len(fees) for _title, fees, _eta in land_leg_fee_blocks(src))
|
||||
if block_n:
|
||||
n = block_n
|
||||
label = _total_label(src, business_line, transport_mode)
|
||||
line = (business_line or "").upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
is_air = line == "AIR" or mode == "空运"
|
||||
if is_air and n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
full = quote_area_fee_text(quote, business_line=business_line, transport_mode=transport_mode)
|
||||
if len(full) <= _QUOTE_AREA_SAFE_CHARS:
|
||||
return full
|
||||
if n:
|
||||
return f"{label}:{total}\n共{n}项费用,详见上一条明细"
|
||||
return f"{label}:{total}"
|
||||
@@ -1094,8 +1116,13 @@ def sea_quote_detail_text(
|
||||
if route:
|
||||
lines.append(route)
|
||||
if line == "LAND":
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
# 只有一段时,费用前的「始发 → 目的」已经是这条线路。
|
||||
# 再写横杠全程(上海-谅山…)就是同一条路出现两次。多段仍保留全程,方便看中转。
|
||||
blocks = land_leg_fee_blocks(src)
|
||||
land_line = str(src.get("route_line") or "").strip()
|
||||
if land_line:
|
||||
if land_line and len(blocks) != 1:
|
||||
lines.append(land_line)
|
||||
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
|
||||
# 空运、陆运的价格明细正文都要带有效期。海运仍放在按钮卡副标题,避免和原口径打架。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
|
||||
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
|
||||
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
|
||||
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。
|
||||
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路。海运私聊同样是核对后确定再查价。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
"""
|
||||
|
||||
|
||||
@@ -27,13 +27,36 @@ from agent.schema.land_options import (
|
||||
)
|
||||
from agent.schema.sea_options import harvest_sea_class
|
||||
|
||||
# 行首或「段N:」后面的运输方式。排除「空运费」,避免费用词被当成新的一段。
|
||||
_MODE_RE = re.compile(r"(?:段\s*(\d+)\s*[::]\s*)?(空运|海运|陆运)(?!费)")
|
||||
# 已经写了「段1:陆运」时,段内再写「运输方式:海运」不能再切一段。
|
||||
_EXPLICIT_SEG = re.compile(r"段\s*(\d+)\s*[::]\s*(空运|海运|陆运)(?!费)")
|
||||
# 「段1:」或「第一段」。第组是阿拉伯数字,第二组是「一」或单个数字。运输方式在第三组。
|
||||
_SEG_PREFIX = (
|
||||
r"(?:段\s*(\d+)\s*[::]\s*|第\s*([一二三四五六七八九]|\d+)\s*段\s*[::,,]?\s*)"
|
||||
)
|
||||
# 行首或段标后面的运输方式。排除「空运费」,避免费用词被当成新的一段。
|
||||
_MODE_RE = re.compile(rf"(?:{_SEG_PREFIX})?(空运|海运|陆运)(?!费)")
|
||||
# 已经写了「段1:陆运」或「第一段陆运」时,段内再写「运输方式:海运」不能再切一段。
|
||||
_EXPLICIT_SEG = re.compile(rf"{_SEG_PREFIX}(空运|海运|陆运)(?!费)")
|
||||
# 单段标签行没覆盖的陆运销售叫法。内部键仍交给字段校验去归一。
|
||||
_EXTRA_LABEL = re.compile(r"^(始发站|货物品名|运输类型|线路类别)\s*[::]\s*(.+)$")
|
||||
_SEG_MARK = re.compile(r"段\s*(\d+)")
|
||||
_SEG_MARK = re.compile(r"第\s*([一二三四五六七八九]|\d+)\s*段|段\s*(\d+)")
|
||||
_CN_INDEX = {
|
||||
"一": 1,
|
||||
"二": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
}
|
||||
# 段标前面写一次、两段都要的货物。地名不在这里,避免把北京写进空运起运地。
|
||||
_SHARED_CARGO_KEYS = ("品名", "件数", "毛重", "体积", "包装方式")
|
||||
_SHARED_NOISE = re.compile(
|
||||
r"\d+(?:\.\d+)?(?:\s*[*xX×]\s*\d+(?:\.\d+)?){1,2}"
|
||||
r"|\d+(?:\.\d+)?\s*(?:kgs|kg|公斤|千克|cbm|立方米|立方|m3|m³|方|件|pcs)\b"
|
||||
r"|纸箱|木箱|托盘|卡板|散货",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_MODE_BY_WORD = {"空运": "AIR", "海运": "SEA", "陆运": "LAND"}
|
||||
_WORD_BY_MODE = {"AIR": "空运", "SEA": "海运", "LAND": "陆运"}
|
||||
@@ -85,7 +108,8 @@ def split_segments(text: str) -> SplitOutcome:
|
||||
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
|
||||
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
|
||||
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
|
||||
每段正文只收「字段名:值」,不猜没写标签的港口。
|
||||
「第一段陆运」和「段1:陆运」是同一种段标。
|
||||
段标前面单独写的品名、件数、重量、体积,每一段都收下。不猜没写标签的港口。
|
||||
"""
|
||||
raw = text or ""
|
||||
explicit = list(_EXPLICIT_SEG.finditer(raw))
|
||||
@@ -93,31 +117,91 @@ def split_segments(text: str) -> SplitOutcome:
|
||||
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
|
||||
if len(hits) < 2:
|
||||
return SplitOutcome()
|
||||
modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits]
|
||||
modes = [_MODE_BY_WORD[hit.group(3)] for hit in hits]
|
||||
unique = set(modes)
|
||||
if len(modes) != len(unique):
|
||||
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
|
||||
if len(explicit) < 2 and len(unique) == 1:
|
||||
return SplitOutcome()
|
||||
return SplitOutcome(same_mode=True)
|
||||
shared = _shared_cargo_facts(raw[: hits[0].start()])
|
||||
lead = raw[: hits[0].start()].strip()
|
||||
segments: list[Segment] = []
|
||||
for i, hit in enumerate(hits):
|
||||
end = hits[i + 1].start() if i + 1 < len(hits) else len(raw)
|
||||
body = raw[hit.end() : end]
|
||||
explicit = hit.group(1)
|
||||
index = int(explicit) if explicit else i + 1
|
||||
mode = modes[i]
|
||||
# 抽取要看得到段前那句货物,否则「茶叶,11kg」写在第一段前面会被丢掉。
|
||||
source = f"{lead}\n{body}".strip() if lead else body
|
||||
segments.append(
|
||||
Segment(
|
||||
index=index,
|
||||
mode=modes[i],
|
||||
facts=_facts_from_body(body, modes[i]),
|
||||
source=body,
|
||||
index=_segment_index(hit, i + 1),
|
||||
mode=mode,
|
||||
facts=_fill_blank(_facts_from_body(body, mode), shared),
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
segments.sort(key=lambda seg: seg.index)
|
||||
return SplitOutcome(ok=True, segments=segments)
|
||||
|
||||
|
||||
def _segment_index(hit: re.Match[str], fallback: int) -> int:
|
||||
"""段标上的序号。没有段标就按出现顺序,从 1 起。"""
|
||||
token = hit.group(1) or hit.group(2) or ""
|
||||
if token in _CN_INDEX:
|
||||
return _CN_INDEX[token]
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
return fallback
|
||||
|
||||
|
||||
def _shared_cargo_facts(header: str) -> dict[str, str]:
|
||||
"""
|
||||
段标前面写一次的货物。
|
||||
|
||||
件数、重量、体积、包装用口语机读。尺寸 1.1*2.1*2.2 保持原文,不乘成立方。
|
||||
去掉这些之后只剩一个词,就当品名。带「到」的地名不当品名。
|
||||
只返回货物键,不把起运地抄到每一段。
|
||||
"""
|
||||
raw = (header or "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
measured = harvest_oral_measures(raw, {})
|
||||
name = _cargo_name_before_segments(raw)
|
||||
if name and not str(measured.get("品名") or "").strip():
|
||||
measured["品名"] = name
|
||||
return {
|
||||
key: str(measured.get(key) or "").strip()
|
||||
for key in _SHARED_CARGO_KEYS
|
||||
if str(measured.get(key) or "").strip()
|
||||
}
|
||||
|
||||
|
||||
def _cargo_name_before_segments(raw: str) -> str:
|
||||
"""段前货物句里,去掉件数重量体积包装后剩下的那一个品名。"""
|
||||
cleaned = _SHARED_NOISE.sub(" ", raw or "")
|
||||
parts: list[str] = []
|
||||
for part in re.split(r"[,,、;;\n]+", cleaned):
|
||||
token = part.strip(" ::。.")
|
||||
if not token or re.fullmatch(r"[\d.\s*xX×]+", token):
|
||||
continue
|
||||
if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")):
|
||||
continue
|
||||
parts.append(token)
|
||||
if len(parts) == 1 and len(parts[0]) <= 30:
|
||||
return parts[0]
|
||||
return ""
|
||||
|
||||
|
||||
def _fill_blank(base: dict[str, str], extra: dict[str, str]) -> dict[str, str]:
|
||||
"""只补还空着的键。段内已经写了的品名、港口不被段前那句盖掉。"""
|
||||
out = dict(base)
|
||||
for key, val in extra.items():
|
||||
if str(val or "").strip() and not str(out.get(key) or "").strip():
|
||||
out[key] = str(val).strip()
|
||||
return out
|
||||
|
||||
|
||||
def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""
|
||||
一段是否齐了询价必填。陆运还要三项选项,模板码站点当没填。
|
||||
@@ -513,6 +597,14 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]:
|
||||
return facts
|
||||
|
||||
|
||||
def _mark_index(mark: re.Match[str]) -> int:
|
||||
"""「段1」或「第一段」的序号。调用前已经匹配到段标。"""
|
||||
token = mark.group(1) or mark.group(2) or ""
|
||||
if token in _CN_INDEX:
|
||||
return _CN_INDEX[token]
|
||||
return int(token)
|
||||
|
||||
|
||||
def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
|
||||
"""
|
||||
补充消息里「段1」「段2」后面的内容。
|
||||
@@ -533,5 +625,5 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
|
||||
facts = harvest_sea_class(body, facts)
|
||||
facts = harvest_oral_measures(body, facts)
|
||||
if facts:
|
||||
out[int(mark.group(1))] = facts
|
||||
out[_mark_index(mark)] = facts
|
||||
return out
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1608,7 +1613,9 @@ def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None
|
||||
out["total"] = total
|
||||
if currency:
|
||||
out["currency"] = currency
|
||||
return out
|
||||
from agent.schema.quote_adjust import stamp_prices_for_sheet
|
||||
|
||||
return stamp_prices_for_sheet(out)
|
||||
|
||||
|
||||
def _save_product_quote(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
海运文字询价确定性流程。
|
||||
|
||||
本文件职责:海运七项补问 → 建单出确认卡 → 主账查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
本文件职责:海运补问 → 无工单号核对卡 → 回复确定后建单查 TMS → 有价/无价卡 → 跳过协同或拉群。
|
||||
空运 Owner 仍在 air_text_flow,禁止把海运再堆回去。
|
||||
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
|
||||
禁止:直连 TMS;本地拆箱型;用户文字冒充群报价版本。
|
||||
@@ -108,6 +108,21 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
adjusted = try_private_adjust(self, sess, text, reply)
|
||||
if adjusted:
|
||||
return adjusted
|
||||
from agent.schema.land_options import looks_like_confirm
|
||||
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("sea.confirm sender=%s", sender_id)
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
text=sess.immutable_text or text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
if not sess and looks_like_confirm(text):
|
||||
reply(copy.SEA_CONFIRM_EXPIRED)
|
||||
return "confirm_expired"
|
||||
# 上一单已出号:新一轮从本句重收,禁止把旧贸易条款/分类带进下一张工单。
|
||||
sess = self._detach_ticketed_for_new_inquiry(sess)
|
||||
|
||||
@@ -132,7 +147,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
@@ -205,7 +220,8 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
# 整句只回「港到港」是贸易条款举例,不要改写成运输分类。
|
||||
class_source = "" if short_sea_trade_reply(source) == "港到港" else source
|
||||
facts = harvest_sea_class(class_source, facts)
|
||||
# 贸易条款标签行原样优先;整句回复门到门/港到港要收下;没写这两个词时模型冒充的门到门丢掉。
|
||||
# 贸易条款标签行原样优先;整句回复或长句里写了门到门/港到港都要收下。
|
||||
# 原话没出现这两个词时,模型冒充的门到门、港到港继续丢掉。
|
||||
facts = apply_spoken_sea_trade_reply(source, facts)
|
||||
spoken = harvest_spoken_trade_term(source)
|
||||
if spoken.get("贸易条款") and not str(facts.get("贸易条款") or "").strip():
|
||||
@@ -243,13 +259,109 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
),
|
||||
)
|
||||
|
||||
return self._create_and_quote(
|
||||
return self._hold_for_confirm(
|
||||
sender_id=sender_id,
|
||||
facts=facts,
|
||||
text=sess.immutable_text if sess and sess.immutable_text else text,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
)
|
||||
|
||||
def _hold_for_confirm(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: Optional[FlowSession],
|
||||
) -> str:
|
||||
"""必填已齐:出海运核对卡,等确定再建单。核对卡没有工单号。"""
|
||||
reply(copy.sea_confirm_card(facts))
|
||||
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="wait_confirm",
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
pending_files=pending,
|
||||
)
|
||||
)
|
||||
return "wait_confirm"
|
||||
|
||||
def _confirm_or_hold(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
facts: dict[str, str],
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
sess: FlowSession,
|
||||
) -> str:
|
||||
"""确定意图:字段仍齐则建单查价。缺了就回到补问,不建单。"""
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
tms_ready = assemble_sea_query(check["facts"])
|
||||
ready_facts = dict(tms_ready.get("facts") or facts)
|
||||
if not check["ok"] or not tms_ready.get("ok"):
|
||||
keys = list(check["missing"] or [])
|
||||
for key in tms_ready.get("missing") or []:
|
||||
if key not in keys:
|
||||
keys.append(key)
|
||||
new_sess = FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="clarify",
|
||||
business_line="SEA",
|
||||
facts=ready_facts,
|
||||
immutable_text=sess.immutable_text or text,
|
||||
wait_version=sess.wait_version + 1,
|
||||
)
|
||||
return self._clarify_or_handoff(
|
||||
sess=sess,
|
||||
keys=keys,
|
||||
new_sess=new_sess,
|
||||
reply=reply,
|
||||
clarify_text=copy.ask_clarify(
|
||||
facts=new_sess.facts,
|
||||
missing_keys=keys,
|
||||
transport_mode="海运",
|
||||
),
|
||||
)
|
||||
return self._create_and_quote(
|
||||
sender_id=sender_id,
|
||||
facts=dict(tms_ready["facts"]),
|
||||
text=text,
|
||||
reply=reply,
|
||||
)
|
||||
|
||||
def _flush_pending_files(self, sender_id: str, work_order_no: str) -> None:
|
||||
"""确定建单后,把核对前收下的图片/附件挂到这张工单。挂完即清,避免重试重复挂。"""
|
||||
sess = self.session_of(sender_id)
|
||||
files = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
if not files or not (work_order_no or "").strip():
|
||||
return
|
||||
for row in files:
|
||||
key = str(row.get("object_key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
try:
|
||||
self._ledger.attach_file(
|
||||
work_order_no=work_order_no,
|
||||
file_name=str(row.get("file_name") or "") or "attachment.bin",
|
||||
object_key=key,
|
||||
content_type=str(row.get("content_type") or "") or "application/octet-stream",
|
||||
sender_id=sender_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("海运核对后挂附件失败 wo=%s", work_order_no)
|
||||
if sess is not None:
|
||||
sess.pending_files = ()
|
||||
self._save(sess)
|
||||
|
||||
def _create_and_quote(
|
||||
self,
|
||||
*,
|
||||
@@ -266,6 +378,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
old = self.session_of(sender_id)
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
@@ -274,6 +387,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
business_line="SEA",
|
||||
facts=facts,
|
||||
immutable_text=text,
|
||||
pending_files=pending,
|
||||
)
|
||||
)
|
||||
created = self._ledger.create_ticket(
|
||||
@@ -286,6 +400,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if not no:
|
||||
reply("建单失败,请稍后把刚才那条再发一次。海运字段已经记下。")
|
||||
return "ledger_fail"
|
||||
self._flush_pending_files(sender_id, no)
|
||||
first, history_wo = self._resolve_similar(created, facts=facts, current_no=no)
|
||||
out_id = reply(
|
||||
copy.inquiry_card(
|
||||
@@ -483,7 +598,14 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
)
|
||||
|
||||
def _replay_wait(self, sess: FlowSession, reply: ReplyFn, *, source: str) -> str:
|
||||
if sess.business_line == "SEA" and sess.phase == "wait_collab":
|
||||
"""
|
||||
海运、陆运停在拉群问句时,继续工单必须把同一张卡拉回来。
|
||||
|
||||
空运父类的无价卡没有「拉产品进群」按钮。陆运以前被当成空运重放,
|
||||
销售继续工单就看不到这张卡。
|
||||
"""
|
||||
line = (sess.business_line or "").upper()
|
||||
if line in {"SEA", "LAND"} and sess.phase == "wait_collab":
|
||||
self._emit_sea_hit(
|
||||
reply,
|
||||
work_order_no=sess.work_order_no,
|
||||
@@ -491,10 +613,10 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
first_or_same=sess.first_or_same,
|
||||
history_work_order_no=sess.history_work_order_no,
|
||||
status=sess.status or "已报价",
|
||||
business_line=sess.business_line or "SEA",
|
||||
business_line=line,
|
||||
)
|
||||
return "wait_collab"
|
||||
if sess.business_line == "SEA" and sess.phase == "tms_miss":
|
||||
if line in {"SEA", "LAND"} and sess.phase == "tms_miss":
|
||||
self._emit_tms_miss(reply, sess.work_order_no)
|
||||
return "tms_miss"
|
||||
return super()._replay_wait(sess, reply, source=source)
|
||||
|
||||
@@ -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()
|
||||
@@ -103,7 +103,7 @@ _SYSTEM = (
|
||||
"【新询价】intent=ordinary_text_inquiry,action_type=ORDINARY_TEXT_INQUIRY。"
|
||||
"原话在描述新的运输需求(港口/机场、货物、件数重量体积、空运海运陆运等),"
|
||||
"即使偶尔拿旧单号当参考,也走新询价,work_order_no 留空。"
|
||||
"陆运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
|
||||
"陆运或海运核对卡之后回复确定、确认、查价、可以查了,仍是新询价,禁止标闲聊。"
|
||||
"机器人正在补问缺项时,销售只回件数、品名、重量、体积等短答(如 111、3件),仍是新询价续填,禁止标闲聊。"
|
||||
"【调价】intent=quote_adjust,action_type=QUOTE_ADJUST。"
|
||||
"inbound_facts 里 session_quoted=true 时,销售用自然语言改当前已有报价:"
|
||||
|
||||
@@ -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:
|
||||
@@ -387,6 +411,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
|
||||
hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw)
|
||||
if hit:
|
||||
merged["体积"] = hit.group(1)
|
||||
else:
|
||||
# 尺寸式不换算成立方,原文留给报价单;TMS 出站再乘。
|
||||
from agent.schema.tms_air_query import dimension_volume_text
|
||||
|
||||
dim = dimension_volume_text(raw)
|
||||
if dim:
|
||||
merged["体积"] = dim
|
||||
pack = _harvest_packaging(raw)
|
||||
if pack:
|
||||
merged["包装方式"] = pack
|
||||
|
||||
@@ -659,6 +659,246 @@ def _mark_sales_adjust(quote: dict[str, Any]) -> None:
|
||||
quote["source_label"] = "销售调价"
|
||||
|
||||
|
||||
def _sheet_follows_adjusted_fees(quote: dict[str, Any]) -> bool:
|
||||
"""
|
||||
产品、航线、销售改过的价,报价单必须跟着这份走。
|
||||
|
||||
航线只是确认沿用 TMS 时不改写舱位明细,避免把附加费之和当成总价。
|
||||
"""
|
||||
label = str(quote.get("source_label") or quote.get("sourceLabel") or "").replace(" ", "")
|
||||
if "确认TMS" in label:
|
||||
return False
|
||||
code = str(quote.get("source") or "").strip().upper()
|
||||
if code == "SALES_ADJUST" or "销售调价" in label:
|
||||
return True
|
||||
if code == "PRODUCT":
|
||||
return True
|
||||
return any(word in label for word in ("产品", "航线", "手工"))
|
||||
|
||||
|
||||
def _json_struct(value: Any) -> tuple[Any, bool]:
|
||||
"""字符串 JSON 先解开。返回 (对象, 原来是不是字符串)。"""
|
||||
if isinstance(value, str) and value.strip()[:1] in {"{", "["}:
|
||||
try:
|
||||
return json.loads(value), True
|
||||
except json.JSONDecodeError:
|
||||
return value, False
|
||||
return value, False
|
||||
|
||||
|
||||
def _put_struct(quote: dict[str, Any], key: str, value: Any, was_str: bool) -> None:
|
||||
quote[key] = json.dumps(value, ensure_ascii=False) if was_str else value
|
||||
|
||||
|
||||
def _fee_title(item: dict[str, Any]) -> str:
|
||||
return str(
|
||||
item.get("name") or item.get("feeName") or item.get("chargeName") or item.get("label") or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _write_price(item: dict[str, Any], plain: str) -> None:
|
||||
"""同一笔钱写进报价单会读的金额字段。单价有值也一起改,否则模板仍按旧单价重算。"""
|
||||
if not plain:
|
||||
return
|
||||
for key in (
|
||||
"amount",
|
||||
"costPrice",
|
||||
"cost_price",
|
||||
"salePrice",
|
||||
"sale_price",
|
||||
"unitPrice",
|
||||
"unit_price",
|
||||
"value",
|
||||
"price",
|
||||
):
|
||||
if item.get(key) not in (None, ""):
|
||||
item[key] = plain
|
||||
item["amount"] = plain
|
||||
if item.get("costPrice") not in (None, "") or item.get("cost_price") not in (None, "") or "costPrice" in item:
|
||||
item["costPrice"] = plain
|
||||
|
||||
|
||||
def _match_fee_index(name: str, fees: list[dict[str, Any]], used: set[int]) -> int:
|
||||
return next(
|
||||
(
|
||||
i
|
||||
for i, fee in enumerate(fees)
|
||||
if i not in used and fee_name_same(name, _fee_title(fee))
|
||||
),
|
||||
-1,
|
||||
)
|
||||
|
||||
|
||||
def _stamp_fee_items(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
|
||||
raw, was_str = _json_struct(quote.get("feeItems"))
|
||||
items = [dict(x) for x in raw if isinstance(x, dict)] if isinstance(raw, list) else []
|
||||
if not items:
|
||||
items = [
|
||||
{
|
||||
"name": _fee_title(fee),
|
||||
"feeName": _fee_title(fee),
|
||||
"amount": _plain_amount(str(fee.get("amount") or "")),
|
||||
"currency": fee.get("currency") or quote.get("currency") or "",
|
||||
}
|
||||
for fee in fees
|
||||
if _fee_title(fee)
|
||||
]
|
||||
else:
|
||||
used: set[int] = set()
|
||||
for item in items:
|
||||
idx = _match_fee_index(_fee_title(item), fees, used)
|
||||
if idx < 0:
|
||||
continue
|
||||
used.add(idx)
|
||||
_write_price(item, _plain_amount(str(fees[idx].get("amount") or "")))
|
||||
if not item.get("name"):
|
||||
item["name"] = _fee_title(fees[idx])
|
||||
if not item.get("feeName"):
|
||||
item["feeName"] = _fee_title(fees[idx])
|
||||
if items:
|
||||
_put_struct(quote, "feeItems", items, was_str)
|
||||
|
||||
|
||||
def _stamp_one_route(row: dict[str, Any], fees: list[dict[str, Any]]) -> None:
|
||||
"""一段线路:运输费写金额,提货/送货写到对应列。"""
|
||||
if len(fees) == 1:
|
||||
_write_price(row, _plain_amount(str(fees[0].get("amount") or "")))
|
||||
return
|
||||
transport = [fee for fee in fees if any(word in _fee_title(fee) for word in ("运输", "运费", "陆运"))]
|
||||
if len(transport) == 1:
|
||||
_write_price(row, _plain_amount(str(transport[0].get("amount") or "")))
|
||||
for fee in fees:
|
||||
title = _fee_title(fee)
|
||||
plain = _plain_amount(str(fee.get("amount") or ""))
|
||||
if not plain:
|
||||
continue
|
||||
if "提货" in title:
|
||||
row["pickup_fee"] = plain
|
||||
row["pickupFee"] = plain
|
||||
elif "送货" in title or "派送" in title:
|
||||
row["delivery_fee"] = plain
|
||||
row["deliveryFee"] = plain
|
||||
|
||||
|
||||
def _stamp_route_list(rows: list[Any], fees: list[dict[str, Any]]) -> None:
|
||||
legs = [row for row in rows if isinstance(row, dict)]
|
||||
if not legs or not fees:
|
||||
return
|
||||
if len(legs) == len(fees):
|
||||
for leg, fee in zip(legs, fees):
|
||||
_write_price(leg, _plain_amount(str(fee.get("amount") or "")))
|
||||
return
|
||||
if len(legs) == 1:
|
||||
_stamp_one_route(legs[0], fees)
|
||||
|
||||
|
||||
def _stamp_route_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
|
||||
"""陆运报价单优先读 route_rows,不读已经改过的 fee_lines。"""
|
||||
for key in ("route_rows", "landSegments", "land_segments"):
|
||||
raw, was_str = _json_struct(quote.get(key))
|
||||
if not isinstance(raw, list) or not raw:
|
||||
continue
|
||||
copied = [dict(x) if isinstance(x, dict) else x for x in raw]
|
||||
_stamp_route_list(copied, fees)
|
||||
_put_struct(quote, key, copied, was_str)
|
||||
for key in ("selected_land_route", "selectedLandRoute"):
|
||||
raw, was_str = _json_struct(quote.get(key))
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
selected = dict(raw)
|
||||
legs = [dict(x) if isinstance(x, dict) else x for x in list(selected.get("legs") or [])]
|
||||
if not legs:
|
||||
continue
|
||||
_stamp_route_list(legs, fees)
|
||||
selected["legs"] = legs
|
||||
_put_struct(quote, key, selected, was_str)
|
||||
|
||||
|
||||
def _stamp_buckets(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
|
||||
"""海运报价单按始发港/海运/目的港三段读,不能只改扁平费用行。"""
|
||||
for key, _label in _SEA_BUCKETS:
|
||||
items = _bucket_items(quote, key)
|
||||
if not items:
|
||||
continue
|
||||
used: set[int] = set()
|
||||
changed = False
|
||||
for item in items:
|
||||
idx = _match_fee_index(_item_fee_name(item), fees, used)
|
||||
if idx < 0:
|
||||
continue
|
||||
used.add(idx)
|
||||
plain = _plain_amount(str(fees[idx].get("amount") or ""))
|
||||
if not plain:
|
||||
continue
|
||||
_write_price(item, plain)
|
||||
changed = True
|
||||
if changed:
|
||||
_store_bucket(quote, key, items)
|
||||
|
||||
|
||||
def _stamp_air_copies(quote: dict[str, Any], fees: list[dict[str, Any]]) -> None:
|
||||
"""空运报价单读舱位 departureCharges 和 salePrice,不读改过的 fee_lines。"""
|
||||
total_plain = _plain_amount(str(quote.get("total") or ""))
|
||||
for key in ("airOptions", "air_options", "selected_air_option", "selectedAirOption"):
|
||||
raw, was_str = _json_struct(quote.get(key))
|
||||
single = isinstance(raw, dict)
|
||||
options = [dict(raw)] if single else [dict(x) if isinstance(x, dict) else x for x in raw] if isinstance(raw, list) else []
|
||||
if not options:
|
||||
continue
|
||||
for opt in options:
|
||||
if not isinstance(opt, dict):
|
||||
continue
|
||||
charge_key = "departureCharges" if "departureCharges" in opt or "departure_charges" not in opt else "departure_charges"
|
||||
if charge_key not in opt and "departureCharges" not in opt and "departure_charges" not in opt:
|
||||
charge_key = ""
|
||||
if charge_key:
|
||||
charges, charges_str = _json_struct(opt.get(charge_key))
|
||||
if isinstance(charges, list) and charges:
|
||||
used: set[int] = set()
|
||||
copied = [dict(x) if isinstance(x, dict) else x for x in charges]
|
||||
for charge in copied:
|
||||
if not isinstance(charge, dict):
|
||||
continue
|
||||
idx = _match_fee_index(_fee_title(charge), fees, used)
|
||||
if idx < 0:
|
||||
continue
|
||||
used.add(idx)
|
||||
_write_price(charge, _plain_amount(str(fees[idx].get("amount") or "")))
|
||||
opt[charge_key] = json.dumps(copied, ensure_ascii=False) if charges_str else copied
|
||||
if total_plain:
|
||||
if opt.get("salePrice") not in (None, ""):
|
||||
opt["salePrice"] = total_plain
|
||||
if opt.get("sale_price") not in (None, ""):
|
||||
opt["sale_price"] = total_plain
|
||||
if single:
|
||||
_put_struct(quote, key, options[0], was_str)
|
||||
else:
|
||||
_put_struct(quote, key, options, was_str)
|
||||
|
||||
|
||||
def stamp_prices_for_sheet(quote: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
把当前生效费用写进报价单实际读取的每一份副本。
|
||||
|
||||
销售调价、产品报价、航线报价都会改 fee_lines,但陆运单读 route_rows,
|
||||
海运单读三段费用,空运单读舱位 departureCharges。这些副本不跟着改,
|
||||
报价单上就还是 TMS 原价。TMS 原价和「确认沿用 TMS」不改写这些副本。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
if not _sheet_follows_adjusted_fees(src):
|
||||
return src
|
||||
fees = [dict(row) for row in quote_fee_rows(src)]
|
||||
if not fees:
|
||||
return src
|
||||
src["fee_lines"] = fees
|
||||
src["fee_rows"] = fees
|
||||
_stamp_fee_items(src, fees)
|
||||
_stamp_route_copies(src, fees)
|
||||
_stamp_buckets(src, fees)
|
||||
_stamp_air_copies(src, fees)
|
||||
return src
|
||||
|
||||
|
||||
def compute_profit(*, x: Optional[float], baseline: str) -> Optional[float]:
|
||||
"""
|
||||
只在销售调价后算一个利润点。
|
||||
@@ -975,6 +1215,7 @@ def apply_adjust(
|
||||
if tms_sale and "tms_baseline_sale" not in out:
|
||||
out["tms_baseline_sale"] = tms_sale
|
||||
_mark_sales_adjust(out)
|
||||
out = stamp_prices_for_sheet(out)
|
||||
logger.info(
|
||||
"quote_adjust applied=%s unmatched=%s hint=%s profit=%s",
|
||||
[x.get("name") for x in applied],
|
||||
|
||||
@@ -144,7 +144,7 @@ def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> d
|
||||
return merged
|
||||
|
||||
|
||||
# 补问举例让销售直接回这两个词。整句只有其中一个才记成贸易条款,长句里的港到港仍给运输分类。
|
||||
# 补问举例让销售直接回这两个词。整句只有其中一个,或长句里写了其中一个,都记成贸易条款。
|
||||
_SHORT_SEA_TRADE_REPLY = re.compile(
|
||||
r"^\s*(门到门|港到港|door\s*to\s*door|door-to-door|d2d)\s*[.。!!??]?\s*$",
|
||||
re.IGNORECASE,
|
||||
@@ -196,17 +196,54 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
|
||||
return merged
|
||||
|
||||
|
||||
def _spoken_door_in_text(text: str) -> str:
|
||||
"""
|
||||
长句里写了「门到门」或「港到港」就是贸易条款。
|
||||
|
||||
举例行「(如:门到门、港到港)」先丢掉,不能当成已填。
|
||||
原话没有这两个词时返回空,禁止把模型猜的条款写回去。
|
||||
同一句里两个词都有时,取靠后的那个。
|
||||
「Port to Port」仍是运输分类,不在这里改写成贸易条款。
|
||||
「广州港到巴生港」不含「港到港」这三个连续字,不能误收。
|
||||
"""
|
||||
cleaned = _without_clarify_examples(text or "")
|
||||
found: list[tuple[int, str]] = []
|
||||
for word in ("门到门", "港到港"):
|
||||
idx = cleaned.rfind(word)
|
||||
if idx >= 0:
|
||||
found.append((idx, word))
|
||||
door = re.search(r"door\s*[- ]?to\s*[- ]?door", cleaned, re.I)
|
||||
if door:
|
||||
found.append((door.start(), "门到门"))
|
||||
if not found:
|
||||
return ""
|
||||
found.sort(key=lambda item: item[0])
|
||||
return found[-1][1]
|
||||
|
||||
|
||||
def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
先丢掉模型冒充的门到门,再收下销售整句回复的门到门或港到港。
|
||||
先丢掉模型冒充的门到门,再按原话收回贸易条款。
|
||||
|
||||
测服 2026-10-03:补问举例是门到门、港到港,销售回「门到门」仍被当成没填,并提示参数提取不全。
|
||||
原话没出现这两个词时,旧草稿里的门到门继续丢掉。返回新 dict。
|
||||
整句只回「门到门」或「港到港」要收下。长句里写了其中一个(例如先报货、再回「2」选海运)
|
||||
也是贸易条款,不能因为原话没有「贸易条款」四个字就丢掉。
|
||||
原话没出现「门到门」时,旧草稿里的门到门继续丢掉(WO202609200013)。
|
||||
原话没出现「港到港」时,模型填的港到港同样丢掉。
|
||||
销售已写「贸易条款:…」标签时以标签为准,不用句中的词覆盖。返回新 dict。
|
||||
"""
|
||||
merged = drop_invented_door_trade_terms(text, facts)
|
||||
if _labeled_value(text or "", _LABELED_TRADE_TERMS):
|
||||
return merged
|
||||
spoken = short_sea_trade_reply(text)
|
||||
if spoken:
|
||||
merged["贸易条款"] = spoken
|
||||
return merged
|
||||
door = _spoken_door_in_text(text)
|
||||
if door:
|
||||
merged["贸易条款"] = door
|
||||
return merged
|
||||
if str(merged.get("贸易条款") or "").strip() == "港到港":
|
||||
merged.pop("贸易条款", None)
|
||||
return merged
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,19 @@ def _first_number(raw: str) -> str:
|
||||
return hit.group(1) if hit else ""
|
||||
|
||||
|
||||
def dimension_volume_text(raw: str) -> str:
|
||||
"""
|
||||
原话里的尺寸式体积,保持销售写法,不在这里相乘。
|
||||
|
||||
「1.1*2.1*2.2」「1.1x1.2x3」都算体积原文。出站数字仍交给 volume_cbm_number。
|
||||
箱型「40HQ*1」中间有字母,不算尺寸。没有尺寸返回空串。
|
||||
"""
|
||||
hit = _DIM_PRODUCT.search(raw or "")
|
||||
if not hit:
|
||||
return ""
|
||||
return hit.group(0).strip()
|
||||
|
||||
|
||||
def volume_cbm_number(raw: str) -> str:
|
||||
"""
|
||||
体积出站用数字:尺寸式先相乘;否则取第一个数。
|
||||
|
||||
@@ -14,7 +14,7 @@ if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.channel.aibot import normalize_aibot_payload
|
||||
from agent.channel.aibot.reply import AibotReplyClient
|
||||
from agent.channel.aibot.reply import AibotReplyClient, protect_markdown_asterisks
|
||||
|
||||
|
||||
class AibotInboundNormalizeTests(unittest.TestCase):
|
||||
@@ -51,6 +51,14 @@ class AibotInboundNormalizeTests(unittest.TestCase):
|
||||
|
||||
|
||||
class AibotReplyTests(unittest.TestCase):
|
||||
def test_group_markdown_keeps_dimension_asterisks(self) -> None:
|
||||
raw = "体积(CBM)(必填):1.1*2.1*2.1\n箱型箱量:40HQ*1"
|
||||
shown = protect_markdown_asterisks(raw)
|
||||
self.assertEqual(
|
||||
shown,
|
||||
"体积(CBM)(必填):`1.1*2.1*2.1`\n箱型箱量:`40HQ*1`",
|
||||
)
|
||||
self.assertEqual(protect_markdown_asterisks(shown), shown)
|
||||
def test_empty_content_skips(self) -> None:
|
||||
client = AibotReplyClient("")
|
||||
out = client.send_group(chat_id="wr_1", content=" ")
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -1091,6 +1091,31 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertTrue(any(f"工单{wo}的1个附件已保存到后台工单" in t for t in self._texts()))
|
||||
self.assertTrue(any("报价单已发送,请确认成交状态" in t for t in self._texts()))
|
||||
|
||||
def test_reactivate_after_airline_file_is_deal_not_blank_sheet(self) -> None:
|
||||
"""航线附件已报价后,再发工单号回到成交跟进,不再要航线报价。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="af3b"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
handle_air_group(
|
||||
self._file(sender="air1", filename="航线报价.pdf", chat="wr_air_1", mid="af4b"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
before = len(self.groups.sent)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_1", mid="af4c"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "activated_deal")
|
||||
added = self.groups.sent[before:]
|
||||
texts = "\n".join(x.get("content") or "" for x in added)
|
||||
self.assertIn("请确认成交状态", texts)
|
||||
self.assertFalse(any(x.get("kind") == "file" for x in added))
|
||||
|
||||
def test_image_rejected(self) -> None:
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
@@ -1376,6 +1401,45 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("锁舱失败的原因:该航班日期不存在可锁定的配载记录", joined)
|
||||
self.assertNotIn("TMS接口异常,IT运维排查中", joined)
|
||||
|
||||
def test_paused_after_lock_still_records_lost_reason(self) -> None:
|
||||
"""锁舱失败标了暂停后,未成交和原因仍要入账,不能回 TMS 异常。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="sales1", text=wo, chat="wr_air_paused_lost", mid="pl0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_activate",
|
||||
)
|
||||
self.ledger.mark_system_exception(
|
||||
work_order_no=wo,
|
||||
system_exception="TMS",
|
||||
system_exception_reason="空运锁舱接口调用失败",
|
||||
step="tms_lock",
|
||||
payload={},
|
||||
)
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="未成交", chat="wr_air_paused_lost", mid="pl1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="deal_lost",
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertNotIn("TMS接口异常", "\n".join(self._texts()))
|
||||
self.groups.sent.clear()
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="sales1", text="客户不需要了", chat="wr_air_paused_lost", mid="pl2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="other",
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("已记下未成交原因:客户不需要了", joined)
|
||||
self.assertNotIn("TMS接口异常", joined)
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.lost_reason, "客户不需要了")
|
||||
|
||||
def test_release_fail_tells_operator_and_tms_reason(self) -> None:
|
||||
"""释放失败同理:操作人、原因、已通知IT,不要系统异常兜底。"""
|
||||
wo = self._create_air()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -213,6 +213,15 @@ class AttachmentInquiryTests(unittest.TestCase):
|
||||
self.assertIn(copy.ATTACH_RECOGNIZING, self._bodies())
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertEqual(sess.phase, "wait_confirm")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
self.assertTrue(sess.pending_files)
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertTrue((sess.work_order_no or "").strip())
|
||||
ticket = get_sea_text_flow().ledger.get_ticket(work_order_no=sess.work_order_no)
|
||||
self.assertTrue(ticket.attachments)
|
||||
@@ -267,6 +276,13 @@ class AttachmentInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(len(acks), 1)
|
||||
self.assertEqual(acks[0], copy.IMAGE_RECOGNIZING)
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertTrue(sess and sess.phase == "wait_confirm")
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertTrue(sess and sess.work_order_no)
|
||||
|
||||
def test_mid_clarify_then_attach_is_new_ticket(self) -> None:
|
||||
@@ -276,6 +292,10 @@ class AttachmentInquiryTests(unittest.TestCase):
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="old-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
wo_old = get_sea_text_flow().session_of("s1").work_order_no
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="我有个excel要询价"),
|
||||
@@ -292,6 +312,10 @@ class AttachmentInquiryTests(unittest.TestCase):
|
||||
"empty": False,
|
||||
},
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="new-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
wo_new = get_sea_text_flow().session_of("s1").work_order_no
|
||||
self.assertNotEqual(wo_old, wo_new)
|
||||
self.assertIsNotNone(get_sea_text_flow().ledger.get_ticket(work_order_no=wo_old))
|
||||
|
||||
@@ -265,6 +265,58 @@ class ContinueTicketTests(unittest.TestCase):
|
||||
self.assertTrue(any("已记录为「已成交」" in x for x in replies))
|
||||
self.assertIn(wo, "\n".join(replies))
|
||||
|
||||
def test_resume_waiting_lost_reason_saves_sentence(self) -> None:
|
||||
"""等未成交原因时继续工单,下一句要写入原因,不能再要一遍。"""
|
||||
ledger = MemoryLedger()
|
||||
created = ledger.create_ticket(
|
||||
sender_id="u7", business_line="AIR", facts=COMPLETE_AIR
|
||||
)
|
||||
wo = created["work_order_no"]
|
||||
ledger.upsert_quote(
|
||||
work_order_no=wo,
|
||||
quote={"total": "USD 1", "eta": "-", "valid_until": "-"},
|
||||
to_status="已报价",
|
||||
)
|
||||
ledger.render_quote(
|
||||
work_order_no=wo,
|
||||
business_line="AIR",
|
||||
facts=COMPLETE_AIR,
|
||||
quote={"total": "USD 1"},
|
||||
)
|
||||
flow = AirTextInquiryFlow(ledger)
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> None:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
|
||||
self.assertEqual(
|
||||
flow.resume_historical(sender_id="u7", work_order_no=wo, reply=reply),
|
||||
"wait_deal",
|
||||
)
|
||||
self.assertEqual(
|
||||
flow.on_button(sender_id="u7", action=copy.BTN_LOST, reply=reply),
|
||||
"wait_lost_reason",
|
||||
)
|
||||
replies.clear()
|
||||
phase = flow.resume_historical(
|
||||
sender_id="u7", work_order_no=wo, reply=reply, text=""
|
||||
)
|
||||
self.assertEqual(phase, "wait_lost_reason")
|
||||
self.assertTrue(any("请补充未成交原因" in x for x in replies))
|
||||
replies.clear()
|
||||
phase = flow.resume_historical(
|
||||
sender_id="u7",
|
||||
work_order_no=wo,
|
||||
reply=reply,
|
||||
text="客户不需要了",
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
self.assertTrue(any("已记下未成交原因:客户不需要了" in x for x in replies))
|
||||
self.assertFalse(any("请补充未成交原因" in x for x in replies))
|
||||
view = ledger.get_for_agent(work_order_no=wo, sender_id="u7") or {}
|
||||
self.assertEqual(view.get("lost_reason"), "客户不需要了")
|
||||
|
||||
def test_resume_missing_wo_asks(self) -> None:
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
|
||||
@@ -59,6 +59,37 @@ class OralExtractTests(unittest.TestCase):
|
||||
out = invoke_shell(payload={"a": 1})
|
||||
self.assertEqual(out.get("error"), "extract_fields_shell_not_implemented")
|
||||
|
||||
def test_city_pair_without_mode_does_not_default_air(self) -> None:
|
||||
"""没写运输方式时,模型猜的空运不能当成已选。港口和日期仍留下。"""
|
||||
text = "查一下10月9日郑州-吉隆坡的报价"
|
||||
guessed = parse_extract_payload(
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"fields": {
|
||||
"起运港": "郑州",
|
||||
"目的港": "吉隆坡",
|
||||
"报价日期": "2026-10-09",
|
||||
},
|
||||
}
|
||||
)
|
||||
snap = extract_inquiry_snapshot(text, b_result=guessed)
|
||||
self.assertEqual(snap["business_line"], "")
|
||||
self.assertEqual(snap["facts"].get("起运港"), "郑州")
|
||||
self.assertEqual(snap["facts"].get("目的港"), "吉隆坡")
|
||||
self.assertEqual(snap["facts"].get("报价日期"), "2026-10-09")
|
||||
flow = AirTextInquiryFlow(MemoryLedger())
|
||||
replies: list[str] = []
|
||||
phase = flow.on_text(
|
||||
sender_id="u-mode",
|
||||
text=text,
|
||||
reply=lambda body, extra=None: replies.append(body),
|
||||
b_result=guessed,
|
||||
allow_b=False,
|
||||
)
|
||||
self.assertEqual(phase, "need_mode")
|
||||
self.assertTrue(any("告知我询价的运输方式" in x for x in replies))
|
||||
self.assertFalse(any(x.startswith("运输方式:空运") or "运输方式:空运" in x for x in replies))
|
||||
|
||||
def test_oral_snapshot_uses_b_not_regex(self) -> None:
|
||||
snap = extract_inquiry_snapshot(ORAL, b_result=parse_extract_payload(B_JSON))
|
||||
self.assertEqual(snap["business_line"], "AIR")
|
||||
@@ -304,6 +335,14 @@ class OralExtractTests(unittest.TestCase):
|
||||
self.assertIn("重量(KG):1.5KG", shown)
|
||||
self.assertNotIn("1.5KGS", shown)
|
||||
|
||||
def test_dimension_text_is_volume_without_multiplying(self) -> None:
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
facts = harvest_oral_measures("茶叶,1.1*2.1*2.2,11kg,21件", {})
|
||||
self.assertEqual(facts["体积"], "1.1*2.1*2.2")
|
||||
self.assertEqual(facts["毛重"], "11kg")
|
||||
self.assertEqual(facts["件数"], "21件")
|
||||
|
||||
def test_oral_units_fill_weight_volume_without_b(self) -> None:
|
||||
snap = extract_inquiry_snapshot(
|
||||
"珠海到克拉克空运,茶叶 10件 100KGS 2CBM 托盘"
|
||||
@@ -533,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()
|
||||
|
||||
@@ -134,6 +134,15 @@ class ImageInquiryFlowTests(unittest.TestCase):
|
||||
sea = get_sea_text_flow()
|
||||
sess = sea.session_of("s1")
|
||||
self.assertIsNotNone(sess)
|
||||
self.assertEqual(sess.phase, "wait_confirm")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
self.assertTrue(sess.pending_files)
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, self._bodies()[-1])
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="sea-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
sess = sea.session_of("s1")
|
||||
self.assertTrue((sess.work_order_no or "").strip())
|
||||
ticket = sea.ledger.get_ticket(work_order_no=sess.work_order_no)
|
||||
self.assertTrue(ticket.attachments)
|
||||
@@ -163,7 +172,8 @@ class ImageInquiryFlowTests(unittest.TestCase):
|
||||
acks = [c for c in self._bodies() if c == copy.IMAGE_RECOGNIZING]
|
||||
self.assertEqual(len(acks), 1)
|
||||
sess = get_sea_text_flow().session_of("s1")
|
||||
self.assertTrue(sess and sess.work_order_no)
|
||||
self.assertTrue(sess and sess.phase == "wait_confirm")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
|
||||
def test_precede_text_joins_new_ticket(self) -> None:
|
||||
handle_text_inquiry(
|
||||
@@ -207,6 +217,10 @@ class ImageInquiryFlowTests(unittest.TestCase):
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="old-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
wo_old = get_sea_text_flow().session_of("s1").work_order_no
|
||||
self.assertTrue(wo_old)
|
||||
handle_image_inquiry(
|
||||
@@ -215,6 +229,10 @@ class ImageInquiryFlowTests(unittest.TestCase):
|
||||
vision_fn=_vision(SEA_ORAL),
|
||||
download_fn=lambda _mid: (b"x", ""),
|
||||
)
|
||||
handle_text_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="new-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
wo_new = get_sea_text_flow().session_of("s1").work_order_no
|
||||
self.assertTrue(wo_new)
|
||||
self.assertNotEqual(wo_old, wo_new)
|
||||
|
||||
@@ -257,7 +257,10 @@ class LandDispatchTests(unittest.TestCase):
|
||||
last = list(self.store._outbox.values())[-1].content
|
||||
self.assertIn(SYS_EXC_TMS_FALLBACK, last)
|
||||
|
||||
def test_sea_still_creates_immediately(self) -> None:
|
||||
def test_sea_holds_confirm_before_quote(self) -> None:
|
||||
"""海运私聊字段齐了先出核对卡,回复确定后才查价。"""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
msg = InboundMessage(sender_id="s-sea", message_id="m-sea-keep", content="海运询价")
|
||||
phase = handle_text_inquiry(
|
||||
msg,
|
||||
@@ -265,6 +268,14 @@ class LandDispatchTests(unittest.TestCase):
|
||||
injected_mode="SEA",
|
||||
store=self.store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
held = "\n".join(item.content for item in self.store._outbox.values())
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, held)
|
||||
self.assertNotIn("工单号", held)
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="s-sea", message_id="m-sea-ok", content="确定"),
|
||||
store=self.store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
|
||||
def _land_to_quoted(self, sender_id: str) -> None:
|
||||
|
||||
@@ -243,6 +243,44 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
valid = text.find("报价有效期:", text.find("费用合计:"))
|
||||
self.assertGreater(valid, text.find("费用合计:"))
|
||||
|
||||
def test_one_leg_detail_does_not_repeat_route_and_card_is_summary(self) -> None:
|
||||
"""一段陆运:目的站名里已有横杠,明细只写始发→目的;卡片引用条只留合计和项数。"""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
dest = "谅山Lạng Sơn-春疆货场Huu Nghi"
|
||||
quote = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"currency": "CNY",
|
||||
"valid_until": "2026-10-31",
|
||||
"landSegments": [
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": dest,
|
||||
"costPrice": 7500,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+2",
|
||||
}
|
||||
],
|
||||
"fee_lines": [{"name": "运输费", "amount": "7500", "currency": "CNY"}],
|
||||
}
|
||||
projected = project_selected_route(quote, group_land_routes(quote)[0])
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202610030035",
|
||||
quote=projected,
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertNotIn(f"上海-{dest}", text)
|
||||
self.assertIn(f"上海 → {dest}", text)
|
||||
self.assertEqual(text.count(dest), 1)
|
||||
self.assertIn("运输费:CNY 7500", text)
|
||||
card = copy.quote_area_safe_text(projected, business_line="LAND")
|
||||
self.assertEqual(card, "费用合计:CNY 7500\n共1项费用,详见上一条明细")
|
||||
self.assertNotIn("运输费", card)
|
||||
self.assertNotIn(dest, card)
|
||||
|
||||
def test_land_detail_and_group_use_selected_route_valid_until(self) -> None:
|
||||
"""选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。"""
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -133,6 +133,29 @@ class SplitTests(unittest.TestCase):
|
||||
self.assertTrue(outcome.ok)
|
||||
self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "SEA"])
|
||||
|
||||
def test_cargo_before_ordinal_segments_fills_both(self) -> None:
|
||||
"""段前写一次的品名、尺寸、重量、件数,陆运和空运都要带上。"""
|
||||
oral = (
|
||||
"茶叶,1.1*2.1*2.2,11kg,21件\n"
|
||||
"第一段陆运,北京到上海\n"
|
||||
"第二段空运,上海到北宁"
|
||||
)
|
||||
outcome = split_segments(oral)
|
||||
self.assertTrue(outcome.ok)
|
||||
self.assertEqual([seg.index for seg in outcome.segments], [1, 2])
|
||||
self.assertEqual([seg.mode for seg in outcome.segments], ["LAND", "AIR"])
|
||||
land, air = outcome.segments
|
||||
self.assertEqual(land.facts.get("品名"), "茶叶")
|
||||
self.assertEqual(air.facts.get("品名"), "茶叶")
|
||||
self.assertEqual(air.facts.get("件数"), "21件")
|
||||
self.assertEqual(air.facts.get("毛重"), "11kg")
|
||||
self.assertEqual(air.facts.get("体积"), "1.1*2.1*2.2")
|
||||
_, _, gaps = refresh_segments(outcome.segments)
|
||||
self.assertNotIn("品名(必填):\n(如:茶叶)", gaps)
|
||||
self.assertNotIn("重量(KG)(必填):\n(如:", gaps)
|
||||
self.assertNotIn("体积(CBM)(必填):\n(如:", gaps)
|
||||
self.assertNotIn("件数(必填):\n(如:", gaps)
|
||||
|
||||
def test_group_brief_has_no_blank_under_mentions(self) -> None:
|
||||
"""拉群摘要 @人后直接接段1,中间不要空行。"""
|
||||
segs = split_segments(SEA_AIR).segments
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
@@ -618,5 +619,102 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
|
||||
|
||||
|
||||
class SheetPriceStampTests(unittest.TestCase):
|
||||
"""报价单读的是线路表和舱位明细,不是只读 fee_lines。"""
|
||||
|
||||
def test_sales_adjust_writes_land_route_copies(self) -> None:
|
||||
src = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"currency": "CNY",
|
||||
"total": "CNY 16500",
|
||||
"fee_lines": [{"name": "运输费", "amount": "16500.00", "currency": "CNY"}],
|
||||
"feeItems": [{"feeName": "运输费", "amount": 16500.0, "currency": "CNY"}],
|
||||
"landSegments": [
|
||||
{"origin": "上海", "destination": "谅山", "costPrice": 16500.0, "currency": "CNY"}
|
||||
],
|
||||
"route_rows": [
|
||||
{
|
||||
"origin": "上海",
|
||||
"destination": "谅山",
|
||||
"costPrice": "16500.00",
|
||||
"amount": "16500.00",
|
||||
}
|
||||
],
|
||||
"selected_land_route": json.dumps(
|
||||
{"legs": [{"origin": "上海", "destination": "谅山", "costPrice": 16500.0}]},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
got = apply_adjust(
|
||||
src,
|
||||
extract_adjust("运输费改为28150", fee_names=["运输费"], allow_model=False),
|
||||
business_line="LAND",
|
||||
tms_cost="16500",
|
||||
tms_sale="16500",
|
||||
)
|
||||
self.assertTrue(got.ok)
|
||||
self.assertIn("28150", str(got.quote["route_rows"][0]["amount"]))
|
||||
self.assertIn("28150", str(got.quote["route_rows"][0]["costPrice"]))
|
||||
self.assertIn("28150", str(got.quote["landSegments"][0]["costPrice"]))
|
||||
selected = json.loads(got.quote["selected_land_route"])
|
||||
self.assertIn("28150", str(selected["legs"][0]["costPrice"]))
|
||||
self.assertIn("28150", str(got.quote["feeItems"][0]["amount"]))
|
||||
|
||||
def test_product_and_airline_sheet_copies_follow_new_fee(self) -> None:
|
||||
from agent.policy.sea_group_ops import merge_quote_fees
|
||||
from agent.schema.quote_adjust import stamp_prices_for_sheet
|
||||
|
||||
base = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"currency": "CNY",
|
||||
"total": "16500",
|
||||
"fee_lines": [{"name": "运输费", "amount": "16500", "currency": "CNY"}],
|
||||
"route_rows": [{"costPrice": "16500", "amount": "16500"}],
|
||||
"oceanCostItems": [{"name": "海运费", "amount": "100", "currency": "USD"}],
|
||||
}
|
||||
merged = merge_quote_fees(
|
||||
base,
|
||||
{"fee_lines": [{"name": "运输费", "amount": "18000"}], "source_label": "产品报价"},
|
||||
)
|
||||
self.assertIn("18000", str(merged["route_rows"][0]["amount"]))
|
||||
self.assertEqual(merged["source_label"], "产品报价")
|
||||
|
||||
air = stamp_prices_for_sheet(
|
||||
{
|
||||
"source_label": "航线报价",
|
||||
"total": "900",
|
||||
"currency": "CNY",
|
||||
"fee_lines": [{"name": "空运费", "amount": "900", "currency": "CNY"}],
|
||||
"airOptions": [
|
||||
{
|
||||
"salePrice": "7918.25",
|
||||
"departureCharges": [
|
||||
{"chargeName": "空运费", "unitPrice": "1000", "amount": "1000"}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertEqual(air["airOptions"][0]["salePrice"], "900")
|
||||
self.assertIn("900", str(air["airOptions"][0]["departureCharges"][0]["amount"]))
|
||||
kept = stamp_prices_for_sheet(
|
||||
{
|
||||
"source_label": "航线确认TMS",
|
||||
"total": "200",
|
||||
"fee_lines": [{"name": "增点费", "amount": "200"}],
|
||||
"airOptions": [
|
||||
{
|
||||
"salePrice": "7918.25",
|
||||
"departureCharges": [{"chargeName": "增点费", "unitPrice": "100", "amount": "100"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertEqual(kept["airOptions"][0]["salePrice"], "7918.25")
|
||||
self.assertEqual(kept["airOptions"][0]["departureCharges"][0]["amount"], "100")
|
||||
|
||||
|
||||
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")
|
||||
@@ -71,6 +71,32 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
example = apply_spoken_sea_trade_reply("(如:门到门、港到港)", {})
|
||||
self.assertNotIn("贸易条款", example)
|
||||
|
||||
def test_door_inside_long_sentence_is_trade_term(self):
|
||||
"""先报货再选海运:长句里的门到门是贸易条款,港到港仍是运输分类。"""
|
||||
from agent.schema.sea_options import apply_spoken_sea_trade_reply
|
||||
|
||||
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
|
||||
got = apply_spoken_sea_trade_reply(text, {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got.get("贸易条款"), "门到门")
|
||||
self.assertEqual(got.get("运输分类"), "Port to Port")
|
||||
absent = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
|
||||
{"贸易条款": "门到门", "运输分类": "Port to Port"},
|
||||
)
|
||||
self.assertNotIn("贸易条款", absent)
|
||||
self.assertEqual(absent.get("运输分类"), "Port to Port")
|
||||
port_term = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港",
|
||||
{"运输分类": "Port to Port"},
|
||||
)
|
||||
self.assertEqual(port_term.get("贸易条款"), "港到港")
|
||||
self.assertEqual(port_term.get("运输分类"), "Port to Port")
|
||||
invented = apply_spoken_sea_trade_reply(
|
||||
"广州港到巴生港,机器设备,40HQ*1,整柜,Port to Port",
|
||||
{"贸易条款": "港到港"},
|
||||
)
|
||||
self.assertNotIn("贸易条款", invented)
|
||||
|
||||
def test_wo012_trade_port_class_other(self):
|
||||
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
|
||||
text = (
|
||||
|
||||
@@ -46,6 +46,14 @@ COMPLETE_SEA = {
|
||||
}
|
||||
|
||||
|
||||
def drive_sea(flow, **kwargs):
|
||||
"""字段齐时先出核对卡;要看到查价结果的旧用例再自动回一句确定。"""
|
||||
phase = flow.on_text(**kwargs)
|
||||
if phase == "wait_confirm":
|
||||
phase = flow.on_text(sender_id=kwargs["sender_id"], text="确定", reply=kwargs["reply"])
|
||||
return phase
|
||||
|
||||
|
||||
class SeaCollabContractTests(unittest.TestCase):
|
||||
def test_collab_no_longer_asks_trade_terms(self):
|
||||
self.assertNotIn("贸易条款", SEA_COLLAB_FIELDS)
|
||||
@@ -127,6 +135,80 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.replies.append(text)
|
||||
self.extras.append(dict(extra) if extra else {})
|
||||
|
||||
def test_complete_fields_hold_confirm_before_tms(self) -> None:
|
||||
"""字段齐了只出核对卡,不建单、不查价。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-hold",
|
||||
text="海运完整",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
card = self.replies[-1]
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, card)
|
||||
self.assertIn("运输方式(必填):海运", card)
|
||||
self.assertIn("货物数量(必填):20吨", card)
|
||||
self.assertIn("贸易条款(必填):FOB", card)
|
||||
self.assertNotIn("工单号", card)
|
||||
self.assertNotIn("正在查询TMS报价", card)
|
||||
sess = self.flow.session_of("s-hold")
|
||||
self.assertEqual(sess.phase, "wait_confirm")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_confirm_phrases_then_query_tms(self) -> None:
|
||||
"""确定、确认、查价、可以查了 都建单并查价。"""
|
||||
for word in ("确定", "确认", "查价", "可以查了"):
|
||||
with self.subTest(word=word):
|
||||
self.replies.clear()
|
||||
sender = f"s-go-{word}"
|
||||
held = self.flow.on_text(
|
||||
sender_id=sender,
|
||||
text="海运完整",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(held, "wait_confirm")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(sender_id=sender, text=word, reply=self._reply)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertIn("正在查询TMS报价", joined)
|
||||
self.assertIn("工单号:", joined)
|
||||
sess = self.flow.session_of(sender)
|
||||
self.assertTrue((sess.work_order_no or "").startswith("WO"))
|
||||
|
||||
def test_resend_while_waiting_merges_and_does_not_quote(self) -> None:
|
||||
"""核对卡出来后改一处,并进原字段再出卡,仍不建单。"""
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="海运完整",
|
||||
reply=self._reply,
|
||||
injected_facts=COMPLETE_SEA,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.replies.clear()
|
||||
fixed = dict(COMPLETE_SEA)
|
||||
fixed["目的港"] = "纽约"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="目的港改成纽约",
|
||||
reply=self._reply,
|
||||
injected_facts=fixed,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertIn("目的港(必填):纽约", self.replies[-1])
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, self.replies[-1])
|
||||
sess = self.flow.session_of("s-fix")
|
||||
self.assertEqual(sess.facts.get("目的港"), "纽约")
|
||||
self.assertEqual(sess.facts.get("起运港"), "上海")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_sea_pieces_fill_cargo_qty(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
@@ -287,7 +369,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
def test_incomplete_without_trade_terms_does_not_create_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts.pop("贸易条款")
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-miss",
|
||||
text="海运缺条款",
|
||||
reply=self._reply,
|
||||
@@ -318,7 +400,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
facts=leftover,
|
||||
)
|
||||
)
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-door",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件",
|
||||
reply=self._reply,
|
||||
@@ -335,7 +417,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"""补问后只回门到门,要记成贸易条款,不能回参数提取不全。"""
|
||||
first = dict(COMPLETE_SEA)
|
||||
first.pop("贸易条款")
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-door-reply",
|
||||
text="先缺条款",
|
||||
reply=self._reply,
|
||||
@@ -344,7 +426,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-door-reply",
|
||||
text="门到门",
|
||||
reply=self._reply,
|
||||
@@ -354,10 +436,92 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("贸易条款:门到门", joined)
|
||||
self.assertNotIn("参数提取不全", joined)
|
||||
|
||||
def test_door_in_sentence_kept_after_choosing_sea(self) -> None:
|
||||
"""没写海运、句中有门到门,回「2」后贸易条款要带上,不能再追问。"""
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.land_text_flow import reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
reset_sea_text_flow_for_test()
|
||||
reset_land_text_flow_for_test()
|
||||
reset_air_text_flow_for_test()
|
||||
store = MemoryMessageStore()
|
||||
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,门到门"
|
||||
facts = {
|
||||
"起运港": "广州港",
|
||||
"目的港": "巴生港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="s-door-mode", message_id="m-door-1", content=text),
|
||||
injected_facts=facts,
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "need_mode")
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="s-door-mode", message_id="m-door-2", content="2"),
|
||||
store=store,
|
||||
)
|
||||
body = "\n".join(item.content for item in store._outbox.values())
|
||||
sess = get_sea_text_flow().session_of("s-door-mode")
|
||||
self.assertEqual(sess.facts.get("贸易条款"), "门到门")
|
||||
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
|
||||
self.assertIn("贸易条款(必填):门到门", body)
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn("1.贸易条款(必填)", body)
|
||||
self.assertNotEqual(phase, "need_mode")
|
||||
|
||||
def test_port_to_port_phrase_kept_after_choosing_sea(self) -> None:
|
||||
"""没写海运、句中有港到港,回「2」后贸易条款是港到港,运输分类仍是 Port to Port。"""
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.policy.air_text_flow import reset_air_text_flow_for_test
|
||||
from agent.policy.land_text_flow import reset_land_text_flow_for_test
|
||||
from agent.policy.sea_text_flow import get_sea_text_flow
|
||||
|
||||
reset_sea_text_flow_for_test()
|
||||
reset_land_text_flow_for_test()
|
||||
reset_air_text_flow_for_test()
|
||||
store = MemoryMessageStore()
|
||||
text = "广州港到巴生港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port,港到港"
|
||||
facts = {
|
||||
"起运港": "广州港",
|
||||
"目的港": "巴生港",
|
||||
"品名": "机器设备",
|
||||
"货量": "15件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="s-port-mode", message_id="m-port-1", content=text),
|
||||
injected_facts=facts,
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "need_mode")
|
||||
phase = handle_text_inquiry(
|
||||
InboundMessage(sender_id="s-port-mode", message_id="m-port-2", content="2"),
|
||||
store=store,
|
||||
)
|
||||
body = "\n".join(item.content for item in store._outbox.values())
|
||||
sess = get_sea_text_flow().session_of("s-port-mode")
|
||||
self.assertEqual(sess.facts.get("贸易条款"), "港到港")
|
||||
self.assertEqual(sess.facts.get("运输分类"), "Port to Port")
|
||||
self.assertIn("贸易条款(必填):港到港", body)
|
||||
self.assertIn(copy.SEA_CONFIRM_TAIL, body)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn("1.贸易条款(必填)", body)
|
||||
self.assertNotEqual(phase, "need_mode")
|
||||
|
||||
def test_harvest_class_from_reply_then_quotes(self) -> None:
|
||||
first = dict(COMPLETE_SEA)
|
||||
first.pop("运输分类")
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-class",
|
||||
text="先缺分类",
|
||||
reply=self._reply,
|
||||
@@ -366,7 +530,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "clarify")
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-class",
|
||||
text="Port to Port",
|
||||
reply=self._reply,
|
||||
@@ -378,7 +542,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("贸易条款:FOB", joined)
|
||||
|
||||
def test_complete_sea_inquiry_card_uses_ten_fields(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运完整",
|
||||
reply=self._reply,
|
||||
@@ -404,7 +568,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
|
||||
def test_new_round_after_ticket_does_not_reuse_old_facts(self) -> None:
|
||||
"""上一单已出号,新一轮不能带上旧贸易条款。"""
|
||||
first = self.flow.on_text(
|
||||
first = drive_sea(self.flow,
|
||||
sender_id="s-round",
|
||||
text="海运第一轮",
|
||||
reply=self._reply,
|
||||
@@ -424,7 +588,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
"箱型箱量": "40HQ*1",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s-round",
|
||||
text="海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件,Port to Port",
|
||||
reply=self._reply,
|
||||
@@ -457,7 +621,7 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
flow=self.flow,
|
||||
store=store,
|
||||
)
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotEqual(phase, "sea_deferred")
|
||||
|
||||
|
||||
@@ -569,9 +733,19 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertNotIn("报价有效期", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote, business_line="SEA")
|
||||
self.assertIn("费用合计:CNY 2287.65", card_text)
|
||||
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
|
||||
self.assertLessEqual(len(card_text), 80)
|
||||
self.assertEqual(
|
||||
card_text,
|
||||
"费用合计:CNY 2287.65\n共7项费用,详见上一条明细",
|
||||
)
|
||||
short_quote = {
|
||||
"total": "100",
|
||||
"currency": "CNY",
|
||||
"fee_lines": [{"name": "海运费", "amount": "100", "currency": "CNY"}],
|
||||
}
|
||||
self.assertEqual(
|
||||
copy.quote_area_safe_text(short_quote, business_line="SEA"),
|
||||
"费用合计:CNY 100\n共1项费用,详见上一条明细",
|
||||
)
|
||||
payload = copy.sea_tms_hit_wecom_payload(
|
||||
work_order_no="WO202609150030", quote=long_quote
|
||||
)
|
||||
@@ -614,7 +788,7 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.assertIn("card", steps)
|
||||
|
||||
def test_sea_hit_card_has_price_and_two_buttons(self) -> None:
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -639,7 +813,7 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
def test_sea_miss_one_card_with_pull_button(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
@@ -664,7 +838,7 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
def test_sea_tech_fail_is_not_miss(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["tms_force"] = "tech"
|
||||
phase = self.flow.on_text(
|
||||
phase = drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运技术失败",
|
||||
reply=self._reply,
|
||||
@@ -708,7 +882,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
return ""
|
||||
|
||||
def _quote_first(self, sender: str = "s1") -> None:
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id=sender,
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -820,7 +994,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
)
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["是否含电"] = "否"
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运不含电",
|
||||
reply=self._reply,
|
||||
@@ -857,7 +1031,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
)
|
||||
with patch("agent.policy.sea_text_flow._default_group_client", return_value=live):
|
||||
flow = SeaTextInquiryFlow(self.ledger, group_client=None)
|
||||
flow.on_text(
|
||||
drive_sea(flow,
|
||||
sender_id="s9",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -964,7 +1138,7 @@ class SeaSkipAndPullTests(unittest.TestCase):
|
||||
)
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="s1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
@@ -1018,7 +1192,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
)
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -1074,7 +1248,7 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
)
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -1258,7 +1432,7 @@ class SeaContinueTests(unittest.TestCase):
|
||||
from agent.handlers.continue_thread import handle_continue_thread
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="c1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -1358,7 +1532,7 @@ class SeaContinueTests(unittest.TestCase):
|
||||
from agent.handlers.continue_thread import handle_continue_thread
|
||||
from agent.policy.air_text_flow import AirTextInquiryFlow
|
||||
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="c3",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -1590,7 +1764,7 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
self.replies.append(text)
|
||||
|
||||
def _open_group(self) -> tuple[str, str]:
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="sales1",
|
||||
text="海运有价",
|
||||
reply=self._reply,
|
||||
@@ -2175,7 +2349,7 @@ class SeaGroupOpsTests(unittest.TestCase):
|
||||
"""TMS 无价时产品文字报价:摘要 TMS 仍是暂无,人工报价两行齐全。"""
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["目的港"] = "无价港"
|
||||
self.flow.on_text(
|
||||
drive_sea(self.flow,
|
||||
sender_id="sales1",
|
||||
text="海运无价",
|
||||
reply=self._reply,
|
||||
@@ -2230,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))
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
+3
-1
@@ -18,6 +18,7 @@ import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
|
||||
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
|
||||
import org.jeecg.modules.inquiry.template.QuoteSheetFileName;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
|
||||
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
|
||||
@@ -770,7 +771,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -15,7 +15,7 @@ import java.util.regex.Pattern;
|
||||
* <p>
|
||||
* 海运/陆运:TMS、产品报价是成本;销售调价后才有销售价,利润点=(销售-成本)/成本。
|
||||
* 空运:成本价始终空;TMS/航线/调价都是销售侧;利润点只在销售调过价后算。
|
||||
* 副作用:只改传入 ticket 的三个展示字段,不写库。调用方决定是否 updateById。
|
||||
* 副作用:只改传入 ticket 的展示字段(三个总价字符串和三列逐行),不写库。调用方决定是否 updateById。
|
||||
*/
|
||||
public final class InquiryTicketPriceSupport {
|
||||
|
||||
@@ -65,17 +65,19 @@ public final class InquiryTicketPriceSupport {
|
||||
ticket.setSalePrice(StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice);
|
||||
ticket.setProfitRate("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
|
||||
ticket.setCostPrice(StringUtils.defaultString(cost));
|
||||
if (StringUtils.isNotBlank(adjustPrice)) {
|
||||
ticket.setSalePrice(adjustPrice);
|
||||
ticket.setProfitRate(profitText(adjustPrice, cost));
|
||||
} else {
|
||||
ticket.setSalePrice("");
|
||||
ticket.setProfitRate("");
|
||||
String cost = StringUtils.isNotBlank(manualPrice) ? manualPrice : tmsPrice;
|
||||
ticket.setCostPrice(StringUtils.defaultString(cost));
|
||||
if (StringUtils.isNotBlank(adjustPrice)) {
|
||||
ticket.setSalePrice(adjustPrice);
|
||||
ticket.setProfitRate(profitText(adjustPrice, cost));
|
||||
} else {
|
||||
ticket.setSalePrice("");
|
||||
ticket.setProfitRate("");
|
||||
}
|
||||
}
|
||||
// 三个总价定完后,按同一批报价拆成逐行。有手工明细盖 TMS 时,这里会改写成本价和利润点。
|
||||
InquiryQuoteLineSupport.fill(ticket, quotes);
|
||||
}
|
||||
|
||||
static boolean isAir(String businessLine) {
|
||||
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+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", ""));
|
||||
}
|
||||
}
|
||||
@@ -570,8 +570,8 @@ body {
|
||||
.ticket-list-table .col-w-quote { width: 140px; }
|
||||
.ticket-list-table .col-w-attach { width: 160px; }
|
||||
.ticket-list-table .col-w-status { width: 84px; }
|
||||
.ticket-list-table .col-w-price { width: 100px; }
|
||||
.ticket-list-table .col-w-margin { width: 72px; }
|
||||
.ticket-list-table .col-w-price { width: 168px; }
|
||||
.ticket-list-table .col-w-margin { width: 132px; }
|
||||
.ticket-list-table .col-w-ex { width: 110px; }
|
||||
.ticket-list-table .col-w-reason { width: 140px; }
|
||||
.ticket-list-table .col-w-time { width: 132px; }
|
||||
@@ -627,6 +627,20 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-price-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
white-space: normal;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ticket-price-line.is-total {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ticket-status {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
|
||||
@@ -19,6 +19,8 @@ import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
/** 成本价 / 销售价 / 利润点的一行。最后一行名称是「费用合计」。 */
|
||||
type PriceLine = { name: string; text: string };
|
||||
type TicketQuote = {
|
||||
source: string;
|
||||
price: string;
|
||||
@@ -43,6 +45,9 @@ type TicketRow = {
|
||||
costPrice: string;
|
||||
salePrice: string;
|
||||
profit: string;
|
||||
costLines: PriceLine[];
|
||||
saleLines: PriceLine[];
|
||||
profitLines: PriceLine[];
|
||||
eventException: string;
|
||||
systemException: string;
|
||||
systemExceptionReason: string;
|
||||
@@ -102,6 +107,17 @@ function dash(v: any) {
|
||||
return s ? s : "-";
|
||||
}
|
||||
|
||||
/** 主账逐行。没有行时页面退回原来的总价字符串。 */
|
||||
function asPriceLines(raw: any): PriceLine[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((row) => ({
|
||||
name: String(row?.name ?? "").trim(),
|
||||
text: String(row?.text ?? "").trim(),
|
||||
}))
|
||||
.filter((row) => row.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台报价来源:TMS标准报价 / 手工报价 / 销售调价。
|
||||
* 禁止把产品报价或销售调价再显示成 TMS。
|
||||
@@ -386,6 +402,9 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
costPrice: dash(t.costPrice),
|
||||
salePrice: dash(t.salePrice),
|
||||
profit: dash(t.profitRate),
|
||||
costLines: asPriceLines(t.costLines),
|
||||
saleLines: asPriceLines(t.saleLines),
|
||||
profitLines: asPriceLines(t.profitLines),
|
||||
eventException: dash(t.eventException),
|
||||
systemException: dash(t.systemException),
|
||||
systemExceptionReason: dash(t.systemExceptionReason),
|
||||
@@ -692,13 +711,43 @@ function refresh() {
|
||||
</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,9 +853,48 @@ 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>
|
||||
<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>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user