陆运多条线路按段展示 TMS 费用名和时效,选定后再写回该条成本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 18:28:29 +08:00
co-authored by Cursor
parent e769eb9c1d
commit 49faa35f47
23 changed files with 2323 additions and 55 deletions
@@ -158,6 +158,52 @@ def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse
return HTMLResponse(render_air_route_ok())
@router.get("/land-routes/{token}", response_class=HTMLResponse)
def h5_land_route_open(token: str) -> HTMLResponse:
"""打开陆运选线路页。身份只认 token。"""
from agent.channel.h5.land_route_page import expired_land_route_page, render_land_route_page
from agent.channel.h5.land_route_store import lookup_land_route_token
rec = lookup_land_route_token(token)
if rec is None:
return HTMLResponse(expired_land_route_page(), status_code=410)
return HTMLResponse(render_land_route_page(rec))
@router.post("/land-routes/{token}", response_class=HTMLResponse)
def h5_land_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse:
"""
选定陆运线路。请求线程只验 token、入出站队列,不查 TMS、不跑整图。
"""
from agent.channel.h5.land_route_page import (
expired_land_route_page,
render_land_route_ok,
render_land_route_page,
)
from agent.channel.h5.land_route_store import lookup_land_route_token
from agent.channel.queue import get_message_store
from agent.policy.land_text_flow import get_land_text_flow
rec = lookup_land_route_token(token)
if rec is None:
return HTMLResponse(expired_land_route_page(), status_code=410)
sender_id = str(rec.get("sender_id") or "").strip()
if not sender_id:
return HTMLResponse(expired_land_route_page(), status_code=410)
def reply(text: str, extra=None) -> None:
store = get_message_store()
store.enqueue_outbound(touser=sender_id, content=text, payload=dict(extra or {}))
phase = get_land_text_flow().select_land_route(
sender_id=sender_id, index=int(index), reply=reply
)
logger.info("h5.land_route pick sender=%s phase=%s idx=%s", sender_id, phase, index)
if phase in {"route_expired", "route_invalid"}:
return HTMLResponse(render_land_route_page(rec), status_code=400)
return HTMLResponse(render_land_route_ok())
@router.get("/{token}")
def h5_open(token: str) -> dict[str, Any]:
"""
@@ -128,6 +128,7 @@ h1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; font-weight: 600; }
line-height: 1.5;
}
.fees .n { color: var(--muted); }
.fees li.leg .n { color: inherit; font-weight: 650; }
.fees .a { font-variant-numeric: tabular-nums; text-align: right; }
.empty {
margin: 12px 0 0;
@@ -0,0 +1,111 @@
"""
陆运选线路 H5 页。
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
"""
from __future__ import annotations
import html
from typing import Any
from agent.channel.h5.air_route_page import _PAGE_CSS, _wrap
from agent.schema.land_route_options import (
_money,
group_land_routes,
land_leg_fee_blocks_for_route,
option_eta_bits,
option_total,
route_line,
)
def expired_land_route_page() -> str:
"""token 过期页。"""
return _wrap(
"已过期",
"<div class='msg'><h1>链接已过期</h1><p>请回企微重新打开选线路卡片。</p></div>",
)
def render_land_route_page(payload: dict[str, Any]) -> str:
"""渲染选线路页。payload 来自 token。"""
quote = dict(payload.get("quote") or {})
wo = html.escape(str(payload.get("work_order_no") or ""))
routes = group_land_routes(quote)
rows = [_route_card(i, route, quote) for i, route in enumerate(routes)]
body = (
"<div class='wrap'>"
"<header class='hero'>"
f"<p class='wo'>{wo}</p>"
"<h1>选择线路</h1>"
f"<p class='lead'>共 {len(routes)} 条线路,每条按段报价。点选一条后确定。</p>"
"</header>"
"<form method='post'>"
+ "".join(rows)
+ "<div class='bar'><div class='bar-inner'>"
"<button type='submit'>选定这条</button>"
"</div></div></form></div>"
"<script>(function(){var f=document.querySelector('form');if(!f)return;"
"function sync(){f.querySelectorAll('.opt').forEach(function(el){"
"el.classList.toggle('is-on',!!(el.querySelector('input')||{}).checked);});}"
"f.addEventListener('change',sync);})();</script>"
)
return _wrap("选择线路", body)
def render_land_route_ok() -> str:
"""选定成功。"""
return _wrap(
"已选定",
"<div class='msg'><h1>已选定</h1><p>请回企微查看价格明细。</p></div>",
)
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
"""一张线路:航线、各段路段+TMS 费目、合计。"""
line = html.escape(route_line(route))
blocks = land_leg_fee_blocks_for_route(quote, route)
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):
items += (
f"<li class='leg'><span class='n'>{html.escape(title)}</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()
if not name or not amount:
continue
items += (
f"<li><span class='n'>{html.escape(name)}</span>"
f"<span class='a'>{html.escape(amount)}</span></li>"
)
days = eta or etas.get(i) or ""
if days:
items += (
f"<li><span class='n'>时效 {html.escape(days)}</span>"
"<span></span></li>"
)
if items:
fee_bits.append(f"<ul class='fees'>{items}</ul>")
else:
fee_bits.append("<p class='empty'>本线路未返回分段成本</p>")
total = html.escape(option_total(route))
legs = list(route.get("legs") or [])
tag = "中转" if len(legs) > 1 else "直达"
tag_cls = "pill via" if len(legs) > 1 else "pill"
return (
"<label class='opt'>"
f"<input type='radio' name='index' value='{index}' required />"
"<div class='head'><span class='tick' aria-hidden='true'></span>"
f"<div class='route'><strong>{line}</strong>"
f"<div class='meta'><span class='{tag_cls}'>{tag}</span></div></div></div>"
+ "".join(fee_bits)
+ f"<p class='total'><span>成本价合计</span><b>{total}</b></p>"
"</label>"
)
@@ -0,0 +1,121 @@
"""
陆运选线路 H5 的短命 token。
本文件职责:token 绑定 sender + 工单 + 线路报价;URL 禁止 userid。
优先 Redis;单测走内存。
禁止:在本包查 TMS、改六态。
"""
from __future__ import annotations
import json
import logging
import secrets
import threading
import time
from typing import Any, Optional
from agent.channel.h5.copy_store import inquiry_form_base
from agent.config import get_settings
from agent.redis_coord.keys import TOKEN_COPY_TICKET
logger = logging.getLogger(__name__)
LAND_ROUTE_TTL_SECONDS = 7 * 24 * 60 * 60
_KEY = TOKEN_COPY_TICKET + ":land_route"
_MEM: dict[str, tuple[str, float]] = {}
_MEM_LOCK = threading.Lock()
def issue_land_route_token(
*,
sender_id: str,
work_order_no: str,
quote: dict[str, Any],
facts: dict[str, str] | None = None,
ttl_seconds: int = LAND_ROUTE_TTL_SECONDS,
) -> str:
"""签发选线路 token。副作用:写 Redis 或内存。"""
payload = json.dumps(
{
"sender_id": sender_id,
"work_order_no": work_order_no,
"quote": quote,
"facts": dict(facts or {}),
},
ensure_ascii=False,
)
token = secrets.token_urlsafe(24)
if _redis_set(token, payload, ttl_seconds):
return token
expires = time.time() + ttl_seconds
with _MEM_LOCK:
_MEM[token] = (payload, expires)
logger.info("land route token 走内存 wo=%s", work_order_no)
return token
def lookup_land_route_token(token: str) -> Optional[dict[str, Any]]:
"""读 token。过期或不存在返回 None。"""
raw = (token or "").strip()
if not raw:
return None
text = _redis_get(raw)
if text is None:
with _MEM_LOCK:
hit = _MEM.get(raw)
if not hit:
return None
payload, expires = hit
if expires < time.time():
_MEM.pop(raw, None)
return None
text = payload
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def build_land_route_url(token: str) -> str:
"""拼选线路页:{inquiry-form}/land-routes/{token}。"""
raw = (token or "").strip()
if not raw:
return ""
return f"{inquiry_form_base().rstrip('/')}/land-routes/{raw}"
def _redis_set(token: str, payload: str, ttl: int) -> bool:
client = _try_redis()
if client is None:
return False
try:
client.raw.set(client.key(_KEY, token), payload, ex=ttl)
return True
except Exception as exc: # noqa: BLE001
logger.warning("land route token Redis 写入失败:%s", exc)
return False
def _redis_get(token: str) -> Optional[str]:
client = _try_redis()
if client is None:
return None
try:
raw = client.raw.get(client.key(_KEY, token))
except Exception as exc: # noqa: BLE001
logger.warning("land route token Redis 读取失败:%s", exc)
return None
if raw is None:
return None
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
def _try_redis():
try:
from agent.redis_coord import get_redis_runtime
return get_redis_runtime(get_settings()).client
except Exception: # noqa: BLE001
return None
+18 -3
View File
@@ -144,9 +144,24 @@ class HttpLedger:
quote: dict[str, Any],
to_status: str = "",
) -> dict[str, Any]:
# 有价时主账 query 已写快照并迁「已报价」;此处只做幂等确认,避免二次假写入。
_ = (work_order_no, quote, to_status)
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
"""
覆盖本单最新 TMS 快照。选定陆运线路后必须走这里,才能把该条成本写回工单。
空报价不打主账,避免把查价结果抹掉。
"""
no = (work_order_no or "").strip()
src = dict(quote or {})
if not no or not src:
return {"ok": True, "quote_version": 1, "status": to_status or "已报价"}
out = self._post(
"/inquiry/agent/ticket/replaceTmsQuote",
{"workOrderNo": no, "quote": src, "toStatus": to_status},
idem=f"tms-quote:{no}:{src.get('total') or ''}:{src.get('route_line') or ''}",
)
if out.get("ok") is False:
return out
out.setdefault("ok", True)
out.setdefault("status", to_status or "已报价")
return out
def render_quote(
self,
+45 -1
View File
@@ -73,7 +73,21 @@ class MemoryLedger:
"bizType": "空运",
"keywords": [],
"isDefault": True,
}
},
{
"templateId": "mem-default-land",
"name": "陆运默认",
"bizType": "陆运",
"keywords": [],
"isDefault": True,
},
{
"templateId": "mem-default-sea",
"name": "海运默认",
"bizType": "海运",
"keywords": [],
"isDefault": True,
},
]
self.last_render: dict[str, Any] = {}
self._sea_staff: list[dict[str, Any]] = []
@@ -192,6 +206,36 @@ class MemoryLedger:
{"name": "其他附加费", "amount": "USD 80"},
],
}
if str(facts.get("tms_land_multi") or "") == "1":
quote["isMulti"] = "2"
quote["business_line"] = "LAND"
quote["landSegments"] = [
{"segmentSequence": 1, "routeLegSequence": 1, "origin": "上海", "destination": "谅山", "costPrice": 7500, "currency": "CNY", "transitDays": 48},
{"segmentSequence": 1, "routeLegSequence": 2, "origin": "谅山", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72},
{"segmentSequence": 2, "routeLegSequence": 1, "origin": "上海", "destination": "深圳", "costPrice": 7500, "currency": "CNY", "transitDays": 48},
{"segmentSequence": 2, "routeLegSequence": 2, "origin": "深圳", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72},
{"segmentSequence": 3, "routeLegSequence": 1, "origin": "上海", "destination": "广西", "costPrice": 7500, "currency": "CNY", "transitDays": 48},
{"segmentSequence": 3, "routeLegSequence": 2, "origin": "广西", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72},
]
quote["fee_lines"] = [
{"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 1},
{"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 2},
{"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 2, "routeLegSequence": 1},
{"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 2, "routeLegSequence": 2},
{"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 3, "routeLegSequence": 1},
{"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 3, "routeLegSequence": 2},
]
if str(facts.get("tms_land_single") or "") == "1":
quote["isMulti"] = "1"
quote["business_line"] = "LAND"
quote["landSegments"] = [
{"segmentSequence": 1, "routeLegSequence": 1, "origin": "上海", "destination": "谅山", "costPrice": 7500, "currency": "CNY", "transitDays": 48},
{"segmentSequence": 1, "routeLegSequence": 2, "origin": "谅山", "destination": "胡志明", "costPrice": 9600, "currency": "CNY", "transitDays": 72},
]
quote["fee_lines"] = [
{"name": "运输费", "feeName": "运输费", "amount": 7500, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 1},
{"name": "运输费", "feeName": "运输费", "amount": 9600, "currency": "CNY", "segmentSequence": 1, "routeLegSequence": 2},
]
if str(facts.get("tms_multi") or "") == "1":
quote["isMulti"] = "2"
quote["airOptions"] = [
+95 -21
View File
@@ -901,6 +901,38 @@ def sea_bucket_fee_lines(quote: dict[str, Any] | None) -> list[str]:
return sections
def _land_grouped_fee_lines(quote: dict[str, Any] | None) -> list[str]:
"""陆运:每段先写始发→目的,再写该段 TMS 费用名。"""
from agent.schema.land_route_options import land_leg_fee_blocks
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
lines: list[str] = []
for title, fees, eta in land_leg_fee_blocks(src):
lines.append(title)
for fee in fees:
name = str(fee.get("name") or "").strip()
amount = str(fee.get("amount") or "").strip()
line_ccy = str(fee.get("currency") or fee.get("currencyCode") or header_ccy).strip()
if name and amount and amount != "-":
lines.append(f"{name}:{money_text(amount, line_ccy)}")
if eta:
lines.append(f"时效:{eta}")
return lines
def _is_land_surface(*, quote: dict[str, Any] | None = None, business_line: str = "", transport_mode: str = "") -> bool:
"""陆运销售面:有 landSegments / 陆运业务线就按段出明细。"""
src = dict(quote or {})
if src.get("landSegments") or src.get("land_segments") or src.get("selected_land_route"):
return True
raw_line = (business_line or "").strip()
if raw_line and scheme_transport_mode(raw_line, fallback="") == "陆运":
return True
mode = (transport_mode or "").strip()
return bool(mode) and scheme_transport_mode(mode, fallback="") == "陆运"
def quote_area_fee_text(
quote: dict[str, Any] | None,
business_line: str = "",
@@ -915,8 +947,13 @@ def quote_area_fee_text(
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
bucket_lines = sea_bucket_fee_lines(src)
land_lines = _land_grouped_fee_lines(src) if _is_land_surface(
quote=src, business_line=business_line, transport_mode=transport_mode
) else []
if bucket_lines:
fee_lines = list(bucket_lines)
elif land_lines:
fee_lines = list(land_lines)
else:
fee_lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
fee_lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
@@ -968,20 +1005,28 @@ def sea_quote_detail_text(
"""
完整价格明细文本:来源、工单号、费用行、总价。海运/陆运/空运共用。
时效、报价有效期只在卡片上出现,这里不写,避免和卡重复。
陆运分段时效跟在各段费用后面,用 TMS 原文。海运时效、报价有效期仍只在卡片上。
空运:业务确认没有时效,总销售价下面只留报价有效期。
不受卡片引用条字数限制。first_or_same 只兼容调用方。
"""
_ = first_or_same, history_work_order_no, status
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
lines = [source, f"{work_order_no}价格明细"]
if (business_line or "").upper() == "AIR":
line = (business_line or "").upper()
if line == "AIR":
from agent.schema.air_route_options import route_codes
route = route_codes(None, {}, src)
if route:
lines.append(route)
if line == "LAND":
land_line = str(src.get("route_line") or "").strip()
if land_line:
lines.append(land_line)
lines.append(quote_area_fee_text(src, business_line=business_line or "SEA"))
if line == "AIR":
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
return "\n".join(lines)
@@ -1048,10 +1093,10 @@ def quote_detail_lines(
transport_mode: str = "",
) -> list[str]:
"""
价格明细正文:TMS 费用、总销售价、时效、有效期。
价格明细正文:TMS 费用、总销售价一行、有效期;海运/陆运再加时效。
给文本兜底和企微引用条共用,禁止在这里编时效/有效期。
价格卡不展示工单状态,也不展示相同询价内容检索。
空运业务确认没有时效,这里不写。禁止编时效/有效期。
总销售价只写一次。价格卡不展示工单状态,也不展示相同询价内容检索。
"""
_ = status, first_or_same, history_work_order_no
src = dict(quote or {})
@@ -1060,13 +1105,18 @@ def quote_detail_lines(
bucket_lines = sea_bucket_fee_lines(src) if (business_line or "").upper() == "SEA" or (
transport_mode or ""
).strip() == "海运" else []
land_lines = _land_grouped_fee_lines(src) if _is_land_surface(
quote=src, business_line=business_line, transport_mode=transport_mode
) else []
if bucket_lines:
lines = list(bucket_lines)
elif land_lines:
lines = list(land_lines)
else:
lines = [f"{name}:{amount}" for name, amount in quote_fee_lines(src)]
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
lines.append(f"{_total_label(src, business_line, transport_mode)}:{total}")
lines.append(f"时效:{tms_eta_display(src)}")
if not _is_air_surface(business_line=business_line, transport_mode=transport_mode):
lines.append(f"时效:{tms_eta_display(src)}")
lines.append(f"报价有效期:{tms_valid_until_display(src)}")
return lines
@@ -1146,7 +1196,7 @@ def tms_hit_wecom_payload(
价格明细
┌ 引用条:空运费 / 其它费用 / 总销售价
时效、报价有效期
报价有效期(空运不写时效)
协同说明(普通文字,贴按钮上方)
按钮
@@ -1159,7 +1209,6 @@ def tms_hit_wecom_payload(
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
meta = "\n".join(
[
f"时效:{tms_eta_display(src)}",
f"报价有效期:{tms_valid_until_display(src)}",
"",
collab_hint(work_order_no),
@@ -1770,13 +1819,18 @@ def scheme_card(
body,
"价格明细",
quote_area_fee_text(src, transport_mode=transport_mode),
f"时效:{eta or '-'}",
f"报价有效期:{valid}",
"请确认是否采用该报价方案:",
f"【{BTN_ADOPT_EXCEL_CARD}】",
f"【{BTN_ADOPT_PDF_CARD}】",
f"【{BTN_REJECT}】",
]
if not _is_air_surface(transport_mode=transport_mode):
lines.append(f"时效:{eta or '-'}")
lines.extend(
[
f"报价有效期:{valid}",
"请确认是否采用该报价方案:",
f"【{BTN_ADOPT_EXCEL_CARD}】",
f"【{BTN_ADOPT_PDF_CARD}】",
f"【{BTN_REJECT}】",
]
)
return "\n".join(lines)
@@ -1806,6 +1860,19 @@ def scheme_transport_mode(business_line: str = "", fallback: str = "空运") ->
return (fallback or "空运").strip() or "空运"
def _is_air_surface(*, business_line: str = "", transport_mode: str = "") -> bool:
"""
空运销售面:业务确认没有时效,展示层一律不写。
海运/陆运仍保留时效。两边都空时不算空运,避免误藏海运/陆运卡。
"""
raw_line = (business_line or "").strip()
if raw_line and scheme_transport_mode(raw_line, fallback="") == "空运":
return True
mode = (transport_mode or "").strip()
return bool(mode) and scheme_transport_mode(mode, fallback="") == "空运"
def scheme_horizontal_rows(
*,
work_order_no: str,
@@ -1857,7 +1924,6 @@ def scheme_horizontal_rows(
{"keyname": "航线", "value": _clip(_air_scheme_route(q, src), 26)},
{"keyname": "货物", "value": _clip(f"{cargo};{pieces};{weight};{volume}", 26)},
{"keyname": "包装", "value": _clip(pack, 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(q), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(q), 26)},
]
@@ -2044,19 +2110,22 @@ def deal_horizontal_rows(
transport_mode: str = "",
) -> list[dict[str, str]]:
"""
成交跟进卡字段:总销售价、时效、报价有效期。
成交跟进卡字段:总销售价、报价有效期;海运/陆运再加时效。
空运业务确认没有时效,这里不写。
不要历史报价 / 工单状态 / 来源。来源只留卡片顶部红字。
"""
_ = facts
src = dict(quote or {})
header_ccy = str(src.get("currency") or src.get("currencyCode") or "").strip()
total = money_text(str(src.get("total") or "-").strip() or "-", header_ccy)
return [
rows = [
{"keyname": _total_label(src, business_line, transport_mode), "value": _clip(total, 26)},
{"keyname": "时效", "value": _clip(tms_eta_display(src), 26)},
{"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)},
]
if not _is_air_surface(business_line=business_line, transport_mode=transport_mode):
rows.append({"keyname": "时效", "value": _clip(tms_eta_display(src), 26)})
rows.append({"keyname": "报价有效期", "value": _clip(tms_valid_until_display(src), 26)})
return rows
def deal_card(
@@ -2098,7 +2167,7 @@ def deal_wecom_payload(
成交跟进卡:对齐现网 button_interaction。
顶部来源红字;标题询价工单号;副标题跟进成交;三个按钮。
字段只要总销售价、时效、有效期。
字段:总销售价、报价有效期;海运/陆运再加时效。空运不写时效。
"""
src = dict(quote or {})
source = str(src.get("source_label") or "TMS标准报价").replace(" ", "")
@@ -2406,6 +2475,7 @@ def air_group_brief(
空运群激活摘要:与海运进群同一套结构,字段用空运。
先 @ 销售和航线,再工单号 + 询价字段 + 协同补充(含电/含磁)+ TMS报价。
TMS 段跟单聊同一套线路分项,总销售价只一行;禁止用「空运费=总价」冒充。
不写「当前工单/询价要点/现在等到哪一步」。
"""
no = (work_order_no or "").strip()
@@ -2433,6 +2503,10 @@ def air_group_brief(
product_quote=product_quote,
has_price=has_price,
)
if tms:
from agent.schema.air_route_options import project_group_air_quote
tms = project_group_air_quote(tms, facts)
lines.append("")
lines.append("TMS报价:")
if tms and (tms.get("total") or quote_fee_lines(tms)):
+126 -5
View File
@@ -2,8 +2,9 @@
陆运文字询价确定性流程。
本文件职责:三项选项各自必填(不联动)→ 按类型补货物 → 无工单号核对卡 →(确定后)建单查 TMS。
查价后:1 条完整线路直接出价格明细;多于 1 条发入口卡,H5 选定后再出该条明细。
查价后半段复用海运拉群/报价单/成交,拉人按线路类别,禁止陆运岗全员。
禁止:字段齐了就建单;直连 TMS;把海运「出卡即查价」改掉。
禁止:字段齐了就建单;直连 TMS;把中转段当成另一条线路;把海运「出卡即查价」改掉。
调用:handler 在 Worker/inbox 线程;禁止回调线程同步跑。
"""
@@ -31,7 +32,7 @@ from agent.schema.tms_land_query import assemble_land_query
logger = logging.getLogger(__name__)
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "idle", ""})
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "idle", ""})
class LandTextInquiryFlow(SeaTextInquiryFlow):
@@ -365,7 +366,11 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
reply: ReplyFn,
retrying: bool,
) -> str:
"""查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。"""
"""
查价回包落到有价/无价/技术失败。失败时把工单号记进待重查。
有价时按完整线路计条:1 条投影后出明细;多于 1 条进 H5。
"""
_ = retrying
no = (sess.work_order_no or "").strip()
if not tms.get("ok"):
@@ -395,7 +400,17 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
self._save(sess)
return "tms_miss"
quote = tms["quote"]
quote = dict(tms["quote"] or {})
from agent.schema.land_route_options import (
group_land_routes,
needs_route_pick,
project_selected_route,
)
raw_quote = dict(quote)
routes = group_land_routes(raw_quote)
if routes and not needs_route_pick(raw_quote):
quote = project_selected_route(raw_quote, routes[0])
up = self._ledger.upsert_quote(work_order_no=no, quote=quote, to_status="已报价")
if not up.get("ok"):
reply("主账写入报价失败,不能当作已报价。")
@@ -403,8 +418,10 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
self._save(sess)
return "ledger_fail"
sess.quote = quote
copy.remember_tms_quote(sess, quote)
copy.remember_tms_quote(sess, raw_quote)
sess.status = "已报价"
if needs_route_pick(raw_quote):
return self._offer_land_routes(sess=sess, work_order_no=no, quote=raw_quote, reply=reply)
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
sess.wait_version += 1
@@ -420,6 +437,110 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
)
return "wait_collab"
def _offer_land_routes(
self,
*,
sess: FlowSession,
work_order_no: str,
quote: dict[str, Any],
reply: ReplyFn,
) -> str:
"""多条陆运线路:企微只发入口卡,选线在 H5。"""
from agent.channel.h5.land_route_store import build_land_route_url, issue_land_route_token
from agent.schema.land_route_options import group_land_routes
token = issue_land_route_token(
sender_id=sess.sender_id,
work_order_no=work_order_no,
quote=quote,
facts=dict(sess.facts),
)
url = build_land_route_url(token)
count = len(group_land_routes(quote))
sess.phase = "wait_land_route"
sess.allowed = ()
sess.wait_version += 1
self._save(sess)
reply(
copy.air_route_entry_text(work_order_no=work_order_no, count=count),
copy.air_route_entry_card(work_order_no=work_order_no, count=count, url=url),
)
return "wait_land_route"
def select_land_route(
self,
*,
sender_id: str,
index: int,
reply: ReplyFn,
) -> str:
"""
H5 选定一条陆运线路:只留该条各段,出价格明细。
已采用过则作废旧报价单后再出新卡。
"""
sess = self.session_of(sender_id)
if sess is None or not (sess.work_order_no or "").strip():
reply("线路选择已过期,请回企微重新询价。")
return "route_expired"
from agent.schema.land_route_options import group_land_routes, project_selected_route
raw_quote = dict(sess.tms_quote or sess.quote or {})
routes = group_land_routes(raw_quote)
if index < 0 or index >= len(routes):
reply("没有这条线路,请重新打开页面选择。")
return "route_invalid"
route = routes[index]
quote = project_selected_route(raw_quote, route)
prev_phase = (sess.phase or "").strip()
already_adopted = prev_phase in {"wait_deal", "wait_file", "wait_adopt"}
if already_adopted:
clearer = getattr(self._ledger, "void_quote_files", None)
if callable(clearer):
clearer(work_order_no=sess.work_order_no)
from agent.redis_coord.quote_file import clear_quote_file_ready
clear_quote_file_ready(sess.work_order_no)
self._ledger.upsert_quote(work_order_no=sess.work_order_no, quote=quote, to_status="已报价")
sess.quote = quote
sess.status = "已报价"
sess.wait_version += 1
if already_adopted:
sess.phase = "wait_adopt"
sess.allowed = (copy.BTN_ADOPT_EXCEL, copy.BTN_ADOPT_PDF, copy.BTN_REJECT)
self._save(sess)
mode = copy.scheme_transport_mode(sess.business_line)
reply(
copy.scheme_card(
work_order_no=sess.work_order_no,
status="已报价",
facts=dict(sess.facts),
quote=quote,
transport_mode=mode,
),
copy.scheme_wecom_payload(
work_order_no=sess.work_order_no,
facts=dict(sess.facts),
quote=quote,
status="已报价",
transport_mode=mode,
),
)
return "wait_adopt"
sess.phase = "wait_collab"
sess.allowed = (copy.BTN_PULL_COLLAB, copy.BTN_SKIP_COLLAB)
self._save(sess)
self._emit_sea_hit(
reply,
work_order_no=sess.work_order_no,
quote=quote,
first_or_same=sess.first_or_same,
history_work_order_no=sess.history_work_order_no,
status="已报价",
business_line="LAND",
)
return "wait_collab"
def _land_ready(self, facts: dict[str, str]) -> dict[str, Any]:
"""选项+货物是否可出核对卡。"""
opt = validate_land_options(facts)
+130 -1
View File
@@ -2,7 +2,8 @@
空运 TMS 线路报价:从 airOptions 拆出可选线路。
本文件职责:判断一条还是多条、拼航线(含中转)、抽出该线路费用。
选定后把起运港写回 facts。中转只用于展示,不写进报价单模板。
选定后把起运港写回 facts。群摘要现场投影同一套分项。
中转只用于展示,不写进报价单模板。
禁止:猜没回的机场名;把贸易条款当线路;改六态。
"""
@@ -248,3 +249,131 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
base["selected_air_option"] = src
base["route_line"] = route_line(src, {})
return base
def _parse_option_dict(raw: Any) -> dict[str, Any]:
"""selected_air_option:字典或 JSON 串。解不开当没有。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return {}
return dict(raw) if isinstance(raw, dict) else {}
def resolve_display_air_option(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> dict[str, Any] | None:
"""
群摘要用哪一条线路:已选定 > 仅 1 条 > 用工单起运/目的对上的那条。
多条对不上就不猜。禁止把整单最低价的空运费套到没选的线路。
"""
src = dict(quote or {})
selected = _parse_option_dict(src.get("selected_air_option"))
if selected:
return selected
options = parse_air_options(src)
if not options:
return None
if len(options) == 1:
return options[0]
facts = facts or {}
origin = str(facts.get("originCode") or "").strip().upper()
if not origin:
origin = resolve_airport_code(
str(facts.get("起运港") or facts.get("起运地") or "")
)
dest = str(facts.get("destinationCode") or "").strip().upper()
if not dest:
dest = resolve_airport_code(
str(facts.get("目的港") or facts.get("目的地") or "")
)
matched: list[dict[str, Any]] = []
for opt in options:
oc = str(opt.get("originAirportCode") or opt.get("origin") or "").strip().upper()
dc = str(
opt.get("destinationAirportCode") or opt.get("destination") or ""
).strip().upper()
if origin and oc and oc != origin:
continue
if dest and dc and dc != dest:
continue
if origin or dest:
matched.append(opt)
if len(matched) == 1:
return matched[0]
return None
def _amount_digits(raw: Any) -> str:
"""只留数字和小数点,便于判断空运费是否就是总价。"""
text = str(raw or "").strip().replace(",", "")
for prefix in ("CNY", "USD", "EUR", "HKD", "RMB"):
if text.upper().startswith(prefix):
text = text[len(prefix):].strip()
break
return text
def _same_amount(left: Any, right: Any) -> bool:
"""金额是否同一笔。币种前缀、逗号、小数末零忽略。"""
a = _amount_digits(left)
b = _amount_digits(right)
if not a or not b or a == "-" or b == "-":
return False
try:
return float(a) == float(b)
except ValueError:
return a == b
def drop_air_freight_total_alias(quote: dict[str, Any] | None) -> dict[str, Any]:
"""
整单兜底「空运费=总价」不是线路分项,群摘要禁止再写。
真有空运费且金额不等于总价(例如另有附加费)则保留。
"""
src = dict(quote or {})
total = src.get("total")
if _same_amount(src.get("freight"), total):
src["freight"] = "-"
raw = src.get("fee_lines") or src.get("fee_rows")
parsed: Any = raw
if isinstance(raw, str) and raw.strip():
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = None
if not isinstance(parsed, list):
return src
kept: list[Any] = []
for item in parsed:
if not isinstance(item, dict):
kept.append(item)
continue
name = str(item.get("name") or item.get("label") or "").strip()
amount = item.get("amount") or item.get("value")
if name == "空运费" and _same_amount(amount, total):
continue
kept.append(item)
src["fee_lines"] = kept
return src
def project_group_air_quote(
quote: dict[str, Any] | None,
facts: dict[str, str] | None = None,
) -> dict[str, Any]:
"""
群摘要 TMS 段:有线路就投影该线路费用,与单聊价格明细同一套分项。
主账查价快照常只有「空运费=总价」;选定后的明细没写回主账,必须现场投影。
副作用:无。不写库、不改六态。
"""
src = dict(quote or {})
option = resolve_display_air_option(src, facts)
if option:
src = project_selected_quote(src, option)
return drop_air_freight_total_alias(src)
@@ -0,0 +1,420 @@
"""
陆运 TMS 线路报价:按 segmentSequence 收成一条条完整线路。
本文件职责:一段里的多段是中转,不是多条线路;抽出每段成本;选定后只留这一条的段。
禁止:把 costPrice 标成销售价;编没有回的站名;改六态。
"""
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger(__name__)
def _as_list(raw: Any) -> list[Any]:
"""list 或 JSON 串。"""
if isinstance(raw, str) and raw.strip():
try:
raw = json.loads(raw)
except json.JSONDecodeError:
logger.warning("land.routes landSegments 不是 JSON")
return []
return list(raw) if isinstance(raw, list) else []
def parse_land_legs(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""摊平 quote.landSegments。"""
src = dict(quote or {})
out: list[dict[str, Any]] = []
for item in _as_list(src.get("landSegments") or src.get("land_segments")):
if isinstance(item, dict):
out.append(dict(item))
return out
def _seq(item: dict[str, Any]) -> int:
try:
return int(item.get("segmentSequence") or item.get("segment_sequence") or 1)
except (TypeError, ValueError):
return 1
def _leg_no(item: dict[str, Any]) -> int:
try:
return int(item.get("routeLegSequence") or item.get("route_leg_sequence") or 0)
except (TypeError, ValueError):
return 0
def group_land_routes(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
同一 segmentSequence 收成一条线路,段按 routeLegSequence 排。
"""
buckets: dict[int, list[dict[str, Any]]] = {}
order: list[int] = []
for leg in parse_land_legs(quote):
key = _seq(leg)
if key not in buckets:
buckets[key] = []
order.append(key)
buckets[key].append(leg)
routes: list[dict[str, Any]] = []
for key in order:
legs = sorted(buckets[key], key=_leg_no)
routes.append({"segmentSequence": key, "legs": legs})
return routes
def needs_route_pick(quote: dict[str, Any] | None) -> bool:
"""多于一条完整线路才进 H5。"""
return len(group_land_routes(quote)) > 1
def route_line(route: dict[str, Any] | None) -> str:
"""上海-谅山-胡志明:第一段起运 + 各段目的。"""
legs = list((route or {}).get("legs") or [])
if not legs:
return ""
names = [str(legs[0].get("origin") or "").strip()]
for leg in legs:
dest = str(leg.get("destination") or "").strip()
if dest:
names.append(dest)
return "-".join(x for x in names if x)
def _money(raw: Any, currency: str = "") -> str:
text = str(raw if raw is not None else "").strip()
if not text or text in {"None", "null"}:
return ""
ccy = (currency or "").strip()
if ccy and ccy not in text:
return f"{ccy} {text}"
return text
def option_fee_lines(route: dict[str, Any] | None) -> list[tuple[str, str]]:
"""选线路页用:始发 → 目的 + 该段成本,方便区分备选。报价明细不走这里。"""
rows: list[tuple[str, str]] = []
for leg in list((route or {}).get("legs") or []):
origin = str(leg.get("origin") or "").strip() or "-"
dest = str(leg.get("destination") or "").strip() or "-"
ccy = str(leg.get("currency") or "").strip()
amount = _money(leg.get("costPrice") if leg.get("costPrice") is not None else leg.get("cost_price"), ccy)
if not amount:
continue
rows.append((f"{origin} → {dest}", amount))
return rows
def _num(raw: Any) -> float | None:
"""金额比大小:去掉币种和逗号。"""
if raw is None:
return None
text = str(raw).strip()
if not text or text in {"-", "None", "null"}:
return None
for bit in text.replace(",", " ").split():
try:
return float(bit)
except ValueError:
continue
return None
def _same_amount(left: Any, right: Any) -> bool:
a, b = _num(left), _num(right)
if a is None or b is None:
return False
return abs(a - b) < 0.0001
def _leg_cost(leg: dict[str, Any] | None) -> Any:
src = dict(leg or {})
if src.get("costPrice") is not None:
return src.get("costPrice")
return src.get("cost_price")
def _has_seq(item: dict[str, Any] | None) -> bool:
src = dict(item or {})
raw = src.get("segmentSequence")
if raw is None:
raw = src.get("segment_sequence")
return raw not in (None, "", "null")
def _fee_name(item: dict[str, Any] | None) -> str:
"""费用名只认 TMS 原文,禁止用路段名冒充。"""
src = dict(item or {})
return str(
src.get("feeName")
or src.get("name")
or src.get("chargeName")
or src.get("itemName")
or src.get("feeType")
or ""
).strip()
def parse_tms_fee_rows(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""摊平 TMS feeItems / 已投影的 fee_lines。"""
src = dict(quote or {})
raw = src.get("feeItems") or src.get("fee_items") or src.get("fee_lines") or src.get("fee_rows")
return [dict(x) for x in _as_list(raw) if isinstance(x, dict)]
def _named_from_rows(raw: Any) -> list[dict[str, Any]]:
"""只要 TMS 费目名。路段名冒充的费用行丢掉。"""
rows: list[dict[str, Any]] = []
for item in _as_list(raw):
if not isinstance(item, dict):
continue
name = _fee_name(item)
if not name or "→" in name or "->" in name:
continue
amount = item.get("amount")
if amount is None:
amount = item.get("costPrice") if item.get("costPrice") is not None else item.get("salePrice")
if _num(amount) is None:
continue
line = dict(item)
line["name"] = name
line["amount"] = amount
rows.append(line)
return rows
def _named_tms_fees(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""
先看已投影的 fee_lines,没有真费目再看 feeItems。
主账有时把路段名写进 fee_lines;这时必须回到 TMS feeItems 取运输费。
"""
src = dict(quote or {})
named = _named_from_rows(src.get("fee_lines") or src.get("fee_rows"))
if named:
return named
named = _named_from_rows(src.get("feeItems") or src.get("fee_items"))
if named:
return named
return _named_from_rows(parse_tms_fee_rows(src))
def _pick_fee_for_leg(
fees: list[dict[str, Any]],
used: set[int],
leg: dict[str, Any],
prefer_seq: int = 0,
) -> dict[str, Any] | None:
"""按段号 / 腿号 / 金额认回这一段的 TMS 费用。"""
want_leg = _leg_no(leg)
want_amt = _leg_cost(leg)
checks = (
lambda f: prefer_seq and _has_seq(f) and _seq(f) == prefer_seq and _leg_no(f) == want_leg,
lambda f: prefer_seq and _has_seq(f) and _seq(f) == prefer_seq and _same_amount(f.get("amount"), want_amt),
lambda f: _leg_no(f) == want_leg and _same_amount(f.get("amount"), want_amt),
lambda f: _same_amount(f.get("amount"), want_amt),
)
for check in checks:
for i, fee in enumerate(fees):
if i in used:
continue
try:
ok = check(fee)
except (TypeError, ValueError):
ok = False
if ok:
used.add(i)
return fee
return None
def selected_tms_fee_lines(
quote: dict[str, Any] | None,
route: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""
选定一条陆运线路后:只留该条 TMS 费用行,名称原样。
主账常把 fee_lines 收成 JSON 串,也不一定带段号;这时按各段成本和腿号认回去。
"""
src = dict(route or {})
try:
seq = int(src.get("segmentSequence") or src.get("segment_sequence") or 0)
except (TypeError, ValueError):
seq = 0
fees = _named_tms_fees(quote)
matched = [f for f in fees if _has_seq(f) and _seq(f) == seq]
if matched:
return matched
used: set[int] = set()
out: list[dict[str, Any]] = []
for leg in list(src.get("legs") or []):
if not isinstance(leg, dict):
continue
picked = _pick_fee_for_leg(fees, used, leg, prefer_seq=seq)
if picked:
out.append(picked)
if out:
return out
if fees and not needs_route_pick(quote):
return fees
return []
def _od_title(leg: dict[str, Any] | None) -> str:
"""上海 → 谅山:站名用 TMS 原文。"""
src = dict(leg or {})
origin = str(src.get("origin") or "").strip()
dest = str(src.get("destination") or "").strip()
if not origin and not dest:
return ""
return f"{origin or '-'} → {dest or '-'}"
def _quote_fee_dicts(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""优先已选定的 fee_lines,避免再摊开整单 feeItems。"""
src = dict(quote or {})
rows = [dict(x) for x in _as_list(src.get("fee_lines") or src.get("fee_rows")) if isinstance(x, dict)]
if rows:
return rows
return parse_tms_fee_rows(src)
def _selected_legs(quote: dict[str, Any] | None) -> list[dict[str, Any]]:
"""当前这条线路的各段。多条未选时不编。"""
src = dict(quote or {})
selected = src.get("selected_land_route")
if isinstance(selected, dict):
legs = [dict(x) for x in list(selected.get("legs") or []) if isinstance(x, dict)]
if legs:
return legs
routes = group_land_routes(src)
if len(routes) == 1:
return [dict(x) for x in list(routes[0].get("legs") or []) if isinstance(x, dict)]
legs = parse_land_legs(src)
seqs = {_seq(x) for x in legs}
if len(seqs) == 1:
return sorted(legs, key=_leg_no)
return []
def land_leg_fee_blocks(
quote: dict[str, Any] | None,
) -> list[tuple[str, list[dict[str, Any]], str]]:
"""
价格明细按段:路段标题、该段 TMS 费目、该段时效原文。
例:上海 → 谅山 / 运输费 7500 / N+2。时效没有就不写,禁止编。
"""
legs = _selected_legs(quote)
if not legs:
return []
fees = _named_tms_fees(quote)
fallback_name = next((str(f.get("name") or "").strip() for f in fees if f.get("name")), "")
# 路段名把 TMS 费目冲掉时,各段成本仍按运输费展示,禁止再用始发→目的冒充费名。
if not fallback_name:
fallback_name = "运输费"
used: set[int] = set()
blocks: list[tuple[str, list[dict[str, Any]], str]] = []
for leg in legs:
title = _od_title(leg)
if not title:
continue
eta = str(leg.get("transitDays") or leg.get("transit_days") or "").strip()
picked: list[dict[str, Any]] = []
one = _pick_fee_for_leg(fees, used, leg, prefer_seq=_seq(leg))
if one:
picked.append(one)
elif fallback_name and _num(_leg_cost(leg)) is not None:
picked.append(
{
"name": fallback_name,
"amount": _leg_cost(leg),
"currency": str(leg.get("currency") or "").strip(),
}
)
blocks.append((title, picked, eta))
return blocks
def land_leg_fee_blocks_for_route(
quote: dict[str, Any] | None,
route: dict[str, Any] | None,
) -> list[tuple[str, list[dict[str, Any]], str]]:
"""H5 某一条备选:只收该条各段,费用名仍是 TMS 原文。"""
src = dict(route or {})
mini = dict(quote or {})
mini["selected_land_route"] = src
mini["landSegments"] = [dict(x) for x in list(src.get("legs") or []) if isinstance(x, dict)]
picked = selected_tms_fee_lines(quote, src)
if picked:
mini["fee_lines"] = picked
return land_leg_fee_blocks(mini)
def option_total(route: dict[str, Any] | None) -> str:
"""各段成本相加。加不上就空。"""
total = 0.0
ccy = ""
any_num = False
for leg in list((route or {}).get("legs") or []):
raw = leg.get("costPrice")
if raw is None:
raw = leg.get("cost_price")
if raw is None or str(raw).strip() in {"", "None", "null"}:
continue
try:
total += float(raw)
any_num = True
except (TypeError, ValueError):
continue
if not ccy:
ccy = str(leg.get("currency") or "").strip()
if not any_num:
return "-"
text = str(int(total)) if total == int(total) else str(total)
return _money(text, ccy) or "-"
def option_eta_bits(route: dict[str, Any] | None) -> list[tuple[str, str]]:
"""每段时效,有 transitDays 才写。"""
bits: list[tuple[str, str]] = []
for i, leg in enumerate(list((route or {}).get("legs") or []), 1):
days = str(leg.get("transitDays") or leg.get("transit_days") or "").strip()
if days:
bits.append((f"第{i}段时效", days))
return bits
def project_selected_route(quote: dict[str, Any] | None, route: dict[str, Any] | None) -> dict[str, Any]:
"""
选定一条后:只留该条 landSegments,费用名按 TMS 原文,合计用成本加总。
"""
base = dict(quote or {})
src = dict(route or {})
legs = [dict(x) for x in list(src.get("legs") or [])]
base["landSegments"] = legs
lines = selected_tms_fee_lines(base, src)
if not lines:
mini = dict(base)
mini["selected_land_route"] = src
for _, fees, _eta in land_leg_fee_blocks(mini):
lines.extend(fees)
base["fee_lines"] = lines
base["total"] = option_total(src)
base["freight"] = "-"
base["other_fee"] = "-"
base["route_line"] = route_line(src)
base["selected_land_route"] = src
etas = option_eta_bits(src)
if etas:
# 时效按段原文拼接(N+2 / N+3),禁止当成小时数相加。
base["eta"] = " / ".join(d for _, d in etas)
if src:
base["cost"] = option_total(src)
return base