Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f0219dfd | ||
|
|
3a4c440354 | ||
|
|
4f8c373121 | ||
|
|
428548c390 |
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
陆运选线路 H5 页。
|
||||
|
||||
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
|
||||
本文件职责:按完整线路列出各段车型、成本与合计,提交所选下标。
|
||||
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
|
||||
"""
|
||||
|
||||
@@ -64,19 +64,40 @@ def render_land_route_ok() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _segment_vehicle(leg: dict[str, Any] | None) -> str:
|
||||
"""每段车型用 TMS 的 vehicleType 原文。没有台数,空值显示 -。"""
|
||||
src = dict(leg or {})
|
||||
raw = str(
|
||||
src.get("vehicleType") or src.get("vehicle_type") or src.get("truckType") or ""
|
||||
).strip()
|
||||
if raw.lower() in {"none", "null"}:
|
||||
raw = ""
|
||||
return raw or "-"
|
||||
|
||||
|
||||
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
|
||||
"""一张线路:航线、各段路段+TMS 费目、合计。"""
|
||||
"""一张线路:航线、各段路段、车型、TMS 费目、合计。"""
|
||||
line = html.escape(route_line(route))
|
||||
blocks = land_leg_fee_blocks_for_route(quote, route)
|
||||
shown_legs = [
|
||||
leg
|
||||
for leg in list(route.get("legs") or [])
|
||||
if str(leg.get("origin") or "").strip() or str(leg.get("destination") or "").strip()
|
||||
]
|
||||
etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)}
|
||||
fee_bits: list[str] = []
|
||||
items = ""
|
||||
if blocks:
|
||||
for i, (title, fees, eta) in enumerate(blocks, 1):
|
||||
vehicle = _segment_vehicle(shown_legs[i - 1] if i - 1 < len(shown_legs) else None)
|
||||
items += (
|
||||
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
items += (
|
||||
f"<li><span class='n'>车型 {html.escape(vehicle)}</span>"
|
||||
"<span></span></li>"
|
||||
)
|
||||
for fee in fees:
|
||||
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
|
||||
name = str(fee.get("name") or "").strip()
|
||||
|
||||
@@ -11,14 +11,17 @@ import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
try:
|
||||
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
|
||||
except ImportError: # 测服旧 release 未导出同义词时仍可抽产品价
|
||||
QUOTE_DATE_SYNONYMS = ()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
|
||||
_QUOTE_DATE_LABEL_RE = "|".join(
|
||||
re.escape(name)
|
||||
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
|
||||
for name in sorted(("报价日期",) + tuple(QUOTE_DATE_SYNONYMS or ()), key=len, reverse=True)
|
||||
)
|
||||
|
||||
_MODE_WORDS = (
|
||||
@@ -829,9 +832,11 @@ def extract_air_collab_fields(
|
||||
return {k: got[k] for k in keys if str(got.get(k) or "").strip()}
|
||||
|
||||
|
||||
# 金额旁币种词:含港币;禁止只认 USD/CNY 把港币单漏成美元。
|
||||
_CCY_TOKEN = r"USD|CNY|RMB|HKD|EUR|JPY|GBP|美金|美元|人民币|港币|港元|欧元|日元|英镑"
|
||||
_RE_MONEY = re.compile(
|
||||
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
|
||||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||||
rf"(?:{_CCY_TOKEN}|元)?\s*(\d+(?:\.\d+)?)\s*(?:{_CCY_TOKEN}|元)?"
|
||||
rf"|(?:{_CCY_TOKEN})\s*(\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
# 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。
|
||||
@@ -839,14 +844,14 @@ _RE_WEIGHT_BAND_PRICE = re.compile(
|
||||
r"(?P<min>\d+)\s*[-~~—–到至]\s*(?P<max>\d+)\s*K\s*G"
|
||||
r"(?:\s*以上)?"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
_RE_WEIGHT_BAND_OPEN = re.compile(
|
||||
r"(?P<min>\d+)\s*K\s*G\s*以上"
|
||||
r"\s*(?:单价)?"
|
||||
r"\s*(?:(?P<ccy>USD|CNY|RMB|美金|美元|人民币)\s*)?"
|
||||
rf"\s*(?:(?P<ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<price>\d+(?:\.\d+)?)",
|
||||
re.I,
|
||||
)
|
||||
@@ -862,9 +867,9 @@ _RE_FEE_LINE = re.compile(
|
||||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||||
r"\s*(?:改为|改成|调整为|调整成)?"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
|
||||
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
|
||||
@@ -873,19 +878,21 @@ _RE_FEE_CHANGE = re.compile(
|
||||
r"\s*(?:改为|改成|调整为|调整成)"
|
||||
r"\s*[::=]?"
|
||||
r"\s*(?P<amount>\d+(?:\.\d+)?)"
|
||||
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||||
rf"\s*(?P<ccy>{_CCY_TOKEN}|元)?",
|
||||
re.I,
|
||||
)
|
||||
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
|
||||
_SKIP_FEE_NAMES = frozenset(
|
||||
{"usd", "cny", "rmb", "hkd", "eur", "jpy", "gbp", "美金", "美元", "人民币", "港币", "港元", "元"}
|
||||
)
|
||||
# 航线常写「空运费 单价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*)?"
|
||||
rf"(?:(?P<unit_ccy>{_CCY_TOKEN})\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*)?"
|
||||
rf"(?:(?P<amt_ccy>{_CCY_TOKEN})\s*)?"
|
||||
r"(?P<amount>\d+(?:\.\d+)?))?",
|
||||
re.I,
|
||||
)
|
||||
@@ -898,21 +905,40 @@ def _ascii_amount(raw: str) -> str:
|
||||
|
||||
|
||||
def _currency_of(text: str, hint: str = "") -> str:
|
||||
"""
|
||||
从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
|
||||
|
||||
港币必须先于「元」判断,避免「港元」被误判成 CNY。
|
||||
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
|
||||
"""
|
||||
blob = f"{hint} {text}".upper()
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币", "元")):
|
||||
raw = f"{hint} {text}"
|
||||
if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
|
||||
return "HKD"
|
||||
if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
|
||||
"元" in raw and "港元" not in raw and "港币" not in raw
|
||||
):
|
||||
return "CNY"
|
||||
if any(x in blob for x in ("USD", "美金", "美元")):
|
||||
return "USD"
|
||||
return hint or "USD"
|
||||
if any(x in blob for x in ("EUR", "欧元")):
|
||||
return "EUR"
|
||||
if any(x in blob for x in ("JPY", "日元")):
|
||||
return "JPY"
|
||||
if any(x in blob for x in ("GBP", "英镑")):
|
||||
return "GBP"
|
||||
return str(hint or "").strip().upper()
|
||||
|
||||
|
||||
def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
|
||||
"""
|
||||
按行抽费用:码头操作费100CNY、报关费改为300CNY。
|
||||
|
||||
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
|
||||
句里无币种时继承 baseline_currency(通常是当前 TMS/主账币种),禁止默认 USD。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
@@ -925,10 +951,11 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
if name in seen:
|
||||
return
|
||||
seen.add(name)
|
||||
line_ccy = _currency_of(raw, ccy or base_ccy) or base_ccy
|
||||
row = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": _currency_of(raw, ccy),
|
||||
"currency": line_ccy,
|
||||
}
|
||||
for key, val in extra.items():
|
||||
if val:
|
||||
@@ -961,7 +988,33 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||||
return rows
|
||||
|
||||
|
||||
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
def _default_product_fee_name(business_line: str = "", transport_mode: str = "") -> str:
|
||||
"""
|
||||
产品文字未点费用名时的默认费目。
|
||||
|
||||
必须跟本单运输方式走:陆运→陆运费、空运→空运费、其余→海运费。
|
||||
禁止陆运单无费名时硬编码「海运费」(WO202610090118「价格为1RMB/KG」)。
|
||||
"""
|
||||
line = (business_line or "").strip().upper()
|
||||
mode = (transport_mode or "").strip()
|
||||
if line == "LAND" or mode in {"陆运", "LAND", "land"}:
|
||||
return "陆运费"
|
||||
if line == "AIR" or mode in {"空运", "AIR", "air"}:
|
||||
return "空运费"
|
||||
if line == "SEA" or mode in {"海运", "SEA", "sea"}:
|
||||
return "海运费"
|
||||
# 未传业务线时保持旧默认,避免海运调用方漏参后费名变空。
|
||||
return "海运费"
|
||||
|
||||
|
||||
def extract_product_quote(
|
||||
text: str,
|
||||
*,
|
||||
injected: Optional[dict[str, Any]] = None,
|
||||
business_line: str = "",
|
||||
transport_mode: str = "",
|
||||
baseline_currency: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
||||
|
||||
@@ -970,7 +1023,10 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||
无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。
|
||||
正文未写币种时继承 baseline_currency(当前 TMS/主账币种),禁止默认 USD(WO107)。
|
||||
"""
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||
out = dict(injected)
|
||||
out.setdefault("source_label", "产品报价")
|
||||
@@ -982,14 +1038,14 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
compact = stripped.replace(" ", "").replace(" ", "")
|
||||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||||
return {}
|
||||
band = _parse_weight_band_quote(raw)
|
||||
band = _parse_weight_band_quote(raw, baseline_currency=base_ccy)
|
||||
if band:
|
||||
return band
|
||||
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
|
||||
scrubbed = _strip_rate_and_band_noise(raw)
|
||||
rows = _parse_fee_rows(scrubbed) if scrubbed.strip() else []
|
||||
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
|
||||
if rows:
|
||||
ccy = rows[0].get("currency") or _currency_of(raw)
|
||||
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
|
||||
money_rows = [x for x in rows if "%" not in str(x.get("amount") or "")]
|
||||
total = (money_rows[0] if money_rows else rows[0])["amount"]
|
||||
if money_rows and len({x.get("currency") for x in money_rows}) == 1:
|
||||
@@ -1014,16 +1070,20 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||||
if not amount:
|
||||
return {}
|
||||
ccy = _currency_of(raw)
|
||||
ccy = _currency_of(raw, base_ccy) or base_ccy
|
||||
default_fee = _default_product_fee_name(business_line, transport_mode)
|
||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||
named = re.search(
|
||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||
scrubbed or raw,
|
||||
)
|
||||
fee_name = (named.group(1) if named else "") or "海运费"
|
||||
if fee_name in {"费用", "燃油附加费"}:
|
||||
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||
fee_name = (named.group(1) if named else "") or default_fee
|
||||
if fee_name == "费用":
|
||||
# 句里只有「费用」没有运输方式前缀时,跟本单默认运费。
|
||||
fee_name = default_fee
|
||||
elif fee_name == "燃油附加费":
|
||||
# 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。
|
||||
fee_name = default_fee if default_fee != "海运费" else "陆运费"
|
||||
out = {
|
||||
"total": amount,
|
||||
"currency": ccy,
|
||||
@@ -1036,6 +1096,7 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _format_percent_rate(raw: str) -> str:
|
||||
"""费率展示统一带 %;已有 % 保留。"""
|
||||
text = (raw or "").strip()
|
||||
@@ -1070,13 +1131,15 @@ def _strip_rate_and_band_noise(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
def _parse_weight_band_quote(text: str, baseline_currency: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
陆运集拼分档单价:2-500KG 单价CNY990 + 可选燃油附加费率/税率。
|
||||
|
||||
产出 weightPrices,供主账出单按「2-500KG」表头对档;费率进顶层字段填 Excel。
|
||||
无币种时优先继承 baseline,再退回 CNY(集拼常见人民币报价)。
|
||||
"""
|
||||
raw = _ascii_amount(text or "")
|
||||
base_ccy = str(baseline_currency or "").strip().upper()
|
||||
tiers: list[dict[str, Any]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
ccy = ""
|
||||
@@ -1086,7 +1149,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}-{hi}KG"
|
||||
tiers.append({"min": lo, "max": hi, "price": price})
|
||||
@@ -1103,7 +1166,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
price = _ascii_amount(hit.group("price") or "")
|
||||
if not price:
|
||||
continue
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or "")
|
||||
line_ccy = _currency_of(raw, hit.group("ccy") or base_ccy) or base_ccy
|
||||
ccy = ccy or line_ccy
|
||||
label = f"{lo}KG以上"
|
||||
tiers.append({"min": lo, "max": None, "price": price})
|
||||
@@ -1132,7 +1195,7 @@ def _parse_weight_band_quote(text: str) -> dict[str, Any]:
|
||||
# 分档是单价,不能把多档相加冒充费用合计。
|
||||
out: dict[str, Any] = {
|
||||
"total": tiers[0]["price"],
|
||||
"currency": ccy or "CNY",
|
||||
"currency": ccy or base_ccy or "CNY",
|
||||
"source_label": "产品报价",
|
||||
"raw_text": (text or "").strip(),
|
||||
"fee_rows": rows,
|
||||
|
||||
@@ -629,12 +629,19 @@ def apply_air_quote(
|
||||
fields = extract_air_collab_fields(text)
|
||||
if fields:
|
||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||
quote = injected_quote or extract_product_quote(text) or {}
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint
|
||||
|
||||
quote = injected_quote or extract_product_quote(
|
||||
text,
|
||||
business_line="AIR",
|
||||
transport_mode="空运",
|
||||
baseline_currency=_quote_currency_hint(current),
|
||||
) or {}
|
||||
if not quote:
|
||||
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
||||
return "quote_empty"
|
||||
view = ticket_view(ticket)
|
||||
current = dict(view.get("quote") or {})
|
||||
if _has_tms_quote(current) and not _is_product_quote(current):
|
||||
_park_air_pending(flow, no, quote, sender_id)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
|
||||
@@ -2624,6 +2624,20 @@ def group_tms_confirm_hint(*, business_line: str = "") -> str:
|
||||
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
|
||||
|
||||
|
||||
def multi_group_field_hint(*, includes_air: bool) -> str:
|
||||
"""
|
||||
多段群里每次带出各段询价字段时,贴在最后一行字段下面。
|
||||
|
||||
任一段是空运,喊航线或产品;没有空运只喊产品。成交跟进不走这里。
|
||||
注里说的是附件报价单只存档,不改报价写入规则。
|
||||
"""
|
||||
who = "航线或产品人员" if includes_air else "产品人员"
|
||||
return (
|
||||
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
|
||||
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
|
||||
)
|
||||
|
||||
|
||||
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
|
||||
"""
|
||||
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
|
||||
|
||||
@@ -18,6 +18,7 @@ from agent.llm.extract_text import extract_collab_fields, extract_product_quote
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.multi_segments import Segment, group_brief, includes_air, segments_from_payload
|
||||
from agent.policy.sea_group_ops import (
|
||||
_quote_currency_hint,
|
||||
apply_group_deal,
|
||||
group_client_of,
|
||||
is_sales,
|
||||
@@ -152,7 +153,22 @@ def handle_multi_inbound(
|
||||
ticket = _reload(flow, no) or ticket
|
||||
segments = load_segments(ticket)
|
||||
target = next((seg for seg in segments if seg.mode == mode), target)
|
||||
quote = extract_product_quote(text or "", injected=injected_quote)
|
||||
# 段报价币种优先;否则用工单顶层 quote(产品未写币种时继承 TMS)
|
||||
baseline = ""
|
||||
if target is not None:
|
||||
baseline = _quote_currency_hint((segment_quotes(ticket) or {}).get(target.mode) or {})
|
||||
if not baseline:
|
||||
baseline = _quote_currency_hint(dict(ticket_view(ticket).get("quote") or {}))
|
||||
quote = extract_product_quote(
|
||||
text or "",
|
||||
injected=injected_quote,
|
||||
business_line=str(mode or "").upper(),
|
||||
transport_mode=(
|
||||
"陆运" if str(mode or "").upper() == "LAND"
|
||||
else ("空运" if str(mode or "").upper() == "AIR" else "海运")
|
||||
),
|
||||
baseline_currency=baseline,
|
||||
)
|
||||
if not quote:
|
||||
if fields:
|
||||
return "multi_collab_saved"
|
||||
|
||||
@@ -385,6 +385,7 @@ def group_brief(
|
||||
|
||||
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
|
||||
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
|
||||
每次拼这段字段都在底部加另报提示;成交跟进是另一条消息,不加。
|
||||
"""
|
||||
_ = work_order_no
|
||||
names: list[str] = []
|
||||
@@ -406,6 +407,8 @@ def group_brief(
|
||||
body = _segment_inquiry_block(seg).strip()
|
||||
if body:
|
||||
lines.extend(body.splitlines())
|
||||
# 紧挨最后一行字段,中间不空行。含空运与不含空运用不同开头。
|
||||
lines.append(copy.multi_group_field_hint(includes_air=includes_air(segments)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -929,19 +929,28 @@ def apply_product_quote(
|
||||
merged = dict(sess.collab_facts)
|
||||
merged.update(fields)
|
||||
sess.collab_facts = merged
|
||||
quote = extract_product_quote(text, injected=injected_quote)
|
||||
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
|
||||
base = _current_quote(view, sess)
|
||||
quote = extract_product_quote(
|
||||
text,
|
||||
injected=injected_quote,
|
||||
business_line=line,
|
||||
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
|
||||
baseline_currency=_quote_currency_hint(base),
|
||||
)
|
||||
if not quote:
|
||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||
return "quote_not_recognized"
|
||||
quote = _attach_land_weight_band_segment(quote, view)
|
||||
base = _current_quote(view, sess)
|
||||
if _has_tms_quote(base):
|
||||
_park_pending_quote(no, quote, sess, flow)
|
||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||
return "wait_tms_choice"
|
||||
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||
# 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。
|
||||
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
||||
base = _align_wrong_default_freight(base, quote, business_line=line)
|
||||
quote = merge_quote_fees(base, quote)
|
||||
_save_product_quote(flow, no, quote, sess)
|
||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||
@@ -1397,6 +1406,53 @@ def _current_quote(view: dict[str, Any], sess: Any) -> dict[str, Any]:
|
||||
return base
|
||||
|
||||
|
||||
def _quote_currency_hint(quote: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
当前报价币种提示:顶层 currency → 金额前缀 → 费用行/线路段。
|
||||
|
||||
产品改价未写币种时继承此值,禁止默认 USD(WO202610090107 中港 HKD)。
|
||||
"""
|
||||
src = dict(quote or {})
|
||||
for key in ("currency", "currencyCode"):
|
||||
code = str(src.get(key) or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("cost", "total", "freight", "land_freight"):
|
||||
raw = str(src.get(key) or "").strip().upper()
|
||||
for prefix in ("HKD", "CNY", "USD", "EUR", "JPY", "GBP", "RMB"):
|
||||
if raw.startswith(prefix):
|
||||
return "CNY" if prefix == "RMB" else prefix
|
||||
for row in _quote_fee_rows(src):
|
||||
code = str(row.get("currency") or row.get("currencyCode") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
for key in ("landSegments", "route_rows", "selected_land_route"):
|
||||
raw = src.get(key)
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
try:
|
||||
import json
|
||||
|
||||
raw = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(raw, dict):
|
||||
legs = raw.get("legs") if isinstance(raw.get("legs"), list) else [raw]
|
||||
for leg in legs:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
elif isinstance(raw, list):
|
||||
for leg in raw:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
code = str(leg.get("currency") or "").strip().upper()
|
||||
if code:
|
||||
return code
|
||||
return ""
|
||||
|
||||
|
||||
def _has_tms_quote(quote: dict[str, Any] | None) -> bool:
|
||||
"""当前价来自 TMS 才要问;产品价、销售调价不再替用户选。"""
|
||||
src = dict(quote or {})
|
||||
@@ -1627,6 +1683,45 @@ def _merged_total(
|
||||
return str(src.get("total") or ""), header_ccy
|
||||
|
||||
|
||||
def _align_wrong_default_freight(
|
||||
base: dict[str, Any] | None,
|
||||
overlay: dict[str, Any] | None,
|
||||
*,
|
||||
business_line: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
陆运/空运旧产品价若误落成别的运输方式默认费名,重报前改成与新报价同名,便于按行盖价。
|
||||
|
||||
只动「海运费/陆运费/空运费/运输费」这类主运费默认名,不碰操作费等真实分项。
|
||||
"""
|
||||
src = dict(base or {})
|
||||
line = (business_line or "").strip().upper()
|
||||
want = {"LAND": "陆运费", "AIR": "空运费", "SEA": "海运费"}.get(line, "")
|
||||
if not want:
|
||||
return src
|
||||
new_names = {
|
||||
str(x.get("name") or "").strip()
|
||||
for x in (overlay or {}).get("fee_rows") or (overlay or {}).get("fee_lines") or []
|
||||
if isinstance(x, dict)
|
||||
}
|
||||
if want not in new_names:
|
||||
return src
|
||||
mains = {"海运费", "陆运费", "空运费", "运输费"}
|
||||
rows = _quote_fee_rows(src)
|
||||
changed = False
|
||||
for row in rows:
|
||||
name = str(row.get("name") or "").strip()
|
||||
if name in mains and name != want:
|
||||
row["name"] = want
|
||||
changed = True
|
||||
if not changed:
|
||||
return src
|
||||
out = dict(src)
|
||||
out["fee_rows"] = rows
|
||||
out["fee_lines"] = rows
|
||||
return out
|
||||
|
||||
|
||||
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""
|
||||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||||
|
||||
@@ -360,6 +360,84 @@ class LandGroupCollabTests(unittest.TestCase):
|
||||
"只有这张单的销售能调价。航线请继续报价,价格未改。",
|
||||
)
|
||||
|
||||
def test_land_bare_price_defaults_to_land_fee(self) -> None:
|
||||
"""陆运「价格为1RMB/KG」无费用名时默认陆运费,禁止落成海运费(WO118)。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
|
||||
land = extract_product_quote("价格为1RMB/KG", business_line="LAND")
|
||||
rows = list(land.get("fee_rows") or [])
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(str(rows[0].get("name") or ""), "陆运费")
|
||||
self.assertEqual(str(land.get("total") or ""), "1")
|
||||
sea = extract_product_quote("价格为1RMB/KG", business_line="SEA")
|
||||
self.assertEqual(str((sea.get("fee_rows") or [{}])[0].get("name") or ""), "海运费")
|
||||
air = extract_product_quote("价格为1RMB/KG", business_line="AIR")
|
||||
self.assertEqual(str((air.get("fee_rows") or [{}])[0].get("name") or ""), "空运费")
|
||||
|
||||
def test_land_adjust_without_currency_inherits_tms_hkd(self) -> None:
|
||||
"""WO202610090107:TMS 港币,产品只写「运输费调整为1100」时继承 HKD,禁止默认 USD。"""
|
||||
from agent.llm.extract_text import extract_product_quote
|
||||
from agent.policy.sea_group_ops import _quote_currency_hint, merge_quote_fees
|
||||
|
||||
tms = {
|
||||
"currency": "HKD",
|
||||
"cost": "HKD 1000",
|
||||
"total": "1000",
|
||||
"source": "TMS",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "运输费",
|
||||
"amount": "1000",
|
||||
"currency": "HKD",
|
||||
}
|
||||
],
|
||||
"landSegments": [
|
||||
{
|
||||
"origin": "广东省-深圳市-福田区",
|
||||
"destination": "香港特别行政区",
|
||||
"currency": "HKD",
|
||||
"costPrice": "1000",
|
||||
}
|
||||
],
|
||||
}
|
||||
self.assertEqual(_quote_currency_hint(tms), "HKD")
|
||||
# 旧行为:无 baseline 会落到 USD
|
||||
legacy = extract_product_quote("运输费调整为1100", business_line="LAND")
|
||||
self.assertNotEqual(str(legacy.get("currency") or ""), "USD")
|
||||
quote = extract_product_quote(
|
||||
"运输费调整为1100",
|
||||
business_line="LAND",
|
||||
baseline_currency=_quote_currency_hint(tms),
|
||||
)
|
||||
self.assertEqual(str(quote.get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str((quote.get("fee_lines") or [{}])[0].get("amount") or ""), "1100")
|
||||
merged = merge_quote_fees(tms, quote)
|
||||
self.assertEqual(str((merged.get("fee_lines") or [{}])[0].get("currency") or ""), "HKD")
|
||||
self.assertEqual(str(merged.get("currency") or ""), "HKD")
|
||||
|
||||
def test_land_requote_replaces_wrong_sea_fee_name(self) -> None:
|
||||
"""旧陆运产品价误落海运费时,重报陆运费应盖掉而不是并排两行。"""
|
||||
from agent.policy.sea_group_ops import _align_wrong_default_freight, merge_quote_fees
|
||||
|
||||
base = {
|
||||
"fee_rows": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "海运费", "amount": "1", "currency": "CNY"}],
|
||||
"total": "1",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
overlay = {
|
||||
"fee_rows": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"fee_lines": [{"name": "陆运费", "amount": "2", "currency": "CNY"}],
|
||||
"total": "2",
|
||||
"source_label": "产品报价",
|
||||
}
|
||||
aligned = _align_wrong_default_freight(base, overlay, business_line="LAND")
|
||||
merged = merge_quote_fees(aligned, overlay)
|
||||
names = [str(x.get("name") or "") for x in (merged.get("fee_rows") or [])]
|
||||
self.assertEqual(names, ["陆运费"])
|
||||
self.assertEqual(str((merged.get("fee_rows") or [{}])[0].get("amount") or ""), "2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -433,6 +433,41 @@ class LandRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("CNY 7500", page)
|
||||
self.assertIn("成本价合计", page)
|
||||
|
||||
def test_h5_shows_vehicle_type_on_each_leg(self) -> None:
|
||||
"""多条线路的 H5 卡片按段展示 TMS 车型,没有就写 -。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
quote = _three_routes()
|
||||
for leg in quote["landSegments"]:
|
||||
dest = str(leg.get("destination") or "")
|
||||
origin = str(leg.get("origin") or "")
|
||||
if dest == "深圳":
|
||||
leg["vehicleType"] = "9米6"
|
||||
elif origin == "深圳":
|
||||
leg["vehicleType"] = "13米"
|
||||
elif dest == "广西":
|
||||
leg["vehicleType"] = None
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500),
|
||||
(1, 2, "运输费", 9600),
|
||||
(2, 1, "运输费", 7500),
|
||||
(2, 2, "运输费", 9600),
|
||||
(3, 1, "运输费", 7500),
|
||||
(3, 2, "运输费", 9600),
|
||||
)
|
||||
page = render_land_route_page(
|
||||
{"work_order_no": "WO202609220010", "quote": quote}
|
||||
)
|
||||
od = page.find("上海 → 深圳")
|
||||
vehicle = page.find("车型 9米6", od)
|
||||
fee = page.find("运输费", vehicle)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(vehicle, od)
|
||||
self.assertGreater(fee, vehicle)
|
||||
self.assertIn("车型 13米", page)
|
||||
self.assertIn("车型 -", page)
|
||||
self.assertNotIn("9米6/1", page)
|
||||
|
||||
def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None:
|
||||
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
@@ -177,6 +177,7 @@ class QuoteTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(phase, "multi_deal_sent")
|
||||
self.assertIn("120", self.client.texts[-1])
|
||||
self.assertNotIn("纯文字", self.client.texts[-1])
|
||||
self.assertNotIn("100", deal_text(
|
||||
work_order_no=self.ticket.work_order_no,
|
||||
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
||||
@@ -198,6 +199,8 @@ class QuoteTests(unittest.TestCase):
|
||||
brief = self.client.texts[-1]
|
||||
self.assertIn("段1:海运", brief)
|
||||
self.assertIn("段2:陆运", brief)
|
||||
self.assertIn("产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("航线或产品人员", brief)
|
||||
self.assertNotIn("TMS报价", brief)
|
||||
self.assertNotIn("其他段报完", brief)
|
||||
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
|
||||
@@ -265,6 +268,8 @@ class QuoteTests(unittest.TestCase):
|
||||
self.assertIn("段2:空运", brief)
|
||||
self.assertIn("目的地(必填):CRK", brief)
|
||||
self.assertIn("贸易条款(非必填):DAP", brief)
|
||||
self.assertTrue(brief.endswith("(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"))
|
||||
self.assertIn("航线或产品人员可通过“纯文字”告知新报价", brief)
|
||||
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
||||
self.assertNotIn("询价字段:", brief)
|
||||
|
||||
|
||||
@@ -167,6 +167,10 @@ class SplitTests(unittest.TestCase):
|
||||
)
|
||||
self.assertIn("@吴季林@林绵助\n段1:", text)
|
||||
self.assertNotIn("@林绵助\n\n段1", text)
|
||||
self.assertTrue(
|
||||
text.endswith(copy.multi_group_field_hint(includes_air=True)),
|
||||
text,
|
||||
)
|
||||
|
||||
def test_missing_fields_use_single_chat_examples(self) -> None:
|
||||
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
|
||||
@@ -694,6 +698,11 @@ class PullTests(unittest.TestCase):
|
||||
segments=flow._segments(flow.session_of("sales")),
|
||||
)
|
||||
self.assertNotIn("TMS", brief_text)
|
||||
self.assertTrue(
|
||||
brief_text.endswith(copy.multi_group_field_hint(includes_air=False)),
|
||||
brief_text,
|
||||
)
|
||||
self.assertNotIn("航线或产品人员", brief_text)
|
||||
|
||||
|
||||
class CardPullTests(unittest.TestCase):
|
||||
|
||||
+5
-2
@@ -27,7 +27,10 @@ public class InquiryDashboardController {
|
||||
|
||||
@ApiOperation("看板指标")
|
||||
@GetMapping("/metrics")
|
||||
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
|
||||
return Result.OK(dashboardService.metrics(range));
|
||||
public Result<Map<String, Object>> metrics(
|
||||
@RequestParam(name = "range", defaultValue = "today") String range,
|
||||
@RequestParam(name = "dateFrom", required = false) String dateFrom,
|
||||
@RequestParam(name = "dateTo", required = false) String dateTo) {
|
||||
return Result.OK(dashboardService.metrics(range, dateFrom, dateTo));
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -9,9 +9,13 @@ public interface IInquiryDashboardService {
|
||||
|
||||
/**
|
||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||
* rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。
|
||||
* <p>
|
||||
* 有 dateFrom/dateTo 时以显式日期为准(与工单列表筛选同源);否则回退 range=today|7d|30d。
|
||||
*
|
||||
* @param range today|7d|30d,默认 today
|
||||
* @param range today|7d|30d,默认 today(无显式日期时)
|
||||
* @param dateFrom 起日 yyyy-MM-dd,可空
|
||||
* @param dateTo 止日 yyyy-MM-dd,可空
|
||||
*/
|
||||
Map<String, Object> metrics(String range);
|
||||
Map<String, Object> metrics(String range, String dateFrom, String dateTo);
|
||||
}
|
||||
|
||||
+47
-27
@@ -78,19 +78,14 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
private InquiryQuoteMapper quoteMapper;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> metrics(String range) {
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
public Map<String, Object> metrics(String range, String dateFromStr, String dateToStr) {
|
||||
LocalDate[] bounds = resolveMetricBounds(range, dateFromStr, dateToStr);
|
||||
LocalDate fromDate = bounds[0];
|
||||
LocalDate toDate = bounds[1];
|
||||
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
Date to = Date.from(LocalDateTime.of(toDate, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||
|
||||
// 指标 / 排名 / 趋势 / 成本:同一批工单、同一日期窗,与工单列表 dateFrom~dateTo 对齐
|
||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, from)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
@@ -127,28 +122,24 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||
|
||||
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||
LambdaQueryWrapper<InquiryTicket> weekQw = new LambdaQueryWrapper<InquiryTicket>()
|
||||
.ge(InquiryTicket::getCreateTime, weekFrom)
|
||||
.le(InquiryTicket::getCreateTime, to);
|
||||
InquiryDataScopeSupport.applySelfOnly(weekQw);
|
||||
List<InquiryTicket> weekTickets = ticketMapper.selectList(weekQw);
|
||||
|
||||
List<String> trendDates = new ArrayList<>(7);
|
||||
List<Map<String, Object>> trend = new ArrayList<>(7);
|
||||
// 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸)
|
||||
List<String> trendDates = new ArrayList<>();
|
||||
List<Map<String, Object>> trend = new ArrayList<>();
|
||||
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
LocalDate d = today.minusDays(i);
|
||||
dayIndex.put(d, trendDates.size());
|
||||
String label = d.format(DAY_FMT);
|
||||
LocalDate cursor = fromDate;
|
||||
int dayGuard = 0;
|
||||
while (!cursor.isAfter(toDate) && dayGuard < 62) {
|
||||
dayIndex.put(cursor, trendDates.size());
|
||||
String label = cursor.format(DAY_FMT);
|
||||
trendDates.add(label);
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("date", label);
|
||||
p.put("value", 0L);
|
||||
trend.add(p);
|
||||
cursor = cursor.plusDays(1);
|
||||
dayGuard++;
|
||||
}
|
||||
for (InquiryTicket t : weekTickets) {
|
||||
for (InquiryTicket t : tickets) {
|
||||
LocalDate d = toLocalDate(t.getCreateTime());
|
||||
Integer idx = d == null ? null : dayIndex.get(d);
|
||||
if (idx == null) {
|
||||
@@ -174,10 +165,12 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
}
|
||||
|
||||
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||
List<Map<String, Object>> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("range", range == null ? "today" : range);
|
||||
result.put("dateFrom", fromDate.toString());
|
||||
result.put("dateTo", toDate.toString());
|
||||
result.put("metrics", metricCards);
|
||||
result.put("trend", trend);
|
||||
result.put("rankings", rankings);
|
||||
@@ -186,6 +179,33 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析看板统计起止日。
|
||||
* 优先用显式 dateFrom/dateTo(与工单列表同源);否则按 range=today|7d|30d 相对「今天」。
|
||||
*/
|
||||
private static LocalDate[] resolveMetricBounds(String range, String dateFromStr, String dateToStr) {
|
||||
if (StringUtils.isNotBlank(dateFromStr) && StringUtils.isNotBlank(dateToStr)) {
|
||||
LocalDate fromDate = LocalDate.parse(dateFromStr.trim());
|
||||
LocalDate toDate = LocalDate.parse(dateToStr.trim());
|
||||
if (toDate.isBefore(fromDate)) {
|
||||
LocalDate tmp = fromDate;
|
||||
fromDate = toDate;
|
||||
toDate = tmp;
|
||||
}
|
||||
return new LocalDate[]{fromDate, toDate};
|
||||
}
|
||||
LocalDate today = LocalDate.now(ZONE);
|
||||
LocalDate fromDate;
|
||||
if ("7d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(6);
|
||||
} else if ("30d".equalsIgnoreCase(range)) {
|
||||
fromDate = today.minusDays(29);
|
||||
} else {
|
||||
fromDate = today;
|
||||
}
|
||||
return new LocalDate[]{fromDate, today};
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员排名:与工单列表 enrichDisplayFields 同口径。
|
||||
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
||||
|
||||
+8
@@ -1176,6 +1176,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
if (!routeRows.isEmpty()) {
|
||||
QuoteTemplateExcelSupport.copyMissingWeightPrices(landSegs, rawLand);
|
||||
}
|
||||
// 销售已选线路透传:跨境整车国内/境外段按 legs 写完整起讫(多备选混在 route_rows 时不能靠同序列过滤)
|
||||
Object selectedLand = q.get("selected_land_route");
|
||||
if (selectedLand == null) {
|
||||
selectedLand = q.get("selectedLandRoute");
|
||||
}
|
||||
if (selectedLand != null) {
|
||||
out.put("selected_land_route", selectedLand);
|
||||
}
|
||||
if (!landSegs.isEmpty()) {
|
||||
out.put("landSegments", landSegs);
|
||||
out.put("route_rows", landSegs);
|
||||
|
||||
+311
-18
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@@ -2798,7 +2797,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
int mappedStart = rowIdx0;
|
||||
rowIdx0 = resolveRouteDataStartRow(sheet, rowIdx0, region.getIntValue("headerRow"));
|
||||
String kind = landRouteSectionKind(sheet, region);
|
||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size());
|
||||
JSONArray sectionRoutes = routesForLandRouteSection(routes, sheet, region, i, regions.size(), facts);
|
||||
if (sectionRoutes == null || sectionRoutes.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
@@ -2858,7 +2857,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(col)) {
|
||||
continue;
|
||||
}
|
||||
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size());
|
||||
String price = resolveRouteVehicleRate(rowObj, facts, vk, vehicleRates.size(), kind);
|
||||
if (StringUtils.isNotBlank(price) && !"-".equals(price)) {
|
||||
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
|
||||
}
|
||||
@@ -2937,6 +2936,19 @@ public final class QuoteTemplateExcelSupport {
|
||||
return firstNonBlank(rowObj.getString("amount"), rowObj.getString("salePrice"),
|
||||
rowObj.getString("costPrice"));
|
||||
}
|
||||
// 集拼矩阵副表头「最低收费」:有分档单价时只用 min_charge;分档壳无单价时回退整票价。
|
||||
if (header.contains("最低收费")) {
|
||||
String minCharge = firstNonBlank(rowObj.getString("min_charge"), rowObj.getString("minCharge"));
|
||||
if (StringUtils.isNotBlank(minCharge) && !"-".equals(minCharge)) {
|
||||
return formatTierPrice(minCharge);
|
||||
}
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
if (tiers != null && !tiers.isEmpty() && !anyWeightTierHasPrice(tiers)) {
|
||||
return formatTierPrice(firstNonBlank(rowObj.getString("salePrice"),
|
||||
rowObj.getString("costPrice"), rowObj.getString("amount")));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -3092,11 +3104,19 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内段写境内城市,境外段写境外城市。两段 TMS 腿按腿序号对上。
|
||||
* 只有一对郑州→吉隆坡时:国内段只写郑州,境外段只写吉隆坡,不发明口岸。
|
||||
* 国内段 / 境外段各写对应 TMS 腿的完整起讫。
|
||||
* <p>
|
||||
* 优先用 selected_land_route.legs(销售已选线路);否则从多条备选里挑一条多腿线路。
|
||||
* 只有一对「境内→境外」且无两段腿时:国内段只写境内点、境外段只写境外点,不发明口岸。
|
||||
* WO202610120001:备选混在 route_rows 时若要求整表同 segmentSequence,会退回切片导致口岸空。
|
||||
*/
|
||||
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
|
||||
int regionIndex, int regionCount) {
|
||||
return routesForLandRouteSection(routes, sheet, region, regionIndex, regionCount, null);
|
||||
}
|
||||
|
||||
static JSONArray routesForLandRouteSection(JSONArray routes, Sheet sheet, JSONObject region,
|
||||
int regionIndex, int regionCount, JSONObject facts) {
|
||||
if (routes == null || routes.isEmpty()) {
|
||||
return routes;
|
||||
}
|
||||
@@ -3107,12 +3127,15 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(kind)) {
|
||||
return routes;
|
||||
}
|
||||
JSONArray byLeg = pickLandRouteLegsForSection(routes, kind);
|
||||
JSONArray preferred = preferredLandRouteLegs(facts, routes);
|
||||
JSONArray byLeg = pickLandRouteLegsForSection(preferred, kind);
|
||||
if (byLeg != null && !byLeg.isEmpty()) {
|
||||
return byLeg;
|
||||
}
|
||||
JSONObject seed = preferred != null && !preferred.isEmpty()
|
||||
? preferred.getJSONObject(0) : routes.getJSONObject(0);
|
||||
JSONArray sliced = new JSONArray();
|
||||
JSONObject one = sliceCrossBorderOd(routes.getJSONObject(0), kind);
|
||||
JSONObject one = sliceCrossBorderOd(seed, kind);
|
||||
if (one != null && (StringUtils.isNotBlank(one.getString("origin"))
|
||||
|| StringUtils.isNotBlank(one.getString("destination")))) {
|
||||
sliced.add(one);
|
||||
@@ -3120,16 +3143,91 @@ public final class QuoteTemplateExcelSupport {
|
||||
return sliced;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单优先腿:已选线路 legs > 同 segmentSequence 且腿数最多的备选 > 原数组。
|
||||
*/
|
||||
static JSONArray preferredLandRouteLegs(JSONObject facts, JSONArray allRoutes) {
|
||||
if (facts != null) {
|
||||
Object rawSel = facts.get("selected_land_route");
|
||||
if (rawSel == null) {
|
||||
rawSel = facts.get("selectedLandRoute");
|
||||
}
|
||||
JSONObject sel = null;
|
||||
if (rawSel instanceof JSONObject) {
|
||||
sel = (JSONObject) rawSel;
|
||||
} else if (rawSel instanceof String && StringUtils.isNotBlank((String) rawSel)) {
|
||||
try {
|
||||
Object parsed = JSON.parse((String) rawSel);
|
||||
if (parsed instanceof JSONObject) {
|
||||
sel = (JSONObject) parsed;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略坏 JSON
|
||||
}
|
||||
} else if (rawSel instanceof java.util.Map) {
|
||||
sel = JSON.parseObject(JSON.toJSONString(rawSel));
|
||||
}
|
||||
if (sel != null) {
|
||||
JSONArray legs = parseFeeRows(sel.get("legs"));
|
||||
if (legs != null && !legs.isEmpty()) {
|
||||
return legs;
|
||||
}
|
||||
}
|
||||
}
|
||||
JSONArray grouped = pickPrimaryLandRouteGroup(allRoutes);
|
||||
return grouped != null && !grouped.isEmpty() ? grouped : allRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多条备选(不同 segmentSequence)时,挑腿数最多的一条完整线路;并列取较小序号。
|
||||
*/
|
||||
static JSONArray pickPrimaryLandRouteGroup(JSONArray routes) {
|
||||
if (routes == null || routes.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
java.util.Map<String, JSONArray> groups = new java.util.LinkedHashMap<>();
|
||||
for (int i = 0; i < routes.size(); i++) {
|
||||
JSONObject row = routes.getJSONObject(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String seq = StringUtils.trimToEmpty(row.getString("segmentSequence"));
|
||||
if (seq.isEmpty()) {
|
||||
seq = "1";
|
||||
}
|
||||
groups.computeIfAbsent(seq, k -> new JSONArray()).add(row);
|
||||
}
|
||||
if (groups.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String bestKey = null;
|
||||
int bestLegs = -1;
|
||||
int bestSeqNum = Integer.MAX_VALUE;
|
||||
for (java.util.Map.Entry<String, JSONArray> e : groups.entrySet()) {
|
||||
int legs = e.getValue() == null ? 0 : e.getValue().size();
|
||||
int seqNum;
|
||||
try {
|
||||
seqNum = Integer.parseInt(e.getKey().trim());
|
||||
} catch (RuntimeException ex) {
|
||||
seqNum = Integer.MAX_VALUE;
|
||||
}
|
||||
if (legs > bestLegs || (legs == bestLegs && seqNum < bestSeqNum)) {
|
||||
bestLegs = legs;
|
||||
bestSeqNum = seqNum;
|
||||
bestKey = e.getKey();
|
||||
}
|
||||
}
|
||||
return bestKey == null ? null : groups.get(bestKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一条线路的多段腿:国内段取首腿完整 OD,境外段取末腿完整 OD。
|
||||
* 不足两腿时返回 null,交给 sliceCrossBorderOd。
|
||||
*/
|
||||
static JSONArray pickLandRouteLegsForSection(JSONArray routes, String kind) {
|
||||
if (routes == null || routes.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
String seq = StringUtils.trimToEmpty(routes.getJSONObject(0).getString("segmentSequence"));
|
||||
for (int i = 1; i < routes.size(); i++) {
|
||||
if (!seq.equals(StringUtils.trimToEmpty(routes.getJSONObject(i).getString("segmentSequence")))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
JSONArray ordered = new JSONArray();
|
||||
ordered.addAll(routes);
|
||||
ordered.sort((a, b) -> {
|
||||
@@ -3140,8 +3238,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
JSONArray out = new JSONArray();
|
||||
if ("domestic".equals(kind)) {
|
||||
out.add(ordered.getJSONObject(0));
|
||||
} else {
|
||||
} else if ("overseas".equals(kind)) {
|
||||
out.add(ordered.getJSONObject(ordered.size() - 1));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -3483,6 +3583,14 @@ public final class QuoteTemplateExcelSupport {
|
||||
* 禁止「无车型时把同一售价灌进所有费种列」。
|
||||
*/
|
||||
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey, int headerCount) {
|
||||
return resolveRouteVehicleRate(rowObj, facts, headerKey, headerCount, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sectionKind domestic / overseas / 空;境外段要把国内标注 3T 对成 3.5T 再写价。
|
||||
*/
|
||||
static String resolveRouteVehicleRate(JSONObject rowObj, JSONObject facts, String headerKey,
|
||||
int headerCount, String sectionKind) {
|
||||
if (StringUtils.isBlank(headerKey)) {
|
||||
return "";
|
||||
}
|
||||
@@ -3517,7 +3625,20 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
// 跨境集拼是分档公斤价(2-500KG=12 …),不是把同一个 price_per_kg 灌进每一列。
|
||||
if (looksLikeKgBracketHeader(key)) {
|
||||
return matchWeightBracketPrice(rowObj, key);
|
||||
String band = matchWeightBracketPrice(rowObj, key);
|
||||
if (StringUtils.isNotBlank(band)) {
|
||||
return band;
|
||||
}
|
||||
// TMS 偶发只给分档壳(min/max)+ 整票 costPrice(计价单位:次),无档内单价。
|
||||
// 禁止灌进所有公斤列;只写入询价重量落入的那一档,避免报价单全空。
|
||||
return flatLandFeeForEmptyWeightBands(rowObj, facts, key);
|
||||
}
|
||||
if (key.contains("最低收费")) {
|
||||
return formatTierPrice(firstNonBlank(rowObj == null ? null : rowObj.getString("min_charge"),
|
||||
rowObj == null ? null : rowObj.getString("minCharge"),
|
||||
rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount")));
|
||||
}
|
||||
if (key.toUpperCase(Locale.ROOT).contains("KG") || key.contains("公斤")) {
|
||||
return firstNonBlank(rowObj == null ? null : rowObj.getString("price_per_kg"),
|
||||
@@ -3527,16 +3648,17 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (key.contains("运输") || key.contains("运费") || key.contains("陆运")) {
|
||||
return firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("land_freight"),
|
||||
facts == null ? null : facts.getString("land_freight"));
|
||||
}
|
||||
// 2) 车型列:仅当 vehicleType 对得上才写售价
|
||||
// 2) 车型列:仅当 vehicleType 对得上才写售价(含跨境国内/国外标注换算)
|
||||
String vehicleType = firstNonBlank(rowObj == null ? null : rowObj.getString("vehicleType"),
|
||||
rowObj == null ? null : rowObj.getString("vehicle_type"));
|
||||
String price = firstNonBlank(rowObj == null ? null : rowObj.getString("salePrice"),
|
||||
rowObj == null ? null : rowObj.getString("costPrice"),
|
||||
rowObj == null ? null : rowObj.getString("amount"));
|
||||
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeEquals(vehicleType, key)) {
|
||||
if (StringUtils.isNotBlank(vehicleType) && vehicleTypeMatchesSection(vehicleType, key, sectionKind)) {
|
||||
return price;
|
||||
}
|
||||
// 3) 仅一列且无车型信息时,允许写入该列(整车单列模板)
|
||||
@@ -3546,6 +3668,65 @@ public final class QuoteTemplateExcelSupport {
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 车型列是否对得上当前段表头。
|
||||
* <p>
|
||||
* 跨境整车:询价/TMS 常写国内标注「3T」,国外段表头是「3.5T」;
|
||||
* 境外段用国内→国外映射后再比,国内段反之。禁止把 3T 误当成所有 T 列。
|
||||
*/
|
||||
static boolean vehicleTypeMatchesSection(String vehicleType, String headerKey, String sectionKind) {
|
||||
if (vehicleTypeEquals(vehicleType, headerKey)) {
|
||||
return true;
|
||||
}
|
||||
String kind = StringUtils.trimToEmpty(sectionKind);
|
||||
if ("overseas".equals(kind)) {
|
||||
String mapped = mapDomesticVehicleToOverseas(vehicleType);
|
||||
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
|
||||
}
|
||||
if ("domestic".equals(kind)) {
|
||||
String mapped = mapOverseasVehicleToDomestic(vehicleType);
|
||||
return StringUtils.isNotBlank(mapped) && vehicleTypeEquals(mapped, headerKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 国内整车标注 → 跨境国外段表头。WO202610080006:3T 对 3.5T。
|
||||
* 未收录的返回空,禁止瞎猜。
|
||||
*/
|
||||
static String mapDomesticVehicleToOverseas(String vehicleType) {
|
||||
String a = normalizeVehicleToken(vehicleType);
|
||||
if (a.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if ("3T".equals(a) || "3吨".equals(a)) {
|
||||
return "3.5T";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 国外段标注 → 国内段表头(单腿 TMS 偶发直接回 3.5T 时,国内段仍能写进 3T)。
|
||||
*/
|
||||
static String mapOverseasVehicleToDomestic(String vehicleType) {
|
||||
String a = normalizeVehicleToken(vehicleType);
|
||||
if (a.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if ("3.5T".equals(a) || "3.5吨".equals(a)) {
|
||||
return "3T";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String normalizeVehicleToken(String vehicleType) {
|
||||
if (StringUtils.isBlank(vehicleType)) {
|
||||
return "";
|
||||
}
|
||||
return vehicleType.replace(" ", "").toUpperCase(Locale.ROOT)
|
||||
.replace("T", "T");
|
||||
}
|
||||
|
||||
/**
|
||||
* 表头是「2-500KG」「5001KG以上」这类分档,不是「元/KG」单价列。
|
||||
*/
|
||||
@@ -3555,6 +3736,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
|
||||
/**
|
||||
* 用 landSegments.weightPrices 对上分档表头。对不上就空着,禁止把一档价抄进所有列。
|
||||
* <p>
|
||||
* 档内单价优先读 price,兼容 TMS 偶发的 unitPrice/salePrice/costPrice/amount。
|
||||
*/
|
||||
static String matchWeightBracketPrice(JSONObject rowObj, String headerKey) {
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
@@ -3571,12 +3754,122 @@ public final class QuoteTemplateExcelSupport {
|
||||
boolean open = tier.get("max") == null || StringUtils.isBlank(String.valueOf(tier.get("max")));
|
||||
int max = open ? -1 : intOf(tier.get("max"));
|
||||
if (min == range[0] && max == range[1]) {
|
||||
return formatTierPrice(tier.get("price"));
|
||||
return formatTierPrice(tierPriceRaw(tier));
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 分档壳有区间但没有任何档内单价时:把整票 salePrice/costPrice 写入询价重量所在那一档。
|
||||
* <p>
|
||||
* 为何:WO202610080043 跨境集拼 TMS 回 weightPrices 只有 min/max,费用是「次」18200;
|
||||
* 若所有公斤列都空着,销售会以为报价没写入。有真实档内单价时禁止走这条。
|
||||
* 副作用:只读 row/facts,不改库。
|
||||
*/
|
||||
static String flatLandFeeForEmptyWeightBands(JSONObject rowObj, JSONObject facts, String headerKey) {
|
||||
int[] range = parseKgBracket(headerKey);
|
||||
if (range == null || rowObj == null) {
|
||||
return "";
|
||||
}
|
||||
JSONArray tiers = weightPricesOf(rowObj);
|
||||
if (tiers == null || tiers.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (anyWeightTierHasPrice(tiers)) {
|
||||
return "";
|
||||
}
|
||||
String flat = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"),
|
||||
rowObj.getString("amount"));
|
||||
if (StringUtils.isBlank(flat) || "-".equals(flat)) {
|
||||
return "";
|
||||
}
|
||||
double weight = inquiryWeightKg(rowObj, facts);
|
||||
boolean hit;
|
||||
if (weight >= 0) {
|
||||
hit = weightInKgBracket(weight, range[0], range[1]);
|
||||
} else {
|
||||
// 无询价重量:只写入 weightPrices 里的第一档,避免抄进所有列
|
||||
JSONObject first = tiers.getJSONObject(0);
|
||||
int min = first == null ? -1 : intOf(first.get("min"));
|
||||
boolean open = first == null || first.get("max") == null
|
||||
|| StringUtils.isBlank(String.valueOf(first.get("max")));
|
||||
int max = open ? -1 : intOf(first.get("max"));
|
||||
hit = min == range[0] && max == range[1];
|
||||
}
|
||||
return hit ? formatTierPrice(flat) : "";
|
||||
}
|
||||
|
||||
/** 档内是否已有任何可用单价(有则禁止整票价回填)。 */
|
||||
static boolean anyWeightTierHasPrice(JSONArray tiers) {
|
||||
if (tiers == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < tiers.size(); i++) {
|
||||
JSONObject tier = tiers.getJSONObject(i);
|
||||
if (tier != null && StringUtils.isNotBlank(formatTierPrice(tierPriceRaw(tier)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** TMS 档内单价字段名不统一,按常见键取第一个非空。 */
|
||||
static Object tierPriceRaw(JSONObject tier) {
|
||||
if (tier == null) {
|
||||
return null;
|
||||
}
|
||||
Object raw = tier.get("price");
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("unitPrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("salePrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("costPrice");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("amount");
|
||||
}
|
||||
if (raw == null || StringUtils.isBlank(String.valueOf(raw)) || "null".equalsIgnoreCase(String.valueOf(raw))) {
|
||||
raw = tier.get("pricePerKg");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** 询价毛重(KG);没有或解析失败返回 -1。 */
|
||||
static double inquiryWeightKg(JSONObject rowObj, JSONObject facts) {
|
||||
String raw = firstNonBlank(
|
||||
rowObj == null ? null : rowObj.getString("weight_kg"),
|
||||
rowObj == null ? null : rowObj.getString("weightKg"),
|
||||
facts == null ? null : facts.getString("weight_kg"),
|
||||
facts == null ? null : facts.getString("weightKg"),
|
||||
facts == null ? null : facts.getString("毛重"),
|
||||
facts == null ? null : facts.getString("重量"),
|
||||
facts == null ? null : facts.getString("weight"));
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw)) {
|
||||
return -1;
|
||||
}
|
||||
String num = raw.trim().replace(",", "").replaceAll("[Kk][Gg].*$", "").trim();
|
||||
try {
|
||||
return new java.math.BigDecimal(num).doubleValue();
|
||||
} catch (RuntimeException ex) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重量是否落在 [min, max];max=-1 表示无上限。 */
|
||||
static boolean weightInKgBracket(double weight, int min, int max) {
|
||||
if (weight < 0 || min < 0) {
|
||||
return false;
|
||||
}
|
||||
if (weight < min) {
|
||||
return false;
|
||||
}
|
||||
return max < 0 || weight <= max;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化 route_rows 会丢掉 weightPrices。出单前从原始 landSegments 补回。
|
||||
*/
|
||||
|
||||
+195
-1
@@ -1480,6 +1480,81 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080043:TMS weightPrices 只有 min/max、整票 costPrice=18200(计价单位:次)。
|
||||
* 询价毛重 2KG 落入 2-500KG 档时写入 18200;其它公斤列保持空;有真实档内单价时不走此回退。
|
||||
*/
|
||||
@Test
|
||||
public void landGroupageEmptyWeightBandsWritesFlatFeeIntoCargoBandOnly() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-groupage-flat", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-groupage-flat-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境集拼");
|
||||
org.apache.poi.ss.usermodel.Row matrix = row(sheet, 7);
|
||||
matrix.createCell(1).setCellValue("始发地");
|
||||
matrix.createCell(2).setCellValue("目的地");
|
||||
matrix.createCell(3).setCellValue("最低收费");
|
||||
matrix.createCell(4).setCellValue("2-500KG");
|
||||
matrix.createCell(5).setCellValue("501-1000KG");
|
||||
matrix.createCell(6).setCellValue("1001-3000KG");
|
||||
matrix.createCell(7).setCellValue("3001-5000KG");
|
||||
matrix.createCell(8).setCellValue("5001KG以上");
|
||||
row(sheet, 9);
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject route = new JSONObject(true);
|
||||
route.put("origin", "凉山-春疆货场");
|
||||
route.put("destination", "湖北省-武汉市");
|
||||
route.put("currency", "CNY");
|
||||
route.put("costPrice", "18200.00");
|
||||
route.put("amount", "18200.00");
|
||||
route.put("weightPrices", JSONArray.parseArray(
|
||||
"[{\"min\":2,\"max\":500},{\"min\":501,\"max\":1000},{\"min\":1001,\"max\":3000},"
|
||||
+ "{\"min\":3001,\"max\":5000},{\"min\":5001}]"));
|
||||
JSONArray routeRows = new JSONArray();
|
||||
routeRows.add(route);
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境集拼");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject region = new JSONObject(true);
|
||||
region.put("headerRow", 8);
|
||||
region.put("dataStartRow", 10);
|
||||
JSONObject columns = new JSONObject(true);
|
||||
columns.put("origin", "B");
|
||||
columns.put("destination", "C");
|
||||
JSONObject vehicleRates = new JSONObject(true);
|
||||
vehicleRates.put("最低收费", "D");
|
||||
vehicleRates.put("2-500KG", "E");
|
||||
vehicleRates.put("501-1000KG", "F");
|
||||
vehicleRates.put("1001-3000KG", "G");
|
||||
vehicleRates.put("3001-5000KG", "H");
|
||||
vehicleRates.put("5001KG以上", "I");
|
||||
columns.put("vehicleRates", vehicleRates);
|
||||
region.put("columns", columns);
|
||||
regions.add(region);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("毛重", "2");
|
||||
facts.put("route_rows", routeRows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("凉山-春疆货场", read(sheet, 9, 1));
|
||||
assertEquals("湖北省-武汉市", read(sheet, 9, 2));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 3)));
|
||||
assertEquals("18200", normalizeMoney(read(sheet, 9, 4)));
|
||||
assertEquals("", read(sheet, 9, 5));
|
||||
assertEquals("", read(sheet, 9, 6));
|
||||
assertEquals("", read(sheet, 9, 7));
|
||||
assertEquals("", read(sheet, 9, 8));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销售没填客户名称时,客户名称格保持空白,不能写成业务员。
|
||||
* 询价条带的时效写在表头下一行;公斤分档价按 weightPrices 落入各列。
|
||||
@@ -1878,6 +1953,7 @@ public class QuoteTemplateFillTest {
|
||||
|
||||
/**
|
||||
* 跨境整车分段切片后仍须带上车型与运价;尺寸副表头行不能被 4000 盖掉,运价写在下一行。
|
||||
* WO006:单腿 vehicleType=3T 时,国内写 3T 列,国外段换算成 3.5T 再写,不能留空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlKeepsVehiclePriceAfterOdSlice() throws Exception {
|
||||
@@ -1900,9 +1976,11 @@ public class QuoteTemplateFillTest {
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("1.25T");
|
||||
head2.createCell(4).setCellValue("2.5T");
|
||||
head2.createCell(5).setCellValue("3.5T");
|
||||
org.apache.poi.ss.usermodel.Row dim2 = row(sheet, 27);
|
||||
dim2.createCell(3).setCellValue("2.8x1.5x1.6M");
|
||||
dim2.createCell(4).setCellValue("3.50x1.6x1.6M");
|
||||
dim2.createCell(5).setCellValue("4.2x1.8x1.8M");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
@@ -1933,6 +2011,7 @@ public class QuoteTemplateFillTest {
|
||||
JSONObject rates2 = new JSONObject(true);
|
||||
rates2.put("1.25T", "D");
|
||||
rates2.put("2.5T", "E");
|
||||
rates2.put("3.5T", "F");
|
||||
cols2.put("vehicleRates", rates2);
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
@@ -1950,12 +2029,14 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("4.1*2.1*2.3M", read(sheet, 8, 3));
|
||||
assertEquals("广东省-深圳市", read(sheet, 9, 1));
|
||||
assertEquals("", read(sheet, 9, 2));
|
||||
assertEquals("4000.00", read(sheet, 9, 3));
|
||||
assertEquals("4000", normalizeMoney(read(sheet, 9, 3)));
|
||||
assertEquals("", read(sheet, 9, 4));
|
||||
assertEquals("2.8x1.5x1.6M", read(sheet, 27, 3));
|
||||
assertEquals("", read(sheet, 28, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 28, 2));
|
||||
assertEquals("", read(sheet, 28, 3));
|
||||
assertEquals("", read(sheet, 28, 4));
|
||||
assertEquals("4000", normalizeMoney(read(sheet, 28, 5)));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
@@ -2080,6 +2161,107 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610120001:route_rows 混有多条备选(不同 segmentSequence),国内/境外段应写完整两段 OD,
|
||||
* 不能因「整表必须同序列」退回切片导致口岸列空。
|
||||
*/
|
||||
@Test
|
||||
public void landCrossBorderFtlFillsFullOdFromMultiAltRouteRows() throws Exception {
|
||||
Path src = Files.createTempFile("quote-land-cb-wo001-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-land-cb-wo001-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("跨境整车");
|
||||
row(sheet, 5).createCell(0).setCellValue("一、国内段报价");
|
||||
org.apache.poi.ss.usermodel.Row head1 = row(sheet, 7);
|
||||
head1.createCell(1).setCellValue("起始地");
|
||||
head1.createCell(2).setCellValue("目的地");
|
||||
head1.createCell(3).setCellValue("3T");
|
||||
row(sheet, 24).createCell(0).setCellValue("二、境外段报价");
|
||||
org.apache.poi.ss.usermodel.Row head2 = row(sheet, 26);
|
||||
head2.createCell(1).setCellValue("起始地");
|
||||
head2.createCell(2).setCellValue("目的地");
|
||||
head2.createCell(3).setCellValue("3.5T");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject(true);
|
||||
mapping.put("sheet", "跨境整车");
|
||||
JSONArray regions = new JSONArray();
|
||||
JSONObject inland = new JSONObject(true);
|
||||
inland.put("title", "国内段报价");
|
||||
inland.put("headerRow", 8);
|
||||
inland.put("dataStartRow", 8);
|
||||
JSONObject cols1 = new JSONObject(true);
|
||||
cols1.put("origin", "B");
|
||||
cols1.put("destination", "C");
|
||||
cols1.put("3T", "D");
|
||||
inland.put("columns", cols1);
|
||||
regions.add(inland);
|
||||
JSONObject overseas = new JSONObject(true);
|
||||
overseas.put("title", "境外段报价");
|
||||
overseas.put("headerRow", 27);
|
||||
overseas.put("dataStartRow", 27);
|
||||
JSONObject cols2 = new JSONObject(true);
|
||||
cols2.put("origin", "B");
|
||||
cols2.put("destination", "C");
|
||||
cols2.put("3.5T", "D");
|
||||
overseas.put("columns", cols2);
|
||||
regions.add(overseas);
|
||||
mapping.put("routeRegions", regions);
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("origin", "上海");
|
||||
facts.put("destination", "胡志明");
|
||||
JSONArray rows = new JSONArray();
|
||||
JSONObject a1 = new JSONObject(true);
|
||||
a1.put("segmentSequence", "1");
|
||||
a1.put("routeLegSequence", "1");
|
||||
a1.put("origin", "上海");
|
||||
a1.put("destination", "谅山-春疆货场");
|
||||
a1.put("vehicleType", "3T");
|
||||
a1.put("costPrice", "7500");
|
||||
a1.put("currency", "CNY");
|
||||
rows.add(a1);
|
||||
JSONObject a2 = new JSONObject(true);
|
||||
a2.put("segmentSequence", "1");
|
||||
a2.put("routeLegSequence", "2");
|
||||
a2.put("origin", "谅山-春疆货场");
|
||||
a2.put("destination", "胡志明");
|
||||
a2.put("vehicleType", "3.5T");
|
||||
a2.put("costPrice", "9600");
|
||||
a2.put("currency", "CNY");
|
||||
rows.add(a2);
|
||||
JSONObject b1 = new JSONObject(true);
|
||||
b1.put("segmentSequence", "2");
|
||||
b1.put("routeLegSequence", "1");
|
||||
b1.put("origin", "上海");
|
||||
b1.put("destination", "凭祥友谊关口岸");
|
||||
b1.put("vehicleType", "3T");
|
||||
b1.put("costPrice", "7000");
|
||||
rows.add(b1);
|
||||
JSONObject b2 = new JSONObject(true);
|
||||
b2.put("segmentSequence", "2");
|
||||
b2.put("routeLegSequence", "2");
|
||||
b2.put("origin", "凭祥友谊关口岸");
|
||||
b2.put("destination", "胡志明");
|
||||
b2.put("vehicleType", "3T");
|
||||
b2.put("costPrice", "9000");
|
||||
rows.add(b2);
|
||||
facts.put("route_rows", rows);
|
||||
facts.put("landSegments", rows);
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook filled = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
Sheet sheet = filled.getSheetAt(0);
|
||||
assertEquals("上海", read(sheet, 8, 1));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 8, 2));
|
||||
assertEquals("谅山-春疆货场", read(sheet, 27, 1));
|
||||
assertEquals("胡志明", read(sheet, 27, 2));
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WO202610080006:跨境整车上传版表头是「时效 | 币种(错行) | 燃油 | 税率 | 含税合计」。
|
||||
* 起始地不能被费用名「运输费」盖掉;CNY 只能进币种列;燃油附加费率要写到燃油列。
|
||||
@@ -2620,6 +2802,18 @@ public class QuoteTemplateFillTest {
|
||||
return cell.getStringCellValue();
|
||||
}
|
||||
|
||||
/** 金额格可能是数字 18200.0 或文本 18200.00,统一成去尾零纯数字再比。 */
|
||||
private static String normalizeMoney(String raw) {
|
||||
if (raw == null || raw.isBlank() || "-".equals(raw.trim())) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return new java.math.BigDecimal(raw.trim()).stripTrailingZeros().toPlainString();
|
||||
} catch (RuntimeException ex) {
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运运价表:燃油附加费率、税率必须写到表头同列的数据行。
|
||||
* rate=0.09→9%;rateFlag 是是否含税,不写进税率格。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 询价后台列表分页条:总数 + 每页条数 + 页码。
|
||||
* 询价后台列表分页条:整组靠右,总数紧挨在「每页」左边,页码在后。
|
||||
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
@@ -87,13 +87,13 @@ function onSizeChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<div class="pagination admin-pagination">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<div class="admin-pagination__right">
|
||||
<span class="admin-pagination__total">
|
||||
共 <b>{{ total }}</b> {{ unit }}
|
||||
<span v-if="total > 0" class="admin-pagination__range">
|
||||
(第 {{ pageNo }} / {{ totalPages }} 页)
|
||||
</span>
|
||||
</span>
|
||||
<label class="admin-pagination__size">
|
||||
每页
|
||||
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
|
||||
@@ -127,6 +127,11 @@ function onSizeChange(e: Event) {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-pagination__total {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-pagination__size {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 分析看板 API。
|
||||
* 显式传 dateFrom/dateTo,与工单列表日期筛选同源;range 仅作无日期时的回退。
|
||||
*/
|
||||
import { defHttp } from '/@/utils/http/axios';
|
||||
|
||||
@@ -7,5 +8,20 @@ enum Api {
|
||||
metrics = '/inquiry/dashboard/metrics',
|
||||
}
|
||||
|
||||
export const getDashboardMetrics = (range = 'today') =>
|
||||
defHttp.get({ url: Api.metrics, params: { range } });
|
||||
export type DashboardMetricsParams = {
|
||||
range?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
};
|
||||
|
||||
export const getDashboardMetrics = (params: DashboardMetricsParams | string = 'today') => {
|
||||
const query =
|
||||
typeof params === 'string'
|
||||
? { range: params }
|
||||
: {
|
||||
range: params.range || 'today',
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
};
|
||||
return defHttp.get({ url: Api.metrics, params: query });
|
||||
};
|
||||
|
||||
@@ -56,11 +56,15 @@ const activePoint = ref<string | null>(null);
|
||||
const INQUIRY_PLOT_TOP = 36;
|
||||
const INQUIRY_PLOT_BOTTOM = 188;
|
||||
|
||||
/**
|
||||
* 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。
|
||||
* 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。
|
||||
*/
|
||||
function resolveRange(): string {
|
||||
const from = new Date(dateFrom.value);
|
||||
const to = new Date(dateTo.value);
|
||||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||||
if (days <= 0) return "today";
|
||||
if (days <= 0) return "1d";
|
||||
if (days <= 6) return "7d";
|
||||
return "30d";
|
||||
}
|
||||
@@ -88,7 +92,12 @@ function onDateRangeChange() {
|
||||
async function loadMetrics() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await getDashboardMetrics(resolveRange());
|
||||
const req = {
|
||||
range: resolveRange(),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value,
|
||||
};
|
||||
const res: any = await getDashboardMetrics(req);
|
||||
const data = res?.result || res || {};
|
||||
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
||||
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
||||
|
||||
@@ -918,6 +918,10 @@ body {
|
||||
color: #155eef !important;
|
||||
}
|
||||
|
||||
.pagination.admin-pagination {
|
||||
justify-content: flex-end !important;
|
||||
}
|
||||
|
||||
.pagination .pages {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -830,7 +830,7 @@ async function downloadPreviewOrOriginal() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<div class="pagination admin-pagination">
|
||||
<span>
|
||||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user