Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d989154485 | ||
|
|
5402f94d30 | ||
|
|
37278aa0db | ||
|
|
4dfd756bcf |
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from agent.channel.h5.wecom_close import wecom_auto_close_script
|
||||
from agent.schema.air_route_options import (
|
||||
option_fee_lines,
|
||||
option_total,
|
||||
@@ -226,10 +227,11 @@ def render_air_route_page(payload: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -272,12 +274,13 @@ def _option_card(index: int, option: dict[str, Any], quote: dict[str, Any], fact
|
||||
)
|
||||
|
||||
|
||||
def _wrap(title: str, body: str) -> str:
|
||||
"""整页壳:标题、viewport、共用样式。"""
|
||||
def _wrap(title: str, body: str, *, auto_close: bool = False) -> str:
|
||||
"""整页壳:标题、viewport、共用样式。成功页可挂企微关窗脚本。"""
|
||||
closer = wecom_auto_close_script() if auto_close else ""
|
||||
return (
|
||||
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8' />"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1' />"
|
||||
f"<title>{html.escape(title)}</title>"
|
||||
f"<style>{_PAGE_CSS}</style></head><body>"
|
||||
f"{body}</body></html>"
|
||||
f"{body}{closer}</body></html>"
|
||||
)
|
||||
|
||||
@@ -56,10 +56,11 @@ def render_land_route_page(payload: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from html import escape
|
||||
import json
|
||||
|
||||
from agent.channel.h5.wecom_close import wecom_auto_close_script
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
# 三页共用:浅底、白卡片、企微蓝主按钮。禁止在页面里写 userid。
|
||||
@@ -188,7 +189,8 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
|
||||
"""成功 / 拒绝 / 过期共用卡片壳,避免三页三种简陋白底。"""
|
||||
icons = {"ok": _ICON_OK, "warn": _ICON_WARN, "expired": _ICON_EXPIRED}
|
||||
icon = icons.get(tone, _ICON_WARN)
|
||||
extra = '<p class="back">请回企微查看识别结果。</p>' if tone == "ok" else ""
|
||||
extra = '<p class="back">正在返回企微,请查看识别结果。</p>' if tone == "ok" else ""
|
||||
closer = wecom_auto_close_script() if tone == "ok" else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
@@ -210,6 +212,7 @@ def _shell(*, title: str, body: str, tone: str = "ok") -> str:
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
{closer}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
企微内嵌 H5 成功后自动关页。
|
||||
|
||||
本文件职责:给附件上传成功、选线路成功页一段共用脚本。
|
||||
手机端可走 WeixinJSBridge;企微 Windows 客户端没有这套桥,必须像复制工单号页一样
|
||||
先加载 wecom-jssdk、用现成 /inquiry-form/jsapi-signature 做 ww.register,再 closeWindow。
|
||||
失败、过期页不要挂这段。
|
||||
副作用:只产出 HTML 片段;关窗签名走浏览器短请求,不占智能体请求线程做慢活。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
|
||||
from agent.config import get_settings
|
||||
|
||||
# 签名完成后再稍停,让销售看见「已成功」。
|
||||
_CLOSE_DELAY_MS = 400
|
||||
|
||||
|
||||
def wecom_auto_close_script() -> str:
|
||||
"""
|
||||
成功页脚:加载企微 JS-SDK,register 后关掉当前 H5 窗口。
|
||||
|
||||
线程:浏览器端执行。签名接口与复制页共用,ticket 已缓存在 Redis。
|
||||
为何这样写:上一版只调 WeixinJSBridge,电脑端企微关不掉,会停在成功页。
|
||||
"""
|
||||
settings = get_settings()
|
||||
corp = escape((settings.wecom_corp_id or "").strip(), quote=True)
|
||||
agent = escape(str(settings.wecom_agent_id or "1000010").strip(), quote=True)
|
||||
delay = _CLOSE_DELAY_MS
|
||||
return f"""<script src="https://wwcdn.weixin.qq.com/node/wework/open/js/wecom-jssdk-2.3.2.js"></script>
|
||||
<script>
|
||||
(function () {{
|
||||
var corpId = "{corp}";
|
||||
var agentId = Number("{agent}") || 0;
|
||||
|
||||
function sign(kind) {{
|
||||
return function (url) {{
|
||||
return fetch("/inquiry-form/jsapi-signature?kind=" + encodeURIComponent(kind)
|
||||
+ "&url=" + encodeURIComponent(url))
|
||||
.then(function (r) {{
|
||||
if (!r.ok) throw new Error("sign");
|
||||
return r.json();
|
||||
}});
|
||||
}};
|
||||
}}
|
||||
|
||||
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) {{}}
|
||||
}}
|
||||
|
||||
function boot() {{
|
||||
if (typeof WeixinJSBridge !== "undefined") {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
}}
|
||||
document.addEventListener("WeixinJSBridgeReady", nativeClose, false);
|
||||
var ready = Promise.resolve();
|
||||
if (window.ww && ww.register && corpId) {{
|
||||
ready = ww.register({{
|
||||
corpId: corpId,
|
||||
agentId: agentId,
|
||||
jsApiList: ["closeWindow"],
|
||||
getConfigSignature: sign("corp"),
|
||||
getAgentConfigSignature: sign("agent")
|
||||
}}).catch(function () {{ return null; }});
|
||||
}}
|
||||
ready.then(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
}}).catch(function () {{
|
||||
setTimeout(nativeClose, {delay});
|
||||
}});
|
||||
}}
|
||||
|
||||
if (document.readyState === "loading") {{
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
}} else {{
|
||||
boot();
|
||||
}}
|
||||
}})();
|
||||
</script>"""
|
||||
@@ -136,6 +136,14 @@ def handle_air_group(
|
||||
if handed:
|
||||
return handed
|
||||
intent = classify_air_group_text(text, injected=injected_intent)
|
||||
# 注入/模型若仍判成激活,同一句里的成交跟进不能丢掉。
|
||||
if wo and intent == INTENT_ACTIVATE:
|
||||
from agent.routing.group_intent import _hint_deal_outcome
|
||||
|
||||
hinted = _hint_deal_outcome(text)
|
||||
if hinted:
|
||||
logger.info("air_group 工单号+成交跟进同一句,按 %s 办 wo=%s", hinted, wo)
|
||||
intent = hinted
|
||||
logger.info(
|
||||
"air_group 文字 chat=%s sender=%s intent=%s wo=%s",
|
||||
chat_id,
|
||||
@@ -261,12 +269,15 @@ def _handle_cabin(
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
)
|
||||
# 「WO…锁舱,锁AIR-OPT01 TH」要带上句中的舱位和航司,不能只靠报价里碰巧只有一条。
|
||||
return apply_air_lock(
|
||||
flow=engine,
|
||||
ticket=ticket,
|
||||
sender_id=message.sender_id,
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=_option_from_text(message.content or ""),
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +311,7 @@ def _handle_bound(
|
||||
chat_id=chat_id,
|
||||
instruction=message.content or "",
|
||||
option_no=option,
|
||||
airline=_airline_from_text(message.content or ""),
|
||||
)
|
||||
if intent in {INTENT_DEAL, INTENT_LOST, INTENT_NEGOTIATE}:
|
||||
outcome = {
|
||||
@@ -363,14 +375,25 @@ def _handle_bound(
|
||||
|
||||
|
||||
def _option_from_text(text: str) -> str:
|
||||
blob = (text or "").replace(" ", "").upper()
|
||||
if "AIR-OPT" in blob or "AIROPT" in blob:
|
||||
start = blob.find("AIR")
|
||||
token = []
|
||||
for ch in blob[start:]:
|
||||
if ch.isalnum() or ch == "-":
|
||||
token.append(ch)
|
||||
else:
|
||||
break
|
||||
return "".join(token)
|
||||
"""从「锁AIR-OPT01 TH」抽出舱位编号;不要把后面的航司码拼进 optionNo。"""
|
||||
import re
|
||||
|
||||
m = re.search(r"AIR-?OPT-?\d+", (text or "").upper())
|
||||
if not m:
|
||||
return ""
|
||||
raw = m.group(0)
|
||||
# 统一成 TMS 常见写法 AIR-OPT01(中间可保留已有的第二根横杠)
|
||||
if raw.startswith("AIROPT"):
|
||||
return "AIR-OPT" + raw[6:]
|
||||
return raw
|
||||
|
||||
|
||||
def _airline_from_text(text: str) -> str:
|
||||
"""从「AIR-OPT01 TH」抽出两位航司码;没有就不猜。"""
|
||||
import re
|
||||
|
||||
blob = (text or "").upper()
|
||||
m = re.search(r"AIR-?OPT-?\d+\s*([A-Z]{2})(?![A-Z0-9])", blob)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return ""
|
||||
|
||||
@@ -402,16 +402,20 @@ class HttpLedger:
|
||||
sender_id: str = "",
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"workOrderNo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"operatorUserId": sender_id,
|
||||
"operatorName": operator_name,
|
||||
"instructionText": instruction_text,
|
||||
}
|
||||
if (airline or "").strip():
|
||||
body["airline"] = airline.strip().upper()
|
||||
return self._post(
|
||||
"/inquiry/agent/tms/lockCabin",
|
||||
{
|
||||
"workOrderNo": work_order_no,
|
||||
"optionNo": option_no,
|
||||
"operatorUserId": sender_id,
|
||||
"operatorName": operator_name,
|
||||
"instructionText": instruction_text,
|
||||
},
|
||||
body,
|
||||
idem=f"air-lock:{work_order_no}:{option_no}",
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,20 @@ logger = logging.getLogger(__name__)
|
||||
_SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _memory_quote_can_lock(quote: dict[str, Any]) -> bool:
|
||||
"""单测锁舱:有 TMS 编号,或有人工总价/费用,才算有报价。不编造编号。"""
|
||||
src = dict(quote or {})
|
||||
label = str(src.get("source_label") or src.get("sourceLabel") or "")
|
||||
if "确认" in label and "TMS" in label.upper():
|
||||
return bool(str(src.get("quoteId") or "").strip()) and not str(src.get("quoteId") or "").upper().startswith("QT-")
|
||||
if str(src.get("total") or "").strip() not in {"", "-"}:
|
||||
return True
|
||||
if src.get("fee_rows") or src.get("fee_lines") or src.get("feeItems") or src.get("departureCharges"):
|
||||
return True
|
||||
quote_id = str(src.get("quoteId") or "").strip()
|
||||
return bool(quote_id) and not quote_id.upper().startswith("QT-WO")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryTicket:
|
||||
"""内存工单。"""
|
||||
@@ -809,20 +823,30 @@ class MemoryLedger:
|
||||
sender_id: str = "",
|
||||
operator_name: str = "",
|
||||
instruction_text: str = "",
|
||||
airline: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""单测可注入 _cabin_lock_result;默认成功。"""
|
||||
_ = sender_id, operator_name, instruction_text
|
||||
_ = sender_id, operator_name, instruction_text, airline
|
||||
forced = getattr(self, "_cabin_lock_result", None)
|
||||
if isinstance(forced, dict):
|
||||
return dict(forced)
|
||||
no = (work_order_no or "").strip()
|
||||
ticket = self.get_ticket(work_order_no=no)
|
||||
quote = dict(ticket.quote or {}) if ticket else {}
|
||||
if not _memory_quote_can_lock(quote):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "no_quote",
|
||||
"failReason": "这张单还没有报价,不能锁舱",
|
||||
}
|
||||
quote_id = str(quote.get("quoteId") or "").strip()
|
||||
if quote_id.upper().startswith("QT-WO"):
|
||||
quote_id = ""
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "LOCKED",
|
||||
"lockId": f"CA-{no}-LOCK",
|
||||
"quoteId": str(quote.get("quoteId") or f"QT-{no}"),
|
||||
"quoteId": quote_id,
|
||||
"optionNo": option_no,
|
||||
"tmsRequestId": f"TMS-LOCK-{no}",
|
||||
}
|
||||
|
||||
@@ -671,6 +671,18 @@ _RE_FEE_CHANGE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
|
||||
# 航线常写「空运费 单价CNY99,数量100,金额CNY199」。单价和金额不是同一个数,必须认金额。
|
||||
_RE_FEE_PRICED = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)"
|
||||
r"\s*单价\s*"
|
||||
r"(?:(?P<unit_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?P<unit>\d+(?:\.\d+)?)"
|
||||
r"(?:\s*[,,、]\s*数量\s*(?P<qty>\d+(?:\.\d+)?))?"
|
||||
r"(?:\s*[,,、]\s*金额\s*"
|
||||
r"(?:(?P<amt_ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?))?",
|
||||
re.I,
|
||||
)
|
||||
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
|
||||
|
||||
|
||||
@@ -698,7 +710,7 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def _add(name: str, amount: str, ccy: str) -> None:
|
||||
def _add(name: str, amount: str, ccy: str, **extra: str) -> None:
|
||||
if not name or not amount or name.lower() in _SKIP_FEE_NAMES:
|
||||
return
|
||||
# 「WO202609160001」会被 [A-Za-z]{2,10}+数字 抽成费用名 WO,群里发工单号不能当报价。
|
||||
@@ -707,14 +719,27 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
if name in seen:
|
||||
return
|
||||
seen.add(name)
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": _currency_of(raw, ccy),
|
||||
}
|
||||
)
|
||||
row = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": _currency_of(raw, ccy),
|
||||
}
|
||||
for key, val in extra.items():
|
||||
if val:
|
||||
row[key] = val
|
||||
rows.append(row)
|
||||
|
||||
for hit in _RE_FEE_PRICED.finditer(raw):
|
||||
# 有「金额」用金额;只有单价时才用单价。禁止把单价误当成这一行的总金额。
|
||||
amount = (hit.group("amount") or "").strip() or (hit.group("unit") or "").strip()
|
||||
ccy = hit.group("amt_ccy") or hit.group("unit_ccy") or ""
|
||||
_add(
|
||||
(hit.group("name") or "").strip(),
|
||||
_ascii_amount(amount),
|
||||
ccy,
|
||||
unit_price=_ascii_amount(hit.group("unit") or ""),
|
||||
quantity=_ascii_amount(hit.group("qty") or ""),
|
||||
)
|
||||
for hit in _RE_FEE_LINE.finditer(raw):
|
||||
_add(
|
||||
(hit.group("name") or "").strip(),
|
||||
@@ -736,6 +761,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
|
||||
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
||||
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
|
||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||
"""
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
@@ -772,11 +798,19 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
if not amount:
|
||||
return {}
|
||||
ccy = _currency_of(raw)
|
||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||
named = re.search(
|
||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||
raw,
|
||||
)
|
||||
fee_name = (named.group(1) if named else "") or "海运费"
|
||||
if fee_name in {"费用"}:
|
||||
fee_name = "海运费"
|
||||
return {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
"source_label": "产品报价",
|
||||
"raw_text": raw,
|
||||
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
|
||||
"fee_lines": [{"name": "海运费", "amount": amount, "currency": ccy}],
|
||||
"fee_rows": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||
"fee_lines": [{"name": fee_name, "amount": amount, "currency": ccy}],
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ def build_adjust_messages(text: str, fee_names: list[str]) -> list[dict[str, str
|
||||
"必须调用 extract_quote_adjust。不要对销售说话,不要改价。"
|
||||
"口令不写死:上升、上浮、往上抬、加一成、涨一点、改到某金额、在现有报价基础上调整都算调价。"
|
||||
"没点名费用、只说报价:is_adjust=true,wants_total=false,items 为空。"
|
||||
"只动总销售价/总成本价/合计/总价/汇总价:wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
|
||||
"只动费用合计(销售也可能说总销售价、总成本价、合计、总价、汇总价):wants_total=true,items 仍要一条(fee_name 可空),带 kind/direction/value。"
|
||||
"kind 只能 percent 或 target;direction 只能 up/down/set。"
|
||||
"value 必须来自原话数字;一成=10。禁止编造费用名和金额。"
|
||||
"不是调价(问候、新询价、谢谢)时 is_adjust=false。"
|
||||
|
||||
@@ -226,6 +226,8 @@ def send_air_brief_and_sheet(
|
||||
空运群再发一版摘要 + 匹配到的空运模板。对齐海运 send_brief_and_sheet。
|
||||
|
||||
必须先发出工单信息,模板等摘要发出后再发,禁止后台线程抢先发文件。
|
||||
填表报价必须带上 TMS 总销售价:群票上的 latest quote 常只有增点费/拆板费明细,
|
||||
若直接拿去出单,预估费用会落成明细加总(如 200)而不是 salePrice(如 7918.25)。
|
||||
"""
|
||||
ticket = hydrate_ticket(flow, ticket)
|
||||
view = ticket_view(ticket)
|
||||
@@ -247,6 +249,8 @@ def send_air_brief_and_sheet(
|
||||
if not manual and copy.quote_is_product(override or view_q):
|
||||
manual = override or view_q
|
||||
quote = dict(override or view_q or tms or manual)
|
||||
# 出单专用:把 TMS salePrice 盖回薄报价,避免预估费用=明细 SUM。
|
||||
sheet_quote = _sheet_quote_with_tms_sale_total(quote, tms=tms, facts=facts)
|
||||
sales_name, airline_names, mention_ids = resolve_air_mentions(flow, view)
|
||||
send_group_text(
|
||||
group_client_of(flow),
|
||||
@@ -264,10 +268,133 @@ def send_air_brief_and_sheet(
|
||||
),
|
||||
mention_userids=mention_ids,
|
||||
)
|
||||
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=quote)
|
||||
_send_air_activate_sheet(flow=flow, ticket=view, chat_id=room, quote=sheet_quote)
|
||||
return "brief_and_sheet"
|
||||
|
||||
|
||||
def _air_usable_sale_total(quote: Optional[dict[str, Any]]) -> str:
|
||||
"""
|
||||
空运出单可用的总销售价:顶层 total / salePrice,或选定/首条线路 salePrice。
|
||||
「-」与空串不算。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
for key in ("total", "total_amount", "salePrice", "totalSalePrice"):
|
||||
val = str(src.get(key) or "").strip()
|
||||
if val and val != "-":
|
||||
return val
|
||||
selected = src.get("selected_air_option") or src.get("selectedAirOption")
|
||||
if isinstance(selected, dict):
|
||||
val = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
|
||||
if val and val != "-":
|
||||
return val
|
||||
raw_opts = src.get("airOptions") or src.get("air_options") or []
|
||||
if isinstance(raw_opts, list) and raw_opts and isinstance(raw_opts[0], dict):
|
||||
val = str(raw_opts[0].get("salePrice") or raw_opts[0].get("sale_price") or "").strip()
|
||||
if val and val != "-":
|
||||
return val
|
||||
return ""
|
||||
|
||||
|
||||
def _money_plain_number(text: str) -> Optional[float]:
|
||||
"""从「CNY 7918.25」类文本抽出数字;解不开返回 None。"""
|
||||
raw = str(text or "").strip()
|
||||
if not raw or raw == "-":
|
||||
return None
|
||||
digits = "".join(ch if (ch.isdigit() or ch in ".-") else " " for ch in raw).split()
|
||||
if not digits:
|
||||
return None
|
||||
try:
|
||||
return float(digits[-1].replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _total_matches_fee_row_sum(quote: dict[str, Any], total_text: str) -> bool:
|
||||
"""
|
||||
顶层 total 是否只是明细金额相加(如增点费100+拆板费100=200)。
|
||||
是则说明缺了 TMS 总销售价,不能当真预估费用。
|
||||
"""
|
||||
total_num = _money_plain_number(total_text)
|
||||
if total_num is None:
|
||||
return False
|
||||
rows = quote.get("fee_rows") or quote.get("fee_lines") or quote.get("feeItems") or []
|
||||
if isinstance(rows, str) and rows.strip():
|
||||
try:
|
||||
rows = json.loads(rows)
|
||||
except json.JSONDecodeError:
|
||||
rows = []
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return False
|
||||
summed = 0.0
|
||||
hit = 0
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
amt = _money_plain_number(
|
||||
str(
|
||||
item.get("amount")
|
||||
or item.get("value")
|
||||
or item.get("salePrice")
|
||||
or item.get("price")
|
||||
or ""
|
||||
)
|
||||
)
|
||||
if amt is None:
|
||||
continue
|
||||
summed += amt
|
||||
hit += 1
|
||||
if hit <= 0:
|
||||
return False
|
||||
return abs(total_num - summed) < 0.01
|
||||
|
||||
|
||||
def _sheet_quote_with_tms_sale_total(
|
||||
quote: dict[str, Any],
|
||||
*,
|
||||
tms: Optional[dict[str, Any]] = None,
|
||||
facts: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
群填表用的报价包:保留产品/航线改过的明细,但预估费用必须能落到 TMS 总销售价。
|
||||
|
||||
协同群 latest quote 常只有 departureCharges 投影出的附加费行,total 空或被加成 200;
|
||||
销售 1V1 会话里带了 salePrice,所以同一工单两边会不一致。这里出单前对齐。
|
||||
"""
|
||||
from agent.schema.air_route_options import project_group_air_quote
|
||||
|
||||
out = dict(quote or {})
|
||||
source = dict(tms or {}) or dict(out)
|
||||
if not source:
|
||||
return out
|
||||
projected = project_group_air_quote(source, dict(facts or {}))
|
||||
tms_total = _air_usable_sale_total(projected)
|
||||
cur_total = _air_usable_sale_total(out)
|
||||
# 补齐线路对象 / 计费重,便于主账从 airOptions.salePrice 再兜底一次。
|
||||
for key in (
|
||||
"airOptions",
|
||||
"air_options",
|
||||
"selected_air_option",
|
||||
"selectedAirOption",
|
||||
"chargeableWeightKg",
|
||||
"chargeable_weight_kg",
|
||||
"currency",
|
||||
"valid_until",
|
||||
):
|
||||
cur = out.get(key)
|
||||
empty = cur in (None, "", "-", [], {})
|
||||
proj_val = projected.get(key)
|
||||
if empty and proj_val not in (None, "", "-", [], {}):
|
||||
out[key] = proj_val
|
||||
if not tms_total:
|
||||
return out
|
||||
need_tms_total = (not cur_total) or cur_total == "-" or _total_matches_fee_row_sum(out, cur_total)
|
||||
if need_tms_total:
|
||||
out["total"] = str(projected.get("total") or tms_total).strip()
|
||||
if projected.get("currency") and not str(out.get("currency") or "").strip():
|
||||
out["currency"] = projected.get("currency")
|
||||
return out
|
||||
|
||||
|
||||
def resolve_air_mentions(flow: Any, ticket: object) -> tuple[str, list[str], list[str]]:
|
||||
"""
|
||||
空运群摘要必须 @ 到本单销售和航线·空运,对上海运 @ 销售/@ 产品。
|
||||
@@ -581,16 +708,21 @@ def apply_air_confirm_tms(*, flow: Any, ticket: object, sender_id: str, chat_id:
|
||||
quote["source_label"] = "航线确认TMS"
|
||||
no = ticket_no(ticket)
|
||||
_clear_air_pending(flow, no, sender_id)
|
||||
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=quote, to_status="已报价") or {}
|
||||
# 确认 TMS 出单:同样用总销售价,避免只落附加费明细时预估费用=200。
|
||||
sheet_quote = _sheet_quote_with_tms_sale_total(
|
||||
quote, tms=quote, facts=dict(view.get("facts") or {})
|
||||
)
|
||||
saved = flow.ledger.upsert_product_quote(work_order_no=no, quote=sheet_quote, to_status="已报价") or {}
|
||||
if not saved.get("ok"):
|
||||
send_group_text(group_client_of(flow), chat_id, "主账写入报价失败。")
|
||||
return "ledger_fail"
|
||||
view["collab_chat_id"] = chat_id
|
||||
view["quote"] = sheet_quote
|
||||
_persist_quote_sheet(
|
||||
flow=flow,
|
||||
ticket=view,
|
||||
sess=None,
|
||||
quote=quote,
|
||||
quote=sheet_quote,
|
||||
sender_id=sender_id,
|
||||
business_line="AIR",
|
||||
)
|
||||
@@ -658,6 +790,7 @@ def apply_air_lock(
|
||||
chat_id: str,
|
||||
instruction: str,
|
||||
option_no: str = "",
|
||||
airline: str = "",
|
||||
) -> str:
|
||||
if not is_airline(flow, sender_id):
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_airline_only_cabin())
|
||||
@@ -680,10 +813,8 @@ def apply_air_lock(
|
||||
)
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_ask_lock_option(options))
|
||||
return "wait_lock_option"
|
||||
if not picked and options:
|
||||
if not picked and len(options) == 1:
|
||||
picked = str(options[0].get("optionNo") or options[0].get("option_no") or "").strip()
|
||||
if not picked:
|
||||
picked = "AIR-OPT-01"
|
||||
locker = getattr(flow.ledger, "lock_cabin", None)
|
||||
if not callable(locker):
|
||||
send_group_text(
|
||||
@@ -707,11 +838,15 @@ def apply_air_lock(
|
||||
sender_id=sender_id,
|
||||
operator_name=name,
|
||||
instruction_text=instruction,
|
||||
airline=(airline or "").strip(),
|
||||
)
|
||||
or {},
|
||||
is_ok=lambda x: bool((x or {}).get("ok")),
|
||||
)
|
||||
if not out.get("ok"):
|
||||
if str(out.get("error") or "") == "no_quote":
|
||||
send_group_text(group_client_of(flow), chat_id, copy.air_lock_need_quote(no))
|
||||
return "no_quote"
|
||||
why = copy.air_cabin_fail_reason(
|
||||
tms_reason=str(out.get("failReason") or out.get("error") or ""),
|
||||
flight_date=_ticket_flight_date(ticket),
|
||||
|
||||
@@ -1709,7 +1709,7 @@ class AirTextInquiryFlow:
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
self._save(sess)
|
||||
reply(copy.lost_reason_ack(text))
|
||||
reply(copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||||
return "done"
|
||||
|
||||
def mark_wait_deal(self, sender_id: str, work_order_no: str) -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
@@ -22,6 +23,14 @@ EnqueueFn = Callable[..., tuple[bool, str]]
|
||||
_LOST_MARKERS = ("未成交", "没成交", "没有成交", "没做成", "黄了")
|
||||
_QUESTION_TAILS = frozenset({"吗", "呢", "吧", "啊", "呀", "嘛", "?", "?"})
|
||||
_KEEP_REASON_AS_IS = frozenset({"原因不明", "原因不详", "理由不明", "理由不详"})
|
||||
# 群里 @询价小助手 是用来叫醒机器人的,不是未成交原因。
|
||||
_MENTION = re.compile(r"@[^\s,,。.;;::、@]+")
|
||||
|
||||
|
||||
def strip_bot_mentions(text: str) -> str:
|
||||
"""去掉 @某人。原因前后都可能带,去掉后不留空标点。"""
|
||||
cleaned = _MENTION.sub("", text or "")
|
||||
return cleaned.strip(",,。.;;::、 ")
|
||||
|
||||
|
||||
def harvest_inline_lost_reason(text: str) -> str:
|
||||
@@ -56,7 +65,10 @@ def harvest_inline_lost_reason(text: str) -> str:
|
||||
break
|
||||
if rest in _QUESTION_TAILS:
|
||||
return ""
|
||||
return rest.strip()
|
||||
rest = strip_bot_mentions(rest)
|
||||
if rest in _QUESTION_TAILS:
|
||||
return ""
|
||||
return rest
|
||||
|
||||
|
||||
def looks_like_lost_outcome(text: str) -> bool:
|
||||
@@ -211,8 +223,8 @@ def finish_deal_outcome(
|
||||
|
||||
|
||||
def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict[str, Any]:
|
||||
"""把销售下一句写进未成交原因。"""
|
||||
text = (reason or "").strip()
|
||||
"""把销售下一句写进未成交原因。@询价小助手 不是原因。"""
|
||||
text = strip_bot_mentions(reason or "")
|
||||
no = (work_order_no or "").strip()
|
||||
if not no or not text:
|
||||
return {"ok": False, "error": "blank"}
|
||||
@@ -220,6 +232,8 @@ def capture_lost_reason(*, ledger: Any, work_order_no: str, reason: str) -> dict
|
||||
if not callable(patcher):
|
||||
return {"ok": False, "error": "no_patch"}
|
||||
out = patcher(work_order_no=no, lost_reason=text) or {"ok": False}
|
||||
if isinstance(out, dict):
|
||||
out.setdefault("lost_reason", text)
|
||||
if out.get("ok"):
|
||||
from agent.policy.event_exception import clear_wait
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ _SALES_CODES = {
|
||||
}
|
||||
|
||||
_LAND_PRODUCT_CODES = {
|
||||
"普件(常用线路/常规车型)": "land_common",
|
||||
"普件(东南亚、中港常用线路/常规车型)": "land_hk_common",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)": "land_hk_cross",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)": "land_hk_special",
|
||||
|
||||
@@ -856,7 +856,7 @@ def _total_label(
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
) -> str:
|
||||
"""空运总销售价;海运/陆运看这版报价是成本还是销售调价。"""
|
||||
"""销售侧汇总行。企微卡片、文字明细、成交通知都叫费用合计。"""
|
||||
src = dict(quote or {})
|
||||
return total_price_label(
|
||||
business_line or str(src.get("business_line") or ""),
|
||||
@@ -2239,13 +2239,14 @@ def adjust_only_sales() -> str:
|
||||
|
||||
|
||||
def adjust_group_body(quote: dict[str, Any] | None, business_line: str = "") -> str:
|
||||
"""群里调后全量明细,利润点不写给销售。"""
|
||||
"""
|
||||
群里调后全量明细,利润点不写给销售。
|
||||
|
||||
海运必须带三段标题与小计(始发港/海运/目的港),禁止只摊平 fee_lines 把小计弄丢。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
|
||||
header = str(src.get("currency") or "")
|
||||
total = money_text(str(src.get("total") or "-").strip() or "-", header)
|
||||
lines.append(f"{_total_label(src, business_line)}:{total}")
|
||||
return "调价后价格:\n" + "\n".join(lines)
|
||||
text = quote_area_fee_text(src, business_line=business_line)
|
||||
return "调价后价格:\n" + text
|
||||
|
||||
|
||||
def adopt_excel_ack() -> str:
|
||||
@@ -2282,7 +2283,7 @@ def deal_horizontal_rows(
|
||||
transport_mode: str = "",
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
|
||||
成交跟进卡字段:费用合计、报价有效期;海运/陆运再加时效。
|
||||
|
||||
空运业务确认没有时效,这里不写。
|
||||
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
|
||||
@@ -2339,7 +2340,7 @@ def deal_wecom_payload(
|
||||
成交跟进卡:对齐现网 button_interaction。
|
||||
|
||||
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
|
||||
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
字段:费用合计、报价有效期;海运/陆运再加时效。空运不写时效。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
|
||||
@@ -2593,6 +2594,19 @@ def deal_notify_card(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def site_card_url() -> str:
|
||||
"""
|
||||
整张卡片点击后打开的后台地址。
|
||||
|
||||
事件异常、系统异常、成交通知都走这里,点开是同一个网站。
|
||||
不用企业微信授权页,也不要用 work.weixin.qq.com。
|
||||
"""
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
return base or "https://ai.ytd-scm.com"
|
||||
|
||||
|
||||
def deal_notify_wecom_payload(
|
||||
*,
|
||||
work_order_no: str,
|
||||
@@ -2615,10 +2629,7 @@ def deal_notify_wecom_payload(
|
||||
quote=dict(quote or {}),
|
||||
transport_mode=mode,
|
||||
)
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
action_url = site_card_url()
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
@@ -2801,8 +2812,13 @@ def air_fields_denied() -> str:
|
||||
|
||||
|
||||
def air_no_tms_quote_to_lock(work_order_no: str) -> str:
|
||||
return air_lock_need_quote(work_order_no)
|
||||
|
||||
|
||||
def air_lock_need_quote(work_order_no: str) -> str:
|
||||
"""没有任何报价时的锁舱拒绝。不提 TMS,避免把人工报价说成没报价。"""
|
||||
no = (work_order_no or "").strip()
|
||||
return f"工单{no}还没有可锁的 TMS 报价,不能锁舱。"
|
||||
return f"工单{no}还没有报价,不能锁舱。"
|
||||
|
||||
|
||||
def air_ask_lock_option(options: list) -> str:
|
||||
@@ -2847,7 +2863,7 @@ def air_lock_fail(*, work_order_no: str, operator: str, reason: str) -> str:
|
||||
def air_cabin_fail_reason(*, tms_reason: str = "", flight_date: str = "", today: str = "") -> str:
|
||||
"""
|
||||
锁舱/释放失败原因:原样回 TMS 的 failReason。
|
||||
没有报价也可以锁舱,禁止改写成「报价选项不存在」或「航班已起飞」。
|
||||
禁止改写成「报价选项不存在」或「航班已起飞」。
|
||||
flight_date / today 只为兼容旧调用,不再用来改写原文。
|
||||
"""
|
||||
_ = flight_date
|
||||
@@ -2991,10 +3007,7 @@ def system_exception_alert_payload(
|
||||
{"keyname": "影响范围", "value": system_exception_impact(step)},
|
||||
{"keyname": "发生时间", "value": (occurred_at or "").strip() or "-"},
|
||||
]
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
action_url = site_card_url()
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
@@ -3090,10 +3103,7 @@ def event_exception_alert_payload(
|
||||
no = (work_order_no or "").strip() or "-"
|
||||
title = (card_title or "").strip() or "事件响应异常"
|
||||
hour_text = format_timeout_hours(hours)
|
||||
from agent.config import get_settings
|
||||
|
||||
base = str(getattr(get_settings(), "public_base_url", "") or "").strip().rstrip("/")
|
||||
action_url = base or "https://work.weixin.qq.com"
|
||||
action_url = site_card_url()
|
||||
return {
|
||||
"msgtype": "template_card",
|
||||
"wecom_app": "notify",
|
||||
|
||||
@@ -80,17 +80,25 @@ def split_segments(text: str) -> SplitOutcome:
|
||||
"""
|
||||
按运输方式出现的先后切段。
|
||||
|
||||
「段1:海运」和同一句里的「陆运+海运」都认。同一方式出现两次则 same_mode,不建单。
|
||||
已经标了两段以上时,只按「段N:」切,表里重复的「海运/陆运」不当新段。
|
||||
「段1:海运」和同一句里的「陆运+海运」都认。
|
||||
写了「段N:」就只按段标切:一份海运报价单文件名会先标「段1:海运」,
|
||||
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
|
||||
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
|
||||
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
|
||||
每段正文只收「字段名:值」,不猜没写标签的港口。
|
||||
"""
|
||||
raw = text or ""
|
||||
explicit = list(_EXPLICIT_SEG.finditer(raw))
|
||||
hits = explicit if len(explicit) >= 2 else list(_MODE_RE.finditer(raw))
|
||||
# explicit_only_segment_split:有段标就只认段标。一份附件常被标「段1:海运」,表里还会再写一次海运。
|
||||
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]
|
||||
if len(modes) != len(set(modes)):
|
||||
unique = set(modes)
|
||||
if len(modes) != len(unique):
|
||||
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
|
||||
if len(explicit) < 2 and len(unique) == 1:
|
||||
return SplitOutcome()
|
||||
return SplitOutcome(same_mode=True)
|
||||
segments: list[Segment] = []
|
||||
for i, hit in enumerate(hits):
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Optional
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.quote_adjust import (
|
||||
adjustable_rows,
|
||||
apply_adjust,
|
||||
extract_adjust,
|
||||
private_session_can_adjust,
|
||||
@@ -151,7 +152,7 @@ def try_private_adjust(
|
||||
"""
|
||||
if not session_quoted(sess):
|
||||
return None
|
||||
names = [str(r.get("name") or "") for r in quote_fee_rows(getattr(sess, "quote", None))]
|
||||
names = [str(r.get("name") or "") for r in adjustable_rows(getattr(sess, "quote", None))]
|
||||
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
|
||||
if not extract.is_adjust:
|
||||
return None
|
||||
@@ -262,7 +263,7 @@ def apply_group_adjust(
|
||||
send_group_text(client, room, copy.adjust_only_sales())
|
||||
return "adjust_not_sales"
|
||||
current = _current_quote(view, sess)
|
||||
names = [str(r.get("name") or "") for r in quote_fee_rows(current)]
|
||||
names = [str(r.get("name") or "") for r in adjustable_rows(current)]
|
||||
extract = extract_adjust(text, fee_names=names, injected=injected_adjust)
|
||||
if not extract.is_adjust:
|
||||
return "not_adjust"
|
||||
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
主账填表只认英文码,货好时间 / 海关编码 / 含油电磁常空着。
|
||||
本文件只改 Excel 字节,不打主账、不占回调线程。
|
||||
|
||||
注意:openpyxl 保存会弄坏海运顶部 Form Control(drawing/VML/sheet1 锚点)。
|
||||
因此只用来「读」标签位置;真正写入走 zip 内定点改格,不动控件骨架。
|
||||
无待写字段、或目标格已是目标值时原样返回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
@@ -30,6 +35,18 @@ _COLLAB_LABEL_MARKERS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("包装方式", ("包装方式", "包装类型")),
|
||||
)
|
||||
|
||||
# 旧路径兜底:若仍误走 openpyxl 保存,至少盖回这些部件
|
||||
_PRESERVE_ZIP_PARTS = (
|
||||
"xl/drawings/drawing1.xml",
|
||||
"xl/drawings/vmlDrawing1.vml",
|
||||
"xl/styles.xml",
|
||||
"xl/ctrlProps/ctrlProp1.xml",
|
||||
"xl/ctrlProps/ctrlProp2.xml",
|
||||
"xl/worksheets/sheet1.xml",
|
||||
"xl/worksheets/_rels/sheet1.xml.rels",
|
||||
"[Content_Types].xml",
|
||||
)
|
||||
|
||||
|
||||
def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
@@ -39,6 +56,102 @@ def _fact_for_markers(facts: Mapping[str, Any], keys: tuple[str, ...]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _restore_sea_draw_parts(original: bytes, rewritten: bytes) -> bytes:
|
||||
"""把原件里的 drawing/VML/ctrl/styles/sheet1 控件骨架盖回 openpyxl 重写后的 xlsx。"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(original)) as zin:
|
||||
keep = {
|
||||
name: zin.read(name)
|
||||
for name in _PRESERVE_ZIP_PARTS
|
||||
if name in zin.namelist()
|
||||
}
|
||||
if not keep:
|
||||
return rewritten
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(rewritten)) as zin, zipfile.ZipFile(
|
||||
out, "w", compression=zipfile.ZIP_DEFLATED
|
||||
) as zout:
|
||||
for info in zin.infolist():
|
||||
data = keep.get(info.filename) or zin.read(info.filename)
|
||||
zout.writestr(info, data)
|
||||
for name, data in keep.items():
|
||||
if name not in zin.namelist():
|
||||
zout.writestr(name, data)
|
||||
return out.getvalue()
|
||||
except Exception:
|
||||
logger.exception("overlay 盖回海运 drawing 失败,退回 openpyxl 结果")
|
||||
return rewritten
|
||||
|
||||
|
||||
def _col_letter(col: int) -> str:
|
||||
"""1-based 列号 → Excel 列字母。"""
|
||||
n = int(col)
|
||||
letters: list[str] = []
|
||||
while n > 0:
|
||||
n, rem = divmod(n - 1, 26)
|
||||
letters.append(chr(65 + rem))
|
||||
return "".join(reversed(letters)) or "A"
|
||||
|
||||
|
||||
def _xml_escape(text: str) -> str:
|
||||
return (
|
||||
str(text)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def _patch_cells_keep_structure(data: bytes, cells: list[tuple[int, int, str]]) -> bytes:
|
||||
"""
|
||||
只改 sheet1 里指定格的字符串,保留 AlternateContent / 控件关系。
|
||||
|
||||
禁止整表经 openpyxl 重存:否则拉群协同 Excel 顶部 Port/其它单选会空白,
|
||||
而「直接生成」不经本函数所以看起来正常(WO031 vs WO033)。
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
if not cells:
|
||||
return data
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zin:
|
||||
if "xl/worksheets/sheet1.xml" not in zin.namelist():
|
||||
return data
|
||||
sheet = zin.read("xl/worksheets/sheet1.xml").decode("utf-8", "replace")
|
||||
others = {name: zin.read(name) for name in zin.namelist() if name != "xl/worksheets/sheet1.xml"}
|
||||
|
||||
for row, col, val in cells:
|
||||
ref = f"{_col_letter(col)}{int(row)}"
|
||||
cell_xml = (
|
||||
f'<c r="{ref}" t="inlineStr"><is><t>{_xml_escape(val)}</t></is></c>'
|
||||
)
|
||||
pat = re.compile(rf'<c\b[^>]*\br="{re.escape(ref)}"[^>]*(?:/>|>[\s\S]*?</c>)')
|
||||
if pat.search(sheet):
|
||||
sheet = pat.sub(cell_xml, sheet, count=1)
|
||||
continue
|
||||
row_pat = re.compile(rf'(<row\b[^>]*\br="{int(row)}"[^>]*>)([\s\S]*?)(</row>)')
|
||||
m = row_pat.search(sheet)
|
||||
if m:
|
||||
sheet = sheet[: m.start(3)] + cell_xml + sheet[m.start(3) :]
|
||||
continue
|
||||
sheet = sheet.replace(
|
||||
"</sheetData>",
|
||||
f'<row r="{int(row)}">{cell_xml}</row></sheetData>',
|
||||
1,
|
||||
)
|
||||
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, blob in others.items():
|
||||
zout.writestr(name, blob)
|
||||
zout.writestr("xl/worksheets/sheet1.xml", sheet.encode("utf-8"))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def overlay_labeled_facts(
|
||||
*,
|
||||
facts: Mapping[str, Any],
|
||||
@@ -48,7 +161,8 @@ def overlay_labeled_facts(
|
||||
"""
|
||||
按模板标签再写协同格。失败则原样交回。
|
||||
|
||||
副作用:可能写临时 xlsx。
|
||||
副作用:可能写临时 xlsx。无待写字段、或格子已是目标值时不改字节,
|
||||
避免毁掉海运顶部运输分类单选。
|
||||
"""
|
||||
import base64
|
||||
import tempfile
|
||||
@@ -91,20 +205,30 @@ def overlay_labeled_facts(
|
||||
if not val:
|
||||
continue
|
||||
dest = _label_value_cell(ws, cell)
|
||||
# 主账已填过同值(如贸易条款):再写会经 openpyxl 毁掉单选
|
||||
if str(dest.value or "").strip() == val:
|
||||
used_rows.add(cell.row)
|
||||
break
|
||||
pending.append((dest.row, dest.column, val))
|
||||
used_rows.add(cell.row)
|
||||
break
|
||||
for r, c, val in pending:
|
||||
ws.cell(row=r, column=c, value=val)
|
||||
written += 1
|
||||
wb.save(tmp)
|
||||
wb.close()
|
||||
if not pending:
|
||||
# 无增量可写:原样返回,禁止重存
|
||||
logger.info("overlay 协同字段 written=0 keep_original")
|
||||
return {
|
||||
"filepath": filepath or str(tmp),
|
||||
"file_b64": file_b64 or base64.b64encode(raw).decode("ascii"),
|
||||
}
|
||||
patched = _patch_cells_keep_structure(raw, pending)
|
||||
tmp.write_bytes(patched)
|
||||
written = len(pending)
|
||||
except Exception:
|
||||
logger.exception("overlay 协同字段失败")
|
||||
return {"filepath": filepath, "file_b64": file_b64}
|
||||
|
||||
out_b64 = base64.b64encode(tmp.read_bytes()).decode("ascii")
|
||||
logger.info("overlay 协同字段 written=%s", written)
|
||||
logger.info("overlay 协同字段 written=%s keep_structure=1", written)
|
||||
return {"filepath": str(tmp), "file_b64": out_b64}
|
||||
|
||||
|
||||
|
||||
@@ -401,17 +401,21 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
)
|
||||
)
|
||||
quote = dict(view.get("quote") or {})
|
||||
has_quote = bool(quote.get("total") or _quote_fee_rows(quote))
|
||||
# total="-" 在 Python 里也是 truthy,不能当成已有总价;只有附加费明细也不算完整。
|
||||
has_sale_total = _quote_has_usable_sale_total(quote)
|
||||
has_fee_rows = bool(_quote_fee_rows(quote))
|
||||
has_quote = has_sale_total or has_fee_rows
|
||||
has_status = bool(ticket_status(ticket))
|
||||
has_tms = bool(dict(view.get("tms_quote") or view.get("tmsQuote") or {}))
|
||||
# 调价后 latest quote 仍有价,但不能因此跳过补 TMS 原价。
|
||||
if has_facts and has_quote and has_status and has_tms:
|
||||
return ticket
|
||||
# 仅有 fee_rows、没有可用总价时仍要往下补全,否则空运出单预估费用会落成明细 SUM。
|
||||
if has_facts and has_sale_total and has_status and has_tms:
|
||||
return _soft_fill_quote_sale_fields(ticket)
|
||||
no = ticket_no(ticket)
|
||||
sales = str(view.get("sales_wecom_id") or view.get("salesWecomId") or "").strip()
|
||||
book = getattr(flow, "ledger", None)
|
||||
if book is None or not no:
|
||||
return ticket
|
||||
return _soft_fill_quote_sale_fields(ticket)
|
||||
full: Any = None
|
||||
getter = getattr(book, "get_for_agent", None)
|
||||
if callable(getter) and sales:
|
||||
@@ -423,13 +427,33 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
if callable(getter):
|
||||
full = getter(work_order_no=no)
|
||||
if full is None:
|
||||
return ticket
|
||||
return _soft_fill_quote_sale_fields(ticket)
|
||||
merged = dict(view)
|
||||
full_view = ticket_view(full)
|
||||
if (not has_facts) and full_view.get("facts"):
|
||||
merged["facts"] = dict(full_view.get("facts") or {})
|
||||
if (not has_quote) and full_view.get("quote"):
|
||||
merged["quote"] = dict(full_view.get("quote") or {})
|
||||
if (not has_sale_total) and full_view.get("quote"):
|
||||
# 薄报价保留已有明细,补上完整快照里的总价/线路。
|
||||
rich = dict(full_view.get("quote") or {})
|
||||
cur = dict(quote)
|
||||
for key in (
|
||||
"total",
|
||||
"salePrice",
|
||||
"totalSalePrice",
|
||||
"airOptions",
|
||||
"air_options",
|
||||
"selected_air_option",
|
||||
"selectedAirOption",
|
||||
"chargeableWeightKg",
|
||||
"chargeable_weight_kg",
|
||||
"currency",
|
||||
"valid_until",
|
||||
):
|
||||
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
|
||||
cur[key] = rich[key]
|
||||
if not has_fee_rows and (rich.get("fee_lines") or rich.get("fee_rows")):
|
||||
cur["fee_lines"] = rich.get("fee_lines") or rich.get("fee_rows")
|
||||
merged["quote"] = cur
|
||||
if full_view.get("tms_quote") or full_view.get("tmsQuote"):
|
||||
merged["tms_quote"] = dict(full_view.get("tms_quote") or full_view.get("tmsQuote") or {})
|
||||
if (not has_status) and full_view.get("status"):
|
||||
@@ -449,6 +473,66 @@ def hydrate_ticket(flow: Any, ticket: object) -> object:
|
||||
):
|
||||
if full_view.get(key) and not merged.get(key):
|
||||
merged[key] = full_view.get(key)
|
||||
return _soft_fill_quote_sale_fields(merged)
|
||||
|
||||
|
||||
def _quote_has_usable_sale_total(quote: dict[str, Any] | None) -> bool:
|
||||
"""
|
||||
是否已有可用于出单预估费用的总价。
|
||||
total="-" 不算;仅有 fee_rows 也不算。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
total = str(src.get("total") or src.get("total_amount") or src.get("salePrice") or "").strip()
|
||||
if total and total != "-":
|
||||
return True
|
||||
selected = src.get("selected_air_option") or src.get("selectedAirOption")
|
||||
if isinstance(selected, dict):
|
||||
sale = str(selected.get("salePrice") or selected.get("sale_price") or "").strip()
|
||||
if sale and sale != "-":
|
||||
return True
|
||||
opts = src.get("airOptions") or src.get("air_options") or []
|
||||
if isinstance(opts, list) and opts and isinstance(opts[0], dict):
|
||||
sale = str(opts[0].get("salePrice") or opts[0].get("sale_price") or "").strip()
|
||||
if sale and sale != "-":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _soft_fill_quote_sale_fields(ticket: object) -> object:
|
||||
"""
|
||||
从 tms_quote 补齐当前 quote 缺失的总销售价 / 线路对象。
|
||||
群 findByCollabChat 常只有薄 latest quote,出单前必须能落到 salePrice。
|
||||
"""
|
||||
view = ticket_view(ticket)
|
||||
cur = dict(view.get("quote") or {})
|
||||
rich = dict(view.get("tms_quote") or view.get("tmsQuote") or {})
|
||||
if not rich:
|
||||
return ticket if isinstance(ticket, dict) else view
|
||||
changed = False
|
||||
for key in (
|
||||
"total",
|
||||
"salePrice",
|
||||
"totalSalePrice",
|
||||
"airOptions",
|
||||
"air_options",
|
||||
"selected_air_option",
|
||||
"selectedAirOption",
|
||||
"chargeableWeightKg",
|
||||
"chargeable_weight_kg",
|
||||
"currency",
|
||||
"valid_until",
|
||||
):
|
||||
if cur.get(key) in (None, "", "-", [], {}) and rich.get(key) not in (None, "", "-", [], {}):
|
||||
cur[key] = rich[key]
|
||||
changed = True
|
||||
if not changed:
|
||||
return ticket if isinstance(ticket, dict) else view
|
||||
if isinstance(ticket, dict):
|
||||
out = dict(ticket)
|
||||
out["quote"] = cur
|
||||
return out
|
||||
merged = dict(view)
|
||||
merged["quote"] = cur
|
||||
return merged
|
||||
|
||||
|
||||
@@ -1204,7 +1288,7 @@ def capture_group_lost_reason(
|
||||
sess.phase = "done"
|
||||
sess.allowed = ()
|
||||
flow._save(sess)
|
||||
send_group_text(client, chat_id, copy.lost_reason_ack(text))
|
||||
send_group_text(client, chat_id, copy.lost_reason_ack(str(result.get("lost_reason") or text)))
|
||||
return "done"
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
from agent.schema.sea_options import drop_invented_door_trade_terms, harvest_sea_class
|
||||
|
||||
facts = harvest_sea_class(text, facts)
|
||||
# 旧陆运/补问草稿可能把「门到门」塞进贸易条款;原话没写条款则丢掉。
|
||||
# 贸易条款标签行原样优先(Port to Port 等);再丢掉无标签的冒充「门到门」
|
||||
facts = drop_invented_door_trade_terms(text, facts)
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
facts = dict(check["facts"])
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.routing.group_intent import (
|
||||
INTENT_NEGOTIATE,
|
||||
INTENT_OTHER,
|
||||
INTENT_QUOTE,
|
||||
_exact_tms_choice,
|
||||
_hint_confirm_tms,
|
||||
_hint_deal_outcome,
|
||||
_hint_tms_choice,
|
||||
@@ -94,6 +95,14 @@ def classify_air_group_text(
|
||||
locked = _hint_lock_or_release(raw)
|
||||
if locked:
|
||||
return locked
|
||||
# 本单已有 TMS 报价时,航线回「2」是选不用 TMS,不是把价格改成 2。
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
return exact
|
||||
# wo_plus_deal_not_activate:工单号+成交跟进同一句先关单,不能只当激活。
|
||||
closed = _hint_deal_outcome(raw)
|
||||
if copy.extract_work_order_no(raw) and closed:
|
||||
return closed
|
||||
# 只发工单号先当激活,不要先打模型;WO + 12 位数字也会被费用行误认成金额。
|
||||
if copy.extract_work_order_no(raw) and _only_work_order(raw):
|
||||
return INTENT_ACTIVATE
|
||||
@@ -178,6 +187,7 @@ def _classify_with_model(text: str, settings) -> str:
|
||||
"deal_negotiate=销售要继续协商;"
|
||||
"other=闲聊。"
|
||||
"一句话里既有锁舱又有工单号,回 air_lock。"
|
||||
"一句话里既有工单号又确认成交/未成交/协商中,回 deal_won/deal_lost/deal_negotiate,不要回 air_activate。"
|
||||
"改已有费用优先 quote_adjust;新报价格才是 product_quote。"
|
||||
)
|
||||
data = client.chat(
|
||||
|
||||
@@ -134,6 +134,10 @@ def classify_group_text(
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return INTENT_OTHER
|
||||
# 问句让回 1 或 2。整句就是选项时先认,不能把「2」抽成价格。
|
||||
exact = _exact_tms_choice(raw)
|
||||
if exact:
|
||||
return exact
|
||||
try:
|
||||
from agent.config import get_settings
|
||||
|
||||
@@ -155,9 +159,9 @@ def classify_group_text(
|
||||
logger.info("群意图模型判成%s,按口令改回 %s text=%s", got, hinted_tms, raw[:40])
|
||||
return hinted_tms
|
||||
hinted_deal = _hint_deal_outcome(raw)
|
||||
# 销售说成交时,模型不能把群拆了。
|
||||
if hinted_deal and got == INTENT_DISSOLVE:
|
||||
logger.warning("群意图模型把成交判成解散,改回 %s text=%s", hinted_deal, raw[:40])
|
||||
# 销售说成交时,模型不能把群拆了,也不能把「工单号+未成交」判成闲聊。
|
||||
if hinted_deal and got in {INTENT_DISSOLVE, INTENT_OTHER, INTENT_FIELDS}:
|
||||
logger.warning("群意图模型把成交判成%s,改回 %s text=%s", got, hinted_deal, raw[:40])
|
||||
return hinted_deal
|
||||
return got
|
||||
except Exception:
|
||||
@@ -184,7 +188,20 @@ def classify_group_text(
|
||||
|
||||
|
||||
def _norm_intent_text(text: str) -> str:
|
||||
return (text or "").replace(" ", "").replace(" ", "").lower()
|
||||
"""去掉空格和 @询价小助手,方便整句就是「2」时对上选项。"""
|
||||
blob = (text or "").replace(" ", "").replace(" ", "").lower()
|
||||
blob = blob.replace("@询价小助手", "").replace("询价小助手", "")
|
||||
return blob.strip()
|
||||
|
||||
|
||||
def _exact_tms_choice(text: str) -> str:
|
||||
"""整句只是 1 / 2 / 第一个 / 第二个。这是在选题,不是报价金额。"""
|
||||
blob = _norm_intent_text(text)
|
||||
if blob in _KEEP_CHOICE:
|
||||
return INTENT_KEEP_TMS
|
||||
if blob in _DROP_CHOICE:
|
||||
return INTENT_DROP_TMS
|
||||
return ""
|
||||
|
||||
|
||||
def _hint_confirm_tms(text: str) -> str:
|
||||
|
||||
@@ -516,16 +516,35 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
# 箱型原文:40HQ*1、1x40HQ、40尺高柜。货物数量不能收这种串。
|
||||
_CONTAINER_SPEC = re.compile(
|
||||
r"(?i)(20|40|45)\s*['\"′尺]?\s*(HQ|GP|NOR|RH|HC|高柜|平柜|柜)"
|
||||
)
|
||||
|
||||
|
||||
def looks_like_container_spec(text: str) -> bool:
|
||||
"""这串是箱型箱量,不是件数或吨数。20件、20吨不算。"""
|
||||
return bool(_CONTAINER_SPEC.search(text or ""))
|
||||
|
||||
|
||||
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
|
||||
"""
|
||||
海运货物数量投影:原话「15件」常被抽成空运「件数」。
|
||||
海运货物数量投影。
|
||||
|
||||
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
|
||||
抽字段常把「20件」放进件数,又把「40HQ*1」写进货量。
|
||||
货量只要长得像箱型,就丢掉,改用件数里的数量;件数也是箱型则不搬。
|
||||
箱型箱量空着时,把这串箱型补到箱型箱量。不限具体件数。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
|
||||
pieces = (merged.get("件数") or "").strip()
|
||||
if not qty and pieces:
|
||||
if looks_like_container_spec(qty):
|
||||
if not (merged.get("箱型箱量") or "").strip():
|
||||
merged["箱型箱量"] = qty
|
||||
merged.pop("货量", None)
|
||||
merged.pop("货物数量", None)
|
||||
qty = ""
|
||||
if not qty and pieces and not looks_like_container_spec(pieces):
|
||||
merged["货量"] = pieces
|
||||
return merged
|
||||
|
||||
@@ -562,6 +581,38 @@ def _pricing_required_keys(
|
||||
return []
|
||||
|
||||
|
||||
_PLACE_KEYS = ("起运港", "目的港")
|
||||
_PACK_IN_PLACE = ("散货", "纸箱", "木箱", "托盘", "卡板", "木托")
|
||||
_PRICE_TAIL = re.compile(r"(的价格|的报价|价格|报价)$")
|
||||
|
||||
|
||||
def peel_trailing_place_clauses(facts: dict[str, str] | None) -> dict[str, str]:
|
||||
"""
|
||||
起运地/目的地只留逗号前的地名。
|
||||
|
||||
口语「目的地吉隆坡,普货,散货的价格」会被抽成一整串目的地。
|
||||
逗号后面的品名、包装、价格不是地名,拆回去;已有品名/包装不覆盖。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
for key in _PLACE_KEYS:
|
||||
raw = (merged.get(key) or "").strip()
|
||||
parts = [p.strip() for p in re.split(r"[,,、]", raw) if p.strip()]
|
||||
if len(parts) <= 1:
|
||||
continue
|
||||
place = parts[0]
|
||||
for part in parts[1:]:
|
||||
part = _PRICE_TAIL.sub("", part).strip()
|
||||
if not part or part in {"价格", "报价", "查价"}:
|
||||
continue
|
||||
if part in _PACK_IN_PLACE and not (merged.get("包装方式") or "").strip():
|
||||
merged["包装方式"] = part
|
||||
continue
|
||||
if not (merged.get("品名") or "").strip() and not looks_like_container_spec(part):
|
||||
merged["品名"] = part
|
||||
merged[key] = place
|
||||
return merged
|
||||
|
||||
|
||||
def display_name(internal_key: str, business_line: str = "AIR") -> str:
|
||||
"""补问时给销售看的名字。"""
|
||||
line = (business_line or "").upper()
|
||||
@@ -589,7 +640,7 @@ def validate_required_fields(
|
||||
"""
|
||||
_ = require_formal
|
||||
contract = require_formal_contract("inquiry-required-fields-v1")
|
||||
normalized = apply_quote_date_default(normalize_facts(facts))
|
||||
normalized = peel_trailing_place_clauses(apply_quote_date_default(normalize_facts(facts)))
|
||||
line = (business_line or "").strip().upper()
|
||||
if line == "SEA":
|
||||
normalized = project_sea_cargo_qty(normalized)
|
||||
|
||||
@@ -28,6 +28,7 @@ ROUTES = (
|
||||
"东南亚",
|
||||
)
|
||||
LAND_CLASSES = (
|
||||
"普件(常用线路/常规车型)",
|
||||
"普件(东南亚、中港常用线路/常规车型)",
|
||||
"普件(东南亚、中亚、中欧涉及国内+国外段)",
|
||||
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)",
|
||||
|
||||
@@ -20,7 +20,7 @@ _CLOSED = frozenset({"已成交", "未成交", "已关闭", "转人工"})
|
||||
_QUOTED = frozenset({"已报价", "协商中"})
|
||||
|
||||
_PERCENT_UP = re.compile(
|
||||
r"(上调|上涨|上升|上浮|提高|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
|
||||
r"(上调|上涨|上升|上浮|提高|提升|加|涨|升)\s*(\d+(?:\.\d+)?)\s*%"
|
||||
)
|
||||
_PERCENT_DOWN = re.compile(r"(下调|下降|降低|减|降)\s*(\d+(?:\.\d+)?)\s*%")
|
||||
_TARGET = re.compile(
|
||||
@@ -28,7 +28,7 @@ _TARGET = re.compile(
|
||||
)
|
||||
_SPLIT = re.compile(r"[,,;;。!!??\n]|和(?=\S)")
|
||||
_FEE_TOKEN = re.compile(r"([\u4e00-\u9fffA-Za-z0-9]{1,16}费)")
|
||||
_TOTAL_WORDS = ("合计", "总价", "汇总价", "总销售价", "总成本价")
|
||||
_TOTAL_WORDS = ("费用合计", "合计", "总价", "汇总价", "总销售价", "总成本价")
|
||||
_QUOTE_WORD = "报价"
|
||||
|
||||
|
||||
@@ -90,12 +90,17 @@ def _fee_key(name: str) -> str:
|
||||
return raw.strip()
|
||||
|
||||
|
||||
_TOTAL_ALIASES = frozenset({"费用合计", "总销售价", "总成本价", "汇总价", "总价"})
|
||||
|
||||
|
||||
def fee_name_same(left: str, right: str) -> bool:
|
||||
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。"""
|
||||
"""费用名对齐:海运费=海运费用;操作费 ≠ 码头操作费。汇总行的旧称呼也算同一行。"""
|
||||
a = _fee_key(left)
|
||||
b = _fee_key(right)
|
||||
if not a or not b:
|
||||
return False
|
||||
if a in _TOTAL_ALIASES and b in _TOTAL_ALIASES:
|
||||
return True
|
||||
if a == b:
|
||||
return True
|
||||
if a == b + "用" or a == b + "费用" or b == a + "用" or b == a + "费用":
|
||||
@@ -126,6 +131,240 @@ def match_fee_names(spoken: str, rows: list[dict[str, Any]]) -> list[str]:
|
||||
return hits
|
||||
|
||||
|
||||
# 海运卡按三段展示。小计是卡上的一行,不在扁平 fee_lines 里。
|
||||
_SEA_BUCKETS = (
|
||||
("polCostItems", "始发港费用小计"),
|
||||
("oceanCostItems", "海运费小计"),
|
||||
("podCostItems", "目的港费用小计"),
|
||||
)
|
||||
|
||||
|
||||
def _bucket_items(quote: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||
"""读海运分段。主账有时给列表,有时给 JSON 字符串。"""
|
||||
raw = quote.get(key)
|
||||
if raw in (None, ""):
|
||||
raw = quote.get("fee_rows_" + key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [dict(row) for row in raw if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _item_fee_name(item: dict[str, Any]) -> str:
|
||||
return str(item.get("name") or item.get("label") or item.get("feeName") or "").strip()
|
||||
|
||||
|
||||
def _same_money(left: Any, right: Any) -> bool:
|
||||
"""两条金额是否同一笔,用来把同名费用配回原来那一行。"""
|
||||
a, _ = parse_money(left)
|
||||
b, _ = parse_money(right)
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return abs(a - b) < 0.0001
|
||||
|
||||
|
||||
def _plain_amount(amount: str) -> str:
|
||||
"""写回分段时去掉币种前缀,避免小计被展示成 CNY CNY。"""
|
||||
text = str(amount or "").strip()
|
||||
for prefix in ("USD", "CNY", "EUR", "HKD", "JPY", "GBP", "RMB"):
|
||||
if text.upper().startswith(prefix):
|
||||
return text[len(prefix) :].strip()
|
||||
return text
|
||||
|
||||
|
||||
def _store_bucket(quote: dict[str, Any], key: str, items: list[dict[str, Any]]) -> None:
|
||||
"""分段和 fee_rows_ 键一起改,卡和模板读的是这两处。"""
|
||||
if key in quote or quote.get(key) not in (None, ""):
|
||||
quote[key] = items
|
||||
alt = "fee_rows_" + key
|
||||
if alt in quote or quote.get(alt) not in (None, ""):
|
||||
quote[alt] = json.dumps(items, ensure_ascii=False)
|
||||
|
||||
|
||||
def adjustable_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
调价能对上的行:扁平明细,加上海运分段里的费目和小计。
|
||||
|
||||
小计只用来对「始发港费用小计」这类话,不写进 fee_lines。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
rows = [dict(x) for x in quote_fee_rows(src)]
|
||||
for row in rows:
|
||||
row["_origin"] = str(row.get("amount") or "")
|
||||
header = str(src.get("currency") or src.get("currencyCode") or "").strip()
|
||||
for key, sub_label in _SEA_BUCKETS:
|
||||
used: set[int] = set()
|
||||
for item in _bucket_items(src, key):
|
||||
name = _item_fee_name(item)
|
||||
amount = str(item.get("amount") or item.get("value") or item.get("salePrice") or "").strip()
|
||||
if not name or not amount:
|
||||
continue
|
||||
idx = next(
|
||||
(
|
||||
i
|
||||
for i, row in enumerate(rows)
|
||||
if i not in used
|
||||
and not row.get("_subtotal_key")
|
||||
and not row.get("_is_total")
|
||||
and fee_name_same(name, str(row.get("name") or ""))
|
||||
and _same_money(row.get("_origin"), amount)
|
||||
),
|
||||
-1,
|
||||
)
|
||||
if idx < 0:
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": item.get("currency") or header,
|
||||
"_bucket": key,
|
||||
"_origin": amount,
|
||||
}
|
||||
)
|
||||
used.add(len(rows) - 1)
|
||||
else:
|
||||
rows[idx]["_bucket"] = key
|
||||
used.add(idx)
|
||||
subtotal = str(src.get("fee_total_" + key) or "").strip()
|
||||
if subtotal and subtotal != "-":
|
||||
rows.append(
|
||||
{
|
||||
"name": sub_label,
|
||||
"amount": subtotal,
|
||||
"currency": header,
|
||||
"_subtotal_key": "fee_total_" + key,
|
||||
"_bucket": key,
|
||||
}
|
||||
)
|
||||
_attach_land_legs(src, rows)
|
||||
total = str(src.get("total") or "").strip()
|
||||
if total and total != "-":
|
||||
rows.append(
|
||||
{
|
||||
"name": total_price_label(
|
||||
str(src.get("business_line") or ""),
|
||||
quote=src,
|
||||
),
|
||||
"amount": total,
|
||||
"currency": header,
|
||||
"_is_total": True,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _attach_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
|
||||
"""陆运卡按段展示。同名费用每段一行,改价要写回路段成本。"""
|
||||
from agent.schema.land_route_options import land_leg_fee_blocks
|
||||
|
||||
used: set[int] = set()
|
||||
for index, (_title, fees, _eta) in enumerate(land_leg_fee_blocks(quote)):
|
||||
for fee in fees:
|
||||
name = str(fee.get("name") or "").strip()
|
||||
amount = str(fee.get("amount") or "").strip()
|
||||
if not name or not amount:
|
||||
continue
|
||||
idx = next(
|
||||
(
|
||||
i
|
||||
for i, row in enumerate(rows)
|
||||
if i not in used
|
||||
and not row.get("_subtotal_key")
|
||||
and not row.get("_is_total")
|
||||
and fee_name_same(name, str(row.get("name") or ""))
|
||||
and str(row.get("amount") or "") == amount
|
||||
),
|
||||
-1,
|
||||
)
|
||||
if idx < 0:
|
||||
rows.append(
|
||||
{
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": fee.get("currency") or quote.get("currency") or "",
|
||||
"_land_leg": index,
|
||||
}
|
||||
)
|
||||
used.add(len(rows) - 1)
|
||||
else:
|
||||
rows[idx]["_land_leg"] = index
|
||||
used.add(idx)
|
||||
|
||||
|
||||
def _write_adjusted_buckets(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
|
||||
"""把改过的费目和小计写回海运分段,价格卡才看得到。"""
|
||||
for key, _label in _SEA_BUCKETS:
|
||||
items = _bucket_items(quote, key)
|
||||
changed = False
|
||||
used: set[int] = set()
|
||||
for item in items:
|
||||
name = _item_fee_name(item)
|
||||
origin = str(item.get("amount") or item.get("value") or item.get("salePrice") or "")
|
||||
hit_i = next(
|
||||
(
|
||||
i
|
||||
for i, row in enumerate(rows)
|
||||
if i not in used
|
||||
and row.get("_bucket") == key
|
||||
and not row.get("_subtotal_key")
|
||||
and fee_name_same(name, str(row.get("name") or ""))
|
||||
and _same_money(row.get("_origin"), origin)
|
||||
),
|
||||
-1,
|
||||
)
|
||||
if hit_i < 0:
|
||||
continue
|
||||
used.add(hit_i)
|
||||
plain = _plain_amount(str(rows[hit_i].get("amount") or ""))
|
||||
if not plain:
|
||||
continue
|
||||
item["amount"] = plain
|
||||
if item.get("name"):
|
||||
item["name"] = name
|
||||
for field_name in ("salePrice", "value", "unit_price"):
|
||||
if item.get(field_name) not in (None, ""):
|
||||
item[field_name] = plain
|
||||
changed = True
|
||||
if changed:
|
||||
_store_bucket(quote, key, items)
|
||||
for row in rows:
|
||||
sub_key = str(row.get("_subtotal_key") or "")
|
||||
if sub_key:
|
||||
quote[sub_key] = _plain_amount(str(row.get("amount") or ""))
|
||||
_write_land_legs(quote, rows)
|
||||
|
||||
|
||||
def _write_land_legs(quote: dict[str, Any], rows: list[dict[str, Any]]) -> None:
|
||||
"""路段成本改掉,下次出卡才不会又按旧成本显示。"""
|
||||
from agent.schema.land_route_options import _selected_legs
|
||||
|
||||
legs = _selected_legs(quote)
|
||||
if not legs:
|
||||
return
|
||||
for row in rows:
|
||||
index = row.get("_land_leg")
|
||||
if index is None or not isinstance(index, int) or index >= len(legs):
|
||||
continue
|
||||
plain = _plain_amount(str(row.get("amount") or ""))
|
||||
if not plain:
|
||||
continue
|
||||
leg = legs[index]
|
||||
leg["costPrice"] = plain
|
||||
leg["cost_price"] = plain
|
||||
if leg.get("salePrice") not in (None, ""):
|
||||
leg["salePrice"] = plain
|
||||
selected = quote.get("selected_land_route")
|
||||
if isinstance(selected, dict):
|
||||
selected["legs"] = legs
|
||||
quote["selected_land_route"] = selected
|
||||
if quote.get("landSegments") or quote.get("land_segments"):
|
||||
quote["landSegments"] = legs
|
||||
|
||||
|
||||
def quote_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""抽出费用行。TMS 常把 fee_lines 存成 JSON 字符串。"""
|
||||
src = dict(quote or {})
|
||||
@@ -292,6 +531,7 @@ def extract_adjust(
|
||||
if not raw:
|
||||
return AdjustExtract(is_adjust=False)
|
||||
names = [str(x).strip() for x in (fee_names or []) if str(x).strip()]
|
||||
names_sorted = sorted(names, key=len, reverse=True)
|
||||
use_model = allow_model
|
||||
if use_model is None:
|
||||
try:
|
||||
@@ -319,11 +559,28 @@ def extract_adjust(
|
||||
op = _item_from_chunk(raw, "")
|
||||
if op:
|
||||
modeled.items = [op]
|
||||
# 模型常只抽出「费用合计」:本地再扫点名小计/费目,避免「始发港费用小计提升10%」被丢掉
|
||||
local_items = _local_fee_items(raw, names_sorted)
|
||||
if local_items:
|
||||
seen = {
|
||||
str(x.fee_name or "").strip()
|
||||
for x in (modeled.items or [])
|
||||
if str(x.fee_name or "").strip()
|
||||
}
|
||||
merged = list(modeled.items or [])
|
||||
for it in local_items:
|
||||
key = str(it.fee_name or "").strip()
|
||||
if key and key not in seen:
|
||||
merged.append(it)
|
||||
seen.add(key)
|
||||
modeled = AdjustExtract(
|
||||
is_adjust=True,
|
||||
items=merged,
|
||||
wants_total=bool(modeled.wants_total),
|
||||
)
|
||||
return modeled
|
||||
except Exception:
|
||||
logger.exception("调价模型抽取失败,回退本地")
|
||||
names_sorted = sorted(names, key=len, reverse=True)
|
||||
blob = raw.replace(" ", "")
|
||||
total_hit = any(w in raw for w in _TOTAL_WORDS)
|
||||
has_op = bool(_PERCENT_UP.search(raw) or _PERCENT_DOWN.search(raw) or _TARGET.search(raw))
|
||||
fee_in_text = [n for n in names_sorted if n and n in raw]
|
||||
@@ -332,6 +589,17 @@ def extract_adjust(
|
||||
return AdjustExtract(is_adjust=True, wants_total=True, items=[op] if op else [])
|
||||
if _QUOTE_WORD in raw and has_op and not fee_in_text and not total_hit:
|
||||
return AdjustExtract(is_adjust=True, wants_total=False)
|
||||
items = _local_fee_items(raw, names_sorted)
|
||||
if not items and has_op:
|
||||
# 说了费用口语但和名单不完全一致:整句再扫一次
|
||||
item = _item_from_chunk(raw, "")
|
||||
if item:
|
||||
items.append(item)
|
||||
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
|
||||
|
||||
def _local_fee_items(raw: str, names_sorted: list[str]) -> list[AdjustItem]:
|
||||
"""按分句把点名费目/小计抽成调价项。提升/上调等口令都认。"""
|
||||
items: list[AdjustItem] = []
|
||||
for part in _SPLIT.split(raw) or [raw]:
|
||||
chunk = (part or "").strip()
|
||||
@@ -346,16 +614,15 @@ def extract_adjust(
|
||||
found = _FEE_TOKEN.findall(chunk)
|
||||
spoken = found[-1] if found else ""
|
||||
if not spoken:
|
||||
continue
|
||||
# 「费用合计提升10%」:名单里有费用合计时上面已命中;否则用合计词
|
||||
if any(w in chunk for w in _TOTAL_WORDS):
|
||||
spoken = "费用合计"
|
||||
else:
|
||||
continue
|
||||
item = _item_from_chunk(chunk, spoken)
|
||||
if item:
|
||||
items.append(item)
|
||||
if not items and has_op:
|
||||
# 说了费用口语但和名单不完全一致:整句再扫一次
|
||||
item = _item_from_chunk(raw, "")
|
||||
if item:
|
||||
items.append(item)
|
||||
return AdjustExtract(is_adjust=bool(items or has_op), items=items)
|
||||
return items
|
||||
|
||||
|
||||
def _item_from_chunk(chunk: str, fee_name: str) -> Optional[AdjustItem]:
|
||||
@@ -377,21 +644,13 @@ def total_price_label(
|
||||
quote: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
销售可见总价名称。
|
||||
销售在企微看到的汇总行名称,一律叫费用合计。
|
||||
|
||||
空运一律总销售价。海运/陆运:TMS、产品报价是总成本价,销售调价后才是总销售价。
|
||||
禁止写「合计」。Excel/PDF 栏目名不走这里。
|
||||
不区分空运/海运/陆运,也不区分 TMS 还是销售调价。
|
||||
后台成本价、销售价不走这里。Excel/PDF 报价单模板也不走这里。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
raw = f"{business_line or ''} {transport_mode or ''} {src.get('business_line') or ''}"
|
||||
key = raw.upper()
|
||||
if key.strip() in {"AIR"} or "AIR" in key or "空运" in raw:
|
||||
return "总销售价"
|
||||
source = str(src.get("source") or "").strip().upper()
|
||||
label = str(src.get("source_label") or src.get("sourceLabel") or "").replace(" ", "")
|
||||
if source == "SALES_ADJUST" or "销售调价" in label:
|
||||
return "总销售价"
|
||||
return "总成本价"
|
||||
_ = business_line, transport_mode, quote
|
||||
return "费用合计"
|
||||
|
||||
|
||||
def _mark_sales_adjust(quote: dict[str, Any]) -> None:
|
||||
@@ -532,6 +791,42 @@ def _apply_total_only(
|
||||
)
|
||||
|
||||
|
||||
def _fold_total_into_items(
|
||||
extract: AdjustExtract,
|
||||
*,
|
||||
business_line: str = "",
|
||||
quote: dict[str, Any] | None = None,
|
||||
) -> AdjustExtract:
|
||||
"""
|
||||
合计与费目小计写在同一条指令里时:取消 wants_total 独占,把合计并进 items。
|
||||
|
||||
为何:wants_total 只改 total,海运「始发港费用小计」不会动,Excel 汇总仍是旧数。
|
||||
"""
|
||||
if not extract.wants_total:
|
||||
return extract
|
||||
named = [x for x in (extract.items or []) if str(x.fee_name or "").strip()]
|
||||
if not named:
|
||||
return extract
|
||||
label = total_price_label(business_line, quote=quote)
|
||||
has_total = any(fee_name_same(str(x.fee_name or ""), label) for x in named)
|
||||
items = list(named)
|
||||
if not has_total:
|
||||
seed = named[0]
|
||||
for raw in extract.items or []:
|
||||
if not str(raw.fee_name or "").strip():
|
||||
seed = raw
|
||||
break
|
||||
items.append(
|
||||
AdjustItem(
|
||||
fee_name=label,
|
||||
kind=seed.kind,
|
||||
direction=seed.direction,
|
||||
value=seed.value,
|
||||
)
|
||||
)
|
||||
return AdjustExtract(is_adjust=True, wants_total=False, items=items)
|
||||
|
||||
|
||||
def apply_adjust(
|
||||
quote: dict[str, Any] | None,
|
||||
extract: AdjustExtract,
|
||||
@@ -545,12 +840,14 @@ def apply_adjust(
|
||||
|
||||
明细只有一项且币种与总价一致:改总价或改明细都一起调。
|
||||
多项同币种改合计:只改合计数字。多币种:只改点名的那一格。
|
||||
副作用:无。原 quote 不被修改。
|
||||
副作用:无。原 quote 不被修改。海运小计按卡上的「始发港费用小计」改。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
rows = [dict(x) for x in quote_fee_rows(src)]
|
||||
rows = adjustable_rows(src)
|
||||
if not extract.is_adjust:
|
||||
return ApplyResult(ok=False, code="not_adjust", quote=src)
|
||||
# 同时说了「始发港费用小计」和「费用合计」时:不能只走合计通道,否则小计不改、Excel 汇总也不动。
|
||||
extract = _fold_total_into_items(extract, business_line=business_line, quote=src)
|
||||
header = str(src.get("currency") or "").strip()
|
||||
total_ccy = row_currency({"currency": header, "amount": src.get("total")}, header)
|
||||
currencies = {row_currency(r, header) for r in rows if row_currency(r, header)}
|
||||
@@ -567,7 +864,13 @@ def apply_adjust(
|
||||
currency_hint = bool(multi)
|
||||
total_amt, _ = parse_money(src.get("total"))
|
||||
new_total = total_amt
|
||||
single_same_ccy = _single_line_same_currency(rows, total_ccy or header) is not None
|
||||
total_touched = False
|
||||
single_same_ccy = _single_line_same_currency(
|
||||
[row for row in rows if not row.get("_subtotal_key") and not row.get("_is_total")],
|
||||
total_ccy or header,
|
||||
) is not None
|
||||
subtotal_only: dict[str, float] = {}
|
||||
touched_buckets: set[str] = set()
|
||||
|
||||
for item in extract.items:
|
||||
spoken = (item.fee_name or "").strip()
|
||||
@@ -581,35 +884,73 @@ def apply_adjust(
|
||||
if not hits:
|
||||
unmatched.append(spoken)
|
||||
continue
|
||||
idx = next(i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0])
|
||||
row = dict(rows[idx])
|
||||
old_amt, prefix = parse_money(row.get("amount"))
|
||||
if old_amt is None:
|
||||
indexes = [i for i, r in enumerate(rows) if str(r.get("name") or "") == hits[0]]
|
||||
if not indexes:
|
||||
unmatched.append(spoken)
|
||||
continue
|
||||
line_ccy = row_currency(row, header) or prefix
|
||||
if item.kind == "target":
|
||||
new_amt = float(item.value)
|
||||
how = f"改到 {item.value:g}"
|
||||
elif item.direction == "down":
|
||||
new_amt = old_amt * (1 - float(item.value) / 100.0)
|
||||
how = f"下调 {item.value:g}%"
|
||||
else:
|
||||
new_amt = old_amt * (1 + float(item.value) / 100.0)
|
||||
how = f"上调 {item.value:g}%"
|
||||
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
|
||||
rows[idx] = row
|
||||
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
|
||||
if (not multi) and new_total is not None:
|
||||
if single_same_ccy:
|
||||
new_total = new_amt
|
||||
any_applied = False
|
||||
for idx in indexes:
|
||||
row = dict(rows[idx])
|
||||
old_amt, prefix = parse_money(row.get("amount"))
|
||||
if old_amt is None:
|
||||
continue
|
||||
line_ccy = row_currency(row, header) or prefix
|
||||
if item.kind == "target":
|
||||
new_amt = float(item.value)
|
||||
how = f"改到 {item.value:g}"
|
||||
elif item.direction == "down":
|
||||
new_amt = old_amt * (1 - float(item.value) / 100.0)
|
||||
how = f"下调 {item.value:g}%"
|
||||
else:
|
||||
new_total = new_total + (new_amt - old_amt)
|
||||
new_amt = old_amt * (1 + float(item.value) / 100.0)
|
||||
how = f"上调 {item.value:g}%"
|
||||
_apply_amount(row, format_money(new_amt, sample=str(row.get("amount") or ""), currency=line_ccy or prefix))
|
||||
rows[idx] = row
|
||||
if not any_applied:
|
||||
applied.append({"name": hits[0], "how": how, "amount": row["amount"]})
|
||||
any_applied = True
|
||||
bucket = str(row.get("_bucket") or "")
|
||||
if row.get("_is_total"):
|
||||
subtotal_only["__total__"] = new_amt
|
||||
total_touched = True
|
||||
continue
|
||||
if row.get("_subtotal_key"):
|
||||
# 只改小计数字写回 fee_total_*;明细是否另调看有没有点名费目
|
||||
subtotal_only[bucket] = new_amt - old_amt
|
||||
continue
|
||||
if bucket:
|
||||
touched_buckets.add(bucket)
|
||||
if (not multi) and new_total is not None:
|
||||
if single_same_ccy:
|
||||
new_total = new_amt
|
||||
else:
|
||||
new_total = new_total + (new_amt - old_amt)
|
||||
if not any_applied:
|
||||
unmatched.append(spoken)
|
||||
|
||||
if (not multi) and new_total is not None:
|
||||
if "__total__" in subtotal_only:
|
||||
# 合计是整单改数;小计差额不再叠进合计,避免「合计×1.1 + 小计差额」重复
|
||||
new_total = subtotal_only.pop("__total__")
|
||||
else:
|
||||
for bucket, delta in subtotal_only.items():
|
||||
if bucket not in touched_buckets:
|
||||
new_total = new_total + delta
|
||||
elif total_touched and "__total__" in subtotal_only:
|
||||
# 多币种:点名「费用合计」仍改合计数字,不做汇率换算
|
||||
new_total = subtotal_only["__total__"]
|
||||
|
||||
out = dict(src)
|
||||
out["fee_rows"] = rows
|
||||
out["fee_lines"] = rows
|
||||
if applied and (not multi) and new_total is not None and new_total != total_amt:
|
||||
visible = []
|
||||
for row in rows:
|
||||
if row.get("_subtotal_key") or row.get("_is_total"):
|
||||
continue
|
||||
clean = {k: v for k, v in row.items() if not str(k).startswith("_")}
|
||||
visible.append(clean)
|
||||
out["fee_rows"] = visible
|
||||
out["fee_lines"] = visible
|
||||
_write_adjusted_buckets(out, rows)
|
||||
if applied and new_total is not None and new_total != total_amt and ((not multi) or total_touched):
|
||||
out["total"] = format_money(new_total, sample=str(src.get("total") or ""), currency=total_ccy)
|
||||
if not applied:
|
||||
code = "ambiguous" if ambiguous else "none_matched"
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
"""
|
||||
海运运输分类:只认 Port to Port、其他。
|
||||
|
||||
本文件职责:从原话收成标准词、生成补问清单。
|
||||
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
|
||||
禁止:发明第三项;把分类带去 TMS;改六态。
|
||||
线程:纯函数,可在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
SEA_CLASSES = ("Port to Port", "其他")
|
||||
|
||||
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
|
||||
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
|
||||
|
||||
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
|
||||
_LABELED_SEA_CLASS = re.compile(
|
||||
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
|
||||
)
|
||||
_LABELED_TRADE_TERMS = re.compile(
|
||||
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
|
||||
)
|
||||
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
|
||||
|
||||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||||
_ALIASES = (
|
||||
("port to port", "Port to Port"),
|
||||
@@ -29,9 +43,16 @@ def canonicalize_sea_class(raw: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
lowered = text.lower()
|
||||
# 企微常把空格发成不换行空格,Port to Port 对不上普通空格就会整段丢掉。
|
||||
folded = re.sub(r"\s+", " ", text).strip()
|
||||
lowered = folded.lower()
|
||||
squashed = re.sub(r"\s+", "", lowered)
|
||||
if "port to port" in lowered or "porttoport" in squashed:
|
||||
return "Port to Port"
|
||||
for needle, canon in _ALIASES:
|
||||
if needle in lowered or needle in text:
|
||||
if needle == "port to port":
|
||||
continue
|
||||
if needle in lowered or needle in text or needle in squashed:
|
||||
return canon
|
||||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||||
if stripped in SEA_CLASSES:
|
||||
@@ -39,15 +60,46 @@ def canonicalize_sea_class(raw: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _without_clarify_examples(text: str) -> str:
|
||||
"""丢掉补问举例行。销售只在冒号后作答,「(如:…)」整行不参与判断。"""
|
||||
return "\n".join(
|
||||
line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line)
|
||||
)
|
||||
|
||||
|
||||
def _without_trade_terms_lines(text: str) -> str:
|
||||
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
|
||||
return "\n".join(
|
||||
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
|
||||
)
|
||||
|
||||
|
||||
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
|
||||
"""取原文最后一次标签行冒号后的值;没有则空串。"""
|
||||
last = ""
|
||||
for hit in pattern.finditer(_without_clarify_examples(text or "")):
|
||||
last = (hit.group(1) or "").strip()
|
||||
return last
|
||||
|
||||
|
||||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
当前句能认则覆盖;认不出则保留已有标准值。
|
||||
|
||||
参数:本轮原文、已有 facts。返回合并后的 facts 副本。
|
||||
非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
|
||||
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
|
||||
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||||
"""
|
||||
merged = dict(current or {})
|
||||
found = canonicalize_sea_class(text or "")
|
||||
cleaned = _without_clarify_examples(text or "")
|
||||
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
|
||||
if labeled:
|
||||
found_labeled = canonicalize_sea_class(labeled)
|
||||
if found_labeled:
|
||||
merged["运输分类"] = found_labeled
|
||||
return merged
|
||||
# 整句扫描:不含贸易条款行
|
||||
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
|
||||
if found:
|
||||
merged["运输分类"] = found
|
||||
return merged
|
||||
@@ -78,6 +130,20 @@ _DOOR_TRADE_TERMS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
海运贸易条款:标签行冒号后原样优先。
|
||||
|
||||
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
|
||||
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||||
if labeled:
|
||||
merged["贸易条款"] = labeled
|
||||
return merged
|
||||
|
||||
|
||||
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""
|
||||
丢掉冒充贸易条款的「门到门」。
|
||||
@@ -85,11 +151,17 @@ def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = Non
|
||||
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
|
||||
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
|
||||
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
|
||||
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
|
||||
"""
|
||||
merged = dict(facts or {})
|
||||
merged = harvest_sea_trade_terms(text, facts)
|
||||
trade = str(merged.get("贸易条款") or "").strip()
|
||||
if not trade:
|
||||
return merged
|
||||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||||
if labeled:
|
||||
# 标签已写死条款:以标签为准(含 Port to Port)
|
||||
merged["贸易条款"] = labeled.strip()
|
||||
return merged
|
||||
if "贸易条款" in (text or ""):
|
||||
return merged
|
||||
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
|
||||
|
||||
@@ -78,6 +78,10 @@ _IATA_PAIR_BLOCK = frozenset(
|
||||
}
|
||||
)
|
||||
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
|
||||
# 销售写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3
|
||||
_DIM_PRODUCT = re.compile(
|
||||
r"(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?:\s*[*xX×]\s*(\d+(?:\.\d+)?))?"
|
||||
)
|
||||
|
||||
|
||||
def _first_number(raw: str) -> str:
|
||||
@@ -85,6 +89,32 @@ def _first_number(raw: str) -> str:
|
||||
return hit.group(1) if hit else ""
|
||||
|
||||
|
||||
def volume_cbm_number(raw: str) -> str:
|
||||
"""
|
||||
体积出站用数字:尺寸式先相乘;否则取第一个数。
|
||||
|
||||
例:1.1*1.2*3 → 3.96;2CBM → 2。空串表示没有可用体积。
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
dims = _DIM_PRODUCT.search(text)
|
||||
if dims:
|
||||
try:
|
||||
a = float(dims.group(1))
|
||||
b = float(dims.group(2))
|
||||
c = float(dims.group(3) or "1")
|
||||
product = a * b * c
|
||||
if product <= 0:
|
||||
return ""
|
||||
# 去掉多余尾零,保持可读小数
|
||||
text_num = f"{product:.10f}".rstrip("0").rstrip(".")
|
||||
return text_num or ""
|
||||
except ValueError:
|
||||
return ""
|
||||
return _first_number(text)
|
||||
|
||||
|
||||
def resolve_airport_code(raw: str) -> str:
|
||||
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -161,7 +191,7 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
cargo = str(src.get("品名") or "").strip()
|
||||
pieces = _first_number(str(src.get("件数") or ""))
|
||||
weight = _first_number(str(src.get("毛重") or ""))
|
||||
volume = _first_number(str(src.get("体积") or ""))
|
||||
volume = volume_cbm_number(str(src.get("体积") or ""))
|
||||
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
|
||||
pack_code = resolve_package_type(pack_raw)
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
|
||||
@@ -21,10 +21,28 @@ from agent.schema.field_validate import (
|
||||
air_general_cargo_collab_defaults,
|
||||
validate_required_fields,
|
||||
)
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
from agent.schema.tms_air_query import assemble_air_query, volume_cbm_number
|
||||
|
||||
|
||||
class AirFieldContractTests(unittest.TestCase):
|
||||
def test_volume_dimension_product(self) -> None:
|
||||
self.assertEqual(volume_cbm_number("1.1*1.2*3"), "3.96")
|
||||
self.assertEqual(volume_cbm_number("1.1x1.2x3"), "3.96")
|
||||
self.assertEqual(volume_cbm_number("2CBM"), "2")
|
||||
out = assemble_air_query(
|
||||
{
|
||||
"目的港": "吉隆坡",
|
||||
"品名": "普货",
|
||||
"件数": "3件",
|
||||
"毛重": "5",
|
||||
"体积": "1.1*1.2*3",
|
||||
"包装方式": "托盘",
|
||||
"报价日期": "2026-10-08",
|
||||
}
|
||||
)
|
||||
self.assertTrue(out["ok"], out)
|
||||
self.assertEqual(out["facts"]["体积"], "1.1*1.2*3")
|
||||
|
||||
def test_origin_optional_still_ok(self) -> None:
|
||||
result = validate_required_fields(
|
||||
facts={
|
||||
|
||||
@@ -38,6 +38,64 @@ class FakeBotGroup:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class AirSheetQuoteTotalTests(unittest.TestCase):
|
||||
"""群填表报价包必须带 TMS 总销售价,不能把附加费之和当预估费用。"""
|
||||
|
||||
def test_sheet_quote_replaces_fee_sum_total_with_tms_sale_price(self) -> None:
|
||||
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
|
||||
|
||||
thin = {
|
||||
"total": "200",
|
||||
"currency": "CNY",
|
||||
"fee_lines": [
|
||||
{"name": "增点费", "amount": "100", "currency": "CNY"},
|
||||
{"name": "拆板费", "amount": "100", "currency": "USD"},
|
||||
],
|
||||
}
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"total": "7918.25",
|
||||
"currency": "CNY",
|
||||
"chargeableWeightKg": "833.5",
|
||||
"airOptions": [
|
||||
{
|
||||
"optionNo": "AIR-1",
|
||||
"salePrice": 7918.25,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "增点费", "unitPrice": 100, "currency": "CNY"},
|
||||
{"chargeName": "拆板费", "unitPrice": 100, "currency": "USD"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
out = _sheet_quote_with_tms_sale_total(thin, tms=tms, facts={"起运港": "南京", "目的港": "吉隆坡"})
|
||||
self.assertIn("7918.25", str(out.get("total") or ""))
|
||||
self.assertNotEqual(str(out.get("total") or "").strip(), "200")
|
||||
self.assertTrue(out.get("airOptions") or out.get("selected_air_option"))
|
||||
|
||||
def test_sheet_quote_keeps_product_total_when_not_fee_sum(self) -> None:
|
||||
from agent.policy.air_group_ops import _sheet_quote_with_tms_sale_total
|
||||
|
||||
product = {
|
||||
"total": "9000",
|
||||
"currency": "CNY",
|
||||
"source_label": "航线报价",
|
||||
"fee_lines": [
|
||||
{"name": "增点费", "amount": "100", "currency": "CNY"},
|
||||
{"name": "拆板费", "amount": "100", "currency": "USD"},
|
||||
],
|
||||
}
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"total": "7918.25",
|
||||
"currency": "CNY",
|
||||
"airOptions": [{"optionNo": "AIR-1", "salePrice": 7918.25, "currency": "CNY"}],
|
||||
}
|
||||
out = _sheet_quote_with_tms_sale_total(product, tms=tms, facts={})
|
||||
self.assertEqual(str(out.get("total") or "").strip(), "9000")
|
||||
|
||||
|
||||
class AirGroupCollabTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from agent.redis_coord.air_inbound import reset_air_inbound_for_tests
|
||||
@@ -210,8 +268,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
tms_quote=tms,
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
self.assertIn("总销售价:CNY 183370.0", tms_block)
|
||||
self.assertEqual(tms_block.count("费用合计:"), 1)
|
||||
self.assertIn("费用合计:CNY 183370.0", tms_block)
|
||||
self.assertNotIn("时效", tms_block)
|
||||
self.assertIn("报价有效期:2026-09-30", tms_block)
|
||||
self.assertIn("请航线人员回复“确认报价”确定采用TMS报价。", text)
|
||||
@@ -266,8 +324,8 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertIn("运输费:CNY 11.0", tms_block)
|
||||
self.assertIn("提送货费:CNY 6.0", tms_block)
|
||||
self.assertIn("人工费:CNY 123.0", tms_block)
|
||||
self.assertIn("总销售价:CNY 183370", tms_block)
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
self.assertIn("费用合计:CNY 183370", tms_block)
|
||||
self.assertEqual(tms_block.count("费用合计:"), 1)
|
||||
self.assertNotIn("空运费", tms_block)
|
||||
|
||||
def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None:
|
||||
@@ -312,8 +370,45 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertNotIn("空运费", tms_block)
|
||||
self.assertNotIn("运输费", tms_block)
|
||||
self.assertIn("总销售价:CNY 200040", tms_block)
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
self.assertIn("费用合计:CNY 200040", tms_block)
|
||||
self.assertEqual(tms_block.count("费用合计:"), 1)
|
||||
|
||||
def test_work_order_plus_lost_reason_is_deal_not_activate(self) -> None:
|
||||
"""WO@助手 未成交,原因… 必须当关单,不能只激活。"""
|
||||
from agent.routing.air_group_intent import INTENT_ACTIVATE, INTENT_LOST
|
||||
|
||||
text = "WO202609220014@询价小助手 未成交,原因客户不想送货了"
|
||||
self.assertEqual(classify_air_group_text(text, allow_network=False), INTENT_LOST)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"agent.routing.air_group_intent._classify_with_model",
|
||||
return_value=INTENT_ACTIVATE,
|
||||
):
|
||||
self.assertEqual(
|
||||
classify_air_group_text(text, allow_network=True),
|
||||
INTENT_LOST,
|
||||
)
|
||||
|
||||
def test_activate_lost_and_reason_same_message(self) -> None:
|
||||
wo = self._create_air()
|
||||
phase = handle_air_group(
|
||||
self._msg(
|
||||
sender="sales1",
|
||||
text=f"{wo}@询价小助手 未成交,原因客户不想送货了",
|
||||
chat="wr_air_1",
|
||||
mid="lost-inline-wo",
|
||||
),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "done")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.status, "未成交")
|
||||
self.assertEqual(ticket.lost_reason, "客户不想送货了")
|
||||
joined = "\n".join(self._texts())
|
||||
self.assertIn("客户不想送货了", joined)
|
||||
self.assertNotIn("请补充未成交原因", joined)
|
||||
|
||||
def test_work_order_only_is_activate_not_quote(self) -> None:
|
||||
from agent.routing.air_group_intent import INTENT_ACTIVATE
|
||||
@@ -658,6 +753,33 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertEqual(getattr(self.ledger, "last_render", {}).get("templateId"), "air-default")
|
||||
self.assertTrue(any(x.get("kind") == "file" for x in self.groups.sent))
|
||||
|
||||
def test_reply_2_means_drop_tms_not_price(self) -> None:
|
||||
"""问 1/2 之后,航线回 @询价小助手 2 是选不用 TMS,不能把价格改成 2。"""
|
||||
wo = self._create_air()
|
||||
handle_air_group(
|
||||
self._msg(sender="air1", text=wo, chat="wr_air_1", mid="d0"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="空运费 900USD", chat="wr_air_1", mid="d1"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
injected_intent="product_quote",
|
||||
injected_quote={"total": "USD 900", "currency": "USD", "source_label": "航线报价"},
|
||||
)
|
||||
self.assertEqual(phase, "wait_tms_choice")
|
||||
phase = handle_air_group(
|
||||
self._msg(sender="air1", text="@询价小助手 2", chat="wr_air_1", mid="d2"),
|
||||
flow=self.flow,
|
||||
ledger=self.ledger,
|
||||
)
|
||||
self.assertEqual(phase, "quoted")
|
||||
ticket = self.ledger.get_ticket(work_order_no=wo)
|
||||
self.assertEqual(ticket.quote.get("total"), "USD 900")
|
||||
self.assertEqual(ticket.quote.get("source_label"), "航线报价")
|
||||
self.assertNotEqual(ticket.quote.get("quoteId"), "QT-AIR-1")
|
||||
|
||||
def test_airline_quote_without_tms_sends_brief_sheet_deal(self) -> None:
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
handle_air_group(
|
||||
@@ -779,10 +901,10 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
ledger=self.ledger,
|
||||
injected_intent="air_lock",
|
||||
)
|
||||
self.assertEqual(phase, "lock_ok")
|
||||
self.assertTrue(any("执行成功" in t for t in self._texts()))
|
||||
self.assertTrue(any("舱位选项:AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("还没有可锁的 TMS 报价" in t for t in self._texts()))
|
||||
self.assertEqual(phase, "no_quote")
|
||||
self.assertTrue(any("还没有报价,不能锁舱" in t for t in self._texts()))
|
||||
self.assertFalse(any("AIR-OPT-01" in t for t in self._texts()))
|
||||
self.assertFalse(any("执行成功" in t for t in self._texts()))
|
||||
|
||||
def test_manual_quote_without_quote_id_can_lock(self) -> None:
|
||||
wo = self._create_air(
|
||||
@@ -1196,7 +1318,7 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertNotIn("与工单不匹配", joined)
|
||||
|
||||
def test_lock_without_quote_still_shows_tms_reason(self) -> None:
|
||||
"""没有报价也可以锁舱;群里只回 TMS 原因,不要说选项不匹配。"""
|
||||
"""TMS 拒绝锁舱时原样回失败原因,不要改写成选项不匹配。"""
|
||||
wo = self._create_air(quote={}, status="询价中")
|
||||
self.ledger._cabin_lock_result = {
|
||||
"ok": False,
|
||||
|
||||
@@ -218,7 +218,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertNotIn("空运费", detail)
|
||||
self.assertIn("总销售价", detail)
|
||||
self.assertIn("费用合计", detail)
|
||||
self.assertIn("200040", detail)
|
||||
|
||||
def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None:
|
||||
@@ -281,7 +281,7 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
"NKG-KUL",
|
||||
"增点费:CNY 100.0",
|
||||
"拆板费:USD 100.0",
|
||||
"总销售价:CNY 5001.0",
|
||||
"费用合计:CNY 5001.0",
|
||||
"报价有效期:2026-09-30",
|
||||
]
|
||||
),
|
||||
@@ -323,6 +323,16 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("/air-routes/tok-abc", url)
|
||||
self.assertNotIn("userid", url)
|
||||
|
||||
def test_ok_page_auto_closes_wecom_h5(self) -> None:
|
||||
from agent.channel.h5.air_route_page import expired_air_route_page, render_air_route_ok
|
||||
|
||||
ok = render_air_route_ok()
|
||||
dead = expired_air_route_page()
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -342,7 +342,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("已报价", self.flow.session_of("u1").status)
|
||||
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
self.assertIn("价格明细", joined)
|
||||
self.assertIn("总销售价", joined)
|
||||
self.assertIn("费用合计", joined)
|
||||
self.assertNotIn("时效:", joined)
|
||||
self.assertIn("报价有效期:", joined)
|
||||
self.assertNotIn("相同询价内容检索", joined)
|
||||
@@ -378,7 +378,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
card = scheme_cards[0]["template_card"]
|
||||
self.assertEqual(
|
||||
card["quote_area"]["quote_text"],
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertEqual(len(card["button_list"]), 3)
|
||||
self.assertEqual(
|
||||
@@ -408,7 +408,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
follow = file_extras[-1].get("followup_card") or {}
|
||||
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
|
||||
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
|
||||
self.assertIn("总销售价", deal_rows)
|
||||
self.assertIn("费用合计", deal_rows)
|
||||
self.assertNotIn("时效", deal_rows)
|
||||
self.assertNotIn("历史报价", deal_rows)
|
||||
self.assertNotIn("工单状态", deal_rows)
|
||||
@@ -873,7 +873,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("title", card["quote_area"])
|
||||
self.assertEqual(
|
||||
quote_text,
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertNotIn("时效:", quote_text)
|
||||
numeric = copy.tms_hit_wecom_payload(
|
||||
@@ -888,7 +888,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)["template_card"]["quote_area"]["quote_text"]
|
||||
self.assertEqual(
|
||||
numeric,
|
||||
"总销售价:CNY 14669.60\n共1项费用,详见上一条明细",
|
||||
"费用合计:CNY 14669.60\n共1项费用,详见上一条明细",
|
||||
)
|
||||
self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
|
||||
self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
|
||||
@@ -928,11 +928,11 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("WO202609220005价格明细", text)
|
||||
self.assertIn("操作费:CNY 1220.00", text)
|
||||
self.assertIn("空运费:USD 1250.00", text)
|
||||
self.assertIn("总销售价:CNY 142998.53", text)
|
||||
self.assertIn("费用合计:CNY 142998.53", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertIn("报价有效期:2026-09-30", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote, business_line="AIR")
|
||||
self.assertIn("总销售价:CNY 142998.53", card_text)
|
||||
self.assertIn("费用合计:CNY 142998.53", card_text)
|
||||
self.assertIn("详见上一条明细", card_text)
|
||||
self.assertNotIn("操作费:CNY 1220.00", card_text)
|
||||
payload = copy.tms_hit_wecom_payload(
|
||||
@@ -965,7 +965,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
card_text = copy.quote_area_safe_text(quote, business_line="AIR")
|
||||
self.assertEqual(
|
||||
card_text,
|
||||
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
|
||||
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
|
||||
)
|
||||
payload = copy.tms_hit_wecom_payload(
|
||||
work_order_no="WO202609220006", quote=quote
|
||||
@@ -979,7 +979,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("ZUH-CAN-CRK", detail)
|
||||
self.assertIn("运输费:CNY 11.0", detail)
|
||||
self.assertIn("总销售价:CNY 183370", detail)
|
||||
self.assertIn("费用合计:CNY 183370", detail)
|
||||
rows = {
|
||||
x["keyname"]: x["value"]
|
||||
for x in copy.scheme_horizontal_rows(
|
||||
@@ -998,7 +998,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
scheme["template_card"]["quote_area"]["quote_text"],
|
||||
"总销售价:CNY 183370\n共3项费用,详见上一条明细",
|
||||
"费用合计:CNY 183370\n共3项费用,详见上一条明细",
|
||||
)
|
||||
|
||||
def test_air_hit_sends_detail_text_before_card(self) -> None:
|
||||
@@ -1072,7 +1072,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertNotIn("title", card["quote_area"])
|
||||
self.assertEqual(
|
||||
quote_text,
|
||||
"总销售价:USD 1,280\n共2项费用,详见上一条明细",
|
||||
"费用合计:USD 1,280\n共2项费用,详见上一条明细",
|
||||
)
|
||||
self.assertNotIn("sub_title_text", card)
|
||||
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
|
||||
@@ -1124,7 +1124,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("询价工单 WO202608210017", text)
|
||||
self.assertIn("报价单已发送,请跟进成交状态", text)
|
||||
self.assertIn("咨询成交状态:请输入成交 / 未成交 (未成交需补充原因)/ 协商中。", text)
|
||||
self.assertIn("总销售价:CNY 722.5", text)
|
||||
self.assertIn("费用合计:CNY 722.5", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertNotIn("- / -", text)
|
||||
self.assertIn("报价有效期:2026-08-24", text)
|
||||
@@ -1144,7 +1144,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
rows,
|
||||
{
|
||||
"总销售价": "CNY 722.5",
|
||||
"费用合计": "CNY 722.5",
|
||||
"报价有效期": "2026-08-24",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -105,6 +105,11 @@ class AttachmentUploadH5Tests(unittest.TestCase):
|
||||
self.assertIn('class="card"', ok)
|
||||
self.assertIn('class="card"', dead)
|
||||
self.assertIn("过期", dead)
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertNotIn("closeWindow", page)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
def test_post_png_rejected(self) -> None:
|
||||
token = self._open_card()
|
||||
|
||||
@@ -90,6 +90,22 @@ class AttachWaveTests(unittest.TestCase):
|
||||
self.assertIn("纽约港", oral)
|
||||
self.assertEqual(classify_multi_text(oral), "multi")
|
||||
|
||||
def test_one_sea_quote_filename_is_single(self) -> None:
|
||||
"""只传一份海运报价单:文件名带海运,表里再写运输方式,不能报同一方式两段。"""
|
||||
from agent.policy.multi_segments import classify_multi_text
|
||||
|
||||
self.book.open_wait("s1", precede_text="", token="tok1", now=1.0)
|
||||
self.book.add_files("s1", ["WO202608250012_海运报价单.xlsx"], now=2.0)
|
||||
self.book.complete_material(
|
||||
"s1",
|
||||
0,
|
||||
text="运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n海运询价",
|
||||
)
|
||||
oral = self.book.compose_oral("s1")
|
||||
self.assertIn("段1:海运", oral)
|
||||
self.assertIn("运输方式:海运", oral)
|
||||
self.assertEqual(classify_multi_text(oral), "single")
|
||||
|
||||
def test_ltl_filename_counts_as_land_segment(self) -> None:
|
||||
"""零担询价表文件名没有「陆运」二字,也要和海运表切成两段。"""
|
||||
from agent.policy.multi_segments import classify_multi_text
|
||||
|
||||
@@ -150,9 +150,17 @@ class TestDealOutcome(unittest.TestCase):
|
||||
|
||||
def test_harvest_inline_lost_reason(self) -> None:
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交,原因客户不想要了"), "客户不想要了")
|
||||
self.assertEqual(
|
||||
harvest_inline_lost_reason("WO202609220014@询价小助手 未成交,原因客户不想送货了"),
|
||||
"客户不想送货了",
|
||||
)
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交,客户不想要了"), "客户不想要了")
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交:价格太高"), "价格太高")
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交原因客户不想要了"), "客户不想要了")
|
||||
self.assertEqual(
|
||||
harvest_inline_lost_reason("WO202609240010未成交,原因客户不需要了@询价小助手"),
|
||||
"客户不需要了",
|
||||
)
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交"), "")
|
||||
self.assertEqual(harvest_inline_lost_reason("未成交吗"), "")
|
||||
self.assertEqual(harvest_inline_lost_reason("成交"), "")
|
||||
|
||||
@@ -62,6 +62,18 @@ class TestTimeoutCode(unittest.TestCase):
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="急件"),
|
||||
"land_hk_express",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(
|
||||
wait_kind=WAIT_PRODUCT_QUOTE,
|
||||
business_line="LAND",
|
||||
transport_class="普件(常用线路/常规车型)",
|
||||
),
|
||||
"land_common",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="LAND", transport_class="常用线路"),
|
||||
"",
|
||||
)
|
||||
self.assertEqual(
|
||||
timeout_code(wait_kind=WAIT_PRODUCT_QUOTE, business_line="SEA", transport_class=""),
|
||||
"",
|
||||
|
||||
@@ -123,6 +123,30 @@ class OralExtractTests(unittest.TestCase):
|
||||
self.assertEqual(bulk["facts"]["包装方式"], "散货")
|
||||
self.assertNotIn("包装方式", bulk["missing"])
|
||||
|
||||
def test_place_stops_before_cargo_and_price(self) -> None:
|
||||
"""目的地只留地名。后面的品名、包装、的价格不能粘在地名上。"""
|
||||
from agent.schema.field_validate import validate_required_fields
|
||||
|
||||
check = validate_required_fields(
|
||||
facts={
|
||||
"目的港": "吉隆坡,普货,散货的价格",
|
||||
"品名": "普货",
|
||||
"包装方式": "散货",
|
||||
"报价日期": "2026-09-29",
|
||||
},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertEqual(check["facts"]["目的港"], "吉隆坡")
|
||||
self.assertEqual(check["facts"]["品名"], "普货")
|
||||
self.assertEqual(check["facts"]["包装方式"], "散货")
|
||||
bare = validate_required_fields(
|
||||
facts={"目的港": "洛杉矶,茶叶,纸箱的价格"},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertEqual(bare["facts"]["目的港"], "洛杉矶")
|
||||
self.assertEqual(bare["facts"]["品名"], "茶叶")
|
||||
self.assertEqual(bare["facts"]["包装方式"], "纸箱")
|
||||
|
||||
def test_fly_route_overwrites_stale_nkg_kul(self) -> None:
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
from agent.schema.tms_air_query import assemble_air_query
|
||||
|
||||
@@ -115,8 +115,9 @@ class LandOptionsTest(unittest.TestCase):
|
||||
self.assertIn("6.中港零担/集拼", text)
|
||||
self.assertIn("线路类别(必填):\n1.1国内长途/零担", text)
|
||||
self.assertIn("6.1中港/中亚/中欧", text)
|
||||
self.assertIn("运输分类(必填):\n1.普件(东南亚、中港常用线路/常规车型)", text)
|
||||
self.assertIn("4.急件", text)
|
||||
self.assertIn("运输分类(必填):\n1.普件(常用线路/常规车型)", text)
|
||||
self.assertIn("2.普件(东南亚、中港常用线路/常规车型)", text)
|
||||
self.assertIn("5.急件", text)
|
||||
self.assertNotIn("请一次选择:运输类型", text)
|
||||
self.assertNotIn("可选择回复", text)
|
||||
self.assertNotIn("填写样例见图", text)
|
||||
@@ -185,13 +186,26 @@ class LandOptionsTest(unittest.TestCase):
|
||||
pasted = (
|
||||
"运输类型(必填):2\n"
|
||||
"线路类别(必填):2.1\n"
|
||||
"运输分类(必填):4"
|
||||
"运输分类(必填):5"
|
||||
)
|
||||
got = harvest_land_options(pasted)
|
||||
self.assertEqual(got.get("运输类型"), "国内运输整车")
|
||||
self.assertEqual(got.get("线路类别"), "国内长途/零担")
|
||||
self.assertEqual(got.get("运输分类"), "急件")
|
||||
|
||||
def test_common_route_class_only_full_text_or_number(self):
|
||||
"""新项只认编号 1 或全文;短话和逗号写法不收。"""
|
||||
full = "普件(常用线路/常规车型)"
|
||||
self.assertEqual(harvest_land_options(full).get("运输分类"), full)
|
||||
numbered = harvest_land_options(
|
||||
"1",
|
||||
current={"运输类型": "国内运输拼车", "线路类别": "国内长途/零担"},
|
||||
)
|
||||
self.assertEqual(numbered.get("运输分类"), full)
|
||||
self.assertFalse(harvest_land_options("常用线路").get("运输分类"))
|
||||
self.assertFalse(harvest_land_options("常规车型").get("运输分类"))
|
||||
self.assertFalse(harvest_land_options("普件,常用线路/常规车型").get("运输分类"))
|
||||
|
||||
def test_route_code_infers_type_when_empty(self):
|
||||
got = harvest_land_options("1.1")
|
||||
self.assertEqual(got.get("运输类型"), "国内运输拼车")
|
||||
|
||||
@@ -239,9 +239,9 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertGreater(eta1, fee1)
|
||||
self.assertLess(eta1, od2)
|
||||
self.assertGreater(eta2, fee2)
|
||||
self.assertIn("总成本价:CNY 17100", text)
|
||||
valid = text.find("报价有效期:", text.find("总成本价:"))
|
||||
self.assertGreater(valid, text.find("总成本价:"))
|
||||
self.assertIn("费用合计:CNY 17100", text)
|
||||
valid = text.find("报价有效期:", text.find("费用合计:"))
|
||||
self.assertGreater(valid, text.find("费用合计:"))
|
||||
|
||||
def test_land_detail_and_group_use_selected_route_valid_until(self) -> None:
|
||||
"""选定广西这条后:私聊明细和群 TMS 段同一套正文,不摊开没选的线路。"""
|
||||
@@ -312,9 +312,9 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("运输费:CNY 7500.00", detail)
|
||||
self.assertIn("广西壮族自治区 → 胡志明", detail)
|
||||
self.assertIn("运输费:CNY 12500.00", detail)
|
||||
self.assertIn("总成本价:CNY 20000", detail)
|
||||
self.assertIn("费用合计:CNY 20000", detail)
|
||||
self.assertIn("报价有效期:2026-10-31", detail)
|
||||
self.assertGreater(detail.find("报价有效期:"), detail.find("总成本价:"))
|
||||
self.assertGreater(detail.find("报价有效期:"), detail.find("费用合计:"))
|
||||
self.assertNotIn("9600", detail)
|
||||
self.assertNotIn("时效:N+3 / N+3", detail)
|
||||
brief = copy.sea_group_brief(
|
||||
@@ -342,12 +342,12 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("上海-广西壮族自治区-胡志明", tms_block)
|
||||
self.assertIn("上海 → 广西壮族自治区", tms_block)
|
||||
self.assertIn("运输费:CNY 12500.00", tms_block)
|
||||
self.assertIn("总成本价:CNY 20000", tms_block)
|
||||
self.assertIn("费用合计:CNY 20000", tms_block)
|
||||
self.assertIn("报价有效期:2026-10-31", tms_block)
|
||||
self.assertNotIn("TMS标准报价", tms_block)
|
||||
self.assertNotIn("价格明细", tms_block)
|
||||
self.assertNotIn("9600", tms_block)
|
||||
self.assertNotIn("总成本价:-", tms_block)
|
||||
self.assertNotIn("费用合计:-", tms_block)
|
||||
self.assertNotIn("时效:-", tms_block)
|
||||
self.assertNotIn("时效:N+3 / N+3", tms_block)
|
||||
self.assertIn("请产品人员回复“确认报价”确定采用TMS报价。", brief)
|
||||
@@ -653,6 +653,16 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertEqual(seqs, {1})
|
||||
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
|
||||
|
||||
def test_ok_page_auto_closes_wecom_h5(self) -> None:
|
||||
from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_ok
|
||||
|
||||
ok = render_land_route_ok()
|
||||
dead = expired_land_route_page()
|
||||
self.assertIn("closeWindow", ok)
|
||||
self.assertIn("wecom-jssdk", ok)
|
||||
self.assertIn("jsapi-signature", ok)
|
||||
self.assertNotIn("closeWindow", dead)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -71,6 +71,25 @@ class SplitTests(unittest.TestCase):
|
||||
def test_same_mode_twice_is_rejected(self) -> None:
|
||||
self.assertEqual(classify_multi_text("段1:海运\n起运港:A\n段2:海运\n目的港:B"), "same_mode")
|
||||
|
||||
def test_one_sea_quote_sheet_is_single(self) -> None:
|
||||
"""测服 WO202608250012 海运报价单:文件名标段1,表里再写运输方式,仍是一段。"""
|
||||
oral = (
|
||||
"段1:海运\n"
|
||||
"运输方式:海运\n"
|
||||
"起运港:纽约港\n"
|
||||
"目的港:巴生西港\n"
|
||||
"品名:机器设备\n"
|
||||
"海运询价"
|
||||
)
|
||||
self.assertEqual(classify_multi_text(oral), "single")
|
||||
|
||||
def test_unlabeled_repeated_sea_is_single(self) -> None:
|
||||
"""报价单原文没有段标、海运出现两次,不能拆开再发。"""
|
||||
self.assertEqual(
|
||||
classify_multi_text("运输方式:海运\n起运港:A\n备注:海运询价"),
|
||||
"single",
|
||||
)
|
||||
|
||||
def test_explicit_markers_ignore_mode_words_in_body(self) -> None:
|
||||
"""文件名先标了段,表里再写「运输方式:海运」不能当成又一段海运。"""
|
||||
oral = (
|
||||
|
||||
@@ -15,6 +15,9 @@ if ROOT not in sys.path:
|
||||
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.schema.quote_adjust import (
|
||||
AdjustExtract,
|
||||
AdjustItem,
|
||||
adjustable_rows,
|
||||
apply_adjust,
|
||||
effective_quote,
|
||||
extract_adjust,
|
||||
@@ -164,6 +167,126 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertFalse(got.ok)
|
||||
self.assertEqual(got.code, "none_matched")
|
||||
|
||||
def test_sea_subtotal_and_bucket_lines(self) -> None:
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "2000",
|
||||
"fee_lines": [
|
||||
{"name": "操作费", "amount": "1000", "currency": "CNY"},
|
||||
{"name": "箱单费", "amount": "200", "currency": "CNY"},
|
||||
],
|
||||
"polCostItems": [
|
||||
{"name": "操作费", "amount": "1000", "currency": "CNY"},
|
||||
{"name": "箱单费", "amount": "200", "currency": "CNY"},
|
||||
],
|
||||
"fee_total_polCostItems": "1200",
|
||||
}
|
||||
ext = AdjustExtract(
|
||||
is_adjust=True,
|
||||
items=[
|
||||
AdjustItem("操作费", "percent", "up", 10),
|
||||
AdjustItem("箱单费", "percent", "up", 5),
|
||||
AdjustItem("始发港费用小计", "percent", "up", 12),
|
||||
],
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="SEA")
|
||||
self.assertTrue(got.ok)
|
||||
self.assertEqual(got.unmatched, [])
|
||||
names = {row["name"]: row["amount"] for row in got.quote["polCostItems"]}
|
||||
self.assertEqual(names["操作费"], "1100.00")
|
||||
self.assertEqual(names["箱单费"], "210.00")
|
||||
self.assertEqual(got.quote["fee_total_polCostItems"], "1344.00")
|
||||
|
||||
def test_sea_pol_subtotal_and_total_up_ten_percent(self) -> None:
|
||||
"""协同话术:始发港费用小计 + 费用合计 各提升 10%。"""
|
||||
src = {
|
||||
"currency": "CNY",
|
||||
"total": "14300",
|
||||
"fee_lines": [
|
||||
{"name": "操作费", "amount": "1220", "currency": "CNY"},
|
||||
{"name": "海运费", "amount": "USD 1250", "currency": "USD"},
|
||||
],
|
||||
"polCostItems": [
|
||||
{"name": "操作费", "amount": "1220", "currency": "CNY"},
|
||||
{"name": "箱单费", "amount": "50", "currency": "CNY"},
|
||||
],
|
||||
"oceanCostItems": [
|
||||
{"name": "海运费", "amount": "1250", "currency": "USD"},
|
||||
],
|
||||
"podCostItems": [
|
||||
{"name": "操作费", "amount": "240", "currency": "USD"},
|
||||
],
|
||||
"fee_total_polCostItems": "2335",
|
||||
"fee_total_oceanCostItems": "8778.75",
|
||||
"fee_total_podCostItems": "2985",
|
||||
}
|
||||
names = [str(r.get("name") or "") for r in adjustable_rows(src)]
|
||||
text = "将始发港费用小计提升10%\n费用合计提升10%"
|
||||
ext = extract_adjust(text, fee_names=names, allow_model=False)
|
||||
self.assertTrue(ext.is_adjust)
|
||||
fee_names = [x.fee_name for x in ext.items]
|
||||
self.assertIn("始发港费用小计", fee_names)
|
||||
self.assertIn("费用合计", fee_names)
|
||||
# 即使模型误标 wants_total,有点名费目时也不能只改合计
|
||||
ext.wants_total = True
|
||||
got = apply_adjust(src, ext, business_line="SEA")
|
||||
self.assertTrue(got.ok)
|
||||
self.assertEqual(got.quote["fee_total_polCostItems"], "2568.50")
|
||||
self.assertEqual(got.quote["total"], "15730.00")
|
||||
body = copy.adjust_group_body(got.quote, business_line="SEA")
|
||||
self.assertIn("始发港费用小计", body)
|
||||
self.assertIn("海运费小计", body)
|
||||
self.assertIn("目的港费用小计", body)
|
||||
|
||||
def test_same_fee_name_keeps_its_own_amount(self) -> None:
|
||||
src = {
|
||||
"currency": "USD",
|
||||
"total": "500",
|
||||
"fee_lines": [
|
||||
{"name": "文件费", "amount": "450", "currency": "USD"},
|
||||
{"name": "文件费", "amount": "50", "currency": "USD"},
|
||||
],
|
||||
"polCostItems": [
|
||||
{"name": "文件费", "amount": "450", "currency": "USD"},
|
||||
{"name": "文件费", "amount": "50", "currency": "USD"},
|
||||
],
|
||||
}
|
||||
ext = AdjustExtract(is_adjust=True, items=[AdjustItem("文件费", "percent", "up", 10)])
|
||||
got = apply_adjust(src, ext, business_line="SEA")
|
||||
self.assertTrue(got.ok)
|
||||
amounts = [row["amount"] for row in got.quote["polCostItems"]]
|
||||
self.assertEqual(amounts, ["495.00", "55.00"])
|
||||
|
||||
def test_land_same_fee_name_each_leg_and_total(self) -> None:
|
||||
src = {
|
||||
"business_line": "LAND",
|
||||
"currency": "CNY",
|
||||
"total": "10000",
|
||||
"fee_lines": [
|
||||
{"name": "运输费", "amount": "7000", "currency": "CNY"},
|
||||
{"name": "运输费", "amount": "3000", "currency": "CNY"},
|
||||
],
|
||||
"landSegments": [
|
||||
{"origin": "上海", "destination": "谅山", "costPrice": "7000", "segmentSequence": 1, "routeLegSequence": 1},
|
||||
{"origin": "谅山", "destination": "河内", "costPrice": "3000", "segmentSequence": 1, "routeLegSequence": 2},
|
||||
],
|
||||
}
|
||||
ext = AdjustExtract(
|
||||
is_adjust=True,
|
||||
items=[
|
||||
AdjustItem("运输费", "percent", "up", 10),
|
||||
AdjustItem("总成本价", "percent", "up", 10),
|
||||
],
|
||||
)
|
||||
got = apply_adjust(src, ext, business_line="LAND")
|
||||
self.assertTrue(got.ok)
|
||||
self.assertEqual(got.unmatched, [])
|
||||
amounts = sorted(row["amount"] for row in got.quote["fee_lines"])
|
||||
self.assertEqual(amounts, ["3300.00", "7700.00"])
|
||||
costs = [leg["costPrice"] for leg in got.quote["landSegments"]]
|
||||
self.assertEqual(costs, ["7700.00", "3300.00"])
|
||||
self.assertEqual(got.quote["total"], "11000.00")
|
||||
|
||||
def test_overlay_manual_wins(self) -> None:
|
||||
tms = _quote(("运输费", "1000", "CNY"), ("卸货费", "200", "CNY"), total="1200")
|
||||
manual = _quote(("卸货费", "300", "CNY"), ("配送费", "20", "CNY"), total="320")
|
||||
@@ -219,17 +342,18 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
)
|
||||
self.assertAlmostEqual(second.profit_point or 0, 30.0)
|
||||
|
||||
def test_total_label_cost_until_sales_adjust(self) -> None:
|
||||
self.assertEqual(total_price_label("AIR"), "总销售价")
|
||||
self.assertEqual(total_price_label("SEA"), "总成本价")
|
||||
self.assertEqual(total_price_label("LAND"), "总成本价")
|
||||
self.assertEqual(total_price_label("", transport_mode="空运"), "总销售价")
|
||||
self.assertEqual(total_price_label("", transport_mode="陆运"), "总成本价")
|
||||
def test_total_label_is_fee_total_for_every_line(self) -> None:
|
||||
"""销售侧汇总一律叫费用合计,空运/海运/陆运、TMS/调价都一样。"""
|
||||
self.assertEqual(total_price_label("AIR"), "费用合计")
|
||||
self.assertEqual(total_price_label("SEA"), "费用合计")
|
||||
self.assertEqual(total_price_label("LAND"), "费用合计")
|
||||
self.assertEqual(total_price_label("", transport_mode="空运"), "费用合计")
|
||||
self.assertEqual(total_price_label("", transport_mode="陆运"), "费用合计")
|
||||
tms = {"source": "TMS", "source_label": "TMS标准报价"}
|
||||
self.assertEqual(total_price_label("LAND", quote=tms), "总成本价")
|
||||
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "总成本价")
|
||||
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "总销售价")
|
||||
self.assertEqual(total_price_label("AIR", quote=tms), "总销售价")
|
||||
self.assertEqual(total_price_label("LAND", quote=tms), "费用合计")
|
||||
self.assertEqual(total_price_label("SEA", quote={"source": "PRODUCT"}), "费用合计")
|
||||
self.assertEqual(total_price_label("LAND", quote={"source": "SALES_ADJUST"}), "费用合计")
|
||||
self.assertEqual(total_price_label("AIR", quote=tms), "费用合计")
|
||||
|
||||
def test_tms_land_card_uses_cost_not_sale(self) -> None:
|
||||
q = _quote(("运输费", "121.00", "CNY"), total="121.00")
|
||||
@@ -239,7 +363,7 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
work_order_no="WO202609200005", quote=q, business_line="LAND"
|
||||
)
|
||||
self.assertEqual(detail["template_card"]["source"]["desc"], "TMS标准报价")
|
||||
self.assertIn("总成本价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertNotIn("总销售价", detail["template_card"]["quote_area"]["quote_text"])
|
||||
scheme = copy.scheme_wecom_payload(
|
||||
work_order_no="WO202609200005",
|
||||
@@ -248,9 +372,9 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
transport_mode="陆运",
|
||||
)
|
||||
self.assertEqual(scheme["template_card"]["source"]["desc"], "TMS标准报价")
|
||||
self.assertIn("总成本价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
|
||||
deal = copy.deal_horizontal_rows(quote=q, business_line="LAND")
|
||||
self.assertEqual(deal[0]["keyname"], "总成本价")
|
||||
self.assertEqual(deal[0]["keyname"], "费用合计")
|
||||
|
||||
def test_adjust_confirm_uses_sale_total_name(self) -> None:
|
||||
src = _quote(("运输费", "110", "CNY"), total="110")
|
||||
@@ -261,24 +385,22 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(land.ok)
|
||||
names = [x.get("name") for x in land.applied]
|
||||
self.assertIn("总销售价", names)
|
||||
self.assertNotIn("合计", names)
|
||||
self.assertIn("费用合计", names)
|
||||
self.assertNotIn("总销售价", names)
|
||||
self.assertNotIn("总成本价", names)
|
||||
text = copy.adjust_confirm(land.applied)
|
||||
self.assertIn("总销售价在当前报价基础上", text)
|
||||
self.assertNotIn("合计在当前报价基础上", text)
|
||||
self.assertIn("费用合计在当前报价基础上", text)
|
||||
body = copy.adjust_group_body(land.quote, business_line="LAND")
|
||||
self.assertIn("总销售价:", body)
|
||||
self.assertNotIn("合计:", body)
|
||||
self.assertIn("费用合计:", body)
|
||||
air = apply_adjust(
|
||||
src,
|
||||
extract_adjust("总销售价改为100CNY", fee_names=["运输费"], allow_model=False),
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertIn("总销售价", [x.get("name") for x in air.applied])
|
||||
self.assertNotIn("合计", [x.get("name") for x in air.applied])
|
||||
self.assertIn("费用合计", [x.get("name") for x in air.applied])
|
||||
note = copy.adjust_ledger_remark(land.applied)
|
||||
self.assertTrue(note.startswith("销售调价:"))
|
||||
self.assertIn("总销售价", note)
|
||||
self.assertIn("费用合计", note)
|
||||
|
||||
def test_quote_copy_uses_line_and_source_total_name(self) -> None:
|
||||
sea = {
|
||||
@@ -293,16 +415,17 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
"source": "TMS",
|
||||
"fee_rows": [{"name": "空运费", "amount": "700", "currency": "CNY"}],
|
||||
}
|
||||
self.assertIn("总成本价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
|
||||
self.assertIn("总销售价:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
|
||||
self.assertIn("费用合计:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
|
||||
self.assertIn("费用合计:CNY 722.5", copy.quote_area_fee_text(air, business_line="AIR"))
|
||||
self.assertNotIn("总销售价合计", copy.quote_area_fee_text(air, business_line="AIR"))
|
||||
self.assertNotIn("总销售价:CNY 2287.65", copy.quote_area_fee_text(sea, business_line="SEA"))
|
||||
self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
|
||||
self.assertNotIn("总销售价", copy.quote_area_fee_text(sea, business_line="SEA"))
|
||||
self.assertNotIn("总成本价", copy.quote_area_fee_text(sea, business_line="SEA"))
|
||||
self.assertIn("费用合计:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
|
||||
rows = copy.deal_horizontal_rows(quote=air, business_line="AIR")
|
||||
self.assertEqual(rows[0]["keyname"], "总销售价")
|
||||
self.assertEqual(rows[0]["keyname"], "费用合计")
|
||||
self.assertNotIn("时效", [x["keyname"] for x in rows])
|
||||
land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND")
|
||||
self.assertEqual(land_rows[0]["keyname"], "总成本价")
|
||||
self.assertEqual(land_rows[0]["keyname"], "费用合计")
|
||||
self.assertIn("时效", [x["keyname"] for x in land_rows])
|
||||
|
||||
def test_adjust_cards_use_sales_adjust_not_tms(self) -> None:
|
||||
@@ -314,7 +437,7 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
work_order_no="WO202609200004", quote=q, business_line="LAND"
|
||||
)
|
||||
self.assertEqual(detail["template_card"]["source"]["desc"], "销售调价")
|
||||
self.assertIn("总销售价:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertIn("费用合计:CNY 121.00", detail["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertNotIn("总成本价", detail["template_card"]["quote_area"]["quote_text"])
|
||||
scheme = copy.scheme_wecom_payload(
|
||||
work_order_no="WO202609200004",
|
||||
@@ -323,7 +446,7 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
transport_mode="陆运",
|
||||
)
|
||||
self.assertEqual(scheme["template_card"]["source"]["desc"], "销售调价")
|
||||
self.assertIn("总销售价:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
|
||||
self.assertIn("费用合计:CNY 121.00", scheme["template_card"]["quote_area"]["quote_text"])
|
||||
|
||||
def test_private_eligibility(self) -> None:
|
||||
self.assertEqual(private_session_can_adjust("已报价", True, ""), "")
|
||||
@@ -469,10 +592,10 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
transport_mode="陆运",
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertIn("总成本价:CNY 110.00", tms_block.split("销售调价:", 1)[0])
|
||||
self.assertIn("费用合计:CNY 110.00", tms_block.split("销售调价:", 1)[0])
|
||||
self.assertIn("销售调价:", text)
|
||||
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
|
||||
self.assertNotIn("总销售价:CNY 100.00", tms_block.split("销售调价:", 1)[0])
|
||||
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
|
||||
self.assertNotIn("费用合计:CNY 100.00", tms_block.split("销售调价:", 1)[0])
|
||||
|
||||
def test_group_brief_adjust_alone_does_not_become_tms(self) -> None:
|
||||
"""没单独传入 TMS 时,调后价也不能写进 TMS 段。"""
|
||||
@@ -492,7 +615,7 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertIn("TMS报价:\nTMS 暂无匹配报价", text)
|
||||
self.assertIn("产品人员可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。", text)
|
||||
self.assertIn("销售调价:", text)
|
||||
self.assertIn("总销售价:CNY 100.00", text.split("销售调价:", 1)[1])
|
||||
self.assertIn("费用合计:CNY 100.00", text.split("销售调价:", 1)[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -26,6 +26,9 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
self.assertEqual(canonicalize_sea_class("port to port"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("P2P"), "Port to Port")
|
||||
self.assertEqual(canonicalize_sea_class("港到港"), "Port to Port")
|
||||
glued = "15件,Port\u00a0to\u00a0Port\n贸易条款:DOOR-DOOR"
|
||||
self.assertEqual(canonicalize_sea_class(glued), "Port to Port")
|
||||
self.assertEqual(harvest_sea_class(glued, {}).get("运输分类"), "Port to Port")
|
||||
|
||||
def test_other_alias(self):
|
||||
self.assertEqual(canonicalize_sea_class("其他"), "其他")
|
||||
@@ -42,6 +45,14 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
got = harvest_sea_class("运输分类:其他", {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
|
||||
def test_clarify_example_does_not_override_filled_other(self):
|
||||
"""举例行里的 Port to Port 不能盖掉冒号后的其它/其他。"""
|
||||
pasted = "1.运输分类(必填):其它\n(如:Port to Port、其他)"
|
||||
got = harvest_sea_class(pasted, {"运输分类": "Port to Port"})
|
||||
self.assertEqual(got["运输分类"], "其他")
|
||||
other = harvest_sea_class("运输分类(必填):其他\n(如:Port to Port、其他)", {})
|
||||
self.assertEqual(other["运输分类"], "其他")
|
||||
|
||||
def test_door_to_door_not_kept_as_trade_terms(self):
|
||||
text = "海运、纽约港到巴生西港,机器设备,40HQ*1,整柜,1.5KG,15CBM,15件"
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "品名": "机器设备"})
|
||||
@@ -49,11 +60,23 @@ class SeaOptionsTest(unittest.TestCase):
|
||||
kept = drop_invented_door_trade_terms("贸易条款:门到门", {"贸易条款": "门到门"})
|
||||
self.assertEqual(kept["贸易条款"], "门到门")
|
||||
|
||||
def test_prompt_lists_two_options_only(self):
|
||||
text = sea_class_prompt()
|
||||
self.assertIn("Port to Port", text)
|
||||
self.assertIn("其他", text)
|
||||
self.assertNotIn("门到门", text)
|
||||
def test_wo012_trade_port_class_other(self):
|
||||
"""条款写 Port to Port、分类写其它:二者各归各栏,禁止互盖。"""
|
||||
text = (
|
||||
"运输方式:海运\n起运港:纽约港\n目的港:巴生西港\n品名:机器设备\n"
|
||||
"货物数量:15件\n整柜或拼柜:整柜\n箱型箱量:40HQ*1\n"
|
||||
"贸易条款:Port to Port\n运输分类:其它\n报价日期:2026-09-29"
|
||||
)
|
||||
got = harvest_sea_class(text, {"贸易条款": "门到门", "运输分类": "Port to Port"})
|
||||
self.assertEqual(got.get("运输分类"), "其他")
|
||||
got = drop_invented_door_trade_terms(text, {"贸易条款": "门到门", "运输分类": "其他"})
|
||||
self.assertEqual(got.get("贸易条款"), "Port to Port")
|
||||
self.assertEqual(got.get("运输分类"), "其他")
|
||||
|
||||
def test_trade_line_port_does_not_set_class_when_class_labeled_other(self):
|
||||
text = "贸易条款:Port to Port\n运输分类:其它"
|
||||
self.assertEqual(harvest_sea_class(text, {}).get("运输分类"), "其他")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -143,6 +143,23 @@ class SeaTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(check["facts"]["货量"], "15件")
|
||||
self.assertNotIn("货量", check["missing"])
|
||||
|
||||
def test_container_shaped_cargo_qty_uses_pieces(self) -> None:
|
||||
"""货量被抽成箱型时,改用件数。20件、15件同一条规则。"""
|
||||
facts = {
|
||||
"起运港": "广州港",
|
||||
"目的港": "巴生港",
|
||||
"品名": "机器设备",
|
||||
"货量": "40HQ*1",
|
||||
"件数": "20件",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "40HQ*1",
|
||||
"贸易条款": "DOOR-DOOR",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
check = validate_required_fields(facts=facts, business_line="SEA")
|
||||
self.assertEqual(check["facts"]["货量"], "20件")
|
||||
self.assertEqual(check["facts"]["箱型箱量"], "40HQ*1")
|
||||
|
||||
def test_sea_missing_container_only_asks_container(self) -> None:
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
@@ -478,7 +495,7 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
self.assertIn("海运费:USD 575", text)
|
||||
self.assertIn("海运费小计:CNY 4038", text)
|
||||
self.assertIn("目的港费用小计:CNY 470", text)
|
||||
self.assertIn("总成本价:CNY 6952.77", text)
|
||||
self.assertIn("费用合计:CNY 6952.77", text)
|
||||
self.assertNotIn("时效", text)
|
||||
|
||||
def test_long_fee_lines_go_to_text_not_clipped_card(self) -> None:
|
||||
@@ -515,14 +532,14 @@ class SeaTmsCardTests(unittest.TestCase):
|
||||
"封条费(SEAI):CNY 50.00",
|
||||
"设备费:CNY 50.00",
|
||||
"报关费:CNY 250.00",
|
||||
"总成本价:CNY 2287.65",
|
||||
"费用合计:CNY 2287.65",
|
||||
]
|
||||
),
|
||||
)
|
||||
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.assertIn("费用合计:CNY 2287.65", card_text)
|
||||
self.assertNotIn("海运费:USD 100.00\n燃油附加费", card_text)
|
||||
self.assertLessEqual(len(card_text), 80)
|
||||
payload = copy.sea_tms_hit_wecom_payload(
|
||||
@@ -1120,6 +1137,21 @@ class SeaGroupCollabFieldTests(unittest.TestCase):
|
||||
self.assertEqual(str(rows["海运费"].get("amount") or ""), "100.00")
|
||||
self.assertEqual(str(rows["燃油附加费"].get("amount") or ""), "15.00")
|
||||
|
||||
def test_air_unit_qty_amount_keeps_air_freight_name(self) -> None:
|
||||
"""空运费 单价/数量/金额:费用名是空运费,金额用「金额」不是单价。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
quote = extract_product_quote(
|
||||
"空运段,空运费 单价CNY99.00,数量100 ,金额CNY199.00@询价小助手"
|
||||
)
|
||||
rows = {str(x.get("name") or ""): x for x in (quote.get("fee_rows") or [])}
|
||||
self.assertIn("空运费", rows)
|
||||
self.assertNotIn("海运费", rows)
|
||||
self.assertEqual(str(rows["空运费"].get("amount") or ""), "199.00")
|
||||
self.assertEqual(str(rows["空运费"].get("currency") or ""), "CNY")
|
||||
self.assertEqual(str(rows["空运费"].get("unit_price") or ""), "99.00")
|
||||
self.assertEqual(str(quote.get("total") or ""), "199.0")
|
||||
|
||||
def test_fee_line_text_is_a_product_quote(self) -> None:
|
||||
"""产品按行改价:码头操作费100CNY / 报关费改为300CNY,必须抽出费用名,不能只当海运费合计。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>4.1.2</version>
|
||||
</dependency>
|
||||
<!-- 模板预览:PDF→PNG 等比例渲染(替代 LibreOffice png 滤镜的非等比拉伸) -->
|
||||
<dependency>
|
||||
<groupId>org.apache.pdfbox</groupId>
|
||||
<artifactId>pdfbox</artifactId>
|
||||
<version>2.0.29</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
+209
-5
@@ -643,11 +643,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找已缓存预览图。
|
||||
* 只用 v2 文件名:旧版 LibreOffice 直转 PNG 可能非等比拉伸,废弃 *-snapshot.png。
|
||||
*/
|
||||
private Path findCachedSnapshot(String versionId) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf");
|
||||
Path cachedPng = previewDir.resolve(versionId + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(versionId + "-snapshot-v4.pdf");
|
||||
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
|
||||
return cachedPng;
|
||||
}
|
||||
@@ -660,8 +664,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
|
||||
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
|
||||
Files.createDirectories(previewDir);
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf");
|
||||
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v4.png");
|
||||
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v4.pdf");
|
||||
Path source = resolveObjectPath(ver.getObjectKey());
|
||||
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
|
||||
Path target = rendered.isImage() ? cachedPng : cachedPdf;
|
||||
@@ -871,8 +875,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("quote_date", new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()));
|
||||
}
|
||||
out.put("valid_until", first(q, "valid_until"));
|
||||
out.put("total_amount", first(q, "total", "total_amount"));
|
||||
// 预估费用必须用 TMS 总销售价。会话 quote 偶发只有 selected_air_option.salePrice、
|
||||
// 顶层 total 空时,若仍走模板 SUM,会把增点费+拆板费加成 200(WO040/WO026)。
|
||||
// 群出单还可能把明细之和误写成 total=200,此时仍以线路 salePrice 为准。
|
||||
String fromOpt = airOptionSalePrice(q);
|
||||
String totalAmount = first(q, "total", "total_amount", "salePrice", "totalSalePrice");
|
||||
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
|
||||
totalAmount = fromOpt;
|
||||
} else if (StringUtils.isNotBlank(fromOpt)
|
||||
&& looksAirInquiry(src, q, businessLine)
|
||||
&& totalLooksLikeFeeRowSum(q, totalAmount)
|
||||
&& !sameMoneyAmount(totalAmount, fromOpt)) {
|
||||
totalAmount = fromOpt;
|
||||
}
|
||||
if (StringUtils.isBlank(totalAmount) || "-".equals(totalAmount)) {
|
||||
// freight 与总价同值时(无分项空运费)也可用
|
||||
String freight = first(q, "freight");
|
||||
if (StringUtils.isNotBlank(freight) && !"-".equals(freight)) {
|
||||
totalAmount = freight;
|
||||
}
|
||||
}
|
||||
out.put("total_amount", totalAmount);
|
||||
String currency = first(q, "currency");
|
||||
if (StringUtils.isBlank(currency)) {
|
||||
currency = airOptionCurrency(q);
|
||||
}
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
out.put("currency", currency);
|
||||
}
|
||||
@@ -977,6 +1004,11 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("fee_rows_" + bucket, part);
|
||||
out.put(bucket, part);
|
||||
}
|
||||
// 三段小计:卡片用 fee_total_*,出单写进「始发港/海运/目的港费用汇总」行
|
||||
String sub = first(q, "fee_total_" + bucket);
|
||||
if (StringUtils.isNotBlank(sub) && !"-".equals(sub)) {
|
||||
out.put("fee_total_" + bucket, sub);
|
||||
}
|
||||
}
|
||||
// 陆运线路:透传 landSegments / route_rows,供 routeRegions 填项目一
|
||||
JSONArray landSegs = QuoteTemplateExcelSupport.parseFeeRows(q.get("route_rows"));
|
||||
@@ -1071,6 +1103,178 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运选定线路 / 首条 airOptions 的 salePrice。
|
||||
* 会话 quote 有时只投影了线路对象,顶层 total 空,出单预估费用必须能落到这个数。
|
||||
*/
|
||||
private static String airOptionSalePrice(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
Object selected = quote.get("selected_air_option");
|
||||
if (selected == null) {
|
||||
selected = quote.get("selectedAirOption");
|
||||
}
|
||||
String fromSelected = salePriceFromOptionObj(selected);
|
||||
if (StringUtils.isNotBlank(fromSelected)) {
|
||||
return fromSelected;
|
||||
}
|
||||
Object raw = quote.get("airOptions");
|
||||
if (raw == null) {
|
||||
raw = quote.get("air_options");
|
||||
}
|
||||
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
|
||||
if (opts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return salePriceFromOptionObj(opts.getJSONObject(0));
|
||||
}
|
||||
|
||||
/** 空运线路币种:选定 > 首条 airOptions。 */
|
||||
private static String airOptionCurrency(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
Object selected = quote.get("selected_air_option");
|
||||
if (selected == null) {
|
||||
selected = quote.get("selectedAirOption");
|
||||
}
|
||||
String ccy = currencyFromOptionObj(selected);
|
||||
if (StringUtils.isNotBlank(ccy)) {
|
||||
return ccy;
|
||||
}
|
||||
Object raw = quote.get("airOptions");
|
||||
if (raw == null) {
|
||||
raw = quote.get("air_options");
|
||||
}
|
||||
JSONArray opts = QuoteTemplateExcelSupport.parseFeeRows(raw);
|
||||
if (opts.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return currencyFromOptionObj(opts.getJSONObject(0));
|
||||
}
|
||||
|
||||
private static String salePriceFromOptionObj(Object raw) {
|
||||
JSONObject opt = asJsonObject(raw);
|
||||
if (opt == null) {
|
||||
return "";
|
||||
}
|
||||
return firstNonBlankLocal(opt.getString("salePrice"), opt.getString("sale_price"),
|
||||
opt.getString("totalSalePrice"), opt.getString("total"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 total 是否只是 fee_lines 金额相加(如 100+100=200)。
|
||||
* 空运群薄报价常把附加费之和误当总价,真总价在 airOptions.salePrice。
|
||||
*/
|
||||
private static boolean totalLooksLikeFeeRowSum(Map<String, Object> quote, String totalText) {
|
||||
Double totalNum = parseMoneyPlain(totalText);
|
||||
if (totalNum == null) {
|
||||
return false;
|
||||
}
|
||||
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_lines"));
|
||||
if (rows.isEmpty()) {
|
||||
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("fee_rows"));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
rows = QuoteTemplateExcelSupport.parseFeeRows(quote.get("feeItems"));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
double sum = 0.0;
|
||||
int hit = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
JSONObject one = rows.getJSONObject(i);
|
||||
if (one == null) {
|
||||
continue;
|
||||
}
|
||||
Double amt = parseMoneyPlain(firstNonBlankLocal(
|
||||
one.getString("amount"), one.getString("value"),
|
||||
one.getString("salePrice"), one.getString("price")));
|
||||
if (amt == null) {
|
||||
continue;
|
||||
}
|
||||
sum += amt;
|
||||
hit++;
|
||||
}
|
||||
if (hit <= 0) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(totalNum - sum) < 0.01;
|
||||
}
|
||||
|
||||
/** 去掉币种前缀后比较金额是否相同。 */
|
||||
private static boolean sameMoneyAmount(String left, String right) {
|
||||
Double a = parseMoneyPlain(left);
|
||||
Double b = parseMoneyPlain(right);
|
||||
if (a == null || b == null) {
|
||||
return StringUtils.equals(StringUtils.trimToEmpty(left), StringUtils.trimToEmpty(right));
|
||||
}
|
||||
return Math.abs(a - b) < 0.01;
|
||||
}
|
||||
|
||||
/** 从「CNY 7918.25」抽出数字;解不开返回 null。 */
|
||||
private static Double parseMoneyPlain(String text) {
|
||||
String raw = StringUtils.trimToEmpty(text);
|
||||
if (raw.isEmpty() || "-".equals(raw)) {
|
||||
return null;
|
||||
}
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("(-?\\d+(?:,\\d{3})*(?:\\.\\d+)?)")
|
||||
.matcher(raw);
|
||||
String last = null;
|
||||
while (m.find()) {
|
||||
last = m.group(1);
|
||||
}
|
||||
if (last == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Double.parseDouble(last.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String currencyFromOptionObj(Object raw) {
|
||||
JSONObject opt = asJsonObject(raw);
|
||||
if (opt == null) {
|
||||
return "";
|
||||
}
|
||||
return StringUtils.trimToEmpty(opt.getString("currency"));
|
||||
}
|
||||
|
||||
private static JSONObject asJsonObject(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
if (raw instanceof JSONObject) {
|
||||
return (JSONObject) raw;
|
||||
}
|
||||
if (raw instanceof Map) {
|
||||
return JSON.parseObject(JSON.toJSONString(raw));
|
||||
}
|
||||
if (raw instanceof String) {
|
||||
String text = StringUtils.trimToEmpty((String) raw);
|
||||
if (text.isEmpty() || "null".equalsIgnoreCase(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object parsed = JSON.parse(text);
|
||||
if (parsed instanceof JSONObject) {
|
||||
return (JSONObject) parsed;
|
||||
}
|
||||
if (parsed instanceof Map) {
|
||||
return JSON.parseObject(JSON.toJSONString(parsed));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 协同项没写,或只写了横杠。横杠是空占位,不能当成已经填过。 */
|
||||
private static boolean collabBlank(String raw) {
|
||||
String val = StringUtils.trimToEmpty(raw);
|
||||
|
||||
+3
-2
@@ -41,7 +41,7 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
private static final Map<String, String> ID_TO_TIMEOUT_CODE = new LinkedHashMap<>();
|
||||
|
||||
static {
|
||||
// 陆运产品仅保留 4 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
// 陆运产品 5 项运输分类(与线路解耦);旧逗号写法一次性纠偏
|
||||
LAND_PRODUCT_TYPE_RENAMES.put(
|
||||
"普件,中港、东南亚常用线路/常规车型",
|
||||
"普件(东南亚、中港常用线路/常规车型)");
|
||||
@@ -58,7 +58,8 @@ public class InquiryResponseTimeoutServiceImpl
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000004", "land_sales");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000005", "sea_product_ptp");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000006", "sea_product_other");
|
||||
// 陆运产品运输分类 4 项(编码沿用 land_hk_*,勿再按线路拆 ltl/city/sea)
|
||||
// 陆运产品运输分类 5 项(编码勿再按线路拆 ltl/city/sea)
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000020", "land_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000007", "land_hk_common");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000008", "land_hk_cross");
|
||||
ID_TO_TIMEOUT_CODE.put("rt100000000000000000000000000009", "land_hk_special");
|
||||
|
||||
+15
-5
@@ -503,7 +503,8 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
ticket.setGoodsName(facts.get("品名"));
|
||||
ticket.setPieces(parseInt(facts.get("件数")));
|
||||
ticket.setWeightKg(parseDecimal(firstFact(facts, "重量", "毛重")));
|
||||
ticket.setVolumeCbm(parseDecimal(facts.get("体积")));
|
||||
// 体积常写尺寸 1.1*1.2*3,必须相乘;旧解析剥 * 会变成 1.11.23 直接 NumberFormat 炸建单。
|
||||
ticket.setVolumeCbm(parseVolumeCbm(facts.get("体积")));
|
||||
ticket.setQuoteDate(parseDate(facts.get("报价日期")));
|
||||
ticket.setCargoQty(StringUtils.trimToNull(firstFact(facts, "货量", "货物数量")));
|
||||
ticket.setFclLcl(StringUtils.trimToNull(facts.get("整柜或拼柜")));
|
||||
@@ -578,7 +579,7 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
Map<String, String> facts = req.getFacts() == null ? new HashMap<>() : req.getFacts();
|
||||
Integer pieces = parseInt(facts.get("件数"));
|
||||
BigDecimal weight = parseDecimal(firstFact(facts, "重量", "毛重"));
|
||||
BigDecimal volume = parseDecimal(facts.get("体积"));
|
||||
BigDecimal volume = parseVolumeCbm(facts.get("体积"));
|
||||
Date quoteDate = parseDate(facts.get("报价日期"));
|
||||
return list(new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getBusinessLine, req.getBusinessLine().trim().toUpperCase())
|
||||
@@ -677,15 +678,24 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return Integer.valueOf(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量等:只取第一个数字(100KGS → 100)。坏串返回 null,禁止抛到建单接口。
|
||||
*/
|
||||
private static BigDecimal parseDecimal(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
String cleaned = raw.replaceAll("[^0-9.]", "");
|
||||
if (cleaned.isEmpty()) {
|
||||
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 体积:尺寸式相乘得 CBM;普通「2CBM」取数字。失败返回 null,不炸建单。
|
||||
*/
|
||||
private static BigDecimal parseVolumeCbm(String raw) {
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
return null;
|
||||
}
|
||||
return new BigDecimal(cleaned);
|
||||
return org.jeecg.modules.inquiry.tms.TmsV2RequestMapper.asVolumeCbm(raw);
|
||||
}
|
||||
|
||||
private static Date parseDate(String raw) {
|
||||
|
||||
+1946
-35
File diff suppressed because it is too large
Load Diff
+211
-5
@@ -2,12 +2,23 @@ package org.jeecg.modules.inquiry.template;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.poi.ss.usermodel.PrintSetup;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -20,8 +31,10 @@ import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。
|
||||
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PDF,再用 PDFBox 等比例渲 PNG。
|
||||
* <p>
|
||||
* 为何不用 LibreOffice 直接转 PNG:其 pdf→png 滤镜在部分版本会把页内容非等比拉伸,
|
||||
* 后台预览出现「字被拉高/压扁」。PDFBox 按页 MediaBox 均匀换算 DPI,保证 1:1 像素比例。
|
||||
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
|
||||
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
|
||||
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
|
||||
@@ -30,6 +43,9 @@ import java.util.stream.Stream;
|
||||
@Component
|
||||
public class QuoteTemplateSnapshotRenderer {
|
||||
|
||||
/** 预览 PNG 渲染 DPI;只影响清晰度,不改变宽高比。 */
|
||||
private static final float PREVIEW_DPI = 144f;
|
||||
|
||||
@Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}")
|
||||
private String sofficePath;
|
||||
|
||||
@@ -73,15 +89,17 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
try {
|
||||
Path localXlsx = workDir.resolve("source.xlsx");
|
||||
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
|
||||
// 预览专用打印版式:按表内容宽高选横/竖,避免宽表硬塞竖版 A4 显得挤扁
|
||||
preparePreviewPrintLayout(localXlsx);
|
||||
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
|
||||
if (pdf == null || !Files.isRegularFile(pdf)) {
|
||||
throw new IllegalStateException("LibreOffice 未生成 PDF");
|
||||
}
|
||||
Path png = convert(soffice, pdf, workDir, "png", ".png");
|
||||
if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) {
|
||||
return new PreviewBytes(Files.readAllBytes(png), "image/png");
|
||||
byte[] pngBytes = pdfToPngEqualRatio(pdf);
|
||||
if (pngBytes != null && pngBytes.length > 0) {
|
||||
return new PreviewBytes(pngBytes, "image/png");
|
||||
}
|
||||
log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId);
|
||||
log.warn("PDFBox 未生成 PNG,退回 PDF 预览 versionId={}", versionId);
|
||||
return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf");
|
||||
} finally {
|
||||
deleteQuietly(workDir);
|
||||
@@ -94,6 +112,194 @@ public class QuoteTemplateSnapshotRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览前改写打印属性:按已用区域宽高比选横向/纵向 A4,并按页适配。
|
||||
* 只动打印,不改单元格业务内容;写回同一 xlsx 供 soffice 使用。
|
||||
*/
|
||||
private void preparePreviewPrintLayout(Path xlsx) throws IOException {
|
||||
try (InputStream in = Files.newInputStream(xlsx);
|
||||
Workbook wb = WorkbookFactory.create(in)) {
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
boolean landscape = preferLandscape(sheet);
|
||||
sheet.setMargin(Sheet.LeftMargin, 0.4);
|
||||
sheet.setMargin(Sheet.RightMargin, 0.4);
|
||||
sheet.setMargin(Sheet.TopMargin, 0.5);
|
||||
sheet.setMargin(Sheet.BottomMargin, 0.5);
|
||||
sheet.setHorizontallyCenter(true);
|
||||
sheet.setVerticallyCenter(false);
|
||||
sheet.setAutobreaks(true);
|
||||
sheet.setFitToPage(true);
|
||||
PrintSetup ps = sheet.getPrintSetup();
|
||||
ps.setPaperSize(PrintSetup.A4_PAPERSIZE);
|
||||
ps.setLandscape(landscape);
|
||||
ps.setFitWidth((short) 1);
|
||||
// 只按页宽适配:不把矮表纵向撑满整页,减少页底空白(最终仍靠 PNG 裁白边)
|
||||
ps.setFitHeight((short) 0);
|
||||
}
|
||||
try (OutputStream out = Files.newOutputStream(xlsx, StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE)) {
|
||||
wb.write(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 粗估工作表内容宽高:列宽合计 vs 行高合计,宽更大则预览用横向。
|
||||
* 单位换算只需相对比较,不要求绝对毫米精度。
|
||||
*/
|
||||
private static boolean preferLandscape(Sheet sheet) {
|
||||
int firstRow = Math.max(sheet.getFirstRowNum(), 0);
|
||||
int lastRow = sheet.getLastRowNum();
|
||||
if (lastRow < firstRow) {
|
||||
return false;
|
||||
}
|
||||
int maxCol = 0;
|
||||
for (int r = firstRow; r <= lastRow && r <= firstRow + 120; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
short last = row.getLastCellNum();
|
||||
if (last > maxCol) {
|
||||
maxCol = last;
|
||||
}
|
||||
}
|
||||
if (maxCol <= 0) {
|
||||
return false;
|
||||
}
|
||||
double widthPx = 0;
|
||||
for (int c = 0; c < maxCol; c++) {
|
||||
// POI 列宽:1/256 字符宽;约 7px/字符(96dpi 经验值)
|
||||
widthPx += (sheet.getColumnWidth(c) / 256.0) * 7.0;
|
||||
}
|
||||
double heightPx = 0;
|
||||
int rowLimit = Math.min(lastRow, firstRow + 120);
|
||||
for (int r = firstRow; r <= rowLimit; r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
float points = row != null ? row.getHeightInPoints() : sheet.getDefaultRowHeightInPoints();
|
||||
heightPx += points * (96.0 / 72.0);
|
||||
}
|
||||
return widthPx > heightPx * 1.08;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF 首页等比例渲 PNG:PDFBox 按页尺寸 × DPI 均匀换算,禁止非等比拉伸。
|
||||
* 渲完后裁掉近白页边,避免 A4 页底大块空白占预览高度。
|
||||
*/
|
||||
private byte[] pdfToPngEqualRatio(Path pdf) throws IOException {
|
||||
try (PDDocument doc = PDDocument.load(pdf.toFile())) {
|
||||
if (doc.getNumberOfPages() < 1) {
|
||||
return null;
|
||||
}
|
||||
PDFRenderer renderer = new PDFRenderer(doc);
|
||||
BufferedImage image = renderer.renderImageWithDPI(0, PREVIEW_DPI, ImageType.RGB);
|
||||
image = trimNearWhiteMargins(image, 8);
|
||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
||||
if (!ImageIO.write(image, "png", buf)) {
|
||||
throw new IOException("ImageIO 无法写出 PNG");
|
||||
}
|
||||
return buf.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁掉四周近白色边距,四周各留 {@code pad} 像素,避免预览弹窗底下留一大块空白。
|
||||
* <p>
|
||||
* 行/列允许极少量杂点(浅网格线);写出前必须拷贝到新 BufferedImage,
|
||||
* 禁止直接 ImageIO.write(getSubimage())——部分 JDK 会把整页母图写出去,裁切等于没做。
|
||||
*/
|
||||
static BufferedImage trimNearWhiteMargins(BufferedImage src, int pad) {
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
int w = src.getWidth();
|
||||
int h = src.getHeight();
|
||||
if (w <= 0 || h <= 0) {
|
||||
return src;
|
||||
}
|
||||
int top = 0;
|
||||
int bottom = h - 1;
|
||||
int left = 0;
|
||||
int right = w - 1;
|
||||
while (top < h && isMostlyWhiteRow(src, top)) {
|
||||
top++;
|
||||
}
|
||||
while (bottom > top && isMostlyWhiteRow(src, bottom)) {
|
||||
bottom--;
|
||||
}
|
||||
while (left < w && isMostlyWhiteCol(src, left, top, bottom)) {
|
||||
left++;
|
||||
}
|
||||
while (right > left && isMostlyWhiteCol(src, right, top, bottom)) {
|
||||
right--;
|
||||
}
|
||||
// 几乎全白:不裁,避免空图
|
||||
if (top >= bottom || left >= right || (bottom - top + 1) < h / 20) {
|
||||
return src;
|
||||
}
|
||||
int padSafe = Math.max(0, pad);
|
||||
int x = Math.max(0, left - padSafe);
|
||||
int y = Math.max(0, top - padSafe);
|
||||
int x2 = Math.min(w - 1, right + padSafe);
|
||||
int y2 = Math.min(h - 1, bottom + padSafe);
|
||||
int cw = x2 - x + 1;
|
||||
int ch = y2 - y + 1;
|
||||
if (cw >= w && ch >= h) {
|
||||
return src;
|
||||
}
|
||||
BufferedImage copy = new BufferedImage(cw, ch, BufferedImage.TYPE_INT_RGB);
|
||||
java.awt.Graphics2D g = copy.createGraphics();
|
||||
try {
|
||||
g.drawImage(src, 0, 0, cw, ch, x, y, x + cw, y + ch, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** 一行里非近白像素不超过约 0.5% 则视为空白行(容忍浅网格)。 */
|
||||
private static boolean isMostlyWhiteRow(BufferedImage img, int y) {
|
||||
int w = img.getWidth();
|
||||
int allow = Math.max(2, w / 200);
|
||||
int dirty = 0;
|
||||
for (int x = 0; x < w; x++) {
|
||||
if (!isNearWhitePixel(img.getRGB(x, y))) {
|
||||
dirty++;
|
||||
if (dirty > allow) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isMostlyWhiteCol(BufferedImage img, int x, int y0, int y1) {
|
||||
int h = y1 - y0 + 1;
|
||||
int allow = Math.max(2, h / 200);
|
||||
int dirty = 0;
|
||||
for (int y = y0; y <= y1; y++) {
|
||||
if (!isNearWhitePixel(img.getRGB(x, y))) {
|
||||
dirty++;
|
||||
if (dirty > allow) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isNearWhitePixel(int rgb) {
|
||||
int r = (rgb >> 16) & 0xff;
|
||||
int g = (rgb >> 8) & 0xff;
|
||||
int b = rgb & 0xff;
|
||||
// 略放宽:打印灰底/抗锯齿边也不挡裁切
|
||||
return r >= 240 && g >= 240 && b >= 240;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
|
||||
*/
|
||||
|
||||
+714
-45
@@ -26,10 +26,11 @@ import java.util.Map;
|
||||
* 空运锁舱/释放:智能体只打主账,本类 HMAC 出站 TMS。
|
||||
* <p>
|
||||
* 锁舱按 /air/cabin/lock:用工单起运地/目的地/报价日期/重量/体积拼正文。
|
||||
* 锁舱优先带 TMS 查价返回的 quoteId / optionNo。库里只有金额、没有编号时当场再查一次。
|
||||
* 没有 5.2 报价也可以锁舱:TMS 锁舱这两项必填,缺了会 1001「参数格式错误」。
|
||||
* 此时补 QT-{工单号} + AIR-OPT-01,禁止拿空字段出站。
|
||||
* 有真实 TMS 编号时不准用自编号盖掉,否则会回 2006。
|
||||
* 没有报价不能锁舱,禁止编造 QT-{工单号}、AIR-OPT-01。
|
||||
* 只看最新一版:库里 source=TMS(或航线确认 TMS)出站写成 source=SYSTEM;人工报价出站 source=MANUAL。
|
||||
* 有真实 TMS 编号才带 quoteId。有值才出站,缺航班不拦。
|
||||
* SYSTEM 出站精简(quoteId + 航班等标量,不带 validUntil/feeItems);
|
||||
* MANUAL 费用名按 TMS 查价回包原样带回,有效期必须带时区 ISO8601。
|
||||
* 已成交再释放:本地拦住。测服打开 TMS_V2_LOCK_ENABLED;现网验收期保持关闭。
|
||||
* 禁止:按 userid 伪造报价;单测打真实 TMS。
|
||||
*/
|
||||
@@ -67,40 +68,124 @@ public class InquiryTmsCabinService {
|
||||
|| TicketStatuses.CLOSED.equals(ticket.getStatus())) {
|
||||
return fail("ticket_closed", "工单已是「" + ticket.getStatus() + "」,不能锁舱");
|
||||
}
|
||||
Map<String, Object> quote = latestQuote(no);
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
String quoteId = lockQuoteId(resolveQuoteId(quote, no), no);
|
||||
List<InquiryQuote> rows = quoteRows(no);
|
||||
if (rows.isEmpty()) {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> quote = payloadOf(rows.get(0));
|
||||
// 「AIR-OPT01 TH」被误落成航线报价不算真正人工价 → 仍走仅 TMS(SYSTEM)。
|
||||
boolean latestManual = isManualQuote(quote) && !isFakeManualLockQuote(quote);
|
||||
if (!latestManual && StringUtils.isBlank(resolveQuoteId(quote, no))) {
|
||||
quote = refreshLockQuote(ticket, quote);
|
||||
}
|
||||
String tmsQuoteId = realTmsQuoteId(rows, no);
|
||||
if (!latestManual && StringUtils.isBlank(tmsQuoteId)) {
|
||||
tmsQuoteId = resolveQuoteId(quote, no);
|
||||
}
|
||||
boolean hasTms = StringUtils.isNotBlank(tmsQuoteId);
|
||||
// 三种锁舱口径(产品):
|
||||
// 1) 仅 TMS → SYSTEM + quoteId
|
||||
// 2) TMS + 人工 → MANUAL + quoteId(引用原 TMS 编号)
|
||||
// 3) 仅人工 → MANUAL,不传 quoteId
|
||||
String lockSource;
|
||||
String quoteId;
|
||||
if (hasTms && !latestManual) {
|
||||
lockSource = "SYSTEM";
|
||||
quoteId = tmsQuoteId;
|
||||
} else if (hasTms) {
|
||||
lockSource = "MANUAL";
|
||||
quoteId = tmsQuoteId;
|
||||
} else if (latestManual) {
|
||||
lockSource = "MANUAL";
|
||||
quoteId = "";
|
||||
} else {
|
||||
return fail("no_quote", "这张单还没有报价,不能锁舱");
|
||||
}
|
||||
Map<String, Object> option = resolveOption(quote, optionNo);
|
||||
if (option == null) {
|
||||
option = new LinkedHashMap<>();
|
||||
}
|
||||
String resolvedOption = lockOptionNo(text(option, "optionNo", "option_no"));
|
||||
option.put("optionNo", resolvedOption);
|
||||
// 手工价常只有金额/错误抽到的 fee_lines(如把 AIR-OPT01 抽成 OPT)。
|
||||
// 只要本单有真实 TMS 舱位,就用它补航班、费用、有效期,不能只因 stub 已有 optionNo 就跳过。
|
||||
String wantOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
|
||||
Map<String, Object> tmsOption = tmsOptionForLock(rows, no, wantOption);
|
||||
if (!tmsOption.isEmpty()) {
|
||||
option = mergeLockOption(option, tmsOption);
|
||||
}
|
||||
Map<String, Object> tmsQuote = tmsQuotePayload(rows, no);
|
||||
if (tmsQuote != null && !tmsQuote.isEmpty()) {
|
||||
quote = enrichLockQuoteFromTms(quote, tmsQuote);
|
||||
}
|
||||
// 航线句中已点名的 optionNo 不能因报价数组对不上就被丢掉。
|
||||
String resolvedOption = firstNonBlank(text(option, "optionNo", "option_no"), optionNo);
|
||||
if (!properties.lockConfigured()) {
|
||||
return fail("lock_disabled", "主账未开放锁舱");
|
||||
}
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("workOrderNo", no);
|
||||
req.put("quoteId", quoteId);
|
||||
if (StringUtils.isNotBlank(quoteId)) {
|
||||
req.put("quoteId", quoteId);
|
||||
}
|
||||
req.put("optionNo", resolvedOption);
|
||||
req.put("flightDate", quoteDate(ticket));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"), ticket.getOrigin(),
|
||||
req.put("flightDate", firstNonBlank(
|
||||
text(option, "flightDate"),
|
||||
flightDateFromDeparture(text(option, "departureTime")),
|
||||
quoteDate(ticket)));
|
||||
req.put("origin", firstNonBlank(text(option, "originAirportCode"),
|
||||
fact(ticket, "originCode"),
|
||||
text(option, "origin"), ticket.getOrigin(),
|
||||
fact(ticket, "起运地", "起运港")));
|
||||
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"), ticket.getDestination(),
|
||||
req.put("destination", firstNonBlank(text(option, "destinationAirportCode"),
|
||||
fact(ticket, "destinationCode"),
|
||||
text(option, "destination"), ticket.getDestination(),
|
||||
fact(ticket, "目的地", "目的港")));
|
||||
req.put("airline", firstNonBlank(text(option, "airlineCode", "airline"),
|
||||
// 航司:句中两位码(如「AIR-OPT01 TH」)> 舱位/报价字段 > 航班号前两位。
|
||||
req.put("airline", firstNonBlank(
|
||||
text(body, "airline", "airlineCode"),
|
||||
airlineFromInstruction(text(body, "instructionText", "instruction_text")),
|
||||
text(option, "airlineCode", "airline"),
|
||||
text(option, "airlineName"),
|
||||
text(quote, "airlineCode", "airline", "airlineName"),
|
||||
fact(ticket, "航司", "航司代码")));
|
||||
fact(ticket, "航司", "航司代码"),
|
||||
airlineFromFlight(firstNonBlank(text(option, "flightNo"), text(quote, "flightNo")))));
|
||||
req.put("weightKg", ticket.getWeightKg());
|
||||
req.put("volumeCbm", ticket.getVolumeCbm());
|
||||
req.put("pieceQty", ticket.getPieces());
|
||||
req.put("chargeableWeightKg", firstNonBlank(
|
||||
text(quote, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||
text(option, "chargeableWeightKg", "chargeable_weight_kg")));
|
||||
text(option, "chargeableWeightKg", "chargeable_weight_kg"),
|
||||
text(quote, "chargeableWeightKg", "chargeable_weight_kg")));
|
||||
req.put("operatorUserId", text(body, "operatorUserId", "sender_id"));
|
||||
req.put("operatorName", text(body, "operatorName", "operator_name"));
|
||||
req.put("instructionText", text(body, "instructionText", "instruction_text"));
|
||||
if ("MANUAL".equals(lockSource)) {
|
||||
// 有 TMS 时保留 quoteId;纯人工才去掉。
|
||||
fillManualLock(req, quote, option, body, StringUtils.isNotBlank(quoteId));
|
||||
} else {
|
||||
// 仅 TMS:source=SYSTEM + quoteId。禁止再塞 validUntil/feeItems(会 1001)。
|
||||
req.put("source", "SYSTEM");
|
||||
req.remove("validUntil");
|
||||
req.remove("requestedUntil");
|
||||
req.remove("departureCharges");
|
||||
req.remove("feeItems");
|
||||
req.remove("currency");
|
||||
req.remove("totalSalePrice");
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
req.put("transferAirport", transfer);
|
||||
}
|
||||
String flightNo = firstNonBlank(
|
||||
text(option, "flightNo", "flight_no"),
|
||||
text(quote, "flightNo", "flight_no"),
|
||||
flightNoFromSelectedAirOption(quote),
|
||||
flightNoFromAirOptions(quote, resolvedOption));
|
||||
if (StringUtils.isNotBlank(flightNo)) {
|
||||
req.put("flightNo", flightNo);
|
||||
}
|
||||
if (StringUtils.isNotBlank(text(body, "requestedUntil"))) {
|
||||
req.put("requestedUntil", text(body, "requestedUntil"));
|
||||
}
|
||||
}
|
||||
return invokeLock(ticket, req, quoteId, resolvedOption);
|
||||
}
|
||||
|
||||
@@ -310,8 +395,523 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 库里的真实 TMS 报价编号。自编 QT-工单号不算:先再查一次 5.2,
|
||||
* 仍没有才在 {@link #lockQuoteId} 补占位,给无报价锁舱用。
|
||||
* 人工报价出站:对齐 TMS MANUAL 合同。
|
||||
* <p>
|
||||
* 必带:source=MANUAL、optionNo、validUntil(ISO)、origin/destination、
|
||||
* flightDate、airline、flightNo、重量体积件数、计费重、币种、总价、
|
||||
* departureCharges、feeItems;可选 requestedUntil(缺则用 validUntil)。
|
||||
* {@code withQuoteId=true}:本单还有 TMS 报价时必须带原 quoteId;
|
||||
* {@code withQuoteId=false}:纯人工、无 TMS 时不传 quoteId。
|
||||
*/
|
||||
static void fillManualLock(Map<String, Object> req, Map<String, Object> quote,
|
||||
Map<String, Object> option, Map<String, Object> body,
|
||||
boolean withQuoteId) {
|
||||
req.put("source", "MANUAL");
|
||||
if (!withQuoteId) {
|
||||
req.remove("quoteId");
|
||||
}
|
||||
fillLockQuoteExtras(req, quote, option);
|
||||
String transfer = firstNonBlank(text(option, "transferAirport", "transferAirportCode"),
|
||||
text(quote, "transferAirport", "transferAirportCode"));
|
||||
if (StringUtils.isNotBlank(transfer)) {
|
||||
req.put("transferAirport", transfer);
|
||||
}
|
||||
String flightNo = firstNonBlank(text(option, "flightNo"), text(quote, "flightNo"));
|
||||
if (StringUtils.isNotBlank(flightNo)) {
|
||||
req.put("flightNo", flightNo);
|
||||
}
|
||||
String currency = firstNonBlank(text(option, "currency"),
|
||||
text(quote, "currency", "currencyCode"));
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
req.put("currency", currency);
|
||||
}
|
||||
// 舱位售价优先;避免手工误抽的 total=1 盖住 TMS 真价。
|
||||
String total = firstNonBlank(text(option, "salePrice", "totalSalePrice"),
|
||||
text(quote, "totalSalePrice", "salePrice", "total"));
|
||||
if (StringUtils.isNotBlank(total) && !"-".equals(total)) {
|
||||
req.put("totalSalePrice", total);
|
||||
}
|
||||
// requestedUntil:调用方有就用;否则用 validUntil(合同样例两者都有)。
|
||||
String requested = firstNonBlank(text(body, "requestedUntil"),
|
||||
text(req, "validUntil"));
|
||||
String isoReq = normalizeValidUntilIso(requested);
|
||||
if (StringUtils.isNotBlank(isoReq)) {
|
||||
req.put("requestedUntil", isoReq);
|
||||
}
|
||||
}
|
||||
|
||||
static String flightDateFromDeparture(String departureTime) {
|
||||
if (StringUtils.isBlank(departureTime) || departureTime.length() < 10) {
|
||||
return "";
|
||||
}
|
||||
return departureTime.substring(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱共用:有效期转 ISO8601,费用名按 TMS 查价回包原样带回。
|
||||
* <p>
|
||||
* 查价落库常是 {@code valid_until=2026-09-30}(仅日期);锁舱要带时区的 ISO,
|
||||
* 否则 TMS 回 1001「参数格式错误」。费用优先 {@code airOptions[].departureCharges} /
|
||||
* {@code feeItems} 原文,禁止用本地改写过的展示名替换。
|
||||
*/
|
||||
static void fillLockQuoteExtras(Map<String, Object> req, Map<String, Object> quote, Map<String, Object> option) {
|
||||
String until = firstNonBlank(text(quote, "validUntil", "valid_until"), text(option, "validUntil"));
|
||||
String isoUntil = normalizeValidUntilIso(until);
|
||||
if (StringUtils.isNotBlank(isoUntil)) {
|
||||
req.put("validUntil", isoUntil);
|
||||
}
|
||||
List<Map<String, Object>> departure = feeRows(quote, option, "departureCharges", "departure_charges");
|
||||
List<Map<String, Object>> fees = feeRows(quote, option, "feeItems", "fee_items");
|
||||
splitFeeRows(quote, departure, fees);
|
||||
if (!departure.isEmpty()) {
|
||||
req.put("departureCharges", toLockFees(departure, true));
|
||||
}
|
||||
if (!fees.isEmpty()) {
|
||||
req.put("feeItems", toLockFees(fees, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱 validUntil / requestedUntil:规范成带时区的 ISO8601。
|
||||
* <ul>
|
||||
* <li>{@code 2026-09-30} → {@code 2026-09-30T23:59:59+08:00}(有效期到当日结束)</li>
|
||||
* <li>已有 {@code T} 但无时区 → 补 {@code +08:00}</li>
|
||||
* <li>已是完整 ISO → 原样</li>
|
||||
* </ul>
|
||||
*/
|
||||
static String normalizeValidUntilIso(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
||||
return "";
|
||||
}
|
||||
String text = raw.trim();
|
||||
// 已是完整带时区的 ISO
|
||||
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.,]\\d+)?(Z|[+-]\\d{2}:?\\d{2})")) {
|
||||
return text;
|
||||
}
|
||||
// 有 T 无时区
|
||||
if (text.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(:\\d{2})?([.,]\\d+)?")) {
|
||||
if (text.length() == 16) {
|
||||
// yyyy-MM-ddTHH:mm
|
||||
return text + ":00+08:00";
|
||||
}
|
||||
return text + "+08:00";
|
||||
}
|
||||
// 仅日期:yyyy-MM-dd 或 yyyy/MM/dd
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})$")
|
||||
.matcher(text);
|
||||
if (m.matches()) {
|
||||
int month = Integer.parseInt(m.group(2));
|
||||
int day = Integer.parseInt(m.group(3));
|
||||
return String.format("%s-%02d-%02dT23:59:59+08:00", m.group(1), month, day);
|
||||
}
|
||||
// 空格分隔的本地时间:yyyy-MM-dd HH:mm[:ss]
|
||||
java.util.regex.Matcher local = java.util.regex.Pattern
|
||||
.compile("^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?$")
|
||||
.matcher(text);
|
||||
if (local.matches()) {
|
||||
String sec = local.group(6) == null ? "00" : local.group(6);
|
||||
return String.format("%s-%02d-%02dT%02d:%02d:%s+08:00",
|
||||
local.group(1),
|
||||
Integer.parseInt(local.group(2)),
|
||||
Integer.parseInt(local.group(3)),
|
||||
Integer.parseInt(local.group(4)),
|
||||
Integer.parseInt(local.group(5)),
|
||||
sec.length() == 1 ? "0" + sec : sec);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 最新一版是不是人工报价。航线只确认沿用 TMS 不算。
|
||||
* 认 payload 里的 source / source_label,因为落库时确认 TMS 也会被写成 PRODUCT。
|
||||
*/
|
||||
static boolean isManualQuote(Map<String, Object> quote) {
|
||||
String label = text(quote, "source_label", "sourceLabel");
|
||||
if (label.contains("确认") && label.toUpperCase().contains("TMS")) {
|
||||
return false;
|
||||
}
|
||||
String src = text(quote, "source").toUpperCase();
|
||||
if ("SALES_ADJUST".equals(src) || label.contains("销售调价")) {
|
||||
return true;
|
||||
}
|
||||
if ("PRODUCT".equals(src) || "MANUAL".equals(src)) {
|
||||
return true;
|
||||
}
|
||||
return label.contains("航线报价") || label.contains("航线附件")
|
||||
|| label.contains("在TMS上改") || label.contains("手工");
|
||||
}
|
||||
|
||||
/**
|
||||
* 误把锁舱口令落成「航线报价」:raw_text 像 AIR-OPT / 锁舱,或费用名只有 OPT 碎片。
|
||||
* 这种不算「有人工报价」,锁舱应按仅 TMS(SYSTEM)。
|
||||
*/
|
||||
static boolean isFakeManualLockQuote(Map<String, Object> quote) {
|
||||
if (quote == null || quote.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (looksLikeOptionTokenFees(quote)) {
|
||||
return true;
|
||||
}
|
||||
String raw = text(quote, "raw_text", "rawText", "instructionText").toUpperCase();
|
||||
if (raw.contains("AIR-OPT") || raw.contains("AIROPT") || raw.contains("锁舱")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工价没有舱位时,用本单 TMS 报价上的那一条。多条且没指定就不猜。
|
||||
* optionNo 大小写、横杠差异按忽略大小写匹配。
|
||||
*/
|
||||
static Map<String, Object> tmsOptionForLock(List<InquiryQuote> rows, String workOrderNo, String optionNo) {
|
||||
if (rows == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
Map<String, Object> payload = payloadOf(row);
|
||||
if (StringUtils.isBlank(resolveQuoteId(payload, workOrderNo))) {
|
||||
continue;
|
||||
}
|
||||
if (isManualQuote(payload)) {
|
||||
continue;
|
||||
}
|
||||
List<Map<String, Object>> options = airOptions(payload);
|
||||
if (options.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(optionNo)) {
|
||||
for (Map<String, Object> item : options) {
|
||||
if (optionNo.equalsIgnoreCase(text(item, "optionNo", "option_no"))) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (options.size() == 1) {
|
||||
return options.get(0);
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 本单最新一版非手工、带真实 quoteId 的 TMS 报价正文。
|
||||
*/
|
||||
static Map<String, Object> tmsQuotePayload(List<InquiryQuote> rows, String workOrderNo) {
|
||||
if (rows == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
Map<String, Object> payload = payloadOf(row);
|
||||
if (isManualQuote(payload)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(resolveQuoteId(payload, workOrderNo))) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱舱位:TMS 明细打底,航线点名的 optionNo 盖上去。
|
||||
*/
|
||||
static Map<String, Object> mergeLockOption(Map<String, Object> stub, Map<String, Object> tmsOption) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (tmsOption != null) {
|
||||
out.putAll(tmsOption);
|
||||
}
|
||||
String no = text(stub, "optionNo", "option_no");
|
||||
if (StringUtils.isNotBlank(no)) {
|
||||
out.put("optionNo", no);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工价缺有效期/计费重时,从本单 TMS 报价补;不覆盖手工已有金额。
|
||||
* 费用明细优先走舱位上的 departureCharges(merge 后),这里只补标量。
|
||||
*/
|
||||
static Map<String, Object> enrichLockQuoteFromTms(Map<String, Object> quote, Map<String, Object> tmsQuote) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (quote != null) {
|
||||
out.putAll(quote);
|
||||
}
|
||||
if (tmsQuote == null || tmsQuote.isEmpty()) {
|
||||
return out;
|
||||
}
|
||||
if (StringUtils.isBlank(text(out, "validUntil", "valid_until"))
|
||||
|| "-".equals(text(out, "validUntil", "valid_until"))) {
|
||||
String until = firstNonBlank(text(tmsQuote, "validUntil", "valid_until"));
|
||||
if (StringUtils.isNotBlank(until) && !"-".equals(until)) {
|
||||
out.put("validUntil", until);
|
||||
out.put("valid_until", until);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(text(out, "chargeableWeightKg", "chargeable_weight_kg"))) {
|
||||
String cw = firstNonBlank(text(tmsQuote, "chargeableWeightKg", "chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(cw)) {
|
||||
out.put("chargeableWeightKg", cw);
|
||||
}
|
||||
}
|
||||
// 手工 fee_lines 若像「OPT」这种从舱位编号误抽的,清掉,避免盖住 TMS departureCharges。
|
||||
if (looksLikeOptionTokenFees(out)) {
|
||||
out.remove("fee_lines");
|
||||
out.remove("fee_rows");
|
||||
out.remove("feeItems");
|
||||
}
|
||||
// 补 TMS 顶层 feeItems(空运费等),手工价常常没有。
|
||||
if (out.get("feeItems") == null && out.get("fee_items") == null
|
||||
&& (tmsQuote.get("feeItems") != null || tmsQuote.get("fee_items") != null)) {
|
||||
Object fees = tmsQuote.get("feeItems");
|
||||
if (fees == null) {
|
||||
fees = tmsQuote.get("fee_items");
|
||||
}
|
||||
out.put("feeItems", fees);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* fee_lines 只有「OPT / AIR-OPT…」这类舱位编号碎片时,不是真费用。
|
||||
*/
|
||||
static boolean looksLikeOptionTokenFees(Map<String, Object> quote) {
|
||||
List<Map<String, Object>> rows = feeRows(quote, null, "fee_lines", "fee_rows", "feeItems");
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
String name = feeName(row).toUpperCase().replace("-", "");
|
||||
if (!(name.equals("OPT") || name.startsWith("AIROPT") || name.matches("OPT\\d+"))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static String realTmsQuoteId(List<InquiryQuote> rows, String workOrderNo) {
|
||||
if (rows == null) {
|
||||
return "";
|
||||
}
|
||||
for (InquiryQuote row : rows) {
|
||||
String id = resolveQuoteId(payloadOf(row), workOrderNo);
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private List<InquiryQuote> quoteRows(String workOrderNo) {
|
||||
if (StringUtils.isBlank(workOrderNo)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<InquiryQuote> rows = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 8"));
|
||||
return rows == null ? new ArrayList<>() : rows;
|
||||
}
|
||||
|
||||
private static Map<String, Object> payloadOf(InquiryQuote row) {
|
||||
Map<String, Object> quote = new LinkedHashMap<>();
|
||||
if (row == null) {
|
||||
return quote;
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getPayloadJson())) {
|
||||
JSONObject parsed = JSON.parseObject(row.getPayloadJson());
|
||||
if (parsed != null) {
|
||||
quote.putAll(parsed);
|
||||
}
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getSource()) && StringUtils.isBlank(text(quote, "source"))) {
|
||||
quote.put("source", row.getSource());
|
||||
}
|
||||
if (StringUtils.isNotBlank(row.getSourceLabel()) && StringUtils.isBlank(text(quote, "source_label", "sourceLabel"))) {
|
||||
quote.put("source_label", row.getSourceLabel());
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> feeRows(Map<String, Object> quote, Map<String, Object> option,
|
||||
String... keys) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
collectFeeRows(out, option, keys);
|
||||
if (out.isEmpty()) {
|
||||
collectFeeRows(out, quote, keys);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void collectFeeRows(List<Map<String, Object>> out, Map<String, Object> src, String... keys) {
|
||||
if (src == null || keys == null) {
|
||||
return;
|
||||
}
|
||||
for (String key : keys) {
|
||||
Object raw = src.get(key);
|
||||
if (raw instanceof String && String.valueOf(raw).trim().startsWith("[")) {
|
||||
try {
|
||||
raw = JSON.parseArray(String.valueOf(raw));
|
||||
} catch (Exception ignore) {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
if (raw instanceof JSONArray) {
|
||||
raw = ((JSONArray) raw).toJavaList(Object.class);
|
||||
}
|
||||
if (!(raw instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object item : (List<?>) raw) {
|
||||
if (item instanceof Map && StringUtils.isNotBlank(feeName((Map<String, Object>) item))) {
|
||||
out.add((Map<String, Object>) item);
|
||||
}
|
||||
}
|
||||
if (!out.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价只存了 fee_rows 时,本地费进 departureCharges,其余进 feeItems。已分开的不再搬。
|
||||
*/
|
||||
private static void splitFeeRows(Map<String, Object> quote, List<Map<String, Object>> departure,
|
||||
List<Map<String, Object>> fees) {
|
||||
if (!departure.isEmpty() || !fees.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Map<String, Object> row : feeRows(quote, null, "fee_rows", "fee_lines")) {
|
||||
String name = feeName(row);
|
||||
if ("运输费".equals(name) || "提送货费".equals(name) || "人工费".equals(name)) {
|
||||
departure.add(row);
|
||||
} else {
|
||||
fees.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String feeName(Map<String, Object> row) {
|
||||
return text(row, "chargeName", "feeName", "name", "label");
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行出站:对齐 MANUAL 合同字段。
|
||||
* departureCharges:chargeName/unitPrice/quantity/amount/unit/currency;
|
||||
* feeItems:feeCode/feeName/…/segmentSequence/remark。
|
||||
* 起运港费缺 quantity 时默认 1(合同样例)。
|
||||
*/
|
||||
static List<Map<String, Object>> toLockFees(List<Map<String, Object>> rows, boolean departure) {
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
String name = departure
|
||||
? firstNonBlank(text(row, "chargeName"), text(row, "feeName"), text(row, "name", "label"))
|
||||
: firstNonBlank(text(row, "feeName"), text(row, "chargeName"), text(row, "name", "label"));
|
||||
if (StringUtils.isNotBlank(name)) {
|
||||
item.put(departure ? "chargeName" : "feeName", name);
|
||||
}
|
||||
String code = text(row, "feeCode", "fee_code");
|
||||
if (!departure && StringUtils.isNotBlank(code)) {
|
||||
item.put("feeCode", code);
|
||||
}
|
||||
putFeeNumber(item, "unitPrice", text(row, "unitPrice", "unit_price"));
|
||||
String qtyRaw = text(row, "quantity");
|
||||
if (StringUtils.isBlank(qtyRaw) && departure) {
|
||||
qtyRaw = "1";
|
||||
}
|
||||
putFeeNumber(item, "quantity", qtyRaw);
|
||||
putFeeNumber(item, "amount", text(row, "amount"));
|
||||
// 有单价无金额时:金额 = 单价 × 数量
|
||||
if (!item.containsKey("amount") && item.get("unitPrice") instanceof java.math.BigDecimal
|
||||
&& item.get("quantity") instanceof java.math.BigDecimal) {
|
||||
item.put("amount", plainFeeDecimal(
|
||||
((java.math.BigDecimal) item.get("unitPrice"))
|
||||
.multiply((java.math.BigDecimal) item.get("quantity"))));
|
||||
}
|
||||
String currency = text(row, "currency");
|
||||
if (StringUtils.isNotBlank(currency)) {
|
||||
item.put("currency", currency);
|
||||
}
|
||||
String unit = text(row, "unit", "单位");
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
item.put("unit", unit);
|
||||
}
|
||||
if (!departure) {
|
||||
// 合同样例:feeCode 在 feeName 前;上面已先写 feeName,这里保持有值即可。
|
||||
String seg = text(row, "segmentSequence", "segment_sequence");
|
||||
if (StringUtils.isNotBlank(seg)) {
|
||||
try {
|
||||
item.put("segmentSequence", Integer.parseInt(seg.trim()));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// 非整数就不送
|
||||
}
|
||||
}
|
||||
String remark = text(row, "remark", "备注");
|
||||
if (StringUtils.isNotBlank(remark)) {
|
||||
item.put("remark", remark);
|
||||
}
|
||||
}
|
||||
// 合同样例 feeItems:feeCode 在 feeName 前
|
||||
if (!departure && item.containsKey("feeCode") && item.containsKey("feeName")) {
|
||||
Map<String, Object> ordered = new LinkedHashMap<>();
|
||||
ordered.put("feeCode", item.get("feeCode"));
|
||||
ordered.put("feeName", item.get("feeName"));
|
||||
for (Map.Entry<String, Object> e : item.entrySet()) {
|
||||
if ("feeCode".equals(e.getKey()) || "feeName".equals(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
ordered.put(e.getKey(), e.getValue());
|
||||
}
|
||||
item = ordered;
|
||||
}
|
||||
if (!item.isEmpty()) {
|
||||
out.add(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用数字:去掉尾零,但避免 100.000 → 1E+2(TMS 当参数格式错误)。
|
||||
* 与重量字段不同:费用合同样例是 11 / 1333.6,整数千不要写成 11.0。
|
||||
*/
|
||||
static java.math.BigDecimal plainFeeDecimal(java.math.BigDecimal value) {
|
||||
java.math.BigDecimal stripped = value.stripTrailingZeros();
|
||||
if (stripped.scale() < 0) {
|
||||
return stripped.setScale(0);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
private static void putFeeNumber(Map<String, Object> item, String key, String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
|
||||
return;
|
||||
}
|
||||
String text = raw.trim().replace(",", "");
|
||||
int space = text.lastIndexOf(' ');
|
||||
if (space >= 0) {
|
||||
text = text.substring(space + 1).trim();
|
||||
}
|
||||
try {
|
||||
item.put(key, plainFeeDecimal(new java.math.BigDecimal(text)));
|
||||
} catch (NumberFormatException ignore) {
|
||||
// 不是数字就不送,避免把「99.00元」这类原文塞进 TMS 数字栏。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 库里的真实 TMS 报价编号。自编 QT-工单号不算,缺了也不补占位。
|
||||
*/
|
||||
static String resolveQuoteId(Map<String, Object> quote, String workOrderNo) {
|
||||
String id = text(quote, "quoteId", "quote_id");
|
||||
@@ -326,33 +926,82 @@ public class InquiryTmsCabinService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的报价编号。TMS /air/cabin/lock 的 quoteId 必填,缺了回 1001。
|
||||
* 有 5.2 真编号就用真编号;没有才补 QT-{工单号},允许无报价锁舱。
|
||||
* 报价没给航司时,用航班号前两位字母。TH739 → TH。
|
||||
* 不是字母开头就不猜,避免把纯数字航班编成航司。
|
||||
*/
|
||||
static String lockQuoteId(String resolved, String workOrderNo) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
static String airlineFromFlight(String flightNo) {
|
||||
String raw = StringUtils.trimToEmpty(flightNo).toUpperCase();
|
||||
if (raw.length() >= 2
|
||||
&& Character.isLetter(raw.charAt(0))
|
||||
&& Character.isLetter(raw.charAt(1))) {
|
||||
return raw.substring(0, 2);
|
||||
}
|
||||
String wo = StringUtils.trimToEmpty(workOrderNo);
|
||||
if (wo.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 从报价 airOptions 里按 optionNo 取航班号;对不上则取唯一有值的那条。
|
||||
* SYSTEM 锁舱缺 flightNo 时补用,避免只带了航司码却没航班号。
|
||||
*/
|
||||
static String flightNoFromAirOptions(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
if (options.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return "QT-" + wo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱出站用的舱位选项。TMS optionNo 必填,缺了回 1001。
|
||||
* 有选项用选项;没有补 AIR-OPT-01。
|
||||
*/
|
||||
static String lockOptionNo(String resolved) {
|
||||
if (StringUtils.isNotBlank(resolved)) {
|
||||
return resolved.trim();
|
||||
String want = StringUtils.trimToEmpty(optionNo);
|
||||
String lone = "";
|
||||
int withFlight = 0;
|
||||
for (Map<String, Object> row : options) {
|
||||
String no = firstNonBlank(text(row, "optionNo", "option_no"));
|
||||
String fn = firstNonBlank(text(row, "flightNo", "flight_no"));
|
||||
if (StringUtils.isBlank(fn)) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(want) && want.equalsIgnoreCase(no)) {
|
||||
return fn;
|
||||
}
|
||||
withFlight++;
|
||||
lone = fn;
|
||||
}
|
||||
return "AIR-OPT-01";
|
||||
if (withFlight == 1) {
|
||||
return lone;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位选项按名称挑;没有就补 AIR-OPT-01,无报价锁舱也要带上必填项。
|
||||
* 从 selected_air_option(常为 JSON 字符串)取航班号。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static String flightNoFromSelectedAirOption(Map<String, Object> quote) {
|
||||
if (quote == null) {
|
||||
return "";
|
||||
}
|
||||
Object raw = quote.get("selected_air_option");
|
||||
if (raw == null) {
|
||||
raw = quote.get("selectedAirOption");
|
||||
}
|
||||
if (raw instanceof String) {
|
||||
String s = String.valueOf(raw).trim();
|
||||
if (s.startsWith("{")) {
|
||||
try {
|
||||
raw = JSON.parseObject(s);
|
||||
} catch (Exception ignore) {
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
if (raw instanceof Map) {
|
||||
return firstNonBlank(text((Map<String, Object>) raw, "flightNo", "flight_no"));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 有舱位按名称挑。没有舱位时,航线已指定的编号原样带上,不编 AIR-OPT-01。
|
||||
* 报价数组对不上时仍保留航线说的 optionNo,避免出站空编号。
|
||||
*/
|
||||
static Map<String, Object> resolveOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
@@ -361,20 +1010,40 @@ public class InquiryTmsCabinService {
|
||||
if (picked != null) {
|
||||
return picked;
|
||||
}
|
||||
if (options.size() == 1) {
|
||||
if (options.size() == 1 && StringUtils.isBlank(optionNo)) {
|
||||
return options.get(0);
|
||||
}
|
||||
if (StringUtils.isNotBlank(optionNo)) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", optionNo.trim());
|
||||
return row;
|
||||
}
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", lockOptionNo(optionNo));
|
||||
String airline = text(quote, "airlineCode", "airline", "airlineName");
|
||||
if (StringUtils.isNotBlank(airline)) {
|
||||
row.put("airlineCode", airline);
|
||||
if (StringUtils.isBlank(optionNo)) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("optionNo", optionNo.trim());
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从锁舱口令抽出两位航司码,例如「锁AIR-OPT01 TH」→ TH。
|
||||
*/
|
||||
static String airlineFromInstruction(String instruction) {
|
||||
if (StringUtils.isBlank(instruction)) {
|
||||
return "";
|
||||
}
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile("(?i)AIR-?OPT-?\\d+\\s*([A-Za-z]{2})(?![A-Za-z0-9])")
|
||||
.matcher(instruction);
|
||||
if (m.find()) {
|
||||
return m.group(1).toUpperCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> pickOption(Map<String, Object> quote, String optionNo) {
|
||||
List<Map<String, Object>> options = airOptions(quote);
|
||||
@@ -459,7 +1128,7 @@ public class InquiryTmsCabinService {
|
||||
|
||||
/**
|
||||
* 锁舱/释放失败话术:原样用 TMS 的 failReason。
|
||||
* 禁止按航班日改写成「起飞」,禁止把 2006 映射成「报价选项不存在」——没有报价也可以锁舱。
|
||||
* 禁止按航班日改写成「起飞」,禁止把 2006 改写成另一句业务话。
|
||||
*/
|
||||
static String cabinFailReason(int code, Map<String, Object> tms, String flightDate) {
|
||||
return cabinFailReason(code, tms, flightDate, "");
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ public class InquiryTmsQueryService {
|
||||
segment.put("destination", "");
|
||||
segment.put("quantity", TmsV2RequestMapper.asInteger(facts.get("件数")));
|
||||
segment.put("weightKg", TmsV2RequestMapper.asPositiveDecimal(facts.get("毛重")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asPositiveDecimal(facts.get("体积")));
|
||||
segment.put("volumeCbm", TmsV2RequestMapper.asVolumeCbm(facts.get("体积")));
|
||||
fillAirportCode(segment, "originCode", text(first(facts.get("originCode"), origin)));
|
||||
fillAirportCode(segment, "destinationCode", text(first(facts.get("destinationCode"), destination)));
|
||||
// 包装优先销售原文,避免旧工单 packageType=2 盖住「托盘」。
|
||||
|
||||
+69
-9
@@ -4,6 +4,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -12,8 +13,11 @@ import java.util.UUID;
|
||||
* <p>
|
||||
* 锁舱唯一键:workOrderNo + quoteId + optionNo。flightDate 用工单报价日期,接口没有单独 quoteDate。
|
||||
* 操作人角色固定 AIRLINE。数字字段保持 Number,禁止带 KGS/CBM。
|
||||
* quoteId / optionNo 必填:有 TMS 报价用真编号;没有时由 CabinService 补 QT-{工单号} / AIR-OPT-01。
|
||||
* mapper 仍跳过空字符串,避免把 null 写成 JSON。
|
||||
* 系统报价:source=SYSTEM + quoteId。
|
||||
* 人工+TMS:source=MANUAL + 原 quoteId。
|
||||
* 纯人工:source=MANUAL,不传 quoteId;validUntil/requestedUntil 用 ISO8601;
|
||||
* 必带 transferAirport/flightNo/departureCharges/feeItems(有值才写)。
|
||||
* 不在这里编造 optionNo。
|
||||
*/
|
||||
public final class TmsCabinRequestMapper {
|
||||
|
||||
@@ -21,30 +25,43 @@ public final class TmsCabinRequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 锁舱正文。字段顺序固定,签名按这份 JSON 原文计算。
|
||||
* 锁舱正文。字段顺序按 TMS MANUAL/SYSTEM 样例排,签名按这份 JSON 原文计算。
|
||||
*/
|
||||
public static Map<String, Object> lock(Map<String, Object> internal) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("requestId", firstText(internal.get("requestId"), UUID.randomUUID().toString()));
|
||||
put(body, "workOrderNo", internal.get("workOrderNo"));
|
||||
put(body, "quoteId", internal.get("quoteId"));
|
||||
put(body, "source", internal.get("source"));
|
||||
put(body, "workOrderNo", internal.get("workOrderNo"));
|
||||
put(body, "optionNo", internal.get("optionNo"));
|
||||
Object until = internal.get("requestedUntil");
|
||||
if (until != null && StringUtils.isNotBlank(String.valueOf(until))) {
|
||||
body.put("requestedUntil", String.valueOf(until).trim());
|
||||
Object requestedUntil = internal.get("requestedUntil");
|
||||
if (requestedUntil != null && StringUtils.isNotBlank(String.valueOf(requestedUntil))) {
|
||||
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(requestedUntil).trim());
|
||||
body.put("requestedUntil", StringUtils.isNotBlank(iso) ? iso : String.valueOf(requestedUntil).trim());
|
||||
}
|
||||
Object validUntil = internal.get("validUntil");
|
||||
if (validUntil != null && StringUtils.isNotBlank(String.valueOf(validUntil))) {
|
||||
String iso = InquiryTmsCabinService.normalizeValidUntilIso(String.valueOf(validUntil).trim());
|
||||
put(body, "validUntil", StringUtils.isNotBlank(iso) ? iso : validUntil);
|
||||
}
|
||||
put(body, "origin", first(internal, "origin", "originCode"));
|
||||
put(body, "transferAirport", internal.get("transferAirport"));
|
||||
put(body, "destination", first(internal, "destination", "destinationCode"));
|
||||
put(body, "flightDate", TmsV2RequestMapper.normalizeQuoteDate(internal.get("flightDate")));
|
||||
put(body, "airline", first(internal, "airline", "airlineCode"));
|
||||
put(body, "flightNo", internal.get("flightNo"));
|
||||
putNumber(body, "weightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("weightKg")));
|
||||
putNumber(body, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(internal.get("volumeCbm")));
|
||||
putNumber(body, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(internal.get("volumeCbm")));
|
||||
putNumber(body, "pieceQty", TmsV2RequestMapper.asInteger(internal.get("pieceQty")));
|
||||
putNumber(body, "chargeableWeightKg", TmsV2RequestMapper.asPositiveDecimal(internal.get("chargeableWeightKg")));
|
||||
put(body, "operatorUserId", internal.get("operatorUserId"));
|
||||
put(body, "operatorName", internal.get("operatorName"));
|
||||
body.put("operatorRole", "AIRLINE");
|
||||
put(body, "instructionText", internal.get("instructionText"));
|
||||
put(body, "currency", internal.get("currency"));
|
||||
putNumber(body, "totalSalePrice", asDecimal(internal.get("totalSalePrice")));
|
||||
putList(body, "departureCharges", internal.get("departureCharges"));
|
||||
putList(body, "feeItems", internal.get("feeItems"));
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -77,10 +94,53 @@ public final class TmsCabinRequestMapper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用数组有行才写入。空数组不传,避免 TMS 把「没有明细」当成一条空费用。
|
||||
*/
|
||||
private static void putList(Map<String, Object> target, String key, Object value) {
|
||||
if (value instanceof List && !((List<?>) value).isEmpty()) {
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static BigDecimal asDecimal(Object value) {
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
if (value == null || StringUtils.isBlank(String.valueOf(value))) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(value).trim().replace(",", "");
|
||||
int space = text.lastIndexOf(' ');
|
||||
if (space >= 0) {
|
||||
text = text.substring(space + 1).trim();
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 100.000 去掉尾零会变成 1E+2,TMS 锁舱当参数格式错误。
|
||||
* 重量和体积类型是 decimal,整数也写成 100.0,避免被收成没有小数点的整数。
|
||||
*/
|
||||
static BigDecimal plainDecimal(BigDecimal value) {
|
||||
BigDecimal stripped = value.stripTrailingZeros();
|
||||
if (stripped.scale() < 1) {
|
||||
return stripped.setScale(1);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
private static void putNumber(Map<String, Object> target, String key, Number value) {
|
||||
if (value != null) {
|
||||
if (value instanceof BigDecimal) {
|
||||
target.put(key, ((BigDecimal) value).stripTrailingZeros());
|
||||
target.put(key, plainDecimal((BigDecimal) value));
|
||||
} else {
|
||||
target.put(key, value);
|
||||
}
|
||||
|
||||
+4
-3
@@ -122,7 +122,8 @@ public class TmsGatewayClient {
|
||||
throw new IllegalStateException("TMS 锁舱未开放或签名未配置");
|
||||
}
|
||||
assertCircuitAvailable();
|
||||
String json = JSON.toJSONString(request);
|
||||
String json = JSON.toJSONString(request, com.alibaba.fastjson.serializer.SerializerFeature.WriteBigDecimalAsPlain);
|
||||
log.info("tms.cabin req path={} json={}", path, json);
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = java.util.UUID.randomUUID().toString();
|
||||
String signature = TmsHmacSigner.sign(properties.getSignatureSecret(), timestamp, nonce, json);
|
||||
@@ -167,10 +168,10 @@ public class TmsGatewayClient {
|
||||
consecutiveFailures.set(0);
|
||||
body.put("formal", true);
|
||||
body.put("source", "TMS");
|
||||
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={}",
|
||||
log.info("tms.cabin path={} wo={} lockId={} quoteId={} code={} http={} costMs={} attempt={} bodyLen={} resp={}",
|
||||
path, request.get("workOrderNo"), request.get("lockId"), request.get("quoteId"),
|
||||
body.get("code"), status,
|
||||
System.currentTimeMillis() - started, attempt, response.bodyLen);
|
||||
System.currentTimeMillis() - started, attempt, response.bodyLen, response.body);
|
||||
return body;
|
||||
} catch (SocketTimeoutException ex) {
|
||||
log.warn("tms.cabin timeout path={} attempt={}", path, attempt);
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ public final class TmsLandSegmentMapper {
|
||||
put(seg, "destination", text(source.get("目的港")));
|
||||
put(seg, "cargoName", text(source.get("品名")));
|
||||
if (LCL.contains(type)) {
|
||||
put(seg, "volumeCbm", TmsV2RequestMapper.asPositiveDecimal(source.get("体积")));
|
||||
put(seg, "volumeCbm", TmsV2RequestMapper.asVolumeCbm(source.get("体积")));
|
||||
put(seg, "weightKg", TmsV2RequestMapper.asPositiveDecimal(source.get("毛重")));
|
||||
} else if ("中港整车".equals(type)) {
|
||||
// 中港整车:询价不要车型/数量,出站也不带
|
||||
|
||||
+43
-2
@@ -24,6 +24,13 @@ public final class TmsV2RequestMapper {
|
||||
/** 从「100KGS」「2.5CBM」「10件」里取出第一个数字。 */
|
||||
private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+(?:\\.\\d+)?)");
|
||||
|
||||
/**
|
||||
* 销售常写尺寸当体积:1.1*1.2*3 / 1.1x1.2x3 / 1.1×1.2×3(米)。
|
||||
* 必须先相乘得 CBM;禁止剥掉 * 后变成 1.11.23 再 new BigDecimal(建单会炸)。
|
||||
*/
|
||||
private static final Pattern DIM_PRODUCT = Pattern.compile(
|
||||
"(\\d+(?:\\.\\d+)?)\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?)(?:\\s*[*xX×]\\s*(\\d+(?:\\.\\d+)?))?");
|
||||
|
||||
/** 文档 2.1:后缀 Date 的字段格式 yyyy-MM-dd;也容忍斜杠或后面跟时间。 */
|
||||
private static final Pattern ISO_DATE = Pattern.compile("(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})");
|
||||
|
||||
@@ -88,7 +95,7 @@ public final class TmsV2RequestMapper {
|
||||
// 5.2:quantity=int,weightKg/volumeCbm=decimal>0,禁止带单位字符串
|
||||
put(target, "quantity", asInteger(first(source, "quantity", "pieces")));
|
||||
put(target, "weightKg", asPositiveDecimal(first(source, "weightKg")));
|
||||
put(target, "volumeCbm", asPositiveDecimal(first(source, "volumeCbm")));
|
||||
put(target, "volumeCbm", asVolumeCbm(first(source, "volumeCbm")));
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
|
||||
applyAirOptionalFields(source, target);
|
||||
@@ -199,7 +206,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 毛重/体积:JSON 小数且 >0。销售原文「100KGS」「2CBM」只取数字。
|
||||
* 毛重等:JSON 小数且 >0。销售原文「100KGS」「2CBM」只取第一个数字。
|
||||
* 体积请用 {@link #asVolumeCbm},避免把尺寸式写成错误小数。
|
||||
*/
|
||||
public static BigDecimal asPositiveDecimal(Object raw) {
|
||||
if (raw instanceof Number) {
|
||||
@@ -213,6 +221,39 @@ public final class TmsV2RequestMapper {
|
||||
return number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 体积 CBM:尺寸式(长*宽*高)先相乘;否则与 {@link #asPositiveDecimal} 相同。
|
||||
* 例:1.1*1.2*3 → 3.96;2CBM → 2。
|
||||
*/
|
||||
public static BigDecimal asVolumeCbm(Object raw) {
|
||||
if (raw instanceof Number) {
|
||||
return asPositiveDecimal(raw);
|
||||
}
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String text = String.valueOf(raw).trim();
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Matcher dims = DIM_PRODUCT.matcher(text);
|
||||
if (dims.find()) {
|
||||
try {
|
||||
BigDecimal a = new BigDecimal(dims.group(1));
|
||||
BigDecimal b = new BigDecimal(dims.group(2));
|
||||
BigDecimal c = dims.group(3) == null ? BigDecimal.ONE : new BigDecimal(dims.group(3));
|
||||
BigDecimal product = a.multiply(b).multiply(c);
|
||||
if (product.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return null;
|
||||
}
|
||||
return product.stripTrailingZeros();
|
||||
} catch (NumberFormatException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return asPositiveDecimal(raw);
|
||||
}
|
||||
|
||||
private static BigDecimal firstDecimal(Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
|
||||
+2
-1
@@ -19,7 +19,8 @@ UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_common' WHERE `id`='rt
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_cross' WHERE `id`='rt100000000000000000000000000008' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_special' WHERE `id`='rt100000000000000000000000000009' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`='land_hk_express' WHERE `id`='rt100000000000000000000000000010' AND (`timeout_code` IS NULL OR `timeout_code`='');
|
||||
-- 陆运产品仅 4 项;ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
-- 陆运产品 5 项;常用线路见 migration_inq_response_timeout_land_common_v1.sql
|
||||
-- ltl/city/sea 重复行已由 migration_inq_response_timeout_land_class_v2.sql 删除
|
||||
|
||||
-- 3) 唯一索引(列已全部非空后再建;若仍有 NULL 会失败)
|
||||
UPDATE `inq_response_timeout` SET `timeout_code`=CONCAT('rt_', `id`) WHERE `timeout_code` IS NULL OR `timeout_code`='';
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
-- 陆运产品运输分类增加「普件(常用线路/常规车型)」,超时小时 0=不监测。
|
||||
-- 排在原 land_hk_common 之前;不改销售行,不改原 4 项文案和小时数。
|
||||
-- 仅改 inquiry_robot;幂等可重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `inq_response_timeout` (
|
||||
`id`, `timeout_code`, `transport_mode`, `role_code`, `route_category`,
|
||||
`transport_type`, `timeout_hours`, `sort_no`, `create_by`, `create_time`, `update_by`, `update_time`
|
||||
)
|
||||
SELECT
|
||||
'rt100000000000000000000000000020',
|
||||
'land_common',
|
||||
'LAND',
|
||||
'product',
|
||||
'',
|
||||
CONVERT(UNHEX('e699aee4bbb628e5b8b8e794a8e7babfe8b7af2fe5b8b8e8a784e8bda6e59e8b29') USING utf8mb4),
|
||||
0,
|
||||
IFNULL((
|
||||
SELECT `sort_no` FROM (
|
||||
SELECT `sort_no` FROM `inq_response_timeout` WHERE `timeout_code`='land_hk_common' LIMIT 1
|
||||
) AS `hk`
|
||||
), 7) - 1,
|
||||
'system',
|
||||
NOW(),
|
||||
'system',
|
||||
NOW()
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT `id` FROM `inq_response_timeout` WHERE `timeout_code`='land_common'
|
||||
) AS `existed`
|
||||
);
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" firstButton="1" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="Radio" checked="Checked" val="0"/>
|
||||
+2
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice Requires="a14" xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><xdr:twoCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:colOff>43180</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>19685</xdr:rowOff></xdr:from><xdr:to><xdr:col>4</xdr:col><xdr:colOff>340995</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>431165</xdr:rowOff></xdr:to><xdr:grpSp><xdr:nvGrpSpPr><xdr:cNvPr id="2" name="组合 1"/><xdr:cNvGrpSpPr/></xdr:nvGrpSpPr><xdr:grpSpPr><a:xfrm><a:off x="3018155" y="503555"/><a:ext cx="2124710" cy="342265"/><a:chOff x="4431" y="1131"/><a:chExt cx="3169" cy="649"/></a:xfrm></xdr:grpSpPr><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2049" name="Option Button 1" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2049"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4434" y="1131"/><a:ext cx="3166" cy="360"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> Port to Port, 1工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent><mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:Choice xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" Requires="a14"><xdr:sp><xdr:nvSpPr><xdr:cNvPr id="2050" name="Option Button 2" hidden="1"><a:extLst><a:ext uri="{63B3BB69-23CF-44E3-9099-C40C66FF867C}"><a14:compatExt spid="_x0000_s2050"/></a:ext></a:extLst></xdr:cNvPr><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr><a:xfrm><a:off x="4431" y="1421"/><a:ext cx="2987" cy="359"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr><xdr:txBody><a:bodyPr vertOverflow="clip" wrap="square" lIns="27432" tIns="18288" rIns="0" bIns="18288" anchor="ctr" upright="1"/><a:lstStyle/><a:p><a:pPr algn="l" rtl="0"><a:defRPr sz="1000"/></a:pPr><a:r><a:rPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:rPr><a:t> 其它, 3工作天回复</a:t></a:r><a:endParaRPr lang="zh-CN" altLang="en-US" sz="900" b="0" i="0" u="none" strike="noStrike" baseline="0"><a:solidFill><a:srgbClr val="000000"/></a:solidFill><a:latin typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/><a:ea typeface="宋体" panose="02010600030101010101" pitchFamily="7" charset="-122"/></a:endParaRPr></a:p></xdr:txBody></xdr:sp></mc:Choice><mc:Fallback/></mc:AlternateContent></xdr:grpSp><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>
|
||||
+4
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
|
||||
>
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
|
||||
<o:lock v:ext="edit" shapetype="t"/>
|
||||
</v:shapetype>
|
||||
|
||||
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.9pt;margin-top:39.65pt;height:14.65pt;width:107.95pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,26</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:206.8pt;margin-top:51.45pt;height:14.65pt;width:101.8pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,34,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
|
||||
<o:lock shapetype="t" v:ext="edit"/>
|
||||
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xml xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap data="2" v:ext="edit"/></o:shapelayout><v:shapetype coordsize="21600,21600" id="_x0000_t201" o:spt="201" path="m,l,21600r21600,l21600,xe">
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path fillok="f" o:connecttype="rect" o:extrusionok="f" shadowok="f" strokeok="f"/>
|
||||
<o:lock shapetype="t" v:ext="edit"/>
|
||||
</v:shapetype><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2049" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape coordsize="21600,21600" filled="f" id="_x0000_s2050" o:insetmode="auto" o:spt="201" stroked="f" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" type="#_x0000_t201"><v:path fillok="t" shadowok="t" strokeok="t"/><v:fill focussize="0,0" on="f"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock aspectratio="f" rotation="t" v:ext="edit"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font color="#000000" face="宋体" size="180"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Checked>1</x:Checked><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<xml xmlns:oa="urn:schemas-microsoft-com:office:activation" xmlns:p="urn:schemas-microsoft-com:office:powerpoint" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="2"/></o:shapelayout><v:shapetype id="_x0000_t201" coordsize="21600,21600" o:spt="201" path="m,l,21600r21600,l21600,xe"
|
||||
>
|
||||
<v:stroke joinstyle="miter"/>
|
||||
<v:path shadowok="f" o:extrusionok="f" strokeok="f" fillok="f" o:connecttype="rect"/>
|
||||
<o:lock v:ext="edit" shapetype="t"/>
|
||||
</v:shapetype>
|
||||
|
||||
<v:shape id="_x0000_s2049" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.8pt;margin-top:39.65pt;height:14.9pt;width:167.15pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> Port to Port, 1工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,2,4,44,2,27</x:Anchor><x:FirstButton>1</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape><v:shape id="_x0000_s2050" o:spt="201" type="#_x0000_t201" style="position:absolute;left:0pt;top:0pt;margin-left:237.65pt;margin-top:51.65pt;height:14.95pt;width:157.65pt;" filled="f" stroked="f" o:insetmode="auto" coordsize="21600,21600"><v:path fillok="t" strokeok="t" shadowok="t"/><v:fill on="f" focussize="0,0"/><v:stroke on="f"/><v:imagedata o:title=""/><o:lock v:ext="edit" rotation="t" aspectratio="f"/><v:textbox style="direction:ltr;layout-flow:horizontal;mso-rotate:0;"><div style="text-align:left"><p><font face="宋体" size="180" color="#000000"><span style="mso-spacerun:yes"> 其它, 3工作天回复</span></font></p></div></v:textbox><x:ClientData ObjectType="Radio"><x:SizeWithCells/><x:Anchor>1,5,2,22,4,28,3,0</x:Anchor><x:Checked>1</x:Checked><x:FirstButton>0</x:FirstButton><x:TextVAlign>Center</x:TextVAlign></x:ClientData></v:shape></xml>
|
||||
+536
@@ -17,7 +17,9 @@ import java.util.Map;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
@@ -236,6 +238,337 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。
|
||||
* 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。
|
||||
*/
|
||||
@Test
|
||||
public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception {
|
||||
Path src = Files.createTempFile("quote-air-notms-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-air-notms-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("Sheet2");
|
||||
// 货物区:与现网空运映射一致(B6 同格冒号后)
|
||||
org.apache.poi.ss.usermodel.Row r5 = sheet.createRow(5);
|
||||
r5.createCell(1).setCellValue("起运港:");
|
||||
r5.createCell(6).setCellValue("目的港:");
|
||||
// 费用表明细(headerRow=16 → 0-based 15;anchor A17 → 0-based 16)
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("费用名称");
|
||||
head.createCell(2).setCellValue("单位");
|
||||
head.createCell(3).setCellValue("币别");
|
||||
head.createCell(4).setCellValue("单价");
|
||||
head.createCell(5).setCellValue("数量");
|
||||
head.createCell(6).setCellValue("金额");
|
||||
head.createCell(7).setCellValue("备注");
|
||||
org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16);
|
||||
airSeg.createCell(0).setCellValue("空运段");
|
||||
airSeg.createCell(1).setCellValue("");
|
||||
org.apache.poi.ss.usermodel.Row polSeg = sheet.createRow(17);
|
||||
polSeg.createCell(0).setCellValue("起运港");
|
||||
polSeg.createCell(1).setCellValue("");
|
||||
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(18);
|
||||
otherSeg.createCell(0).setCellValue("其他");
|
||||
otherSeg.createCell(1).setCellValue("");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = JSONObject.parseObject(
|
||||
java.nio.file.Files.readString(
|
||||
java.nio.file.Paths.get("d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/mapping_air_ddp.json")));
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("pol", "广州");
|
||||
facts.put("pod", "香港");
|
||||
facts.put("quote_no", "WO202609230026");
|
||||
// 无 TMS:不带 fee_rows
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
String b16 = sheet.getRow(16).getCell(1) == null ? ""
|
||||
: String.valueOf(sheet.getRow(16).getCell(1));
|
||||
String b17 = sheet.getRow(17).getCell(1) == null ? ""
|
||||
: String.valueOf(sheet.getRow(17).getCell(1));
|
||||
String b18 = sheet.getRow(18).getCell(1) == null ? ""
|
||||
: String.valueOf(sheet.getRow(18).getCell(1));
|
||||
// 用 DataFormatter 读字符串更稳
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
b16 = fmt.formatCellValue(sheet.getRow(16).getCell(1));
|
||||
b17 = fmt.formatCellValue(sheet.getRow(17).getCell(1));
|
||||
b18 = fmt.formatCellValue(sheet.getRow(18).getCell(1));
|
||||
String cargoPol = fmt.formatCellValue(sheet.getRow(5).getCell(1));
|
||||
assertTrue(cargoPol.contains("广州"), "货物区起运港应写入广州,实际=" + cargoPol);
|
||||
assertFalse(b16.contains("广州"), "空运段旁费用名称不应是起运港,实际=" + b16);
|
||||
assertFalse(b17.contains("广州"), "起运港项目旁费用名称不应是起运港,实际=" + b17);
|
||||
assertFalse(b18.contains("广州"), "其他旁费用名称不应是起运港,实际=" + b18);
|
||||
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
|
||||
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归:海运双栏同行时,「整柜或拼柜」不得跨过右侧「送货地址」标签写到 H 列;
|
||||
* 运输分类单选(drawing 文字)不得被 POI 填表抹掉。
|
||||
* 对应 WO202609240027:送货地址=整柜、表外残留、运输分类文字丢失。
|
||||
*/
|
||||
@Test
|
||||
public void seaTwoColumnMustNotSpillFclIntoDeliveryAddress() throws Exception {
|
||||
Path seed = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
|
||||
Path out = Files.createTempFile("quote-sea-spill-out", ".xlsx");
|
||||
try {
|
||||
// 读模板 zip 内 drawing 文字作基线
|
||||
java.util.List<String> before = readDrawingTexts(seed);
|
||||
org.junit.jupiter.api.Assertions.assertFalse(before.isEmpty(), "模板应有运输分类 drawing 文字");
|
||||
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("quote_no", "WO202609240027");
|
||||
facts.put("fcl_or_lcl", "整柜");
|
||||
facts.put("pol", "广州港");
|
||||
facts.put("pod", "巴生港");
|
||||
facts.put("commodity", "机器设备");
|
||||
facts.put("quantity", "15件");
|
||||
facts.put("container_type_qty", "40HQ*1");
|
||||
facts.put("trade_terms", "门到门");
|
||||
facts.put("运输分类", "其他");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
|
||||
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
String b7 = fmt.formatCellValue(sheet.getRow(6).getCell(1));
|
||||
String h7 = fmt.formatCellValue(sheet.getRow(6).getCell(7));
|
||||
String e7 = fmt.formatCellValue(sheet.getRow(6).getCell(4));
|
||||
assertTrue(e7.contains("送货地址"), "送货地址标签应保留,实际=" + e7);
|
||||
assertEquals("整柜", b7, "整柜应写在左侧值区 B7");
|
||||
assertFalse(h7.contains("整柜"), "送货地址值区不得写入整柜,实际=" + h7);
|
||||
}
|
||||
// 「其它」:ClientData 保持 WO011 首钮勾选(才能画);文案对调后勾选看起来是「其它」
|
||||
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
|
||||
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
|
||||
assertTrue(p1.contains("checked=\"Checked\"") && p1.contains("firstButton"),
|
||||
"其它也须保持 WO011 首钮勾选结构,实际=" + p1);
|
||||
assertFalse(p2.contains("checked=\"Checked\""), "次钮不应 Checked,实际=" + p2);
|
||||
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
|
||||
java.util.List<String> bodies = new java.util.ArrayList<>();
|
||||
java.util.regex.Matcher vm = java.util.regex.Pattern.compile(
|
||||
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
|
||||
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
|
||||
while (vm.find()) {
|
||||
bodies.add(vm.group(1));
|
||||
}
|
||||
assertEquals(2, bodies.size());
|
||||
assertTrue(bodies.get(0).contains("<x:Checked>"), "ClientData 首钮须 Checked");
|
||||
assertFalse(bodies.get(1).contains("<x:Checked>"), "ClientData 次钮不得 Checked");
|
||||
// 文案已对调:第一颗显示「其它」
|
||||
assertTrue(vml.contains("其它") && vml.indexOf("其它") < vml.indexOf("Port"),
|
||||
"其它文案应出现在第一颗单选位置");
|
||||
try (Workbook filled2 = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled2.getSheetAt(0);
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
String b3 = fmt.formatCellValue(sheet.getRow(2).getCell(1));
|
||||
assertFalse(b3.contains("●") || b3.contains("○") || b3.contains("工作天"),
|
||||
"B3 不得写明文,实际=" + b3);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归 WO007:运输分类 Port to Port 时,ctrlProp 与 VML 都必须勾 Port to Port(WPS 读 VML)。
|
||||
*/
|
||||
@Test
|
||||
public void seaPortToPortMustCheckVmlAndCtrlProp() throws Exception {
|
||||
Path seed = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
|
||||
Path out = Files.createTempFile("quote-sea-p2p-out", ".xlsx");
|
||||
try {
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("quote_no", "WO202609280007");
|
||||
facts.put("运输分类", "Port to Port");
|
||||
facts.put("trade_terms", "Port to Port");
|
||||
facts.put("贸易条款", "Port to Port");
|
||||
facts.put("pol", "广州港");
|
||||
facts.put("pod", "巴生港");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
|
||||
String p1 = readZipEntryText(out, "xl/ctrlProps/ctrlProp1.xml");
|
||||
String p2 = readZipEntryText(out, "xl/ctrlProps/ctrlProp2.xml");
|
||||
assertTrue(p1.contains("checked=\"Checked\""), "Port to Port 应勾选 ctrlProp1,实际=" + p1);
|
||||
assertFalse(p2.contains("checked=\"Checked\""), "其它不应勾选,实际=" + p2);
|
||||
// WO011 标准:强制瘦 drawing≈3966 + 原 Option Button;B3 无合成 ●/○
|
||||
int outDraw = readZipEntryBytes(out, "xl/drawings/drawing1.xml").length;
|
||||
assertTrue(outDraw > 3000 && outDraw < 5000,
|
||||
"应使用 WO011 标准瘦 drawing,out=" + outDraw);
|
||||
String outDrawXml = readZipEntryText(out, "xl/drawings/drawing1.xml");
|
||||
assertTrue(outDrawXml.contains("Option Button"),
|
||||
"drawing 应保留原 Option Button(与 WO011 一致)");
|
||||
int stylesLen = readZipEntryBytes(out, "xl/styles.xml").length;
|
||||
assertTrue(stylesLen > 50000,
|
||||
"应盖回 WO011 styles,避免 POI 改瘦后 WPS 空白,styles=" + stylesLen);
|
||||
String vml = readZipEntryText(out, "xl/drawings/vmlDrawing1.vml");
|
||||
// VML 文案用 NBSP(\u00A0)分隔,不能直接 contains "Port to Port"
|
||||
assertTrue(vml.contains("Port") && vml.contains("其它"),
|
||||
"VML 应保留原 Form Control 文案");
|
||||
assertTrue(vml.contains("xmlns:o=") && !vml.contains("xmlns:oa="),
|
||||
"VML 应为 WO011 标准 xmlns:o");
|
||||
assertTrue(vml.contains("ObjectType=\"Radio\""), "VML 应有 Radio ClientData");
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile(
|
||||
"<x:ClientData\\s+ObjectType=\"Radio\">([\\s\\S]*?)</x:ClientData>",
|
||||
java.util.regex.Pattern.CASE_INSENSITIVE).matcher(vml);
|
||||
boolean sawFirst = false;
|
||||
boolean sawOther = false;
|
||||
while (m.find()) {
|
||||
String body = m.group(1);
|
||||
boolean first = body.contains("<x:FirstButton>1</x:FirstButton>");
|
||||
boolean checked = body.contains("<x:Checked>");
|
||||
if (first) {
|
||||
sawFirst = true;
|
||||
assertTrue(checked, "Port to Port 的 VML 应 Checked,body=" + body);
|
||||
} else {
|
||||
sawOther = true;
|
||||
assertFalse(checked, "其它的 VML 不应 Checked,body=" + body);
|
||||
}
|
||||
}
|
||||
assertTrue(sawFirst && sawOther, "VML 应有两组 Radio ClientData");
|
||||
// WO011 标准:B3 无合成 ●/○;原控件勾选即可
|
||||
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
|
||||
try {
|
||||
org.apache.poi.ss.usermodel.Sheet sh = null;
|
||||
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
|
||||
if (wb.getSheetName(i).contains("海运")) {
|
||||
sh = wb.getSheetAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(sh, "缺海运 sheet");
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
String b3 = "";
|
||||
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
|
||||
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
|
||||
}
|
||||
assertFalse(b3.contains("●") || b3.contains("○"),
|
||||
"WO011 标准:B3 不得写合成 ●/○,实际=" + b3);
|
||||
} finally {
|
||||
wb.close();
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void seaOtherKeepsWo011VmlAndEmptyB3() throws Exception {
|
||||
Path seed = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
|
||||
Path wo011 = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/wo011.xlsx");
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(wo011), "缺 WO011");
|
||||
Path out = Files.createTempFile("quote-sea-other-out", ".xlsx");
|
||||
try {
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("quote_no", "WO202609280033");
|
||||
facts.put("运输分类", "其它");
|
||||
facts.put("pol", "纽约港");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
|
||||
byte[] vmlOut = readZipEntryBytes(out, "xl/drawings/vmlDrawing1.vml");
|
||||
byte[] vml011 = readZipEntryBytes(wo011, "xl/drawings/vmlDrawing1.vml");
|
||||
assertArrayEquals(vml011, vmlOut, "其它场景 VML 必须与 WO011 字节级一致(否则 WPS 空白)");
|
||||
org.apache.poi.ss.usermodel.Workbook wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(out.toFile());
|
||||
try {
|
||||
org.apache.poi.ss.usermodel.Sheet sh = null;
|
||||
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
|
||||
if (wb.getSheetName(i).contains("海运")) {
|
||||
sh = wb.getSheetAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(sh);
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
String b3 = "";
|
||||
if (sh.getRow(2) != null && sh.getRow(2).getCell(1) != null) {
|
||||
b3 = fmt.formatCellValue(sh.getRow(2).getCell(1));
|
||||
}
|
||||
// WO033 证据:B3 明文会让 WPS 不画原控件;必须空着,与 WO011/WO030 一致
|
||||
assertTrue(b3 == null || b3.trim().isEmpty(),
|
||||
"其它场景 B3 必须空白(控件锚点区),实际=" + b3);
|
||||
assertFalse(b3.contains("●") || b3.contains("○"), "不得写合成 ●/○");
|
||||
} finally {
|
||||
wb.close();
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readZipEntryBytes(Path xlsx, String entry) throws Exception {
|
||||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
|
||||
java.util.zip.ZipEntry e = zf.getEntry(entry);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream in = zf.getInputStream(e)) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readZipEntryText(Path xlsx, String entry) throws Exception {
|
||||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
|
||||
java.util.zip.ZipEntry e = zf.getEntry(entry);
|
||||
org.junit.jupiter.api.Assertions.assertNotNull(e, "缺 zip 条目 " + entry);
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream in = zf.getInputStream(e)) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
return bos.toString("UTF-8");
|
||||
}
|
||||
}
|
||||
|
||||
private static java.util.List<String> readDrawingTexts(Path xlsx) throws Exception {
|
||||
java.util.List<String> out = new java.util.ArrayList<>();
|
||||
try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(xlsx.toFile())) {
|
||||
java.util.zip.ZipEntry e = zf.getEntry("xl/drawings/drawing1.xml");
|
||||
if (e != null) {
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
try (java.io.InputStream in = zf.getInputStream(e)) {
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = in.read(buf)) >= 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
String xml = bos.toString("UTF-8");
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("<a:t>([^<]+)</a:t>").matcher(xml);
|
||||
while (m.find()) {
|
||||
out.add(m.group(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数,
|
||||
* 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。
|
||||
@@ -351,6 +684,166 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TMS 已返回分段汇总时:海运「始发港费用汇总」格若是 SUM,必须去公式改写 fee_total_*。
|
||||
*/
|
||||
@Test
|
||||
public void seaSectionSubtotalStripsSumWhenTmsTotalPresent() throws Exception {
|
||||
Path src = Files.createTempFile("quote-sea-sub-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-sea-sub-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("询价单");
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(10);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("币别");
|
||||
head.createCell(7).setCellValue("金额");
|
||||
sheet.createRow(11).createCell(0).setCellValue("THC");
|
||||
sheet.getRow(11).createCell(7).setCellValue(100.0);
|
||||
sheet.createRow(12).createCell(0).setCellValue("文件费");
|
||||
sheet.getRow(12).createCell(7).setCellValue(50.0);
|
||||
org.apache.poi.ss.usermodel.Row sub = sheet.createRow(13);
|
||||
sub.createCell(0).setCellValue("始发港费用汇总");
|
||||
sub.createCell(7).setCellFormula("SUM(H12:H13)");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("fee_total_polCostItems", "CNY 888.00");
|
||||
facts.put("total_amount", "9999.00");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Cell amt = got.getSheetAt(0).getRow(13).getCell(7);
|
||||
assertEquals(CellType.NUMERIC, amt.getCellType());
|
||||
assertEquals(888.00, amt.getNumericCellValue(), 0.01);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO040:departureCharges 只有增点费/拆板费时,模板 SUM=200;
|
||||
* 预估费用必须盖成 TMS salePrice(7918.25),且 G 列不能再留 SUM 公式。
|
||||
*/
|
||||
@Test
|
||||
public void estimateTotalUsesSalePriceWhenFeeLinesAreSurchargesOnly() throws Exception {
|
||||
Path src = Files.createTempFile("quote-est-air-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-est-air-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("Sheet2");
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("费用名称");
|
||||
head.createCell(2).setCellValue("单位");
|
||||
head.createCell(3).setCellValue("币别");
|
||||
head.createCell(4).setCellValue("单价");
|
||||
head.createCell(5).setCellValue("数量");
|
||||
head.createCell(6).setCellValue("金额");
|
||||
org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16);
|
||||
r1.createCell(0).setCellValue("空运段");
|
||||
r1.createCell(1).setCellValue("增点费");
|
||||
r1.createCell(6).setCellValue(100.0);
|
||||
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
|
||||
r2.createCell(0).setCellValue("起运港");
|
||||
r2.createCell(1).setCellValue("拆板费");
|
||||
r2.createCell(6).setCellValue(100.0);
|
||||
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
|
||||
total.createCell(0).setCellValue("预估费用");
|
||||
total.createCell(6).setCellFormula("SUM(G17:G22)");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("total_amount", "7918.25");
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"["
|
||||
+ "{\"name\":\"增点费\",\"amount\":\"100.0\",\"currency\":\"CNY\",\"unit\":\"点\",\"quantity\":\"1\",\"unit_price\":\"100.0\"},"
|
||||
+ "{\"name\":\"拆板费\",\"amount\":\"100.0\",\"currency\":\"USD\",\"unit\":\"板数\",\"quantity\":\"1\",\"unit_price\":\"100.0\"}"
|
||||
+ "]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Cell sum = got.getSheetAt(0).getRow(22).getCell(6);
|
||||
assertEquals(CellType.NUMERIC, sum.getCellType());
|
||||
assertEquals(7918.25, sum.getNumericCellValue(), 0.01);
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 total 空、只有 selected_air_option.salePrice 时,填表 total_amount 仍要落到销售价。
|
||||
*/
|
||||
@Test
|
||||
public void buildFillFactsAirTotalFromSelectedOptionSalePrice() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输方式", "空运");
|
||||
facts.put("品名", "普货");
|
||||
Map<String, Object> charge = new HashMap<>();
|
||||
charge.put("chargeName", "增点费");
|
||||
charge.put("unitPrice", 100.0);
|
||||
charge.put("currency", "CNY");
|
||||
charge.put("unit", "点");
|
||||
Map<String, Object> option = new HashMap<>();
|
||||
option.put("optionNo", "AIR-OPT01");
|
||||
option.put("salePrice", 7918.25);
|
||||
option.put("currency", "CNY");
|
||||
option.put("departureCharges", java.util.Collections.singletonList(charge));
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("selected_air_option", option);
|
||||
quote.put("airOptions", java.util.Collections.singletonList(option));
|
||||
quote.put("chargeableWeightKg", "833.5");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609290040", facts, quote, "WuJiLin", "AIR");
|
||||
assertEquals("7918.25", out.getString("total_amount"));
|
||||
assertEquals("CNY", out.getString("currency"));
|
||||
assertTrue(out.getJSONArray("fee_rows").size() >= 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* WO026/WO040:群薄报价把增点费+拆板费之和写成 total=200,
|
||||
* 但 airOptions.salePrice=7918.25 时,预估费用仍必须用销售价。
|
||||
*/
|
||||
@Test
|
||||
public void buildFillFactsAirPrefersSalePriceWhenTotalIsFeeSum() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("运输方式", "空运");
|
||||
facts.put("品名", "普货");
|
||||
Map<String, Object> option = new HashMap<>();
|
||||
option.put("optionNo", "AIR-OPT01");
|
||||
option.put("salePrice", 7918.25);
|
||||
option.put("currency", "CNY");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "200");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("airOptions", java.util.Collections.singletonList(option));
|
||||
quote.put("selected_air_option", option);
|
||||
quote.put("fee_lines", java.util.Arrays.asList(
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "增点费");
|
||||
put("amount", "100");
|
||||
put("currency", "CNY");
|
||||
}},
|
||||
new HashMap<String, Object>() {{
|
||||
put("name", "拆板费");
|
||||
put("amount", "100");
|
||||
put("currency", "USD");
|
||||
}}
|
||||
));
|
||||
quote.put("chargeableWeightKg", "833.5");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609290026", facts, quote, "WuJiLin", "AIR");
|
||||
assertEquals("7918.25", out.getString("total_amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void feeQuantityDefaultsToOneWhenBlank() {
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
|
||||
@@ -915,6 +1408,49 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回归 WO202609220088:三段汇总写币别+金额;费用总计写在金额列并带币别,不得落在 B 列。
|
||||
*/
|
||||
@Test
|
||||
public void seaSubtotalsAndTotalGoToFeeColumns() throws Exception {
|
||||
Path seed = java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_verify_quote/sea_template_seed.xlsx");
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(Files.exists(seed), "缺海运 seed 模板");
|
||||
Path out = Files.createTempFile("quote-sea-subtotal-out", ".xlsx");
|
||||
try {
|
||||
JSONObject mapping = com.alibaba.fastjson.JSON.parseObject(
|
||||
java.nio.file.Files.readString(
|
||||
java.nio.file.Paths.get(
|
||||
"d:/own/友通达询价智能体(新)/inquiry-agent/scripts/_last_sea_mapping.json")));
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("currency", "CNY");
|
||||
facts.put("total_amount", "14298.53");
|
||||
facts.put("fee_total_polCostItems", "2335.00");
|
||||
facts.put("fee_total_oceanCostItems", "8778.75");
|
||||
facts.put("fee_total_podCostItems", "3184.78");
|
||||
QuoteTemplateExcelSupport.fillWorkbook(seed, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
org.apache.poi.ss.usermodel.DataFormatter fmt = new org.apache.poi.ss.usermodel.DataFormatter();
|
||||
// 汇总行:B=币别 H=金额(0-based: 1 / 7)
|
||||
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(27).getCell(1)));
|
||||
assertEquals("2335", fmt.formatCellValue(sheet.getRow(27).getCell(7)).replace(".0", "").replace(".00", ""));
|
||||
assertTrue(fmt.formatCellValue(sheet.getRow(27).getCell(7)).contains("2335"));
|
||||
assertTrue(fmt.formatCellValue(sheet.getRow(32).getCell(7)).contains("8778"));
|
||||
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(32).getCell(1)));
|
||||
assertTrue(fmt.formatCellValue(sheet.getRow(42).getCell(7)).contains("3184"));
|
||||
assertEquals("CNY", fmt.formatCellValue(sheet.getRow(42).getCell(1)));
|
||||
// 费用总计:金额在 H,币别在 B;B 不得只是裸金额
|
||||
String b44 = fmt.formatCellValue(sheet.getRow(43).getCell(1));
|
||||
String h44 = fmt.formatCellValue(sheet.getRow(43).getCell(7));
|
||||
assertEquals("CNY", b44);
|
||||
assertTrue(h44.contains("14298"), "费用总计金额应在 H 列,实际 B=" + b44 + " H=" + h44);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.apache.poi.ss.usermodel.Row row(Sheet sheet, int r) {
|
||||
org.apache.poi.ss.usermodel.Row exist = sheet.getRow(r);
|
||||
return exist == null ? sheet.createRow(r) : exist;
|
||||
|
||||
+146
-12
@@ -48,28 +48,41 @@ public class InquiryTmsCabinServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveOptionWithoutTmsUsesAirOpt01() {
|
||||
public void resolveOptionWithoutTmsStaysBlank() {
|
||||
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "");
|
||||
assertEquals("AIR-OPT-01", option.get("optionNo"));
|
||||
assertNull(option.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdWithoutTmsUsesWorkOrderPlaceholder() {
|
||||
assertEquals("QT-WO202609210003",
|
||||
InquiryTmsCabinService.lockQuoteId("", "WO202609210003"));
|
||||
public void manualQuoteKeepsCallerOptionText() {
|
||||
Map<String, Object> option = InquiryTmsCabinService.resolveOption(new HashMap<>(), "AIR-OPT01");
|
||||
assertEquals("AIR-OPT01", option.get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockQuoteIdKeepsRealAirId() {
|
||||
assertEquals("AIR-2094721692181016578-2094721692654972929",
|
||||
InquiryTmsCabinService.lockQuoteId(
|
||||
"AIR-2094721692181016578-2094721692654972929",
|
||||
"WO202609160015"));
|
||||
public void airlineFromFlightUsesFirstTwoLetters() {
|
||||
assertEquals("TH", InquiryTmsCabinService.airlineFromFlight("TH739"));
|
||||
assertEquals("CZ", InquiryTmsCabinService.airlineFromFlight("cz327"));
|
||||
assertEquals("", InquiryTmsCabinService.airlineFromFlight("739"));
|
||||
assertEquals("", InquiryTmsCabinService.airlineFromFlight(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockOptionNoWithoutTmsUsesAirOpt01() {
|
||||
assertEquals("AIR-OPT-01", InquiryTmsCabinService.lockOptionNo(""));
|
||||
public void confirmTmsIsNotManual() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("source", "PRODUCT");
|
||||
quote.put("source_label", "航线确认TMS");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(quote));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void salesAdjustAndProductAreManual() {
|
||||
Map<String, Object> adjust = new HashMap<>();
|
||||
adjust.put("source", "SALES_ADJUST");
|
||||
Map<String, Object> edited = new HashMap<>();
|
||||
edited.put("source_label", "航线在TMS上改");
|
||||
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(adjust));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.isManualQuote(edited));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -127,4 +140,125 @@ public class InquiryTmsCabinServiceTest {
|
||||
String why = InquiryTmsCabinService.cabinFailReason(2006, new HashMap<>(), "2026-09-20", "2026-09-21");
|
||||
assertEquals("TMS 业务码=2006", why);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validUntilDateOnlyBecomesIsoWithZone() {
|
||||
assertEquals("2026-09-30T23:59:59+08:00",
|
||||
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30"));
|
||||
assertEquals("2026-09-30T23:59:59+08:00",
|
||||
InquiryTmsCabinService.normalizeValidUntilIso("2026/09/30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validUntilAlreadyIsoKept() {
|
||||
assertEquals("2026-09-30T12:00:00+08:00",
|
||||
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00+08:00"));
|
||||
assertEquals("2026-09-30T12:00:00+08:00",
|
||||
InquiryTmsCabinService.normalizeValidUntilIso("2026-09-30T12:00:00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fillLockQuoteExtrasPutsIsoAndTmsFeeNames() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("valid_until", "2026-09-30");
|
||||
Map<String, Object> option = new HashMap<>();
|
||||
Map<String, Object> charge = new HashMap<>();
|
||||
charge.put("chargeName", "运输费");
|
||||
charge.put("unitPrice", "11.00000");
|
||||
charge.put("unit", "板数");
|
||||
charge.put("currency", "CNY");
|
||||
option.put("departureCharges", java.util.Arrays.asList(charge));
|
||||
Map<String, Object> fee = new HashMap<>();
|
||||
fee.put("feeName", "空运费");
|
||||
fee.put("unitPrice", 18);
|
||||
fee.put("currency", "CNY");
|
||||
quote.put("feeItems", java.util.Arrays.asList(fee));
|
||||
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, option);
|
||||
|
||||
assertEquals("2026-09-30T23:59:59+08:00", req.get("validUntil"));
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> dep =
|
||||
(java.util.List<Map<String, Object>>) req.get("departureCharges");
|
||||
assertEquals("运输费", dep.get(0).get("chargeName"));
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> fees =
|
||||
(java.util.List<Map<String, Object>>) req.get("feeItems");
|
||||
assertEquals("空运费", fees.get(0).get("feeName"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void airlineFromInstructionReadsCodeAfterOption() {
|
||||
assertEquals("TH", InquiryTmsCabinService.airlineFromInstruction(
|
||||
"WO202609290004锁舱,锁AIR-OPT01 TH@询价小助手"));
|
||||
assertEquals("CZ", InquiryTmsCabinService.airlineFromInstruction("AIR-OPT-02 CZ"));
|
||||
assertEquals("", InquiryTmsCabinService.airlineFromInstruction("WO202609290004锁舱"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveOptionKeepsCallerOptionWhenNotInList() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
Map<String, Object> only = new HashMap<>();
|
||||
only.put("optionNo", "AIR-OPT02");
|
||||
quote.put("airOptions", java.util.Arrays.asList(only));
|
||||
assertEquals("AIR-OPT01",
|
||||
InquiryTmsCabinService.resolveOption(quote, "AIR-OPT01").get("optionNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void looksLikeOptionTokenFeesDetectsOptFragment() {
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
Map<String, Object> fee = new HashMap<>();
|
||||
fee.put("name", "OPT");
|
||||
fee.put("amount", "01");
|
||||
quote.put("fee_lines", java.util.Arrays.asList(fee));
|
||||
org.junit.jupiter.api.Assertions.assertTrue(InquiryTmsCabinService.looksLikeOptionTokenFees(quote));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enrichLockQuoteClearsOptFeesAndCopiesValidUntil() {
|
||||
Map<String, Object> manual = new HashMap<>();
|
||||
manual.put("source", "PRODUCT");
|
||||
Map<String, Object> fee = new HashMap<>();
|
||||
fee.put("name", "OPT");
|
||||
fee.put("amount", "01");
|
||||
manual.put("fee_lines", java.util.Arrays.asList(fee));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("valid_until", "2026-09-30");
|
||||
tms.put("chargeableWeightKg", "66.68");
|
||||
Map<String, Object> out = InquiryTmsCabinService.enrichLockQuoteFromTms(manual, tms);
|
||||
assertEquals("2026-09-30", out.get("validUntil"));
|
||||
org.junit.jupiter.api.Assertions.assertNull(out.get("fee_lines"));
|
||||
assertEquals("66.68", out.get("chargeableWeightKg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plainFeeDecimalStripsTrailingZerosWithoutSci() {
|
||||
assertEquals("100", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("100.00000")).toPlainString());
|
||||
assertEquals("10.5", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("10.50")).toPlainString());
|
||||
assertEquals("166.7", InquiryTmsCabinService.plainFeeDecimal(new java.math.BigDecimal("166.7000")).toPlainString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockModeTmsOnlyIsSystemWithQuoteId() {
|
||||
// 口径1:仅 TMS → SYSTEM + quoteId(由 lockCabin 组装;此处用 isManual 判定)
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("source", "TMS");
|
||||
tms.put("source_label", "TMS 标准报价");
|
||||
org.junit.jupiter.api.Assertions.assertFalse(InquiryTmsCabinService.isManualQuote(tms));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockModeManualKeepsQuoteIdWhenFlagTrue() {
|
||||
Map<String, Object> req = new HashMap<>();
|
||||
req.put("quoteId", "AIR-1-2");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("valid_until", "2026-09-30");
|
||||
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), true);
|
||||
assertEquals("MANUAL", req.get("source"));
|
||||
assertEquals("AIR-1-2", req.get("quoteId"));
|
||||
InquiryTmsCabinService.fillManualLock(req, quote, new HashMap<>(), new HashMap<>(), false);
|
||||
org.junit.jupiter.api.Assertions.assertNull(req.get("quoteId"));
|
||||
}
|
||||
}
|
||||
|
||||
+41
-9
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -53,15 +54,27 @@ public class TmsCabinRequestMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockKeepsNoQuotePlaceholders() {
|
||||
Map<String, Object> filled = new LinkedHashMap<>();
|
||||
filled.put("workOrderNo", "WO202609210003");
|
||||
filled.put("quoteId", "QT-WO202609210003");
|
||||
filled.put("optionNo", "AIR-OPT-01");
|
||||
filled.put("flightDate", "2026-09-20");
|
||||
Map<String, Object> sent = TmsCabinRequestMapper.lock(filled);
|
||||
assertEquals("QT-WO202609210003", sent.get("quoteId"));
|
||||
assertEquals("AIR-OPT-01", sent.get("optionNo"));
|
||||
public void weightWithTrailingZerosStaysPlain() {
|
||||
assertEquals("100.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("100.000")).toString());
|
||||
assertEquals("2.0", TmsCabinRequestMapper.plainDecimal(new BigDecimal("2.0000")).toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualLockOmitsEmptyFeesAndKeepsSource() {
|
||||
Map<String, Object> internal = new LinkedHashMap<>();
|
||||
internal.put("workOrderNo", "WO202609220001");
|
||||
internal.put("source", "MANUAL");
|
||||
internal.put("quoteId", "AIR-1");
|
||||
internal.put("totalSalePrice", "14809.6");
|
||||
internal.put("currency", "CNY");
|
||||
internal.put("departureCharges", java.util.Collections.emptyList());
|
||||
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
|
||||
assertEquals("MANUAL", body.get("source"));
|
||||
assertEquals("AIR-1", body.get("quoteId"));
|
||||
assertEquals(new java.math.BigDecimal("14809.6"), body.get("totalSalePrice"));
|
||||
assertEquals(null, body.get("departureCharges"));
|
||||
assertEquals(null, body.get("feeItems"));
|
||||
assertEquals(null, body.get("flightNo"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,4 +89,23 @@ public class TmsCabinRequestMapperTest {
|
||||
assertEquals("2026-09-16", body.get("flightDate"));
|
||||
assertEquals("AIRLINE", body.get("operatorRole"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockNormalizesValidUntilToIso() {
|
||||
Map<String, Object> internal = new LinkedHashMap<>();
|
||||
internal.put("workOrderNo", "WO202609290004");
|
||||
internal.put("source", "SYSTEM");
|
||||
internal.put("validUntil", "2026-09-30");
|
||||
Map<String, Object> charge = new LinkedHashMap<>();
|
||||
charge.put("chargeName", "运输费");
|
||||
charge.put("unitPrice", new BigDecimal("11"));
|
||||
internal.put("departureCharges", java.util.Collections.singletonList(charge));
|
||||
Map<String, Object> body = TmsCabinRequestMapper.lock(internal);
|
||||
assertEquals("2026-09-30T23:59:59+08:00", body.get("validUntil"));
|
||||
assertEquals("SYSTEM", body.get("source"));
|
||||
@SuppressWarnings("unchecked")
|
||||
java.util.List<Map<String, Object>> dep =
|
||||
(java.util.List<Map<String, Object>>) body.get("departureCharges");
|
||||
assertEquals("运输费", dep.get(0).get("chargeName"));
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -222,4 +222,31 @@ public class TmsV2RequestMapperTest {
|
||||
assertEquals("CRK", out.get("destinationCode"));
|
||||
assertEquals("托盘", out.get("packageType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void volumeDimensionProductBecomesCbm() {
|
||||
assertEquals(new java.math.BigDecimal("3.96"),
|
||||
TmsV2RequestMapper.asVolumeCbm("1.1*1.2*3"));
|
||||
assertEquals(new java.math.BigDecimal("3.96"),
|
||||
TmsV2RequestMapper.asVolumeCbm("1.1x1.2x3"));
|
||||
assertEquals(new java.math.BigDecimal("3.96"),
|
||||
TmsV2RequestMapper.asVolumeCbm("1.1×1.2×3"));
|
||||
assertEquals(new java.math.BigDecimal("2"),
|
||||
TmsV2RequestMapper.asVolumeCbm("2CBM"));
|
||||
Map<String, Object> seg = new HashMap<>();
|
||||
seg.put("originCode", "SZX");
|
||||
seg.put("destinationCode", "KUL");
|
||||
seg.put("volumeCbm", "1.1*1.2*3");
|
||||
seg.put("weightKg", "5");
|
||||
seg.put("quantity", "3");
|
||||
seg.put("packageType", "托盘");
|
||||
Map<String, Object> internal = new HashMap<>();
|
||||
internal.put("transportMode", "AIR");
|
||||
internal.put("quoteDate", "2026-10-08");
|
||||
internal.put("segments", Collections.singletonList(seg));
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("3.96"), segs.get(0).get("volumeCbm"));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -30,6 +30,8 @@
|
||||
<jeecgboot.version>3.5.3</jeecgboot.version>
|
||||
<java.version>1.8</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- 默认跳过单测;本地排查时加 -DskipTests=false -->
|
||||
<skipTests>true</skipTests>
|
||||
|
||||
<!-- 微服务 -->
|
||||
<spring-cloud.version>2021.0.3</spring-cloud.version>
|
||||
@@ -367,7 +369,7 @@
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<skipTests>true</skipTests>
|
||||
<skipTests>${skipTests}</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 避免font文件的二进制文件格式压缩破坏 -->
|
||||
|
||||
@@ -335,7 +335,6 @@ export const usePermissionStore = defineStore({
|
||||
try {
|
||||
this.changePermissionCode();
|
||||
routeList = (await getMenuList()) as AppRouteRecordRaw[];
|
||||
routeList = ensureInquiryHandwrittenRoutes(routeList);
|
||||
// update-begin----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
|
||||
let hasIndex: boolean = false;
|
||||
let hasIcon: boolean = false;
|
||||
@@ -373,7 +372,11 @@ export const usePermissionStore = defineStore({
|
||||
// update-end----author:sunjianlei---date:20220315------for: 判断是否是 vue3 版本的菜单 ---
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// 菜单接口失败时仍补挂询价手写页,避免侧栏点进去全是 404
|
||||
routeList = Array.isArray(routeList) ? routeList : [];
|
||||
}
|
||||
// 无论菜单接口成功与否,都按侧栏清单补齐手写路由
|
||||
routeList = ensureInquiryHandwrittenRoutes(routeList || []);
|
||||
// 组件地址前加斜杠处理 author: lsq date:2021-09-08
|
||||
routeList = addSlashToRouteComponent(routeList);
|
||||
// 动态引入组件
|
||||
|
||||
@@ -1262,6 +1262,43 @@ html {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报价模板预览截图:强制等比例(宽高同比例缩放),覆盖任何全局 img 拉满规则。
|
||||
* 为何写在 fidelity:Teleport 弹窗挂 body,需非 scoped + !important 兜底。
|
||||
*/
|
||||
.qt-snapshot-wrap {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: flex-start !important;
|
||||
max-height: min(72vh, 760px) !important;
|
||||
min-height: 0 !important;
|
||||
overflow: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 6px !important;
|
||||
}
|
||||
|
||||
.qt-snapshot-img,
|
||||
.modal-overlay .qt-snapshot-img,
|
||||
.qt-preview-dialog .qt-snapshot-img {
|
||||
display: block !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: min(70vh, 720px) !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center !important;
|
||||
flex: 0 0 auto !important;
|
||||
align-self: flex-start !important;
|
||||
}
|
||||
|
||||
.qt-lightbox-img {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: calc(100vw - 48px) !important;
|
||||
max-height: calc(100vh - 72px) !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.qt-upload-title,
|
||||
.qt-file-name {
|
||||
font-size: 11px !important;
|
||||
|
||||
@@ -770,6 +770,50 @@ textarea:focus-visible {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* 报价费用明细:列表紧凑一行;详情左右对齐名称/金额 */
|
||||
.quote-fee-line {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #344054;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.quote-fee-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 6px 0 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-name {
|
||||
color: #475467;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ticket-detail-quote .quote-fee-amount {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-detail-quote {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ticket-detail-quote:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.attach-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2249,6 +2293,47 @@ textarea:focus-visible {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* 报价模板原表截图:等比例适配弹窗,禁止拉伸变形 */
|
||||
.qt-snapshot-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-height: min(72vh, 760px);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.qt-snapshot-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: min(70vh, 720px);
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.qt-lightbox-img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 72px);
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* 工单详情:对齐原型 admin-inquiry-tickets 分节网格 */
|
||||
.ticket-detail-no {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 海运 / 空运 / 陆运同页自上而下分块展示,只允许改超时小时;无增删、无 Tab。
|
||||
* 「编码」= timeoutCode,每条唯一(如 sea_sales / land_hk_express),供智能体单码调用;只读。
|
||||
* roleCode(销售/产品)仅为角色维度,可重复,不是调用主键。
|
||||
* 陆运产品运输分类仅 4 项(与线路解耦);不展示线路类别。
|
||||
* 陆运产品运输分类 5 项(与线路解耦);不展示线路类别。
|
||||
* 数据来自主账 inq_response_timeout;本页不做工单超时判定。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
@@ -70,6 +70,8 @@ const previewState = reactive({
|
||||
isImage: true,
|
||||
mappedCount: 0,
|
||||
fileName: "",
|
||||
/** 浏览器侧已裁过白边,防止 @load 重复裁切 */
|
||||
trimmed: false,
|
||||
});
|
||||
|
||||
/** 列表「解析状态」点击弹窗:已解析 / 未解析 */
|
||||
@@ -125,16 +127,103 @@ const formSub = computed(() =>
|
||||
: "上传 Excel 后 AI 异步解析映射;失败原因会显示在列表",
|
||||
);
|
||||
const formOkText = computed(() => (editing.value ? "保存" : "上传并解析"));
|
||||
const previewFootHint = "按上传原表打印版式截图,比例 1:1 不变形;点击图片可放大。";
|
||||
const previewFootHint = "按原表打印版式等比例截图(不拉高/压扁);点击图片可放大。";
|
||||
const previewLightbox = ref(false);
|
||||
|
||||
const filtered = computed(() => rows.value);
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (previewState.objectUrl) {
|
||||
if (previewState.objectUrl && previewState.objectUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(previewState.objectUrl);
|
||||
previewState.objectUrl = "";
|
||||
}
|
||||
previewState.objectUrl = "";
|
||||
previewState.trimmed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器侧再裁一层近白边:主账若仍带 A4 页底空白,预览也能贴紧内容。
|
||||
* 副作用:替换 objectUrl(旧 blob 释放);仅图片预览调用一次。
|
||||
*/
|
||||
function onSnapshotImgLoad(ev: Event) {
|
||||
if (previewState.trimmed || !previewState.isImage) return;
|
||||
const img = ev.target as HTMLImageElement | null;
|
||||
if (!img?.naturalWidth || !img.naturalHeight) return;
|
||||
try {
|
||||
const next = cropNearWhiteObjectUrl(img);
|
||||
if (!next) {
|
||||
previewState.trimmed = true;
|
||||
return;
|
||||
}
|
||||
const old = previewState.objectUrl;
|
||||
previewState.trimmed = true;
|
||||
previewState.objectUrl = next;
|
||||
if (old && old.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(old);
|
||||
}
|
||||
} catch {
|
||||
previewState.trimmed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas 裁近白边 → data URL。几乎无内容可裁时返回 null。
|
||||
*/
|
||||
function cropNearWhiteObjectUrl(img: HTMLImageElement): string | null {
|
||||
const w = img.naturalWidth;
|
||||
const h = img.naturalHeight;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
const isWhite = (i: number) => data[i] >= 240 && data[i + 1] >= 240 && data[i + 2] >= 240;
|
||||
const rowMostlyWhite = (y: number) => {
|
||||
const allow = Math.max(2, Math.floor(w / 200));
|
||||
let dirty = 0;
|
||||
const row = y * w * 4;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!isWhite(row + x * 4)) {
|
||||
dirty++;
|
||||
if (dirty > allow) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const colMostlyWhite = (x: number, y0: number, y1: number) => {
|
||||
const allow = Math.max(2, Math.floor((y1 - y0 + 1) / 200));
|
||||
let dirty = 0;
|
||||
for (let y = y0; y <= y1; y++) {
|
||||
if (!isWhite((y * w + x) * 4)) {
|
||||
dirty++;
|
||||
if (dirty > allow) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let top = 0;
|
||||
let bottom = h - 1;
|
||||
let left = 0;
|
||||
let right = w - 1;
|
||||
while (top < h && rowMostlyWhite(top)) top++;
|
||||
while (bottom > top && rowMostlyWhite(bottom)) bottom--;
|
||||
while (left < w && colMostlyWhite(left, top, bottom)) left++;
|
||||
while (right > left && colMostlyWhite(right, top, bottom)) right--;
|
||||
if (top >= bottom || left >= right || bottom - top + 1 < h / 20) return null;
|
||||
const pad = 8;
|
||||
const x = Math.max(0, left - pad);
|
||||
const y = Math.max(0, top - pad);
|
||||
const cw = Math.min(w - 1, right + pad) - x + 1;
|
||||
const ch = Math.min(h - 1, bottom + pad) - y + 1;
|
||||
if (cw >= w && ch >= h) return null;
|
||||
const out = document.createElement("canvas");
|
||||
out.width = cw;
|
||||
out.height = ch;
|
||||
const octx = out.getContext("2d");
|
||||
if (!octx) return null;
|
||||
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
|
||||
return out.toDataURL("image/png");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +238,7 @@ async function openPreview(row: TemplateRow) {
|
||||
previewState.loading = true;
|
||||
previewState.error = "";
|
||||
previewState.isImage = true;
|
||||
previewState.trimmed = false;
|
||||
previewState.mappedCount = row.mappingPointCount || 0;
|
||||
previewState.fileName = row.fileName || "";
|
||||
if (!row.versionId) {
|
||||
@@ -919,6 +1009,7 @@ async function downloadPreviewOrOriginal() {
|
||||
:src="previewState.objectUrl"
|
||||
alt="原表截图预览"
|
||||
title="点击放大"
|
||||
@load="onSnapshotImgLoad"
|
||||
@click="openLightbox"
|
||||
/>
|
||||
<iframe
|
||||
@@ -1248,19 +1339,35 @@ async function downloadPreviewOrOriginal() {
|
||||
.qt-paper-loading--err {
|
||||
color: #b42318;
|
||||
}
|
||||
/**
|
||||
* 原表截图:等比例缩放;容器贴合图片,不人为撑出大块空白。
|
||||
* 弹窗 Teleport 到 body,关键规则在 admin-fidelity 也有 !important 兜底。
|
||||
*/
|
||||
.qt-snapshot-wrap {
|
||||
max-height: min(70vh, 720px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
max-height: min(72vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
/* 不设 min-height,避免图矮时底下留灰白空带 */
|
||||
min-height: 0;
|
||||
}
|
||||
.qt-snapshot-img {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
display: block;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: min(70vh, 720px) !important;
|
||||
object-fit: contain !important;
|
||||
object-position: top center;
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
vertical-align: top;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
|
||||
@@ -17,7 +17,16 @@ import ProtoIcon from "/@/views/inquiry/components/ProtoIcon.vue";
|
||||
import AdminModal from "/@/views/inquiry/components/AdminModal.vue";
|
||||
import AdminPagination from "/@/views/inquiry/components/AdminPagination.vue";
|
||||
|
||||
type TicketQuote = { source: string; price: string; time?: string; highlight?: boolean };
|
||||
/** 单条费用明细:名称 + 带币种金额,对齐企微价格明细。 */
|
||||
type TicketQuoteFee = { name: string; amount: string };
|
||||
type TicketQuote = {
|
||||
source: string;
|
||||
price: string;
|
||||
time?: string;
|
||||
highlight?: boolean;
|
||||
/** TMS/手工报价费用明细行;无明细时为空数组 */
|
||||
fees: TicketQuoteFee[];
|
||||
};
|
||||
type TicketAttach = { name: string; uploadedAt: string; uploader: string };
|
||||
type TicketTimeline = { status: string; remark: string; time: string };
|
||||
|
||||
@@ -108,6 +117,110 @@ function quoteSourceLabel(q: any) {
|
||||
return label && label !== "报价" ? "手工报价" : "报价";
|
||||
}
|
||||
|
||||
/** JSON 字符串或对象都解开;解不开当没有。 */
|
||||
function asObject(raw: any): Record<string, any> | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "object" && !Array.isArray(raw)) return raw as Record<string, any>;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, any>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 费用数组:List / JSON 字符串 / 二次编码都认。 */
|
||||
function asFeeArray(raw: any): any[] {
|
||||
if (raw == null) return [];
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw === "string" && raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (typeof parsed === "string") {
|
||||
const again = JSON.parse(parsed);
|
||||
return Array.isArray(again) ? again : [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条费用行收成「名称 + 金额」。
|
||||
* TMS 字段:name/chargeName、amount/unitPrice、currency。
|
||||
*/
|
||||
function feeRowToDisplay(item: any): TicketQuoteFee | null {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const name = String(
|
||||
item.name || item.chargeName || item.feeName || item.label || ""
|
||||
).trim();
|
||||
if (!name) return null;
|
||||
let amount = String(item.amount ?? item.totalPrice ?? item.value ?? "").trim();
|
||||
const unitPrice = String(item.unitPrice ?? item.unit_price ?? "").trim();
|
||||
if (!amount || amount === "-") {
|
||||
amount = unitPrice;
|
||||
}
|
||||
if (!amount || amount === "-") return null;
|
||||
const ccy = String(item.currency || "").trim().toUpperCase();
|
||||
const amountUpper = amount.toUpperCase();
|
||||
if (ccy && !amountUpper.startsWith(ccy) && !/^[A-Z]{3}\s/.test(amountUpper)) {
|
||||
amount = `${ccy} ${amount}`;
|
||||
}
|
||||
return { name, amount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 从报价 payload 抽费用明细,供后台「价格与利润」展示。
|
||||
* 优先 fee_lines / fee_rows;空运再看 departureCharges;海运再看三段桶。
|
||||
* 副作用:无。不写库。
|
||||
*/
|
||||
function parseQuoteFees(q: any): TicketQuoteFee[] {
|
||||
const payload = asObject(q?.payloadJson) || asObject(q?.payload) || {};
|
||||
const out: TicketQuoteFee[] = [];
|
||||
const pushRows = (rows: any[]) => {
|
||||
for (const row of rows) {
|
||||
const one = feeRowToDisplay(row);
|
||||
if (one) out.push(one);
|
||||
}
|
||||
};
|
||||
pushRows(asFeeArray(payload.fee_lines ?? payload.fee_rows ?? q?.fee_lines ?? q?.fee_rows));
|
||||
if (!out.length) {
|
||||
const selected =
|
||||
asObject(payload.selected_air_option) || asObject(payload.selectedAirOption);
|
||||
if (selected) {
|
||||
pushRows(
|
||||
asFeeArray(
|
||||
selected.departureCharges || selected.departure_charges || selected.feeItems
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!out.length) {
|
||||
const opts = asFeeArray(payload.airOptions || payload.air_options);
|
||||
const first = opts.find((x) => x && typeof x === "object") || null;
|
||||
if (first) {
|
||||
pushRows(
|
||||
asFeeArray(first.departureCharges || first.departure_charges || first.feeItems)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!out.length) {
|
||||
for (const bucket of ["polCostItems", "oceanCostItems", "podCostItems"] as const) {
|
||||
const key = `fee_rows_${bucket}`;
|
||||
pushRows(asFeeArray(payload[key] || payload[bucket]));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主账 inq_ticket_status_log.action 是内部码,禁止当备注展示。
|
||||
* transition 与「已报价」等 toStatus 重复,无用户备注时不再跟一段英文。
|
||||
@@ -260,6 +373,7 @@ function mapTicket(t: any, quotes: any[] = [], attachments: any[] = [], timeline
|
||||
price: q.priceText || "-",
|
||||
time: fmtTime(q.createTime),
|
||||
highlight: q.highlighted === 1 || q.highlighted === true,
|
||||
fees: parseQuoteFees(q),
|
||||
})),
|
||||
attachments: attachRows.map((a) => a.name),
|
||||
attachRows,
|
||||
@@ -556,6 +670,13 @@ function refresh() {
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<div class="quote-source">{{ q.source }}</div>
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
{{ fee.name }}:{{ fee.amount }}
|
||||
</div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">
|
||||
{{ q.price }}
|
||||
</div>
|
||||
@@ -688,9 +809,30 @@ function refresh() {
|
||||
<div class="ticket-detail-item"><label>利润点</label><span>{{ current.profit }}</span></div>
|
||||
</div>
|
||||
<div v-if="current.quotes.length" class="ticket-detail-demand">
|
||||
<div v-for="q in current.quotes" :key="q.source + q.price + (q.time || '')" class="quote-row" style="margin-bottom: 6px">
|
||||
<div class="quote-source">{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span></div>
|
||||
<div class="quote-price" :style="q.highlight ? { color: '#1d4ed8' } : undefined">{{ q.price }}</div>
|
||||
<div
|
||||
v-for="q in current.quotes"
|
||||
:key="q.source + q.price + (q.time || '')"
|
||||
class="quote-row ticket-detail-quote"
|
||||
>
|
||||
<div class="quote-source">
|
||||
{{ q.source }}<span v-if="q.time && q.time !== '-'"> · {{ q.time }}</span>
|
||||
</div>
|
||||
<div v-if="q.fees.length" class="quote-fee-list">
|
||||
<div
|
||||
v-for="(fee, fi) in q.fees"
|
||||
:key="q.source + '-detail-fee-' + fi"
|
||||
class="quote-fee-line"
|
||||
>
|
||||
<span class="quote-fee-name">{{ fee.name }}</span>
|
||||
<span class="quote-fee-amount">{{ fee.amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="quote-price"
|
||||
:style="q.highlight ? { color: '#1d4ed8' } : undefined"
|
||||
>
|
||||
合计 {{ q.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,7 +169,8 @@ async function checkPhone(rule, value, callback) {
|
||||
* 判断是否是OAuth2APP环境
|
||||
*/
|
||||
export function isOAuth2AppEnv() {
|
||||
return /wxwork|dingtalk/i.test(navigator.userAgent);
|
||||
// 后台只用账号密码。企微里打开卡片也不能走企业微信授权,否则会跳去授权地址并不断刷新。
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user