陆运多条线路按段展示 TMS 费用名和时效,选定后再写回该条成本。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user