Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049ee51c3c | ||
|
|
8e73af0d03 | ||
|
|
49faa35f47 |
@@ -2,6 +2,8 @@
|
||||
H5 补问 / 附件上传 / 复制工单号通道。
|
||||
|
||||
本文件职责:挂载 /inquiry-form;用 token 绑定身份或工单号(URL 禁止 userid)。
|
||||
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token},
|
||||
附件票必须在这两条路径上出同一张上传页、收同一套文件。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||||
"""
|
||||
|
||||
@@ -73,11 +75,19 @@ def h5_jsapi_signature(
|
||||
raise HTTPException(status_code=503, detail="jsapi_unavailable") from exc
|
||||
|
||||
|
||||
@router.get("/upload/{token}", response_class=HTMLResponse)
|
||||
def h5_upload_open(token: str) -> HTMLResponse:
|
||||
def _is_upload_token(token: str) -> bool:
|
||||
"""
|
||||
打开附件上传页。身份只认 token;过期给 410,不吐内部栈。
|
||||
附件上传票:allowed_actions 含 upload_attachment。
|
||||
|
||||
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token},
|
||||
不能只认 /upload/ 这一条路径。
|
||||
"""
|
||||
rec = validate_token(token)
|
||||
return rec is not None and "upload_attachment" in (rec.allowed_actions or [])
|
||||
|
||||
|
||||
def _upload_page(token: str) -> HTMLResponse:
|
||||
"""打开附件上传页。身份只认 token;过期给 410,不吐内部栈。"""
|
||||
from agent.handlers.attachment_inquiry import lookup_upload_sender
|
||||
|
||||
if lookup_upload_sender(token) is None:
|
||||
@@ -85,10 +95,9 @@ def h5_upload_open(token: str) -> HTMLResponse:
|
||||
return HTMLResponse(render_upload_page())
|
||||
|
||||
|
||||
@router.post("/upload/{token}", response_class=HTMLResponse)
|
||||
async def h5_upload_submit(
|
||||
async def _accept_upload_files(
|
||||
token: str,
|
||||
files: Optional[list[UploadFile]] = File(default=None),
|
||||
files: Optional[list[UploadFile]],
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
接收多个 Excel/PDF。请求线程只验 token、读字节、入队,不等千问。
|
||||
@@ -112,6 +121,21 @@ async def h5_upload_submit(
|
||||
return HTMLResponse(render_upload_message("上传成功", copy.ATTACH_H5_OK))
|
||||
|
||||
|
||||
@router.get("/upload/{token}", response_class=HTMLResponse)
|
||||
def h5_upload_open(token: str) -> HTMLResponse:
|
||||
"""显式上传路径。公网可能被 Nginx 改写到 /{token},两路必须同一页。"""
|
||||
return _upload_page(token)
|
||||
|
||||
|
||||
@router.post("/upload/{token}", response_class=HTMLResponse)
|
||||
async def h5_upload_submit(
|
||||
token: str,
|
||||
files: Optional[list[UploadFile]] = File(default=None),
|
||||
) -> HTMLResponse:
|
||||
"""显式上传提交。与 POST /{token} 同一套入队逻辑。"""
|
||||
return await _accept_upload_files(token, files)
|
||||
|
||||
|
||||
@router.get("/air-routes/{token}", response_class=HTMLResponse)
|
||||
def h5_air_route_open(token: str) -> HTMLResponse:
|
||||
"""打开空运选线路页。身份只认 token。"""
|
||||
@@ -158,16 +182,66 @@ def h5_air_route_pick(token: str, index: int = Form(default=-1)) -> HTMLResponse
|
||||
return HTMLResponse(render_air_route_ok())
|
||||
|
||||
|
||||
@router.get("/{token}")
|
||||
def h5_open(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
打开补问页:校验 token 未过期。
|
||||
@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
|
||||
|
||||
返回允许动作与 wait_version;不返回内部异常栈。
|
||||
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):
|
||||
"""
|
||||
合同入口:/inquiry-form/{token}。
|
||||
|
||||
附件票出上传页(公网 Nginx 也会把 /upload/{token} 改写到这里)。
|
||||
其它票仍回补问骨架,不吐内部栈。
|
||||
"""
|
||||
rec = validate_token(token)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||||
if "upload_attachment" in (rec.allowed_actions or []):
|
||||
logger.info("h5.open upload thread=%s", rec.thread_id)
|
||||
return _upload_page(token)
|
||||
return {
|
||||
"status": "ok",
|
||||
"thread_id": rec.thread_id,
|
||||
@@ -177,6 +251,22 @@ def h5_open(token: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{token}", response_class=HTMLResponse)
|
||||
async def h5_post_by_token(
|
||||
token: str,
|
||||
files: Optional[list[UploadFile]] = File(default=None),
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
附件票在 /{token} 上收文件。
|
||||
|
||||
浏览器地址仍是 /upload/{token},表单相对提交后被 Nginx 改写到这里。
|
||||
非附件票不收文件,避免冒充补问提交。
|
||||
"""
|
||||
if not _is_upload_token(token):
|
||||
raise HTTPException(status_code=405, detail="该页不接收文件上传")
|
||||
return await _accept_upload_files(token, files)
|
||||
|
||||
|
||||
@router.post("/{token}/submit")
|
||||
def h5_submit(token: str, body: H5SubmitBody) -> 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
|
||||
@@ -79,6 +79,21 @@ def is_active_attachment_wait(sender_id: str, *, now: Optional[float] = None) ->
|
||||
return get_attach_wave_book().active(sender_id, now=now) is not None
|
||||
|
||||
|
||||
def is_idle_attachment_wait(sender_id: str, *, now: Optional[float] = None) -> bool:
|
||||
"""已发卡但还没传 Excel/PDF/图。此时销售打字应走文字询价。"""
|
||||
wave = get_attach_wave_book().active(sender_id, now=now)
|
||||
return wave is not None and not wave.started
|
||||
|
||||
|
||||
def abandon_idle_attach_wait(sender_id: str) -> bool:
|
||||
"""
|
||||
作废未开始认的等待票。已开始认返回 False。
|
||||
|
||||
副作用:旧上传卡随后按过期拒绝。
|
||||
"""
|
||||
return get_attach_wave_book().abandon_if_idle(sender_id)
|
||||
|
||||
|
||||
def append_attach_text(sender_id: str, text: str) -> bool:
|
||||
"""认完之前的字并进本票,不走文字询价。"""
|
||||
return get_attach_wave_book().append_follow_text(sender_id, text)
|
||||
|
||||
@@ -144,9 +144,61 @@ 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 快照。选定陆运线路后必须走这里,才能把该条成本写回工单。
|
||||
空报价不打主账,避免把查价结果抹掉。
|
||||
|
||||
若接口暂不可用(热更丢映射常见 404)但查价时主账已落库为已报价,
|
||||
则按成功续办,避免销售侧误报「写入报价失败」。
|
||||
"""
|
||||
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:
|
||||
recovered = self._recover_upsert_if_already_quoted(work_order_no=no, failed=out)
|
||||
if recovered is not None:
|
||||
return recovered
|
||||
return out
|
||||
out.setdefault("ok", True)
|
||||
out.setdefault("status", to_status or "已报价")
|
||||
return out
|
||||
|
||||
def _recover_upsert_if_already_quoted(
|
||||
self,
|
||||
*,
|
||||
work_order_no: str,
|
||||
failed: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
replaceTmsQuote 失败时,读主账工单:已报价且已有 TMS 价则视为写入成功。
|
||||
查价路径会先 persistHit,接口 404 时工单其实已经有价。
|
||||
"""
|
||||
try:
|
||||
snap = self.get_for_agent(work_order_no=work_order_no, sender_id="")
|
||||
except Exception: # noqa: BLE001 — 兜底读失败仍按原失败返回
|
||||
return None
|
||||
status = str(snap.get("status") or "")
|
||||
tms_hit = snap.get("tms_hit") in (1, "1", True, "true")
|
||||
sale = str(snap.get("sale_price") or snap.get("salePrice") or "").strip()
|
||||
if status == "已报价" and (tms_hit or sale):
|
||||
logger.warning(
|
||||
"upsert_quote 接口失败但工单已报价,按成功续办 wo=%s err=%s",
|
||||
work_order_no,
|
||||
failed.get("error") or failed,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "已报价",
|
||||
"recovered": True,
|
||||
"quote_version": snap.get("quote_version") or 1,
|
||||
}
|
||||
return None
|
||||
|
||||
def render_quote(
|
||||
self,
|
||||
|
||||
@@ -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"] = [
|
||||
|
||||
@@ -287,6 +287,24 @@ class AttachWaveBook:
|
||||
parts.append(wave.follow_text.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
def abandon_if_idle(self, sender_id: str) -> bool:
|
||||
"""
|
||||
还没开始认材料:作废等待票。已开始认或无票返回 False。
|
||||
|
||||
销售发卡后改口打字询价时用。旧 H5 随后按过期拒绝。
|
||||
"""
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid:
|
||||
return False
|
||||
with self._lock:
|
||||
self._hydrate(sid)
|
||||
wave = self._waves.get(sid)
|
||||
if wave is None or wave.business_done or wave.started:
|
||||
return False
|
||||
wave.business_done = True
|
||||
self._persist(sid)
|
||||
return True
|
||||
|
||||
def mark_business_done(self, sender_id: str) -> None:
|
||||
"""业务补问/卡片已发出。之后旧 H5 与并入图都必须拒绝。"""
|
||||
sid = (sender_id or "").strip()
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -262,16 +262,29 @@ def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
handle_image_inquiry(message)
|
||||
return "image_inquiry"
|
||||
if kind in {"text", ""}:
|
||||
from agent.handlers.attachment_inquiry import append_attach_text, is_active_attachment_wait
|
||||
from agent.handlers.attachment_inquiry import (
|
||||
abandon_idle_attach_wait,
|
||||
append_attach_text,
|
||||
is_active_attachment_wait,
|
||||
is_idle_attachment_wait,
|
||||
)
|
||||
from agent.handlers.image_inquiry import append_wave_text, is_active_image_wave
|
||||
|
||||
if is_active_attachment_wait(message.sender_id) and (message.content or "").strip():
|
||||
if decision.intent == "attachment_inquiry":
|
||||
handle_attachment_inquiry(message)
|
||||
return "attachment_inquiry"
|
||||
append_attach_text(message.sender_id, message.content or "")
|
||||
logger.info("dispatch 附件波次收字 sender=%s", message.sender_id)
|
||||
return "attach_wave_follow_text"
|
||||
if is_idle_attachment_wait(message.sender_id):
|
||||
abandon_idle_attach_wait(message.sender_id)
|
||||
logger.info(
|
||||
"dispatch 附件未传材料,改走文字 sender=%s intent=%s",
|
||||
message.sender_id,
|
||||
decision.intent,
|
||||
)
|
||||
else:
|
||||
append_attach_text(message.sender_id, message.content or "")
|
||||
logger.info("dispatch 附件波次收字 sender=%s", message.sender_id)
|
||||
return "attach_wave_follow_text"
|
||||
if is_active_image_wave(message.sender_id) and (message.content or "").strip():
|
||||
append_wave_text(message.sender_id, message.content or "")
|
||||
logger.info("dispatch 图片波次收字 sender=%s", message.sender_id)
|
||||
|
||||
@@ -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
|
||||
@@ -171,6 +171,129 @@ class AirGroupCollabTests(unittest.TestCase):
|
||||
self.assertNotIn("当前工单:", text)
|
||||
self.assertNotIn("贸易条款", text)
|
||||
|
||||
def test_air_group_brief_tms_total_only_once(self) -> None:
|
||||
"""群摘要 TMS 段总销售价只一行,禁止重复。"""
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"total": "183370.0",
|
||||
"currency": "CNY",
|
||||
"eta": "-",
|
||||
"valid_until": "2026-09-30",
|
||||
"fee_lines": [
|
||||
{"name": "运输费", "amount": "11.0", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202609220018",
|
||||
facts={"起运港": "珠海", "目的港": "克拉克"},
|
||||
quote=tms,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=tms,
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
self.assertIn("总销售价:CNY 183370.0", tms_block)
|
||||
self.assertNotIn("时效", tms_block)
|
||||
self.assertIn("报价有效期:2026-09-30", tms_block)
|
||||
|
||||
def test_air_group_brief_uses_route_fees_not_air_freight_total(self) -> None:
|
||||
"""
|
||||
群摘要必须跟单聊同一套线路分项。
|
||||
主账快照若只有「空运费=总价」,仍要列出该线路运输费/提送货费/人工费。
|
||||
"""
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"source_label": "TMS标准报价",
|
||||
"total": "183370.0",
|
||||
"freight": "183370.0",
|
||||
"currency": "CNY",
|
||||
"eta": "-",
|
||||
"valid_until": "2026-09-30",
|
||||
"fee_lines": [
|
||||
{
|
||||
"name": "空运费",
|
||||
"amount": "183370.00",
|
||||
"currency": "CNY",
|
||||
}
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CHG",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0},
|
||||
{"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0},
|
||||
{"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202609220018",
|
||||
facts={"起运港": "珠海", "目的港": "克拉克", "originCode": "ZUH"},
|
||||
quote=tms,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=tms,
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertIn("运输费:CNY 11.0", tms_block)
|
||||
self.assertIn("提送货费:CNY 6.0", tms_block)
|
||||
self.assertIn("人工费:CNY 123.0", tms_block)
|
||||
self.assertIn("总销售价:CNY 183370", tms_block)
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
self.assertNotIn("空运费", tms_block)
|
||||
|
||||
def test_air_group_brief_multi_route_does_not_borrow_other_air_freight(self) -> None:
|
||||
"""多条线路:只出对上起运港那条的分项,禁止套整单空运费或另一条的运输费。"""
|
||||
tms = {
|
||||
"source": "TMS",
|
||||
"total": "200040",
|
||||
"freight": "183370.00",
|
||||
"currency": "CNY",
|
||||
"valid_until": "2026-09-30",
|
||||
"fee_lines": [
|
||||
{"name": "空运费", "amount": "183370.00", "currency": "CNY"},
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CHG",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"originAirportCode": "SZX",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 200040,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
text = copy.air_group_brief(
|
||||
work_order_no="WO202609220018",
|
||||
facts={"起运港": "深圳", "目的港": "克拉克", "originCode": "SZX"},
|
||||
quote=tms,
|
||||
collab_facts={},
|
||||
has_price=True,
|
||||
tms_quote=tms,
|
||||
)
|
||||
tms_block = text.split("TMS报价:", 1)[1]
|
||||
self.assertNotIn("空运费", tms_block)
|
||||
self.assertNotIn("运输费", tms_block)
|
||||
self.assertIn("总销售价:CNY 200040", tms_block)
|
||||
self.assertEqual(tms_block.count("总销售价:"), 1)
|
||||
|
||||
def test_work_order_only_is_activate_not_quote(self) -> None:
|
||||
from agent.routing.air_group_intent import INTENT_ACTIVATE
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from agent.schema.air_route_options import (
|
||||
needs_route_pick,
|
||||
option_fee_lines,
|
||||
parse_air_options,
|
||||
project_group_air_quote,
|
||||
project_selected_quote,
|
||||
route_codes,
|
||||
route_line,
|
||||
@@ -220,6 +221,73 @@ class AirRouteOptionsTests(unittest.TestCase):
|
||||
self.assertIn("总销售价", detail)
|
||||
self.assertIn("200040", detail)
|
||||
|
||||
def test_group_air_quote_projects_route_fees_and_drops_total_alias(self) -> None:
|
||||
"""群摘要投影:该线路分项留下,空运费=总价丢掉。"""
|
||||
quote = {
|
||||
"total": "183370.0",
|
||||
"freight": "183370.0",
|
||||
"currency": "CNY",
|
||||
"fee_lines": [
|
||||
{"name": "空运费", "amount": "183370.00", "currency": "CNY"},
|
||||
],
|
||||
"airOptions": [
|
||||
{
|
||||
"originAirportCode": "ZUH",
|
||||
"transferAirportCode": "CHG",
|
||||
"destinationAirportCode": "CRK",
|
||||
"salePrice": 183370,
|
||||
"currency": "CNY",
|
||||
"departureCharges": [
|
||||
{"chargeName": "运输费", "currency": "CNY", "unitPrice": 11.0},
|
||||
{"chargeName": "提送货费", "currency": "CNY", "unitPrice": 6.0},
|
||||
{"chargeName": "人工费", "currency": "CNY", "unitPrice": 123.0},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
projected = project_group_air_quote(quote, {"originCode": "ZUH"})
|
||||
names = [str(x.get("name") or "") for x in (projected.get("fee_lines") or [])]
|
||||
self.assertEqual(names, ["运输费", "提送货费", "人工费"])
|
||||
self.assertNotIn("空运费", names)
|
||||
self.assertEqual(copy.quote_fee_lines(projected)[0][0], "运输费")
|
||||
|
||||
def test_air_detail_text_hides_eta_keeps_valid_until(self) -> None:
|
||||
"""空运没有时效;价格明细只留报价有效期。"""
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220013",
|
||||
quote={
|
||||
"source_label": "TMS标准报价",
|
||||
"selected_air_option": {
|
||||
"originAirportCode": "NKG",
|
||||
"destinationAirportCode": "KUL",
|
||||
},
|
||||
"fee_lines": [
|
||||
{"name": "增点费", "amount": "100.0", "currency": "CNY"},
|
||||
{"name": "拆板费", "amount": "100.0", "currency": "USD"},
|
||||
],
|
||||
"total": "5001.0",
|
||||
"currency": "CNY",
|
||||
"eta": "3天",
|
||||
"valid_until": "2026-09-30",
|
||||
},
|
||||
business_line="AIR",
|
||||
)
|
||||
self.assertEqual(
|
||||
text,
|
||||
"\n".join(
|
||||
[
|
||||
"TMS标准报价",
|
||||
"WO202609220013价格明细",
|
||||
"NKG-KUL",
|
||||
"增点费:CNY 100.0",
|
||||
"拆板费:USD 100.0",
|
||||
"总销售价:CNY 5001.0",
|
||||
"报价有效期:2026-09-30",
|
||||
]
|
||||
),
|
||||
)
|
||||
self.assertNotIn("时效", text)
|
||||
|
||||
def test_h5_page_lists_route_and_fees(self) -> None:
|
||||
from agent.channel.h5.air_route_page import render_air_route_page
|
||||
from agent.channel.h5.air_route_store import build_air_route_url
|
||||
|
||||
@@ -280,7 +280,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn(copy.BTN_SKIP_COLLAB, joined)
|
||||
self.assertIn("价格明细", joined)
|
||||
self.assertIn("总销售价", joined)
|
||||
self.assertIn("时效:", joined)
|
||||
self.assertNotIn("时效:", joined)
|
||||
self.assertIn("报价有效期:", joined)
|
||||
self.assertNotIn("相同询价内容检索", joined)
|
||||
self.assertNotIn("引用历史工单号", joined)
|
||||
@@ -324,7 +324,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
)
|
||||
rows = {x["keyname"]: x["value"] for x in card["horizontal_content_list"]}
|
||||
self.assertIn("2.5", rows["货物"])
|
||||
self.assertEqual(rows["时效"], "3-5 天")
|
||||
self.assertNotIn("时效", rows)
|
||||
self.assertNotIn("sub_title_text", card)
|
||||
n = len(self.replies)
|
||||
again = self.flow.on_button(
|
||||
@@ -346,6 +346,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertTrue(str(follow.get("task_id") or "").startswith("deal-"))
|
||||
deal_rows = {r["keyname"]: r["value"] for r in follow.get("horizontal_content_list") or []}
|
||||
self.assertIn("总销售价", deal_rows)
|
||||
self.assertNotIn("时效", deal_rows)
|
||||
self.assertNotIn("历史报价", deal_rows)
|
||||
self.assertNotIn("工单状态", deal_rows)
|
||||
self.assertNotIn("来源", deal_rows)
|
||||
@@ -829,7 +830,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(copy.money_text("14669.60", ""), "14669.60")
|
||||
self.assertNotIn(copy.collab_hint("WO202609140001"), quote_text)
|
||||
sub = card["sub_title_text"]
|
||||
self.assertIn("时效:3-5 天", sub)
|
||||
self.assertNotIn("时效", sub)
|
||||
self.assertIn("报价有效期:2026-09-20", sub)
|
||||
self.assertNotIn("相同询价内容检索", sub)
|
||||
self.assertNotIn("引用历史工单号", sub)
|
||||
@@ -866,6 +867,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("空运费:USD 1250.00", text)
|
||||
self.assertIn("总销售价:CNY 142998.53", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertIn("报价有效期:2026-09-30", text)
|
||||
card_text = copy.quote_area_safe_text(long_quote, business_line="AIR")
|
||||
self.assertIn("总销售价:CNY 142998.53", card_text)
|
||||
self.assertIn("详见上一条明细", card_text)
|
||||
@@ -985,7 +987,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("起运地:上海", text)
|
||||
self.assertIn("体积:2.5", text)
|
||||
self.assertIn("空运费:USD 1,200", text)
|
||||
self.assertIn("时效:3-5 天", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertIn("报价有效期:2026-09-20", text)
|
||||
self.assertNotIn("报价日期:", text)
|
||||
self.assertNotIn("工单状态", text)
|
||||
@@ -1015,20 +1017,20 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(rows["航线"], "空运SHA→LAX")
|
||||
self.assertIn("普货", rows["货物"])
|
||||
self.assertIn("2.5", rows["货物"])
|
||||
self.assertEqual(rows["时效"], "3-5 天")
|
||||
self.assertNotIn("时效", rows)
|
||||
self.assertEqual(rows["报价有效期"], "2026-09-20")
|
||||
self.assertNotIn("报价日期", rows)
|
||||
empty_eta = copy.scheme_horizontal_rows(
|
||||
work_order_no="WO1", facts=COMPLETE_AIR, quote={"valid_until": "2026-09-20"}
|
||||
)
|
||||
self.assertEqual({x["keyname"]: x["value"] for x in empty_eta}["时效"], "-")
|
||||
self.assertNotIn("时效", {x["keyname"] for x in empty_eta})
|
||||
dashed = copy.scheme_horizontal_rows(
|
||||
work_order_no="WO1",
|
||||
facts=COMPLETE_AIR,
|
||||
quote={"eta": "-3-5 天", "valid_until": "2026-09-20"},
|
||||
)
|
||||
dashed_rows = {x["keyname"]: x["value"] for x in dashed}
|
||||
self.assertEqual(dashed_rows["时效"], "3-5 天")
|
||||
self.assertNotIn("时效", dashed_rows)
|
||||
self.assertEqual(len(card["button_list"]), 3)
|
||||
self.assertEqual(
|
||||
[x["text"] for x in card["button_list"]],
|
||||
@@ -1060,8 +1062,7 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertIn("报价单已发送,请跟进成交状态", text)
|
||||
self.assertIn("咨询成交状态:请选择成交 / 未成交 / 协商中(未成交需补充原因)。", text)
|
||||
self.assertIn("总销售价:CNY 722.5", text)
|
||||
self.assertIn("时效:2天", text)
|
||||
self.assertNotIn("时效(要求/TMS)", text)
|
||||
self.assertNotIn("时效", text)
|
||||
self.assertNotIn("- / -", text)
|
||||
self.assertIn("报价有效期:2026-08-24", text)
|
||||
self.assertNotIn("历史报价", text)
|
||||
@@ -1081,7 +1082,6 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
rows,
|
||||
{
|
||||
"总销售价": "CNY 722.5",
|
||||
"时效": "2天",
|
||||
"报价有效期": "2026-08-24",
|
||||
},
|
||||
)
|
||||
@@ -1102,10 +1102,13 @@ class AirTextInquiryTests(unittest.TestCase):
|
||||
self.assertEqual(copy.canonical_button("negotiate:WO202608210017"), copy.BTN_NEGOTIATE)
|
||||
empty_rows = {
|
||||
x["keyname"]: x["value"]
|
||||
for x in copy.deal_horizontal_rows(quote={"total": "-", "valid_until": "-"})
|
||||
for x in copy.deal_horizontal_rows(
|
||||
quote={"total": "-", "valid_until": "-"},
|
||||
business_line="AIR",
|
||||
)
|
||||
}
|
||||
self.assertEqual(empty_rows["时效"], "-")
|
||||
self.assertNotEqual(empty_rows["时效"], "- / -")
|
||||
self.assertNotIn("时效", empty_rows)
|
||||
self.assertEqual(empty_rows["报价有效期"], "-")
|
||||
|
||||
def test_same_demand_cites_latest_even_if_closed(self) -> None:
|
||||
ledger = MemoryLedger()
|
||||
|
||||
@@ -239,13 +239,34 @@ class AttachmentInquiryTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(name, "attachment_inquiry")
|
||||
|
||||
def test_follow_text_not_create_ticket_before_upload(self) -> None:
|
||||
def test_text_inquiry_before_upload_gets_reply(self) -> None:
|
||||
"""发卡后还没传材料:销售改口打字询价必须有回话,不能默收。"""
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="海运上海到纽约 2个40尺"),
|
||||
InboundMessage(sender_id="s1", message_id="m2", content=SEA_ORAL),
|
||||
RouteDecision(intent="ordinary_text_inquiry"),
|
||||
)
|
||||
self.assertEqual(name, "ordinary_text_inquiry")
|
||||
self.assertIsNone(get_attach_wave_book().active("s1"))
|
||||
self.assertIsNotNone(get_sea_text_flow().session_of("s1"))
|
||||
|
||||
def test_follow_text_while_recognizing_not_new_ticket(self) -> None:
|
||||
"""已经开始认附件:期间的字并进口播,不另开文字询价。"""
|
||||
handle_attachment_inquiry(
|
||||
InboundMessage(sender_id="s1", message_id="m1", content="附件询价"),
|
||||
store=self.store,
|
||||
)
|
||||
accept_h5_uploads(
|
||||
sender_id="s1",
|
||||
files=[("sea.xlsx", b"fake")],
|
||||
store=self.store,
|
||||
process_now=False,
|
||||
)
|
||||
name = dispatch_inbound(
|
||||
InboundMessage(sender_id="s1", message_id="m2", content="只要整柜"),
|
||||
RouteDecision(intent="ordinary_text_inquiry"),
|
||||
)
|
||||
self.assertEqual(name, "attach_wave_follow_text")
|
||||
|
||||
@@ -66,6 +66,30 @@ class AttachmentUploadH5Tests(unittest.TestCase):
|
||||
self.assertEqual(miss.status_code, 410)
|
||||
self.assertIn("过期", miss.text)
|
||||
|
||||
def test_bare_token_opens_upload_page_not_json(self) -> None:
|
||||
"""
|
||||
公网 Nginx 会把 /inquiry-form/upload/{token} 改写成 /inquiry-form/{token}。
|
||||
附件票必须出上传页,不能把补问骨架 JSON 直接给销售。
|
||||
"""
|
||||
token = self._open_card()
|
||||
hit = self.client.get(f"/inquiry-form/{token}")
|
||||
self.assertEqual(hit.status_code, 200)
|
||||
self.assertIn("text/html", hit.headers.get("content-type", ""))
|
||||
self.assertIn("Excel", hit.text)
|
||||
self.assertNotIn("骨架", hit.text)
|
||||
self.assertNotIn("s1", hit.text)
|
||||
miss = self.client.get("/inquiry-form/not-a-real-token")
|
||||
self.assertIn(miss.status_code, (410, 422))
|
||||
|
||||
def test_bare_token_post_png_rejected(self) -> None:
|
||||
token = self._open_card()
|
||||
hit = self.client.post(
|
||||
f"/inquiry-form/{token}",
|
||||
files=[("files", ("shot.png", b"\x89PNG", "image/png"))],
|
||||
)
|
||||
self.assertEqual(hit.status_code, 400)
|
||||
self.assertIn("Excel", hit.text)
|
||||
|
||||
def test_upload_page_uses_card_chrome(self) -> None:
|
||||
from agent.channel.h5.upload_page import expired_upload_page, render_upload_message, render_upload_page
|
||||
|
||||
|
||||
@@ -74,6 +74,21 @@ class HttpLedgerParseTests(unittest.TestCase):
|
||||
self.assertEqual(body["workOrderNo"], "WO202609220001")
|
||||
self.assertEqual(body["facts"]["起运港"], "深圳")
|
||||
|
||||
def test_upsert_quote_posts_replace_tms(self) -> None:
|
||||
fake = _FakeLedgerClient()
|
||||
out = HttpLedger(client=fake).upsert_quote(
|
||||
work_order_no="WO202609220015",
|
||||
quote={"total": "17100", "currency": "CNY", "route_line": "上海-谅山-胡志明"},
|
||||
to_status="已报价",
|
||||
)
|
||||
self.assertTrue(out.get("ok"))
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
method, path, body = fake.calls[0]
|
||||
self.assertEqual(method, "POST")
|
||||
self.assertEqual(path, "/inquiry/agent/ticket/replaceTmsQuote")
|
||||
self.assertEqual(body["workOrderNo"], "WO202609220015")
|
||||
self.assertEqual(body["quote"]["total"], "17100")
|
||||
|
||||
def test_void_quote_files_posts_agent_path(self) -> None:
|
||||
fake = _FakeLedgerClient()
|
||||
out = HttpLedger(client=fake).void_quote_files(work_order_no="WO202609220001")
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
陆运线路:按 segmentSequence 成条;一段两段是一条带中转,不是两条。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from agent.schema.land_route_options import (
|
||||
group_land_routes,
|
||||
needs_route_pick,
|
||||
option_fee_lines,
|
||||
option_total,
|
||||
project_selected_route,
|
||||
route_line,
|
||||
)
|
||||
|
||||
|
||||
def _single_with_transfer() -> dict:
|
||||
return {
|
||||
"isMulti": 1,
|
||||
"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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _three_routes() -> dict:
|
||||
base = _single_with_transfer()
|
||||
base["isMulti"] = 2
|
||||
extra = [
|
||||
{
|
||||
"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,
|
||||
},
|
||||
]
|
||||
base["landSegments"] = list(base["landSegments"]) + extra
|
||||
return base
|
||||
|
||||
|
||||
def _tms_land_fees(*pairs: tuple[int, int, str, int]) -> list[dict]:
|
||||
"""TMS feeItems 原文:费用名按回包,不编路段名。"""
|
||||
rows: list[dict] = []
|
||||
for seq, leg, name, amount in pairs:
|
||||
rows.append(
|
||||
{
|
||||
"feeName": name,
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"currency": "CNY",
|
||||
"segmentSequence": seq,
|
||||
"routeLegSequence": leg,
|
||||
"unit": "台",
|
||||
"quantity": 1,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
class LandRouteOptionsTests(unittest.TestCase):
|
||||
def test_two_legs_one_route(self) -> None:
|
||||
quote = _single_with_transfer()
|
||||
routes = group_land_routes(quote)
|
||||
self.assertEqual(len(routes), 1)
|
||||
self.assertFalse(needs_route_pick(quote))
|
||||
self.assertEqual(route_line(routes[0]), "上海-谅山-胡志明")
|
||||
fees = option_fee_lines(routes[0])
|
||||
self.assertEqual(fees[0][0], "上海 → 谅山")
|
||||
self.assertIn("7500", fees[0][1])
|
||||
self.assertEqual(fees[1][0], "谅山 → 胡志明")
|
||||
self.assertIn("17100", option_total(routes[0]))
|
||||
|
||||
def test_three_segment_sequences_need_h5(self) -> None:
|
||||
quote = _three_routes()
|
||||
routes = group_land_routes(quote)
|
||||
self.assertEqual(len(routes), 3)
|
||||
self.assertTrue(needs_route_pick(quote))
|
||||
self.assertEqual(route_line(routes[1]), "上海-深圳-胡志明")
|
||||
|
||||
def test_project_writes_n_plus_eta_not_hours(self) -> None:
|
||||
quote = {
|
||||
"isMulti": 1,
|
||||
"eta": "-",
|
||||
"landSegments": [
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": "谅山",
|
||||
"costPrice": 7500,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+2",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 2,
|
||||
"origin": "谅山",
|
||||
"destination": "胡志明",
|
||||
"costPrice": 9600,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
],
|
||||
}
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[0])
|
||||
eta = str(projected.get("eta") or "")
|
||||
self.assertIn("N+2", eta)
|
||||
self.assertIn("N+3", eta)
|
||||
self.assertIn("17100", str(projected.get("total") or ""))
|
||||
|
||||
def test_project_keeps_tms_fee_names_not_od(self) -> None:
|
||||
quote = _single_with_transfer()
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500),
|
||||
(1, 2, "运输费", 9600),
|
||||
)
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[0])
|
||||
names = [x.get("name") for x in projected.get("fee_lines") or []]
|
||||
self.assertEqual(names, ["运输费", "运输费"])
|
||||
joined = json.dumps(projected.get("fee_lines"), ensure_ascii=False)
|
||||
self.assertNotIn("上海 →", joined)
|
||||
self.assertIn("7500", joined)
|
||||
self.assertIn("9600", joined)
|
||||
|
||||
def test_project_keeps_only_selected_legs(self) -> None:
|
||||
quote = _three_routes()
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500),
|
||||
(1, 2, "运输费", 9600),
|
||||
(2, 1, "运输费", 7500),
|
||||
(2, 2, "运输费", 9600),
|
||||
(3, 1, "提货费", 500),
|
||||
(3, 2, "运输费", 9600),
|
||||
)
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[1])
|
||||
raw = projected.get("landSegments")
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
seqs = {int(x.get("segmentSequence") or 0) for x in raw}
|
||||
self.assertEqual(seqs, {2})
|
||||
names = [x.get("name") for x in projected.get("fee_lines") or []]
|
||||
self.assertEqual(names, ["运输费", "运输费"])
|
||||
self.assertIn("17100", str(projected.get("total") or ""))
|
||||
self.assertNotIn("谅山", json.dumps(projected.get("fee_lines"), ensure_ascii=False))
|
||||
self.assertNotIn("提货费", json.dumps(projected.get("fee_lines"), ensure_ascii=False))
|
||||
self.assertNotIn("上海 →", json.dumps(projected.get("fee_lines"), ensure_ascii=False))
|
||||
|
||||
def test_detail_puts_tms_fee_under_each_leg(self) -> None:
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
quote = _single_with_transfer()
|
||||
quote["landSegments"][0]["origin"] = "上海"
|
||||
quote["landSegments"][0]["destination"] = "谅山Lạng Sơn-春疆货场Huu Nghi"
|
||||
quote["landSegments"][0]["transitDays"] = "N+2"
|
||||
quote["landSegments"][1]["origin"] = "谅山Lạng Sơn-春疆货场Huu Nghi"
|
||||
quote["landSegments"][1]["destination"] = "胡志明Hồ Chí Minh"
|
||||
quote["landSegments"][1]["transitDays"] = "N+3"
|
||||
quote["fee_lines"] = _tms_land_fees(
|
||||
(1, 1, "运输费", 7500.0),
|
||||
(1, 2, "运输费", 9600.0),
|
||||
)
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[0])
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220029",
|
||||
quote=projected,
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertIn("上海-谅山Lạng Sơn-春疆货场Huu Nghi-胡志明Hồ Chí Minh", text)
|
||||
od1 = text.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi")
|
||||
fee1 = text.find("运输费:CNY 7500", od1)
|
||||
od2 = text.find("谅山Lạng Sơn-春疆货场Huu Nghi → 胡志明Hồ Chí Minh")
|
||||
fee2 = text.find("运输费:CNY 9600", od2)
|
||||
self.assertGreaterEqual(od1, 0)
|
||||
self.assertGreater(fee1, od1)
|
||||
self.assertGreater(od2, fee1)
|
||||
self.assertGreater(fee2, od2)
|
||||
eta1 = text.find("时效:N+2", fee1)
|
||||
eta2 = text.find("时效:N+3", fee2)
|
||||
self.assertGreater(eta1, fee1)
|
||||
self.assertLess(eta1, od2)
|
||||
self.assertGreater(eta2, fee2)
|
||||
self.assertIn("总成本价:CNY 17100", text)
|
||||
|
||||
def test_h5_lists_segment_costs(self) -> None:
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
html = render_land_route_page(
|
||||
{"work_order_no": "WO202609220010", "quote": _three_routes()}
|
||||
)
|
||||
self.assertIn("上海-深圳-胡志明", html)
|
||||
self.assertIn("上海 → 深圳", html)
|
||||
self.assertIn("成本价合计", html)
|
||||
self.assertNotIn("userid", html.lower())
|
||||
|
||||
def test_h5_puts_tms_fee_under_each_leg(self) -> None:
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
|
||||
quote = _three_routes()
|
||||
for leg in quote["landSegments"]:
|
||||
if str(leg.get("destination") or "") == "深圳":
|
||||
leg["transitDays"] = "N+2"
|
||||
if str(leg.get("origin") or "") == "深圳":
|
||||
leg["transitDays"] = "N+3"
|
||||
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("上海 → 深圳")
|
||||
fee = page.find("运输费", od)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(fee, od)
|
||||
eta = page.find("时效", fee)
|
||||
self.assertGreater(eta, fee)
|
||||
self.assertIn("N+2", page)
|
||||
self.assertIn("N+3", page)
|
||||
self.assertIn("CNY 7500", page)
|
||||
self.assertIn("成本价合计", 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
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
quote = {
|
||||
"isMulti": "2",
|
||||
"currency": "CNY",
|
||||
"landSegments": json.dumps(
|
||||
[
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": "谅山Lạng Sơn-春疆货场Huu Nghi",
|
||||
"costPrice": 7500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+2",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 2,
|
||||
"origin": "谅山Lạng Sơn-春疆货场Huu Nghi",
|
||||
"destination": "胡志明Hồ Chí Minh",
|
||||
"costPrice": 9600.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 2,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": "广西",
|
||||
"costPrice": 7500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 2,
|
||||
"routeLegSequence": 2,
|
||||
"origin": "广西",
|
||||
"destination": "胡志明Hồ Chí Minh",
|
||||
"costPrice": 12500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"fee_lines": json.dumps(
|
||||
[
|
||||
{"name": "运输费", "amount": "7500.0", "currency": "CNY"},
|
||||
{"name": "运输费", "amount": "9600.0", "currency": "CNY"},
|
||||
{"name": "运输费", "amount": "7500.0", "currency": "CNY"},
|
||||
{"name": "运输费", "amount": "12500.0", "currency": "CNY"},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
page = render_land_route_page(
|
||||
{"work_order_no": "WO202609220036", "quote": quote}
|
||||
)
|
||||
od = page.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi")
|
||||
fee = page.find("运输费", od)
|
||||
eta = page.find("时效", fee)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(fee, od)
|
||||
self.assertGreater(eta, fee)
|
||||
self.assertIn("N+2", page)
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[0])
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220036",
|
||||
quote=projected,
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertIn("运输费:CNY 7500", text)
|
||||
self.assertIn("时效:N+2", text)
|
||||
self.assertIn("时效:N+3", text)
|
||||
self.assertNotRegex(text, r"上海 → 谅山.+:CNY")
|
||||
|
||||
def test_recovers_transport_fee_when_fee_lines_are_od_names(self) -> None:
|
||||
"""测服 WO036 这类:fee_lines 被写成路段名,销售侧仍要看到运输费和时效。"""
|
||||
from agent.channel.h5.land_route_page import render_land_route_page
|
||||
from agent.policy import inquiry_copy as copy
|
||||
|
||||
quote = {
|
||||
"isMulti": "2",
|
||||
"currency": "CNY",
|
||||
"route_line": "上海-谅山Lạng Sơn-春疆货场Huu Nghi-胡志明Hồ Chí Minh",
|
||||
"landSegments": [
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": "谅山Lạng Sơn-春疆货场Huu Nghi",
|
||||
"costPrice": 7500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+2",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 1,
|
||||
"routeLegSequence": 2,
|
||||
"origin": "谅山Lạng Sơn-春疆货场Huu Nghi",
|
||||
"destination": "胡志明Hồ Chí Minh",
|
||||
"costPrice": 9600.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 2,
|
||||
"routeLegSequence": 1,
|
||||
"origin": "上海",
|
||||
"destination": "广西",
|
||||
"costPrice": 7500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
{
|
||||
"segmentSequence": 2,
|
||||
"routeLegSequence": 2,
|
||||
"origin": "广西",
|
||||
"destination": "胡志明Hồ Chí Minh",
|
||||
"costPrice": 12500.0,
|
||||
"currency": "CNY",
|
||||
"transitDays": "N+3",
|
||||
},
|
||||
],
|
||||
"fee_lines": [
|
||||
{"name": "上海 → 谅山Lạng Sơn-春疆货场Huu Nghi", "amount": "7500.0", "currency": "CNY"},
|
||||
{"name": "谅山Lạng Sơn-春疆货场Huu Nghi → 胡志明Hồ Chí Minh", "amount": "9600.0", "currency": "CNY"},
|
||||
],
|
||||
}
|
||||
page = render_land_route_page(
|
||||
{"work_order_no": "WO202609220036", "quote": quote}
|
||||
)
|
||||
od = page.find("上海 → 谅山Lạng Sơn-春疆货场Huu Nghi")
|
||||
fee = page.find("运输费", od)
|
||||
eta = page.find("时效", fee)
|
||||
self.assertGreaterEqual(od, 0)
|
||||
self.assertGreater(fee, od)
|
||||
self.assertGreater(eta, fee)
|
||||
self.assertIn("N+2", page)
|
||||
routes = group_land_routes(quote)
|
||||
projected = project_selected_route(quote, routes[0])
|
||||
text = copy.sea_quote_detail_text(
|
||||
work_order_no="WO202609220036",
|
||||
quote=projected,
|
||||
business_line="LAND",
|
||||
)
|
||||
self.assertIn("运输费:CNY 7500", text)
|
||||
self.assertIn("时效:N+2", text)
|
||||
self.assertIn("时效:N+3", text)
|
||||
self.assertNotRegex(text, r"上海 → 谅山.+:CNY")
|
||||
|
||||
def test_single_emits_detail_multi_opens_h5(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
def _go(flag: str) -> tuple[str, list[str]]:
|
||||
flow = LandTextInquiryFlow(MemoryLedger())
|
||||
flow._detail_before_card_gap_sec = 0
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "胡志明",
|
||||
"品名": "衣服",
|
||||
"体积": "100CBM",
|
||||
"毛重": "50kg",
|
||||
flag: "1",
|
||||
}
|
||||
flow.on_text(
|
||||
sender_id="u_land_rt",
|
||||
text="国内运输拼车 国内长途/零担 急件",
|
||||
reply=reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="LAND",
|
||||
)
|
||||
phase = flow.on_text(sender_id="u_land_rt", text="确定", reply=reply)
|
||||
return phase, replies
|
||||
|
||||
phase, replies = _go("tms_land_single")
|
||||
self.assertEqual(phase, "wait_collab")
|
||||
self.assertTrue(any("运输费" in x for x in replies))
|
||||
self.assertTrue(any("上海-谅山-胡志明" in x for x in replies))
|
||||
self.assertTrue(any("上海 → 谅山" in x for x in replies))
|
||||
phase, replies = _go("tms_land_multi")
|
||||
self.assertEqual(phase, "wait_land_route")
|
||||
self.assertTrue(any("条线路" in x for x in replies))
|
||||
|
||||
def test_select_then_reselect_after_adopt_voids_old_file(self) -> None:
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy import inquiry_copy as copy
|
||||
from agent.policy.land_text_flow import LandTextInquiryFlow
|
||||
|
||||
ledger = MemoryLedger()
|
||||
flow = LandTextInquiryFlow(ledger)
|
||||
flow._detail_before_card_gap_sec = 0
|
||||
replies: list[str] = []
|
||||
|
||||
def reply(text: str, extra=None) -> str:
|
||||
replies.append(text)
|
||||
_ = extra
|
||||
return ""
|
||||
|
||||
facts = {
|
||||
"起运港": "上海",
|
||||
"目的港": "胡志明",
|
||||
"品名": "衣服",
|
||||
"体积": "100CBM",
|
||||
"毛重": "50kg",
|
||||
"tms_land_multi": "1",
|
||||
}
|
||||
flow.on_text(
|
||||
sender_id="u_land_void",
|
||||
text="国内运输拼车 国内长途/零担 急件",
|
||||
reply=reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="LAND",
|
||||
)
|
||||
self.assertEqual(
|
||||
flow.on_text(sender_id="u_land_void", text="确定", reply=reply),
|
||||
"wait_land_route",
|
||||
)
|
||||
self.assertEqual(
|
||||
flow.select_land_route(sender_id="u_land_void", index=1, reply=reply),
|
||||
"wait_collab",
|
||||
)
|
||||
joined = "\n".join(replies)
|
||||
self.assertIn("上海-深圳-胡志明", joined)
|
||||
self.assertIn("运输费", joined)
|
||||
self.assertIn("上海 → 深圳", joined)
|
||||
self.assertNotIn("谅山", "\n".join(replies[-3:]))
|
||||
self.assertEqual(
|
||||
flow.on_button(sender_id="u_land_void", action=copy.BTN_SKIP_COLLAB, reply=reply),
|
||||
"wait_adopt",
|
||||
)
|
||||
self.assertEqual(
|
||||
flow.on_button(sender_id="u_land_void", action=copy.BTN_ADOPT_EXCEL, reply=reply),
|
||||
"wait_deal",
|
||||
)
|
||||
wo = flow.session_of("u_land_void").work_order_no
|
||||
self.assertTrue(ledger.get_ticket(work_order_no=wo).quote_file_ready)
|
||||
phase = flow.select_land_route(sender_id="u_land_void", index=0, reply=reply)
|
||||
self.assertEqual(phase, "wait_adopt")
|
||||
self.assertFalse(ledger.get_ticket(work_order_no=wo).quote_file_ready)
|
||||
ticket = ledger.get_ticket(work_order_no=wo)
|
||||
segs = ticket.quote.get("landSegments") or []
|
||||
seqs = {int(x.get("segmentSequence") or 0) for x in segs}
|
||||
self.assertEqual(seqs, {1})
|
||||
self.assertTrue(any("报价方案" in x or "生成Excel" in x for x in replies[-3:]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -300,8 +300,10 @@ class QuoteAdjustTests(unittest.TestCase):
|
||||
self.assertIn("总成本价:CNY 2287.65", "\n".join(copy.quote_detail_lines(sea, business_line="SEA")))
|
||||
rows = copy.deal_horizontal_rows(quote=air, business_line="AIR")
|
||||
self.assertEqual(rows[0]["keyname"], "总销售价")
|
||||
self.assertNotIn("时效", [x["keyname"] for x in rows])
|
||||
land_rows = copy.deal_horizontal_rows(quote=sea, business_line="LAND")
|
||||
self.assertEqual(land_rows[0]["keyname"], "总成本价")
|
||||
self.assertIn("时效", [x["keyname"] for x in land_rows])
|
||||
|
||||
def test_adjust_cards_use_sales_adjust_not_tms(self) -> None:
|
||||
"""调价后价格明细卡、方案卡必须写销售调价,禁止再套 TMS 标准报价。"""
|
||||
|
||||
+10
@@ -290,6 +290,16 @@ public class InquiryAgentController {
|
||||
return Result.OK(workOrderService.queryTms(body));
|
||||
}
|
||||
|
||||
@ApiOperation("覆盖本单最新 TMS 报价(选定陆运线路后写回成本)")
|
||||
@PostMapping("/ticket/replaceTmsQuote")
|
||||
public Result<Map<String, Object>> replaceTmsQuote(@RequestBody Map<String, Object> body,
|
||||
HttpServletRequest http) {
|
||||
if (!authed(http)) {
|
||||
return Result.error(401, "unauthorized");
|
||||
}
|
||||
return Result.OK(workOrderService.replaceTmsQuote(body));
|
||||
}
|
||||
|
||||
@ApiOperation("海运拉群:按起运/目的匹配启用的产品·海运")
|
||||
@PostMapping("/staff/matchSea")
|
||||
public Result<Map<String, Object>> matchSea(@RequestBody Map<String, Object> body,
|
||||
|
||||
+6
@@ -78,6 +78,12 @@ public interface IInquiryWorkOrderService extends IService<InquiryTicket> {
|
||||
*/
|
||||
Map<String, Object> queryTms(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 覆盖本单最新 TMS 报价快照(选定陆运线路后写回成本/时效)。
|
||||
* 不新插 TMS 行,不改手工/调价行。
|
||||
*/
|
||||
Map<String, Object> replaceTmsQuote(Map<String, Object> body);
|
||||
|
||||
/**
|
||||
* 智能体续办:按工单号取字段/报价/推断停点。只读。
|
||||
* quote 是当前生效价(可能已是销售调价);tms_quote 永远是 TMS 原价,拉群摘要必须用这份。
|
||||
|
||||
+54
-4
@@ -751,7 +751,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
log.warn("报价出单 mappingJson 非法 wo={} err={}", workOrderNo, e.getMessage());
|
||||
return fail;
|
||||
}
|
||||
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId);
|
||||
JSONObject fillFacts = buildFillFacts(workOrderNo, facts, quote, salesWecomId, businessLine);
|
||||
String safeNo = StringUtils.defaultIfBlank(workOrderNo, "WO").replaceAll("[^A-Za-z0-9_-]", "");
|
||||
Path targetDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "render");
|
||||
Path target = targetDir.resolve(safeNo + "-" + System.currentTimeMillis() + ".xlsx");
|
||||
@@ -782,6 +782,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
*/
|
||||
public static JSONObject buildFillFacts(String workOrderNo, Map<String, Object> facts,
|
||||
Map<String, Object> quote, String salesWecomId) {
|
||||
return buildFillFacts(workOrderNo, facts, quote, salesWecomId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单填表投影。空运不写时效:业务确认空运没有时效字段。
|
||||
*
|
||||
* @param businessLine 智能体传入的业务线;空时再看询价事实 / 报价快照
|
||||
*/
|
||||
public static JSONObject buildFillFacts(String workOrderNo, Map<String, Object> facts,
|
||||
Map<String, Object> quote, String salesWecomId,
|
||||
String businessLine) {
|
||||
JSONObject out = new JSONObject(true);
|
||||
Map<String, Object> src = facts == null ? new LinkedHashMap<>() : facts;
|
||||
Map<String, Object> q = quote == null ? new LinkedHashMap<>() : quote;
|
||||
@@ -802,8 +813,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("fcl_or_lcl", first(src, "整柜或拼柜", "fcl_or_lcl"));
|
||||
out.put("container_type_qty", first(src, "箱型箱量", "container_type_qty"));
|
||||
out.put("pieces", first(src, "件数", "pieces"));
|
||||
out.put("weight_kg", first(src, "毛重", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm"));
|
||||
// 销售常写「重量」,合同键是毛重;两边都认
|
||||
out.put("weight_kg", first(src, "毛重", "重量", "weight_kg"));
|
||||
out.put("volume_cbm", first(src, "体积", "volume_cbm", "volume"));
|
||||
// 去掉单位后缀,避免模板「KG/CBM」列旁再写一遍单位
|
||||
String w = out.getString("weight_kg");
|
||||
if (StringUtils.isNotBlank(w)) {
|
||||
out.put("weight_kg", w.replaceAll("(?i)\\s*(KG|KGS|公斤)\\s*$", "").trim());
|
||||
}
|
||||
String vol = out.getString("volume_cbm");
|
||||
if (StringUtils.isNotBlank(vol)) {
|
||||
out.put("volume_cbm", vol.replaceAll("(?i)\\s*(CBM|立方米|方)\\s*$", "").trim());
|
||||
}
|
||||
out.put("quote_date", first(src, "报价日期", "quote_date", "询价日期"));
|
||||
out.put("trade_terms", first(src, "贸易条款", "trade_terms"));
|
||||
out.put("运输分类", first(src, "运输分类"));
|
||||
@@ -844,8 +865,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("currency", currency);
|
||||
}
|
||||
out.put("sales_name", StringUtils.defaultString(salesWecomId));
|
||||
boolean hideAirEta = looksAirInquiry(src, q, businessLine);
|
||||
String eta = first(q, "eta", "transit_days", "transitDays");
|
||||
if (StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
if (!hideAirEta && StringUtils.isNotBlank(eta) && !"-".equals(eta)) {
|
||||
out.put("transit_days", eta);
|
||||
out.put("transit_time", eta);
|
||||
}
|
||||
@@ -1014,6 +1036,14 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
out.put("route_rows", routeRows);
|
||||
}
|
||||
}
|
||||
if (hideAirEta) {
|
||||
// 空运没有时效:询价事实拷进来的、TMS eta、陆运键投影一律拿掉
|
||||
out.remove("transit_days");
|
||||
out.remove("transit_time");
|
||||
out.remove("transitDays");
|
||||
out.remove("时效");
|
||||
out.remove("时效(天)");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1029,6 +1059,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运销售面没有时效:出单不把 eta / transit_* 填进模板。
|
||||
* 业务线 AIR / 运输方式含空运 / 报价带 airOptions 都算空运。
|
||||
*/
|
||||
private static boolean looksAirInquiry(Map<String, Object> src, Map<String, Object> quote,
|
||||
String businessLine) {
|
||||
String line = StringUtils.defaultString(businessLine);
|
||||
if (StringUtils.isBlank(line) && src != null) {
|
||||
line = first(src, "业务线", "business_line", "businessLine", "运输方式");
|
||||
}
|
||||
if ("AIR".equalsIgnoreCase(line) || "空".equals(line) || (line != null && line.contains("空运"))) {
|
||||
return true;
|
||||
}
|
||||
if (quote != null && (quote.get("airOptions") != null || quote.get("air_options") != null
|
||||
|| quote.get("selected_air_option") != null)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 询价事实里已有陆运选项/始发站,运费兜底名就不能再写成空运费。
|
||||
*/
|
||||
|
||||
+12
@@ -586,6 +586,18 @@ public class InquiryWorkOrderServiceImpl extends ServiceImpl<InquiryTicketMapper
|
||||
return tmsQueryService.queryFromAgent(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定陆运线路后覆盖 TMS 快照。请求线程只转发,不查 TMS。
|
||||
*/
|
||||
@Override
|
||||
public Map<String, Object> replaceTmsQuote(Map<String, Object> body) {
|
||||
String no = text(body, "workOrderNo", "work_order_no");
|
||||
Object raw = body == null ? null : body.get("quote");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> quote = raw instanceof Map ? (Map<String, Object>) raw : new LinkedHashMap<String, Object>();
|
||||
return tmsQueryService.replaceTmsQuote(no, quote);
|
||||
}
|
||||
|
||||
private String nextWorkOrderNo() {
|
||||
String day = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||
long n = count(new LambdaQueryWrapper<InquiryTicket>().likeRight(InquiryTicket::getWorkOrderNo, "WO" + day));
|
||||
|
||||
+202
-14
@@ -595,9 +595,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
// 模板「预估总价」是 SUM。POI 写完数字不会重算,缓存仍是 0。
|
||||
// 企微/WPS 预览常不重算,销售会看到汇总为 0。这里把公式缓存刷成当前值。
|
||||
refreshFormulas(wb);
|
||||
// 刷公式后:预估费用若 SUM 了「发生时收取」固定费,会与 TMS 总销售价不一致。
|
||||
// 有 total_amount 时再按标签写回,以总销售价为准。
|
||||
// 其它标签可再写一遍;合计单独处理,避免「标签旁一个数 + SUM 格另一个数」。
|
||||
writeFactsByLabel(wb, facts, yellow);
|
||||
// 预估费用以 TMS 总销售价为准:盖掉 SUM(常把提送货/人工费加进去),本行只留一个数。
|
||||
overwriteEstimateTotal(wb, facts);
|
||||
// 转 PDF 前把纸张改成 A4 居中,避免顶部裁字、整页偏左。
|
||||
applyQuotePrintLayout(wb);
|
||||
Files.createDirectories(targetXlsx.getParent());
|
||||
@@ -779,7 +780,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("quantity"),
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")),
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, rowIdx, columns.getString("unit_price"),
|
||||
firstNonBlank(rowObj.getString("unit_price"), rowObj.getString("unitPrice")),
|
||||
yellow, sheet.getSheetName());
|
||||
@@ -820,15 +823,29 @@ public final class QuoteTemplateExcelSupport {
|
||||
|
||||
/**
|
||||
* 清空 [fromRow, stopRow) 费用列;黄底固定行保留(如「发生时收取」占位)。
|
||||
* <p>
|
||||
* stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE。
|
||||
* 若原样循环清空,会捅到 Excel 最大行(1048576)并抛 IllegalArgumentException,整张出单失败。
|
||||
* 因此未命中时只清到表内已有末行,绝不 createRow 扩到上限。
|
||||
*/
|
||||
private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns,
|
||||
Set<String> yellow, String sheetName) {
|
||||
if (sheet == null || columns == null || fromRow >= stopRow) {
|
||||
if (sheet == null || columns == null || fromRow < 0) {
|
||||
return;
|
||||
}
|
||||
int lastExisting = sheet.getLastRowNum();
|
||||
int end = stopRow;
|
||||
if (end == Integer.MAX_VALUE || end > lastExisting + 1) {
|
||||
end = lastExisting + 1;
|
||||
}
|
||||
// XSSF 合法行号 0..1048575;end 是开区间上界
|
||||
end = Math.min(end, 1048575 + 1);
|
||||
if (fromRow >= end) {
|
||||
return;
|
||||
}
|
||||
// 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过
|
||||
String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"};
|
||||
for (int r = fromRow; r < stopRow; r++) {
|
||||
for (int r = fromRow; r < end; r++) {
|
||||
for (String key : keys) {
|
||||
String colLetter = columns.getString(key);
|
||||
if (StringUtils.isBlank(colLetter)) {
|
||||
@@ -1317,8 +1334,11 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
|
||||
private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) {
|
||||
if (sheet == null || stopAtText == null || stopAtText.isEmpty()) {
|
||||
return Integer.MAX_VALUE;
|
||||
if (sheet == null) {
|
||||
return 0;
|
||||
}
|
||||
if (stopAtText == null || stopAtText.isEmpty()) {
|
||||
return sheet.getLastRowNum() + 1;
|
||||
}
|
||||
DataFormatter fmt = new DataFormatter();
|
||||
List<String> needles = new ArrayList<>();
|
||||
@@ -1329,7 +1349,7 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
if (needles.isEmpty()) {
|
||||
return Integer.MAX_VALUE;
|
||||
return sheet.getLastRowNum() + 1;
|
||||
}
|
||||
int last = sheet.getLastRowNum();
|
||||
for (int r = Math.max(0, fromRow); r <= last; r++) {
|
||||
@@ -1352,7 +1372,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Integer.MAX_VALUE;
|
||||
// 未命中:停在表末,禁止回 Integer.MAX_VALUE(清空费用占位时会捅爆 Excel 行上限)
|
||||
return last + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1440,7 +1461,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
int r = startRow + i;
|
||||
writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("quantity"),
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")),
|
||||
yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("unit_price"), rowObj.getString("unit_price"), yellow, sheet.getSheetName());
|
||||
writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName());
|
||||
if (columns.containsKey("currency")) {
|
||||
@@ -1456,6 +1479,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(colLetter)) {
|
||||
return;
|
||||
}
|
||||
// 清空不存在的行:不必 createRow,避免 stop 未命中时把表撑到 Excel 上限
|
||||
if (rowIdx < 0 || rowIdx > 1048575) {
|
||||
return;
|
||||
}
|
||||
int col = CellReference.convertColStringToIndex(colLetter.trim());
|
||||
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
|
||||
if (yellow != null && yellow.contains(ref)) {
|
||||
@@ -1463,6 +1490,9 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
Row row = sheet.getRow(rowIdx);
|
||||
if (row == null) {
|
||||
if (StringUtils.isBlank(value)) {
|
||||
return;
|
||||
}
|
||||
row = sheet.createRow(rowIdx);
|
||||
}
|
||||
Cell cell = row.getCell(col);
|
||||
@@ -1561,6 +1591,14 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isNotBlank(unit)) {
|
||||
line.put("unit", unit);
|
||||
}
|
||||
// 固定费无数量时默认 1;运输费留给 enrich 写计费重
|
||||
String qty = firstNonBlank(ch.getString("quantity"), ch.getString("chargeQuantity"),
|
||||
ch.getString("charge_quantity"));
|
||||
if (StringUtils.isNotBlank(qty)) {
|
||||
line.put("quantity", qty);
|
||||
} else if (!isAirWeightFeeName(name)) {
|
||||
line.put("quantity", "1");
|
||||
}
|
||||
String ccy = firstNonBlank(ch.getString("currency"), optCcy);
|
||||
if (StringUtils.isNotBlank(ccy)) {
|
||||
line.put("currency", ccy);
|
||||
@@ -1629,6 +1667,26 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 费用行数量:固定费(提送货/人工等)无 quantity 时默认 1;
|
||||
* 空运运输费缺数量时不要填 1(应由计费重 enrich,误填 1 会把金额算错观感)。
|
||||
*/
|
||||
static String feeQuantityOrDefault(String quantity, String feeName) {
|
||||
String q = StringUtils.trimToEmpty(quantity);
|
||||
if (!q.isEmpty() && !"-".equals(q)) {
|
||||
return q;
|
||||
}
|
||||
if (isAirWeightFeeName(feeName)) {
|
||||
return "";
|
||||
}
|
||||
return "1";
|
||||
}
|
||||
|
||||
/** 兼容旧调用:无费目名时按固定费处理(默认 1)。 */
|
||||
static String feeQuantityOrDefault(String quantity) {
|
||||
return feeQuantityOrDefault(quantity, "");
|
||||
}
|
||||
|
||||
public static boolean isAirWeightFeeName(String name) {
|
||||
String n = StringUtils.trimToEmpty(name);
|
||||
if (n.isEmpty()) {
|
||||
@@ -1681,6 +1739,123 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预估费用 / 预估总价行只保留一个总价。
|
||||
* <p>
|
||||
* 为何:模板金额列是 SUM,刷公式后常把「发生时收取」的提送货/人工费加进去;
|
||||
* 若再按标签把 TMS total 写到标签右侧空格,销售会看到同行两个数(如 183370 与 183499)。
|
||||
* 有 total_amount 时以它为准,写入原 SUM 格(或本行最右金额格),并清空同行其它多余数字。
|
||||
*/
|
||||
static void overwriteEstimateTotal(Workbook wb, JSONObject facts) {
|
||||
if (wb == null || facts == null) {
|
||||
return;
|
||||
}
|
||||
String total = firstFact(facts, "total_amount", "total", "报价合计", "预估费用", "预估总价");
|
||||
if (StringUtils.isBlank(total) || "-".equals(total.trim())) {
|
||||
return;
|
||||
}
|
||||
String norm = stripMoneyPrefix(total.trim());
|
||||
Double num;
|
||||
try {
|
||||
num = Double.parseDouble(norm.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
num = null;
|
||||
}
|
||||
for (int s = 0; s < wb.getNumberOfSheets(); s++) {
|
||||
Sheet sheet = wb.getSheetAt(s);
|
||||
if (sheet == null) {
|
||||
continue;
|
||||
}
|
||||
for (int r = 0; r <= sheet.getLastRowNum(); r++) {
|
||||
Row row = sheet.getRow(r);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
int labelCol = -1;
|
||||
for (Cell cell : row) {
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String t = StringUtils.trimToEmpty(readCellText(cell));
|
||||
if (t.contains("预估费用") || t.contains("预估总价") || t.contains("费用总计")
|
||||
|| t.contains("报价合计") || t.contains("总销售价")) {
|
||||
labelCol = cell.getColumnIndex();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (labelCol < 0) {
|
||||
continue;
|
||||
}
|
||||
Cell target = null;
|
||||
List<Cell> extras = new ArrayList<>();
|
||||
short last = row.getLastCellNum();
|
||||
for (int c = labelCol + 1; c < last; c++) {
|
||||
int[] dest = resolveMergeOrigin(sheet, r, c);
|
||||
Cell cell = row.getCell(dest[1]);
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
String exist = readCellText(cell);
|
||||
if (isFeeTableHeader(exist) || isLabelLike(exist) || isSectionTitle(sheet, cell)) {
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.FORMULA) {
|
||||
if (target == null) {
|
||||
target = cell;
|
||||
} else {
|
||||
extras.add(cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC
|
||||
|| looksLikeMoneyText(exist)) {
|
||||
extras.add(cell);
|
||||
}
|
||||
}
|
||||
if (target == null && !extras.isEmpty()) {
|
||||
// 无公式时取最右金额格作为唯一总价格
|
||||
target = extras.remove(extras.size() - 1);
|
||||
}
|
||||
if (target == null) {
|
||||
// 标签右侧尚无格:落在金额列常见位置(与费目表头对齐时由调用方保证)
|
||||
target = row.getCell(labelCol + 1);
|
||||
if (target == null) {
|
||||
target = row.createCell(labelCol + 1);
|
||||
}
|
||||
}
|
||||
if (num != null) {
|
||||
target.setCellValue(num.doubleValue());
|
||||
} else {
|
||||
target.setCellValue(norm);
|
||||
}
|
||||
for (Cell extra : extras) {
|
||||
if (extra == null || extra == target) {
|
||||
continue;
|
||||
}
|
||||
// POI 旧版无 setBlank;清空即可,避免同行残留第二份总价
|
||||
extra.setCellValue("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否像金额文本(含小数或纯数字),用于清掉预估费用行上误写的第二份数。 */
|
||||
private static boolean looksLikeMoneyText(String raw) {
|
||||
if (StringUtils.isBlank(raw) || "-".equals(raw.trim())) {
|
||||
return false;
|
||||
}
|
||||
String t = stripMoneyPrefix(raw.trim()).replace(",", "");
|
||||
if (!t.matches("-?\\d+(\\.\\d+)?")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Double.parseDouble(t);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回填后重算公式,把 SUM 等缓存从 0 刷成真实合计。
|
||||
* 失败不抛:出单仍要发出,只记日志。
|
||||
@@ -1922,7 +2097,8 @@ public final class QuoteTemplateExcelSupport {
|
||||
}
|
||||
writeMappedText(sheet, r, cols.get("currency"), lineCurrency, yellow);
|
||||
writeMappedText(sheet, r, cols.get("unit"), rowObj.getString("unit"), yellow);
|
||||
writeMappedNumber(sheet, r, qtyCol, rowObj.getString("quantity"), yellow);
|
||||
writeMappedNumber(sheet, r, qtyCol,
|
||||
feeQuantityOrDefault(rowObj.getString("quantity"), rowObj.getString("name")), yellow);
|
||||
writeMappedNumber(sheet, r, cols.get("unit_price"), rowObj.getString("unit_price"), yellow);
|
||||
writeMappedText(sheet, r, cols.get("remark"), formatFeeRemark(rowObj.getString("remark")), yellow);
|
||||
Integer boxCol = pickContainerRateColumn(cols, boxType);
|
||||
@@ -2552,6 +2728,10 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (StringUtils.isBlank(code)) {
|
||||
continue;
|
||||
}
|
||||
// 合计由 overwriteEstimateTotal 独占写入,避免标签旁与 SUM 格各写一份
|
||||
if ("total_amount".equals(code)) {
|
||||
continue;
|
||||
}
|
||||
if ("currency".equals(code) && feeHeader) {
|
||||
continue;
|
||||
}
|
||||
@@ -2635,13 +2815,21 @@ public final class QuoteTemplateExcelSupport {
|
||||
if (dict.containsKey(primary)) {
|
||||
return dict.get(primary);
|
||||
}
|
||||
// 模板常写成「重 量:」「体 积:」,去空白后再对
|
||||
String compact = primary.replace(":", "").replaceAll("\\s+", "");
|
||||
if (dict.containsKey(compact)) {
|
||||
return dict.get(compact);
|
||||
}
|
||||
String noColon = primary.replace(":", "").trim();
|
||||
for (Map.Entry<String, String> e : dict.entrySet()) {
|
||||
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
|
||||
if (dk.length() < 3 || noColon.length() < 3) {
|
||||
String dk = e.getKey().replace(":", ":").replace(":", "").replaceAll("\\s+", "").trim();
|
||||
if (dk.length() < 2 || compact.length() < 2) {
|
||||
continue;
|
||||
}
|
||||
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
|
||||
if (dk.equalsIgnoreCase(compact) || compact.equals(dk)) {
|
||||
return e.getValue();
|
||||
}
|
||||
if (dk.length() >= 3 && (compact.startsWith(dk) || dk.startsWith(compact))) {
|
||||
return e.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
+231
-9
@@ -238,12 +238,7 @@ public class InquiryTmsQueryService {
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(workOrderNo);
|
||||
row.setQuoteVersion(1);
|
||||
row.setSource("TMS");
|
||||
row.setSourceLabel("TMS 标准报价");
|
||||
// 后台售价栏必须带 TMS 币种,不能只落纯数字。
|
||||
row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")));
|
||||
row.setHighlighted(1);
|
||||
row.setPayloadJson(JSON.toJSONString(quote));
|
||||
applySnapshotToQuoteRow(row, quote);
|
||||
row.setCreateTime(new Date());
|
||||
quoteMapper.insert(row);
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
@@ -323,6 +318,7 @@ public class InquiryTmsQueryService {
|
||||
quote.put("source", "TMS");
|
||||
quote.put("source_label", "TMS 标准报价");
|
||||
quote.put("fee_lines", buildFeeLinesJson(data, freightMoney.amount, otherMoney.amount, headerCurrency));
|
||||
putFeeItemsRaw(quote, data);
|
||||
// 海运 feeItems 是分段桶:polCostItems / oceanCostItems / podCostItems,单独透出给模板分区回填
|
||||
putSeaFeeBuckets(quote, data);
|
||||
// 锁舱必须带着 TMS 报价编号和舱位选项,不能只落金额。
|
||||
@@ -349,6 +345,8 @@ public class InquiryTmsQueryService {
|
||||
}
|
||||
// 陆运线路段:与空运 airOptions 同级透出,供模板 routeRegions 填项目一
|
||||
putLandSegments(quote, data, tms);
|
||||
// 陆运常无整单总价:一条线路才把各段成本加总;多条备选禁止加在一起
|
||||
applyLandSegmentTotals(quote);
|
||||
String chargeable = firstText(data.get("chargeableWeightKg"), data.get("chargeable_weight_kg"));
|
||||
if (StringUtils.isNotBlank(chargeable)) {
|
||||
quote.put("chargeableWeightKg", chargeable);
|
||||
@@ -385,9 +383,9 @@ public class InquiryTmsQueryService {
|
||||
if (!rows.isEmpty()) {
|
||||
quote.put("route_rows", JSON.toJSONString(rows));
|
||||
}
|
||||
// feeItems 为空时,用各腿售价补一条运输费,避免报价明细全空
|
||||
// feeItems 为空时,用各腿成本补运输费。顶层总价会被误收成「空运费」,陆运要换掉。
|
||||
String feeLines = quote.get("fee_lines");
|
||||
if ((StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) && !rows.isEmpty()) {
|
||||
if (shouldFillLandFeeLines(feeLines) && !rows.isEmpty()) {
|
||||
List<Map<String, String>> lines = new ArrayList<>();
|
||||
for (Map<String, String> seg : rows) {
|
||||
String amount = firstText(seg.get("salePrice"), seg.get("costPrice"), seg.get("amount"));
|
||||
@@ -395,11 +393,17 @@ public class InquiryTmsQueryService {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> line = new LinkedHashMap<>();
|
||||
line.put("name", "运输费");
|
||||
line.put("name", firstText(seg.get("feeName"), seg.get("name"), "运输费"));
|
||||
line.put("amount", amount);
|
||||
if (StringUtils.isNotBlank(seg.get("currency"))) {
|
||||
line.put("currency", seg.get("currency"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("segmentSequence"))) {
|
||||
line.put("segmentSequence", seg.get("segmentSequence"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("routeLegSequence"))) {
|
||||
line.put("routeLegSequence", seg.get("routeLegSequence"));
|
||||
}
|
||||
if (StringUtils.isNotBlank(seg.get("remark"))) {
|
||||
line.put("remark", seg.get("remark"));
|
||||
}
|
||||
@@ -477,6 +481,201 @@ public class InquiryTmsQueryService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运分段成本收成工单总价。
|
||||
* <p>
|
||||
* 同一 segmentSequence 是一条完整线路。只有 1 条时才把各段 costPrice 相加,
|
||||
* 时效按段原文用「 / 」拼上(如 N+2 / N+3)。多条备选禁止加总,避免 37100 这种假合计。
|
||||
* 顶层已有真实总价时不覆盖。
|
||||
*/
|
||||
static void applyLandSegmentTotals(Map<String, String> quote) {
|
||||
if (quote == null) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, String>> rows = normalizeLandSegmentRows(quote.get("landSegments"));
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Integer, List<Map<String, String>>> groups = new LinkedHashMap<>();
|
||||
for (Map<String, String> row : rows) {
|
||||
int seq = 1;
|
||||
try {
|
||||
seq = Integer.parseInt(firstText(row.get("segmentSequence"), "1"));
|
||||
} catch (RuntimeException ex) {
|
||||
seq = 1;
|
||||
}
|
||||
groups.computeIfAbsent(seq, k -> new ArrayList<>()).add(row);
|
||||
}
|
||||
if (groups.size() != 1) {
|
||||
return;
|
||||
}
|
||||
List<Map<String, String>> legs = groups.values().iterator().next();
|
||||
java.math.BigDecimal sum = java.math.BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
List<String> etas = new ArrayList<>();
|
||||
String ccy = firstText(quote.get("currency"));
|
||||
for (Map<String, String> leg : legs) {
|
||||
String amt = firstText(leg.get("costPrice"), leg.get("salePrice"), leg.get("amount"));
|
||||
if (StringUtils.isNotBlank(amt) && !"-".equals(amt)) {
|
||||
try {
|
||||
sum = sum.add(new java.math.BigDecimal(amt.trim().replace(",", "")));
|
||||
any = true;
|
||||
} catch (RuntimeException ignored) {
|
||||
// 非数字成本不编
|
||||
}
|
||||
}
|
||||
if (StringUtils.isBlank(ccy)) {
|
||||
ccy = firstText(leg.get("currency"));
|
||||
}
|
||||
String days = firstText(leg.get("transitDays"), leg.get("transit_time"));
|
||||
if (StringUtils.isNotBlank(days) && !"-".equals(days)) {
|
||||
etas.add(days);
|
||||
}
|
||||
}
|
||||
if (any && blankOrDash(quote.get("total"))) {
|
||||
quote.put("total", stripPlainNumber(sum));
|
||||
}
|
||||
if (any && blankOrDash(quote.get("cost"))) {
|
||||
quote.put("cost", stripPlainNumber(sum));
|
||||
}
|
||||
if (StringUtils.isNotBlank(ccy) && StringUtils.isBlank(quote.get("currency"))) {
|
||||
quote.put("currency", ccy);
|
||||
}
|
||||
if (!etas.isEmpty() && blankOrDash(quote.get("eta"))) {
|
||||
quote.put("eta", String.join(" / ", etas));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把规范化 quote 写进 TMS 报价行:金额带币种,载荷原样 JSON。
|
||||
* 查价落库与选定线路后覆盖共用,禁止再插一条假 TMS。
|
||||
*/
|
||||
static void applySnapshotToQuoteRow(InquiryQuote row, Map<String, String> quote) {
|
||||
if (row == null || quote == null) {
|
||||
return;
|
||||
}
|
||||
row.setSource("TMS");
|
||||
row.setSourceLabel("TMS 标准报价");
|
||||
row.setPriceText(TmsQuoteCurrency.formatDisplay(quote.get("currency"), quote.get("total")));
|
||||
row.setHighlighted(1);
|
||||
row.setPayloadJson(JSON.toJSONString(quote));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选定陆运线路后覆盖本单最新 TMS 快照,并重算工单成本价。
|
||||
* <p>
|
||||
* 为何:查价当时可能是多条备选,工单总价只能先空着;销售选定后必须把该条成本写回。
|
||||
* 不新插 TMS 行,避免后台出现两条「TMS标准报价」。已冻结工单拒绝。
|
||||
*/
|
||||
public Map<String, Object> replaceTmsQuote(String workOrderNo, Map<String, Object> incoming) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
if (StringUtils.isBlank(workOrderNo) || incoming == null || incoming.isEmpty()) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "quote_blank");
|
||||
return out;
|
||||
}
|
||||
InquiryTicket ticket = workOrderService.getOne(
|
||||
new LambdaQueryWrapper<InquiryTicket>()
|
||||
.eq(InquiryTicket::getWorkOrderNo, workOrderNo)
|
||||
.last("LIMIT 1"));
|
||||
if (ticket == null) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "ticket_not_found");
|
||||
return out;
|
||||
}
|
||||
if (TicketStatuses.isFrozen(ticket.getStatus())) {
|
||||
out.put("ok", false);
|
||||
out.put("error", "frozen");
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
Map<String, String> quote = stringifyQuote(incoming);
|
||||
applyLandSegmentTotals(quote);
|
||||
InquiryQuote row = quoteMapper.selectOne(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.eq(InquiryQuote::getSource, "TMS")
|
||||
.orderByDesc(InquiryQuote::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
boolean insert = row == null;
|
||||
if (insert) {
|
||||
row = new InquiryQuote();
|
||||
row.setTicketId(ticket.getId());
|
||||
row.setWorkOrderNo(workOrderNo);
|
||||
row.setQuoteVersion(1);
|
||||
row.setCreateTime(new Date());
|
||||
}
|
||||
applySnapshotToQuoteRow(row, quote);
|
||||
row.setUpdateTime(new Date());
|
||||
if (insert) {
|
||||
quoteMapper.insert(row);
|
||||
} else {
|
||||
quoteMapper.updateById(row);
|
||||
}
|
||||
List<InquiryQuote> quotes = quoteMapper.selectList(new LambdaQueryWrapper<InquiryQuote>()
|
||||
.eq(InquiryQuote::getWorkOrderNo, workOrderNo)
|
||||
.orderByAsc(InquiryQuote::getCreateTime));
|
||||
InquiryTicketPriceSupport.apply(ticket, quotes);
|
||||
ticket.setTmsHit(1);
|
||||
workOrderService.updateById(ticket);
|
||||
if (TicketStatuses.canTransit(ticket.getStatus(), TicketStatuses.QUOTED)) {
|
||||
TicketTransitionRequest tr = new TicketTransitionRequest();
|
||||
tr.setWorkOrderNo(workOrderNo);
|
||||
tr.setToStatus(TicketStatuses.QUOTED);
|
||||
workOrderService.transition(tr, "agent");
|
||||
}
|
||||
out.put("ok", true);
|
||||
out.put("quote_version", row.getQuoteVersion() == null ? 1 : row.getQuoteVersion());
|
||||
out.put("status", ticket.getStatus());
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体回写的 quote 可能带 list/map,落库前收成字符串。
|
||||
*/
|
||||
static Map<String, String> stringifyQuote(Map<String, Object> incoming) {
|
||||
Map<String, String> quote = new LinkedHashMap<>();
|
||||
if (incoming == null) {
|
||||
return quote;
|
||||
}
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
if (e.getKey() == null || e.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
Object v = e.getValue();
|
||||
if (v instanceof String) {
|
||||
quote.put(e.getKey(), (String) v);
|
||||
} else if (v instanceof Map || v instanceof List) {
|
||||
quote.put(e.getKey(), JSON.toJSONString(v));
|
||||
} else {
|
||||
quote.put(e.getKey(), String.valueOf(v));
|
||||
}
|
||||
}
|
||||
return quote;
|
||||
}
|
||||
|
||||
private static boolean blankOrDash(String raw) {
|
||||
return StringUtils.isBlank(raw) || "-".equals(raw.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运分段行能不能盖掉当前 fee_lines。
|
||||
* 空列表、或只有「空运费」兜底(把陆运总价误写成空运)时才盖。
|
||||
*/
|
||||
static boolean shouldFillLandFeeLines(String feeLines) {
|
||||
if (StringUtils.isBlank(feeLines) || "[]".equals(feeLines.trim())) {
|
||||
return true;
|
||||
}
|
||||
String text = feeLines.replace(" ", "");
|
||||
return text.contains("空运费") && !text.contains("运输费");
|
||||
}
|
||||
|
||||
private static String stripPlainNumber(java.math.BigDecimal value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.stripTrailingZeros().toPlainString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本次 TMS 报价写回工单主表,供后台列表/详情展示。
|
||||
* 海运/陆运进成本价,空运进销售价;利润点必须等销售调价后才算。
|
||||
@@ -593,6 +792,9 @@ public class InquiryTmsQueryService {
|
||||
Map<String, String> extras = new LinkedHashMap<>();
|
||||
putExtra(extras, "unit", firstText(ch.get("unit"), ch.get("unitName")));
|
||||
putExtra(extras, "unit_price", unitPrice);
|
||||
// 固定费无数量时默认 1;运输费 enrich 会改成计费重
|
||||
String qty = firstText(ch.get("quantity"), ch.get("chargeQuantity"), ch.get("charge_quantity"));
|
||||
putExtra(extras, "quantity", StringUtils.isBlank(qty) ? "1" : qty);
|
||||
putExtra(extras, "project", isAirWeightFeeName(name) ? "空运段" : "起运港");
|
||||
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
|
||||
addFeeLine(out, name, unitPrice, lineCcy, extras);
|
||||
@@ -762,9 +964,29 @@ public class InquiryTmsQueryService {
|
||||
putExtra(extras, "quantity", firstText(row.get("quantity"), row.get("chargeQuantity"), row.get("charge_quantity")));
|
||||
putExtra(extras, "unit_price", firstText(row.get("unitPrice"), row.get("unit_price")));
|
||||
putExtra(extras, "remark", firstText(row.get("remark"), row.get("description"), row.get("note")));
|
||||
putExtra(extras, "segmentSequence", firstText(row.get("segmentSequence"), row.get("segment_sequence")));
|
||||
putExtra(extras, "routeLegSequence", firstText(row.get("routeLegSequence"), row.get("route_leg_sequence")));
|
||||
return extras;
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运选线要按段过滤费用行:feeItems 原文单独落下,名称保持 TMS 回的 feeName。
|
||||
*/
|
||||
private static void putFeeItemsRaw(Map<String, String> quote, Map<String, Object> data) {
|
||||
if (quote == null || data == null) {
|
||||
return;
|
||||
}
|
||||
Object raw = first(data.get("feeItems"), data.get("fee_items"), data.get("feeList"));
|
||||
if (raw == null) {
|
||||
return;
|
||||
}
|
||||
String json = raw instanceof String ? String.valueOf(raw) : JSON.toJSONString(raw);
|
||||
if (StringUtils.isBlank(json) || "null".equalsIgnoreCase(json) || "[]".equals(json.trim())) {
|
||||
return;
|
||||
}
|
||||
quote.put("feeItems", json);
|
||||
}
|
||||
|
||||
private static void putExtra(Map<String, String> extras, String key, String value) {
|
||||
if (StringUtils.isNotBlank(value) && !"-".equals(value) && !"null".equalsIgnoreCase(value)) {
|
||||
extras.put(key, value);
|
||||
|
||||
+8
-14
@@ -10,11 +10,12 @@ import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 海运出站前把销售口语整理成 TMS V2 字段。
|
||||
* 海运出站字段整理。
|
||||
* <p>
|
||||
* 对照接口文档 6.3:FCL 必须带 containerType + containerQty(如 40HQ / 1),
|
||||
* loadType 只能是 FCL/LCL,cargoQty 必须是数字。销售卡片仍看原文「整柜 / 1x40HQ / 15件」。
|
||||
* 禁止:把口语原样扔给 TMS;在这里猜港口。
|
||||
* 产品口径(与陆运一致):整柜/拼柜等业务枚举<strong>不再</strong>翻成 FCL/LCL,销售原文直传。
|
||||
* 箱型箱量、货量仍只做「去单位 / 拆箱型」的格式整理(20GP*1→20GP+1、15件→15),
|
||||
* 避免带「件」「吨」触发 TMS 1001;这不是中英字典映射。
|
||||
* 禁止:在这里猜港口。
|
||||
*/
|
||||
public final class SeaTmsFieldNormalizer {
|
||||
|
||||
@@ -38,7 +39,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 整柜/拼柜 → FCL/LCL。已经是 FCL/LCL 的原样(大写)。
|
||||
* 整柜/拼柜原文直传(trim)。已是 FCL/LCL 的旧快照也原样放过,不做双向翻译。
|
||||
*/
|
||||
public static String loadType(Object raw) {
|
||||
if (raw == null) {
|
||||
@@ -48,14 +49,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
if (text.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String upper = text.toUpperCase(Locale.ROOT);
|
||||
if ("FCL".equals(upper) || text.contains("整柜") || text.contains("整箱")) {
|
||||
return "FCL";
|
||||
}
|
||||
if ("LCL".equals(upper) || text.contains("拼柜") || text.contains("拼箱")) {
|
||||
return "LCL";
|
||||
}
|
||||
return upper;
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +115,7 @@ public final class SeaTmsFieldNormalizer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 口语箱型落到 TMS 码:40HQ / 40GP / 20GP / 45HQ。
|
||||
* 箱型尺码落到标准码:40HQ / 40GP / 20GP / 45HQ(从销售原文拆出,非中英字典表)。
|
||||
*/
|
||||
static String toTmsType(String size, String suffix) {
|
||||
if (size == null) {
|
||||
|
||||
+14
-45
@@ -1,68 +1,37 @@
|
||||
package org.jeecg.modules.inquiry.tms;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 陆运 TMS 6.1.1 枚举:销售中文 → 出站码。
|
||||
* 陆运运输类型 / 线路类别出站:产品要求原文直传,不再翻成 TMS 英文枚举。
|
||||
* <p>
|
||||
* 职责:只在打 TMS 前翻译运输类型、线路类别。工单/卡片/拉人仍用中文。
|
||||
* 禁止:把运输分类、中港装载方式译进出站;发明表外码。
|
||||
* 职责:统一 trim;空串表示未填。工单/卡片/拉人本来就是中文,出站与之一致。
|
||||
* 禁止:再把「跨境整车」写成 CROSS_BORDER_FTL;把运输分类、中港装载方式塞进出站。
|
||||
* 历史英文码若仍传入,原样放过(兼容旧快照),不做双向翻译。
|
||||
*/
|
||||
public final class TmsLandEnumMapper {
|
||||
|
||||
private static final Map<String, String> TYPE = new LinkedHashMap<String, String>();
|
||||
private static final Map<String, String> ROUTE = new LinkedHashMap<String, String>();
|
||||
|
||||
static {
|
||||
TYPE.put("国内运输拼车", "DOMESTIC_LTL");
|
||||
TYPE.put("国内运输整车", "DOMESTIC_FTL");
|
||||
TYPE.put("跨境集拼", "CROSS_BORDER_LTL");
|
||||
TYPE.put("跨境整车", "CROSS_BORDER_FTL");
|
||||
TYPE.put("中港整车", "CHINA_HK");
|
||||
TYPE.put("中港零担/集拼", "CHINA_HK");
|
||||
TYPE.put("中港", "CHINA_HK");
|
||||
TYPE.put("DOMESTIC_LTL", "DOMESTIC_LTL");
|
||||
TYPE.put("DOMESTIC_FTL", "DOMESTIC_FTL");
|
||||
TYPE.put("CROSS_BORDER_LTL", "CROSS_BORDER_LTL");
|
||||
TYPE.put("CROSS_BORDER_FTL", "CROSS_BORDER_FTL");
|
||||
TYPE.put("CHINA_HK", "CHINA_HK");
|
||||
ROUTE.put("国内长途/零担", "DOMESTIC_LONG_LTL");
|
||||
ROUTE.put("国内城配/拖车/打包", "DOMESTIC_CITY_DIST");
|
||||
ROUTE.put("中港/中亚/中欧", "CN_HK_CA_EU");
|
||||
ROUTE.put("东南亚", "SOUTHEAST_ASIA");
|
||||
ROUTE.put("DOMESTIC_LONG_LTL", "DOMESTIC_LONG_LTL");
|
||||
ROUTE.put("DOMESTIC_CITY_DIST", "DOMESTIC_CITY_DIST");
|
||||
ROUTE.put("CN_HK_CA_EU", "CN_HK_CA_EU");
|
||||
ROUTE.put("SOUTHEAST_ASIA", "SOUTHEAST_ASIA");
|
||||
}
|
||||
|
||||
private TmsLandEnumMapper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw 销售中文或已是 TMS 码
|
||||
* @return 6.1.1 landTransportType;对不上返回空串,禁止用中文顶上
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return trim 后原文;空则 ""
|
||||
*/
|
||||
public static String transportType(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String key = raw.trim();
|
||||
String code = TYPE.get(key);
|
||||
return code == null ? "" : code;
|
||||
return trimOrEmpty(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw 销售中文或已是 TMS 码
|
||||
* @return 6.1.1 routeCategory;对不上返回空串
|
||||
* @param raw 销售中文(或历史英文码)
|
||||
* @return trim 后原文;空则 ""
|
||||
*/
|
||||
public static String routeCategory(String raw) {
|
||||
return trimOrEmpty(raw);
|
||||
}
|
||||
|
||||
private static String trimOrEmpty(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
String key = raw.trim();
|
||||
String code = ROUTE.get(key);
|
||||
return code == null ? "" : code;
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
+13
-9
@@ -18,9 +18,9 @@ import java.util.regex.Pattern;
|
||||
* 职责:把工单 facts 里的中文键写成 TMS 段;运输分类只留在工单,不进出站。
|
||||
* 拼车/跨境集拼带体积重量;国内整车/跨境整车/中港零担带「车型/数量」一项再拆成出站车型与台数;
|
||||
* 中港整车不带车型/数量。通关口岸有值才放。
|
||||
* 中港整车、中港零担/集拼出站类型都是 CHINA_HK。
|
||||
* 类型/线路出站必须是 TMS 6.1.1 英文枚举,线路键名 routeCategory。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS。
|
||||
* 中港整车、中港零担/集拼:出站类型仍用销售中文「中港整车」「中港零担/集拼」(产品要求不做英文枚举映射)。
|
||||
* 类型/线路键名仍是 landTransportType / routeCategory,值用中文原文。
|
||||
* 禁止:按空运要包装件数;把分类当查价条件;直连 TMS;再把中文翻成 CROSS_BORDER_*。
|
||||
*/
|
||||
public final class TmsLandSegmentMapper {
|
||||
|
||||
@@ -91,15 +91,19 @@ public final class TmsLandSegmentMapper {
|
||||
missing.add(key);
|
||||
}
|
||||
}
|
||||
String typeEnum = text(seg.get("landTransportType"));
|
||||
// 出站已改为中文原文;拦网同时认中文集合与历史英文码,避免旧快照误拦。
|
||||
String typeOnSeg = text(seg.get("landTransportType"));
|
||||
String cnType = text(facts == null ? null : facts.get("运输类型"));
|
||||
boolean lcl = LCL.contains(cnType)
|
||||
|| "DOMESTIC_LTL".equals(typeEnum)
|
||||
|| "CROSS_BORDER_LTL".equals(typeEnum);
|
||||
boolean hkFull = "中港整车".equals(cnType);
|
||||
|| LCL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_LTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_LTL".equals(typeOnSeg);
|
||||
boolean hkFull = "中港整车".equals(cnType) || "中港整车".equals(typeOnSeg);
|
||||
boolean ftl = FTL.contains(cnType)
|
||||
|| "DOMESTIC_FTL".equals(typeEnum)
|
||||
|| "CROSS_BORDER_FTL".equals(typeEnum)
|
||||
|| FTL.contains(typeOnSeg)
|
||||
|| "DOMESTIC_FTL".equals(typeOnSeg)
|
||||
|| "CROSS_BORDER_FTL".equals(typeOnSeg)
|
||||
|| "CHINA_HK".equals(typeOnSeg)
|
||||
|| "中港零担/集拼".equals(cnType);
|
||||
if (lcl) {
|
||||
if (seg.get("volumeCbm") == null) {
|
||||
|
||||
+4
-4
@@ -92,8 +92,8 @@ public final class TmsV2RequestMapper {
|
||||
put(target, "packageType", first(source, "packageType", "packagingType"));
|
||||
// 空运选填:有值才带。贸易条款不进出站,只给后台模板关键词匹配。
|
||||
applyAirOptionalFields(source, target);
|
||||
// 海运出站:口语必须先整理。FCL 要 containerType+containerQty,loadType 只能 FCL/LCL,
|
||||
// cargoQty 只能是数字。销售侧「整柜 / 1x40HQ / 15件」原样留在工单,不在这里改。
|
||||
// 海运出站:货量去单位、箱型拆码;整柜/拼柜改为原文直传(不再 FCL/LCL)。
|
||||
// FCL 要 containerType+containerQty;销售侧「整柜 / 1x40HQ / 15件」卡片仍看原文。
|
||||
applySeaFields(source, target);
|
||||
applyLandFields(source, target);
|
||||
return compact(target);
|
||||
@@ -144,8 +144,8 @@ public final class TmsV2RequestMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* 陆运出站:类型+线路用 6.1.1 枚举;线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。中文不得原样出站。
|
||||
* 陆运出站:类型+线路用销售中文原文(产品要求不做英文枚举映射);线路字段名是 routeCategory。
|
||||
* 分类和中港装载即使误放在 source 也不抄走。
|
||||
*/
|
||||
private static void applyLandFields(Map<String, Object> source, Map<String, Object> target) {
|
||||
String type = textOf(first(source, "landTransportType"));
|
||||
|
||||
+104
-5
@@ -87,6 +87,31 @@ public class QuoteTemplateFillTest {
|
||||
assertEquals("CNY", ((JSONArray) fee).getJSONObject(0).getString("currency"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运没有时效:即便 TMS 回了 eta,出单也不填 transit_* / 时效。
|
||||
* 报价有效期仍要留下。
|
||||
*/
|
||||
@Test
|
||||
public void buildFillFactsHidesAirTransitKeepsValidUntil() {
|
||||
Map<String, Object> facts = new HashMap<>();
|
||||
facts.put("起运港", "南京");
|
||||
facts.put("目的港", "吉隆坡");
|
||||
facts.put("运输方式", "空运");
|
||||
facts.put("时效", "3-5 天");
|
||||
Map<String, Object> quote = new HashMap<>();
|
||||
quote.put("total", "5001.0");
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("eta", "3天");
|
||||
quote.put("valid_until", "2026-09-30");
|
||||
quote.put("airOptions", "[{\"originAirportCode\":\"NKG\"}]");
|
||||
JSONObject out = org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
|
||||
.buildFillFacts("WO202609220018", facts, quote, "WuJiLin", "AIR");
|
||||
assertEquals("2026-09-30", out.getString("valid_until"));
|
||||
assertTrue(StringUtils.isBlank(out.getString("transit_days")));
|
||||
assertTrue(StringUtils.isBlank(out.getString("transit_time")));
|
||||
assertTrue(StringUtils.isBlank(out.getString("时效")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writeFeeDetailColumnsOntoNamedFeeRow() throws Exception {
|
||||
Path src = Files.createTempFile("quote-fee-src", ".xlsx");
|
||||
@@ -196,8 +221,8 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运模板「预估总价」是 SUM。回填后必须把公式缓存刷成合计,
|
||||
* 否则企微/WPS 预览仍显示模板里的 0。
|
||||
* 空运模板「预估总价」原是 SUM。有 total_amount(TMS 总销售价)时改写成该数,
|
||||
* 避免刷公式后把固定费加进去、或标签旁再写一份形成两个总价。
|
||||
*/
|
||||
@Test
|
||||
public void refreshSumCacheAfterWritingFeeAmount() throws Exception {
|
||||
@@ -235,9 +260,7 @@ public class QuoteTemplateFillTest {
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook formulas = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Cell sum = formulas.getSheetAt(0).getRow(22).getCell(6);
|
||||
assertEquals(CellType.FORMULA, sum.getCellType());
|
||||
assertEquals("SUM(G17:G22)", sum.getCellFormula());
|
||||
// 公式格的数字缓存 = 企微预览看到的值;没刷缓存会是 0
|
||||
assertEquals(CellType.NUMERIC, sum.getCellType());
|
||||
assertEquals(14669.60, sum.getNumericCellValue(), 0.01);
|
||||
assertEquals("单价", formulas.getSheetAt(0).getRow(15).getCell(4).getStringCellValue());
|
||||
} finally {
|
||||
@@ -246,6 +269,82 @@ public class QuoteTemplateFillTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空运段 + 起运港固定费时:SUM 会大于 TMS 总销售价;
|
||||
* 预估费用行只能出现 total_amount 一个数,不能标签旁与金额列各一份。
|
||||
*/
|
||||
@Test
|
||||
public void estimateTotalKeepsSingleTmsAmount() throws Exception {
|
||||
Path src = Files.createTempFile("quote-est-src", ".xlsx");
|
||||
Path out = Files.createTempFile("quote-est-out", ".xlsx");
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("报价");
|
||||
org.apache.poi.ss.usermodel.Row head = sheet.createRow(10);
|
||||
head.createCell(0).setCellValue("项目");
|
||||
head.createCell(1).setCellValue("费用名称");
|
||||
head.createCell(6).setCellValue("金额");
|
||||
sheet.createRow(11).createCell(1).setCellValue("运输费");
|
||||
sheet.createRow(12).createCell(1).setCellValue("提送货费");
|
||||
sheet.createRow(13).createCell(1).setCellValue("人工费");
|
||||
org.apache.poi.ss.usermodel.Row total = sheet.createRow(15);
|
||||
total.createCell(0).setCellValue("预估费用");
|
||||
total.createCell(6).setCellFormula("SUM(G12:G14)");
|
||||
try (java.io.OutputStream os = Files.newOutputStream(src)) {
|
||||
wb.write(os);
|
||||
}
|
||||
}
|
||||
JSONObject mapping = new JSONObject();
|
||||
mapping.put("fields", new JSONObject());
|
||||
JSONObject facts = new JSONObject(true);
|
||||
facts.put("total_amount", "183370.00");
|
||||
facts.put("fee_rows", QuoteTemplateExcelSupport.parseFeeRows(
|
||||
"["
|
||||
+ "{\"name\":\"运输费\",\"amount\":\"183370.00\",\"currency\":\"CNY\",\"unit\":\"KG\",\"quantity\":\"16670\",\"unit_price\":\"11\"},"
|
||||
+ "{\"name\":\"提送货费\",\"amount\":\"6.00\",\"currency\":\"CNY\",\"unit\":\"板数\",\"unit_price\":\"6\"},"
|
||||
+ "{\"name\":\"人工费\",\"amount\":\"123.00\",\"currency\":\"CNY\",\"unit\":\"柜\",\"unit_price\":\"123\"}"
|
||||
+ "]"));
|
||||
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
|
||||
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
|
||||
org.apache.poi.ss.usermodel.Row row = got.getSheetAt(0).getRow(15);
|
||||
int numericCount = 0;
|
||||
Double only = null;
|
||||
for (int c = 1; c < row.getLastCellNum(); c++) {
|
||||
org.apache.poi.ss.usermodel.Cell cell = row.getCell(c);
|
||||
if (cell == null) {
|
||||
continue;
|
||||
}
|
||||
if (cell.getCellType() == CellType.NUMERIC) {
|
||||
numericCount++;
|
||||
only = cell.getNumericCellValue();
|
||||
} else if (cell.getCellType() == CellType.FORMULA) {
|
||||
numericCount++;
|
||||
only = cell.getNumericCellValue();
|
||||
} else if (cell.getCellType() == CellType.STRING
|
||||
&& cell.getStringCellValue() != null
|
||||
&& cell.getStringCellValue().trim().matches("-?\\d+(\\.\\d+)?")) {
|
||||
numericCount++;
|
||||
only = Double.parseDouble(cell.getStringCellValue().trim());
|
||||
}
|
||||
}
|
||||
assertEquals(1, numericCount);
|
||||
assertEquals(183370.00, only, 0.01);
|
||||
assertEquals(CellType.NUMERIC, row.getCell(6).getCellType());
|
||||
} finally {
|
||||
Files.deleteIfExists(src);
|
||||
Files.deleteIfExists(out);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void feeQuantityDefaultsToOneWhenBlank() {
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "提送货费"));
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "人工费"));
|
||||
assertEquals("1", QuoteTemplateExcelSupport.feeQuantityOrDefault("-", "操作费"));
|
||||
assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault(null, "运输费"));
|
||||
assertEquals("", QuoteTemplateExcelSupport.feeQuantityOrDefault("", "空运费"));
|
||||
assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
|
||||
* 否则转 PDF 会顶部裁字、整页偏左。
|
||||
|
||||
+3
-3
@@ -15,9 +15,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
public class SeaTmsFieldNormalizerTest {
|
||||
|
||||
@Test
|
||||
public void loadTypeFclLcl() {
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("LCL", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
public void loadTypePassesChinese() {
|
||||
assertEquals("整柜", SeaTmsFieldNormalizer.loadType("整柜"));
|
||||
assertEquals("拼柜", SeaTmsFieldNormalizer.loadType("拼柜"));
|
||||
assertEquals("FCL", SeaTmsFieldNormalizer.loadType("FCL"));
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -5,24 +5,30 @@ import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 陆运 TMS 枚举:销售中文不得原样出站。不打真实 TMS。
|
||||
* 陆运类型/线路:产品要求原文直传,不再翻英文枚举。
|
||||
*/
|
||||
public class TmsLandEnumMapperTest {
|
||||
|
||||
@Test
|
||||
public void pincheCityDistMatchesTms61() {
|
||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("国内运输拼车"));
|
||||
assertEquals("DOMESTIC_CITY_DIST", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包"));
|
||||
public void transportTypePassesChinese() {
|
||||
assertEquals("国内运输拼车", TmsLandEnumMapper.transportType("国内运输拼车"));
|
||||
assertEquals("跨境整车", TmsLandEnumMapper.transportType(" 跨境整车 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hkSplitTypesBothMapChinaHk() {
|
||||
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港整车"));
|
||||
assertEquals("CHINA_HK", TmsLandEnumMapper.transportType("中港零担/集拼"));
|
||||
public void routeCategoryPassesChinese() {
|
||||
assertEquals("国内城配/拖车/打包", TmsLandEnumMapper.routeCategory("国内城配/拖车/打包"));
|
||||
assertEquals("东南亚", TmsLandEnumMapper.routeCategory("东南亚"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alreadyEnumStays() {
|
||||
public void blankBecomesEmpty() {
|
||||
assertEquals("", TmsLandEnumMapper.transportType(null));
|
||||
assertEquals("", TmsLandEnumMapper.routeCategory(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyEnglishCodesPassThrough() {
|
||||
assertEquals("DOMESTIC_LTL", TmsLandEnumMapper.transportType("DOMESTIC_LTL"));
|
||||
assertEquals("SOUTHEAST_ASIA", TmsLandEnumMapper.routeCategory("SOUTHEAST_ASIA"));
|
||||
}
|
||||
|
||||
+9
-9
@@ -27,8 +27,8 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("毛重", "50");
|
||||
facts.put("报价日期", "2026-09-17");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CROSS_BORDER_LTL", seg.get("landTransportType"));
|
||||
assertEquals("SOUTHEAST_ASIA", seg.get("routeCategory"));
|
||||
assertEquals("跨境集拼", seg.get("landTransportType"));
|
||||
assertEquals("东南亚", seg.get("routeCategory"));
|
||||
assertFalse(seg.containsKey("landRouteCategory"));
|
||||
assertNull(seg.get("landClass"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
@@ -46,7 +46,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("车型/数量", "9米6/1");
|
||||
facts.put("体积", "99");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CHINA_HK", seg.get("landTransportType"));
|
||||
assertEquals("中港零担/集拼", seg.get("landTransportType"));
|
||||
assertEquals("9米6", seg.get("vehicleType"));
|
||||
assertNull(seg.get("volumeCbm"));
|
||||
assertFalse(seg.containsKey("hkLoadType"));
|
||||
@@ -62,7 +62,7 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("目的港", "香港");
|
||||
facts.put("品名", "衣服");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("CHINA_HK", seg.get("landTransportType"));
|
||||
assertEquals("中港整车", seg.get("landTransportType"));
|
||||
assertNull(seg.get("vehicleType"));
|
||||
assertNull(seg.get("quantity"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
@@ -85,7 +85,7 @@ public class TmsLandSegmentMapperTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void wo011PincheCityDistUsesTmsEnums() {
|
||||
public void wo011PincheCityDistPassesChinese() {
|
||||
Map<String, Object> facts = new HashMap<String, Object>();
|
||||
facts.put("运输类型", "国内运输拼车");
|
||||
facts.put("线路类别", "国内城配/拖车/打包");
|
||||
@@ -95,8 +95,8 @@ public class TmsLandSegmentMapperTest {
|
||||
facts.put("体积", "10CBM");
|
||||
facts.put("毛重", "10KG");
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
|
||||
assertEquals("DOMESTIC_CITY_DIST", seg.get("routeCategory"));
|
||||
assertEquals("国内运输拼车", seg.get("landTransportType"));
|
||||
assertEquals("国内城配/拖车/打包", seg.get("routeCategory"));
|
||||
assertFalse(seg.containsKey("landRouteCategory"));
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170011", "2026-09-17", seg, facts);
|
||||
@@ -116,8 +116,8 @@ public class TmsLandSegmentMapperTest {
|
||||
Map<String, Object> seg = TmsLandSegmentMapper.build(facts);
|
||||
java.util.List<String> missing = TmsLandSegmentMapper.missingQuery(
|
||||
"WO202609170005", "2026-09-17", seg);
|
||||
assertEquals("DOMESTIC_LTL", seg.get("landTransportType"));
|
||||
assertEquals("DOMESTIC_LONG_LTL", seg.get("routeCategory"));
|
||||
assertEquals("国内运输拼车", seg.get("landTransportType"));
|
||||
assertEquals("国内长途/零担", seg.get("routeCategory"));
|
||||
assertNull(seg.get("quantity"));
|
||||
assertEquals(java.util.Collections.emptyList(), missing);
|
||||
}
|
||||
|
||||
+117
@@ -331,4 +331,121 @@ public class TmsQuoteCurrencyTest {
|
||||
assertEquals("11.00", lines.get(0).get("unit_price"));
|
||||
assertEquals("123.00", lines.get(1).get("amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteSumsSingleLandRouteCostWhenNoHeaderTotal() {
|
||||
// 陆运只有分段成本、没有整单总价:一条两段要加出 17100,时效带上各段原文。
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("currency", "CNY");
|
||||
data.put("isMulti", 1);
|
||||
data.put("landSegments", java.util.Arrays.asList(
|
||||
landLeg(1, 1, "上海", "谅山", 7500, "N+2"),
|
||||
landLeg(1, 2, "谅山", "胡志明", 9600, "N+3")));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
tms.put("quoteFound", true);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertEquals("17100", quote.get("total"));
|
||||
assertEquals("17100", quote.get("cost"));
|
||||
assertTrue(quote.get("eta").contains("N+2"));
|
||||
assertTrue(quote.get("eta").contains("N+3"));
|
||||
assertFalse(quote.get("eta").contains("120"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteKeepsLandFeeItemNameAndSegment() {
|
||||
// 陆运 feeItems 的费用名原样落下,并带上段号,选线后才能按条过滤。
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("currency", "CNY");
|
||||
data.put("isMulti", 2);
|
||||
data.put("landSegments", java.util.Arrays.asList(
|
||||
landLeg(1, 1, "上海", "谅山", 7500, "N+2"),
|
||||
landLeg(1, 2, "谅山", "胡志明", 9600, "N+3"),
|
||||
landLeg(2, 1, "上海", "广西", 7500, "N+3"),
|
||||
landLeg(2, 2, "广西", "胡志明", 12500, "N+3")));
|
||||
data.put("feeItems", java.util.Arrays.asList(
|
||||
landFee(1, 1, "运输费", 7500),
|
||||
landFee(1, 2, "运输费", 9600),
|
||||
landFee(2, 1, "运输费", 7500),
|
||||
landFee(2, 2, "运输费", 12500)));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
tms.put("quoteFound", true);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
String fees = quote.get("fee_lines");
|
||||
assertTrue(fees.contains("\"name\":\"运输费\""));
|
||||
assertTrue(fees.contains("\"segmentSequence\":\"1\""));
|
||||
assertTrue(fees.contains("\"routeLegSequence\":\"2\""));
|
||||
assertFalse(fees.contains("上海 →"));
|
||||
assertTrue(quote.get("feeItems").contains("运输费"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAgentQuoteDoesNotSumTwoLandRoutes() {
|
||||
// 两条备选不能加在一起写成 37100;未选线时整单总价保持空。
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("quoteFound", true);
|
||||
data.put("currency", "CNY");
|
||||
data.put("isMulti", 2);
|
||||
data.put("landSegments", java.util.Arrays.asList(
|
||||
landLeg(1, 1, "上海", "谅山", 7500, "N+2"),
|
||||
landLeg(1, 2, "谅山", "胡志明", 9600, "N+3"),
|
||||
landLeg(2, 1, "上海", "广西", 7500, "N+3"),
|
||||
landLeg(2, 2, "广西", "胡志明", 12500, "N+3")));
|
||||
Map<String, Object> tms = new HashMap<>();
|
||||
tms.put("code", 1);
|
||||
tms.put("tmsData", data);
|
||||
tms.put("quoteFound", true);
|
||||
Map<String, String> quote = InquiryTmsQueryService.toAgentQuote(tms);
|
||||
assertTrue(StringUtilsBlankOrDash(quote.get("total")));
|
||||
assertFalse("37100".equals(quote.get("total")));
|
||||
assertTrue(quote.get("landSegments").contains("谅山"));
|
||||
assertTrue(quote.get("landSegments").contains("广西"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void applySnapshotWritesLandCostAsTmsPriceText() {
|
||||
Map<String, String> quote = new LinkedHashMap<>();
|
||||
quote.put("currency", "CNY");
|
||||
quote.put("total", "17100");
|
||||
org.jeecg.modules.inquiry.entity.InquiryQuote row =
|
||||
new org.jeecg.modules.inquiry.entity.InquiryQuote();
|
||||
InquiryTmsQueryService.applySnapshotToQuoteRow(row, quote);
|
||||
assertEquals("CNY 17100", row.getPriceText());
|
||||
assertTrue(row.getPayloadJson().contains("17100"));
|
||||
}
|
||||
|
||||
private static boolean StringUtilsBlankOrDash(String raw) {
|
||||
return raw == null || raw.trim().isEmpty() || "-".equals(raw.trim());
|
||||
}
|
||||
|
||||
private static Map<String, Object> landLeg(int seq, int leg, String origin, String dest,
|
||||
int cost, String transit) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("segmentSequence", seq);
|
||||
row.put("routeLegSequence", leg);
|
||||
row.put("origin", origin);
|
||||
row.put("destination", dest);
|
||||
row.put("costPrice", cost);
|
||||
row.put("currency", "CNY");
|
||||
row.put("transitDays", transit);
|
||||
row.put("vehicleType", "3T");
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Map<String, Object> landFee(int seq, int leg, String name, int amount) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("segmentSequence", seq);
|
||||
row.put("routeLegSequence", leg);
|
||||
row.put("feeName", name);
|
||||
row.put("amount", amount);
|
||||
row.put("currency", "CNY");
|
||||
row.put("unit", "台");
|
||||
row.put("quantity", 1);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -79,7 +79,7 @@ public class TmsV2RequestMapperTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("20"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("整柜", segs.get(0).get("loadType"));
|
||||
assertEquals(items, segs.get(0).get("containerItems"));
|
||||
assertFalse(segs.get(0).containsKey("quantity"));
|
||||
assertFalse(segs.get(0).containsKey("weightKg"));
|
||||
@@ -102,7 +102,7 @@ public class TmsV2RequestMapperTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals(new java.math.BigDecimal("15"), segs.get(0).get("cargoQty"));
|
||||
assertEquals("FCL", segs.get(0).get("loadType"));
|
||||
assertEquals("整柜", segs.get(0).get("loadType"));
|
||||
assertEquals("40HQ", segs.get(0).get("containerType"));
|
||||
assertEquals(Integer.valueOf(1), segs.get(0).get("containerQty"));
|
||||
}
|
||||
@@ -126,8 +126,8 @@ public class TmsV2RequestMapperTest {
|
||||
Map<String, Object> body = TmsV2RequestMapper.quote(internal);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> segs = (List<Map<String, Object>>) body.get("segments");
|
||||
assertEquals("CROSS_BORDER_LTL", segs.get(0).get("landTransportType"));
|
||||
assertEquals("SOUTHEAST_ASIA", segs.get(0).get("routeCategory"));
|
||||
assertEquals("跨境集拼", segs.get(0).get("landTransportType"));
|
||||
assertEquals("东南亚", segs.get(0).get("routeCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landRouteCategory"));
|
||||
assertFalse(segs.get(0).containsKey("landClass"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user