Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f8c373121 |
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
陆运选线路 H5 页。
|
陆运选线路 H5 页。
|
||||||
|
|
||||||
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
|
本文件职责:按完整线路列出各段车型、成本与合计,提交所选下标。
|
||||||
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
|
样式对齐空运选线路页。禁止 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:
|
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
|
||||||
"""一张线路:航线、各段路段+TMS 费目、合计。"""
|
"""一张线路:航线、各段路段、车型、TMS 费目、合计。"""
|
||||||
line = html.escape(route_line(route))
|
line = html.escape(route_line(route))
|
||||||
blocks = land_leg_fee_blocks_for_route(quote, 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)}
|
etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)}
|
||||||
fee_bits: list[str] = []
|
fee_bits: list[str] = []
|
||||||
items = ""
|
items = ""
|
||||||
if blocks:
|
if blocks:
|
||||||
for i, (title, fees, eta) in enumerate(blocks, 1):
|
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 += (
|
items += (
|
||||||
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
|
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
|
||||||
"<span></span></li>"
|
"<span></span></li>"
|
||||||
)
|
)
|
||||||
|
items += (
|
||||||
|
f"<li><span class='n'>车型 {html.escape(vehicle)}</span>"
|
||||||
|
"<span></span></li>"
|
||||||
|
)
|
||||||
for fee in fees:
|
for fee in fees:
|
||||||
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
|
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
|
||||||
name = str(fee.get("name") or "").strip()
|
name = str(fee.get("name") or "").strip()
|
||||||
|
|||||||
@@ -961,32 +961,7 @@ def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _default_product_fee_name(business_line: str = "", transport_mode: str = "") -> str:
|
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||||
"""
|
|
||||||
产品文字未点费用名时的默认费目。
|
|
||||||
|
|
||||||
必须跟本单运输方式走:陆运→陆运费、空运→空运费、其余→海运费。
|
|
||||||
禁止陆运单无费名时硬编码「海运费」(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 = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
||||||
|
|
||||||
@@ -995,7 +970,6 @@ def extract_product_quote(
|
|||||||
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
「空运费 单价…金额…」必须保留空运费,金额用「金额」后面的数,不用单价。
|
||||||
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
陆运集拼:「2-500KG 单价CNY990,燃油附加费率1%,税率6%」走分档单价+费率,
|
||||||
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
禁止把公斤档下限「2」当成燃油附加费金额(WO021)。
|
||||||
无费用名时按 business_line/transport_mode 默认陆运费/空运费/海运费,禁止陆运硬套海运费。
|
|
||||||
"""
|
"""
|
||||||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||||||
out = dict(injected)
|
out = dict(injected)
|
||||||
@@ -1041,19 +1015,15 @@ def extract_product_quote(
|
|||||||
if not amount:
|
if not amount:
|
||||||
return {}
|
return {}
|
||||||
ccy = _currency_of(raw)
|
ccy = _currency_of(raw)
|
||||||
default_fee = _default_product_fee_name(business_line, transport_mode)
|
|
||||||
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
# 句里已经点了费用名(空运费)时,不能退回默认海运费。
|
||||||
named = re.search(
|
named = re.search(
|
||||||
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
r"([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费)",
|
||||||
scrubbed or raw,
|
scrubbed or raw,
|
||||||
)
|
)
|
||||||
fee_name = (named.group(1) if named else "") or default_fee
|
fee_name = (named.group(1) if named else "") or "海运费"
|
||||||
if fee_name == "费用":
|
if fee_name in {"费用", "燃油附加费"}:
|
||||||
# 句里只有「费用」没有运输方式前缀时,跟本单默认运费。
|
# 「燃油附加费率」里的「燃油附加费」不是费用行名;无其它费名时用运费。
|
||||||
fee_name = default_fee
|
fee_name = "海运费" if fee_name == "费用" else "陆运费"
|
||||||
elif fee_name == "燃油附加费":
|
|
||||||
# 「燃油附加费率」误抽成费名时:陆运/空运用本单默认;海运旧行为落陆运费,避免当海运费金额。
|
|
||||||
fee_name = default_fee if default_fee != "海运费" else "陆运费"
|
|
||||||
out = {
|
out = {
|
||||||
"total": amount,
|
"total": amount,
|
||||||
"currency": ccy,
|
"currency": ccy,
|
||||||
|
|||||||
@@ -629,9 +629,7 @@ def apply_air_quote(
|
|||||||
fields = extract_air_collab_fields(text)
|
fields = extract_air_collab_fields(text)
|
||||||
if fields:
|
if fields:
|
||||||
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
|
||||||
quote = injected_quote or extract_product_quote(
|
quote = injected_quote or extract_product_quote(text) or {}
|
||||||
text, business_line="AIR", transport_mode="空运"
|
|
||||||
) or {}
|
|
||||||
if not quote:
|
if not quote:
|
||||||
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
send_group_text(client, chat_id, copy.air_quote_not_recognized())
|
||||||
return "quote_empty"
|
return "quote_empty"
|
||||||
|
|||||||
@@ -2624,6 +2624,20 @@ def group_tms_confirm_hint(*, business_line: str = "") -> str:
|
|||||||
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
|
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:
|
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
|
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
|
||||||
|
|||||||
@@ -152,15 +152,7 @@ def handle_multi_inbound(
|
|||||||
ticket = _reload(flow, no) or ticket
|
ticket = _reload(flow, no) or ticket
|
||||||
segments = load_segments(ticket)
|
segments = load_segments(ticket)
|
||||||
target = next((seg for seg in segments if seg.mode == mode), target)
|
target = next((seg for seg in segments if seg.mode == mode), target)
|
||||||
quote = extract_product_quote(
|
quote = extract_product_quote(text or "", injected=injected_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 "海运")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if not quote:
|
if not quote:
|
||||||
if fields:
|
if fields:
|
||||||
return "multi_collab_saved"
|
return "multi_collab_saved"
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ def group_brief(
|
|||||||
|
|
||||||
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
|
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
|
||||||
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
|
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
|
||||||
|
每次拼这段字段都在底部加另报提示;成交跟进是另一条消息,不加。
|
||||||
"""
|
"""
|
||||||
_ = work_order_no
|
_ = work_order_no
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
@@ -406,6 +407,8 @@ def group_brief(
|
|||||||
body = _segment_inquiry_block(seg).strip()
|
body = _segment_inquiry_block(seg).strip()
|
||||||
if body:
|
if body:
|
||||||
lines.extend(body.splitlines())
|
lines.extend(body.splitlines())
|
||||||
|
# 紧挨最后一行字段,中间不空行。含空运与不含空运用不同开头。
|
||||||
|
lines.append(copy.multi_group_field_hint(includes_air=includes_air(segments)))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -929,13 +929,7 @@ def apply_product_quote(
|
|||||||
merged = dict(sess.collab_facts)
|
merged = dict(sess.collab_facts)
|
||||||
merged.update(fields)
|
merged.update(fields)
|
||||||
sess.collab_facts = merged
|
sess.collab_facts = merged
|
||||||
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
|
quote = extract_product_quote(text, injected=injected_quote)
|
||||||
quote = extract_product_quote(
|
|
||||||
text,
|
|
||||||
injected=injected_quote,
|
|
||||||
business_line=line,
|
|
||||||
transport_mode="陆运" if line == "LAND" else ("海运" if line == "SEA" else ""),
|
|
||||||
)
|
|
||||||
if not quote:
|
if not quote:
|
||||||
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
logger.info("产品报价未识别 wo=%s text=%s", no, (text or "")[:200])
|
||||||
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
send_group_text(client, chat_id, copy.group_quote_not_recognized())
|
||||||
@@ -947,9 +941,7 @@ def apply_product_quote(
|
|||||||
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
send_group_text(client, chat_id, copy.group_ask_tms_or_product())
|
||||||
return "wait_tms_choice"
|
return "wait_tms_choice"
|
||||||
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
# 分档单价是完整人工价(含费率),禁止和旧错误行(如燃油附加费=2)相加。
|
||||||
# 旧错默认「海运费」的陆运产品价:重报时按本单默认运费对齐后再盖,避免海运费+陆运费并排。
|
|
||||||
if _quote_fee_rows(base) and not (quote.get("weightPrices") or []):
|
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)
|
quote = merge_quote_fees(base, quote)
|
||||||
_save_product_quote(flow, no, quote, sess)
|
_save_product_quote(flow, no, quote, sess)
|
||||||
ticket2 = _reload(flow, chat_id, no) or ticket
|
ticket2 = _reload(flow, chat_id, no) or ticket
|
||||||
@@ -1635,45 +1627,6 @@ def _merged_total(
|
|||||||
return str(src.get("total") or ""), header_ccy
|
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]:
|
def merge_quote_fees(base: dict[str, Any] | None, overlay: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
产品按行改价盖到原 TMS 明细上。没点名的费用原样保留。
|
||||||
|
|||||||
@@ -360,42 +360,6 @@ 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_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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -433,6 +433,41 @@ class LandRouteOptionsTests(unittest.TestCase):
|
|||||||
self.assertIn("CNY 7500", page)
|
self.assertIn("CNY 7500", page)
|
||||||
self.assertIn("成本价合计", 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:
|
def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None:
|
||||||
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
|
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
|
||||||
from agent.channel.h5.land_route_page import render_land_route_page
|
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.assertEqual(phase, "multi_deal_sent")
|
||||||
self.assertIn("120", self.client.texts[-1])
|
self.assertIn("120", self.client.texts[-1])
|
||||||
|
self.assertNotIn("纯文字", self.client.texts[-1])
|
||||||
self.assertNotIn("100", deal_text(
|
self.assertNotIn("100", deal_text(
|
||||||
work_order_no=self.ticket.work_order_no,
|
work_order_no=self.ticket.work_order_no,
|
||||||
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
|
||||||
@@ -198,6 +199,8 @@ class QuoteTests(unittest.TestCase):
|
|||||||
brief = self.client.texts[-1]
|
brief = self.client.texts[-1]
|
||||||
self.assertIn("段1:海运", brief)
|
self.assertIn("段1:海运", brief)
|
||||||
self.assertIn("段2:陆运", brief)
|
self.assertIn("段2:陆运", brief)
|
||||||
|
self.assertIn("产品人员可通过“纯文字”告知新报价", brief)
|
||||||
|
self.assertNotIn("航线或产品人员", brief)
|
||||||
self.assertNotIn("TMS报价", brief)
|
self.assertNotIn("TMS报价", brief)
|
||||||
self.assertNotIn("其他段报完", brief)
|
self.assertNotIn("其他段报完", brief)
|
||||||
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
|
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("段2:空运", brief)
|
||||||
self.assertIn("目的地(必填):CRK", brief)
|
self.assertIn("目的地(必填):CRK", brief)
|
||||||
self.assertIn("贸易条款(非必填):DAP", brief)
|
self.assertIn("贸易条款(非必填):DAP", brief)
|
||||||
|
self.assertTrue(brief.endswith("(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"))
|
||||||
|
self.assertIn("航线或产品人员可通过“纯文字”告知新报价", brief)
|
||||||
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
|
||||||
self.assertNotIn("询价字段:", brief)
|
self.assertNotIn("询价字段:", brief)
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ class SplitTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("@吴季林@林绵助\n段1:", text)
|
self.assertIn("@吴季林@林绵助\n段1:", text)
|
||||||
self.assertNotIn("@林绵助\n\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:
|
def test_missing_fields_use_single_chat_examples(self) -> None:
|
||||||
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
|
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
|
||||||
@@ -694,6 +698,11 @@ class PullTests(unittest.TestCase):
|
|||||||
segments=flow._segments(flow.session_of("sales")),
|
segments=flow._segments(flow.session_of("sales")),
|
||||||
)
|
)
|
||||||
self.assertNotIn("TMS", brief_text)
|
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):
|
class CardPullTests(unittest.TestCase):
|
||||||
|
|||||||
+2
-5
@@ -27,10 +27,7 @@ public class InquiryDashboardController {
|
|||||||
|
|
||||||
@ApiOperation("看板指标")
|
@ApiOperation("看板指标")
|
||||||
@GetMapping("/metrics")
|
@GetMapping("/metrics")
|
||||||
public Result<Map<String, Object>> metrics(
|
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
|
||||||
@RequestParam(name = "range", defaultValue = "today") String range,
|
return Result.OK(dashboardService.metrics(range));
|
||||||
@RequestParam(name = "dateFrom", required = false) String dateFrom,
|
|
||||||
@RequestParam(name = "dateTo", required = false) String dateTo) {
|
|
||||||
return Result.OK(dashboardService.metrics(range, dateFrom, dateTo));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-7
@@ -9,13 +9,9 @@ public interface IInquiryDashboardService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
* 返回看板指标:metrics + trend + rankings + costDates/costRoutes。
|
||||||
* rankings 与工单列表同套发起人解析;costRoutes 为选定周期内线路均价(主账工单投影)。
|
* rankings 与工单列表同套发起人解析;costRoutes 为近 7 日线路均价(主账工单投影)。
|
||||||
* <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, String dateFrom, String dateTo);
|
Map<String, Object> metrics(String range);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-47
@@ -78,14 +78,19 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
private InquiryQuoteMapper quoteMapper;
|
private InquiryQuoteMapper quoteMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Object> metrics(String range, String dateFromStr, String dateToStr) {
|
public Map<String, Object> metrics(String range) {
|
||||||
LocalDate[] bounds = resolveMetricBounds(range, dateFromStr, dateToStr);
|
LocalDate today = LocalDate.now(ZONE);
|
||||||
LocalDate fromDate = bounds[0];
|
LocalDate fromDate;
|
||||||
LocalDate toDate = bounds[1];
|
if ("7d".equalsIgnoreCase(range)) {
|
||||||
|
fromDate = today.minusDays(6);
|
||||||
|
} else if ("30d".equalsIgnoreCase(range)) {
|
||||||
|
fromDate = today.minusDays(29);
|
||||||
|
} else {
|
||||||
|
fromDate = today;
|
||||||
|
}
|
||||||
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
Date from = Date.from(fromDate.atStartOfDay(ZONE).toInstant());
|
||||||
Date to = Date.from(LocalDateTime.of(toDate, LocalTime.MAX).atZone(ZONE).toInstant());
|
Date to = Date.from(LocalDateTime.of(today, LocalTime.MAX).atZone(ZONE).toInstant());
|
||||||
|
|
||||||
// 指标 / 排名 / 趋势 / 成本:同一批工单、同一日期窗,与工单列表 dateFrom~dateTo 对齐
|
|
||||||
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
LambdaQueryWrapper<InquiryTicket> qw = new LambdaQueryWrapper<InquiryTicket>()
|
||||||
.ge(InquiryTicket::getCreateTime, from)
|
.ge(InquiryTicket::getCreateTime, from)
|
||||||
.le(InquiryTicket::getCreateTime, to);
|
.le(InquiryTicket::getCreateTime, to);
|
||||||
@@ -122,24 +127,28 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
metricCards.add(card("avgResp", "内部报价平均响应",
|
metricCards.add(card("avgResp", "内部报价平均响应",
|
||||||
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
"AVG(首次有效回复时间 − 分发时间)", avgResp, true));
|
||||||
|
|
||||||
// 趋势 / 成本:按选定起止日逐日铺点(单日则一点;长周期最多 62 天防图爆炸)
|
// 近 7 日趋势 + 成本:一次拉周内工单,避免 7 次 count
|
||||||
List<String> trendDates = new ArrayList<>();
|
Date weekFrom = Date.from(today.minusDays(6).atStartOfDay(ZONE).toInstant());
|
||||||
List<Map<String, Object>> trend = new ArrayList<>();
|
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);
|
||||||
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
Map<LocalDate, Integer> dayIndex = new HashMap<>();
|
||||||
LocalDate cursor = fromDate;
|
for (int i = 6; i >= 0; i--) {
|
||||||
int dayGuard = 0;
|
LocalDate d = today.minusDays(i);
|
||||||
while (!cursor.isAfter(toDate) && dayGuard < 62) {
|
dayIndex.put(d, trendDates.size());
|
||||||
dayIndex.put(cursor, trendDates.size());
|
String label = d.format(DAY_FMT);
|
||||||
String label = cursor.format(DAY_FMT);
|
|
||||||
trendDates.add(label);
|
trendDates.add(label);
|
||||||
Map<String, Object> p = new HashMap<>();
|
Map<String, Object> p = new HashMap<>();
|
||||||
p.put("date", label);
|
p.put("date", label);
|
||||||
p.put("value", 0L);
|
p.put("value", 0L);
|
||||||
trend.add(p);
|
trend.add(p);
|
||||||
cursor = cursor.plusDays(1);
|
|
||||||
dayGuard++;
|
|
||||||
}
|
}
|
||||||
for (InquiryTicket t : tickets) {
|
for (InquiryTicket t : weekTickets) {
|
||||||
LocalDate d = toLocalDate(t.getCreateTime());
|
LocalDate d = toLocalDate(t.getCreateTime());
|
||||||
Integer idx = d == null ? null : dayIndex.get(d);
|
Integer idx = d == null ? null : dayIndex.get(d);
|
||||||
if (idx == null) {
|
if (idx == null) {
|
||||||
@@ -165,12 +174,10 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
List<Map<String, Object>> rankings = buildRankings(tickets, staffByWecom, staffByName);
|
||||||
List<Map<String, Object>> costRoutes = buildCostRoutes(tickets, dayIndex, trendDates);
|
List<Map<String, Object>> costRoutes = buildCostRoutes(weekTickets, dayIndex, trendDates);
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
result.put("range", range == null ? "today" : range);
|
result.put("range", range == null ? "today" : range);
|
||||||
result.put("dateFrom", fromDate.toString());
|
|
||||||
result.put("dateTo", toDate.toString());
|
|
||||||
result.put("metrics", metricCards);
|
result.put("metrics", metricCards);
|
||||||
result.put("trend", trend);
|
result.put("trend", trend);
|
||||||
result.put("rankings", rankings);
|
result.put("rankings", rankings);
|
||||||
@@ -179,33 +186,6 @@ public class InquiryDashboardServiceImpl implements IInquiryDashboardService {
|
|||||||
return result;
|
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 同口径。
|
* 人员排名:与工单列表 enrichDisplayFields 同口径。
|
||||||
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
* 聚合键优先 salesWecomId(同人合并),展示名走 resolveSalesDisplayName。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 询价后台列表分页条:总数 + 每页条数 + 页码。
|
* 询价后台列表分页条:整组靠右,总数紧挨在「每页」左边,页码在后。
|
||||||
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
|
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
|
||||||
*/
|
*/
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
@@ -87,13 +87,13 @@ function onSizeChange(e: Event) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pagination admin-pagination">
|
<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">
|
<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">
|
<label class="admin-pagination__size">
|
||||||
每页
|
每页
|
||||||
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
|
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
|
||||||
@@ -127,6 +127,11 @@ function onSizeChange(e: Event) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination__total {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-pagination__size {
|
.admin-pagination__size {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 分析看板 API。
|
* 分析看板 API。
|
||||||
* 显式传 dateFrom/dateTo,与工单列表日期筛选同源;range 仅作无日期时的回退。
|
|
||||||
*/
|
*/
|
||||||
import { defHttp } from '/@/utils/http/axios';
|
import { defHttp } from '/@/utils/http/axios';
|
||||||
|
|
||||||
@@ -8,20 +7,5 @@ enum Api {
|
|||||||
metrics = '/inquiry/dashboard/metrics',
|
metrics = '/inquiry/dashboard/metrics',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashboardMetricsParams = {
|
export const getDashboardMetrics = (range = 'today') =>
|
||||||
range?: string;
|
defHttp.get({ url: Api.metrics, params: { range } });
|
||||||
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,15 +56,11 @@ const activePoint = ref<string | null>(null);
|
|||||||
const INQUIRY_PLOT_TOP = 36;
|
const INQUIRY_PLOT_TOP = 36;
|
||||||
const INQUIRY_PLOT_BOTTOM = 188;
|
const INQUIRY_PLOT_BOTTOM = 188;
|
||||||
|
|
||||||
/**
|
|
||||||
* 粗粒度 range 仅作后端无 dateFrom/dateTo 时的回退;正式请求一律带显式日期。
|
|
||||||
* 注意:不可再把「单日」映射成 today——today 是日历今天,不是所选那一天。
|
|
||||||
*/
|
|
||||||
function resolveRange(): string {
|
function resolveRange(): string {
|
||||||
const from = new Date(dateFrom.value);
|
const from = new Date(dateFrom.value);
|
||||||
const to = new Date(dateTo.value);
|
const to = new Date(dateTo.value);
|
||||||
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
const days = Math.max(0, Math.round((to.getTime() - from.getTime()) / 86400000));
|
||||||
if (days <= 0) return "1d";
|
if (days <= 0) return "today";
|
||||||
if (days <= 6) return "7d";
|
if (days <= 6) return "7d";
|
||||||
return "30d";
|
return "30d";
|
||||||
}
|
}
|
||||||
@@ -92,12 +88,7 @@ function onDateRangeChange() {
|
|||||||
async function loadMetrics() {
|
async function loadMetrics() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const req = {
|
const res: any = await getDashboardMetrics(resolveRange());
|
||||||
range: resolveRange(),
|
|
||||||
dateFrom: dateFrom.value,
|
|
||||||
dateTo: dateTo.value,
|
|
||||||
};
|
|
||||||
const res: any = await getDashboardMetrics(req);
|
|
||||||
const data = res?.result || res || {};
|
const data = res?.result || res || {};
|
||||||
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
todayMetrics.value = (data.metrics || []) as MetricCard[];
|
||||||
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
inquiryTrend.value = ((data.trend || []) as any[]).map((p) => ({
|
||||||
|
|||||||
@@ -918,6 +918,10 @@ body {
|
|||||||
color: #155eef !important;
|
color: #155eef !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination.admin-pagination {
|
||||||
|
justify-content: flex-end !important;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination .pages {
|
.pagination .pages {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ async function downloadPreviewOrOriginal() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination">
|
<div class="pagination admin-pagination">
|
||||||
<span>
|
<span>
|
||||||
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
|
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user