Compare commits

..
22 changed files with 1002 additions and 100 deletions
@@ -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()
+92 -29
View File
@@ -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,
+10 -3
View File
@@ -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 另报。
+17 -1
View File
@@ -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)
+97 -2
View File
@@ -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
+5
View File
@@ -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):
@@ -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));
}
}
@@ -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);
}
@@ -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。
@@ -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);
@@ -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 &gt; 同 segmentSequence 且腿数最多的备选 &gt; 原数组。
*/
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 补回。
*/
@@ -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>